order sagas
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled

This commit is contained in:
2026-06-21 11:33:20 +02:00
parent 484f2364fb
commit 1a365de071
25 changed files with 4224 additions and 1 deletions
+169
View File
@@ -0,0 +1,169 @@
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)
}
}