From 9d3643923c4f311dbb2c698fa6e46334292f6769 Mon Sep 17 00:00:00 2001 From: Josh Potts <9337140+jlpdeveloper@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:39:17 -0500 Subject: [PATCH 1/6] Combine `created_at` and `updated_at` into a single `timestamp` field in `CreateProduct` query and related implementation adjustments. --- internal/db/product/products.sql.go | 8 +++----- queries/products.sql | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/internal/db/product/products.sql.go b/internal/db/product/products.sql.go index 6a0dda0..690e92d 100644 --- a/internal/db/product/products.sql.go +++ b/internal/db/product/products.sql.go @@ -12,15 +12,14 @@ import ( ) const createProduct = `-- name: CreateProduct :exec -INSERT INTO products (platform_id, name, description, created_at, updated_at) VALUES ($1, $2, $3, $4, $5) +INSERT INTO products (platform_id, name, description, created_at, updated_at) VALUES ($1, $2, $3, $4, $4) ` type CreateProductParams struct { PlatformID int32 Name string Description pgtype.Text - CreatedAt pgtype.Timestamptz - UpdatedAt pgtype.Timestamptz + Timestamp pgtype.Timestamptz } func (q *Queries) CreateProduct(ctx context.Context, arg CreateProductParams) error { @@ -28,8 +27,7 @@ func (q *Queries) CreateProduct(ctx context.Context, arg CreateProductParams) er arg.PlatformID, arg.Name, arg.Description, - arg.CreatedAt, - arg.UpdatedAt, + arg.Timestamp, ) return err } diff --git a/queries/products.sql b/queries/products.sql index 0e2f15b..62fb97e 100644 --- a/queries/products.sql +++ b/queries/products.sql @@ -5,7 +5,7 @@ SELECT id, platform_id, name, description, created_at, updated_at FROM products SELECT id, platform_id, name, description, created_at, updated_at FROM products WHERE id = @id; -- name: CreateProduct :exec -INSERT INTO products (platform_id, name, description, created_at, updated_at) VALUES (@platform_id, @name, @description, @created_at, @updated_at); +INSERT INTO products (platform_id, name, description, created_at, updated_at) VALUES (@platform_id, @name, @description, @timestamp, @timestamp); -- name: UpdateProduct :exec UPDATE products SET name = @name, description = @description, updated_at = @updated_at WHERE id = @id RETURNING id; From da4f1c75b516e4f0752071b8fb0d6808e9b33bc1 Mon Sep 17 00:00:00 2001 From: Josh Potts <9337140+jlpdeveloper@users.noreply.github.com> Date: Fri, 1 May 2026 20:04:12 -0500 Subject: [PATCH 2/6] Add `CreateProduct` handler with tests, mock implementation, and route integration. --- api/product/create.go | 33 +++++++++++ api/product/product.go | 6 ++ api/product/product_test.go | 112 ++++++++++++++++++++++++++++++++++++ router/router.go | 10 ++++ 4 files changed, 161 insertions(+) create mode 100644 api/product/create.go create mode 100644 api/product/product_test.go diff --git a/api/product/create.go b/api/product/create.go new file mode 100644 index 0000000..e2428c0 --- /dev/null +++ b/api/product/create.go @@ -0,0 +1,33 @@ +package productHandler + +import ( + "context" + "encoding/json" + "net/http" + "products/internal/db/product" + "time" + + "github.com/jackc/pgx/v5/pgtype" +) + +func (h *ProductHandler) CreateProduct(w http.ResponseWriter, r *http.Request) { + var req product.CreateProductParams + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + req.Timestamp = pgtype.Timestamptz{ + Valid: true, + Time: time.Now().UTC(), + } + + contextWithTimeOut, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + if err := h.queries.CreateProduct(contextWithTimeOut, req); err != nil { + http.Error(w, "Failed to create product", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) + +} diff --git a/api/product/product.go b/api/product/product.go index e987bb7..42cc8dc 100644 --- a/api/product/product.go +++ b/api/product/product.go @@ -5,3 +5,9 @@ import "products/internal/db/product" type ProductHandler struct { queries product.Querier } + +func NewProductHandler(q product.Querier) *ProductHandler { + return &ProductHandler{ + queries: q, + } +} diff --git a/api/product/product_test.go b/api/product/product_test.go new file mode 100644 index 0000000..ee69ec2 --- /dev/null +++ b/api/product/product_test.go @@ -0,0 +1,112 @@ +package productHandler + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "products/internal/db/product" + "testing" +) + +type mockProductQuerier struct { + createProductFunc func(ctx context.Context, arg product.CreateProductParams) error + deleteProductFunc func(ctx context.Context, id int32) error + getProductByIdFunc func(ctx context.Context, id int32) (product.Product, error) + getProductsByPlatformFunc func(ctx context.Context, platformID int32) ([]product.Product, error) + updateProductFunc func(ctx context.Context, arg product.UpdateProductParams) error +} + +func (m *mockProductQuerier) CreateProduct(ctx context.Context, arg product.CreateProductParams) error { + return m.createProductFunc(ctx, arg) +} + +func (m *mockProductQuerier) DeleteProduct(ctx context.Context, id int32) error { + return m.deleteProductFunc(ctx, id) +} + +func (m *mockProductQuerier) GetProductById(ctx context.Context, id int32) (product.Product, error) { + return m.getProductByIdFunc(ctx, id) +} + +func (m *mockProductQuerier) GetProductsByPlatform(ctx context.Context, platformID int32) ([]product.Product, error) { + return m.getProductsByPlatformFunc(ctx, platformID) +} + +func (m *mockProductQuerier) UpdateProduct(ctx context.Context, arg product.UpdateProductParams) error { + return m.updateProductFunc(ctx, arg) +} + +func TestCreateProduct(t *testing.T) { + tests := []struct { + name string + requestBody any + mockSetup func(m *mockProductQuerier) + expectedStatus int + }{ + { + name: "Success", + requestBody: product.CreateProductParams{ + PlatformID: 1, + Name: "Test Product", + }, + mockSetup: func(m *mockProductQuerier) { + m.createProductFunc = func(ctx context.Context, arg product.CreateProductParams) error { + if arg.Name != "Test Product" { + return errors.New("unexpected name") + } + if arg.PlatformID != 1 { + return errors.New("unexpected platform id") + } + return nil + } + }, + expectedStatus: http.StatusCreated, + }, + { + name: "Invalid JSON", + requestBody: "invalid json", + mockSetup: func(m *mockProductQuerier) {}, + expectedStatus: http.StatusBadRequest, + }, + { + name: "DB Failure", + requestBody: product.CreateProductParams{ + PlatformID: 1, + Name: "Fail Product", + }, + mockSetup: func(m *mockProductQuerier) { + m.createProductFunc = func(ctx context.Context, arg product.CreateProductParams) error { + return errors.New("db error") + } + }, + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &mockProductQuerier{} + tt.mockSetup(mock) + h := NewProductHandler(mock) + + var body []byte + if s, ok := tt.requestBody.(string); ok { + body = []byte(s) + } else { + body, _ = json.Marshal(tt.requestBody) + } + + req := httptest.NewRequest(http.MethodPost, "/products", bytes.NewBuffer(body)) + rr := httptest.NewRecorder() + + h.CreateProduct(rr, req) + + if rr.Code != tt.expectedStatus { + t.Errorf("expected status %v, got %v", tt.expectedStatus, rr.Code) + } + }) + } +} diff --git a/router/router.go b/router/router.go index d1463b7..336c344 100644 --- a/router/router.go +++ b/router/router.go @@ -4,10 +4,12 @@ import ( "log/slog" "net/http" platformHandler "products/api/platform" + productHandler "products/api/product" systemHandler "products/api/system" "products/internal" "products/internal/db" platformDb "products/internal/db/platform" + productDb "products/internal/db/product" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -33,6 +35,7 @@ func SetupRouter(dbConn db.DBTX) http.Handler { registerSystemCallHandler(router) registerPlatformCallHandler(store.Platform, router) + registerProductCallHandler(store.Product, router) slog.Debug("Router setup complete") return router } @@ -52,3 +55,10 @@ func registerPlatformCallHandler(q platformDb.Querier, r *chi.Mux) { u.Put("/{id}", handler.UpdatePlatform) }) } + +func registerProductCallHandler(q productDb.Querier, r *chi.Mux) { + handler := productHandler.NewProductHandler(q) + r.Route("/api/products", func(u chi.Router) { + u.Post("/", handler.CreateProduct) + }) +} From f99cd05232fbd13dcf7074f191371acc06b65aa1 Mon Sep 17 00:00:00 2001 From: Josh Potts <9337140+jlpdeveloper@users.noreply.github.com> Date: Fri, 1 May 2026 20:11:29 -0500 Subject: [PATCH 3/6] Add validation for `Name` and `PlatformID` in `CreateProduct` handler and extend tests for missing fields --- api/product/create.go | 4 ++++ api/product/product_test.go | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/api/product/create.go b/api/product/create.go index e2428c0..de19df9 100644 --- a/api/product/create.go +++ b/api/product/create.go @@ -16,6 +16,10 @@ func (h *ProductHandler) CreateProduct(w http.ResponseWriter, r *http.Request) { http.Error(w, "Invalid request body", http.StatusBadRequest) return } + if req.Name == "" || req.PlatformID == 0 { + http.Error(w, "Name and platform ID are required", http.StatusBadRequest) + return + } req.Timestamp = pgtype.Timestamptz{ Valid: true, Time: time.Now().UTC(), diff --git a/api/product/product_test.go b/api/product/product_test.go index ee69ec2..0bfcc07 100644 --- a/api/product/product_test.go +++ b/api/product/product_test.go @@ -84,6 +84,22 @@ func TestCreateProduct(t *testing.T) { }, expectedStatus: http.StatusInternalServerError, }, + { + name: "Missing Name", + requestBody: product.CreateProductParams{ + PlatformID: 1, + }, + mockSetup: func(m *mockProductQuerier) {}, + expectedStatus: http.StatusBadRequest, + }, + { + name: "Missing PlatformID", + requestBody: product.CreateProductParams{ + Name: "Test Product", + }, + mockSetup: func(m *mockProductQuerier) {}, + expectedStatus: http.StatusBadRequest, + }, } for _, tt := range tests { From cb7c5c0f882164474d36b0b20ffea5aaeca58faf Mon Sep 17 00:00:00 2001 From: Josh Potts <9337140+jlpdeveloper@users.noreply.github.com> Date: Sat, 2 May 2026 09:15:07 -0500 Subject: [PATCH 4/6] Refactor `CreateProduct` handler to use `CreateProductRequest` struct, update parameter mapping, and adjust related tests. --- api/product/create.go | 25 ++++++++++++++++++++----- api/product/product_test.go | 8 ++++---- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/api/product/create.go b/api/product/create.go index de19df9..fdd8388 100644 --- a/api/product/create.go +++ b/api/product/create.go @@ -10,8 +10,14 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type CreateProductRequest struct { + Name string `json:"name"` + PlatformID int32 `json:"platform_id"` + Description string `json:"description"` +} + func (h *ProductHandler) CreateProduct(w http.ResponseWriter, r *http.Request) { - var req product.CreateProductParams + var req CreateProductRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return @@ -20,14 +26,23 @@ func (h *ProductHandler) CreateProduct(w http.ResponseWriter, r *http.Request) { http.Error(w, "Name and platform ID are required", http.StatusBadRequest) return } - req.Timestamp = pgtype.Timestamptz{ - Valid: true, - Time: time.Now().UTC(), + + params := product.CreateProductParams{ + Name: req.Name, + PlatformID: req.PlatformID, + Description: pgtype.Text{ + Valid: req.Description != "", + String: req.Description, + }, + Timestamp: pgtype.Timestamptz{ + Valid: true, + Time: time.Now().UTC(), + }, } contextWithTimeOut, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() - if err := h.queries.CreateProduct(contextWithTimeOut, req); err != nil { + if err := h.queries.CreateProduct(contextWithTimeOut, params); err != nil { http.Error(w, "Failed to create product", http.StatusInternalServerError) return } diff --git a/api/product/product_test.go b/api/product/product_test.go index 0bfcc07..aefa8f8 100644 --- a/api/product/product_test.go +++ b/api/product/product_test.go @@ -48,7 +48,7 @@ func TestCreateProduct(t *testing.T) { }{ { name: "Success", - requestBody: product.CreateProductParams{ + requestBody: CreateProductRequest{ PlatformID: 1, Name: "Test Product", }, @@ -73,7 +73,7 @@ func TestCreateProduct(t *testing.T) { }, { name: "DB Failure", - requestBody: product.CreateProductParams{ + requestBody: CreateProductRequest{ PlatformID: 1, Name: "Fail Product", }, @@ -86,7 +86,7 @@ func TestCreateProduct(t *testing.T) { }, { name: "Missing Name", - requestBody: product.CreateProductParams{ + requestBody: CreateProductRequest{ PlatformID: 1, }, mockSetup: func(m *mockProductQuerier) {}, @@ -94,7 +94,7 @@ func TestCreateProduct(t *testing.T) { }, { name: "Missing PlatformID", - requestBody: product.CreateProductParams{ + requestBody: CreateProductRequest{ Name: "Test Product", }, mockSetup: func(m *mockProductQuerier) {}, From 9bd78fbf224dfd5f010484891c906571f2d51be2 Mon Sep 17 00:00:00 2001 From: Josh Potts <9337140+jlpdeveloper@users.noreply.github.com> Date: Sat, 2 May 2026 09:17:18 -0500 Subject: [PATCH 5/6] Add error handling for `json.Marshal` in `product_test.go` to prevent silent test failures. --- api/product/product_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/product/product_test.go b/api/product/product_test.go index aefa8f8..c110489 100644 --- a/api/product/product_test.go +++ b/api/product/product_test.go @@ -112,7 +112,11 @@ func TestCreateProduct(t *testing.T) { if s, ok := tt.requestBody.(string); ok { body = []byte(s) } else { - body, _ = json.Marshal(tt.requestBody) + var err error + body, err = json.Marshal(tt.requestBody) + if err != nil { + t.Fatalf("json.Marshal requestBody failed: %v", err) + } } req := httptest.NewRequest(http.MethodPost, "/products", bytes.NewBuffer(body)) From 5dfe2b9d931ba25fc7fca249287014b72c604d43 Mon Sep 17 00:00:00 2001 From: Josh Potts <9337140+jlpdeveloper@users.noreply.github.com> Date: Sat, 2 May 2026 09:27:00 -0500 Subject: [PATCH 6/6] Add error handling for HTTP response writes in `structured_logger_test.go` --- internal/structured_logger_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/structured_logger_test.go b/internal/structured_logger_test.go index cd3c9d3..9ac9ffd 100644 --- a/internal/structured_logger_test.go +++ b/internal/structured_logger_test.go @@ -20,7 +20,7 @@ func TestStructuredLogger(t *testing.T) { // Create a test HTTP handler that returns a specific status code testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) - w.Write([]byte("test response")) + _, _ = w.Write([]byte("test response")) }) // Wrap our test handler with the StructuredLogger middleware @@ -153,7 +153,7 @@ func TestStructuredLoggerWithDefaultStatus(t *testing.T) { // Create a test HTTP handler that doesn't explicitly set a status code testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("default status test")) + _, _ = w.Write([]byte("default status test")) }) // Wrap our test handler with the StructuredLogger middleware