package main import ( "encoding/json" "net/http/httptest" "testing" "git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/mats/go-cart-actor/pkg/cart" ) // TestWriteResult_CartGrainCarriesEvaluatedItems verifies that the per-line // promotion breakdown lives on CartGrain.EvaluatedItems (so the canonical // promotion pipeline populates it once per mutation, not at every read // path) and naturally serialises through WriteResult alongside the rest // of the grain's tagged fields. There is no envelope wrapper anymore — // the grain's own JSON tag carries the field. See pkg/cart/evaluated_item.go // for EvaluatedItem semantics and pkg/cart/cart-grain.go for the EvaluatedItems // field declaration. func TestWriteResult_CartGrainCarriesEvaluatedItems(t *testing.T) { grain := &cart.CartGrain{ Id: 1, Currency: "SEK", Items: []*cart.CartItem{ { Sku: "promo-1", Quantity: 2, Price: *cart.NewPriceFromIncVat(10000, 2500), Discount: cart.NewPriceFromIncVat(2000, 500), Meta: &cart.ItemMeta{Name: "Promo Item"}, }, }, TotalPrice: cart.NewPriceFromIncVat(20000, 5000), TotalDiscount: cart.NewPriceFromIncVat(4000, 1000), EvaluatedItems: []cart.EvaluatedItem{{ Sku: "promo-1", Name: "Promo Item", Quantity: 2, PriceIncVat: 10000, OrgPriceIncVat: 12000, DiscountIncVat: 2000, EffectivePriceIncVat: 9000, EffectiveTotalIncVat: 18000, }}, } s := &PoolServer{} w := httptest.NewRecorder() if err := s.WriteResult(w, grain); err != nil { t.Fatalf("WriteResult: %v", err) } var resp map[string]json.RawMessage if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v (body=%q)", err, w.Body.String()) } for _, key := range []string{"id", "currency", "items", "totalPrice", "totalDiscount", "evaluatedItems"} { if _, ok := resp[key]; !ok { t.Fatalf("expected top-level %q in grain serialisation, got keys %v", key, mapKeys(resp)) } } var ev []struct { Sku string `json:"sku"` DiscountIncVat int64 `json:"discountIncVat"` EffectiveTotalIncVat int64 `json:"effectiveTotalIncVat"` EffectivePriceIncVat int64 `json:"effectivePriceIncVat"` } if err := json.Unmarshal(resp["evaluatedItems"], &ev); err != nil { t.Fatalf("evaluatedItems parse: %v", err) } if len(ev) != 1 || ev[0].Sku != "promo-1" { t.Fatalf("expected one promo-1 evaluated item, got %+v", ev) } if ev[0].DiscountIncVat != 2000 || ev[0].EffectiveTotalIncVat != 18000 || ev[0].EffectivePriceIncVat != 9000 { t.Fatalf("evaluatedItem math off: %+v", ev[0]) } } // TestWriteResult_MutationResultNestsEvaluatedItems verifies the natural // post-mutation wire shape: actor.MutationResult[cart.CartGrain] serialises // Result (the grain) and Mutations (applied-change metadata) at the existing // top-level positions, and CartGrain's EvaluatedItems rides inside Result // because the grain owns the field. Previously the envelope wrapper pulled // evaluatedItems out as a sibling of {result, mutations}; the natural // nesting is what the user wanted ("no strange patterns on a global level"). // Internal/admin tools that read response.evaluatedItems will need to read // response.result.evaluatedItems instead. Wrap-around breakage acknowledged. func TestWriteResult_MutationResultNestsEvaluatedItems(t *testing.T) { grain := &cart.CartGrain{ Id: 1, Currency: "SEK", Items: []*cart.CartItem{ { Sku: "promo-1", Quantity: 1, Price: *cart.NewPriceFromIncVat(10000, 2500), Discount: cart.NewPriceFromIncVat(3000, 750), }, }, EvaluatedItems: []cart.EvaluatedItem{{ Sku: "promo-1", Quantity: 1, PriceIncVat: 10000, DiscountIncVat: 3000, EffectiveTotalIncVat: 7000, EffectivePriceIncVat: 7000, }}, } mr := &actor.MutationResult[cart.CartGrain]{ Result: *grain, Mutations: []actor.ApplyResult{{Type: "cart.AddItem"}}, } s := &PoolServer{} w := httptest.NewRecorder() if err := s.WriteResult(w, mr); err != nil { t.Fatalf("WriteResult: %v", err) } var resp map[string]json.RawMessage if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } // {result, mutations} are the existing envelope — preserved. for _, key := range []string{"result", "mutations"} { if _, ok := resp[key]; !ok { t.Fatalf("expected top-level %q in mutation response, got %v", key, mapKeys(resp)) } } // evaluatedItems no longer rises to the top level (the envelope is // gone). Confirm absence at the top — it's now nested under result. if _, hasTop := resp["evaluatedItems"]; hasTop { t.Fatalf("evaluatedItems leaked to top level — envelope wrapper returned somehow") } // Result nests the grain, including EvaluatedItems naturally. var inner map[string]json.RawMessage if err := json.Unmarshal(resp["result"], &inner); err != nil { t.Fatalf("result parse: %v", err) } if _, ok := inner["items"]; !ok { t.Fatalf("result.items missing — Result did not carry the grain") } evRaw, ok := inner["evaluatedItems"] if !ok { t.Fatalf("result.evaluatedItems missing — grain field should nest under result") } var ev []struct { Sku string `json:"sku"` DiscountIncVat int64 `json:"discountIncVat"` } if err := json.Unmarshal(evRaw, &ev); err != nil { t.Fatalf("result.evaluatedItems parse: %v", err) } if len(ev) != 1 || ev[0].Sku != "promo-1" || ev[0].DiscountIncVat != 3000 { t.Fatalf("result.evaluatedItems wrong: %+v", ev) } // Mutations are still a sibling of result. var muts []map[string]any if err := json.Unmarshal(resp["mutations"], &muts); err != nil { t.Fatalf("mutations parse: %v", err) } if len(muts) != 1 || muts[0]["type"] != "cart.AddItem" { t.Fatalf("mutations wrong: %+v", muts) } } // TestWriteResult_NoEvaluatedItemsOmitsField verifies the EvaluatedItems // omitempty on CartGrain drops the field entirely when the canonical // pipeline hasn't run (synthetic grain in a fixture, fresh spawn before // any promotion-aware mutation). Clients reading evaluatedItems can // rely on absence to mean "no promotion compute yet". func TestWriteResult_NoEvaluatedItemsOmitsField(t *testing.T) { grain := &cart.CartGrain{ Id: 1, Currency: "SEK", } s := &PoolServer{} w := httptest.NewRecorder() if err := s.WriteResult(w, grain); err != nil { t.Fatalf("WriteResult: %v", err) } var resp map[string]json.RawMessage if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } if _, has := resp["evaluatedItems"]; has { t.Fatalf("expected evaluatedItems omitted on a grain with nil breakdown") } } // mapKeys returns the sorted set of top-level keys for nicer failure output. func mapKeys(m map[string]json.RawMessage) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } return keys }