package cart import ( "context" "encoding/json" "fmt" "reflect" "slices" "strings" "testing" "time" "github.com/gogo/protobuf/proto" "github.com/matst80/go-redis-inventory/pkg/inventory" "google.golang.org/protobuf/types/known/anypb" "git.k6n.net/go-cart-actor/pkg/actor" messages "git.k6n.net/go-cart-actor/pkg/messages" ) // ---------------------- // Helper constructors // ---------------------- func newTestGrain() *CartGrain { return NewCartGrain(123, time.Now()) } type MockReservationService struct { } func (m *MockReservationService) ReserveForCart(ctx context.Context, req inventory.CartReserveRequest) error { return nil } func (m *MockReservationService) ReleaseForCart(ctx context.Context, sku inventory.SKU, locationID inventory.LocationID, cartID inventory.CartID) error { return nil } func (m *MockReservationService) GetAvailableInventory(ctx context.Context, sku inventory.SKU, locationID inventory.LocationID) (int64, error) { return 1000, nil } func (m *MockReservationService) GetReservationExpiry(ctx context.Context, sku inventory.SKU, locationID inventory.LocationID, cartID inventory.CartID) (time.Time, error) { return time.Time{}, nil } func (m *MockReservationService) GetReservationStatus(ctx context.Context, sku inventory.SKU, locationID inventory.LocationID, cartID inventory.CartID) (*inventory.ReservationStatus, error) { return nil, nil } func (m *MockReservationService) GetReservationSummary(ctx context.Context, sku inventory.SKU, locationID inventory.LocationID) (*inventory.ReservationSummary, error) { return nil, nil } func newRegistry() actor.MutationRegistry { cartCtx := &CartMutationContext{ reservationService: &MockReservationService{}, } return NewCartMultationRegistry(cartCtx) } 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 msgSetUserId(userId string) *messages.SetUserId { return &messages.SetUserId{UserId: userId} } func msgLineItemMarking(id uint32, typ uint32, marking string) *messages.LineItemMarking { return &messages.LineItemMarking{Id: id, Type: typ, Marking: marking} } func msgRemoveLineItemMarking(id uint32) *messages.RemoveLineItemMarking { return &messages.RemoveLineItemMarking{Id: id} } func msgSubscriptionAdded(itemId uint32, detailsId, orderRef string) *messages.SubscriptionAdded { return &messages.SubscriptionAdded{ItemId: itemId, DetailsId: detailsId, OrderReference: orderRef} } func msgPaymentDeclined(message, code string) *messages.PaymentDeclined { return &messages.PaymentDeclined{Message: message, Code: &code} } func msgConfirmationViewed() *messages.ConfirmationViewed { return &messages.ConfirmationViewed{} } func msgCreateCheckoutOrder(terms, country string) *messages.CreateCheckoutOrder { return &messages.CreateCheckoutOrder{Terms: terms, Country: country} } func msgAddGiftcard(value int64, deliveryDate, recipient, recipientType, message string, designConfig *anypb.Any) *messages.AddGiftcard { return &messages.AddGiftcard{ Giftcard: &messages.GiftcardItem{ Value: value, DeliveryDate: deliveryDate, Recipient: recipient, RecipientType: recipientType, Message: message, DesignConfig: designConfig, }, } } func msgRemoveGiftcard(id uint32) *messages.RemoveGiftcard { return &messages.RemoveGiftcard{Id: id} } 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", "InventoryReserved", "PreConditionFailed", "SetUserId", "LineItemMarking", "RemoveLineItemMarking", "SubscriptionAdded", "PaymentDeclined", "ConfirmationViewed", "CreateCheckoutOrder", "AddGiftcard", "RemoveGiftcard", } 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(context.Background(), 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") } } type SubscriptionDetailsRequest struct { Id *string `json:"id,omitempty"` OfferingCode string `json:"offeringCode,omitempty"` SigningType string `json:"signingType,omitempty"` Data json.RawMessage `json:"data,omitempty"` } func (sd *SubscriptionDetailsRequest) ToMessage() *messages.UpsertSubscriptionDetails { return &messages.UpsertSubscriptionDetails{ Id: sd.Id, OfferingCode: sd.OfferingCode, SigningType: sd.SigningType, Data: &anypb.Any{Value: sd.Data}, } } func TestSubscriptionDetailsJSONValidation(t *testing.T) { reg := newRegistry() g := newTestGrain() // Valid JSON on create jsonStr := `{"offeringCode": "OFFJSON", "signingType": "TYPEJSON", "data": {"value":"test","a":1}}` var validCreate SubscriptionDetailsRequest if err := json.Unmarshal([]byte(jsonStr), &validCreate); err != nil { t.Fatal(err) } applyOK(t, reg, g, validCreate.ToMessage()) 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) != `{"value":"test","a":1}` { t.Fatalf("expected meta stored as valid json, got %s", string(g.SubscriptionDetails[id].Meta)) } // Update with valid JSON replaces meta jsonStr2 := fmt.Sprintf(`{"id": "%s", "data": {"value": "eyJjaGFuZ2VkIjoxMjN9"}}`, id) var updateValid messages.UpsertSubscriptionDetails if err := json.Unmarshal([]byte(jsonStr2), &updateValid); err != nil { t.Fatal(err) } 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 jsonStr3 := `{"offeringCode": "BAD", "signingType": "TYPE", "data": {"value": "eyJicm9rZW4iO30="}}` var invalidCreate messages.UpsertSubscriptionDetails if err := json.Unmarshal([]byte(jsonStr3), &invalidCreate); err != nil { t.Fatal(err) } 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 jsonStr4 := fmt.Sprintf(`{"id": "%s", "data": {"value": "e29vcHM="}}`, id) var badUpdate messages.UpsertSubscriptionDetails if err := json.Unmarshal([]byte(jsonStr4), &badUpdate); err != nil { t.Fatal(err) } 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 should not overwrite existing meta jsonStr5 := fmt.Sprintf(`{"id": "%s"}`, id) var emptyUpdate messages.UpsertSubscriptionDetails if err := json.Unmarshal([]byte(jsonStr5), &emptyUpdate); err != nil { t.Fatal(err) } 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)) } } func TestSetUserId(t *testing.T) { reg := newRegistry() g := newTestGrain() applyOK(t, reg, g, msgSetUserId("user123")) if g.userId != "user123" { t.Fatalf("expected userId=user123, got %s", g.userId) } applyErrorContains(t, reg, g, msgSetUserId(""), "cannot be empty") } func TestLineItemMarking(t *testing.T) { reg := newRegistry() g := newTestGrain() applyOK(t, reg, g, msgAddItem("MARK", 1000, 1, nil)) id := g.Items[0].Id applyOK(t, reg, g, msgLineItemMarking(id, 1, "Gift message")) if g.Items[0].Marking == nil || g.Items[0].Marking.Type != 1 || g.Items[0].Marking.Text != "Gift message" { t.Fatalf("marking not set correctly: %+v", g.Items[0].Marking) } applyErrorContains(t, reg, g, msgLineItemMarking(9999, 2, "Test"), "not found") } func TestRemoveLineItemMarking(t *testing.T) { reg := newRegistry() g := newTestGrain() applyOK(t, reg, g, msgAddItem("REMOVE", 1000, 1, nil)) id := g.Items[0].Id // First set a marking applyOK(t, reg, g, msgLineItemMarking(id, 1, "Test marking")) if g.Items[0].Marking == nil || g.Items[0].Marking.Text != "Test marking" { t.Fatalf("marking not set") } // Now remove it applyOK(t, reg, g, msgRemoveLineItemMarking(id)) if g.Items[0].Marking != nil { t.Fatalf("marking not removed") } applyErrorContains(t, reg, g, msgRemoveLineItemMarking(9999), "not found") } func TestSubscriptionAdded(t *testing.T) { reg := newRegistry() g := newTestGrain() applyOK(t, reg, g, msgAddItem("SUB", 1000, 1, nil)) id := g.Items[0].Id applyOK(t, reg, g, msgSubscriptionAdded(id, "det123", "ord456")) if g.Items[0].SubscriptionDetailsId != "det123" || g.Items[0].OrderReference != "ord456" || !g.Items[0].IsSubscribed { t.Fatalf("subscription not added: detailsId=%s orderRef=%s isSubscribed=%v", g.Items[0].SubscriptionDetailsId, g.Items[0].OrderReference, g.Items[0].IsSubscribed) } applyErrorContains(t, reg, g, msgSubscriptionAdded(9999, "", ""), "not found") } func TestPaymentDeclined(t *testing.T) { reg := newRegistry() g := newTestGrain() g.CheckoutOrderId = "test-order" applyOK(t, reg, g, msgPaymentDeclined("Payment failed due to insufficient funds", "INSUFFICIENT_FUNDS")) if g.PaymentStatus != "declined" || g.CheckoutOrderId != "" { t.Fatalf("payment declined not handled: status=%s checkoutId=%s", g.PaymentStatus, g.CheckoutOrderId) } if len(g.PaymentDeclinedNotices) != 1 { t.Fatalf("expected 1 notice, got %d", len(g.PaymentDeclinedNotices)) } notice := g.PaymentDeclinedNotices[0] if notice.Message != "Payment failed due to insufficient funds" { t.Fatalf("notice message not set correctly: %s", notice.Message) } if notice.Code == nil || *notice.Code != "INSUFFICIENT_FUNDS" { t.Fatalf("notice code not set correctly: %v", notice.Code) } if notice.Timestamp.IsZero() { t.Fatalf("notice timestamp not set") } } func TestConfirmationViewed(t *testing.T) { reg := newRegistry() g := newTestGrain() // Initial state if g.Confirmation != nil { t.Fatalf("confirmation should be nil, got %v", g.Confirmation) } // First view applyOK(t, reg, g, msgConfirmationViewed()) if g.Confirmation.ViewCount != 1 { t.Fatalf("view count should be 1, got %d", g.Confirmation.ViewCount) } if g.Confirmation.LastViewedAt.IsZero() { t.Fatalf("ConfirmationLastViewedAt not set") } firstTime := g.Confirmation.LastViewedAt // Second view applyOK(t, reg, g, msgConfirmationViewed()) if g.Confirmation.ViewCount != 2 { t.Fatalf("view count should be 2, got %d", g.Confirmation.ViewCount) } if g.Confirmation.LastViewedAt == firstTime { t.Fatalf("ConfirmationLastViewedAt should have updated") } } func TestCreateCheckoutOrder(t *testing.T) { reg := newRegistry() g := newTestGrain() applyOK(t, reg, g, msgAddItem("CHECKOUT", 1000, 1, nil)) applyOK(t, reg, g, msgCreateCheckoutOrder("accepted", "SE")) if g.CheckoutOrderId == "" || g.CheckoutStatus != "pending" || g.CheckoutCountry != "SE" { t.Fatalf("checkout order not created: id=%s status=%s country=%s", g.CheckoutOrderId, g.CheckoutStatus, g.CheckoutCountry) } // Empty cart g2 := newTestGrain() applyErrorContains(t, reg, g2, msgCreateCheckoutOrder("accepted", ""), "empty cart") // Terms not accepted applyErrorContains(t, reg, g, msgCreateCheckoutOrder("no", ""), "terms must be accepted") } func TestAddGiftcard(t *testing.T) { reg := newRegistry() g := newTestGrain() designConfig, _ := anypb.New(&messages.AddItem{}) // example applyOK(t, reg, g, msgAddGiftcard(5000, "2023-12-25", "John", "email", "Happy Birthday!", designConfig)) if len(g.Giftcards) != 1 { t.Fatalf("expected 1 giftcard, got %d", len(g.Giftcards)) } gc := g.Giftcards[0] if gc.Value.IncVat != 5000 || gc.DeliveryDate != "2023-12-25" || gc.Recipient != "John" || gc.RecipientType != "email" || gc.Message != "Happy Birthday!" { t.Fatalf("giftcard not set correctly: %+v", gc) } if g.TotalPrice.IncVat != 5000 { t.Fatalf("total price not updated, got %d", g.TotalPrice.IncVat) } // Test invalid value applyErrorContains(t, reg, g, msgAddGiftcard(0, "", "", "", "", nil), "must be positive") } func TestRemoveGiftcard(t *testing.T) { reg := newRegistry() g := newTestGrain() applyOK(t, reg, g, msgAddGiftcard(1000, "2023-01-01", "Jane", "sms", "Cheers!", nil)) id := g.Giftcards[0].Id applyOK(t, reg, g, msgRemoveGiftcard(id)) if len(g.Giftcards) != 0 { t.Fatalf("giftcard not removed") } if g.TotalPrice.IncVat != 0 { t.Fatalf("total price not updated after removal, got %d", g.TotalPrice.IncVat) } applyErrorContains(t, reg, g, msgRemoveGiftcard(id), "not found") }