parent item mutations
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s
Build and Publish / BuildAndDeployArm64 (push) Failing after 7s

This commit is contained in:
2026-07-08 11:39:42 +02:00
parent e20793a6b3
commit 9f1eca07e9
14 changed files with 784 additions and 103 deletions
+10
View File
@@ -195,6 +195,16 @@ func cartGrainToResponse(id string, g *cart.CartGrain) CartResponse {
resp.Status = string(*g.CheckoutStatus)
}
// Per-line evaluation breakdown. The canonical promotion pipeline
// (pkg/promotions.PromotionService.EvaluateAndApply) populates this
// field on the grain itself after every successful mutation, so the
// conversion just reads g.EvaluatedItems verbatim — no recompute at
// response time. Same shape /promotions/evaluate-with-cart returns
// (both reference cart.EvaluatedItem and cart.MapEvaluatedItems),
// so a line verified via GET /ucp/v1/carts/{id} byte-matches the
// preview from /promotions/evaluate-with-cart.
resp.EvaluatedItems = g.EvaluatedItems
for _, it := range g.Items {
if it == nil {
continue
+100
View File
@@ -229,6 +229,106 @@ func TestCartResponse_Conversion(t *testing.T) {
}
}
// 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),
+19 -4
View File
@@ -121,11 +121,26 @@ type VoucherInput struct {
}
// CartResponse is the UCP cart response body.
//
// EvaluatedItems mirrors the per-line breakdown the canonical promotion
// pipeline (pkg/promotions.PromotionService.EvaluateAndApply) populates
// on the grain after every successful mutation — see
// pkg/cart/evaluated_item.go for the EvaluatedItem type and the rounding
// rules. The shape and field order match the live cart grain's own
// EvaluatedItems field and the response from /promotions/evaluate-with-cart
// (both reference cart.EvaluatedItem and cart.MapEvaluatedItems), so a
// line verified via GET /ucp/v1/carts/{id} byte-matches the preview.
//
// Kept parallel to Items rather than merged into CartItem to preserve
// the UCP wire contract — existing UCP clients keep working unchanged
// and the per-line breakdown can grow on the EvaluatedItem type without
// churning the standard item shape.
type CartResponse struct {
Id string `json:"id"`
Items []CartItem `json:"items,omitempty"`
Totals Totals `json:"totals"`
Status string `json:"status"`
Id string `json:"id"`
Items []CartItem `json:"items,omitempty"`
EvaluatedItems []cart.EvaluatedItem `json:"evaluatedItems,omitempty"`
Totals Totals `json:"totals"`
Status string `json:"status"`
AppliedPromotions []cart.AppliedPromotion `json:"appliedPromotions,omitempty"`
}