170 lines
5.9 KiB
Go
170 lines
5.9 KiB
Go
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)
|
|
}
|
|
}
|