Files
go-cart-actor/internal/ucp/cart_test.go
T
mats 9f1eca07e9
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s
Build and Publish / BuildAndDeployArm64 (push) Failing after 7s
parent item mutations
2026-07-08 11:39:42 +02:00

338 lines
10 KiB
Go

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
}
}