From 2f7ad96b1bf4ddd3b1a6a68b5578f23cfea98ae1 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Thu, 7 Nov 2024 21:43:58 +0200 Subject: [PATCH] Start y-note API --- .github/workflows/main.yml | 2 +- .gitignore | 3 +- README.md | 56 +++-- access_token.go | 9 + client.go | 111 +++++++--- client_config.go | 16 +- client_option.go | 47 ++++- client_option_test.go | 74 +++++-- client_test.go | 55 +++++ go.mod | 13 +- go.sum | 15 +- http_status.go | 6 - internal/helpers/test_helper.go | 18 +- internal/stubs/refund.go | 6 + internal/stubs/token.go | 13 ++ refund.go | 14 ++ refund_service.go | 74 +++++++ refund_service_test.go | 362 ++++++++++++++++++++++++++++++++ response.go | 2 +- status_service.go | 32 --- status_service_test.go | 70 ------ 21 files changed, 771 insertions(+), 227 deletions(-) create mode 100644 access_token.go create mode 100644 client_test.go delete mode 100644 http_status.go create mode 100644 internal/stubs/refund.go create mode 100644 internal/stubs/token.go create mode 100644 refund.go create mode 100644 refund_service.go create mode 100644 refund_service_test.go delete mode 100644 status_service.go delete mode 100644 status_service_test.go diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8a2cef9..2e4f556 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,7 +17,7 @@ jobs: - name: Setup Dependencies run: | - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sudo sh -s -- -b $GOPATH/bin v1.24.0 + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sudo sh -s -- -b $GOPATH/bin v1.50.0 golangci-lint --version go get golang.org/x/tools/cmd/cover go get -t -v ./... diff --git a/.gitignore b/.gitignore index 6160f56..1163044 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,5 @@ # Dependency directories (remove the comment below to include it) # vendor/ $path -.idea \ No newline at end of file +.idea +cmd diff --git a/README.md b/README.md index 6b1bbae..41fd726 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,38 @@ -# go-http-client +# Y-Note Go Client -[![Build](https://github.com/NdoleStudio/go-http-client/actions/workflows/main.yml/badge.svg)](https://github.com/NdoleStudio/go-http-client/actions/workflows/main.yml) -[![codecov](https://codecov.io/gh/NdoleStudio/go-http-client/branch/main/graph/badge.svg)](https://codecov.io/gh/NdoleStudio/go-http-client) -[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/NdoleStudio/go-http-client/badges/quality-score.png?b=main)](https://scrutinizer-ci.com/g/NdoleStudio/go-http-client/?branch=main) -[![Go Report Card](https://goreportcard.com/badge/github.com/NdoleStudio/go-http-client)](https://goreportcard.com/report/github.com/NdoleStudio/go-http-client) -[![GitHub contributors](https://img.shields.io/github/contributors/NdoleStudio/go-http-client)](https://github.com/NdoleStudio/go-http-client/graphs/contributors) -[![GitHub license](https://img.shields.io/github/license/NdoleStudio/go-http-client?color=brightgreen)](https://github.com/NdoleStudio/go-http-client/blob/master/LICENSE) -[![PkgGoDev](https://pkg.go.dev/badge/github.com/NdoleStudio/go-http-client)](https://pkg.go.dev/github.com/NdoleStudio/go-http-client) +[![Build](https://github.com/NdoleStudio/ynote-go/actions/workflows/main.yml/badge.svg)](https://github.com/NdoleStudio/ynote-go/actions/workflows/main.yml) +[![codecov](https://codecov.io/gh/NdoleStudio/ynote-go/branch/main/graph/badge.svg)](https://codecov.io/gh/NdoleStudio/ynote-go) +[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/NdoleStudio/ynote-go/badges/quality-score.png?b=main)](https://scrutinizer-ci.com/g/NdoleStudio/ynote-go/?branch=main) +[![Go Report Card](https://goreportcard.com/badge/github.com/NdoleStudio/ynote-go)](https://goreportcard.com/report/github.com/NdoleStudio/ynote-go) +[![GitHub contributors](https://img.shields.io/github/contributors/NdoleStudio/ynote-go)](https://github.com/NdoleStudio/ynote-go/graphs/contributors) +[![GitHub license](https://img.shields.io/github/license/NdoleStudio/ynote-go?color=brightgreen)](https://github.com/NdoleStudio/ynote-go/blob/master/LICENSE) +[![PkgGoDev](https://pkg.go.dev/badge/github.com/NdoleStudio/ynote-go)](https://pkg.go.dev/github.com/NdoleStudio/ynote-go) -This package provides a generic `go` client template for an HTTP API +This package provides a generic `go` client for the Y-Note API ## Installation -`go-http-client` is compatible with modern Go releases in module mode, with Go installed: +`ynote-go` is compatible with modern Go releases in module mode, with Go installed: ```bash -go get github.com/NdoleStudio/go-http-client +go get github.com/NdoleStudio/ynote-go ``` Alternatively the same can be achieved if you use `import` in a package: ```go -import "github.com/NdoleStudio/go-http-client" +import "github.com/NdoleStudio/ynote-go" ``` ## Implemented -- [Status Codes](#status-codes) - - `GET /200`: OK +- **Token** + - `POST {baseURL}/token`: Get Access Token +- **Refund** + - `POST {baseURL}/prod/refund`: Generate pay token. + - `GET {baseURL}/prod/refund/status/{transactionID}`: Get the status of a refund transaction ## Usage @@ -41,11 +44,14 @@ An instance of the client can be created using `New()`. package main import ( - "github.com/NdoleStudio/go-http-client" + "github.com/NdoleStudio/ynote-go" ) -func main() { - statusClient := client.New(client.WithDelay(200)) +func main() { + client := ynote.New( + ynote.WithUsername(""), + ynote.WithPassword(""), + ) } ``` @@ -54,26 +60,12 @@ func main() { All API calls return an `error` as the last return object. All successful calls will return a `nil` error. ```go -status, response, err := statusClient.Status.Ok(context.Background()) +transaction, response, err := client.Refund.Status(context.Background(), "") if err != nil { //handle error } ``` -### Status Codes - -#### `GET /200`: OK - -```go -status, response, err := statusClient.Status.Ok(context.Background()) - -if err != nil { - log.Fatal(err) -} - -log.Println(status.Description) // OK -``` - ## Testing You can run the unit tests for this client from the root directory using the command below: diff --git a/access_token.go b/access_token.go new file mode 100644 index 0000000..d24cd31 --- /dev/null +++ b/access_token.go @@ -0,0 +1,9 @@ +package ynote + +// AccessTokenResponse is the response when fetching the access token +type AccessTokenResponse struct { + AccessToken string `json:"access_token"` + Scope string `json:"scope"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` +} diff --git a/client.go b/client.go index 670a6d5..1dd6499 100644 --- a/client.go +++ b/client.go @@ -1,4 +1,4 @@ -package client +package ynote import ( "bytes" @@ -6,27 +6,39 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "net/http" + "net/url" "strconv" + "strings" + "sync" + "time" ) type service struct { client *Client } -// Client is the campay API client. +// Client is the Y-Note API client. // Do not instantiate this client with Client{}. Use the New method instead. type Client struct { httpClient *http.Client common service - baseURL string - delay int - Status *statusService + customerKey string + customerSecret string + username string + password string + tokenURL string + apiURL string + + accessToken string + tokenExpirationTime int64 + mutex sync.Mutex + + Refund *RefundService } -// New creates and returns a new campay.Client from a slice of campay.ClientOption. +// New creates and returns a new ynote.Client from a slice of ynote.Option. func New(options ...Option) *Client { config := defaultClientConfig() @@ -35,20 +47,74 @@ func New(options ...Option) *Client { } client := &Client{ - httpClient: config.httpClient, - delay: config.delay, - baseURL: config.baseURL, + httpClient: config.httpClient, + tokenURL: config.tokenURL, + apiURL: config.apiURL, + username: config.username, + password: config.password, + customerKey: config.customerKey, + customerSecret: config.customerSecret, + mutex: sync.Mutex{}, } client.common.client = client - client.Status = (*statusService)(&client.common) + client.Refund = (*RefundService)(&client.common) return client } +// AccessToken fetches the access token used to authenticate api requests. +func (client *Client) AccessToken(ctx context.Context) (*AccessTokenResponse, *Response, error) { + data := url.Values{} + data.Set("grant_type", "client_credentials") + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, client.tokenURL+"/oauth2/token", strings.NewReader(data.Encode())) + if err != nil { + return nil, nil, err + } + + request.SetBasicAuth(client.username, client.password) + request.Header.Add("Content-Type", "application/x-www-form-urlencoded") + request.Header.Add("Content-Length", strconv.Itoa(len(data.Encode()))) + + resp, err := client.do(request) + if err != nil { + return nil, resp, err + } + + var token AccessTokenResponse + if err = json.Unmarshal(*resp.Body, &token); err != nil { + return nil, resp, err + } + + return &token, resp, nil +} + +// refreshToken refreshes the authentication AccessTokenResponse +func (client *Client) refreshToken(ctx context.Context) error { + client.mutex.Lock() + defer client.mutex.Unlock() + + if client.tokenExpirationTime > time.Now().UTC().Unix() { + return nil + } + + client.accessToken = "" + + token, _, err := client.AccessToken(ctx) + if err != nil { + return err + } + + client.accessToken = token.AccessToken + client.tokenExpirationTime = time.Now().UTC().Unix() + token.ExpiresIn - 100 // Give extra 100 second buffer + + return nil +} + // newRequest creates an API request. A relative URL can be provided in uri, // in which case it is resolved relative to the BaseURL of the Client. // URI's should always be specified without a preceding slash. -func (client *Client) newRequest(ctx context.Context, method, uri string, body interface{}) (*http.Request, error) { +func (client *Client) newRequest(ctx context.Context, method, uri string, body any) (*http.Request, error) { var buf io.ReadWriter if body != nil { buf = &bytes.Buffer{} @@ -60,31 +126,18 @@ func (client *Client) newRequest(ctx context.Context, method, uri string, body i } } - req, err := http.NewRequestWithContext(ctx, method, client.baseURL+uri, buf) + req, err := http.NewRequestWithContext(ctx, method, client.apiURL+uri, buf) if err != nil { return nil, err } + req.Header.Set("Authorization", "Bearer "+client.accessToken) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - if client.delay > 0 { - client.addURLParams(req, map[string]string{"sleep": strconv.Itoa(client.delay)}) - } - return req, nil } -// addURLParams adds urls parameters to an *http.Request -func (client *Client) addURLParams(request *http.Request, params map[string]string) *http.Request { - q := request.URL.Query() - for key, value := range params { - q.Add(key, value) - } - request.URL.RawQuery = q.Encode() - return request -} - // do carries out an HTTP request and returns a Response func (client *Client) do(req *http.Request) (*Response, error) { if req == nil { @@ -103,7 +156,7 @@ func (client *Client) do(req *http.Request) (*Response, error) { return resp, err } - _, err = io.Copy(ioutil.Discard, httpResponse.Body) + _, err = io.Copy(io.Discard, httpResponse.Body) if err != nil { return resp, err } @@ -120,7 +173,7 @@ func (client *Client) newResponse(httpResponse *http.Response) (*Response, error resp := new(Response) resp.HTTPResponse = httpResponse - buf, err := ioutil.ReadAll(resp.HTTPResponse.Body) + buf, err := io.ReadAll(resp.HTTPResponse.Body) if err != nil { return nil, err } diff --git a/client_config.go b/client_config.go index c9c68c8..d01151d 100644 --- a/client_config.go +++ b/client_config.go @@ -1,17 +1,21 @@ -package client +package ynote import "net/http" type clientConfig struct { - httpClient *http.Client - delay int - baseURL string + httpClient *http.Client + customerKey string + customerSecret string + username string + password string + tokenURL string + apiURL string } func defaultClientConfig() *clientConfig { return &clientConfig{ httpClient: http.DefaultClient, - delay: 0, - baseURL: "https://httpstat.us", + tokenURL: "https://omapi-token.ynote.africa", + apiURL: "https://omapi.ynote.africa", } } diff --git a/client_option.go b/client_option.go index 3001106..a7bbaa9 100644 --- a/client_option.go +++ b/client_option.go @@ -1,4 +1,4 @@ -package client +package ynote import ( "net/http" @@ -26,21 +26,48 @@ func WithHTTPClient(httpClient *http.Client) Option { }) } -// WithBaseURL set's the base url for the flutterwave API -func WithBaseURL(baseURL string) Option { +// WithTokenURL set's the token URL for the Y-Note API +func WithTokenURL(tokenURL string) Option { return clientOptionFunc(func(config *clientConfig) { - if baseURL != "" { - config.baseURL = strings.TrimRight(baseURL, "/") + if tokenURL != "" { + config.tokenURL = strings.TrimRight(tokenURL, "/") } }) } -// WithDelay sets the delay in milliseconds before a response is gotten. -// The delay must be > 0 for it to be used. -func WithDelay(delay int) Option { +// WithApiURL set's the api URL for the Y-Note API +func WithApiURL(apiURL string) Option { return clientOptionFunc(func(config *clientConfig) { - if delay > 0 { - config.delay = delay + if apiURL != "" { + config.apiURL = strings.TrimRight(apiURL, "/") } }) } + +// WithUsername sets the Y-Note API Username used to fetch the access token +func WithUsername(username string) Option { + return clientOptionFunc(func(config *clientConfig) { + config.username = username + }) +} + +// WithPassword sets the Y-Note API password used to fetch the access token +func WithPassword(password string) Option { + return clientOptionFunc(func(config *clientConfig) { + config.password = password + }) +} + +// WithCustomerKey sets the customer key used to make API requests +func WithCustomerKey(customerKey string) Option { + return clientOptionFunc(func(config *clientConfig) { + config.customerKey = customerKey + }) +} + +// WithCustomerSecret sets the customer secret used to make API requests +func WithCustomerSecret(customerSecret string) Option { + return clientOptionFunc(func(config *clientConfig) { + config.customerSecret = customerSecret + }) +} diff --git a/client_option_test.go b/client_option_test.go index 7756017..1f6d5b0 100644 --- a/client_option_test.go +++ b/client_option_test.go @@ -1,4 +1,4 @@ -package client +package ynote import ( "net/http" @@ -39,66 +39,102 @@ func TestWithHTTPClient(t *testing.T) { }) } -func TestWithBaseURL(t *testing.T) { - t.Run("baseURL is set successfully", func(t *testing.T) { +func TestWithApiURL(t *testing.T) { + t.Run("apiURL is set successfully", func(t *testing.T) { // Setup t.Parallel() // Arrange - baseURL := "https://example.com" + apiURL := "https://example.com" config := defaultClientConfig() // Act - WithBaseURL(baseURL).apply(config) + WithApiURL(apiURL).apply(config) // Assert - assert.Equal(t, config.baseURL, config.baseURL) + assert.Equal(t, apiURL, config.apiURL) }) - t.Run("tailing / is trimmed from baseURL", func(t *testing.T) { + t.Run("tailing / is trimmed from apiURL", func(t *testing.T) { // Setup t.Parallel() // Arrange - baseURL := "https://example.com/" + apiURL := "https://example.com/" config := defaultClientConfig() // Act - WithBaseURL(baseURL).apply(config) + WithApiURL(apiURL).apply(config) // Assert - assert.Equal(t, "https://example.com", config.baseURL) + assert.Equal(t, "https://example.com", config.apiURL) }) } -func TestWithDelay(t *testing.T) { - t.Run("delay is set successfully", func(t *testing.T) { +func TestWithUsername(t *testing.T) { + t.Run("username is set successfully", func(t *testing.T) { // Setup t.Parallel() // Arrange + username := "username-1" config := defaultClientConfig() - delay := 1 // Act - WithDelay(delay).apply(config) + WithUsername(username).apply(config) // Assert - assert.Equal(t, delay, config.delay) + assert.Equal(t, username, config.username) }) +} + +func TestWithPassword(t *testing.T) { + t.Run("password is set successfully", func(t *testing.T) { + // Setup + t.Parallel() + + // Arrange + password := "password-1" + config := defaultClientConfig() + + // Act + WithPassword(password).apply(config) + + // Assert + assert.Equal(t, password, config.password) + }) +} + +func TestWithCustomerKey(t *testing.T) { + t.Run("customerKey is set successfully", func(t *testing.T) { + // Setup + t.Parallel() + + // Arrange + customerKey := "key-1" + config := defaultClientConfig() + + // Act + WithCustomerKey(customerKey).apply(config) + + // Assert + assert.Equal(t, customerKey, config.customerKey) + }) +} - t.Run("delay is not set when value < 0", func(t *testing.T) { +func TestWith(t *testing.T) { + t.Run("customerSecret is set successfully", func(t *testing.T) { // Setup t.Parallel() // Arrange + customerSecret := "secret-1" config := defaultClientConfig() - delay := -1 // Act - WithDelay(delay).apply(config) + WithCustomerSecret(customerSecret).apply(config) // Assert - assert.Equal(t, 0, config.delay) + assert.Equal(t, customerSecret, config.customerSecret) }) } diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..54e9bdc --- /dev/null +++ b/client_test.go @@ -0,0 +1,55 @@ +package ynote + +import ( + "context" + "github.com/NdoleStudio/ynote-go/internal/helpers" + "github.com/NdoleStudio/ynote-go/internal/stubs" + "github.com/stretchr/testify/assert" + "net/http" + "testing" +) + +const ( + testUsername = "username-2" + testPassword = "password-3" +) + +func TestClient_Token(t *testing.T) { + // Setup + t.Parallel() + + // Arrange + requests := make([]http.Request, 0) + server := helpers.MakeRequestCapturingTestServer([]int{http.StatusOK}, [][]byte{stubs.TokenResponse()}, &requests) + + client := New( + WithTokenURL(server.URL), + WithUsername(testUsername), + WithPassword(testPassword), + ) + + // Act + accessToken, response, err := client.AccessToken(context.Background()) + + // Assert + assert.Nil(t, err) + + assert.Equal(t, 1, len(requests)) + + request := requests[0] + actualUsername, actualPassword, ok := request.BasicAuth() + assert.True(t, ok) + + assert.Equal(t, testUsername, actualUsername) + assert.Equal(t, testPassword, actualPassword) + assert.Equal(t, "/oauth2/token", request.URL.Path) + assert.Equal(t, "application/x-www-form-urlencoded", request.Header.Get("Content-Type")) + + assert.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode) + assert.Equal(t, int64(2496), accessToken.ExpiresIn) + assert.Equal(t, "Bearer", accessToken.TokenType) + assert.Equal(t, "19077204-9d0a-31fa-85cf-xxxxxxxxxx", accessToken.AccessToken) + + // Teardown + server.Close() +} diff --git a/go.mod b/go.mod index 5e43425..f76b752 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,14 @@ -module github.com/NdoleStudio/go-http-client +module github.com/NdoleStudio/ynote-go -go 1.17 +go 1.23 -require github.com/stretchr/testify v1.7.0 +require ( + github.com/NdoleStudio/orangemoney-go v0.0.1 + github.com/stretchr/testify v1.9.0 +) require ( - github.com/davecgh/go-spew v1.1.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index acb88a4..0e45082 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,12 @@ -github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/NdoleStudio/orangemoney-go v0.0.1 h1:VRCPb18BlSOwAy106t8k3mz95FT6w5fTwtJnt1vlW3A= +github.com/NdoleStudio/orangemoney-go v0.0.1/go.mod h1:FIed5i5iG+NHrItYivX1fX7nxMLXyPeHB57CrOHC7zU= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/http_status.go b/http_status.go deleted file mode 100644 index 798523f..0000000 --- a/http_status.go +++ /dev/null @@ -1,6 +0,0 @@ -package client - -type HTTPStatus struct { - Code int `json:"code"` - Description string `json:"description"` -} diff --git a/internal/helpers/test_helper.go b/internal/helpers/test_helper.go index ffd5b2c..2d26b51 100644 --- a/internal/helpers/test_helper.go +++ b/internal/helpers/test_helper.go @@ -3,7 +3,7 @@ package helpers import ( "bytes" "context" - "io/ioutil" + "io" "net/http" "net/http/httptest" ) @@ -20,22 +20,24 @@ func MakeTestServer(responseCode int, body []byte) *httptest.Server { } // MakeRequestCapturingTestServer creates an api server that captures the request object -func MakeRequestCapturingTestServer(responseCode int, response []byte, request *http.Request) *httptest.Server { +func MakeRequestCapturingTestServer(responseCodes []int, responses [][]byte, requests *[]http.Request) *httptest.Server { + index := 0 return httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, req *http.Request) { clonedRequest := req.Clone(context.Background()) // clone body - body, err := ioutil.ReadAll(req.Body) + body, err := io.ReadAll(req.Body) if err != nil { panic(err) } - req.Body = ioutil.NopCloser(bytes.NewReader(body)) - clonedRequest.Body = ioutil.NopCloser(bytes.NewReader(body)) + req.Body = io.NopCloser(bytes.NewReader(body)) + clonedRequest.Body = io.NopCloser(bytes.NewReader(body)) - *request = *clonedRequest + *requests = append(*requests, *clonedRequest) - responseWriter.WriteHeader(responseCode) - _, err = responseWriter.Write(response) + responseWriter.WriteHeader(responseCodes[index]) + _, err = responseWriter.Write(responses[index]) + index++ if err != nil { panic(err) } diff --git a/internal/stubs/refund.go b/internal/stubs/refund.go new file mode 100644 index 0000000..4c78406 --- /dev/null +++ b/internal/stubs/refund.go @@ -0,0 +1,6 @@ +package stubs + +// RefundInvalidClientResponse is the response when refunding with an invalid client +func RefundInvalidClientResponse() []byte { + return []byte(`{"error":"invalid_client"}`) +} diff --git a/internal/stubs/token.go b/internal/stubs/token.go new file mode 100644 index 0000000..0aa3078 --- /dev/null +++ b/internal/stubs/token.go @@ -0,0 +1,13 @@ +package stubs + +// TokenResponse is the response when getting the access token +func TokenResponse() []byte { + return []byte(` +{ + "access_token":"19077204-9d0a-31fa-85cf-xxxxxxxxxx", + "scope":"am_application_scope default", + "token_type":"Bearer", + "expires_in":2496 +} +`) +} diff --git a/refund.go b/refund.go new file mode 100644 index 0000000..c6b8a8f --- /dev/null +++ b/refund.go @@ -0,0 +1,14 @@ +package ynote + +// RefundParams are the parameters for executing a refund transaction +type RefundParams struct { + ChannelUserMsisdn string `json:"channelUserMsisdn"` + Pin string `json:"pin"` + Webhook string `json:"webhook"` + Amount string `json:"amount"` + FinalCustomerPhone string `json:"final_customer_phone"` + FinalCustomerName string `json:"final_customer_name"` + RefundMethod string `json:"refund_method"` + FeesIncluded bool `json:"fees_included"` + FinalCustomerNameAccuracy string `json:"final_customer_name_accuracy"` +} diff --git a/refund_service.go b/refund_service.go new file mode 100644 index 0000000..141323e --- /dev/null +++ b/refund_service.go @@ -0,0 +1,74 @@ +package ynote + +import ( + "context" + "encoding/json" + "net/http" +) + +// RefundService is the API client for the `/prod/refund` endpoint +type RefundService service + +//// Status returns the status of an initiated transaction +//func (service *RefundService) Status(ctx context.Context, payToken *string) (*OrangeResponse[MerchantPaymentTransaction], *Response, error) { +// err := service.client.refreshToken(ctx) +// if err != nil { +// return nil, nil, err +// } +// +// request, err := service.client.newRequest(ctx, http.MethodGet, "/omcoreapis/1.0.2/mp/paymentstatus/"+*payToken, nil) +// if err != nil { +// return nil, nil, err +// } +// +// response, err := service.client.do(request) +// if err != nil { +// return nil, response, err +// } +// +// transaction := new(OrangeResponse[MerchantPaymentTransaction]) +// if err = json.Unmarshal(*response.Body, transaction); err != nil { +// return nil, response, err +// } +// +// return transaction, response, nil +//} + +// Refund executes an initiated transaction +func (service *RefundService) Refund(ctx context.Context, params *RefundParams) (*map[string]any, *Response, error) { + err := service.client.refreshToken(ctx) + if err != nil { + return nil, nil, err + } + + payload := map[string]any{ + "customerkey": service.client.customerKey, + "customersecret": service.client.customerSecret, + "channelUserMsisdn": params.ChannelUserMsisdn, + "pin": params.Pin, + "webhook": params.Webhook, + "amount": params.Amount, + "final_customer_phone": params.FinalCustomerPhone, + "final_customer_name": params.FinalCustomerName, + "refund_method": params.RefundMethod, + "fees_included": params.FeesIncluded, + "final_customer_name_accuracy": params.FinalCustomerNameAccuracy, + } + + request, err := service.client.newRequest(ctx, http.MethodPost, "/prod/refund", payload) + if err != nil { + return nil, nil, err + } + + response, err := service.client.do(request) + if err != nil { + return nil, response, err + } + + transaction := new(map[string]any) + if err = json.Unmarshal(*response.Body, transaction); err != nil { + return nil, response, err + } + + return transaction, response, nil +} diff --git a/refund_service_test.go b/refund_service_test.go new file mode 100644 index 0000000..076985a --- /dev/null +++ b/refund_service_test.go @@ -0,0 +1,362 @@ +package ynote + +import ( + "context" + "github.com/NdoleStudio/ynote-go/internal/helpers" + "github.com/NdoleStudio/ynote-go/internal/stubs" + "github.com/stretchr/testify/assert" + "net/http" + "testing" +) + +func TestRefundService_Refund(t *testing.T) { + // Setup + t.Parallel() + + // Arrange + requests := make([]http.Request, 0) + responses := [][]byte{stubs.TokenResponse(), stubs.RefundInvalidClientResponse()} + server := helpers.MakeRequestCapturingTestServer([]int{http.StatusOK, http.StatusOK}, responses, &requests) + client := New( + WithTokenURL(server.URL), + WithApiURL(server.URL), + WithUsername(testUsername), + WithPassword(testPassword), + ) + + payload := &RefundParams{ + ChannelUserMsisdn: "699999999", + Pin: "0000", + Webhook: "https://api.nyangapay.com/v1/y-note", + Amount: "100", + FinalCustomerPhone: "699999999", + FinalCustomerName: "", + RefundMethod: "OrangeMoney", + FeesIncluded: false, + FinalCustomerNameAccuracy: "0", + } + + // Act + _, response, err := client.Refund.Refund(context.Background(), payload) + + // Assert + assert.Nil(t, err) + + assert.GreaterOrEqual(t, len(requests), 2) + request := requests[len(requests)-1] + + assert.Equal(t, "/prod/refund", request.URL.Path) + assert.Equal(t, "Bearer 19077204-9d0a-31fa-85cf-xxxxxxxxxx", request.Header.Get("Authorization")) + + assert.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode) + + //Teardown + server.Close() +} + +func TestRefundService_RefundWithInvalidClient(t *testing.T) { + // Setup + t.Parallel() + + // Arrange + requests := make([]http.Request, 0) + responses := [][]byte{stubs.TokenResponse(), stubs.RefundInvalidClientResponse()} + server := helpers.MakeRequestCapturingTestServer([]int{http.StatusOK, http.StatusBadRequest}, responses, &requests) + client := New( + WithTokenURL(server.URL), + WithApiURL(server.URL), + WithUsername(testUsername), + WithPassword(testPassword), + ) + + payload := &RefundParams{ + ChannelUserMsisdn: "699999999", + Pin: "0000", + Webhook: "https://api.nyangapay.com/v1/y-note", + Amount: "100", + FinalCustomerPhone: "699999999", + FinalCustomerName: "", + RefundMethod: "OrangeMoney", + FeesIncluded: false, + FinalCustomerNameAccuracy: "0", + } + + // Act + transaction, response, err := client.Refund.Refund(context.Background(), payload) + + // Assert + assert.Nil(t, transaction) + assert.NotNil(t, err) + + assert.Equal(t, http.StatusBadRequest, response.HTTPResponse.StatusCode) + + //Teardown + server.Close() +} + +//func TestMerchantPaymentService_Pay(t *testing.T) { +// // Setup +// t.Parallel() +// +// // Arrange +// requests := make([]http.Request, 0) +// responses := [][]byte{stubs.TokenResponse(), stubs.MerchantPaymentPayResponse()} +// server := helpers.MakeRequestCapturingTestServer([]int{http.StatusOK, http.StatusOK}, responses, &requests) +// client := New( +// WithBaseURL(server.URL), +// WithUsername(testUsername), +// WithPassword(testPassword), +// WithAuthToken(testAuthToken), +// ) +// +// // Act +// transaction, response, err := client.MerchantPayment.Pay(context.Background(), &MerchantPaymentPayPrams{ +// SubscriberMSISDN: "69XXXXXXX", +// ChannelUserMSISDN: "69XXXXXXX", +// Amount: "100", +// Description: "Payment Description", +// OrderID: "abcdef", +// Pin: "123456", +// PayToken: "MP22120771FEB7B21FD2381C3786", +// NotificationURL: "https://example.com/payment-notification", +// }) +// +// // Assert +// assert.Nil(t, err) +// +// assert.GreaterOrEqual(t, len(requests), 2) +// request := requests[len(requests)-1] +// +// assert.Equal(t, "/omcoreapis/1.0.2/mp/pay", request.URL.Path) +// assert.Equal(t, testAuthToken, request.Header.Get("X-AUTH-TOKEN")) +// assert.Equal(t, "Bearer 19077204-9d0a-31fa-85cf-xxxxxxxxxx", request.Header.Get("Authorization")) +// +// assert.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode) +// +// assert.Equal(t, &OrangeResponse[MerchantPaymentTransaction]{ +// Message: "Merchant payment successfully initiated", +// Data: MerchantPaymentTransaction{ +// ID: 48463325, +// CreatedTime: "1670442691", +// SubscriberMSISDN: "69XXXXXXX", +// Amount: 100, +// PayToken: "MP22120771FEB7B21FD2381C3786", +// TransactionID: "MP221207.2051.B56929", +// TransactionMode: "12345", +// InitTransactionMessage: "Paiement e la clientele done.The devrez confirmer le paiement en saisissant son code PIN et vous recevrez alors un SMS. Merci dutiliser des services Orange Money.", +// InitTransactionStatus: "200", +// ConfirmTransactionStatus: nil, +// ConfirmTransactionMessage: nil, +// Status: "PENDING", +// NotificationURL: "https://example.com/payment-notification", +// Description: "Payment Description", +// ChannelUserMSISDN: "69XXXXXXX", +// }, +// }, transaction) +// +// assert.True(t, transaction.Data.IsPending()) +// assert.False(t, transaction.Data.IsConfirmed()) +// assert.False(t, transaction.Data.IsExpired()) +// +// // Teardown +// server.Close() +//} +// +//func TestMerchantPaymentService_PayWithInsufficientFunds(t *testing.T) { +// // Setup +// t.Parallel() +// +// // Arrange +// requests := make([]http.Request, 0) +// responses := [][]byte{stubs.TokenResponse(), stubs.MerchantPaymentPayResponseWithInsufficientFunds()} +// server := helpers.MakeRequestCapturingTestServer([]int{http.StatusOK, http.StatusExpectationFailed}, responses, &requests) +// client := New( +// WithBaseURL(server.URL), +// WithUsername(testUsername), +// WithPassword(testPassword), +// WithAuthToken(testAuthToken), +// ) +// +// // Act +// _, response, err := client.MerchantPayment.Pay(context.Background(), &MerchantPaymentPayPrams{ +// SubscriberMSISDN: "69XXXXXXX", +// ChannelUserMSISDN: "69XXXXXXX", +// Amount: "100", +// Description: "Payment Description", +// OrderID: "abcdef", +// Pin: "123456", +// PayToken: "MP22120771FEB7B21FD2381C3786", +// NotificationURL: "https://example.com/payment-notification", +// }) +// +// // Assert +// assert.NotNil(t, err) +// assert.Equal(t, http.StatusExpectationFailed, response.HTTPResponse.StatusCode) +// assert.True(t, strings.Contains(string(*response.Body), "60019 :: Le solde du compte du payeur est insuffisant")) +// +// // Teardown +// server.Close() +//} +// +//func TestMerchantPaymentService_Push(t *testing.T) { +// // Setup +// t.Parallel() +// +// // Arrange +// requests := make([]http.Request, 0) +// responses := [][]byte{stubs.TokenResponse(), stubs.MerchantPaymentPushResponse()} +// server := helpers.MakeRequestCapturingTestServer([]int{http.StatusOK, http.StatusOK}, responses, &requests) +// client := New( +// WithBaseURL(server.URL), +// WithUsername(testUsername), +// WithPassword(testPassword), +// WithAuthToken(testAuthToken), +// ) +// payToken := "MP22120771FEB7B21FD2381C3786" +// +// // Act +// transaction, response, err := client.MerchantPayment.Push(context.Background(), &payToken) +// +// // Assert +// assert.Nil(t, err) +// +// assert.GreaterOrEqual(t, len(requests), 2) +// request := requests[len(requests)-1] +// +// assert.Equal(t, "/omcoreapis/1.0.2/mp/push/"+payToken, request.URL.Path) +// assert.Equal(t, testAuthToken, request.Header.Get("X-AUTH-TOKEN")) +// assert.Equal(t, "Bearer 19077204-9d0a-31fa-85cf-xxxxxxxxxx", request.Header.Get("Authorization")) +// +// assert.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode) +// +// assert.Equal(t, &OrangeResponse[MerchantPaymentTransaction]{ +// Message: "Push sent to customer", +// Data: MerchantPaymentTransaction{ +// ID: 48463325, +// CreatedTime: "1670442691", +// SubscriberMSISDN: "69XXXXXXX", +// Amount: 100, +// PayToken: "MP22120771FEB7B21FD2381C3786", +// TransactionID: "MP221207.2051.B56929", +// TransactionMode: "12345", +// InitTransactionMessage: "Paiement e la clientele done.The devrez confirmer le paiement en saisissant son code PIN et vous recevrez alors un SMS. Merci dutiliser des services Orange Money.", +// InitTransactionStatus: "200", +// ConfirmTransactionStatus: nil, +// ConfirmTransactionMessage: nil, +// Status: "PENDING", +// NotificationURL: "https://example.com/payment-notification", +// Description: "Payment Description", +// ChannelUserMSISDN: "69XXXXXXX", +// }, +// }, transaction) +// +// assert.True(t, transaction.Data.IsPending()) +// assert.False(t, transaction.Data.IsConfirmed()) +// assert.False(t, transaction.Data.IsExpired()) +// +// // Teardown +// server.Close() +//} +// +//func TestMerchantPaymentService_TransactionStatus(t *testing.T) { +// // Setup +// t.Parallel() +// +// // Arrange +// requests := make([]http.Request, 0) +// responses := [][]byte{stubs.TokenResponse(), stubs.MerchantPaymentTransactionStatusResponse()} +// server := helpers.MakeRequestCapturingTestServer([]int{http.StatusOK, http.StatusOK}, responses, &requests) +// client := New( +// WithBaseURL(server.URL), +// WithUsername(testUsername), +// WithPassword(testPassword), +// WithAuthToken(testAuthToken), +// ) +// payToken := "MP22120771FEB7B21FD2381C3786" +// +// // Act +// transaction, response, err := client.MerchantPayment.TransactionStatus(context.Background(), &payToken) +// +// // Assert +// assert.Nil(t, err) +// +// assert.GreaterOrEqual(t, len(requests), 2) +// request := requests[len(requests)-1] +// +// assert.Equal(t, "/omcoreapis/1.0.2/mp/paymentstatus/"+payToken, request.URL.Path) +// assert.Equal(t, testAuthToken, request.Header.Get("X-AUTH-TOKEN")) +// assert.Equal(t, "Bearer 19077204-9d0a-31fa-85cf-xxxxxxxxxx", request.Header.Get("Authorization")) +// +// assert.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode) +// +// strPtr := func(val string) *string { +// return &val +// } +// +// assert.Equal(t, &OrangeResponse[MerchantPaymentTransaction]{ +// Message: "Transaction retrieved successfully", +// Data: MerchantPaymentTransaction{ +// ID: 48463325, +// CreatedTime: "1670442691", +// SubscriberMSISDN: "69XXXXXXX", +// Amount: 100, +// PayToken: "MP22120771FEB7B21FD2381C3786", +// TransactionID: "MP221207.2051.B56929", +// TransactionMode: "12345", +// InitTransactionMessage: "Paiement e la clientele done.The devrez confirmer le paiement en saisissant son code PIN et vous recevrez alors un SMS. Merci dutiliser des services Orange Money.", +// InitTransactionStatus: "200", +// ConfirmTransactionStatus: strPtr("200"), +// ConfirmTransactionMessage: strPtr("Successful Payment of COMPANY_NAME from 69XXXXXXX CUSTOMER_NAME. Transaction ID:MP221207.2051.B56929, Amount:100, New balance:1103.5."), +// Status: "SUCCESSFULL", +// NotificationURL: "https://example.com/payment-notification", +// Description: "Payment Description", +// ChannelUserMSISDN: "69XXXXXXX", +// }, +// }, transaction) +// +// assert.False(t, transaction.Data.IsPending()) +// assert.True(t, transaction.Data.IsConfirmed()) +// assert.False(t, transaction.Data.IsExpired()) +// +// // Teardown +// server.Close() +//} +// +//func TestMerchantPaymentService_TransactionStatusWithExpired(t *testing.T) { +// // Setup +// t.Parallel() +// +// // Arrange +// requests := make([]http.Request, 0) +// responses := [][]byte{stubs.TokenResponse(), stubs.MerchantPaymentTransactionStatusResponseWithExpired()} +// server := helpers.MakeRequestCapturingTestServer([]int{http.StatusOK, http.StatusOK}, responses, &requests) +// client := New( +// WithBaseURL(server.URL), +// WithUsername(testUsername), +// WithPassword(testPassword), +// WithAuthToken(testAuthToken), +// ) +// payToken := "MP22120771FEB7B21FD2381C3786" +// +// // Act +// transaction, response, err := client.MerchantPayment.TransactionStatus(context.Background(), &payToken) +// +// // Assert +// assert.Nil(t, err) +// +// assert.GreaterOrEqual(t, len(requests), 2) +// request := requests[len(requests)-1] +// +// assert.Equal(t, "/omcoreapis/1.0.2/mp/paymentstatus/"+payToken, request.URL.Path) +// assert.Equal(t, testAuthToken, request.Header.Get("X-AUTH-TOKEN")) +// assert.Equal(t, "Bearer 19077204-9d0a-31fa-85cf-xxxxxxxxxx", request.Header.Get("Authorization")) +// +// assert.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode) +// +// assert.False(t, transaction.Data.IsPending()) +// assert.False(t, transaction.Data.IsConfirmed()) +// assert.True(t, transaction.Data.IsExpired()) +// +// // Teardown +// server.Close() +//} diff --git a/response.go b/response.go index eb1adce..d96a26b 100644 --- a/response.go +++ b/response.go @@ -1,4 +1,4 @@ -package client +package ynote import ( "bytes" diff --git a/status_service.go b/status_service.go deleted file mode 100644 index 385cac4..0000000 --- a/status_service.go +++ /dev/null @@ -1,32 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "net/http" -) - -// statusService is the API client for the `/` endpoint -type statusService service - -// Ok returns the 200 HTTP status Code. -// -// API Docs: https://httpstat.us -func (service *statusService) Ok(ctx context.Context) (*HTTPStatus, *Response, error) { - request, err := service.client.newRequest(ctx, http.MethodGet, "/200", nil) - if err != nil { - return nil, nil, err - } - - response, err := service.client.do(request) - if err != nil { - return nil, response, err - } - - status := new(HTTPStatus) - if err = json.Unmarshal(*response.Body, status); err != nil { - return nil, response, err - } - - return status, response, nil -} diff --git a/status_service_test.go b/status_service_test.go deleted file mode 100644 index 85e57fd..0000000 --- a/status_service_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package client - -import ( - "context" - "net/http" - "testing" - "time" - - "github.com/NdoleStudio/go-http-client/internal/helpers" - "github.com/stretchr/testify/assert" -) - -func TestStatusService_Ok(t *testing.T) { - // Setup - t.Parallel() - - // Arrange - client := New() - - // Act - status, response, err := client.Status.Ok(context.Background()) - - // Assert - assert.Nil(t, err) - - assert.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode) - assert.Equal(t, &HTTPStatus{Code: 200, Description: "OK"}, status) -} - -func TestBillsService_OkWithDelay(t *testing.T) { - // Setup - t.Parallel() - start := time.Now() - - // Arrange - client := New(WithDelay(500)) - - // Act - status, response, err := client.Status.Ok(context.Background()) - - // Assert - assert.Nil(t, err) - assert.LessOrEqual(t, int64(100), time.Since(start).Milliseconds()) - assert.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode) - assert.Equal(t, &HTTPStatus{Code: 200, Description: "OK"}, status) -} - -func TestBillsService_OkWithError(t *testing.T) { - // Setup - t.Parallel() - - // Arrange - server := helpers.MakeTestServer(http.StatusInternalServerError, []byte("Internal Server Error")) - client := New(WithBaseURL(server.URL)) - - // Act - status, response, err := client.Status.Ok(context.Background()) - - // Assert - assert.NotNil(t, err) - assert.Nil(t, status) - - assert.Equal(t, "500: Internal Server Error, Body: Internal Server Error", err.Error()) - - assert.Equal(t, http.StatusInternalServerError, response.HTTPResponse.StatusCode) - assert.Equal(t, "Internal Server Error", string(*response.Body)) - - // Teardown - server.Close() -}