order sagas
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user