207 lines
6.8 KiB
Go
207 lines
6.8 KiB
Go
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
|
|
}
|