diff --git a/api/components/parameters/ProductID.yaml b/api/components/parameters/ID.yaml similarity index 100% rename from api/components/parameters/ProductID.yaml rename to api/components/parameters/ID.yaml diff --git a/api/openapi.yaml b/api/openapi.yaml index e269e51..900d933 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -5,6 +5,8 @@ info: tags: - name: Products description: Product query and management endpoints + - name: Users + description: User account retrieval endpoints paths: /products/{id}: @@ -13,6 +15,10 @@ paths: $ref: './paths/products/search.yaml' /products: $ref: './paths/products/collection.yaml' + /users/{id}: + $ref: './paths/users/item.yaml' + + components: schemas: @@ -22,5 +28,7 @@ components: $ref: './schemas/ProductCreate.yaml' ProductList: $ref: './schemas/ProductList.yaml' + User: + $ref: './schemas/User.yaml' Error: $ref: './schemas/Error.yaml' diff --git a/api/paths/products/item.yaml b/api/paths/products/item.yaml index 15f8c92..f30472d 100644 --- a/api/paths/products/item.yaml +++ b/api/paths/products/item.yaml @@ -2,7 +2,7 @@ get: tags: [Products] operationId: GetProductByID parameters: - - $ref: '../../components/parameters/ProductID.yaml' + - $ref: '../../components/parameters/ID.yaml' responses: '200': description: Single product @@ -19,7 +19,7 @@ put: tags: [Products] operationId: UpdateProduct parameters: - - $ref: '../../components/parameters/ProductID.yaml' + - $ref: '../../components/parameters/ID.yaml' requestBody: $ref: '../../components/requestBodies/ProductCreate.yaml' responses: @@ -38,7 +38,7 @@ delete: tags: [Products] operationId: DeleteProductByID parameters: - - $ref: '../../components/parameters/ProductID.yaml' + - $ref: '../../components/parameters/ID.yaml' responses: '204': description: Deleted diff --git a/api/paths/users/item.yaml b/api/paths/users/item.yaml new file mode 100644 index 0000000..ea4761e --- /dev/null +++ b/api/paths/users/item.yaml @@ -0,0 +1,17 @@ +get: + tags: [Users] + operationId: GetUserByID + parameters: + - $ref: '../../components/parameters/ID.yaml' + responses: + '200': + description: Single user + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + $ref: '../../components/responses/Error.yaml' + '404': + $ref: '../../components/responses/Error.yaml' + diff --git a/api/schemas/User.yaml b/api/schemas/User.yaml new file mode 100644 index 0000000..05bda28 --- /dev/null +++ b/api/schemas/User.yaml @@ -0,0 +1,21 @@ +type: object +description: User profile returned by the API. +properties: + id: + type: integer + format: int64 + readOnly: true + name: + type: string + minLength: 1 + maxLength: 120 + email: + type: string + format: email + maxLength: 254 + createdAt: + type: string + format: date-time + readOnly: true + +required: [id, name, email, createdAt] diff --git a/apps/product-query-svc/adapters/inbound/http/handler_product_read.go b/apps/product-query-svc/adapters/inbound/http/handler_product_read.go index 1ba5b0d..3eddc4d 100644 --- a/apps/product-query-svc/adapters/inbound/http/handler_product_read.go +++ b/apps/product-query-svc/adapters/inbound/http/handler_product_read.go @@ -1,29 +1,18 @@ package httpadapter -import ( - "net/http" - "errors" - - "github.com/fightingBald/GoTuto/apps/product-query-svc/domain" - "github.com/fightingBald/GoTuto/apps/product-query-svc/ports" -) - -type Server struct{ svc ports.ProductService } - -func NewServer(s ports.ProductService) *Server { return &Server{svc: s} } +import "net/http" func (s *Server) GetProductByID(w http.ResponseWriter, r *http.Request, id int64) { - p, err := s.svc.GetProduct(r.Context(), id) - if err != nil { - if errors.Is(err, domain.ErrNotFound) { - writeError(w, http.StatusNotFound, "NOT_FOUND", err.Error()) - } else { - writeError(w, http.StatusInternalServerError, "INTERNAL", err.Error()) - } - return - } - out := Product{Id: p.ID, Name: p.Name, Price: float32(p.Price) / 100.0} - writeJSON(w, http.StatusOK, out) + if id <= 0 { + writeError(w, http.StatusBadRequest, "INVALID_ID", "id must be a positive integer") + return + } + p, err := s.products.GetProduct(r.Context(), id) + if err != nil { + writeDomainError(w, err) + return + } + writeJSON(w, http.StatusOK, presentProduct(p)) } func (s *Server) SearchProducts(w http.ResponseWriter, r *http.Request, params SearchProductsParams) { @@ -44,21 +33,11 @@ func (s *Server) SearchProducts(w http.ResponseWriter, r *http.Request, params S if params.PageSize != nil { pageSize = *params.PageSize } - items, total, err := s.svc.SearchProducts(r.Context(), q, page, pageSize) + items, total, err := s.products.SearchProducts(r.Context(), q, page, pageSize) if err != nil { - writeError(w, http.StatusInternalServerError, "INTERNAL", err.Error()) + writeDomainError(w, err) return } - var out []Product - for _, it := range items { - out = append(out, Product{Id: it.ID, Name: it.Name, Price: float32(it.Price) / 100.0}) - } - resp := ProductList{Items: out, Page: page, PageSize: pageSize, Total: total} + resp := ProductList{Items: presentProducts(items), Page: page, PageSize: pageSize, Total: total} writeJSON(w, http.StatusOK, resp) } - -// Healthz 健康检查 -func (s *Server) Health(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("ok")) -} 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 466a51f..48d4488 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 @@ -2,8 +2,6 @@ package httpadapter import ( "encoding/json" - "errors" - "math" "net/http" "github.com/fightingBald/GoTuto/apps/product-query-svc/domain" @@ -15,12 +13,8 @@ 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()) + if err := s.products.DeleteProduct(r.Context(), id); err != nil { + writeDomainError(w, err) return } w.WriteHeader(http.StatusNoContent) @@ -33,23 +27,20 @@ func (s *Server) CreateProduct(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body") return } - cents := int64(math.Round(float64(in.Price) * 100.0)) + cents := amountToCents(in.Price) p, err := domain.NewProduct(in.Name, cents, nil) if err != nil { - writeError(w, http.StatusBadRequest, "VALIDATION", err.Error()) + writeDomainError(w, err) return } - id, err := s.svc.CreateProduct(r.Context(), p) + id, err := s.products.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()) + writeDomainError(w, err) return } - out := Product{Id: id, Name: p.Name, Price: float32(cents) / 100.0} - writeJSON(w, http.StatusCreated, out) + created := *p + created.ID = id + writeJSON(w, http.StatusCreated, presentProduct(&created)) } // UpdateProduct implements OpenAPI operation: PUT /products/{id} @@ -63,25 +54,17 @@ func (s *Server) UpdateProduct(w http.ResponseWriter, r *http.Request, id int64) writeError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body") return } - cents := int64(math.Round(float64(in.Price) * 100.0)) + cents := amountToCents(in.Price) p, err := domain.NewProduct(in.Name, cents, nil) if err != nil { - writeError(w, http.StatusBadRequest, "VALIDATION", err.Error()) + writeDomainError(w, err) return } p.ID = id - updated, err := s.svc.UpdateProduct(r.Context(), p) + updated, err := s.products.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()) - } + writeDomainError(w, err) return } - out := Product{Id: updated.ID, Name: updated.Name, Price: float32(updated.Price) / 100.0} - writeJSON(w, http.StatusOK, out) + writeJSON(w, http.StatusOK, presentProduct(updated)) } diff --git a/apps/product-query-svc/adapters/inbound/http/handler_user_read.go b/apps/product-query-svc/adapters/inbound/http/handler_user_read.go new file mode 100644 index 0000000..f90a7ab --- /dev/null +++ b/apps/product-query-svc/adapters/inbound/http/handler_user_read.go @@ -0,0 +1,16 @@ +package httpadapter + +import "net/http" + +func (s *Server) GetUserByID(w http.ResponseWriter, r *http.Request, id int64) { + if id <= 0 { + writeError(w, http.StatusBadRequest, "INVALID_ID", "id must be a positive integer") + return + } + u, err := s.users.GetUser(r.Context(), id) + if err != nil { + writeDomainError(w, err) + return + } + writeJSON(w, http.StatusOK, presentUser(u)) +} 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 2988fe4..b7cca9c 100644 --- a/apps/product-query-svc/adapters/inbound/http/marketplaceapi.gen.go +++ b/apps/product-query-svc/adapters/inbound/http/marketplaceapi.gen.go @@ -14,11 +14,13 @@ import ( "net/url" "path" "strings" + "time" "github.com/getkin/kin-openapi/openapi3" "github.com/go-chi/chi/v5" "github.com/oapi-codegen/runtime" strictnethttp "github.com/oapi-codegen/runtime/strictmiddleware/nethttp" + openapi_types "github.com/oapi-codegen/runtime/types" ) // Product defines model for Product. @@ -36,6 +38,14 @@ type ProductList struct { Total int `json:"total"` } +// User User profile returned by the API. +type User struct { + CreatedAt *time.Time `json:"createdAt,omitempty"` + Email openapi_types.Email `json:"email"` + Id *int64 `json:"id,omitempty"` + Name string `json:"name"` +} + // CreateProductJSONBody defines parameters for CreateProduct. type CreateProductJSONBody struct { Name string `json:"name"` @@ -78,6 +88,9 @@ type ServerInterface interface { // (PUT /products/{id}) UpdateProduct(w http.ResponseWriter, r *http.Request, id int64) + + // (GET /users/{id}) + GetUserByID(w http.ResponseWriter, r *http.Request, id int64) } // Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. @@ -109,6 +122,11 @@ func (_ Unimplemented) UpdateProduct(w http.ResponseWriter, r *http.Request, id w.WriteHeader(http.StatusNotImplemented) } +// (GET /users/{id}) +func (_ Unimplemented) GetUserByID(w http.ResponseWriter, r *http.Request, id int64) { + w.WriteHeader(http.StatusNotImplemented) +} + // ServerInterfaceWrapper converts contexts to parameters. type ServerInterfaceWrapper struct { Handler ServerInterface @@ -250,6 +268,31 @@ func (siw *ServerInterfaceWrapper) UpdateProduct(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } +// GetUserByID operation middleware +func (siw *ServerInterfaceWrapper) GetUserByID(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.GetUserByID(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + type UnescapedCookieParamError struct { ParamName string Err error @@ -378,6 +421,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Put(options.BaseURL+"/products/{id}", wrapper.UpdateProduct) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/users/{id}", wrapper.GetUserByID) + }) return r } @@ -595,6 +641,55 @@ func (response UpdateProduct404JSONResponse) VisitUpdateProductResponse(w http.R return json.NewEncoder(w).Encode(response) } +type GetUserByIDRequestObject struct { + Id int64 `json:"id"` +} + +type GetUserByIDResponseObject interface { + VisitGetUserByIDResponse(w http.ResponseWriter) error +} + +type GetUserByID200JSONResponse User + +func (response GetUserByID200JSONResponse) VisitGetUserByIDResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUserByID400JSONResponse 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 GetUserByID400JSONResponse) VisitGetUserByIDResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type GetUserByID404JSONResponse 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 GetUserByID404JSONResponse) VisitGetUserByIDResponse(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 { @@ -612,6 +707,9 @@ type StrictServerInterface interface { // (PUT /products/{id}) UpdateProduct(ctx context.Context, request UpdateProductRequestObject) (UpdateProductResponseObject, error) + + // (GET /users/{id}) + GetUserByID(ctx context.Context, request GetUserByIDRequestObject) (GetUserByIDResponseObject, error) } type StrictHandlerFunc = strictnethttp.StrictHTTPHandlerFunc @@ -785,23 +883,52 @@ func (sh *strictHandler) UpdateProduct(w http.ResponseWriter, r *http.Request, i } } +// GetUserByID operation middleware +func (sh *strictHandler) GetUserByID(w http.ResponseWriter, r *http.Request, id int64) { + var request GetUserByIDRequestObject + + request.Id = id + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetUserByID(ctx, request.(GetUserByIDRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetUserByID") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetUserByIDResponseObject); ok { + if err := validResponse.VisitGetUserByIDResponse(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{ - "H4sIAAAAAAAC/+RYT2/7Ngz9Kga3o1E7bbCDb+syDAE6oEOxUxEUqsU46qw/leVhWeDvPkjy30QJ2i4J", - "sP1OTUSJfHyPItXsIJdcSYHCVJDtQBFNOBrU7ttgexkML4+kQGtlAjJ4r1FvIQZBOEIGytpiqPINcmI3", - "UVyTujSQzWLgTDBec/fZbJXdz4TBAjU0TXwi2hP7+2REZw9GvU1j4OSvNmyafhWElrTOzXLRo1DEbAYQ", - "jEIMGt9rppFCZnSNYzhrqTkxPtIPc/hE4N+Opf0+yZcz8YCiMBvI7nrvldFMFNBY7xYbVuZeUob70k5s", - "Xao/aSQG/U5hUBj7kShVspwYJkXyVklh1wYQ32tcQwbfJYPvxFv7v3veHTSKVa6Zsk4hg3ZDlNsdTIpI", - "kW0pySG/PqtKSVEFMmrXX37WWuqzZ+G9BtA7Q9SFd7q2Z6zrNjd3z7RUqE0rBqMfqpJO/J0t6U7v2a0v", - "6v77gf4xKM1ybOvEF3/a7xI1f21LcCD42Ve0C9cdX/VH5Osb5sY6bjN6YFUoK4N8+uETFWK9t+GI1mTr", - "0mgbzyEvatQkDq1GGlKGTPs5O5xx18RGncV7CDEwLYkDDnJJx6AGSSgawsopPdOja4YlDZ7VSNqy3b/p", - "AYD7JHKsqimPo9NjNhz0Yf+p5P9/hR3uVwf5XQ/2ZxF/s1fSemBiLZ1zZkpr+5XoP9CokuQYLZDL6MfH", - "JcTwJ+rK9+3ZTXqTWlxSoSCKQQZ3bil2g94xlShPg7+q0tNryXWTZEkhA18mHV3jqbs9xvVk+CYfnMr7", - "o+82nV1qUoemnEdBIzXUxTxNj2fYIk1OTGjXukhRWc0fO55XdrWnPamQ6HxjwxQYIP/JmfvD8eQt+xzG", - "NmxJjr2/mviLR90z+V+ddpXerA7ETi8ltmsaAcHteiTXUX8DrqT4jtHGP+dL9O13KvnCrben77fLxZlU", - "H577AfbnHtGYH4+DnoUW62J+CWbj8LX5Bc2VCUyv2auemChKPGuruqBCqg4o9Luik6lyCYGuNaeuqr0n", - "jv43xB8t7478T+x+AoiIoBEnghTIUZgIBVWSCTfx2t8Geq/NqvknAAD//zWbF0rhEQAA", + "H4sIAAAAAAAC/+xYS4/bNhD+KwLbo7rWbtwedEvqojCQoi4WOQWLBVcc20zFx5KjoK6h/16Q1NOiFtnE", + "azRtTrY5nOf3zWjkIymU0EqCREvyI9HUUAEIxv/qZfe94H69cjIuSU40xT1JiaQCSE44Iykx8FhxA4zk", + "aCpIiS32IKjT2CojKLp7En9akpTgQUP4CTswpK7TGY8buoPO52MF5tA71U42dMNgS6sSSX6dEsElF5Xw", + "35/l7Zb//aRHL496vclSIuhfjdss+8wg/pjz/jhyK7h8C3KHe5K/6qxbNFzuSO2sOzjA4hvFOJxiOpLd", + "b4xiVYE/G6AI4aZEkOi+Uq1LXlDkSi4+WCXdWR/E9wa2JCffLXrbiyDtPk+s+9AY2MJw7YySnDQXksLd", + "4Eommh5KRaeUCllZraSNZNSc3/9ijDJnzyJYjUTvBUnr3uPa6DjTTW6+wYzSYLABg7NPaowW/KNjVov3", + "9U3gVvd7gn9KtOEFNDwJHMy6W7ISDw0F+wK/D03s3bXqd52KevgABTrDTUZvuY1lhSDGX57BEGe9cUeN", + "oQefRtP/07roQa9OpaiQljHRac4+zrSdJYMGDxZiFXhnwYTGH/LAnSbaqC0vITGAlZHAkodDgntIXm/W", + "V76qw2J5vgN7jSMmMIrwA3KPggHKfpfloZ2oE5RBUF6O1MNJOqTLzY/LiOoMAWdcfikhn6BaG3FfjljR", + "x304IV6h2JAJfZoMkPJyzMmx6pZDyaK6BmgzK6bZTAI8Za4Aa8fknamFD72//1Ty/71pEn9ITPK7XNjP", + "jfh/PAfbsL/Nw4vPQ6fP5VZ5ZDmWTvYbNX8C6pIWkKxAKFdkkpKPYGxA5Poqu8pcxEqDpJqTnLzyR6nf", + "5j0GCx04GOakCtx2IPndac1ITkKPtlwd7pmHOaKP1s3FJ+6hp8veTXb9UrtpbK8LUbBE9025zLL5DJtI", + "F0/spP65QXfWIb5p63znTruyLyxQU+ydmx1Ein/rxZ1yOnptex+Prb+ymHvjqNPPVPXvZ1+k7cdMfTcB", + "O3spsP3EjgDuzhO1TboOuBDiR87qMD5LCM++MeQrf95ovzmsV2dCfb2KlX05neQhAHaWejgTy5coaRrv", + "l18BL1W57JLT6ZbLXQlnHU4vCI2uItC802z0HDkrMpd6JF0U9FAx9nWg7mZcZR1U7YCba1C3KX6F3enX", + "3vnWrCyYfyFCLmoPT392nPk/zv/9mFDJEkEl3YEAiQlIphWXfvdo/pfc9A/M6EsALQpVSXQvAYbDR1rG", + "jIS46rv6nwAAAP//Xglf8ZgWAAA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/apps/product-query-svc/adapters/inbound/http/presenters.go b/apps/product-query-svc/adapters/inbound/http/presenters.go new file mode 100644 index 0000000..ab5c40b --- /dev/null +++ b/apps/product-query-svc/adapters/inbound/http/presenters.go @@ -0,0 +1,52 @@ +package httpadapter + +import ( + "math" + + "github.com/fightingBald/GoTuto/apps/product-query-svc/domain" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +func presentProduct(p *domain.Product) Product { + if p == nil { + return Product{} + } + return Product{ + Id: p.ID, + Name: p.Name, + Price: centsToAmount(p.Price), + } +} + +func presentProducts(items []domain.Product) []Product { + if len(items) == 0 { + return []Product{} + } + out := make([]Product, 0, len(items)) + for i := range items { + out = append(out, presentProduct(&items[i])) + } + return out +} + +func centsToAmount(cents int64) float32 { + return float32(cents) / 100.0 +} + +func amountToCents(amount float32) int64 { + return int64(math.Round(float64(amount) * 100.0)) +} + +func presentUser(u *domain.User) User { + if u == nil { + return User{} + } + id := u.ID + createdAt := u.CreatedAt.UTC() + return User{ + Id: &id, + Name: u.Name, + Email: openapi_types.Email(u.Email), + CreatedAt: &createdAt, + } +} 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 e9687e2..5822a22 100644 --- a/apps/product-query-svc/adapters/inbound/http/response_helpers.go +++ b/apps/product-query-svc/adapters/inbound/http/response_helpers.go @@ -2,7 +2,11 @@ package httpadapter import ( "encoding/json" + "errors" "net/http" + "strings" + + "github.com/fightingBald/GoTuto/apps/product-query-svc/domain" ) func writeJSON(w http.ResponseWriter, status int, v any) { @@ -23,3 +27,31 @@ type errorBody struct { func writeError(w http.ResponseWriter, status int, code, message string) { writeJSON(w, status, errorBody{Code: code, Message: message}) } + +func writeDomainError(w http.ResponseWriter, err error) { + status, code := classifyDomainError(err) + writeError(w, status, code, domainErrorMessage(status, err)) +} + +func classifyDomainError(err error) (int, string) { + switch { + case errors.Is(err, domain.ErrValidation): + return http.StatusBadRequest, "VALIDATION" + case errors.Is(err, domain.ErrNotFound): + return http.StatusNotFound, "NOT_FOUND" + default: + return http.StatusInternalServerError, "INTERNAL" + } +} + +func domainErrorMessage(status int, err error) string { + if status == http.StatusInternalServerError { + return http.StatusText(status) + } + if errors.Is(err, domain.ErrValidation) { + if split := strings.SplitN(err.Error(), "\n", 2); len(split) == 2 { + return split[1] + } + } + return err.Error() +} diff --git a/apps/product-query-svc/adapters/inbound/http/server.go b/apps/product-query-svc/adapters/inbound/http/server.go new file mode 100644 index 0000000..a479e90 --- /dev/null +++ b/apps/product-query-svc/adapters/inbound/http/server.go @@ -0,0 +1,23 @@ +package httpadapter + +import ( + "net/http" + + "github.com/fightingBald/GoTuto/apps/product-query-svc/ports" +) + +// Server wires product and user services to HTTP handlers generated from OpenAPI. +type Server struct { + products ports.ProductService + users ports.UserService +} + +func NewServer(products ports.ProductService, users ports.UserService) *Server { + return &Server{products: products, users: users} +} + +// Health 健康检查 +func (s *Server) Health(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) +} 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 a5ce130..71e1d82 100644 --- a/apps/product-query-svc/adapters/outbound/inmem/product_repository.go +++ b/apps/product-query-svc/adapters/outbound/inmem/product_repository.go @@ -4,31 +4,38 @@ import ( "context" "strings" "sync" + "time" "github.com/fightingBald/GoTuto/apps/product-query-svc/domain" - "github.com/fightingBald/GoTuto/apps/product-query-svc/ports" ) // 简单的内存实现,用于本地开发/测试和示例 wiring type InMemRepo struct { - mu sync.RWMutex - data map[int64]domain.Product - next int64 + mu sync.RWMutex + products map[int64]domain.Product + nextProduct int64 + users map[int64]domain.User } -func NewInMemRepo() ports.ProductRepo { - r := &InMemRepo{data: make(map[int64]domain.Product), next: 1} +func NewInMemRepo() *InMemRepo { + r := &InMemRepo{ + products: make(map[int64]domain.Product), + nextProduct: 1, + users: make(map[int64]domain.User), + } // seed demo data - r.data[1] = domain.Product{ID: 1, Name: "Blue Widget", Price: 1999} - r.data[2] = domain.Product{ID: 2, Name: "Red Gizmo", Price: 2999} - r.next = 3 + r.products[1] = domain.Product{ID: 1, Name: "Blue Widget", Price: 1999} + r.products[2] = domain.Product{ID: 2, Name: "Red Gizmo", Price: 2999} + r.nextProduct = 3 + r.users[1] = domain.User{ID: 1, Name: "Alice", Email: "alice@example.com", CreatedAt: time.Date(2024, time.January, 10, 12, 0, 0, 0, time.UTC)} + r.users[2] = domain.User{ID: 2, Name: "Bob", Email: "bob@example.com", CreatedAt: time.Date(2024, time.January, 11, 9, 30, 0, 0, time.UTC)} return r } func (r *InMemRepo) GetByID(ctx context.Context, id int64) (*domain.Product, error) { r.mu.RLock() defer r.mu.RUnlock() - p, ok := r.data[id] + p, ok := r.products[id] if !ok { return nil, domain.ErrNotFound } @@ -47,7 +54,7 @@ func (r *InMemRepo) Search(ctx context.Context, q string, page, pageSize int) ([ r.mu.RLock() defer r.mu.RUnlock() var filtered []domain.Product - for _, p := range r.data { + for _, p := range r.products { if q == "" || strings.Contains(strings.ToLower(p.Name), q) { filtered = append(filtered, p) } @@ -67,29 +74,40 @@ func (r *InMemRepo) Search(ctx context.Context, q string, page, pageSize int) ([ func (r *InMemRepo) Create(ctx context.Context, p *domain.Product) (int64, error) { r.mu.Lock() defer r.mu.Unlock() - id := r.next + id := r.nextProduct p.ID = id - r.data[id] = *p - r.next = id + 1 + r.products[id] = *p + r.nextProduct = id + 1 return id, nil } func (r *InMemRepo) Delete(ctx context.Context, id int64) error { r.mu.Lock() defer r.mu.Unlock() - if _, ok := r.data[id]; !ok { + if _, ok := r.products[id]; !ok { return domain.ErrNotFound } - delete(r.data, id) + delete(r.products, 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 { + if _, ok := r.products[p.ID]; !ok { return domain.ErrNotFound } - r.data[p.ID] = *p + r.products[p.ID] = *p return nil } + +func (r *InMemRepo) GetUserByID(ctx context.Context, id int64) (*domain.User, error) { + r.mu.RLock() + defer r.mu.RUnlock() + u, ok := r.users[id] + if !ok { + return nil, domain.ErrNotFound + } + uu := u + return &uu, nil +} diff --git a/apps/product-query-svc/adapters/outbound/postgres/repo_docker_test.go b/apps/product-query-svc/adapters/outbound/postgres/repo_docker_test.go index 4dab983..c7b3a32 100644 --- a/apps/product-query-svc/adapters/outbound/postgres/repo_docker_test.go +++ b/apps/product-query-svc/adapters/outbound/postgres/repo_docker_test.go @@ -3,113 +3,119 @@ package postgres import ( - "context" - "os" - "path/filepath" - "sort" - "testing" - "time" - "io/ioutil" - "strings" + "context" + "io/ioutil" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/fightingBald/GoTuto/apps/product-query-svc/domain" - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/wait" + "github.com/fightingBald/GoTuto/apps/product-query-svc/domain" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" ) // applyMigrations runs all *.up.sql files in the migrations directory in lexicographic order. func applyMigrations(ctx context.Context, pool *pgxpool.Pool, dir string, t *testing.T) { - entries, err := ioutil.ReadDir(dir) - if err != nil { - t.Fatalf("read migrations dir: %v", err) - } - var files []string - for _, e := range entries { - if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") { - files = append(files, filepath.Join(dir, e.Name())) - } - } - sort.Strings(files) - for _, f := range files { - b, err := os.ReadFile(f) - if err != nil { - t.Fatalf("read %s: %v", f, err) - } - if _, err := pool.Exec(ctx, string(b)); err != nil { - t.Fatalf("exec migration %s: %v", f, err) - } - } + entries, err := ioutil.ReadDir(dir) + if err != nil { + t.Fatalf("read migrations dir: %v", err) + } + var files []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") { + files = append(files, filepath.Join(dir, e.Name())) + } + } + sort.Strings(files) + for _, f := range files { + b, err := os.ReadFile(f) + if err != nil { + t.Fatalf("read %s: %v", f, err) + } + if _, err := pool.Exec(ctx, string(b)); err != nil { + t.Fatalf("exec migration %s: %v", f, err) + } + } } func TestPostgresRepo_WithDocker(t *testing.T) { - if os.Getenv("SKIP_DOCKER_TESTS") == "1" { - t.Skip("skipped by SKIP_DOCKER_TESTS=1") - } + if os.Getenv("SKIP_DOCKER_TESTS") == "1" { + t.Skip("skipped by SKIP_DOCKER_TESTS=1") + } - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() - // Start Postgres container - req := testcontainers.ContainerRequest{ - Image: "postgres:16-alpine", - ExposedPorts: []string{"5432/tcp"}, - Env: map[string]string{ - "POSTGRES_USER": "app", - "POSTGRES_PASSWORD": "app_password", - "POSTGRES_DB": "productdb", - }, - WaitingFor: wait.ForListeningPort("5432/tcp").WithStartupTimeout(60 * time.Second), - } - pgC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: req, Started: true}) - if err != nil { - t.Fatalf("start container: %v", err) - } - defer func() { _ = pgC.Terminate(context.Background()) }() + // Start Postgres container + req := testcontainers.ContainerRequest{ + Image: "postgres:16-alpine", + ExposedPorts: []string{"5432/tcp"}, + Env: map[string]string{ + "POSTGRES_USER": "app", + "POSTGRES_PASSWORD": "app_password", + "POSTGRES_DB": "productdb", + }, + WaitingFor: wait.ForListeningPort("5432/tcp").WithStartupTimeout(60 * time.Second), + } + pgC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: req, Started: true}) + if err != nil { + t.Fatalf("start container: %v", err) + } + defer func() { _ = pgC.Terminate(context.Background()) }() - host, err := pgC.Host(ctx) - if err != nil { t.Fatalf("host: %v", err) } - port, err := pgC.MappedPort(ctx, "5432/tcp") - if err != nil { t.Fatalf("mapped port: %v", err) } + host, err := pgC.Host(ctx) + if err != nil { + t.Fatalf("host: %v", err) + } + port, err := pgC.MappedPort(ctx, "5432/tcp") + if err != nil { + t.Fatalf("mapped port: %v", err) + } - dsn := "postgres://app:app_password@" + host + ":" + port.Port() + "/productdb?sslmode=disable" + dsn := "postgres://app:app_password@" + host + ":" + port.Port() + "/productdb?sslmode=disable" - // Connect pool - pool, err := pgxpool.New(ctx, dsn) - if err != nil { t.Fatalf("pgxpool.New: %v", err) } - defer pool.Close() + // Connect pool + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("pgxpool.New: %v", err) + } + defer pool.Close() - // Apply migrations from local dir (same package directory) - migDir := filepath.Join("migrations") - applyMigrations(ctx, pool, migDir, t) + // Apply migrations from local dir (same package directory) + migDir := filepath.Join("migrations") + applyMigrations(ctx, pool, migDir, t) - // Run a few repo operations - repo := NewProductRepository(pool) + // Run a few repo operations + repo := NewProductRepository(pool) - // Search should work on seeded data (may be empty if seeds change) - if items, total, err := repo.Search(ctx, "pro", 1, 10); err != nil { - t.Fatalf("repo.Search: %v", err) - } else if total < 0 || len(items) < 0 { // sanity - t.Fatalf("unexpected search result: total=%d items=%d", total, len(items)) - } + // Search should work on seeded data (may be empty if seeds change) + if items, total, err := repo.Search(ctx, "pro", 1, 10); err != nil { + t.Fatalf("repo.Search: %v", err) + } else if total < 0 || len(items) < 0 { // sanity + t.Fatalf("unexpected search result: total=%d items=%d", total, len(items)) + } - // Create -> Get -> Delete roundtrip - id, err := repo.Create(ctx, &domain.Product{ - Name: "DockerTest", - Price: 1234, - Tags: []string{"tc"}, - }) - if err != nil { - t.Fatalf("repo.Create: %v", err) - } - p, err := repo.GetByID(ctx, id) - if err != nil { - t.Fatalf("repo.GetByID: %v", err) - } - if p.ID != id || p.Name != "DockerTest" || p.Price != 1234 { - t.Fatalf("unexpected product: %#v", p) - } - if err := repo.Delete(ctx, id); err != nil { - t.Fatalf("repo.Delete: %v", err) - } + // Create -> Get -> Delete roundtrip + id, err := repo.Create(ctx, &domain.Product{ + Name: "DockerTest", + Price: 1234, + Tags: []string{"tc"}, + }) + if err != nil { + t.Fatalf("repo.Create: %v", err) + } + p, err := repo.GetByID(ctx, id) + if err != nil { + t.Fatalf("repo.GetByID: %v", err) + } + if p.ID != id || p.Name != "DockerTest" || p.Price != 1234 { + t.Fatalf("unexpected product: %#v", p) + } + if err := repo.Delete(ctx, id); err != nil { + t.Fatalf("repo.Delete: %v", err) + } } diff --git a/apps/product-query-svc/adapters/outbound/postgres/user_repository.go b/apps/product-query-svc/adapters/outbound/postgres/user_repository.go new file mode 100644 index 0000000..6bd0e6c --- /dev/null +++ b/apps/product-query-svc/adapters/outbound/postgres/user_repository.go @@ -0,0 +1,32 @@ +package postgres + +import ( + "context" + "errors" + + "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" +) + +type PGUserRepo struct{ pool *pgxpool.Pool } + +func NewUserRepository(pool *pgxpool.Pool) ports.UserRepo { return &PGUserRepo{pool: pool} } + +func (r *PGUserRepo) GetUserByID(ctx context.Context, id int64) (*domain.User, error) { + q, args, err := psql.Select("id", "name", "email", "created_at").From("users").Where(squirrel.Eq{"id": id}).ToSql() + if err != nil { + return nil, err + } + var u domain.User + if err := r.pool.QueryRow(ctx, q, args...).Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrNotFound + } + return nil, err + } + u.CreatedAt = u.CreatedAt.UTC() + return &u, nil +} diff --git a/apps/product-query-svc/app/user_service.go b/apps/product-query-svc/app/user_service.go new file mode 100644 index 0000000..8a8440b --- /dev/null +++ b/apps/product-query-svc/app/user_service.go @@ -0,0 +1,22 @@ +package app + +import ( + "context" + "errors" + + "github.com/fightingBald/GoTuto/apps/product-query-svc/domain" + "github.com/fightingBald/GoTuto/apps/product-query-svc/ports" +) + +type UserService struct { + repo ports.UserRepo +} + +func NewUserService(r ports.UserRepo) *UserService { return &UserService{repo: r} } + +func (s *UserService) GetUser(ctx context.Context, id int64) (*domain.User, error) { + if id <= 0 { + return nil, errors.Join(domain.ErrValidation, errors.New("id must be a positive integer")) + } + return s.repo.GetUserByID(ctx, id) +} diff --git a/apps/product-query-svc/domain/product.go b/apps/product-query-svc/domain/product.go index 82378c2..f448341 100644 --- a/apps/product-query-svc/domain/product.go +++ b/apps/product-query-svc/domain/product.go @@ -1,77 +1,136 @@ package domain -import "errors" +import ( + "errors" + "strings" +) // 领域错误(供适配器映射状态码) var ( - ErrValidation = errors.New("validation error") - ErrNotFound = errors.New("not found") + ErrValidation = errors.New("validation error") + ErrNotFound = errors.New("not found") ) +// Product 是领域聚合根,Price 以分为单位避免浮点误差。 type Product struct { - ID int64 - Name string - Price int64 // 分为单位(避免浮点) - Tags []string + ID int64 + Name string + Price int64 + Tags []string } -// 工厂方法:统一创建入口,保证不变式 +const maxTags = 5 + +// NewProduct 统一入口,构建并校验不变式。 func NewProduct(name string, priceCents int64, tags []string) (*Product, error) { - p := &Product{Name: name, Price: priceCents} - if len(tags) > 0 { - // 去重 - seen := map[string]struct{}{} - for _, t := range tags { - if t == "" { continue } - if _, ok := seen[t]; ok { continue } - seen[t] = struct{}{} - p.Tags = append(p.Tags, t) - } - } - if err := p.Validate(); err != nil { - return nil, err - } - return p, nil + p := &Product{Name: strings.TrimSpace(name), Price: priceCents} + if err := p.replaceTags(tags); err != nil { + return nil, err + } + if err := p.Validate(); err != nil { + return nil, err + } + return p, nil } -// 基础不变式校验 +// Validate 检查核心不变式。 func (p *Product) Validate() error { - if p.Name == "" { - return ErrValidation - } - if p.Price < 0 { - return ErrValidation - } - return nil + if p.Name == "" { + return errValidation("name required") + } + if p.Price < 0 { + return errValidation("price must be >= 0") + } + if len(p.Tags) > maxTags { + return errValidation("tags exceed limit") + } + return nil } -// 富领域行为示例:修改价格(非负) -func (p *Product) ChangePrice(newPriceCents int64) error { - if newPriceCents < 0 { - return ErrValidation - } - p.Price = newPriceCents - return nil +// ChangePrice 变更价格(分为单位)。 +func (p *Product) ChangePrice(newPrice int64) error { + if newPrice < 0 { + return errValidation("price must be >= 0") + } + p.Price = newPrice + return nil } -// 富领域行为示例:添加标签(去重,最多 5 个) +// AddTag 添加标签,自动去重并限制数量。 func (p *Product) AddTag(tag string) error { - if tag == "" { return nil } - for _, t := range p.Tags { - if t == tag { return nil } - } - if len(p.Tags) >= 5 { - return ErrValidation - } - p.Tags = append(p.Tags, tag) - return nil + cleaned := strings.TrimSpace(tag) + if cleaned == "" { + return nil + } + for _, t := range p.Tags { + if equalFold(t, cleaned) { + return nil + } + } + if len(p.Tags) >= maxTags { + return errValidation("tags exceed limit") + } + p.Tags = append(p.Tags, cleaned) + return nil } -// 富领域行为示例:移除标签 +// RemoveTag 移除指定标签(按不区分大小写匹配)。 func (p *Product) RemoveTag(tag string) { - out := p.Tags[:0] - for _, t := range p.Tags { - if t != tag { out = append(out, t) } - } - p.Tags = out + cleaned := strings.TrimSpace(tag) + if cleaned == "" { + return + } + out := p.Tags[:0] + for _, t := range p.Tags { + if !equalFold(t, cleaned) { + out = append(out, t) + } + } + p.Tags = out +} + +// replaceTags 重建标签列表(调用方负责去重复构建)。 +func (p *Product) replaceTags(tags []string) error { + sanitized, err := sanitizeTags(tags) + if err != nil { + return err + } + p.Tags = sanitized + return nil +} + +// errValidation 构造带细节的校验错误。 +func errValidation(msg string) error { + return errors.Join(ErrValidation, errors.New(msg)) +} + +func equalFold(a, b string) bool { + return strings.EqualFold(strings.TrimSpace(a), strings.TrimSpace(b)) +} + +func sanitizeTags(tags []string) ([]string, error) { + if len(tags) == 0 { + return nil, nil + } + seen := make(map[string]struct{}, len(tags)) + sanitized := make([]string, 0, len(tags)) + for _, raw := range tags { + cleaned := strings.TrimSpace(raw) + if cleaned == "" { + continue + } + key := strings.ToLower(cleaned) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + sanitized = append(sanitized, cleaned) + if len(sanitized) > maxTags { + return nil, errValidation("tags exceed limit") + } + } + if len(sanitized) == 0 { + return nil, nil + } + return sanitized, nil } diff --git a/apps/product-query-svc/domain/users.go b/apps/product-query-svc/domain/users.go new file mode 100644 index 0000000..9ba0d27 --- /dev/null +++ b/apps/product-query-svc/domain/users.go @@ -0,0 +1,55 @@ +package domain + +import ( + "regexp" + "strings" + "time" +) + +var emailRegexp = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) + +func IsValidEmail(email string) bool { + return emailRegexp.MatchString(strings.TrimSpace(email)) +} + +type User struct { + ID int64 + Name string + Email string + CreatedAt time.Time +} + +func NewUser(name string, email string) (*User, error) { + u := &User{ + Name: strings.TrimSpace(name), + Email: strings.TrimSpace(email), + CreatedAt: time.Now().UTC(), + } + + if err := u.Validate(); err != nil { + return nil, err + } + + return u, nil +} + +func (u *User) Validate() error { + if u.Name == "" { + return errValidation("name required") + } + if !IsValidEmail(u.Email) { + return errValidation("invalid email format") + } + return nil +} + +//TODO how to avoid a same email could create several account + +func (u *User) ChangeName(newName string) error { + cleaned := strings.TrimSpace(newName) + if cleaned == "" { + return errValidation("name required") + } + u.Name = cleaned + return nil +} diff --git a/apps/product-query-svc/ports/inbound.go b/apps/product-query-svc/ports/inbound.go index 945ad7d..4b39cc9 100644 --- a/apps/product-query-svc/ports/inbound.go +++ b/apps/product-query-svc/ports/inbound.go @@ -21,3 +21,8 @@ type ProductService interface { // UpdateProduct replaces the existing product state and returns the updated snapshot UpdateProduct(ctx context.Context, p *domain.Product) (*domain.Product, error) } + +// UserService exposes user-related use cases to driving adapters. +type UserService interface { + GetUser(ctx context.Context, id int64) (*domain.User, error) +} diff --git a/apps/product-query-svc/ports/outbound.go b/apps/product-query-svc/ports/outbound.go index 5e58844..4c8b44b 100644 --- a/apps/product-query-svc/ports/outbound.go +++ b/apps/product-query-svc/ports/outbound.go @@ -16,3 +16,8 @@ type ProductRepo interface { Delete(ctx context.Context, id int64) error Update(ctx context.Context, p *domain.Product) error } + +// UserRepo abstracts access to persistent user data. +type UserRepo interface { + GetUserByID(ctx context.Context, id int64) (*domain.User, error) +} diff --git a/backend/cmd/marketplace/product-query-svc/main.go b/backend/cmd/marketplace/product-query-svc/main.go index b9497d3..e2fa348 100644 --- a/backend/cmd/marketplace/product-query-svc/main.go +++ b/backend/cmd/marketplace/product-query-svc/main.go @@ -39,8 +39,9 @@ func main() { log.Println("starting product-query-svc") var ( - repo ports.ProductRepo - pool *pgxpool.Pool + repo ports.ProductRepo + userRepo ports.UserRepo + pool *pgxpool.Pool ) // If DSN provided, use Postgres wiring @@ -55,14 +56,18 @@ func main() { pool = p repo = appspg.NewProductRepository(pool) + userRepo = appspg.NewUserRepository(pool) } else { - repo = appsinmem.NewInMemRepo() + store := appsinmem.NewInMemRepo() + repo = store + userRepo = store } // build service - svc := appsvc.NewProductService(repo) + productSvc := appsvc.NewProductService(repo) + userSvc := appsvc.NewUserService(userRepo) - server := appshttp.NewServer(svc) + server := appshttp.NewServer(productSvc, userSvc) r := chi.NewRouter() // 注册健康检查 diff --git a/internal/testutil/httpserver.go b/internal/testutil/httpserver.go index 16c46e2..78a98cf 100644 --- a/internal/testutil/httpserver.go +++ b/internal/testutil/httpserver.go @@ -1,26 +1,26 @@ package testutil import ( - "net/http" - "net/http/httptest" + "net/http" + "net/http/httptest" - app "github.com/fightingBald/GoTuto/apps/product-query-svc/app" - httpadapter "github.com/fightingBald/GoTuto/apps/product-query-svc/adapters/inbound/http" - "github.com/fightingBald/GoTuto/apps/product-query-svc/ports" - "github.com/go-chi/chi/v5" + httpadapter "github.com/fightingBald/GoTuto/apps/product-query-svc/adapters/inbound/http" + app "github.com/fightingBald/GoTuto/apps/product-query-svc/app" + "github.com/fightingBald/GoTuto/apps/product-query-svc/ports" + "github.com/go-chi/chi/v5" ) -// NewHTTPHandlerWithRepo wires repo -> service -> http server handler. -func NewHTTPHandlerWithRepo(repo ports.ProductRepo) http.Handler { - svc := app.NewProductService(repo) - server := httpadapter.NewServer(svc) - r := chi.NewRouter() - return httpadapter.HandlerFromMux(server, r) +// NewHTTPHandler wires repos -> services -> HTTP handler. +func NewHTTPHandler(productRepo ports.ProductRepo, userRepo ports.UserRepo) http.Handler { + productSvc := app.NewProductService(productRepo) + userSvc := app.NewUserService(userRepo) + server := httpadapter.NewServer(productSvc, userSvc) + r := chi.NewRouter() + return httpadapter.HandlerFromMux(server, r) } -// NewHTTPServerWithRepo starts an httptest.Server for convenience. -func NewHTTPServerWithRepo(repo ports.ProductRepo) *httptest.Server { - h := NewHTTPHandlerWithRepo(repo) - return httptest.NewServer(h) +// NewHTTPServer starts an httptest.Server for convenience. +func NewHTTPServer(productRepo ports.ProductRepo, userRepo ports.UserRepo) *httptest.Server { + h := NewHTTPHandler(productRepo, userRepo) + return httptest.NewServer(h) } - diff --git a/internal/testutil/postgres.go b/internal/testutil/postgres.go index 14483e0..f649902 100644 --- a/internal/testutil/postgres.go +++ b/internal/testutil/postgres.go @@ -1,167 +1,203 @@ package testutil import ( - "context" - "os" - "path/filepath" - "sort" - "strings" - "testing" + "context" + "os" + "path/filepath" + "sort" + "strings" + "testing" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/wait" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" ) // DSNFromEnvOrDocker returns a Postgres DSN. If DATABASE_URL is set, it is used. // Otherwise a temporary Docker Postgres is started. The boolean indicates // whether a temp container was started (call cleanup when true). func DSNFromEnvOrDocker(ctx context.Context, t testing.TB) (dsn string, isTemp bool, cleanup func()) { - t.Helper() - if v := os.Getenv("DATABASE_URL"); v != "" { - return v, false, func() {} - } - dsn, cleanup = StartDockerPostgres(ctx, t) - return dsn, true, cleanup + t.Helper() + if v := os.Getenv("DATABASE_URL"); v != "" { + return v, false, func() {} + } + dsn, cleanup = StartDockerPostgres(ctx, t) + return dsn, true, cleanup } // StartDockerPostgres launches a temporary Postgres container and returns the DSN and cleanup. func StartDockerPostgres(ctx context.Context, t testing.TB) (dsn string, cleanup func()) { - t.Helper() - req := testcontainers.ContainerRequest{ - Image: "postgres:16-alpine", - ExposedPorts: []string{"5432/tcp"}, - Env: map[string]string{ - "POSTGRES_USER": "app", - "POSTGRES_PASSWORD": "app_password", - "POSTGRES_DB": "productdb", - }, - WaitingFor: wait.ForListeningPort("5432/tcp"), - } - pgC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: req, Started: true}) - if err != nil { t.Fatalf("start container: %v", err) } - host, err := pgC.Host(ctx) - if err != nil { t.Fatalf("host: %v", err) } - port, err := pgC.MappedPort(ctx, "5432/tcp") - if err != nil { t.Fatalf("mapped port: %v", err) } - cleanup = func() { _ = pgC.Terminate(context.Background()) } - dsn = "postgres://app:app_password@" + host + ":" + port.Port() + "/productdb?sslmode=disable" - return dsn, cleanup + t.Helper() + req := testcontainers.ContainerRequest{ + Image: "postgres:16-alpine", + ExposedPorts: []string{"5432/tcp"}, + Env: map[string]string{ + "POSTGRES_USER": "app", + "POSTGRES_PASSWORD": "app_password", + "POSTGRES_DB": "productdb", + }, + WaitingFor: wait.ForListeningPort("5432/tcp"), + } + pgC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: req, Started: true}) + if err != nil { + t.Fatalf("start container: %v", err) + } + host, err := pgC.Host(ctx) + if err != nil { + t.Fatalf("host: %v", err) + } + port, err := pgC.MappedPort(ctx, "5432/tcp") + if err != nil { + t.Fatalf("mapped port: %v", err) + } + cleanup = func() { _ = pgC.Terminate(context.Background()) } + dsn = "postgres://app:app_password@" + host + ":" + port.Port() + "/productdb?sslmode=disable" + return dsn, cleanup } // StartDockerPostgresMain is a variant for TestMain usage (no testing.TB). func StartDockerPostgresMain(ctx context.Context) (dsn string, cleanup func(), err error) { - req := testcontainers.ContainerRequest{ - Image: "postgres:16-alpine", - ExposedPorts: []string{"5432/tcp"}, - Env: map[string]string{ - "POSTGRES_USER": "app", - "POSTGRES_PASSWORD": "app_password", - "POSTGRES_DB": "productdb", - }, - WaitingFor: wait.ForListeningPort("5432/tcp"), - } - pgC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: req, Started: true}) - if err != nil { return "", func(){}, err } - host, err := pgC.Host(ctx) - if err != nil { _ = pgC.Terminate(context.Background()); return "", func(){}, err } - port, err := pgC.MappedPort(ctx, "5432/tcp") - if err != nil { _ = pgC.Terminate(context.Background()); return "", func(){}, err } - cleanup = func() { _ = pgC.Terminate(context.Background()) } - dsn = "postgres://app:app_password@" + host + ":" + port.Port() + "/productdb?sslmode=disable" - return dsn, cleanup, nil + req := testcontainers.ContainerRequest{ + Image: "postgres:16-alpine", + ExposedPorts: []string{"5432/tcp"}, + Env: map[string]string{ + "POSTGRES_USER": "app", + "POSTGRES_PASSWORD": "app_password", + "POSTGRES_DB": "productdb", + }, + WaitingFor: wait.ForListeningPort("5432/tcp"), + } + pgC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: req, Started: true}) + if err != nil { + return "", func() {}, err + } + host, err := pgC.Host(ctx) + if err != nil { + _ = pgC.Terminate(context.Background()) + return "", func() {}, err + } + port, err := pgC.MappedPort(ctx, "5432/tcp") + if err != nil { + _ = pgC.Terminate(context.Background()) + return "", func() {}, err + } + cleanup = func() { _ = pgC.Terminate(context.Background()) } + dsn = "postgres://app:app_password@" + host + ":" + port.Port() + "/productdb?sslmode=disable" + return dsn, cleanup, nil } // ApplyMigrations applies repository migrations in order to the given pool. // It looks for migrations under apps/product-query-svc/adapters/outbound/postgres/migrations // starting from the module root. func ApplyMigrations(ctx context.Context, t testing.TB, pool *pgxpool.Pool) { - t.Helper() - root := moduleRoot(t) - migDir := filepath.Join(root, "apps", "product-query-svc", "adapters", "outbound", "postgres", "migrations") - entries, err := os.ReadDir(migDir) - if err != nil { t.Fatalf("read dir: %v", err) } - var files []string - for _, e := range entries { - if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") { - files = append(files, filepath.Join(migDir, e.Name())) - } - } - sort.Strings(files) - for _, f := range files { - b, err := os.ReadFile(f) - if err != nil { t.Fatalf("read %s: %v", f, err) } - if _, err := pool.Exec(ctx, string(b)); err != nil { t.Fatalf("exec %s: %v", f, err) } - } + t.Helper() + root := moduleRoot(t) + migDir := filepath.Join(root, "apps", "product-query-svc", "adapters", "outbound", "postgres", "migrations") + entries, err := os.ReadDir(migDir) + if err != nil { + t.Fatalf("read dir: %v", err) + } + var files []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") { + files = append(files, filepath.Join(migDir, e.Name())) + } + } + sort.Strings(files) + for _, f := range files { + b, err := os.ReadFile(f) + if err != nil { + t.Fatalf("read %s: %v", f, err) + } + if _, err := pool.Exec(ctx, string(b)); err != nil { + t.Fatalf("exec %s: %v", f, err) + } + } } // ApplyMigrationsMain is a variant for TestMain usage (no testing.TB). func ApplyMigrationsMain(ctx context.Context, pool *pgxpool.Pool) error { - root := moduleRootNoTB() - migDir := filepath.Join(root, "apps", "product-query-svc", "adapters", "outbound", "postgres", "migrations") - entries, err := os.ReadDir(migDir) - if err != nil { return err } - var files []string - for _, e := range entries { - if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") { - files = append(files, filepath.Join(migDir, e.Name())) - } - } - sort.Strings(files) - for _, f := range files { - b, err := os.ReadFile(f) - if err != nil { return err } - if _, err := pool.Exec(ctx, string(b)); err != nil { return err } - } - return nil + root := moduleRootNoTB() + migDir := filepath.Join(root, "apps", "product-query-svc", "adapters", "outbound", "postgres", "migrations") + entries, err := os.ReadDir(migDir) + if err != nil { + return err + } + var files []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") { + files = append(files, filepath.Join(migDir, e.Name())) + } + } + sort.Strings(files) + for _, f := range files { + b, err := os.ReadFile(f) + if err != nil { + return err + } + if _, err := pool.Exec(ctx, string(b)); err != nil { + return err + } + } + return nil } // NewPool creates a pgxpool.Pool and fails the test on error. func NewPool(ctx context.Context, t testing.TB, dsn string) *pgxpool.Pool { - t.Helper() - pool, err := pgxpool.New(ctx, dsn) - if err != nil { t.Fatalf("pgxpool.New: %v", err) } - return pool + t.Helper() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("pgxpool.New: %v", err) + } + return pool } // NewPoolMain is a variant for TestMain usage (no testing.TB). func NewPoolMain(ctx context.Context, dsn string) (*pgxpool.Pool, error) { - return pgxpool.New(ctx, dsn) + return pgxpool.New(ctx, dsn) } // moduleRoot returns the directory containing go.mod by walking up from CWD. func moduleRoot(t testing.TB) string { - t.Helper() - dir, err := os.Getwd() - if err != nil { t.Fatalf("getwd: %v", err) } - for { - if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { - return dir - } - parent := filepath.Dir(dir) - if parent == dir { t.Fatalf("go.mod not found from %s", dir) } - dir = parent - } + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("go.mod not found from %s", dir) + } + dir = parent + } } func moduleRootNoTB() string { - dir, _ := os.Getwd() - for { - if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { - return dir - } - parent := filepath.Dir(dir) - if parent == dir { return dir } - dir = parent - } + dir, _ := os.Getwd() + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + return dir + } + dir = parent + } } // DSNFromEnvOrDockerMain returns DSN, and if started temp, a cleanup. func DSNFromEnvOrDockerMain(ctx context.Context) (dsn string, isTemp bool, cleanup func(), err error) { - if v := os.Getenv("DATABASE_URL"); v != "" { - return v, false, func(){}, nil - } - dsn, cleanup, err = StartDockerPostgresMain(ctx) - if err != nil { return "", false, func(){}, err } - return dsn, true, cleanup, nil + if v := os.Getenv("DATABASE_URL"); v != "" { + return v, false, func() {}, nil + } + dsn, cleanup, err = StartDockerPostgresMain(ctx) + if err != nil { + return "", false, func() {}, err + } + return dsn, true, cleanup, nil } diff --git a/test/http_inmem/delete_test.go b/test/http_inmem/delete_test.go index 21fcc84..f471a47 100644 --- a/test/http_inmem/delete_test.go +++ b/test/http_inmem/delete_test.go @@ -9,7 +9,8 @@ import ( ) func TestDeleteProduct_InMem(t *testing.T) { - ts := testutil.NewHTTPServerWithRepo(appsinmem.NewInMemRepo()) + store := appsinmem.NewInMemRepo() + ts := testutil.NewHTTPServer(store, store) defer ts.Close() t.Run("delete id=1 returns 204", func(t *testing.T) { diff --git a/test/http_inmem/search_test.go b/test/http_inmem/search_test.go index 7786948..ade314d 100644 --- a/test/http_inmem/search_test.go +++ b/test/http_inmem/search_test.go @@ -13,7 +13,8 @@ import ( func TestHTTP_InMem_Product(t *testing.T) { t.Run("search returns items", func(t *testing.T) { - ts := testutil.NewHTTPServerWithRepo(appsinmem.NewInMemRepo()) + store := appsinmem.NewInMemRepo() + ts := testutil.NewHTTPServer(store, store) defer ts.Close() resp, err := http.Get(ts.URL + "/products/search?q=wid&page=1&pageSize=10") @@ -34,7 +35,8 @@ func TestHTTP_InMem_Product(t *testing.T) { }) t.Run("get id=1 returns product", func(t *testing.T) { - ts := testutil.NewHTTPServerWithRepo(appsinmem.NewInMemRepo()) + store := appsinmem.NewInMemRepo() + ts := testutil.NewHTTPServer(store, store) defer ts.Close() resp, err := http.Get(ts.URL + "/products/1") @@ -55,7 +57,8 @@ func TestHTTP_InMem_Product(t *testing.T) { }) t.Run("update id=1 returns updated product", func(t *testing.T) { - ts := testutil.NewHTTPServerWithRepo(appsinmem.NewInMemRepo()) + store := appsinmem.NewInMemRepo() + ts := testutil.NewHTTPServer(store, store) defer ts.Close() body := `{"name":"Updated Widget","price":15.25}` @@ -79,7 +82,8 @@ func TestHTTP_InMem_Product(t *testing.T) { }) t.Run("search with short q returns 400", func(t *testing.T) { - ts := testutil.NewHTTPServerWithRepo(appsinmem.NewInMemRepo()) + store := appsinmem.NewInMemRepo() + ts := testutil.NewHTTPServer(store, store) defer ts.Close() resp, err := http.Get(ts.URL + "/products/search?q=ab") diff --git a/test/http_inmem/user_test.go b/test/http_inmem/user_test.go new file mode 100644 index 0000000..96d5a75 --- /dev/null +++ b/test/http_inmem/user_test.go @@ -0,0 +1,45 @@ +package http_inmem_test + +import ( + "encoding/json" + "net/http" + "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" +) + +func TestGetUserByID_InMem(t *testing.T) { + store := appsinmem.NewInMemRepo() + ts := testutil.NewHTTPServer(store, store) + t.Cleanup(ts.Close) + + resp, err := http.Get(ts.URL + "/users/1") + if err != nil { + t.Fatalf("http get user: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var user appshttp.User + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + t.Fatalf("decode user: %v", err) + } + if user.Id == nil || *user.Id != 1 { + t.Fatalf("unexpected user id: %+v", user) + } + if user.Email != "alice@example.com" { + t.Fatalf("unexpected user email: %+v", user) + } + + resp404, err := http.Get(ts.URL + "/users/9999") + if err != nil { + t.Fatalf("http get user 404: %v", err) + } + defer resp404.Body.Close() + if resp404.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp404.StatusCode) + } +} diff --git a/test/http_pg/create_test.go b/test/http_pg/create_test.go index 04c9f4c..1567680 100644 --- a/test/http_pg/create_test.go +++ b/test/http_pg/create_test.go @@ -28,9 +28,11 @@ func TestCreateProduct_Postgres(t *testing.T) { testutil.ApplyMigrations(ctx, t, pool) } - repo := appspg.NewProductRepository(pool) - svc := appsvc.NewProductService(repo) - server := appshttp.NewServer(svc) + productRepo := appspg.NewProductRepository(pool) + userRepo := appspg.NewUserRepository(pool) + productSvc := appsvc.NewProductService(productRepo) + userSvc := appsvc.NewUserService(userRepo) + server := appshttp.NewServer(productSvc, userSvc) r := chi.NewRouter() h := appshttp.HandlerFromMux(server, r) diff --git a/test/http_pg/pg_fixture_test.go b/test/http_pg/pg_fixture_test.go index f94536b..fa50087 100644 --- a/test/http_pg/pg_fixture_test.go +++ b/test/http_pg/pg_fixture_test.go @@ -1,34 +1,35 @@ package http_pg_test import ( - "context" - "log" - "os" - "testing" - "time" + "context" + "log" + "os" + "testing" + "time" - "github.com/fightingBald/GoTuto/internal/testutil" + "github.com/fightingBald/GoTuto/internal/testutil" ) var ( - pgDSN string - pgTemp bool - pgCleanup func() + pgDSN string + pgTemp bool + pgCleanup func() ) func TestMain(m *testing.M) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() - dsn, isTemp, cleanup, err := testutil.DSNFromEnvOrDockerMain(ctx) - if err != nil { - log.Fatalf("pg fixture: %v", err) - } - pgDSN, pgTemp, pgCleanup = dsn, isTemp, cleanup + dsn, isTemp, cleanup, err := testutil.DSNFromEnvOrDockerMain(ctx) + if err != nil { + log.Fatalf("pg fixture: %v", err) + } + pgDSN, pgTemp, pgCleanup = dsn, isTemp, cleanup - code := m.Run() + code := m.Run() - if pgCleanup != nil { pgCleanup() } - os.Exit(code) + if pgCleanup != nil { + pgCleanup() + } + os.Exit(code) } - diff --git a/test/http_pg/search_test.go b/test/http_pg/search_test.go index 2a5d948..6baebb4 100644 --- a/test/http_pg/search_test.go +++ b/test/http_pg/search_test.go @@ -1,51 +1,63 @@ package http_pg_test import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "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" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "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" ) // TestSearchProducts_Postgres seeds are applied via migrations in dev/CI. // This test requires DATABASE_URL to be set; otherwise it is skipped. func TestSearchProducts_Postgres(t *testing.T) { - 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) } - - repo := appspg.NewProductRepository(pool) - svc := appsvc.NewProductService(repo) - server := appshttp.NewServer(svc) - - r := chi.NewRouter() - h := appshttp.HandlerFromMux(server, r) - - ts := httptest.NewServer(h) - defer ts.Close() - - t.Run("search wid returns 200", func(t *testing.T) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/products/search?q=wid&page=1&pageSize=10", nil) - if err != nil { t.Fatalf("new request: %v", err) } - resp, err := http.DefaultClient.Do(req) - if err != nil { t.Fatalf("http do: %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 pl.Total < len(pl.Items) { - t.Fatalf("expected total >= items length; got total=%d items=%d", pl.Total, len(pl.Items)) - } - }) + 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) + } + + productRepo := appspg.NewProductRepository(pool) + userRepo := appspg.NewUserRepository(pool) + productSvc := appsvc.NewProductService(productRepo) + userSvc := appsvc.NewUserService(userRepo) + server := appshttp.NewServer(productSvc, userSvc) + + r := chi.NewRouter() + h := appshttp.HandlerFromMux(server, r) + + ts := httptest.NewServer(h) + defer ts.Close() + + t.Run("search wid returns 200", func(t *testing.T) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/products/search?q=wid&page=1&pageSize=10", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("http do: %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 pl.Total < len(pl.Items) { + t.Fatalf("expected total >= items length; got total=%d items=%d", pl.Total, len(pl.Items)) + } + }) } diff --git a/test/http_pg/user_test.go b/test/http_pg/user_test.go new file mode 100644 index 0000000..928e6b5 --- /dev/null +++ b/test/http_pg/user_test.go @@ -0,0 +1,58 @@ +package http_pg_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "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" +) + +func TestGetUserByID_Postgres(t *testing.T) { + 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) + } + + productRepo := appspg.NewProductRepository(pool) + userRepo := appspg.NewUserRepository(pool) + productSvc := appsvc.NewProductService(productRepo) + userSvc := appsvc.NewUserService(userRepo) + server := appshttp.NewServer(productSvc, userSvc) + + r := chi.NewRouter() + h := appshttp.HandlerFromMux(server, r) + + ts := httptest.NewServer(h) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/users/1") + if err != nil { + t.Fatalf("http get user: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var user appshttp.User + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + t.Fatalf("decode user: %v", err) + } + if user.Id == nil || *user.Id != 1 { + t.Fatalf("unexpected user id: %+v", user) + } + if user.Email == "" { + t.Fatalf("expected email to be set: %+v", user) + } +}