diff --git a/api/platform/create.go b/api/platform/create.go deleted file mode 100644 index db2c3a4..0000000 --- a/api/platform/create.go +++ /dev/null @@ -1,47 +0,0 @@ -package platformHandler - -import ( - "context" - "encoding/json" - "net/http" - db "products/internal/db/platform" - "time" - - "github.com/jackc/pgx/v5/pgtype" -) - -type CreatePlatformRequest struct { - Name string `json:"name"` - Description string `json:"description"` -} - -func (h *PlatformHandler) CreatePlatform(w http.ResponseWriter, r *http.Request) { - var req CreatePlatformRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if req.Name == "" { - http.Error(w, "Name is required", http.StatusBadRequest) - return - } - contextWithTimeOut, cancel := context.WithTimeout(r.Context(), 5*time.Second) - defer cancel() - if err := h.queries.CreatePlatform(contextWithTimeOut, db.CreatePlatformParams{ - Name: req.Name, - Description: pgtype.Text{ - Valid: req.Description != "", - String: req.Description, - }, - Timestamp: pgtype.Timestamptz{ - Valid: true, - Time: time.Now().UTC(), - }, - }); err != nil { - http.Error(w, "Failed to create platform", http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusCreated) -} diff --git a/api/platform/delete.go b/api/platform/delete.go deleted file mode 100644 index 53f6ec6..0000000 --- a/api/platform/delete.go +++ /dev/null @@ -1,32 +0,0 @@ -package platformHandler - -import ( - "context" - "errors" - "net/http" - "products/internal" - "time" - - "github.com/jackc/pgx/v5" -) - -func (h *PlatformHandler) DeletePlatform(w http.ResponseWriter, r *http.Request) { - id, ok := internal.GetIntFromRequestPath("id", r) - if !ok { - http.Error(w, "Invalid platform ID", http.StatusBadRequest) - return - } - contextWithTimeOut, cancel := context.WithTimeout(r.Context(), 5*time.Second) - defer cancel() - _, err := h.queries.DeletePlatform(contextWithTimeOut, id) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - http.Error(w, "Platform not found", http.StatusNotFound) - return - } - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusNoContent) -} diff --git a/api/platform/get.go b/api/platform/get.go deleted file mode 100644 index 40f18c8..0000000 --- a/api/platform/get.go +++ /dev/null @@ -1,59 +0,0 @@ -package platformHandler - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "products/internal" - db "products/internal/db/platform" - "time" - - "github.com/jackc/pgx/v5" -) - -func (h *PlatformHandler) GetPlatforms(w http.ResponseWriter, r *http.Request) { - contextWithTimeOut, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - platforms, err := h.queries.GetPlatforms(contextWithTimeOut) - if err != nil { - http.Error(w, "Failed to fetch platforms", http.StatusInternalServerError) - return - } - if platforms == nil { - platforms = []db.Platform{} - } - w.Header().Set("Content-Type", "application/json") - err = json.NewEncoder(w).Encode(platforms) - if err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - return - } - -} - -func (h *PlatformHandler) GetPlatform(w http.ResponseWriter, r *http.Request) { - id, ok := internal.GetIntFromRequestPath("id", r) - if !ok { - http.Error(w, "Invalid platform ID", http.StatusBadRequest) - return - } - contextWithTimeOut, cancel := context.WithTimeout(r.Context(), 5*time.Second) - defer cancel() - platform, err := h.queries.GetPlatform(contextWithTimeOut, id) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - http.Error(w, "Platform not found", http.StatusNotFound) - return - } - http.Error(w, "Failed to fetch platform", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - err = json.NewEncoder(w).Encode(platform) - if err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - return - } -} diff --git a/api/platform/platform.go b/api/platform/platform.go deleted file mode 100644 index 6a870b3..0000000 --- a/api/platform/platform.go +++ /dev/null @@ -1,13 +0,0 @@ -package platformHandler - -import "products/internal/db/platform" - -type PlatformHandler struct { - queries platform.Querier -} - -func NewPlatformHandler(q platform.Querier) *PlatformHandler { - return &PlatformHandler{ - queries: q, - } -} diff --git a/api/platform/update.go b/api/platform/update.go deleted file mode 100644 index f4f9272..0000000 --- a/api/platform/update.go +++ /dev/null @@ -1,58 +0,0 @@ -package platformHandler - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "products/internal" - db "products/internal/db/platform" - "time" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -func (h *PlatformHandler) UpdatePlatform(w http.ResponseWriter, r *http.Request) { - var req db.Platform - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - id, ok := internal.GetIntFromRequestPath("id", r) - if !ok { - http.Error(w, "Invalid platform ID", http.StatusBadRequest) - return - } - - if req.Name == "" { - http.Error(w, "Name is required", http.StatusBadRequest) - return - } - - if req.ID != id { - http.Error(w, "Platform ID does not match path", http.StatusBadRequest) - return - } - contextWithTimeOut, cancel := context.WithTimeout(r.Context(), 5*time.Second) - defer cancel() - _, err := h.queries.UpdatePlatform(contextWithTimeOut, db.UpdatePlatformParams{ - ID: req.ID, - Name: req.Name, - Description: req.Description, - Updatedat: pgtype.Timestamptz{ - Valid: true, - Time: time.Now().UTC(), - }, - }) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - http.Error(w, "Platform not found", http.StatusNotFound) - return - } - http.Error(w, "Failed to update platform", http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusNoContent) -} diff --git a/api/product/create.go b/api/product/create.go deleted file mode 100644 index fdd8388..0000000 --- a/api/product/create.go +++ /dev/null @@ -1,52 +0,0 @@ -package productHandler - -import ( - "context" - "encoding/json" - "net/http" - "products/internal/db/product" - "time" - - "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 CreateProductRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - 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 - } - - 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, params); err != nil { - http.Error(w, "Failed to create product", http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusCreated) - -} diff --git a/api/product/delete.go b/api/product/delete.go deleted file mode 100644 index d209b60..0000000 --- a/api/product/delete.go +++ /dev/null @@ -1,30 +0,0 @@ -package productHandler - -import ( - "context" - "errors" - "net/http" - "products/internal" - "time" - - "github.com/jackc/pgx/v5" -) - -func (h *ProductHandler) DeleteProduct(w http.ResponseWriter, r *http.Request) { - id, ok := internal.GetIntFromRequestPath("id", r) - if !ok { - http.Error(w, "Invalid product ID", http.StatusBadRequest) - return - } - contextWithTimeOut, cancel := context.WithTimeout(r.Context(), 5*time.Second) - defer cancel() - if _, err := h.queries.DeleteProduct(contextWithTimeOut, id); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - http.Error(w, "Product not found", http.StatusNotFound) - return - } - http.Error(w, "Failed to delete product", http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusNoContent) -} diff --git a/api/product/get.go b/api/product/get.go deleted file mode 100644 index 6bdd321..0000000 --- a/api/product/get.go +++ /dev/null @@ -1,69 +0,0 @@ -package productHandler - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "products/internal" - db "products/internal/db/product" - "time" - - "github.com/jackc/pgx/v5" -) - -// GetProductsByPlatform fetches products by platform ID. -func (h *ProductHandler) GetProductsByPlatform(w http.ResponseWriter, r *http.Request) { - platformID, ok := internal.GetIntFromRequestPath("platform_id", r) - if !ok { - http.Error(w, "Invalid platform ID", http.StatusBadRequest) - return - } - - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - - products, err := h.queries.GetProductsByPlatform(ctx, platformID) - if err != nil { - http.Error(w, "Failed to fetch products", http.StatusInternalServerError) - return - } - - if products == nil { - products = []db.Product{} - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(products); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - return - } -} - -// GetProductById fetches a single product by ID. -func (h *ProductHandler) GetProductById(w http.ResponseWriter, r *http.Request) { - id, ok := internal.GetIntFromRequestPath("id", r) - if !ok { - http.Error(w, "Invalid product ID", http.StatusBadRequest) - return - } - - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - - product, err := h.queries.GetProductById(ctx, id) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - http.Error(w, "Product not found", http.StatusNotFound) - return - } - http.Error(w, "Failed to fetch product", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(product); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - return - } -} diff --git a/api/product/product.go b/api/product/product.go deleted file mode 100644 index 42cc8dc..0000000 --- a/api/product/product.go +++ /dev/null @@ -1,13 +0,0 @@ -package productHandler - -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/update.go b/api/product/update.go deleted file mode 100644 index a686b5b..0000000 --- a/api/product/update.go +++ /dev/null @@ -1,69 +0,0 @@ -package productHandler - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "products/internal" - "products/internal/db/product" - "strings" - "time" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -type UpdateProductRequest struct { - PlatformID int32 `json:"platform_id"` - Name string `json:"name"` - Description string `json:"description"` -} - -func (h *ProductHandler) UpdateProduct(w http.ResponseWriter, r *http.Request) { - id, ok := internal.GetIntFromRequestPath("id", r) - if !ok { - http.Error(w, "Invalid product ID", http.StatusBadRequest) - return - } - - var req UpdateProductRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - req.Name = strings.TrimSpace(req.Name) - if req.Name == "" || req.PlatformID == 0 { - http.Error(w, "Name and platform ID are required", http.StatusBadRequest) - return - } - - params := product.UpdateProductParams{ - ID: id, - PlatformID: req.PlatformID, - Name: req.Name, - Description: pgtype.Text{ - Valid: req.Description != "", - String: req.Description, - }, - UpdatedAt: pgtype.Timestamptz{ - Valid: true, - Time: time.Now().UTC(), - }, - } - - ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) - defer cancel() - - if _, err := h.queries.UpdateProduct(ctx, params); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - http.Error(w, "Product not found", http.StatusNotFound) - return - } - http.Error(w, "Failed to update product", http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} diff --git a/internal/db/store.go b/internal/db/store.go index ba0b06d..84df096 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -2,25 +2,11 @@ package db import ( "context" - "products/internal/db/platform" - "products/internal/db/product" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" ) -type Store struct { - Platform platform.Querier - Product product.Querier -} - -func New(db DBTX) *Store { - return &Store{ - Platform: platform.New(db), - Product: product.New(db), - } -} - // This interface is repeated in every db package, it is auto-generated by sqlc type DBTX interface { Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) diff --git a/internal/db/platform/db.go b/internal/platform/db.gen.go similarity index 100% rename from internal/db/platform/db.go rename to internal/platform/db.gen.go diff --git a/internal/platform/handler.go b/internal/platform/handler.go new file mode 100644 index 0000000..b84e35d --- /dev/null +++ b/internal/platform/handler.go @@ -0,0 +1,151 @@ +package platform + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "products/internal" + "time" + + "github.com/jackc/pgx/v5" +) + +func NewPlatformHandler(db DBTX) Handler { + queries := &Queries{ + db: db, + } + return &platformHandler{ + queries: queries, + } +} + +type platformHandler struct { + queries Querier +} + +func (h *platformHandler) CreatePlatform(w http.ResponseWriter, r *http.Request) { + var req createPlatformRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Name == "" { + http.Error(w, "Name is required", http.StatusBadRequest) + return + } + contextWithTimeOut, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + if err := h.queries.CreatePlatform(contextWithTimeOut, req.ToParams()); err != nil { + http.Error(w, "Failed to create platform", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) +} + +func (h *platformHandler) UpdatePlatform(w http.ResponseWriter, r *http.Request) { + var req updatePlatformRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + id, ok := internal.GetIntFromRequestPath("id", r) + if !ok { + http.Error(w, "Invalid platform ID", http.StatusBadRequest) + return + } + + if req.Name == "" { + http.Error(w, "Name is required", http.StatusBadRequest) + return + } + + if req.ID != id { + http.Error(w, "Platform ID does not match path", http.StatusBadRequest) + return + } + contextWithTimeOut, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + _, err := h.queries.UpdatePlatform(contextWithTimeOut, req.ToParams(id)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.Error(w, "Platform not found", http.StatusNotFound) + return + } + http.Error(w, "Failed to update platform", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *platformHandler) DeletePlatform(w http.ResponseWriter, r *http.Request) { + id, ok := internal.GetIntFromRequestPath("id", r) + if !ok { + http.Error(w, "Invalid platform ID", http.StatusBadRequest) + return + } + contextWithTimeOut, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + _, err := h.queries.DeletePlatform(contextWithTimeOut, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.Error(w, "Platform not found", http.StatusNotFound) + return + } + logger := internal.LoggerFromContext(r.Context()) + logger.Error("Failed to delete platform", "error", err, "platform_id", id) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *platformHandler) GetPlatforms(w http.ResponseWriter, r *http.Request) { + contextWithTimeOut, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + platforms, err := h.queries.GetPlatforms(contextWithTimeOut) + if err != nil { + http.Error(w, "Failed to fetch platforms", http.StatusInternalServerError) + return + } + if platforms == nil { + platforms = []Platform{} + } + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(platforms) + if err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + +} + +func (h *platformHandler) GetPlatform(w http.ResponseWriter, r *http.Request) { + id, ok := internal.GetIntFromRequestPath("id", r) + if !ok { + http.Error(w, "Invalid platform ID", http.StatusBadRequest) + return + } + contextWithTimeOut, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + platform, err := h.queries.GetPlatform(contextWithTimeOut, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.Error(w, "Platform not found", http.StatusNotFound) + return + } + http.Error(w, "Failed to fetch platform", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(platform) + if err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} diff --git a/api/platform/platform_test.go b/internal/platform/handler_test.go similarity index 83% rename from api/platform/platform_test.go rename to internal/platform/handler_test.go index 33ec606..103627a 100644 --- a/api/platform/platform_test.go +++ b/internal/platform/handler_test.go @@ -1,4 +1,4 @@ -package platformHandler +package platform import ( "bytes" @@ -7,7 +7,6 @@ import ( "errors" "net/http" "net/http/httptest" - "products/internal/db/platform" "strconv" "testing" @@ -17,14 +16,14 @@ import ( type mockPlatformQuerier struct { err error - createPlatform func(ctx context.Context, arg platform.CreatePlatformParams) error - getPlatforms func(ctx context.Context) ([]platform.Platform, error) - getPlatform func(ctx context.Context, id int32) (platform.Platform, error) + createPlatform func(ctx context.Context, arg CreatePlatformParams) error + getPlatforms func(ctx context.Context) ([]Platform, error) + getPlatform func(ctx context.Context, id int32) (Platform, error) deletePlatform func(ctx context.Context, id int32) (int32, error) - updatePlatform func(ctx context.Context, arg platform.UpdatePlatformParams) (int32, error) + updatePlatform func(ctx context.Context, arg UpdatePlatformParams) (int32, error) } -func (m *mockPlatformQuerier) CreatePlatform(ctx context.Context, arg platform.CreatePlatformParams) error { +func (m *mockPlatformQuerier) CreatePlatform(ctx context.Context, arg CreatePlatformParams) error { if m.createPlatform != nil { return m.createPlatform(ctx, arg) } @@ -38,21 +37,21 @@ func (m *mockPlatformQuerier) DeletePlatform(ctx context.Context, id int32) (int return -1, m.err } -func (m *mockPlatformQuerier) GetPlatform(ctx context.Context, id int32) (platform.Platform, error) { +func (m *mockPlatformQuerier) GetPlatform(ctx context.Context, id int32) (Platform, error) { if m.getPlatform != nil { return m.getPlatform(ctx, id) } - return platform.Platform{}, m.err + return Platform{}, m.err } -func (m *mockPlatformQuerier) GetPlatforms(ctx context.Context) ([]platform.Platform, error) { +func (m *mockPlatformQuerier) GetPlatforms(ctx context.Context) ([]Platform, error) { if m.getPlatforms != nil { return m.getPlatforms(ctx) } return nil, m.err } -func (m *mockPlatformQuerier) UpdatePlatform(ctx context.Context, arg platform.UpdatePlatformParams) (int32, error) { +func (m *mockPlatformQuerier) UpdatePlatform(ctx context.Context, arg UpdatePlatformParams) (int32, error) { if m.updatePlatform != nil { return m.updatePlatform(ctx, arg) } @@ -68,7 +67,7 @@ func TestCreatePlatform(t *testing.T) { }{ { name: "Success", - requestBody: CreatePlatformRequest{ + requestBody: createPlatformRequest{ Name: "Test Platform", Description: "Test Description", }, @@ -77,7 +76,7 @@ func TestCreatePlatform(t *testing.T) { }, { name: "Missing Name", - requestBody: CreatePlatformRequest{ + requestBody: createPlatformRequest{ Name: "", Description: "Test Description", }, @@ -92,7 +91,7 @@ func TestCreatePlatform(t *testing.T) { }, { name: "DB Error", - requestBody: CreatePlatformRequest{ + requestBody: createPlatformRequest{ Name: "Test Platform", Description: "Test Description", }, @@ -104,7 +103,7 @@ func TestCreatePlatform(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mDB := &mockPlatformQuerier{err: tt.dbErr} - h := NewPlatformHandler(mDB) + h := &platformHandler{queries: mDB} var body []byte if s, ok := tt.requestBody.(string); ok { @@ -129,12 +128,12 @@ func TestGetPlatforms(t *testing.T) { tests := []struct { name string dbErr error - platforms []platform.Platform + platforms []Platform expectedStatus int }{ { name: "Success", - platforms: []platform.Platform{ + platforms: []Platform{ {ID: 1, Name: "Platform 1", Description: pgtype.Text{String: "Desc 1", Valid: true}}, {ID: 2, Name: "Platform 2", Description: pgtype.Text{String: "Desc 2", Valid: true}}, }, @@ -143,7 +142,7 @@ func TestGetPlatforms(t *testing.T) { }, { name: "Empty Success", - platforms: []platform.Platform{}, + platforms: []Platform{}, dbErr: nil, expectedStatus: http.StatusOK, }, @@ -159,11 +158,11 @@ func TestGetPlatforms(t *testing.T) { t.Run(tt.name, func(t *testing.T) { mDB := &mockPlatformQuerier{ err: tt.dbErr, - getPlatforms: func(ctx context.Context) ([]platform.Platform, error) { + getPlatforms: func(ctx context.Context) ([]Platform, error) { return tt.platforms, tt.dbErr }, } - h := NewPlatformHandler(mDB) + h := &platformHandler{queries: mDB} req := httptest.NewRequest(http.MethodGet, "/api/platforms", nil) rr := httptest.NewRecorder() @@ -175,7 +174,7 @@ func TestGetPlatforms(t *testing.T) { } if tt.expectedStatus == http.StatusOK { - var got []platform.Platform + var got []Platform err := json.Unmarshal(rr.Body.Bytes(), &got) if err != nil { t.Fatalf("failed to unmarshal response: %v", err) @@ -192,56 +191,56 @@ func TestGetPlatform(t *testing.T) { tests := []struct { name string id string - dbPlatform platform.Platform + dbPlatform Platform dbErr error expectedStatus int }{ { name: "Success", id: "1", - dbPlatform: platform.Platform{ID: 1, Name: "Platform 1"}, + dbPlatform: Platform{ID: 1, Name: "Platform 1"}, dbErr: nil, expectedStatus: http.StatusOK, }, { name: "Invalid ID", id: "abc", - dbPlatform: platform.Platform{}, + dbPlatform: Platform{}, dbErr: nil, expectedStatus: http.StatusBadRequest, }, { name: "Invalid ID (Zero)", id: "0", - dbPlatform: platform.Platform{}, + dbPlatform: Platform{}, dbErr: nil, expectedStatus: http.StatusBadRequest, }, { name: "Invalid ID (Negative)", id: "-1", - dbPlatform: platform.Platform{}, + dbPlatform: Platform{}, dbErr: nil, expectedStatus: http.StatusBadRequest, }, { name: "Invalid ID (Overflow)", id: "2147483648", - dbPlatform: platform.Platform{}, + dbPlatform: Platform{}, dbErr: nil, expectedStatus: http.StatusBadRequest, }, { name: "Not Found", id: "999", - dbPlatform: platform.Platform{}, + dbPlatform: Platform{}, dbErr: pgx.ErrNoRows, expectedStatus: http.StatusNotFound, }, { name: "DB Error", id: "1", - dbPlatform: platform.Platform{}, + dbPlatform: Platform{}, dbErr: errors.New("db error"), expectedStatus: http.StatusInternalServerError, }, @@ -251,11 +250,11 @@ func TestGetPlatform(t *testing.T) { t.Run(tt.name, func(t *testing.T) { mDB := &mockPlatformQuerier{ err: tt.dbErr, - getPlatform: func(ctx context.Context, id int32) (platform.Platform, error) { + getPlatform: func(ctx context.Context, id int32) (Platform, error) { return tt.dbPlatform, tt.dbErr }, } - h := NewPlatformHandler(mDB) + h := &platformHandler{queries: mDB} req := httptest.NewRequest(http.MethodGet, "/api/platforms/"+tt.id, nil) req.SetPathValue("id", tt.id) @@ -268,7 +267,7 @@ func TestGetPlatform(t *testing.T) { } if tt.expectedStatus == http.StatusOK { - var got platform.Platform + var got Platform err := json.Unmarshal(rr.Body.Bytes(), &got) if err != nil { t.Fatalf("failed to unmarshal response: %v", err) @@ -287,6 +286,7 @@ func TestDeletePlatform(t *testing.T) { id string dbErr error expectedStatus int + expectedBody string }{ { name: "Success", @@ -305,6 +305,14 @@ func TestDeletePlatform(t *testing.T) { id: "1", dbErr: errors.New("db error"), expectedStatus: http.StatusInternalServerError, + expectedBody: "Internal server error\n", + }, + { + name: "DB Error (pgx.ErrNoRows)", + id: "1", + dbErr: pgx.ErrNoRows, + expectedStatus: http.StatusNotFound, + expectedBody: "Platform not found\n", }, { name: "Invalid ID (Zero)", @@ -343,7 +351,7 @@ func TestDeletePlatform(t *testing.T) { return -1, tt.dbErr }, } - h := NewPlatformHandler(mDB) + h := &platformHandler{queries: mDB} req := httptest.NewRequest(http.MethodDelete, "/api/platforms/"+tt.id, nil) req.SetPathValue("id", tt.id) @@ -354,6 +362,12 @@ func TestDeletePlatform(t *testing.T) { if rr.Code != tt.expectedStatus { t.Errorf("expected status %d, got %d", tt.expectedStatus, rr.Code) } + + if tt.expectedBody != "" { + if rr.Body.String() != tt.expectedBody { + t.Errorf("expected body %q, got %q", tt.expectedBody, rr.Body.String()) + } + } }) } } @@ -369,7 +383,7 @@ func TestUpdatePlatform(t *testing.T) { { name: "Success", pathID: "1", - requestBody: platform.Platform{ + requestBody: Platform{ ID: 1, Name: "Updated Platform", Description: pgtype.Text{String: "Updated Description", Valid: true}, @@ -380,7 +394,7 @@ func TestUpdatePlatform(t *testing.T) { { name: "Invalid Path ID", pathID: "abc", - requestBody: platform.Platform{ + requestBody: Platform{ ID: 1, Name: "Updated Platform", }, @@ -390,7 +404,7 @@ func TestUpdatePlatform(t *testing.T) { { name: "Invalid Path ID (Zero)", pathID: "0", - requestBody: platform.Platform{ + requestBody: Platform{ ID: 0, Name: "Updated Platform", }, @@ -400,7 +414,7 @@ func TestUpdatePlatform(t *testing.T) { { name: "Invalid Path ID (Negative)", pathID: "-1", - requestBody: platform.Platform{ + requestBody: Platform{ ID: -1, Name: "Updated Platform", }, @@ -410,7 +424,7 @@ func TestUpdatePlatform(t *testing.T) { { name: "Invalid Path ID (Overflow)", pathID: "2147483648", - requestBody: platform.Platform{ + requestBody: Platform{ ID: 1, Name: "Updated Platform", }, @@ -420,7 +434,7 @@ func TestUpdatePlatform(t *testing.T) { { name: "ID Mismatch", pathID: "2", - requestBody: platform.Platform{ + requestBody: Platform{ ID: 1, Name: "Updated Platform", }, @@ -430,7 +444,7 @@ func TestUpdatePlatform(t *testing.T) { { name: "Missing Name", pathID: "1", - requestBody: platform.Platform{ + requestBody: Platform{ ID: 1, Name: "", }, @@ -447,7 +461,7 @@ func TestUpdatePlatform(t *testing.T) { { name: "Platform Not Found", pathID: "999", - requestBody: platform.Platform{ + requestBody: Platform{ ID: 999, Name: "Non-existent", }, @@ -457,7 +471,7 @@ func TestUpdatePlatform(t *testing.T) { { name: "DB Error", pathID: "1", - requestBody: platform.Platform{ + requestBody: Platform{ ID: 1, Name: "Test Platform", }, @@ -470,9 +484,9 @@ func TestUpdatePlatform(t *testing.T) { t.Run(tt.name, func(t *testing.T) { mDB := &mockPlatformQuerier{ err: tt.dbErr, - updatePlatform: func(ctx context.Context, arg platform.UpdatePlatformParams) (int32, error) { + updatePlatform: func(ctx context.Context, arg UpdatePlatformParams) (int32, error) { if tt.name == "Success" { - expectedBody := tt.requestBody.(platform.Platform) + expectedBody := tt.requestBody.(Platform) if arg.ID != expectedBody.ID { t.Errorf("expected ID %d, got %d", expectedBody.ID, arg.ID) } @@ -492,7 +506,7 @@ func TestUpdatePlatform(t *testing.T) { return arg.ID, tt.dbErr }, } - h := NewPlatformHandler(mDB) + h := &platformHandler{queries: mDB} var body []byte if s, ok := tt.requestBody.(string); ok { diff --git a/internal/platform/interface.go b/internal/platform/interface.go new file mode 100644 index 0000000..d528f74 --- /dev/null +++ b/internal/platform/interface.go @@ -0,0 +1,11 @@ +package platform + +import "net/http" + +type Handler interface { + CreatePlatform(w http.ResponseWriter, r *http.Request) + GetPlatforms(w http.ResponseWriter, r *http.Request) + GetPlatform(w http.ResponseWriter, r *http.Request) + UpdatePlatform(w http.ResponseWriter, r *http.Request) + DeletePlatform(w http.ResponseWriter, r *http.Request) +} diff --git a/internal/db/platform/models.go b/internal/platform/models.gen.go similarity index 100% rename from internal/db/platform/models.go rename to internal/platform/models.gen.go diff --git a/queries/platforms.sql b/internal/platform/platforms.sql similarity index 100% rename from queries/platforms.sql rename to internal/platform/platforms.sql diff --git a/internal/db/platform/platforms.sql.go b/internal/platform/platforms.sql.gen.go similarity index 100% rename from internal/db/platform/platforms.sql.go rename to internal/platform/platforms.sql.gen.go diff --git a/internal/db/platform/querier.go b/internal/platform/querier.gen.go similarity index 100% rename from internal/db/platform/querier.go rename to internal/platform/querier.gen.go diff --git a/internal/platform/request.go b/internal/platform/request.go new file mode 100644 index 0000000..d24001e --- /dev/null +++ b/internal/platform/request.go @@ -0,0 +1,47 @@ +package platform + +import ( + "time" + + "github.com/jackc/pgx/v5/pgtype" +) + +type createPlatformRequest struct { + Name string `json:"name"` + Description string `json:"description"` +} + +func (r *createPlatformRequest) ToParams() CreatePlatformParams { + return CreatePlatformParams{ + Name: r.Name, + Description: pgtype.Text{ + Valid: r.Description != "", + String: r.Description, + }, + Timestamp: pgtype.Timestamptz{ + Valid: true, + Time: time.Now().UTC(), + }, + } +} + +type updatePlatformRequest struct { + ID int32 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +func (r *updatePlatformRequest) ToParams(id int32) UpdatePlatformParams { + return UpdatePlatformParams{ + ID: id, + Name: r.Name, + Description: pgtype.Text{ + Valid: r.Description != "", + String: r.Description, + }, + Updatedat: pgtype.Timestamptz{ + Valid: true, + Time: time.Now().UTC(), + }, + } +} diff --git a/internal/platform/request_test.go b/internal/platform/request_test.go new file mode 100644 index 0000000..58730a6 --- /dev/null +++ b/internal/platform/request_test.go @@ -0,0 +1,120 @@ +package platform + +import ( + "testing" + "time" +) + +func TestCreatePlatformRequest_ToParams(t *testing.T) { + req := createPlatformRequest{ + Name: "Test Platform", + Description: "Test Description", + } + + params := req.ToParams() + + if params.Name != req.Name { + t.Errorf("expected name %q, got %q", req.Name, params.Name) + } + if !params.Description.Valid { + t.Error("expected description to be valid") + } + if params.Description.String != req.Description { + t.Errorf("expected description %q, got %q", req.Description, params.Description.String) + } + if !params.Timestamp.Valid { + t.Error("expected timestamp to be valid") + } + diff := time.Since(params.Timestamp.Time) + if diff < 0 { + diff = -diff + } + if diff > 2*time.Second { + t.Errorf("timestamp too far from now: %v", diff) + } +} + +func TestCreatePlatformRequest_ToParams_EmptyDescription(t *testing.T) { + req := createPlatformRequest{ + Name: "Test Platform", + Description: "", + } + + params := req.ToParams() + + if params.Description.Valid { + t.Error("expected description to be invalid") + } +} + +func TestUpdatePlatformRequest_ToParams(t *testing.T) { + req := updatePlatformRequest{ + ID: 1, + Name: "Updated Platform", + Description: "Updated Description", + } + id := int32(1) + + params := req.ToParams(id) + + if params.ID != id { + t.Errorf("expected id %d, got %d", id, params.ID) + } + if params.Name != req.Name { + t.Errorf("expected name %q, got %q", req.Name, params.Name) + } + if !params.Description.Valid { + t.Error("expected description to be valid") + } + if params.Description.String != req.Description { + t.Errorf("expected description %q, got %q", req.Description, params.Description.String) + } + if !params.Updatedat.Valid { + t.Error("expected updatedat to be valid") + } + diff := time.Since(params.Updatedat.Time) + if diff < 0 { + diff = -diff + } + if diff > 2*time.Second { + t.Errorf("updatedat too far from now: %v", diff) + } +} + +func TestUpdatePlatformRequest_ToParams_EmptyDescription(t *testing.T) { + req := updatePlatformRequest{ + ID: 1, + Name: "Updated Platform", + Description: "", + } + + params := req.ToParams(1) + + if params.Description.Valid { + t.Error("expected description to be invalid") + } +} + +func TestUpdatePlatformRequest_ToParams_IDPrecedence(t *testing.T) { + req := updatePlatformRequest{ + ID: 100, // ID from body + Name: "Precedence Test", + Description: "Testing ID precedence", + } + pathID := int32(200) // ID from path/argument + + params := req.ToParams(pathID) + + // Verify that the method argument ID is preferred over the struct field ID + if params.ID != pathID { + t.Errorf("expected id %d (path ID), got %d", pathID, params.ID) + } + + // Verify that Name and Description are preserved + if params.Name != req.Name { + t.Errorf("expected name %q, got %q", req.Name, params.Name) + } + if params.Description.String != req.Description { + t.Errorf("expected description %q, got %q", req.Description, params.Description.String) + } +} diff --git a/internal/db/product/db.go b/internal/product/db.gen.go similarity index 100% rename from internal/db/product/db.go rename to internal/product/db.gen.go diff --git a/internal/product/handler.go b/internal/product/handler.go new file mode 100644 index 0000000..95dbb8b --- /dev/null +++ b/internal/product/handler.go @@ -0,0 +1,159 @@ +package product + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "products/internal" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +func NewProductHandler(db DBTX) Handler { + queries := &Queries{ + db: db, + } + return &productHandler{ + queries: queries, + } +} + +type productHandler struct { + queries Querier +} + +func (h *productHandler) CreateProduct(w http.ResponseWriter, r *http.Request) { + var req createProductRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" || req.PlatformID == 0 { + http.Error(w, "Name and platform ID are required", http.StatusBadRequest) + return + } + + contextWithTimeOut, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + if err := h.queries.CreateProduct(contextWithTimeOut, req.ToParams()); err != nil { + http.Error(w, "Failed to create product", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) +} + +func (h *productHandler) DeleteProduct(w http.ResponseWriter, r *http.Request) { + id, ok := internal.GetIntFromRequestPath("id", r) + if !ok { + http.Error(w, "Invalid product ID", http.StatusBadRequest) + return + } + contextWithTimeOut, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + if _, err := h.queries.DeleteProduct(contextWithTimeOut, id); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.Error(w, "Product not found", http.StatusNotFound) + return + } + http.Error(w, "Failed to delete product", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *productHandler) UpdateProduct(w http.ResponseWriter, r *http.Request) { + id, ok := internal.GetIntFromRequestPath("id", r) + if !ok { + http.Error(w, "Invalid product ID", http.StatusBadRequest) + return + } + + var req updateProductRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" || req.PlatformID == 0 { + http.Error(w, "Name and platform ID are required", http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + if _, err := h.queries.UpdateProduct(ctx, req.ToParams(id)); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.Error(w, "Product not found", http.StatusNotFound) + return + } + http.Error(w, "Failed to update product", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +// GetProductsByPlatform fetches products by platform ID. +func (h *productHandler) GetProductsByPlatform(w http.ResponseWriter, r *http.Request) { + platformID, ok := internal.GetIntFromRequestPath("platform_id", r) + if !ok { + http.Error(w, "Invalid platform ID", http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + products, err := h.queries.GetProductsByPlatform(ctx, platformID) + if err != nil { + http.Error(w, "Failed to fetch products", http.StatusInternalServerError) + return + } + + if products == nil { + products = []Product{} + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(products); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} + +// GetProductById fetches a single product by ID. +func (h *productHandler) GetProductById(w http.ResponseWriter, r *http.Request) { + id, ok := internal.GetIntFromRequestPath("id", r) + if !ok { + http.Error(w, "Invalid product ID", http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + product, err := h.queries.GetProductById(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.Error(w, "Product not found", http.StatusNotFound) + return + } + http.Error(w, "Failed to fetch product", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(product); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} diff --git a/api/product/product_test.go b/internal/product/handler_test.go similarity index 84% rename from api/product/product_test.go rename to internal/product/handler_test.go index 4a36a1e..24743b4 100644 --- a/api/product/product_test.go +++ b/internal/product/handler_test.go @@ -1,4 +1,4 @@ -package productHandler +package product import ( "bytes" @@ -7,7 +7,6 @@ import ( "errors" "net/http" "net/http/httptest" - "products/internal/db/product" "testing" "time" @@ -15,14 +14,14 @@ import ( ) type mockProductQuerier struct { - createProductFunc func(ctx context.Context, arg product.CreateProductParams) error + createProductFunc func(ctx context.Context, arg CreateProductParams) error deleteProductFunc func(ctx context.Context, id int32) (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) (int32, error) + getProductByIdFunc func(ctx context.Context, id int32) (Product, error) + getProductsByPlatformFunc func(ctx context.Context, platformID int32) ([]Product, error) + updateProductFunc func(ctx context.Context, arg UpdateProductParams) (int32, error) } -func (m *mockProductQuerier) CreateProduct(ctx context.Context, arg product.CreateProductParams) error { +func (m *mockProductQuerier) CreateProduct(ctx context.Context, arg CreateProductParams) error { return m.createProductFunc(ctx, arg) } @@ -30,15 +29,15 @@ func (m *mockProductQuerier) DeleteProduct(ctx context.Context, id int32) (int32 return m.deleteProductFunc(ctx, id) } -func (m *mockProductQuerier) GetProductById(ctx context.Context, id int32) (product.Product, error) { +func (m *mockProductQuerier) GetProductById(ctx context.Context, id int32) (Product, error) { return m.getProductByIdFunc(ctx, id) } -func (m *mockProductQuerier) GetProductsByPlatform(ctx context.Context, platformID int32) ([]product.Product, error) { +func (m *mockProductQuerier) GetProductsByPlatform(ctx context.Context, platformID int32) ([]Product, error) { return m.getProductsByPlatformFunc(ctx, platformID) } -func (m *mockProductQuerier) UpdateProduct(ctx context.Context, arg product.UpdateProductParams) (int32, error) { +func (m *mockProductQuerier) UpdateProduct(ctx context.Context, arg UpdateProductParams) (int32, error) { return m.updateProductFunc(ctx, arg) } @@ -51,12 +50,12 @@ func TestCreateProduct(t *testing.T) { }{ { name: "Success", - requestBody: CreateProductRequest{ + requestBody: createProductRequest{ PlatformID: 1, Name: "Test Product", }, mockSetup: func(m *mockProductQuerier) { - m.createProductFunc = func(ctx context.Context, arg product.CreateProductParams) error { + m.createProductFunc = func(ctx context.Context, arg CreateProductParams) error { if arg.Name != "Test Product" { return errors.New("unexpected name") } @@ -76,12 +75,12 @@ func TestCreateProduct(t *testing.T) { }, { name: "DB Failure", - requestBody: CreateProductRequest{ + requestBody: createProductRequest{ PlatformID: 1, Name: "Fail Product", }, mockSetup: func(m *mockProductQuerier) { - m.createProductFunc = func(ctx context.Context, arg product.CreateProductParams) error { + m.createProductFunc = func(ctx context.Context, arg CreateProductParams) error { return errors.New("db error") } }, @@ -89,7 +88,7 @@ func TestCreateProduct(t *testing.T) { }, { name: "Missing Name", - requestBody: CreateProductRequest{ + requestBody: createProductRequest{ PlatformID: 1, }, mockSetup: func(m *mockProductQuerier) {}, @@ -97,19 +96,28 @@ func TestCreateProduct(t *testing.T) { }, { name: "Missing PlatformID", - requestBody: CreateProductRequest{ + requestBody: createProductRequest{ Name: "Test Product", }, mockSetup: func(m *mockProductQuerier) {}, expectedStatus: http.StatusBadRequest, }, + { + name: "Whitespace Name", + requestBody: createProductRequest{ + PlatformID: 1, + Name: " ", + }, + mockSetup: func(m *mockProductQuerier) {}, + expectedStatus: http.StatusBadRequest, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mock := &mockProductQuerier{} tt.mockSetup(mock) - h := NewProductHandler(mock) + h := &productHandler{queries: mock} var body []byte if s, ok := tt.requestBody.(string); ok { @@ -216,7 +224,7 @@ func TestDeleteProduct(t *testing.T) { t.Run(tt.name, func(t *testing.T) { mock := &mockProductQuerier{} tt.mockSetup(mock) - h := NewProductHandler(mock) + h := &productHandler{queries: mock} req := httptest.NewRequest(http.MethodDelete, "/api/products/"+tt.id, nil) req.SetPathValue("id", tt.id) @@ -243,11 +251,11 @@ func TestGetProductsByPlatform(t *testing.T) { name: "Success", platformID: "1", mockSetup: func(m *mockProductQuerier) { - m.getProductsByPlatformFunc = func(ctx context.Context, platformID int32) ([]product.Product, error) { + m.getProductsByPlatformFunc = func(ctx context.Context, platformID int32) ([]Product, error) { if platformID != 1 { return nil, errors.New("unexpected platform id") } - return []product.Product{ + return []Product{ {ID: 1, PlatformID: 1, Name: "Product 1"}, {ID: 2, PlatformID: 1, Name: "Product 2"}, }, nil @@ -260,7 +268,7 @@ func TestGetProductsByPlatform(t *testing.T) { name: "Empty list (Nil guard)", platformID: "1", mockSetup: func(m *mockProductQuerier) { - m.getProductsByPlatformFunc = func(ctx context.Context, platformID int32) ([]product.Product, error) { + m.getProductsByPlatformFunc = func(ctx context.Context, platformID int32) ([]Product, error) { return nil, nil } }, @@ -277,7 +285,7 @@ func TestGetProductsByPlatform(t *testing.T) { name: "DB Failure", platformID: "1", mockSetup: func(m *mockProductQuerier) { - m.getProductsByPlatformFunc = func(ctx context.Context, platformID int32) ([]product.Product, error) { + m.getProductsByPlatformFunc = func(ctx context.Context, platformID int32) ([]Product, error) { return nil, errors.New("db error") } }, @@ -289,7 +297,7 @@ func TestGetProductsByPlatform(t *testing.T) { t.Run(tt.name, func(t *testing.T) { mock := &mockProductQuerier{} tt.mockSetup(mock) - h := NewProductHandler(mock) + h := &productHandler{queries: mock} req := httptest.NewRequest(http.MethodGet, "/api/platforms/"+tt.platformID+"/products", nil) req.SetPathValue("platform_id", tt.platformID) @@ -302,7 +310,7 @@ func TestGetProductsByPlatform(t *testing.T) { } if tt.expectedStatus == http.StatusOK { - var products []product.Product + var products []Product if err := json.NewDecoder(rr.Body).Decode(&products); err != nil { t.Fatalf("failed to decode response: %v", err) } @@ -328,11 +336,11 @@ func TestGetProductById(t *testing.T) { name: "Success", id: "1", mockSetup: func(m *mockProductQuerier) { - m.getProductByIdFunc = func(ctx context.Context, id int32) (product.Product, error) { + m.getProductByIdFunc = func(ctx context.Context, id int32) (Product, error) { if id != 1 { - return product.Product{}, errors.New("unexpected id") + return Product{}, errors.New("unexpected id") } - return product.Product{ID: 1, Name: "Test Product"}, nil + return Product{ID: 1, Name: "Test Product"}, nil } }, expectedStatus: http.StatusOK, @@ -341,8 +349,8 @@ func TestGetProductById(t *testing.T) { name: "Not Found", id: "999", mockSetup: func(m *mockProductQuerier) { - m.getProductByIdFunc = func(ctx context.Context, id int32) (product.Product, error) { - return product.Product{}, pgx.ErrNoRows + m.getProductByIdFunc = func(ctx context.Context, id int32) (Product, error) { + return Product{}, pgx.ErrNoRows } }, expectedStatus: http.StatusNotFound, @@ -357,8 +365,8 @@ func TestGetProductById(t *testing.T) { name: "DB Failure", id: "1", mockSetup: func(m *mockProductQuerier) { - m.getProductByIdFunc = func(ctx context.Context, id int32) (product.Product, error) { - return product.Product{}, errors.New("db error") + m.getProductByIdFunc = func(ctx context.Context, id int32) (Product, error) { + return Product{}, errors.New("db error") } }, expectedStatus: http.StatusInternalServerError, @@ -369,7 +377,7 @@ func TestGetProductById(t *testing.T) { t.Run(tt.name, func(t *testing.T) { mock := &mockProductQuerier{} tt.mockSetup(mock) - h := NewProductHandler(mock) + h := &productHandler{queries: mock} req := httptest.NewRequest(http.MethodGet, "/api/products/"+tt.id, nil) req.SetPathValue("id", tt.id) @@ -401,13 +409,13 @@ func TestUpdateProduct(t *testing.T) { { name: "Success", id: "1", - requestBody: UpdateProductRequest{ + requestBody: updateProductRequest{ PlatformID: 2, Name: "Updated Product", Description: "Updated Description", }, mockSetup: func(m *mockProductQuerier) { - m.updateProductFunc = func(ctx context.Context, arg product.UpdateProductParams) (int32, error) { + m.updateProductFunc = func(ctx context.Context, arg UpdateProductParams) (int32, error) { if arg.ID != 1 { return 0, errors.New("unexpected id") } @@ -425,7 +433,7 @@ func TestUpdateProduct(t *testing.T) { { name: "Invalid ID", id: "abc", - requestBody: UpdateProductRequest{PlatformID: 1, Name: "Test"}, + requestBody: updateProductRequest{PlatformID: 1, Name: "Test"}, mockSetup: func(m *mockProductQuerier) {}, expectedStatus: http.StatusBadRequest, }, @@ -439,7 +447,7 @@ func TestUpdateProduct(t *testing.T) { { name: "Missing Name", id: "1", - requestBody: UpdateProductRequest{ + requestBody: updateProductRequest{ PlatformID: 1, }, mockSetup: func(m *mockProductQuerier) {}, @@ -448,7 +456,7 @@ func TestUpdateProduct(t *testing.T) { { name: "Missing PlatformID", id: "1", - requestBody: UpdateProductRequest{ + requestBody: updateProductRequest{ Name: "Test", }, mockSetup: func(m *mockProductQuerier) {}, @@ -457,21 +465,21 @@ func TestUpdateProduct(t *testing.T) { { name: "Zero ID", id: "0", - requestBody: UpdateProductRequest{PlatformID: 1, Name: "Test"}, + requestBody: updateProductRequest{PlatformID: 1, Name: "Test"}, mockSetup: func(m *mockProductQuerier) {}, expectedStatus: http.StatusBadRequest, }, { name: "Negative ID", id: "-1", - requestBody: UpdateProductRequest{PlatformID: 1, Name: "Test"}, + requestBody: updateProductRequest{PlatformID: 1, Name: "Test"}, mockSetup: func(m *mockProductQuerier) {}, expectedStatus: http.StatusBadRequest, }, { name: "Whitespace Name", id: "1", - requestBody: UpdateProductRequest{ + requestBody: updateProductRequest{ PlatformID: 1, Name: " ", }, @@ -481,12 +489,12 @@ func TestUpdateProduct(t *testing.T) { { name: "Timeout verification", id: "1", - requestBody: UpdateProductRequest{ + requestBody: updateProductRequest{ PlatformID: 1, Name: "Test", }, mockSetup: func(m *mockProductQuerier) { - m.updateProductFunc = func(ctx context.Context, arg product.UpdateProductParams) (int32, error) { + m.updateProductFunc = func(ctx context.Context, arg UpdateProductParams) (int32, error) { deadline, ok := ctx.Deadline() if !ok { return 0, errors.New("deadline not set") @@ -503,12 +511,12 @@ func TestUpdateProduct(t *testing.T) { { name: "DB Failure", id: "1", - requestBody: UpdateProductRequest{ + requestBody: updateProductRequest{ PlatformID: 1, Name: "Fail", }, mockSetup: func(m *mockProductQuerier) { - m.updateProductFunc = func(ctx context.Context, arg product.UpdateProductParams) (int32, error) { + m.updateProductFunc = func(ctx context.Context, arg UpdateProductParams) (int32, error) { return 0, errors.New("db error") } }, @@ -517,12 +525,12 @@ func TestUpdateProduct(t *testing.T) { { name: "Not Found", id: "999", - requestBody: UpdateProductRequest{ + requestBody: updateProductRequest{ PlatformID: 1, Name: "Not Found", }, mockSetup: func(m *mockProductQuerier) { - m.updateProductFunc = func(ctx context.Context, arg product.UpdateProductParams) (int32, error) { + m.updateProductFunc = func(ctx context.Context, arg UpdateProductParams) (int32, error) { return 0, pgx.ErrNoRows } }, @@ -534,7 +542,7 @@ func TestUpdateProduct(t *testing.T) { t.Run(tt.name, func(t *testing.T) { mock := &mockProductQuerier{} tt.mockSetup(mock) - h := NewProductHandler(mock) + h := &productHandler{queries: mock} var body []byte if s, ok := tt.requestBody.(string); ok { diff --git a/internal/product/interface.go b/internal/product/interface.go new file mode 100644 index 0000000..4ad54d9 --- /dev/null +++ b/internal/product/interface.go @@ -0,0 +1,11 @@ +package product + +import "net/http" + +type Handler interface { + CreateProduct(w http.ResponseWriter, r *http.Request) + GetProductsByPlatform(w http.ResponseWriter, r *http.Request) + GetProductById(w http.ResponseWriter, r *http.Request) + UpdateProduct(w http.ResponseWriter, r *http.Request) + DeleteProduct(w http.ResponseWriter, r *http.Request) +} diff --git a/internal/db/product/models.go b/internal/product/models.gen.go similarity index 100% rename from internal/db/product/models.go rename to internal/product/models.gen.go diff --git a/queries/products.sql b/internal/product/products.sql similarity index 100% rename from queries/products.sql rename to internal/product/products.sql diff --git a/internal/db/product/products.sql.go b/internal/product/products.sql.gen.go similarity index 100% rename from internal/db/product/products.sql.go rename to internal/product/products.sql.gen.go diff --git a/internal/db/product/querier.go b/internal/product/querier.gen.go similarity index 100% rename from internal/db/product/querier.go rename to internal/product/querier.gen.go diff --git a/internal/product/request.go b/internal/product/request.go new file mode 100644 index 0000000..e01ecdd --- /dev/null +++ b/internal/product/request.go @@ -0,0 +1,50 @@ +package product + +import ( + "time" + + "github.com/jackc/pgx/v5/pgtype" +) + +type createProductRequest struct { + Name string `json:"name"` + PlatformID int32 `json:"platform_id"` + Description string `json:"description"` +} + +func (r *createProductRequest) ToParams() CreateProductParams { + return CreateProductParams{ + Name: r.Name, + PlatformID: r.PlatformID, + Description: pgtype.Text{ + Valid: r.Description != "", + String: r.Description, + }, + Timestamp: pgtype.Timestamptz{ + Valid: true, + Time: time.Now().UTC(), + }, + } +} + +type updateProductRequest struct { + PlatformID int32 `json:"platform_id"` + Name string `json:"name"` + Description string `json:"description"` +} + +func (r *updateProductRequest) ToParams(id int32) UpdateProductParams { + return UpdateProductParams{ + ID: id, + PlatformID: r.PlatformID, + Name: r.Name, + Description: pgtype.Text{ + Valid: r.Description != "", + String: r.Description, + }, + UpdatedAt: pgtype.Timestamptz{ + Valid: true, + Time: time.Now().UTC(), + }, + } +} diff --git a/internal/product/request_test.go b/internal/product/request_test.go new file mode 100644 index 0000000..a210c16 --- /dev/null +++ b/internal/product/request_test.go @@ -0,0 +1,104 @@ +package product + +import ( + "testing" + "time" +) + +func TestCreateProductRequest_ToParams(t *testing.T) { + req := createProductRequest{ + Name: "Test Product", + PlatformID: 1, + Description: "Test Description", + } + + params := req.ToParams() + + if params.Name != req.Name { + t.Errorf("expected name %q, got %q", req.Name, params.Name) + } + if params.PlatformID != req.PlatformID { + t.Errorf("expected platform_id %d, got %d", req.PlatformID, params.PlatformID) + } + if !params.Description.Valid { + t.Error("expected description to be valid") + } + if params.Description.String != req.Description { + t.Errorf("expected description %q, got %q", req.Description, params.Description.String) + } + if !params.Timestamp.Valid { + t.Error("expected timestamp to be valid") + } + diff := time.Since(params.Timestamp.Time) + if diff < 0 { + diff = -diff + } + if diff > 2*time.Second { + t.Errorf("timestamp too far from now: %v", diff) + } +} + +func TestCreateProductRequest_ToParams_EmptyDescription(t *testing.T) { + req := createProductRequest{ + Name: "Test Product", + PlatformID: 1, + Description: "", + } + + params := req.ToParams() + + if params.Description.Valid { + t.Error("expected description to be invalid") + } +} + +func TestUpdateProductRequest_ToParams(t *testing.T) { + req := updateProductRequest{ + PlatformID: 2, + Name: "Updated Product", + Description: "Updated Description", + } + id := int32(10) + + params := req.ToParams(id) + + if params.ID != id { + t.Errorf("expected id %d, got %d", id, params.ID) + } + if params.PlatformID != req.PlatformID { + t.Errorf("expected platform_id %d, got %d", req.PlatformID, params.PlatformID) + } + if params.Name != req.Name { + t.Errorf("expected name %q, got %q", req.Name, params.Name) + } + if !params.Description.Valid { + t.Error("expected description to be valid") + } + if params.Description.String != req.Description { + t.Errorf("expected description %q, got %q", req.Description, params.Description.String) + } + if !params.UpdatedAt.Valid { + t.Error("expected updated_at to be valid") + } + diff := time.Since(params.UpdatedAt.Time) + if diff < 0 { + diff = -diff + } + if diff > 2*time.Second { + t.Errorf("updated_at too far from now: %v", diff) + } +} + +func TestUpdateProductRequest_ToParams_EmptyDescription(t *testing.T) { + req := updateProductRequest{ + PlatformID: 2, + Name: "Updated Product", + Description: "", + } + + params := req.ToParams(10) + + if params.Description.Valid { + t.Error("expected description to be invalid") + } +} diff --git a/main.go b/main.go index 4ae3f5d..ba870e8 100644 --- a/main.go +++ b/main.go @@ -14,7 +14,6 @@ import ( "time" internalConfig "products/internal/config" - "products/internal/db" "products/router" "github.com/jackc/pgx/v5/pgxpool" @@ -28,10 +27,7 @@ func main() { if err != nil { log.Fatal(err) } - - if dbPool, ok := dbConn.(*pgxpool.Pool); ok { - defer dbPool.Close() - } + defer dbConn.Close() r := router.SetupRouter(dbConn) addr := internalConfig.GetConfigValue("ADDRESS") @@ -57,7 +53,7 @@ func main() { } -func getDbConn() (db.DBTX, error) { +func getDbConn() (*pgxpool.Pool, error) { connStr, err := getConnStr() if err != nil { return nil, err diff --git a/router/router.go b/router/router.go index a8390bb..873888f 100644 --- a/router/router.go +++ b/router/router.go @@ -3,13 +3,11 @@ package router 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" + "products/internal/platform" + "products/internal/product" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -20,8 +18,6 @@ func SetupRouter(dbConn db.DBTX) http.Handler { slog.Debug("Setting up router") router := chi.NewRouter() - store := db.New(dbConn) - router.Use(internal.StructuredLogger(slog.Default())) router.Use(middleware.Recoverer) router.Use(middleware.Compress(5)) @@ -34,8 +30,8 @@ func SetupRouter(dbConn db.DBTX) http.Handler { })) registerSystemCallHandler(router) - registerPlatformCallHandler(store.Platform, router) - registerProductCallHandler(store.Product, router) + registerPlatformCallHandler(platform.NewPlatformHandler(dbConn), router) + registerProductCallHandler(product.NewProductHandler(dbConn), router) slog.Debug("Router setup complete") return router } @@ -45,8 +41,7 @@ func registerSystemCallHandler(r *chi.Mux) { r.Get("/api/time", h.GetTime) } -func registerPlatformCallHandler(q platformDb.Querier, r *chi.Mux) { - handler := platformHandler.NewPlatformHandler(q) +func registerPlatformCallHandler(handler platform.Handler, r *chi.Mux) { r.Route("/api/platforms", func(u chi.Router) { u.Post("/", handler.CreatePlatform) u.Get("/", handler.GetPlatforms) @@ -56,8 +51,7 @@ func registerPlatformCallHandler(q platformDb.Querier, r *chi.Mux) { }) } -func registerProductCallHandler(q productDb.Querier, r *chi.Mux) { - handler := productHandler.NewProductHandler(q) +func registerProductCallHandler(handler product.Handler, r *chi.Mux) { r.Route("/api/products", func(u chi.Router) { u.Post("/", handler.CreateProduct) u.Get("/{id}", handler.GetProductById) diff --git a/sqlc.yml b/sqlc.yml index ad67b35..5d7b476 100644 --- a/sqlc.yml +++ b/sqlc.yml @@ -1,21 +1,27 @@ version: "2" sql: - engine: "postgresql" - queries: "queries/platforms.sql" + queries: "internal/platform/platforms.sql" schema: "schema.sql" gen: go: - out: "internal/db/platform" + out: "internal/platform" sql_package: "pgx/v5" - output_models_file_name: "models.go" + output_models_file_name: "models.gen.go" + output_db_file_name: "db.gen.go" + output_files_suffix: ".gen.go" + output_querier_file_name: "querier.gen.go" emit_interface: true - engine: "postgresql" - queries: "queries/products.sql" + queries: "internal/product/products.sql" schema: "schema.sql" gen: go: - out: "internal/db/product" + out: "internal/product" sql_package: "pgx/v5" - output_models_file_name: "models.go" - emit_interface: true \ No newline at end of file + output_models_file_name: "models.gen.go" + output_db_file_name: "db.gen.go" + output_files_suffix: ".gen.go" + output_querier_file_name: "querier.gen.go" + emit_interface: true