package main import ( "context" "fmt" "io" "log/slog" "path/filepath" "testing" "time" "git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/mats/go-cart-actor/pkg/idempotency" "git.k6n.net/mats/go-cart-actor/pkg/flow" "git.k6n.net/mats/go-cart-actor/pkg/order" ) func testServer(t *testing.T) *server { t.Helper() reg := actor.NewMutationRegistry() order.RegisterMutations(reg) storage := actor.NewDiskStorage[order.OrderGrain](t.TempDir(), reg) pool, err := actor.NewSimpleGrainPool(actor.GrainPoolConfig[order.OrderGrain]{ Hostname: "test", Spawn: func(_ context.Context, id uint64) (actor.Grain[order.OrderGrain], error) { return order.NewOrderGrain(id, time.Now()), nil }, SpawnHost: func(string) (actor.Host[order.OrderGrain], error) { return nil, fmt.Errorf("none") }, Destroy: func(actor.Grain[order.OrderGrain]) error { return nil }, TTL: time.Hour, PoolSize: 100, MutationRegistry: reg, Storage: nil, }) if err != nil { t.Fatal(err) } t.Cleanup(pool.Close) applier := &orderedApplier{pool: pool, storage: storage} provider := order.NewPassthroughProvider("legacy", "ref", 15000) freg := flow.NewRegistry() flow.RegisterBuiltinHooks(freg) order.RegisterFlowActions(freg, applier, provider) order.RegisterEmitHook(freg, nil) engine := flow.NewEngine(freg, slog.New(slog.NewTextHandler(io.Discard, nil))) def, err := order.EmbeddedFlow("place-and-pay") if err != nil { t.Fatal(err) } idem, err := idempotency.Open(filepath.Join(t.TempDir(), "idem.log")) if err != nil { t.Fatal(err) } return &server{ pool: pool, applier: applier, storage: storage, reg: reg, engine: engine, freg: freg, flows: &flowStore{defs: map[string]*flow.Definition{def.Name: def}}, provider: provider, taxProvider: order.NewStaticTaxProvider(), defaultFlow: def.Name, idem: idem, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } } func TestIngestOrderFromQueue(t *testing.T) { s := testServer(t) ctx := context.Background() body := []byte(`{ "checkoutId":"K-1","idempotencyKey":"checkout-K-1","cartId":"cart-1", "currency":"SEK","country":"SE","customerEmail":"customer@example.com", "lines":[{"reference":"l1","sku":"l1","name":"Widget","quantity":1,"unitPrice":15000,"taxRate":25}], "payment":{"provider":"klarna","reference":"ref-123","amount":15000} }`) if err := ingestOrderFromQueue(ctx, s, body); err != nil { t.Fatalf("ingest: %v", err) } // Retrieve the created order grain. The new order ID is recorded in idempotency.Store id, ok := s.idem.Get("checkout-K-1") if !ok { t.Fatalf("expected order ID to be stored in idempotency cache") } g, err := s.applier.Get(ctx, id) if err != nil { t.Fatal(err) } if g.Status != order.StatusCaptured { t.Fatalf("status = %q, want captured", g.Status) } if g.CapturedAmount != 15000 { t.Fatalf("captured = %d, want 15000", g.CapturedAmount) } // Redelivery must be idempotent — no double capture, no new payment. if err := ingestOrderFromQueue(ctx, s, body); err != nil { t.Fatalf("re-ingest: %v", err) } g2, _ := s.applier.Get(ctx, id) if g2.CapturedAmount != 15000 { t.Fatalf("redelivery double-captured: %d", g2.CapturedAmount) } if len(g2.Payments) != 1 { t.Fatalf("redelivery created %d payments, want 1", len(g2.Payments)) } }