package order import ( "context" "fmt" "testing" "time" "git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/mats/go-cart-actor/pkg/flow" ) // newPool builds a single-node order grain pool backed by a disk event log in a // temp dir — the same machinery production uses, minus clustering. func newPool(t *testing.T) *actor.SimpleGrainPool[OrderGrain] { t.Helper() reg := actor.NewMutationRegistry() RegisterMutations(reg) storage := actor.NewDiskStorage[OrderGrain](t.TempDir(), reg) pool, err := actor.NewSimpleGrainPool(actor.GrainPoolConfig[OrderGrain]{ Hostname: "test", Spawn: func(_ context.Context, id uint64) (actor.Grain[OrderGrain], error) { return NewOrderGrain(id, time.Now()), nil }, SpawnHost: func(string) (actor.Host[OrderGrain], error) { return nil, fmt.Errorf("no remotes") }, Destroy: func(actor.Grain[OrderGrain]) error { return nil }, TTL: time.Hour, PoolSize: 100, MutationRegistry: reg, Storage: storage, }) if err != nil { t.Fatal(err) } t.Cleanup(pool.Close) return pool } // alwaysAllowChecker is a mock EmailPreferenceChecker that allows every email. type alwaysAllowChecker struct{} func (alwaysAllowChecker) Allowed(_ context.Context, _ string, _ string) bool { return true } func newFlowRegistry(pool Applier, provider PaymentProvider) *flow.Registry { reg := flow.NewRegistry() flow.RegisterBuiltinHooks(reg) RegisterFlowActions(reg, pool, provider, alwaysAllowChecker{}) // place-and-pay references the emit_order_created hook; register it (nil // publisher = no-op) so flow validation passes in tests too. RegisterOrderCreatedEmit(reg, pool, nil, "order", "order") // place-and-pay also references send_email hooks; register it with nil // sender and nil templates so validation passes (runtime = error, tests // that exercise email have their own sender/templates). RegisterEmailHook(reg, pool, nil, nil) return reg } func TestPlaceAndPayFlow(t *testing.T) { pool := newPool(t) eng := flow.NewEngine(newFlowRegistry(pool, NewMockProvider()), nil) def, err := EmbeddedFlow("place-and-pay") if err != nil { t.Fatalf("load flow: %v", err) } if err := eng.Validate(def); err != nil { t.Fatalf("validate: %v", err) } const id = 1001 st := flow.NewState(id, nil) st.Vars[PlaceOrderVar] = placeMsg() res, err := eng.Run(context.Background(), def, st) if err != nil { t.Fatalf("run: %v", err) } if res.Failed { t.Fatalf("flow reported failure: %+v", res.Steps) } o, err := pool.Get(context.Background(), id) if err != nil { t.Fatal(err) } if o.Status != StatusCaptured { t.Fatalf("status = %q, want captured", o.Status) } if o.CapturedAmount != 25000 { t.Fatalf("captured = %d, want 25000", o.CapturedAmount) } } // failCapture authorizes fine but fails to capture, exercising the saga // compensation path (void_payment + cancel). type failCapture struct{ *MockProvider } func (failCapture) Capture(context.Context, string, int64) (Capture, error) { return Capture{}, fmt.Errorf("processor declined capture") } func TestCaptureFailureCompensates(t *testing.T) { pool := newPool(t) eng := flow.NewEngine(newFlowRegistry(pool, failCapture{NewMockProvider()}), nil) def, err := EmbeddedFlow("place-and-pay") if err != nil { t.Fatal(err) } const id = 1002 st := flow.NewState(id, nil) st.Vars[PlaceOrderVar] = placeMsg() res, err := eng.Run(context.Background(), def, st) if err == nil { t.Fatal("expected flow to fail on capture") } if !res.Failed { t.Fatal("result should be marked failed") } if len(res.Compensated) != 1 || res.Compensated[0] != "authorize" { t.Fatalf("compensated = %v, want [authorize]", res.Compensated) } o, err := pool.Get(context.Background(), id) if err != nil { t.Fatal(err) } if o.Status != StatusCancelled { t.Fatalf("status = %q, want cancelled (compensated)", o.Status) } }