diff --git a/api/components/parameters/Page.yaml b/api/components/parameters/Page.yaml new file mode 100644 index 0000000..d54ce5c --- /dev/null +++ b/api/components/parameters/Page.yaml @@ -0,0 +1,7 @@ +name: page +in: query +schema: + type: integer + minimum: 1 + default: 1 + diff --git a/api/components/parameters/PageSize.yaml b/api/components/parameters/PageSize.yaml new file mode 100644 index 0000000..aa25bd2 --- /dev/null +++ b/api/components/parameters/PageSize.yaml @@ -0,0 +1,8 @@ +name: pageSize +in: query +schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + diff --git a/api/components/parameters/ProductID.yaml b/api/components/parameters/ProductID.yaml new file mode 100644 index 0000000..2d4075f --- /dev/null +++ b/api/components/parameters/ProductID.yaml @@ -0,0 +1,7 @@ +name: id +in: path +required: true +schema: + type: integer + format: int64 + diff --git a/api/components/parameters/Q.yaml b/api/components/parameters/Q.yaml new file mode 100644 index 0000000..aa7de63 --- /dev/null +++ b/api/components/parameters/Q.yaml @@ -0,0 +1,6 @@ +name: q +in: query +schema: + type: string + minLength: 3 + diff --git a/api/components/requestBodies/ProductCreate.yaml b/api/components/requestBodies/ProductCreate.yaml new file mode 100644 index 0000000..8c30a97 --- /dev/null +++ b/api/components/requestBodies/ProductCreate.yaml @@ -0,0 +1,7 @@ +description: Product creation payload +required: true +content: + application/json: + schema: + $ref: '../../schemas/ProductCreate.yaml' + diff --git a/api/components/responses/Error.yaml b/api/components/responses/Error.yaml new file mode 100644 index 0000000..29ced01 --- /dev/null +++ b/api/components/responses/Error.yaml @@ -0,0 +1,5 @@ +description: Error response +content: + application/json: + schema: + $ref: '../../schemas/Error.yaml' diff --git a/api/openapi.yaml b/api/openapi.yaml index a0d0085..e269e51 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2,14 +2,17 @@ openapi: 3.0.0 info: title: Marketplace Demo API version: 1.0.0 +tags: + - name: Products + description: Product query and management endpoints paths: /products/{id}: - $ref: './paths/products_by_id.yaml' + $ref: './paths/products/item.yaml' /products/search: - $ref: './paths/products_search.yaml' + $ref: './paths/products/search.yaml' /products: - $ref: './paths/products_create.yaml' + $ref: './paths/products/collection.yaml' components: schemas: @@ -21,11 +24,3 @@ components: $ref: './schemas/ProductList.yaml' Error: $ref: './schemas/Error.yaml' - - responses: - ErrorResponse: - description: Error response - content: - application/json: - schema: - $ref: '#/components/schemas/Error' diff --git a/api/paths/products_create.yaml b/api/paths/products/collection.yaml similarity index 56% rename from api/paths/products_create.yaml rename to api/paths/products/collection.yaml index b2b79bf..dc3639f 100644 --- a/api/paths/products_create.yaml +++ b/api/paths/products/collection.yaml @@ -1,11 +1,8 @@ post: + tags: [Products] operationId: CreateProduct requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ProductCreate' + $ref: '../../components/requestBodies/ProductCreate.yaml' responses: '201': description: Created product @@ -14,5 +11,4 @@ post: schema: $ref: '#/components/schemas/Product' '400': - $ref: '#/components/responses/ErrorResponse' - + $ref: '../../components/responses/Error.yaml' diff --git a/api/paths/products/item.yaml b/api/paths/products/item.yaml new file mode 100644 index 0000000..15f8c92 --- /dev/null +++ b/api/paths/products/item.yaml @@ -0,0 +1,48 @@ +get: + tags: [Products] + operationId: GetProductByID + parameters: + - $ref: '../../components/parameters/ProductID.yaml' + responses: + '200': + description: Single product + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + '400': + $ref: '../../components/responses/Error.yaml' + '404': + $ref: '../../components/responses/Error.yaml' + +put: + tags: [Products] + operationId: UpdateProduct + parameters: + - $ref: '../../components/parameters/ProductID.yaml' + requestBody: + $ref: '../../components/requestBodies/ProductCreate.yaml' + responses: + '200': + description: Updated product + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + '400': + $ref: '../../components/responses/Error.yaml' + '404': + $ref: '../../components/responses/Error.yaml' + +delete: + tags: [Products] + operationId: DeleteProductByID + parameters: + - $ref: '../../components/parameters/ProductID.yaml' + responses: + '204': + description: Deleted + '400': + $ref: '../../components/responses/Error.yaml' + '404': + $ref: '../../components/responses/Error.yaml' diff --git a/api/paths/products/search.yaml b/api/paths/products/search.yaml new file mode 100644 index 0000000..87eec88 --- /dev/null +++ b/api/paths/products/search.yaml @@ -0,0 +1,16 @@ +get: + tags: [Products] + operationId: SearchProducts + parameters: + - $ref: '../../components/parameters/Q.yaml' + - $ref: '../../components/parameters/Page.yaml' + - $ref: '../../components/parameters/PageSize.yaml' + responses: + '200': + description: List of products + content: + application/json: + schema: + $ref: '#/components/schemas/ProductList' + '400': + $ref: '../../components/responses/Error.yaml' diff --git a/api/paths/products_by_id.yaml b/api/paths/products_by_id.yaml deleted file mode 100644 index 4c1a149..0000000 --- a/api/paths/products_by_id.yaml +++ /dev/null @@ -1,38 +0,0 @@ -get: - operationId: GetProductByID - parameters: - - in: path - name: id - required: true - schema: - type: integer - format: int64 - responses: - '200': - description: Single product - content: - application/json: - schema: - $ref: '#/components/schemas/Product' - '400': - $ref: '#/components/responses/ErrorResponse' - '404': - $ref: '#/components/responses/ErrorResponse' - -delete: - operationId: DeleteProductByID - parameters: - - in: path - name: id - required: true - schema: - type: integer - format: int64 - responses: - '204': - description: Deleted - '400': - $ref: '#/components/responses/ErrorResponse' - '404': - $ref: '#/components/responses/ErrorResponse' - diff --git a/api/paths/products_search.yaml b/api/paths/products_search.yaml deleted file mode 100644 index cf25f46..0000000 --- a/api/paths/products_search.yaml +++ /dev/null @@ -1,27 +0,0 @@ -get: - operationId: SearchProducts - parameters: - - in: query - name: q - schema: - type: string - minLength: 3 - - in: query - name: page - schema: - type: integer - default: 1 - - in: query - name: pageSize - schema: - type: integer - default: 20 - responses: - '200': - description: List of products - content: - application/json: - schema: - $ref: '#/components/schemas/ProductList' - '400': - $ref: '#/components/responses/ErrorResponse' diff --git a/api/responses/ErrorResponse.yaml b/api/responses/ErrorResponse.yaml deleted file mode 100644 index e09b208..0000000 --- a/api/responses/ErrorResponse.yaml +++ /dev/null @@ -1,9 +0,0 @@ -description: Error response -content: - application/json: - schema: - type: object - properties: - error: - $ref: '../schemas/Error.yaml' - required: [error] diff --git a/api/responses/ProductListResponse.yaml b/api/responses/ProductListResponse.yaml deleted file mode 100644 index 74b1bd2..0000000 --- a/api/responses/ProductListResponse.yaml +++ /dev/null @@ -1,9 +0,0 @@ -description: List of products response -content: - application/json: - schema: - type: object - properties: - data: - $ref: '../schemas/ProductList.yaml' - required: [data] diff --git a/api/responses/ProductResponse.yaml b/api/responses/ProductResponse.yaml deleted file mode 100644 index 9955959..0000000 --- a/api/responses/ProductResponse.yaml +++ /dev/null @@ -1,9 +0,0 @@ -description: Single product response -content: - application/json: - schema: - type: object - properties: - data: - $ref: '../schemas/Product.yaml' - required: [data] diff --git a/apps/product-query-svc/adapters/inbound/http/handler_product_write.go b/apps/product-query-svc/adapters/inbound/http/handler_product_write.go index dbab212..466a51f 100644 --- a/apps/product-query-svc/adapters/inbound/http/handler_product_write.go +++ b/apps/product-query-svc/adapters/inbound/http/handler_product_write.go @@ -1,12 +1,12 @@ package httpadapter import ( - "encoding/json" - "math" - "net/http" - "errors" + "encoding/json" + "errors" + "math" + "net/http" - "github.com/fightingBald/GoTuto/apps/product-query-svc/domain" + "github.com/fightingBald/GoTuto/apps/product-query-svc/domain" ) // DeleteProductByID implements OpenAPI operation: DELETE /products/{id} @@ -15,40 +15,73 @@ func (s *Server) DeleteProductByID(w http.ResponseWriter, r *http.Request, id in writeError(w, http.StatusBadRequest, "INVALID_ID", "id must be a positive integer") return } - if err := s.svc.DeleteProduct(r.Context(), id); err != nil { - if errors.Is(err, domain.ErrNotFound) { - writeError(w, http.StatusNotFound, "NOT_FOUND", err.Error()) - return - } - writeError(w, http.StatusInternalServerError, "INTERNAL", err.Error()) - return - } - w.WriteHeader(http.StatusNoContent) + if err := s.svc.DeleteProduct(r.Context(), id); err != nil { + if errors.Is(err, domain.ErrNotFound) { + writeError(w, http.StatusNotFound, "NOT_FOUND", err.Error()) + return + } + writeError(w, http.StatusInternalServerError, "INTERNAL", err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) } // CreateProduct implements POST /products func (s *Server) CreateProduct(w http.ResponseWriter, r *http.Request) { - var in ProductCreate - if err := json.NewDecoder(r.Body).Decode(&in); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body") - return - } - // 价格从美元浮点转分(四舍五入) - cents := int64(math.Round(float64(in.Price) * 100.0)) - p, err := domain.NewProduct(in.Name, cents, nil) - if err != nil { - writeError(w, http.StatusBadRequest, "VALIDATION", err.Error()) - return - } - id, err := s.svc.CreateProduct(r.Context(), p) - if err != nil { - if errors.Is(err, domain.ErrValidation) { - writeError(w, http.StatusBadRequest, "VALIDATION", err.Error()) - return - } - writeError(w, http.StatusInternalServerError, "INTERNAL", err.Error()) - return - } - out := Product{Id: id, Name: p.Name, Price: in.Price} - writeJSON(w, http.StatusCreated, out) + var in CreateProductJSONBody + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body") + return + } + cents := int64(math.Round(float64(in.Price) * 100.0)) + p, err := domain.NewProduct(in.Name, cents, nil) + if err != nil { + writeError(w, http.StatusBadRequest, "VALIDATION", err.Error()) + return + } + id, err := s.svc.CreateProduct(r.Context(), p) + if err != nil { + if errors.Is(err, domain.ErrValidation) { + writeError(w, http.StatusBadRequest, "VALIDATION", err.Error()) + return + } + writeError(w, http.StatusInternalServerError, "INTERNAL", err.Error()) + return + } + out := Product{Id: id, Name: p.Name, Price: float32(cents) / 100.0} + writeJSON(w, http.StatusCreated, out) +} + +// UpdateProduct implements OpenAPI operation: PUT /products/{id} +func (s *Server) UpdateProduct(w http.ResponseWriter, r *http.Request, id int64) { + if id <= 0 { + writeError(w, http.StatusBadRequest, "INVALID_ID", "id must be a positive integer") + return + } + var in UpdateProductJSONBody + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body") + return + } + cents := int64(math.Round(float64(in.Price) * 100.0)) + p, err := domain.NewProduct(in.Name, cents, nil) + if err != nil { + writeError(w, http.StatusBadRequest, "VALIDATION", err.Error()) + return + } + p.ID = id + updated, err := s.svc.UpdateProduct(r.Context(), p) + if err != nil { + switch { + case errors.Is(err, domain.ErrValidation): + writeError(w, http.StatusBadRequest, "VALIDATION", err.Error()) + case errors.Is(err, domain.ErrNotFound): + writeError(w, http.StatusNotFound, "NOT_FOUND", err.Error()) + default: + writeError(w, http.StatusInternalServerError, "INTERNAL", err.Error()) + } + return + } + out := Product{Id: updated.ID, Name: updated.Name, Price: float32(updated.Price) / 100.0} + writeJSON(w, http.StatusOK, out) } diff --git a/apps/product-query-svc/adapters/inbound/http/marketplaceapi.gen.go b/apps/product-query-svc/adapters/inbound/http/marketplaceapi.gen.go index 5a8a424..2988fe4 100644 --- a/apps/product-query-svc/adapters/inbound/http/marketplaceapi.gen.go +++ b/apps/product-query-svc/adapters/inbound/http/marketplaceapi.gen.go @@ -21,16 +21,6 @@ import ( strictnethttp "github.com/oapi-codegen/runtime/strictmiddleware/nethttp" ) -// Error defines model for Error. -type Error struct { - Code string `json:"code"` - Details *[]struct { - Field *string `json:"field,omitempty"` - Reason *string `json:"reason,omitempty"` - } `json:"details,omitempty"` - Message string `json:"message"` -} - // Product defines model for Product. type Product struct { Id int64 `json:"id"` @@ -38,12 +28,6 @@ type Product struct { Price float32 `json:"price"` } -// ProductCreate defines model for ProductCreate. -type ProductCreate struct { - Name string `json:"name"` - Price float32 `json:"price"` -} - // ProductList defines model for ProductList. type ProductList struct { Items []Product `json:"items"` @@ -52,8 +36,11 @@ type ProductList struct { Total int `json:"total"` } -// ErrorResponse defines model for ErrorResponse. -type ErrorResponse = Error +// CreateProductJSONBody defines parameters for CreateProduct. +type CreateProductJSONBody struct { + Name string `json:"name"` + Price float32 `json:"price"` +} // SearchProductsParams defines parameters for SearchProducts. type SearchProductsParams struct { @@ -62,8 +49,17 @@ type SearchProductsParams struct { PageSize *int `form:"pageSize,omitempty" json:"pageSize,omitempty"` } +// UpdateProductJSONBody defines parameters for UpdateProduct. +type UpdateProductJSONBody struct { + Name string `json:"name"` + Price float32 `json:"price"` +} + // CreateProductJSONRequestBody defines body for CreateProduct for application/json ContentType. -type CreateProductJSONRequestBody = ProductCreate +type CreateProductJSONRequestBody CreateProductJSONBody + +// UpdateProductJSONRequestBody defines body for UpdateProduct for application/json ContentType. +type UpdateProductJSONRequestBody UpdateProductJSONBody // ServerInterface represents all server handlers. type ServerInterface interface { @@ -79,6 +75,9 @@ type ServerInterface interface { // (GET /products/{id}) GetProductByID(w http.ResponseWriter, r *http.Request, id int64) + + // (PUT /products/{id}) + UpdateProduct(w http.ResponseWriter, r *http.Request, id int64) } // Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. @@ -105,6 +104,11 @@ func (_ Unimplemented) GetProductByID(w http.ResponseWriter, r *http.Request, id w.WriteHeader(http.StatusNotImplemented) } +// (PUT /products/{id}) +func (_ Unimplemented) UpdateProduct(w http.ResponseWriter, r *http.Request, id int64) { + w.WriteHeader(http.StatusNotImplemented) +} + // ServerInterfaceWrapper converts contexts to parameters. type ServerInterfaceWrapper struct { Handler ServerInterface @@ -221,6 +225,31 @@ func (siw *ServerInterfaceWrapper) GetProductByID(w http.ResponseWriter, r *http handler.ServeHTTP(w, r) } +// UpdateProduct operation middleware +func (siw *ServerInterfaceWrapper) UpdateProduct(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "id" ------------- + var id int64 + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UpdateProduct(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + type UnescapedCookieParamError struct { ParamName string Err error @@ -346,12 +375,13 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/products/{id}", wrapper.GetProductByID) }) + r.Group(func(r chi.Router) { + r.Put(options.BaseURL+"/products/{id}", wrapper.UpdateProduct) + }) return r } -type ErrorResponseJSONResponse Error - type CreateProductRequestObject struct { Body *CreateProductJSONRequestBody } @@ -369,7 +399,14 @@ func (response CreateProduct201JSONResponse) VisitCreateProductResponse(w http.R return json.NewEncoder(w).Encode(response) } -type CreateProduct400JSONResponse struct{ ErrorResponseJSONResponse } +type CreateProduct400JSONResponse struct { + Code string `json:"code"` + Details *[]struct { + Field *string `json:"field,omitempty"` + Reason *string `json:"reason,omitempty"` + } `json:"details,omitempty"` + Message string `json:"message"` +} func (response CreateProduct400JSONResponse) VisitCreateProductResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") @@ -395,7 +432,14 @@ func (response SearchProducts200JSONResponse) VisitSearchProductsResponse(w http return json.NewEncoder(w).Encode(response) } -type SearchProducts400JSONResponse struct{ ErrorResponseJSONResponse } +type SearchProducts400JSONResponse struct { + Code string `json:"code"` + Details *[]struct { + Field *string `json:"field,omitempty"` + Reason *string `json:"reason,omitempty"` + } `json:"details,omitempty"` + Message string `json:"message"` +} func (response SearchProducts400JSONResponse) VisitSearchProductsResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") @@ -420,7 +464,14 @@ func (response DeleteProductByID204Response) VisitDeleteProductByIDResponse(w ht return nil } -type DeleteProductByID400JSONResponse struct{ ErrorResponseJSONResponse } +type DeleteProductByID400JSONResponse struct { + Code string `json:"code"` + Details *[]struct { + Field *string `json:"field,omitempty"` + Reason *string `json:"reason,omitempty"` + } `json:"details,omitempty"` + Message string `json:"message"` +} func (response DeleteProductByID400JSONResponse) VisitDeleteProductByIDResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") @@ -429,7 +480,14 @@ func (response DeleteProductByID400JSONResponse) VisitDeleteProductByIDResponse( return json.NewEncoder(w).Encode(response) } -type DeleteProductByID404JSONResponse Error +type DeleteProductByID404JSONResponse struct { + Code string `json:"code"` + Details *[]struct { + Field *string `json:"field,omitempty"` + Reason *string `json:"reason,omitempty"` + } `json:"details,omitempty"` + Message string `json:"message"` +} func (response DeleteProductByID404JSONResponse) VisitDeleteProductByIDResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") @@ -455,7 +513,14 @@ func (response GetProductByID200JSONResponse) VisitGetProductByIDResponse(w http return json.NewEncoder(w).Encode(response) } -type GetProductByID400JSONResponse struct{ ErrorResponseJSONResponse } +type GetProductByID400JSONResponse struct { + Code string `json:"code"` + Details *[]struct { + Field *string `json:"field,omitempty"` + Reason *string `json:"reason,omitempty"` + } `json:"details,omitempty"` + Message string `json:"message"` +} func (response GetProductByID400JSONResponse) VisitGetProductByIDResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") @@ -464,7 +529,14 @@ func (response GetProductByID400JSONResponse) VisitGetProductByIDResponse(w http return json.NewEncoder(w).Encode(response) } -type GetProductByID404JSONResponse Error +type GetProductByID404JSONResponse struct { + Code string `json:"code"` + Details *[]struct { + Field *string `json:"field,omitempty"` + Reason *string `json:"reason,omitempty"` + } `json:"details,omitempty"` + Message string `json:"message"` +} func (response GetProductByID404JSONResponse) VisitGetProductByIDResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") @@ -473,6 +545,56 @@ func (response GetProductByID404JSONResponse) VisitGetProductByIDResponse(w http return json.NewEncoder(w).Encode(response) } +type UpdateProductRequestObject struct { + Id int64 `json:"id"` + Body *UpdateProductJSONRequestBody +} + +type UpdateProductResponseObject interface { + VisitUpdateProductResponse(w http.ResponseWriter) error +} + +type UpdateProduct200JSONResponse Product + +func (response UpdateProduct200JSONResponse) VisitUpdateProductResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateProduct400JSONResponse struct { + Code string `json:"code"` + Details *[]struct { + Field *string `json:"field,omitempty"` + Reason *string `json:"reason,omitempty"` + } `json:"details,omitempty"` + Message string `json:"message"` +} + +func (response UpdateProduct400JSONResponse) VisitUpdateProductResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateProduct404JSONResponse struct { + Code string `json:"code"` + Details *[]struct { + Field *string `json:"field,omitempty"` + Reason *string `json:"reason,omitempty"` + } `json:"details,omitempty"` + Message string `json:"message"` +} + +func (response UpdateProduct404JSONResponse) VisitUpdateProductResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + // StrictServerInterface represents all server handlers. type StrictServerInterface interface { @@ -487,6 +609,9 @@ type StrictServerInterface interface { // (GET /products/{id}) GetProductByID(ctx context.Context, request GetProductByIDRequestObject) (GetProductByIDResponseObject, error) + + // (PUT /products/{id}) + UpdateProduct(ctx context.Context, request UpdateProductRequestObject) (UpdateProductResponseObject, error) } type StrictHandlerFunc = strictnethttp.StrictHTTPHandlerFunc @@ -627,21 +752,56 @@ func (sh *strictHandler) GetProductByID(w http.ResponseWriter, r *http.Request, } } +// UpdateProduct operation middleware +func (sh *strictHandler) UpdateProduct(w http.ResponseWriter, r *http.Request, id int64) { + var request UpdateProductRequestObject + + request.Id = id + + var body UpdateProductJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.UpdateProduct(ctx, request.(UpdateProductRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UpdateProduct") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(UpdateProductResponseObject); ok { + if err := validResponse.VisitUpdateProductResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/apps/product-query-svc/adapters/inbound/http/response_helpers.go b/apps/product-query-svc/adapters/inbound/http/response_helpers.go index 0cf875b..e9687e2 100644 --- a/apps/product-query-svc/adapters/inbound/http/response_helpers.go +++ b/apps/product-query-svc/adapters/inbound/http/response_helpers.go @@ -11,6 +11,15 @@ func writeJSON(w http.ResponseWriter, status int, v any) { _ = json.NewEncoder(w).Encode(v) } +type errorBody struct { + Code string `json:"code"` + Message string `json:"message"` + Details *[]struct { + Field *string `json:"field,omitempty"` + Reason *string `json:"reason,omitempty"` + } `json:"details,omitempty"` +} + func writeError(w http.ResponseWriter, status int, code, message string) { - writeJSON(w, status, Error{Code: code, Message: message}) + writeJSON(w, status, errorBody{Code: code, Message: message}) } diff --git a/apps/product-query-svc/adapters/outbound/inmem/product_repository.go b/apps/product-query-svc/adapters/outbound/inmem/product_repository.go index 8d02d86..a5ce130 100644 --- a/apps/product-query-svc/adapters/outbound/inmem/product_repository.go +++ b/apps/product-query-svc/adapters/outbound/inmem/product_repository.go @@ -1,12 +1,12 @@ package inmem import ( - "context" - "strings" - "sync" + "context" + "strings" + "sync" - "github.com/fightingBald/GoTuto/apps/product-query-svc/domain" - "github.com/fightingBald/GoTuto/apps/product-query-svc/ports" + "github.com/fightingBald/GoTuto/apps/product-query-svc/domain" + "github.com/fightingBald/GoTuto/apps/product-query-svc/ports" ) // 简单的内存实现,用于本地开发/测试和示例 wiring @@ -29,9 +29,9 @@ func (r *InMemRepo) GetByID(ctx context.Context, id int64) (*domain.Product, err r.mu.RLock() defer r.mu.RUnlock() p, ok := r.data[id] - if !ok { - return nil, domain.ErrNotFound - } + if !ok { + return nil, domain.ErrNotFound + } // return copy pp := p return &pp, nil @@ -77,9 +77,19 @@ func (r *InMemRepo) Create(ctx context.Context, p *domain.Product) (int64, error func (r *InMemRepo) Delete(ctx context.Context, id int64) error { r.mu.Lock() defer r.mu.Unlock() - if _, ok := r.data[id]; !ok { - return domain.ErrNotFound - } + if _, ok := r.data[id]; !ok { + return domain.ErrNotFound + } delete(r.data, id) return nil } + +func (r *InMemRepo) Update(ctx context.Context, p *domain.Product) error { + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.data[p.ID]; !ok { + return domain.ErrNotFound + } + r.data[p.ID] = *p + return nil +} diff --git a/apps/product-query-svc/adapters/outbound/postgres/product_repository.go b/apps/product-query-svc/adapters/outbound/postgres/product_repository.go index c2efc7b..c8ebb0d 100644 --- a/apps/product-query-svc/adapters/outbound/postgres/product_repository.go +++ b/apps/product-query-svc/adapters/outbound/postgres/product_repository.go @@ -1,15 +1,15 @@ package postgres import ( - "context" - "errors" - "strings" + "context" + "errors" + "strings" - "github.com/Masterminds/squirrel" - "github.com/fightingBald/GoTuto/apps/product-query-svc/domain" - "github.com/fightingBald/GoTuto/apps/product-query-svc/ports" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" + "github.com/Masterminds/squirrel" + "github.com/fightingBald/GoTuto/apps/product-query-svc/domain" + "github.com/fightingBald/GoTuto/apps/product-query-svc/ports" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" ) var psql = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar) @@ -25,12 +25,12 @@ func (r *PGProductRepo) GetByID(ctx context.Context, id int64) (*domain.Product, } var p domain.Product var tags []string - if err := r.pool.QueryRow(ctx, q, args...).Scan(&p.ID, &p.Name, &p.Price, &tags); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, domain.ErrNotFound - } - return nil, err - } + if err := r.pool.QueryRow(ctx, q, args...).Scan(&p.ID, &p.Name, &p.Price, &tags); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrNotFound + } + return nil, err + } p.Tags = tags return &p, nil } @@ -103,8 +103,19 @@ func (r *PGProductRepo) Delete(ctx context.Context, id int64) error { if err != nil { return err } - if ct.RowsAffected() == 0 { - return domain.ErrNotFound - } - return nil + if ct.RowsAffected() == 0 { + return domain.ErrNotFound + } + return nil +} + +func (r *PGProductRepo) Update(ctx context.Context, p *domain.Product) error { + ct, err := r.pool.Exec(ctx, "UPDATE products SET name=$1, price=$2, tags=$3 WHERE id=$4", p.Name, p.Price, p.Tags, p.ID) + if err != nil { + return err + } + if ct.RowsAffected() == 0 { + return domain.ErrNotFound + } + return nil } diff --git a/apps/product-query-svc/app/product_service.go b/apps/product-query-svc/app/product_service.go index e39ab01..29a8e52 100644 --- a/apps/product-query-svc/app/product_service.go +++ b/apps/product-query-svc/app/product_service.go @@ -21,12 +21,25 @@ func (s *ProductService) SearchProducts(ctx context.Context, q string, page, pag } func (s *ProductService) DeleteProduct(ctx context.Context, id int64) error { - return s.repo.Delete(ctx, id) + return s.repo.Delete(ctx, id) } func (s *ProductService) CreateProduct(ctx context.Context, p *domain.Product) (int64, error) { - if err := p.Validate(); err != nil { - return 0, err - } - return s.repo.Create(ctx, p) + if err := p.Validate(); err != nil { + return 0, err + } + return s.repo.Create(ctx, p) +} + +func (s *ProductService) UpdateProduct(ctx context.Context, p *domain.Product) (*domain.Product, error) { + if p.ID <= 0 { + return nil, domain.ErrValidation + } + if err := p.Validate(); err != nil { + return nil, err + } + if err := s.repo.Update(ctx, p); err != nil { + return nil, err + } + return s.repo.GetByID(ctx, p.ID) } diff --git a/apps/product-query-svc/ports/inbound.go b/apps/product-query-svc/ports/inbound.go index 0f40241..945ad7d 100644 --- a/apps/product-query-svc/ports/inbound.go +++ b/apps/product-query-svc/ports/inbound.go @@ -11,11 +11,13 @@ import ( // ProductService is an inbound port exposing application use cases // to driving adapters (e.g., HTTP, gRPC, CLI). type ProductService interface { - GetProduct(ctx context.Context, id int64) (*domain.Product, error) - // SearchProducts returns items and total count - SearchProducts(ctx context.Context, q string, page, pageSize int) ([]domain.Product, int, error) - // DeleteProduct removes the product by id - DeleteProduct(ctx context.Context, id int64) error - // CreateProduct validates and persists a new product, returning its id - CreateProduct(ctx context.Context, p *domain.Product) (int64, error) + GetProduct(ctx context.Context, id int64) (*domain.Product, error) + // SearchProducts returns items and total count + SearchProducts(ctx context.Context, q string, page, pageSize int) ([]domain.Product, int, error) + // DeleteProduct removes the product by id + DeleteProduct(ctx context.Context, id int64) error + // CreateProduct validates and persists a new product, returning its id + CreateProduct(ctx context.Context, p *domain.Product) (int64, error) + // UpdateProduct replaces the existing product state and returns the updated snapshot + UpdateProduct(ctx context.Context, p *domain.Product) (*domain.Product, error) } diff --git a/apps/product-query-svc/ports/outbound.go b/apps/product-query-svc/ports/outbound.go index 442b5e1..5e58844 100644 --- a/apps/product-query-svc/ports/outbound.go +++ b/apps/product-query-svc/ports/outbound.go @@ -14,4 +14,5 @@ type ProductRepo interface { Search(ctx context.Context, q string, page, pageSize int) ([]domain.Product, int, error) Create(ctx context.Context, p *domain.Product) (int64, error) Delete(ctx context.Context, id int64) error + Update(ctx context.Context, p *domain.Product) error } diff --git a/readme.md b/readme.md index e85b676..e9aca32 100644 --- a/readme.md +++ b/readme.md @@ -81,20 +81,28 @@ curl -s -X POST http://localhost:8080/products \ -d '{"name":"Sample Plan","price":123.45}' | jq ``` -3) GET /products/{id}(按 ID 查询,示例使用已种子或上一步创建的 id) +3) PUT /products/{id}(整资源更新,示例使用已存在的 id) + +```sh +curl -s -X PUT http://localhost:8080/products/1 \ + -H 'Content-Type: application/json' \ + -d '{"name":"Updated Plan","price":199.99}' | jq +``` + +4) GET /products/{id}(按 ID 查询,示例使用已种子或上一步创建/更新的 id) ```sh # 如果使用迁移种子数据(Postgres),通常 1 为 Basic Plan curl -s http://localhost:8080/products/1 | jq ``` -4) GET /products/search(分页搜索;注意 q 至少 3 个字符) +5) GET /products/search(分页搜索;注意 q 至少 3 个字符) ```sh curl -s 'http://localhost:8080/products/search?q=pro&page=1&pageSize=10' | jq ``` -5) DELETE /products/{id}(删除;示例:先创建临时商品再删除) +6) DELETE /products/{id}(删除;示例:先创建临时商品再删除) ```sh ID=$(curl -s -X POST http://localhost:8080/products \ diff --git a/test/http_inmem/delete_test.go b/test/http_inmem/delete_test.go index 5ae8437..21fcc84 100644 --- a/test/http_inmem/delete_test.go +++ b/test/http_inmem/delete_test.go @@ -1,33 +1,37 @@ package http_inmem_test import ( - "net/http" - "testing" + "net/http" + "testing" - appsinmem "github.com/fightingBald/GoTuto/apps/product-query-svc/adapters/outbound/inmem" - "github.com/fightingBald/GoTuto/internal/testutil" + appsinmem "github.com/fightingBald/GoTuto/apps/product-query-svc/adapters/outbound/inmem" + "github.com/fightingBald/GoTuto/internal/testutil" ) func TestDeleteProduct_InMem(t *testing.T) { - ts := testutil.NewHTTPServerWithRepo(appsinmem.NewInMemRepo()) - defer ts.Close() + ts := testutil.NewHTTPServerWithRepo(appsinmem.NewInMemRepo()) + defer ts.Close() - t.Run("delete id=1 returns 204", func(t *testing.T) { - req, _ := http.NewRequest(http.MethodDelete, ts.URL+"/products/1", nil) - resp, err := http.DefaultClient.Do(req) - if err != nil { t.Fatalf("http delete: %v", err) } - resp.Body.Close() - if resp.StatusCode != http.StatusNoContent { - t.Fatalf("expected 204, got %d", resp.StatusCode) - } - }) + t.Run("delete id=1 returns 204", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodDelete, ts.URL+"/products/1", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("http delete: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204, got %d", resp.StatusCode) + } + }) - t.Run("get after delete returns 404", func(t *testing.T) { - resp2, err := http.Get(ts.URL + "/products/1") - if err != nil { t.Fatalf("http get: %v", err) } - resp2.Body.Close() - if resp2.StatusCode != http.StatusNotFound { - t.Fatalf("expected 404 after delete, got %d", resp2.StatusCode) - } - }) + t.Run("get after delete returns 404", func(t *testing.T) { + resp2, err := http.Get(ts.URL + "/products/1") + if err != nil { + t.Fatalf("http get: %v", err) + } + resp2.Body.Close() + if resp2.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 after delete, got %d", resp2.StatusCode) + } + }) } diff --git a/test/http_inmem/search_test.go b/test/http_inmem/search_test.go index 953e6a5..7786948 100644 --- a/test/http_inmem/search_test.go +++ b/test/http_inmem/search_test.go @@ -1,50 +1,94 @@ package http_inmem_test import ( - "encoding/json" - "net/http" - "testing" + "encoding/json" + "net/http" + "strings" + "testing" - appshttp "github.com/fightingBald/GoTuto/apps/product-query-svc/adapters/inbound/http" - appsinmem "github.com/fightingBald/GoTuto/apps/product-query-svc/adapters/outbound/inmem" - "github.com/fightingBald/GoTuto/internal/testutil" + appshttp "github.com/fightingBald/GoTuto/apps/product-query-svc/adapters/inbound/http" + appsinmem "github.com/fightingBald/GoTuto/apps/product-query-svc/adapters/outbound/inmem" + "github.com/fightingBald/GoTuto/internal/testutil" ) func TestHTTP_InMem_Product(t *testing.T) { - ts := testutil.NewHTTPServerWithRepo(appsinmem.NewInMemRepo()) - defer ts.Close() - - t.Run("search returns items", func(t *testing.T) { - // q must be at least 3 characters; use 'wid' to match 'Blue Widget' - resp, err := http.Get(ts.URL + "/products/search?q=wid&page=1&pageSize=10") - if err != nil { t.Fatalf("http get: %v", err) } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { t.Fatalf("unexpected status: %d", resp.StatusCode) } - var pl appshttp.ProductList - if err := json.NewDecoder(resp.Body).Decode(&pl); err != nil { t.Fatalf("decode: %v", err) } - if len(pl.Items) == 0 || pl.Total == 0 { - t.Fatalf("expected seeded items in in-memory repo; got items=%d total=%d", len(pl.Items), pl.Total) - } - }) - - t.Run("get id=1 returns product", func(t *testing.T) { - resp, err := http.Get(ts.URL + "/products/1") - if err != nil { t.Fatalf("http get: %v", err) } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { t.Fatalf("unexpected status: %d", resp.StatusCode) } - var p appshttp.Product - if err := json.NewDecoder(resp.Body).Decode(&p); err != nil { t.Fatalf("decode: %v", err) } - if p.Id != 1 || p.Name == "" { - t.Fatalf("expected product id=1 with name; got id=%d name=%q", p.Id, p.Name) - } - }) - - t.Run("search with short q returns 400", func(t *testing.T) { - resp, err := http.Get(ts.URL + "/products/search?q=ab") - if err != nil { t.Fatalf("http get: %v", err) } - defer resp.Body.Close() - if resp.StatusCode != http.StatusBadRequest { - t.Fatalf("expected 400 for short q; got %d", resp.StatusCode) - } - }) + t.Run("search returns items", func(t *testing.T) { + ts := testutil.NewHTTPServerWithRepo(appsinmem.NewInMemRepo()) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/products/search?q=wid&page=1&pageSize=10") + if err != nil { + t.Fatalf("http get: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %d", resp.StatusCode) + } + var pl appshttp.ProductList + if err := json.NewDecoder(resp.Body).Decode(&pl); err != nil { + t.Fatalf("decode: %v", err) + } + if len(pl.Items) == 0 || pl.Total == 0 { + t.Fatalf("expected seeded items in in-memory repo; got items=%d total=%d", len(pl.Items), pl.Total) + } + }) + + t.Run("get id=1 returns product", func(t *testing.T) { + ts := testutil.NewHTTPServerWithRepo(appsinmem.NewInMemRepo()) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/products/1") + if err != nil { + t.Fatalf("http get: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %d", resp.StatusCode) + } + var p appshttp.Product + if err := json.NewDecoder(resp.Body).Decode(&p); err != nil { + t.Fatalf("decode: %v", err) + } + if p.Id != 1 || p.Name == "" { + t.Fatalf("expected product id=1 with name; got id=%d name=%q", p.Id, p.Name) + } + }) + + t.Run("update id=1 returns updated product", func(t *testing.T) { + ts := testutil.NewHTTPServerWithRepo(appsinmem.NewInMemRepo()) + defer ts.Close() + + body := `{"name":"Updated Widget","price":15.25}` + req, _ := http.NewRequest(http.MethodPut, ts.URL+"/products/1", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("http put: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var updated appshttp.Product + if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil { + t.Fatalf("decode: %v", err) + } + if updated.Id != 1 || updated.Name != "Updated Widget" { + t.Fatalf("unexpected updated product: %+v", updated) + } + }) + + t.Run("search with short q returns 400", func(t *testing.T) { + ts := testutil.NewHTTPServerWithRepo(appsinmem.NewInMemRepo()) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/products/search?q=ab") + if err != nil { + t.Fatalf("http get: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for short q; got %d", resp.StatusCode) + } + }) } diff --git a/test/http_pg/create_test.go b/test/http_pg/create_test.go index 35c29e3..04c9f4c 100644 --- a/test/http_pg/create_test.go +++ b/test/http_pg/create_test.go @@ -1,88 +1,140 @@ package http_pg_test import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strconv" - "strings" - "testing" - "time" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" - appshttp "github.com/fightingBald/GoTuto/apps/product-query-svc/adapters/inbound/http" - appspg "github.com/fightingBald/GoTuto/apps/product-query-svc/adapters/outbound/postgres" - appsvc "github.com/fightingBald/GoTuto/apps/product-query-svc/app" - "github.com/fightingBald/GoTuto/internal/testutil" - "github.com/go-chi/chi/v5" + appshttp "github.com/fightingBald/GoTuto/apps/product-query-svc/adapters/inbound/http" + appspg "github.com/fightingBald/GoTuto/apps/product-query-svc/adapters/outbound/postgres" + appsvc "github.com/fightingBald/GoTuto/apps/product-query-svc/app" + "github.com/fightingBald/GoTuto/internal/testutil" + "github.com/go-chi/chi/v5" ) // TestCreateProduct_Postgres validates POST /products on a real Postgres. func TestCreateProduct_Postgres(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() - pool := testutil.NewPool(ctx, t, pgDSN) - defer pool.Close() - if pgTemp { testutil.ApplyMigrations(ctx, t, pool) } + pool := testutil.NewPool(ctx, t, pgDSN) + defer pool.Close() + if pgTemp { + testutil.ApplyMigrations(ctx, t, pool) + } - repo := appspg.NewProductRepository(pool) - svc := appsvc.NewProductService(repo) - server := appshttp.NewServer(svc) + repo := appspg.NewProductRepository(pool) + svc := appsvc.NewProductService(repo) + server := appshttp.NewServer(svc) - r := chi.NewRouter() - h := appshttp.HandlerFromMux(server, r) + r := chi.NewRouter() + h := appshttp.HandlerFromMux(server, r) - ts := httptest.NewServer(h) - defer ts.Close() + ts := httptest.NewServer(h) + defer ts.Close() - var created appshttp.Product - t.Run("create 201", func(t *testing.T) { - body := `{"name":"CI Test Item","price":12.34}` - resp, err := http.Post(ts.URL+"/products", "application/json", strings.NewReader(body)) - if err != nil { t.Fatalf("http post: %v", err) } - t.Cleanup(func(){ resp.Body.Close() }) - if resp.StatusCode != http.StatusCreated { - t.Fatalf("expected 201, got %d", resp.StatusCode) - } - if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { - t.Fatalf("decode created: %v", err) - } - if created.Id == 0 || created.Name != "CI Test Item" { - t.Fatalf("unexpected created product: %+v", created) - } - }) + var created appshttp.Product + t.Run("create 201", func(t *testing.T) { + body := `{"name":"CI Test Item","price":12.34}` + resp, err := http.Post(ts.URL+"/products", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("http post: %v", err) + } + t.Cleanup(func() { resp.Body.Close() }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201, got %d", resp.StatusCode) + } + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + t.Fatalf("decode created: %v", err) + } + if created.Id == 0 || created.Name != "CI Test Item" { + t.Fatalf("unexpected created product: %+v", created) + } + }) - t.Run("get 200", func(t *testing.T) { - resp2, err := http.Get(ts.URL + "/products/" + strconv.FormatInt(created.Id, 10)) - if err != nil { t.Fatalf("http get: %v", err) } - t.Cleanup(func(){ resp2.Body.Close() }) - if resp2.StatusCode != http.StatusOK { - t.Fatalf("expected 200, got %d", resp2.StatusCode) - } - var fetched appshttp.Product - if err := json.NewDecoder(resp2.Body).Decode(&fetched); err != nil { t.Fatalf("decode fetched: %v", err) } - if fetched.Id != created.Id || fetched.Name != created.Name { - t.Fatalf("mismatch fetched vs created: created=%+v fetched=%+v", created, fetched) - } - }) + t.Run("get 200", func(t *testing.T) { + resp2, err := http.Get(ts.URL + "/products/" + strconv.FormatInt(created.Id, 10)) + if err != nil { + t.Fatalf("http get: %v", err) + } + t.Cleanup(func() { resp2.Body.Close() }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + var fetched appshttp.Product + if err := json.NewDecoder(resp2.Body).Decode(&fetched); err != nil { + t.Fatalf("decode fetched: %v", err) + } + if fetched.Id != created.Id || fetched.Name != created.Name { + t.Fatalf("mismatch fetched vs created: created=%+v fetched=%+v", created, fetched) + } + }) - t.Run("delete 204", func(t *testing.T) { - req, _ := http.NewRequest(http.MethodDelete, ts.URL+"/products/"+strconv.FormatInt(created.Id, 10), nil) - resp3, err := http.DefaultClient.Do(req) - if err != nil { t.Fatalf("http delete: %v", err) } - resp3.Body.Close() - if resp3.StatusCode != http.StatusNoContent { - t.Fatalf("expected 204, got %d", resp3.StatusCode) - } - }) + t.Run("update 200", func(t *testing.T) { + body := `{"name":"CI Test Item Updated","price":15.67}` + req, _ := http.NewRequest(http.MethodPut, ts.URL+"/products/"+strconv.FormatInt(created.Id, 10), strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("http put: %v", err) + } + t.Cleanup(func() { resp.Body.Close() }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var updated appshttp.Product + if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil { + t.Fatalf("decode updated: %v", err) + } + if updated.Name != "CI Test Item Updated" { + t.Fatalf("expected updated name, got %+v", updated) + } + created = updated + }) - t.Run("get after delete 404", func(t *testing.T) { - resp4, err := http.Get(ts.URL + "/products/" + strconv.FormatInt(created.Id, 10)) - if err != nil { t.Fatalf("http get after delete: %v", err) } - resp4.Body.Close() - if resp4.StatusCode != http.StatusNotFound { - t.Fatalf("expected 404 after delete, got %d", resp4.StatusCode) - } - }) + t.Run("get after update 200", func(t *testing.T) { + resp, err := http.Get(ts.URL + "/products/" + strconv.FormatInt(created.Id, 10)) + if err != nil { + t.Fatalf("http get: %v", err) + } + t.Cleanup(func() { resp.Body.Close() }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var fetched appshttp.Product + if err := json.NewDecoder(resp.Body).Decode(&fetched); err != nil { + t.Fatalf("decode fetched: %v", err) + } + if fetched.Name != "CI Test Item Updated" { + t.Fatalf("expected updated name, got %+v", fetched) + } + }) + + t.Run("delete 204", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodDelete, ts.URL+"/products/"+strconv.FormatInt(created.Id, 10), nil) + resp3, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("http delete: %v", err) + } + resp3.Body.Close() + if resp3.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204, got %d", resp3.StatusCode) + } + }) + + t.Run("get after delete 404", func(t *testing.T) { + resp4, err := http.Get(ts.URL + "/products/" + strconv.FormatInt(created.Id, 10)) + if err != nil { + t.Fatalf("http get after delete: %v", err) + } + resp4.Body.Close() + if resp4.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 after delete, got %d", resp4.StatusCode) + } + }) }