package cart import ( "context" "reflect" "slices" "strings" "testing" "time" "github.com/gogo/protobuf/proto" anypb "google.golang.org/protobuf/types/known/anypb" "git.tornberg.me/go-cart-actor/pkg/actor" messages "git.tornberg.me/go-cart-actor/pkg/messages" ) // ---------------------- // Helper constructors // ---------------------- func newTestGrain() *CartGrain { return NewCartGrain(123, time.Now()) } func newRegistry() actor.MutationRegistry { return NewCartMultationRegistry() } func msgAddItem(sku string, price int64, qty int32, storePtr *string) *messages.AddItem { return &messages.AddItem{ Sku: sku, Price: price, Quantity: qty, // Tax left 0 -> handler uses default 25% StoreId: storePtr, } } func msgChangeQty(id uint32, qty int32) *messages.ChangeQuantity { return &messages.ChangeQuantity{Id: id, Quantity: qty} } func msgRemoveItem(id uint32) *messages.RemoveItem { return &messages.RemoveItem{Id: id} } func msgSetDelivery(provider string, items ...uint32) *messages.SetDelivery { uitems := make([]uint32, len(items)) copy(uitems, items) return &messages.SetDelivery{Provider: provider, Items: uitems} } func msgSetPickupPoint(deliveryId uint32, id string) *messages.SetPickupPoint { return &messages.SetPickupPoint{ DeliveryId: deliveryId, Id: id, Name: ptr("Pickup"), Address: ptr("Street 1"), City: ptr("Town"), Zip: ptr("12345"), Country: ptr("SE"), } } func msgClearCart() *messages.ClearCartRequest { return &messages.ClearCartRequest{} } func msgAddVoucher(code string, value int64, rules ...string) *messages.AddVoucher { return &messages.AddVoucher{Code: code, Value: value, VoucherRules: rules} } func msgRemoveVoucher(id uint32) *messages.RemoveVoucher { return &messages.RemoveVoucher{Id: id} } func msgInitializeCheckout(orderId, status string, inProgress bool) *messages.InitializeCheckout { return &messages.InitializeCheckout{OrderId: orderId, Status: status, PaymentInProgress: inProgress} } func msgOrderCreated(orderId, status string) *messages.OrderCreated { return &messages.OrderCreated{OrderId: orderId, Status: status} } func ptr[T any](v T) *T { return &v } // ---------------------- // Apply helpers // ---------------------- func applyOne(t *testing.T, reg actor.MutationRegistry, g *CartGrain, msg proto.Message) actor.ApplyResult { t.Helper() results, err := reg.Apply(context.Background(), g, msg) if err != nil { t.Fatalf("unexpected registry-level error applying %T: %v", msg, err) } if len(results) != 1 { t.Fatalf("expected exactly one ApplyResult, got %d", len(results)) } return results[0] } // Expect success (nil error inside ApplyResult). func applyOK(t *testing.T, reg actor.MutationRegistry, g *CartGrain, msg proto.Message) { t.Helper() res := applyOne(t, reg, g, msg) if res.Error != nil { t.Fatalf("expected mutation %s (%T) to succeed, got error: %v", res.Type, msg, res.Error) } } // Expect an error matching substring. func applyErrorContains(t *testing.T, reg actor.MutationRegistry, g *CartGrain, msg proto.Message, substr string) { t.Helper() res := applyOne(t, reg, g, msg) if res.Error == nil { t.Fatalf("expected error applying %T, got nil", msg) } if substr != "" && !strings.Contains(res.Error.Error(), substr) { t.Fatalf("error mismatch, want substring %q got %q", substr, res.Error.Error()) } } // ---------------------- // Tests // ---------------------- func TestMutationRegistryCoverage(t *testing.T) { reg := newRegistry() expected := []string{ "AddItem", "ChangeQuantity", "RemoveItem", "InitializeCheckout", "OrderCreated", "RemoveDelivery", "SetDelivery", "SetPickupPoint", "ClearCartRequest", "AddVoucher", "RemoveVoucher", "UpsertSubscriptionDetails", } names := reg.(*actor.ProtoMutationRegistry).RegisteredMutations() for _, want := range expected { if !slices.Contains(names, want) { t.Fatalf("registry missing mutation %s; got %v", want, names) } } // Create() by name returns correct concrete type. for _, name := range expected { msg, ok := reg.Create(name) if !ok { t.Fatalf("Create failed for %s", name) } rt := reflect.TypeOf(msg) if rt.Kind() == reflect.Ptr { rt = rt.Elem() } if rt.Name() != name { t.Fatalf("Create(%s) returned wrong type %s", name, rt.Name()) } } // Unregistered create if m, ok := reg.Create("DoesNotExist"); ok || m != nil { t.Fatalf("Create should fail for unknown; got (%T,%v)", m, ok) } // GetTypeName sanity add := &messages.AddItem{} nm, ok := reg.GetTypeName(add) if !ok || nm != "AddItem" { t.Fatalf("GetTypeName failed for AddItem, got (%q,%v)", nm, ok) } // Apply unregistered message -> result should contain ErrMutationNotRegistered, no top-level error results, err := reg.Apply(context.Background(), newTestGrain(), &messages.Noop{}) if err != nil { t.Fatalf("unexpected top-level error applying unregistered mutation: %v", err) } if len(results) != 1 || results[0].Error == nil || results[0].Error != actor.ErrMutationNotRegistered { t.Fatalf("expected ApplyResult with ErrMutationNotRegistered, got %#v", results) } } func TestAddItemAndMerging(t *testing.T) { reg := newRegistry() g := newTestGrain() // Merge scenario (same SKU + same store pointer) add1 := msgAddItem("SKU-1", 1000, 2, nil) applyOK(t, reg, g, add1) if len(g.Items) != 1 || g.Items[0].Quantity != 2 { t.Fatalf("expected first item added; items=%d qty=%d", len(g.Items), g.Items[0].Quantity) } applyOK(t, reg, g, msgAddItem("SKU-1", 1000, 3, nil)) // should merge if len(g.Items) != 1 || g.Items[0].Quantity != 5 { t.Fatalf("expected merge quantity=5 items=%d qty=%d", len(g.Items), g.Items[0].Quantity) } // Different store pointer -> new line store := "S1" applyOK(t, reg, g, msgAddItem("SKU-1", 1000, 1, &store)) if len(g.Items) != 2 { t.Fatalf("expected second line for different store pointer; items=%d", len(g.Items)) } // Same store pointer & SKU -> merge with second line applyOK(t, reg, g, msgAddItem("SKU-1", 1000, 4, &store)) if len(g.Items) != 2 || g.Items[1].Quantity != 5 { t.Fatalf("expected merge on second line; items=%d second.qty=%d", len(g.Items), g.Items[1].Quantity) } // Invalid quantity applyErrorContains(t, reg, g, msgAddItem("BAD", 1000, 0, nil), "invalid quantity") } func TestChangeQuantityBehavior(t *testing.T) { reg := newRegistry() g := newTestGrain() applyOK(t, reg, g, msgAddItem("A", 1500, 2, nil)) id := g.Items[0].Id // Increase quantity applyOK(t, reg, g, msgChangeQty(id, 5)) if g.Items[0].Quantity != 5 { t.Fatalf("quantity not updated expected=5 got=%d", g.Items[0].Quantity) } // Remove item by setting <=0 applyOK(t, reg, g, msgChangeQty(id, 0)) if len(g.Items) != 0 { t.Fatalf("expected item removed; items=%d", len(g.Items)) } // Not found applyErrorContains(t, reg, g, msgChangeQty(9999, 1), "not found") } func TestRemoveItemBehavior(t *testing.T) { reg := newRegistry() g := newTestGrain() applyOK(t, reg, g, msgAddItem("X", 1200, 1, nil)) id := g.Items[0].Id applyOK(t, reg, g, msgRemoveItem(id)) if len(g.Items) != 0 { t.Fatalf("expected item removed; items=%d", len(g.Items)) } applyErrorContains(t, reg, g, msgRemoveItem(id), "not found") } func TestDeliveryMutations(t *testing.T) { reg := newRegistry() g := newTestGrain() applyOK(t, reg, g, msgAddItem("D1", 1000, 1, nil)) applyOK(t, reg, g, msgAddItem("D2", 2000, 1, nil)) i1 := g.Items[0].Id // Explicit items applyOK(t, reg, g, msgSetDelivery("POSTNORD", i1)) if len(g.Deliveries) != 1 || len(g.Deliveries[0].Items) != 1 || g.Deliveries[0].Items[0] != i1 { t.Fatalf("delivery not created as expected: %+v", g.Deliveries) } // Attempt to attach an already-delivered item applyErrorContains(t, reg, g, msgSetDelivery("POSTNORD", i1), "already has a delivery") // Attach remaining item via empty list (auto include items without delivery) applyOK(t, reg, g, msgSetDelivery("DHL")) if len(g.Deliveries) != 2 { t.Fatalf("expected second delivery; deliveries=%d", len(g.Deliveries)) } // Non-existent item applyErrorContains(t, reg, g, msgSetDelivery("UPS", 99999), "not found") // No eligible items left applyErrorContains(t, reg, g, msgSetDelivery("UPS"), "no eligible items") // Set pickup point on first delivery did := g.Deliveries[0].Id applyOK(t, reg, g, msgSetPickupPoint(did, "PP1")) if g.Deliveries[0].PickupPoint == nil || g.Deliveries[0].PickupPoint.Id != "PP1" { t.Fatalf("pickup point not set correctly: %+v", g.Deliveries[0].PickupPoint) } // Bad delivery id applyErrorContains(t, reg, g, msgSetPickupPoint(9999, "PPX"), "delivery id") // Remove delivery applyOK(t, reg, g, &messages.RemoveDelivery{Id: did}) if len(g.Deliveries) != 1 || g.Deliveries[0].Id == did { t.Fatalf("expected first delivery removed, remaining: %+v", g.Deliveries) } // Remove delivery not found applyErrorContains(t, reg, g, &messages.RemoveDelivery{Id: did}, "not found") } func TestClearCart(t *testing.T) { reg := newRegistry() g := newTestGrain() applyOK(t, reg, g, msgAddItem("X", 1000, 2, nil)) applyOK(t, reg, g, msgSetDelivery("P", g.Items[0].Id)) applyOK(t, reg, g, msgClearCart()) if len(g.Items) != 0 || len(g.Deliveries) != 0 { t.Fatalf("expected cart cleared; items=%d deliveries=%d", len(g.Items), len(g.Deliveries)) } } func TestVoucherMutations(t *testing.T) { reg := newRegistry() g := newTestGrain() applyOK(t, reg, g, msgAddItem("VOUCH", 10000, 1, nil)) applyOK(t, reg, g, msgAddVoucher("PROMO", 5000)) if len(g.Vouchers) != 1 { t.Fatalf("voucher not stored") } if g.TotalDiscount.IncVat != 5000 { t.Fatalf("expected discount 5000 got %d", g.TotalDiscount.IncVat) } if g.TotalPrice.IncVat != 5000 { t.Fatalf("expected total price 5000 got %d", g.TotalPrice.IncVat) } // Duplicate voucher code applyErrorContains(t, reg, g, msgAddVoucher("PROMO", 1000), "already applied") // Add a large voucher (should not apply because value > total price) applyOK(t, reg, g, msgAddVoucher("BIG", 100000)) if len(g.Vouchers) != 2 { t.Fatalf("expected second voucher stored") } if g.TotalDiscount.IncVat != 5000 || g.TotalPrice.IncVat != 5000 { t.Fatalf("large voucher incorrectly applied discount=%d total=%d", g.TotalDiscount.IncVat, g.TotalPrice.IncVat) } // Remove existing voucher firstId := g.Vouchers[0].Id applyOK(t, reg, g, msgRemoveVoucher(firstId)) if slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool { return v.Id == firstId }) { t.Fatalf("voucher id %d not removed", firstId) } // After removing PROMO, BIG remains but is not applied (exceeds price) if g.TotalDiscount.IncVat != 0 || g.TotalPrice.IncVat != 10000 { t.Fatalf("totals incorrect after removal discount=%d total=%d", g.TotalDiscount.IncVat, g.TotalPrice.IncVat) } // Remove not applied applyErrorContains(t, reg, g, msgRemoveVoucher(firstId), "not applied") } func TestCheckoutMutations(t *testing.T) { reg := newRegistry() g := newTestGrain() applyOK(t, reg, g, msgInitializeCheckout("ORD-1", "PENDING", true)) if g.OrderReference != "ORD-1" || g.PaymentStatus != "PENDING" || !g.PaymentInProgress { t.Fatalf("initialize checkout failed: ref=%s status=%s inProgress=%v", g.OrderReference, g.PaymentStatus, g.PaymentInProgress) } applyOK(t, reg, g, msgOrderCreated("ORD-1", "COMPLETED")) if g.OrderReference != "ORD-1" || g.PaymentStatus != "COMPLETED" || g.PaymentInProgress { t.Fatalf("order created mutation failed: ref=%s status=%s inProgress=%v", g.OrderReference, g.PaymentStatus, g.PaymentInProgress) } applyErrorContains(t, reg, g, msgInitializeCheckout("", "X", true), "missing orderId") applyErrorContains(t, reg, g, msgOrderCreated("", "X"), "missing orderId") } func TestSubscriptionDetailsMutation(t *testing.T) { reg := newRegistry() g := newTestGrain() // Upsert new (Id == nil) msgNew := &messages.UpsertSubscriptionDetails{ OfferingCode: "OFF1", SigningType: "TYPE1", } applyOK(t, reg, g, msgNew) if len(g.SubscriptionDetails) != 1 { t.Fatalf("expected one subscription detail; got=%d", len(g.SubscriptionDetails)) } // Capture created id var createdId string for k := range g.SubscriptionDetails { createdId = k } // Update existing msgUpdate := &messages.UpsertSubscriptionDetails{ Id: &createdId, OfferingCode: "OFF2", SigningType: "TYPE2", } applyOK(t, reg, g, msgUpdate) if g.SubscriptionDetails[createdId].OfferingCode != "OFF2" || g.SubscriptionDetails[createdId].SigningType != "TYPE2" { t.Fatalf("subscription details not updated: %+v", g.SubscriptionDetails[createdId]) } // Update non-existent badId := "NON_EXISTENT" applyErrorContains(t, reg, g, &messages.UpsertSubscriptionDetails{Id: &badId}, "not found") // Nil mutation should be ignored and produce zero results. resultsNil, errNil := reg.Apply(context.Background(), g, (*messages.UpsertSubscriptionDetails)(nil)) if errNil != nil { t.Fatalf("unexpected error for nil mutation element: %v", errNil) } if len(resultsNil) != 0 { t.Fatalf("expected zero results for nil mutation, got %d", len(resultsNil)) } } // Ensure registry Apply handles nil grain and nil message defensive errors consistently. func TestRegistryDefensiveErrors(t *testing.T) { reg := newRegistry() g := newTestGrain() // Nil grain results, err := reg.Apply(nil, &messages.AddItem{}) if err == nil { t.Fatalf("expected error for nil grain") } if len(results) != 0 { t.Fatalf("expected no results for nil grain") } // Nil message slice results, _ = reg.Apply(context.Background(), g, nil) if len(results) != 0 { t.Fatalf("expected no results when message slice nil") } } func TestSubscriptionDetailsJSONValidation(t *testing.T) { reg := newRegistry() g := newTestGrain() // Valid JSON on create validCreate := &messages.UpsertSubscriptionDetails{ OfferingCode: "OFFJSON", SigningType: "TYPEJSON", Data: &anypb.Any{Value: []byte(`{"ok":true}`)}, } applyOK(t, reg, g, validCreate) if len(g.SubscriptionDetails) != 1 { t.Fatalf("expected one subscription detail after valid create, got %d", len(g.SubscriptionDetails)) } var id string for k := range g.SubscriptionDetails { id = k } if string(g.SubscriptionDetails[id].Meta) != `{"ok":true}` { t.Fatalf("expected meta stored as valid json, got %s", string(g.SubscriptionDetails[id].Meta)) } // Update with valid JSON replaces meta updateValid := &messages.UpsertSubscriptionDetails{ Id: &id, Data: &anypb.Any{Value: []byte(`{"changed":123}`)}, } applyOK(t, reg, g, updateValid) if string(g.SubscriptionDetails[id].Meta) != `{"changed":123}` { t.Fatalf("expected meta updated to new json, got %s", string(g.SubscriptionDetails[id].Meta)) } // Invalid JSON on create invalidCreate := &messages.UpsertSubscriptionDetails{ OfferingCode: "BAD", Data: &anypb.Any{Value: []byte(`{"broken":}`)}, } res := applyOne(t, reg, g, invalidCreate) if res.Error == nil || !strings.Contains(res.Error.Error(), "invalid json") { t.Fatalf("expected invalid json error on create, got %v", res.Error) } // Invalid JSON on update badUpdate := &messages.UpsertSubscriptionDetails{ Id: &id, Data: &anypb.Any{Value: []byte(`{oops`)}, } res2 := applyOne(t, reg, g, badUpdate) if res2.Error == nil || !strings.Contains(res2.Error.Error(), "invalid json") { t.Fatalf("expected invalid json error on update, got %v", res2.Error) } // Empty Data.Value should not overwrite existing meta emptyUpdate := &messages.UpsertSubscriptionDetails{ Id: &id, Data: &anypb.Any{Value: []byte{}}, } applyOK(t, reg, g, emptyUpdate) if string(g.SubscriptionDetails[id].Meta) != `{"changed":123}` { t.Fatalf("empty update should not change meta, got %s", string(g.SubscriptionDetails[id].Meta)) } }