diff --git a/internal/ucp/checkout.go b/internal/ucp/checkout.go index 0e1a963..2e2006b 100644 --- a/internal/ucp/checkout.go +++ b/internal/ucp/checkout.go @@ -45,12 +45,70 @@ func (s *CheckoutServer) handleCreateCheckout(w http.ResponseWriter, r *http.Req writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) return } - if req.CartId == "" { - writeError(w, http.StatusBadRequest, "cartId is required") + + items := req.Items + if len(items) == 0 && len(req.LineItems) > 0 { + items = req.LineItems + } + + var cartIdStr string + if req.CartId != "" { + cartIdStr = req.CartId + } else if len(items) > 0 { + // Fetch cart base URL + cartBaseURL := os.Getenv("CART_INTERNAL_URL") + if cartBaseURL == "" { + cartBaseURL = "http://cart:8080" + } + + // Prepare POST request to /ucp/v1/carts/ to create and seed the cart + createReqBody := CreateCartRequest{ + Items: items, + } + bodyBytes, err := json.Marshal(createReqBody) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to marshal create cart request: "+err.Error()) + return + } + + client := &http.Client{Timeout: 10 * time.Second} + cartReq, err := http.NewRequestWithContext(r.Context(), "POST", fmt.Sprintf("%s/ucp/v1/carts/", cartBaseURL), bytes.NewReader(bodyBytes)) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create post cart request: "+err.Error()) + return + } + cartReq.Header.Set("Content-Type", "application/json") + + profile := os.Getenv("UCP_AGENT_PROFILE") + if profile == "" { + profile = DefaultUCPAgentProfile + } + cartReq.Header.Set("UCP-Agent", `profile="`+profile+`"`) + + cartResp, err := client.Do(cartReq) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create implicit cart: "+err.Error()) + return + } + defer cartResp.Body.Close() + + if cartResp.StatusCode != http.StatusCreated && cartResp.StatusCode != http.StatusOK { + writeError(w, http.StatusBadRequest, fmt.Sprintf("cart service returned status: %d when creating implicit cart", cartResp.StatusCode)) + return + } + + var cartRespBody CartResponse + if err := json.NewDecoder(cartResp.Body).Decode(&cartRespBody); err != nil { + writeError(w, http.StatusInternalServerError, "failed to decode implicit cart response: "+err.Error()) + return + } + cartIdStr = cartRespBody.Id + } else { + writeError(w, http.StatusBadRequest, "cartId or items/line_items is required") return } - cartId, ok := cart.ParseCartId(req.CartId) + cartId, ok := cart.ParseCartId(cartIdStr) if !ok { writeError(w, http.StatusBadRequest, "invalid cartId") return diff --git a/internal/ucp/checkout_test.go b/internal/ucp/checkout_test.go new file mode 100644 index 0000000..9ae5a62 --- /dev/null +++ b/internal/ucp/checkout_test.go @@ -0,0 +1,153 @@ +package ucp + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "git.k6n.net/mats/go-cart-actor/pkg/actor" + "git.k6n.net/mats/go-cart-actor/pkg/cart" + "git.k6n.net/mats/go-cart-actor/pkg/checkout" + checkoutMessages "git.k6n.net/mats/go-cart-actor/proto/checkout" + "google.golang.org/protobuf/proto" +) + +type testCheckoutApplier struct { + grains map[uint64]*checkout.CheckoutGrain +} + +func newTestCheckoutApplier() *testCheckoutApplier { + return &testCheckoutApplier{ + grains: make(map[uint64]*checkout.CheckoutGrain), + } +} + +func (a *testCheckoutApplier) Get(_ context.Context, id uint64) (*checkout.CheckoutGrain, error) { + g, ok := a.grains[id] + if !ok { + g = checkout.NewCheckoutGrain(id, 0, 0, time.UnixMilli(1000000), nil) + a.grains[id] = g + } + return g, nil +} + +func (a *testCheckoutApplier) Apply(ctx context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[checkout.CheckoutGrain], error) { + g, err := a.Get(ctx, id) + if err != nil { + return nil, err + } + for _, msg := range msgs { + switch m := msg.(type) { + case *checkoutMessages.InitializeCheckout: + g.CartId = cart.CartId(m.CartId) + } + } + return &actor.MutationResult[checkout.CheckoutGrain]{ + Result: *g, + }, nil +} + +func TestCheckoutHandler_CreateWithCartId(t *testing.T) { + // Start a mock Cart server. + mockCartServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/cart/byid/") { + t.Errorf("expected path to contain /cart/byid/, got %q", r.URL.Path) + } + // Return dummy cart state bytes. + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + })) + defer mockCartServer.Close() + + os.Setenv("CART_INTERNAL_URL", mockCartServer.URL) + defer os.Unsetenv("CART_INTERNAL_URL") + + applier := newTestCheckoutApplier() + handler := CheckoutHandler(applier) + + // Create a dummy cart ID. + dummyCartId, _ := cart.NewCartId() + + reqBody := fmt.Sprintf(`{"cartId": %q}`, dummyCartId.String()) + req := httptest.NewRequest("POST", "/", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201 Created, got %d. Body: %s", rec.Code, rec.Body.String()) + } + + var resp CheckoutResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Id == "" { + t.Fatal("expected non-empty checkout session id") + } + if resp.CartId != dummyCartId.String() { + t.Fatalf("expected cartId %q, got %q", dummyCartId.String(), resp.CartId) + } +} + +func TestCheckoutHandler_CreateWithLineItemsFallback(t *testing.T) { + // Start a mock Cart server that supports both creating a cart and getting it. + var createdCartId cart.CartId + mockCartServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" && strings.Contains(r.URL.Path, "/ucp/v1/carts/") { + var err error + createdCartId, err = cart.NewCartId() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(fmt.Sprintf(`{"id": %q, "status": "active"}`, createdCartId.String()))) + return + } + + if r.Method == "GET" && strings.Contains(r.URL.Path, "/cart/byid/") { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + return + } + + w.WriteHeader(http.StatusNotFound) + })) + defer mockCartServer.Close() + + os.Setenv("CART_INTERNAL_URL", mockCartServer.URL) + defer os.Unsetenv("CART_INTERNAL_URL") + + applier := newTestCheckoutApplier() + handler := CheckoutHandler(applier) + + // POST with line_items and no cartId. + reqBody := `{"line_items": [{"sku": "SKU123", "quantity": 2}]}` + req := httptest.NewRequest("POST", "/", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201 Created, got %d. Body: %s", rec.Code, rec.Body.String()) + } + + var resp CheckoutResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Id == "" { + t.Fatal("expected non-empty checkout session id") + } + if resp.CartId != createdCartId.String() { + t.Fatalf("expected cartId %q to match created implicit cart %q", resp.CartId, createdCartId.String()) + } +} diff --git a/internal/ucp/types.go b/internal/ucp/types.go index 57a5c6b..ff2247d 100644 --- a/internal/ucp/types.go +++ b/internal/ucp/types.go @@ -143,10 +143,11 @@ type Totals struct { // CreateCheckoutRequest is the body for POST /checkout-sessions. type CreateCheckoutRequest struct { - CartId string `json:"cartId"` - Items []CartItemInput `json:"items,omitempty"` - Country string `json:"country,omitempty"` - Currency string `json:"currency,omitempty"` + CartId string `json:"cartId"` + Items []CartItemInput `json:"items,omitempty"` + LineItems []CartItemInput `json:"line_items,omitempty"` + Country string `json:"country,omitempty"` + Currency string `json:"currency,omitempty"` } // UpdateCheckoutRequest is the body for PUT /checkout-sessions/{id}.