Files
go-cart-actor/pkg/order/flow_test.go
T
2026-07-01 10:40:28 +02:00

130 lines
3.6 KiB
Go

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
}
func newFlowRegistry(pool Applier, provider PaymentProvider) *flow.Registry {
reg := flow.NewRegistry()
flow.RegisterBuiltinHooks(reg)
RegisterFlowActions(reg, pool, provider)
// 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)
}
}