package order import ( "context" "testing" "time" "git.k6n.net/mats/go-cart-actor/pkg/actor" messages "git.k6n.net/mats/go-cart-actor/proto/order" "google.golang.org/protobuf/proto" ) // apply runs one mutation through the registry and returns the handler error // (the registry surfaces per-mutation handler errors in the result, returning a // top-level error only for unregistered messages). func apply(t *testing.T, reg actor.MutationRegistry, g *OrderGrain, msg proto.Message) error { t.Helper() results, err := reg.Apply(context.Background(), g, msg) if err != nil { return err } if len(results) != 1 { t.Fatalf("expected 1 result, got %d", len(results)) } return results[0].Error } func newRegistry() actor.MutationRegistry { reg := actor.NewMutationRegistry() RegisterMutations(reg) return reg } func placeMsg() *messages.PlaceOrder { return &messages.PlaceOrder{ OrderReference: "ref-1", CartId: "cart-1", Currency: "SEK", Country: "se", TotalAmount: 25000, TotalTax: 5000, PlacedAtMs: time.Date(2026, 6, 20, 10, 0, 0, 0, time.UTC).UnixMilli(), Lines: []*messages.OrderLine{ {Reference: "l1", Sku: "ABC", Name: "Widget", Quantity: 2, UnitPrice: 10000, TaxRate: 25, TotalAmount: 20000}, {Reference: "l2", Sku: "DEF", Name: "Gizmo", Quantity: 1, UnitPrice: 5000, TaxRate: 25, TotalAmount: 5000}, }, } } func TestHappyPathLifecycle(t *testing.T) { reg := newRegistry() g := NewOrderGrain(1, time.Now()) steps := []struct { name string msg proto.Message want Status }{ {"place", placeMsg(), StatusPending}, {"authorize", &messages.AuthorizePayment{Provider: "mock", Amount: 25000, Reference: "mock-auth-ref-1"}, StatusAuthorized}, {"capture", &messages.CapturePayment{Provider: "mock", Amount: 25000, Reference: "mock-capture-1"}, StatusCaptured}, {"ship l1", &messages.CreateFulfillment{Id: "f1", Lines: []*messages.FulfillmentLine{{Reference: "l1", Quantity: 2}}}, StatusPartiallyFulfilled}, {"ship l2", &messages.CreateFulfillment{Id: "f2", Lines: []*messages.FulfillmentLine{{Reference: "l2", Quantity: 1}}}, StatusFulfilled}, {"complete", &messages.CompleteOrder{}, StatusCompleted}, } for _, s := range steps { if err := apply(t, reg, g, s.msg); err != nil { t.Fatalf("%s: unexpected error: %v", s.name, err) } if g.Status != s.want { t.Fatalf("%s: status = %q, want %q", s.name, g.Status, s.want) } } if g.CapturedAmount != 25000 { t.Fatalf("captured = %d, want 25000", g.CapturedAmount) } } func TestIllegalTransitionsRejected(t *testing.T) { reg := newRegistry() // Capture before placing must fail and leave the order untouched. g := NewOrderGrain(2, time.Now()) if err := apply(t, reg, g, &messages.CapturePayment{Provider: "mock", Amount: 100}); err == nil { t.Fatal("capture on a new order should fail") } if g.Status != StatusNew { t.Fatalf("status = %q, want new", g.Status) } // After capture, cancel is illegal (must refund instead). g = NewOrderGrain(3, time.Now()) mustApply(t, reg, g, placeMsg()) mustApply(t, reg, g, &messages.AuthorizePayment{Provider: "mock", Amount: 25000, Reference: "a"}) mustApply(t, reg, g, &messages.CapturePayment{Provider: "mock", Amount: 25000, Reference: "c"}) if err := apply(t, reg, g, &messages.CancelOrder{Reason: "changed mind"}); err == nil { t.Fatal("cancel after capture should fail") } if g.Status != StatusCaptured { t.Fatalf("status = %q, want captured", g.Status) } } func TestRefundClosesOrder(t *testing.T) { reg := newRegistry() g := NewOrderGrain(4, time.Now()) mustApply(t, reg, g, placeMsg()) mustApply(t, reg, g, &messages.AuthorizePayment{Provider: "mock", Amount: 25000, Reference: "a"}) mustApply(t, reg, g, &messages.CapturePayment{Provider: "mock", Amount: 25000, Reference: "c"}) mustApply(t, reg, g, &messages.IssueRefund{Provider: "mock", Amount: 25000, Reference: "r"}) if g.Status != StatusRefunded { t.Fatalf("status = %q, want refunded", g.Status) } if g.RefundedAmount != 25000 { t.Fatalf("refunded = %d, want 25000", g.RefundedAmount) } // Over-refunding must be rejected. if err := apply(t, reg, g, &messages.IssueRefund{Provider: "mock", Amount: 1, Reference: "r2"}); err == nil { t.Fatal("refund beyond captured should fail") } } // TestEventSourcedReplay proves the core B1.2 property: the order log is the // source of truth and replaying it through the registry reproduces the exact // projected state. func TestEventSourcedReplay(t *testing.T) { reg := newRegistry() storage := actor.NewDiskStorage[OrderGrain](t.TempDir(), reg) const id = 42 g := NewOrderGrain(id, time.Now()) events := []proto.Message{ placeMsg(), &messages.AuthorizePayment{Provider: "mock", Amount: 25000, Reference: "a", AtMs: 1}, &messages.CapturePayment{Provider: "mock", Amount: 25000, Reference: "c", AtMs: 2}, &messages.CreateFulfillment{Id: "f1", Lines: []*messages.FulfillmentLine{{Reference: "l1", Quantity: 2}}, AtMs: 3}, } for _, e := range events { mustApply(t, reg, g, e) if err := storage.AppendMutations(id, e); err != nil { t.Fatalf("append: %v", err) } } // Replay into a brand-new grain and compare the projection. replayed := NewOrderGrain(id, time.Now()) if err := storage.LoadEvents(context.Background(), id, replayed); err != nil { t.Fatalf("replay: %v", err) } if replayed.Status != g.Status { t.Fatalf("replayed status = %q, want %q", replayed.Status, g.Status) } if replayed.CapturedAmount != g.CapturedAmount { t.Fatalf("replayed captured = %d, want %d", replayed.CapturedAmount, g.CapturedAmount) } if len(replayed.Fulfillments) != len(g.Fulfillments) { t.Fatalf("replayed fulfillments = %d, want %d", len(replayed.Fulfillments), len(g.Fulfillments)) } if got := replayed.Lines[0].Fulfilled; got != 2 { t.Fatalf("replayed line l1 fulfilled = %d, want 2", got) } } func mustApply(t *testing.T, reg actor.MutationRegistry, g *OrderGrain, msg proto.Message) { t.Helper() if err := apply(t, reg, g, msg); err != nil { t.Fatalf("apply %T: %v", msg, err) } }