521 lines
15 KiB
Go
521 lines
15 KiB
Go
package cart
|
|
|
|
import (
|
|
"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 ...*messages.VoucherRule) *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(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(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(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(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))
|
|
}
|
|
}
|