130 lines
3.6 KiB
Go
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)
|
|
}
|
|
}
|