diff --git a/Makefile b/Makefile index b0d07d0..c3a8d11 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ test: ## Run integration tests .PHONY: integration-test integration-test: - go test -tags integration -run="$(testcase)" ./... + go test -tags integration -run="$(testcase)" -cover ./... ## Run lint .PHONY: lint diff --git a/README.md b/README.md index c0cae2f..30e992c 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ The testcase variable could be used to run a specific test ## Ship a new version `make image/publish` - `heroky container:release web -a contractus` + `heroku container:release web -a contractus` ## Logs in production `heroku logs --tail -a contractus` diff --git a/api/api.go b/api/api.go index 844b2d0..10760bb 100644 --- a/api/api.go +++ b/api/api.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" + "errors" "fmt" "io" "net/http" @@ -23,13 +24,20 @@ type Error struct { func (e Error) Error() string { return fmt.Sprintf("%s: %s", e.Code, e.Msg) } + func sendErr(w http.ResponseWriter, statusCode int, err error) { + var httpErr Error + if !errors.As(err, &httpErr) { + httpErr = Error{ + Code: "unknown_error", + Msg: "An unexpected error happened", + } + } if statusCode >= 500 { - slog.Error("Internal server error", "error", err) + slog.Error("Unable to process request", "error", err.Error(), "status_code", statusCode) } - err = Error{Code: "internal_server_error error", Msg: "Internal server error"} - send(w, statusCode, err) + send(w, statusCode, httpErr) } func send(w http.ResponseWriter, statusCode int, body interface{}) { @@ -64,6 +72,7 @@ func parseFile(r *http.Request) (content string, err error) { return string(contentByte), nil } +// convert is an internal function responsible for converting the file content into transactions. func convert(content string) (t []contractus.Transaction, err error) { content = strings.Replace(content, "\t", "", -1) var re = regexp.MustCompile(`(?m).*$\n`) diff --git a/api/api_test.go b/api/api_test.go index 8bd2118..fd3ad7d 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -4,6 +4,9 @@ import ( "net/http" "net/http/httptest" "testing" + "time" + + "github.com/perebaj/contractus" ) // TODO(JOJO) assert the body @@ -26,3 +29,62 @@ func TestSend(t *testing.T) { t.Errorf("expected status code %d, got %d", http.StatusOK, w.Code) } } + +func TestConvert(t *testing.T) { + content := `12022-01-15T19:20:30-03:00CURSO DE BEM-ESTAR 0000012750JOSE CARLOS + 12021-12-03T11:46:02-03:00DOMINANDO INVESTIMENTOS 0000050000MARIA CANDIDA + ` + transac, err := convert(content) + if err != nil { + t.Fatal(err) + } + + want := []contractus.Transaction{ + { + Type: 1, + Date: time.Date(2022, 01, 15, 22, 20, 30, 0, time.UTC), + ProductDescription: "CURSO DE BEM-ESTAR", + ProductPriceCents: 12750, + SellerName: "JOSE CARLOS", + SellerType: "producer", + Action: "venda produtor", + }, + { + Type: 1, + Date: time.Date(2021, 12, 03, 14, 46, 02, 0, time.UTC), + ProductDescription: "DOMINANDO INVESTIMENTOS", + ProductPriceCents: 50000, + SellerName: "MARIA CANDIDA", + SellerType: "producer", + Action: "venda produtor", + }, + } + + if len(transac) == len(want) { + assert(t, transac[0].Type, want[0].Type) + assert(t, transac[0].Date.Format(time.RFC3339), want[0].Date.Format(time.RFC3339)) + assert(t, transac[0].ProductDescription, want[0].ProductDescription) + assert(t, transac[0].ProductPriceCents, want[0].ProductPriceCents) + assert(t, transac[0].SellerName, want[0].SellerName) + assert(t, transac[0].SellerType, want[0].SellerType) + assert(t, transac[0].Action, want[0].Action) + + assert(t, transac[1].Type, want[1].Type) + assert(t, transac[1].Date.Format(time.RFC3339), want[1].Date.Format(time.RFC3339)) + assert(t, transac[1].ProductDescription, want[1].ProductDescription) + assert(t, transac[1].ProductPriceCents, want[1].ProductPriceCents) + assert(t, transac[1].SellerName, want[1].SellerName) + assert(t, transac[1].SellerType, want[1].SellerType) + assert(t, transac[1].Action, want[1].Action) + } else { + t.Fatalf("expected %d transactions, got %d", len(want), len(transac)) + } +} + +func assert(t *testing.T, got, want interface{}) { + t.Helper() + + if got != want { + t.Fatalf("got %v want %v", got, want) + } +} diff --git a/api/transactionhandler.go b/api/transactionhandler.go index d0311b9..5f5195a 100644 --- a/api/transactionhandler.go +++ b/api/transactionhandler.go @@ -12,10 +12,12 @@ import ( "github.com/go-chi/chi/v5" "github.com/perebaj/contractus" + "github.com/perebaj/contractus/postgres" ) type transactionStorage interface { SaveTransaction(ctx context.Context, t []contractus.Transaction) error + Balance(ctx context.Context, sellerType, sellerName string) (*contractus.BalanceResponse, error) } type transactionHandler struct { @@ -29,18 +31,55 @@ func RegisterHandler(r chi.Router, storage transactionStorage) { } const balanceProducer = "/balance/producer" - r.Method(http.MethodGet, balanceProducer, http.HandlerFunc(h.balance)) + r.Method(http.MethodGet, balanceProducer, http.HandlerFunc(h.producerBalance)) + + const balanceAffiliate = "/balance/affiliate" + r.Method(http.MethodGet, balanceAffiliate, http.HandlerFunc(h.affiliateBalance)) const upload = "/upload" r.Method(http.MethodPost, upload, http.HandlerFunc(h.upload)) } -func (s transactionHandler) balance(w http.ResponseWriter, _ *http.Request) { - t := struct { - ProducerID string `json:"producer_id"` - }{ProducerID: "123"} +func (s transactionHandler) producerBalance(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + name := query.Get("name") + if name == "" { + sendErr(w, http.StatusBadRequest, fmt.Errorf("name is required")) + return + } + + b, err := s.storage.Balance(r.Context(), "producer", name) + if err != nil { + if err == postgres.ErrSellerNotFound { + sendErr(w, http.StatusNotFound, postgres.ErrSellerNotFound) + return + } + sendErr(w, http.StatusInternalServerError, err) + return + } + + send(w, http.StatusOK, b) +} + +func (s transactionHandler) affiliateBalance(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + name := query.Get("name") + if name == "" { + sendErr(w, http.StatusBadRequest, fmt.Errorf("name is required")) + return + } + + b, err := s.storage.Balance(r.Context(), "affiliate", name) + if err != nil { + if err == postgres.ErrSellerNotFound { + sendErr(w, http.StatusNotFound, postgres.ErrSellerNotFound) + return + } + sendErr(w, http.StatusInternalServerError, err) + return + } - send(w, http.StatusOK, t) + send(w, http.StatusOK, b) } func (s transactionHandler) upload(w http.ResponseWriter, r *http.Request) { diff --git a/api/transactionhandler_test.go b/api/transactionhandler_test.go index 169cf11..8f755b2 100644 --- a/api/transactionhandler_test.go +++ b/api/transactionhandler_test.go @@ -8,7 +8,6 @@ import ( "net/http" "net/http/httptest" "testing" - "time" "github.com/go-chi/chi/v5" "github.com/perebaj/contractus" @@ -20,6 +19,10 @@ func (m *mockTransactionStorage) SaveTransaction(_ context.Context, _ []contract return nil } +func (m *mockTransactionStorage) Balance(_ context.Context, _ string, _ string) (*contractus.BalanceResponse, error) { + return nil, nil +} + func TestTransactionHandlerUpload(t *testing.T) { var b bytes.Buffer w := multipart.NewWriter(&b) @@ -54,61 +57,32 @@ func TestTransactionHandlerUpload(t *testing.T) { } } -func TestConvert(t *testing.T) { - content := `12022-01-15T19:20:30-03:00CURSO DE BEM-ESTAR 0000012750JOSE CARLOS - 12021-12-03T11:46:02-03:00DOMINANDO INVESTIMENTOS 0000050000MARIA CANDIDA - ` - transac, err := convert(content) - if err != nil { - t.Fatal(err) - } +func TestTransactionHandlerBalanceProducer(t *testing.T) { + m := &mockTransactionStorage{} + r := chi.NewRouter() + RegisterHandler(r, m) - want := []contractus.Transaction{ - { - Type: 1, - Date: time.Date(2022, 01, 15, 22, 20, 30, 0, time.UTC), - ProductDescription: "CURSO DE BEM-ESTAR", - ProductPriceCents: 12750, - SellerName: "JOSE CARLOS", - SellerType: "producer", - Action: "venda produtor", - }, - { - Type: 1, - Date: time.Date(2021, 12, 03, 14, 46, 02, 0, time.UTC), - ProductDescription: "DOMINANDO INVESTIMENTOS", - ProductPriceCents: 50000, - SellerName: "MARIA CANDIDA", - SellerType: "producer", - Action: "venda produtor", - }, - } + req := httptest.NewRequest(http.MethodGet, "/balance/producer?name=JOSE", nil) + resp := httptest.NewRecorder() + + r.ServeHTTP(resp, req) - if len(transac) == len(want) { - assert(t, transac[0].Type, want[0].Type) - assert(t, transac[0].Date.Format(time.RFC3339), want[0].Date.Format(time.RFC3339)) - assert(t, transac[0].ProductDescription, want[0].ProductDescription) - assert(t, transac[0].ProductPriceCents, want[0].ProductPriceCents) - assert(t, transac[0].SellerName, want[0].SellerName) - assert(t, transac[0].SellerType, want[0].SellerType) - assert(t, transac[0].Action, want[0].Action) - - assert(t, transac[1].Type, want[1].Type) - assert(t, transac[1].Date.Format(time.RFC3339), want[1].Date.Format(time.RFC3339)) - assert(t, transac[1].ProductDescription, want[1].ProductDescription) - assert(t, transac[1].ProductPriceCents, want[1].ProductPriceCents) - assert(t, transac[1].SellerName, want[1].SellerName) - assert(t, transac[1].SellerType, want[1].SellerType) - assert(t, transac[1].Action, want[1].Action) - } else { - t.Fatalf("expected %d transactions, got %d", len(want), len(transac)) + if resp.Code != http.StatusOK { + t.Fatalf("expected status code %d, got %d", http.StatusOK, resp.Code) } } -func assert(t *testing.T, got, want interface{}) { - t.Helper() +func TestTransactionHandlerBalanceAffiliate(t *testing.T) { + m := &mockTransactionStorage{} + r := chi.NewRouter() + RegisterHandler(r, m) - if got != want { - t.Fatalf("got %v want %v", got, want) + req := httptest.NewRequest(http.MethodGet, "/balance/affiliate?name=JOSE", nil) + resp := httptest.NewRecorder() + + r.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("expected status code %d, got %d", http.StatusOK, resp.Code) } } diff --git a/docs/api.md b/docs/api.md index b2d2a43..5c17473 100644 --- a/docs/api.md +++ b/docs/api.md @@ -8,10 +8,48 @@ Basic usage examples of the contractus API Request: -``` +```bash curl -i -X 'POST' \ 'http://localhost:8080/upload' \ -H 'accept: application/json' \ -H 'Content-Type: multipart/form-data' \ -F 'file=@sales.txt;type=text/plain' ``` + +## `/balance/affiliate?name=<>` + +Request: + +```bash +curl -X 'GET' \ + 'http://localhost:8080/balance/affiliate?name=<>' \ + -H 'accept: application/json' +``` + +Response: + +```JSON +{ + "balance": 0, + "seller_name": "string" +} +``` + +## `/balance/producer?name=<>` + +Request: + +```bash +curl -X 'GET' \ + 'http://localhost:8080/balance/producer?name=<>' \ + -H 'accept: application/json' +``` + +Response: + +```JSON +{ + "balance": 0, + "seller_name": "string" +} +``` diff --git a/docs/api.yml b/docs/api.yml index d97d11e..2de8d92 100644 --- a/docs/api.yml +++ b/docs/api.yml @@ -6,16 +6,6 @@ info: servers: - url: http://localhost:8080 paths: - /health: - get: - responses: - '200': - description: OK - content: - application/json: - schema: - type: string - default: 'Jojo is awesome!' /upload: post: summary: Upload a transaction file @@ -79,6 +69,12 @@ paths: seller_name: type: string description: Affiliate name + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /balance/producer: get: @@ -106,6 +102,12 @@ paths: seller_name: type: string description: Producer name + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' components: schemas: ErrorResponse: @@ -114,7 +116,7 @@ components: code: type: string description: Error code error - message: + msg: type: string description: Error message Transaction: diff --git a/postgres/storage.go b/postgres/storage.go index f9e7426..df11735 100644 --- a/postgres/storage.go +++ b/postgres/storage.go @@ -2,11 +2,17 @@ package postgres import ( "context" + "errors" "github.com/jmoiron/sqlx" "github.com/perebaj/contractus" ) +var ( + // ErrSellerNotFound is returned when the seller is not found in the database. + ErrSellerNotFound = errors.New("seller not found") +) + // Storage deal with the database layer for transactions. type Storage struct { db *sqlx.DB @@ -58,6 +64,9 @@ func (s Storage) Balance(ctx context.Context, sellerType, sellerName string) (*c WHERE seller_type = $1 AND seller_name = $2 `, sellerType, sellerName) + if len(transactions) == 0 { + return nil, ErrSellerNotFound + } if err != nil { return nil, err } diff --git a/postgres/storage_test.go b/postgres/storage_test.go index 6c3c346..a557e7b 100644 --- a/postgres/storage_test.go +++ b/postgres/storage_test.go @@ -5,6 +5,7 @@ package postgres_test import ( "context" "database/sql" + "errors" "fmt" "net/url" "os" @@ -253,6 +254,36 @@ func TestStorageBalance(t *testing.T) { assert(t, got.SellerName, "CARLOS BATISTA") } +func TestStorageBalance_NotFound(t *testing.T) { + db := OpenDB(t) + storage := postgres.NewStorage(db) + ctx := context.Background() + + transactions1 := []contractus.Transaction{ + { + Type: 1, + Date: time.Now().UTC(), + ProductDescription: "Product description", + ProductPriceCents: 12750, + SellerName: "JOSE CARLOS", + SellerType: "producer", + Action: "venda produtor", + }, + } + + err := storage.SaveTransaction(ctx, transactions1) + if err != nil { + t.Fatalf("error saving transactions 1: %v", err) + } + + got, err := storage.Balance(ctx, "producer", "INVALID NAME") + if !errors.Is(err, postgres.ErrSellerNotFound) { + t.Fatalf("error getting balance: %v", err) + } + + assert(t, got, (*contractus.BalanceResponse)(nil)) +} + func assert(t *testing.T, got, want interface{}) { t.Helper()