package ucp import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/mats/go-cart-actor/pkg/actor" messages "git.k6n.net/mats/go-cart-actor/proto/cart" "git.k6n.net/mats/platform/money" "google.golang.org/protobuf/proto" ) // testApplier implements CartApplier with an in-memory map of grains. type testApplier struct { grains map[uint64]*cart.CartGrain } func newTestApplier() *testApplier { return &testApplier{ grains: make(map[uint64]*cart.CartGrain), } } func (a *testApplier) Get(_ context.Context, id uint64) (*cart.CartGrain, error) { g, ok := a.grains[id] if !ok { g = cart.NewCartGrain(id, time.UnixMilli(1000000)) a.grains[id] = g } return g, nil } func (a *testApplier) Apply(_ context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[cart.CartGrain], error) { g, err := a.Get(nil, id) if err != nil { return nil, err } results := make([]actor.ApplyResult, len(msgs)) for i, msg := range msgs { results[i] = actor.ApplyResult{Type: string(msg.ProtoReflect().Descriptor().FullName())} switch m := msg.(type) { case *messages.ClearCartRequest: g.Items = nil g.Vouchers = nil g.TotalDiscount = cart.NewPrice() g.TotalPrice = cart.NewPrice() case *messages.SetUserId: // userId is unexported; rely on serialization to verify. _ = m } } return &actor.MutationResult[cart.CartGrain]{ Result: *g, Mutations: results, }, nil } // mustParseID returns the uint64 representation of a base62 cart id string. func mustParseID(s string) uint64 { id, _ := cart.ParseCartId(s) return uint64(id) } func TestCartHandler_CreateCart(t *testing.T) { applier := newTestApplier() handler := CartHandler(applier) req := httptest.NewRequest("POST", "/", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("expected 201 Created, got %d", rec.Code) } var resp CartResponse 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 cart id") } if resp.Status != "active" { t.Fatalf("expected status 'active', got %q", resp.Status) } } func TestCartHandler_GetCartNotFound(t *testing.T) { applier := newTestApplier() handler := CartHandler(applier) req := httptest.NewRequest("GET", "/nonexistent", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) // /nonexistent is a valid base62 string, so it parses, but the cart // auto-creates on first Get, so we get a 200 with an empty cart. // This is expected behavior (UCP auto-creates carts on read). if rec.Code != http.StatusOK { t.Fatalf("expected 200 (auto-create), got %d", rec.Code) } } func TestCartHandler_CreateAndGet(t *testing.T) { applier := newTestApplier() handler := CartHandler(applier) // Create a cart. req := httptest.NewRequest("POST", "/", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("create failed: %d", rec.Code) } var created CartResponse json.NewDecoder(rec.Body).Decode(&created) // GET it back. req = httptest.NewRequest("GET", "/"+created.Id, nil) rec = httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } var got CartResponse json.NewDecoder(rec.Body).Decode(&got) if got.Id != created.Id { t.Fatalf("expected id %q, got %q", created.Id, got.Id) } } func TestCartHandler_CancelCart(t *testing.T) { applier := newTestApplier() handler := CartHandler(applier) // Create a cart. req := httptest.NewRequest("POST", "/", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) var created CartResponse json.NewDecoder(rec.Body).Decode(&created) // Cancel it. req = httptest.NewRequest("POST", "/"+created.Id+"/cancel", nil) rec = httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } var cancelled CartResponse json.NewDecoder(rec.Body).Decode(&cancelled) if cancelled.Id != created.Id { t.Fatalf("expected same id %q, got %q", created.Id, cancelled.Id) } } func TestCartHandler_UpdateCart(t *testing.T) { applier := newTestApplier() handler := CartHandler(applier) // Create a cart. req := httptest.NewRequest("POST", "/", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) var created CartResponse json.NewDecoder(rec.Body).Decode(&created) // Update with items (test applier doesn't process AddItem, but the // handler still returns 200 — items are populated by the real grain pool). updateBody := `{"items": [{"sku": "test-sku", "quantity": 2, "name": "Test Item"}]}` req = httptest.NewRequest("PUT", "/"+created.Id, strings.NewReader(updateBody)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) } var updated CartResponse json.NewDecoder(rec.Body).Decode(&updated) if updated.Id != created.Id { t.Fatalf("expected same id %q, got %q", created.Id, updated.Id) } } func TestCartResponse_Conversion(t *testing.T) { id := mustParseID("ABCD") g := cart.NewCartGrain(id, time.UnixMilli(1000000)) g.Currency = "SEK" g.Items = append(g.Items, &cart.CartItem{ Sku: "test-1", Quantity: 2, Price: *mustPrice(10000, 2500), TotalPrice: *mustPrice(20000, 5000), Tax: 2500, Meta: &cart.ItemMeta{Name: "Test Item"}, }) g.TotalPrice = mustPrice(20000, 5000) resp := cartGrainToResponse(g.Id.String(), g) if len(resp.Items) != 1 { t.Fatalf("expected 1 item, got %d", len(resp.Items)) } if resp.Items[0].Sku != "test-1" { t.Fatalf("expected sku 'test-1', got %q", resp.Items[0].Sku) } if resp.Items[0].Name != "Test Item" { t.Fatalf("expected name 'Test Item', got %q", resp.Items[0].Name) } if resp.Totals.TotalIncVat != 20000 { t.Fatalf("expected total 20000, got %d", resp.Totals.TotalIncVat) } if resp.Totals.Currency != "SEK" { t.Fatalf("expected currency SEK, got %q", resp.Totals.Currency) } } // TestCartResponse_EvaluatedItems verifies that the per-line breakdown the // canonical promotion pipeline populates on CartGrain.EvaluatedItems flows // verbatim into the UCP CartResponse's EvaluatedItems field. Conversion is a // straight pass-through now — we hand-construct the grain's EvaluatedItems // field with the expected values to lock down the wire shape independent of // the math MapEvaluatedItems applies (which has its own tests in pkg/cart). func TestCartResponse_EvaluatedItems(t *testing.T) { id := mustParseID("ABCD") g := cart.NewCartGrain(id, time.UnixMilli(1000000)) g.Currency = "SEK" g.Items = append(g.Items, &cart.CartItem{ Sku: "promo-1", Quantity: 2, Price: *mustPrice(10000, 2500), TotalPrice: *mustPrice(20000, 5000), Tax: 2500, OrgPrice: mustPrice(12000, 3000), Discount: mustPrice(2000, 500), Meta: &cart.ItemMeta{Name: "Promo Item"}, }) g.TotalPrice = mustPrice(20000, 5000) // In the live cart, pkg/promotions.PromotionService.EvaluateAndApply // populates g.EvaluatedItems via cart.MapEvaluatedItems after every // successful mutation. The conversion test only cares that whatever // the grain carries projects into resp.EvaluatedItems unchanged — so // the input is the canonical shape, not the math's output. g.EvaluatedItems = []cart.EvaluatedItem{{ Sku: "promo-1", Name: "Promo Item", Quantity: 2, PriceIncVat: 10000, OrgPriceIncVat: 12000, DiscountIncVat: 2000, EffectivePriceIncVat: 9000, EffectiveTotalIncVat: 18000, }} resp := cartGrainToResponse(g.Id.String(), g) if len(resp.EvaluatedItems) != 1 { t.Fatalf("expected 1 evaluated item, got %d", len(resp.EvaluatedItems)) } ev := resp.EvaluatedItems[0] if ev.Sku != "promo-1" { t.Fatalf("expected sku 'promo-1', got %q", ev.Sku) } if ev.Name != "Promo Item" { t.Fatalf("expected name 'Promo Item', got %q", ev.Name) } if ev.Quantity != 2 { t.Fatalf("expected qty 2, got %d", ev.Quantity) } if ev.PriceIncVat != 10000 { t.Fatalf("expected priceIncVat 10000, got %d", ev.PriceIncVat) } if ev.OrgPriceIncVat != 12000 { t.Fatalf("expected orgPriceIncVat 12000, got %d", ev.OrgPriceIncVat) } if ev.DiscountIncVat != 2000 { t.Fatalf("expected discountIncVat 2000, got %d", ev.DiscountIncVat) } if ev.EffectiveTotalIncVat != 18000 { t.Fatalf("expected effectiveTotalIncVat 18000, got %d", ev.EffectiveTotalIncVat) } if ev.EffectivePriceIncVat != 9000 { t.Fatalf("expected effectivePriceIncVat 9000, got %d", ev.EffectivePriceIncVat) } } // TestCartResponse_EvaluatedItems_MapEquivalence confirms that // cart.MapEvaluatedItems produces the same shape the test's hand-built // []cart.EvaluatedItem entries contain, given matching inputs. This locks // the conversion path's output to the canonical pipeline's output — if // either side starts omitting or renaming a field, this fails. func TestCartResponse_EvaluatedItems_MapEquivalence(t *testing.T) { id := mustParseID("ABCD") g := cart.NewCartGrain(id, time.UnixMilli(1000000)) g.Currency = "SEK" g.Items = append(g.Items, &cart.CartItem{ Sku: "promo-1", Quantity: 2, Price: *mustPrice(10000, 2500), TotalPrice: *mustPrice(20000, 5000), Tax: 2500, OrgPrice: mustPrice(12000, 3000), Discount: mustPrice(2000, 500), Meta: &cart.ItemMeta{Name: "Promo Item"}, }) g.TotalPrice = mustPrice(20000, 5000) canonical := cart.MapEvaluatedItems(g) if len(canonical) != 1 { t.Fatalf("expected 1 evaluated item from MapEvaluatedItems, got %d", len(canonical)) } e := canonical[0] if e.DiscountIncVat != 2000 || e.EffectiveTotalIncVat != 18000 || e.EffectivePriceIncVat != 9000 { t.Fatalf("MapEvaluatedItems math off: %+v", e) } } func mustPrice(incVat int64, totalVat int64) *cart.Price { return &cart.Price{ IncVat: money.Cents(incVat), VatRates: map[int]money.Cents{2500: money.Cents(totalVat)}, // 25% in basis points } }