package order import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/mail" "slices" "strings" "testing" "time" "git.k6n.net/mats/go-cart-actor/pkg/flow" ) func fullRegistry(t *testing.T, pub Publisher) *flow.Registry { t.Helper() pool := newPool(t) reg := flow.NewRegistry() flow.RegisterBuiltinHooks(reg) RegisterFlowActions(reg, pool, NewMockProvider()) RegisterInventoryReservationActions(reg, pool, nil) RegisterEmitHook(reg, pub) RegisterEmailHook(reg, pool, nil, nil) RegisterFulfillmentWebhookHook(reg, pool, nil) return reg } func TestCapabilitiesIncludesPredicatesAndEmit(t *testing.T) { caps := fullRegistry(t, nil).Capabilities() for _, want := range []string{"has_customer_email", "has_items", "is_captured", "not_captured", "has_open_balance"} { if !slices.Contains(caps.Predicates, want) { t.Errorf("predicates missing %q (got %v)", want, caps.Predicates) } } if !slices.Contains(caps.Hooks, "amqp_emit") { t.Errorf("hooks missing amqp_emit (got %v)", caps.Hooks) } if !slices.Contains(caps.Hooks, "send_email") { t.Errorf("hooks missing send_email (got %v)", caps.Hooks) } if !slices.Contains(caps.Hooks, "fulfillment_webhook") { t.Errorf("hooks missing fulfillment_webhook (got %v)", caps.Hooks) } if !slices.Contains(caps.Actions, "create_fulfillment") { t.Errorf("actions missing create_fulfillment (got %v)", caps.Actions) } if !slices.Contains(caps.Actions, "reserve_inventory") { t.Errorf("actions missing reserve_inventory (got %v)", caps.Actions) } if !slices.Contains(caps.Actions, "release_inventory") { t.Errorf("actions missing release_inventory (got %v)", caps.Actions) } if caps.ActionMeta["create_fulfillment"].Description == "" { t.Fatalf("action meta missing for create_fulfillment: %#v", caps.ActionMeta["create_fulfillment"]) } if len(caps.HookMeta["fulfillment_webhook"].ExampleParams) == 0 { t.Fatalf("hook meta missing example params for fulfillment_webhook: %#v", caps.HookMeta["fulfillment_webhook"]) } if caps.PredicateMeta["has_customer_email"].Description == "" { t.Fatalf("predicate meta missing for has_customer_email: %#v", caps.PredicateMeta["has_customer_email"]) } } type fakeInventoryReservationService struct { reserved []InventoryReservationRequest released []InventoryReservationRequest } func (f *fakeInventoryReservationService) Reserve(_ context.Context, req InventoryReservationRequest) error { f.reserved = append(f.reserved, req) return nil } func (f *fakeInventoryReservationService) Release(_ context.Context, req InventoryReservationRequest) error { f.released = append(f.released, req) return nil } func TestReserveInventoryActionBuildsReservationRequest(t *testing.T) { pool := newPool(t) reg := flow.NewRegistry() RegisterFlowActions(reg, pool, NewMockProvider()) fake := &fakeInventoryReservationService{} RegisterInventoryReservationActions(reg, pool, fake) eng := flow.NewEngine(reg, nil) const id = 703 st := flow.NewState(id, nil) po := placeMsg() st.Vars[PlaceOrderVar] = po def, err := EmbeddedFlow("place-and-pay") if err != nil { t.Fatal(err) } if _, err := eng.Run(context.Background(), def, st); err != nil { t.Fatal(err) } reserve := &flow.Definition{Name: "reserve", Steps: []flow.Step{{ Name: "reserve", Action: "reserve_inventory", Params: json.RawMessage(`{"ttlSeconds":600,"lines":[{"reference":"l1","quantity":1,"locationId":"se"}]}`), Compensate: &flow.ActionRef{ Action: "release_inventory", Params: json.RawMessage(`{"lines":[{"reference":"l1","quantity":1,"locationId":"se"}]}`), }, }}} if _, err := eng.Run(context.Background(), reserve, flow.NewState(id, nil)); err != nil { t.Fatal(err) } if len(fake.reserved) != 1 { t.Fatalf("reserved = %d, want 1", len(fake.reserved)) } got := fake.reserved[0] if got.HolderID != "order:"+OrderId(id).String() { t.Fatalf("holderId = %q", got.HolderID) } if got.TTL != 10*time.Minute { t.Fatalf("ttl = %s", got.TTL) } if len(got.Lines) != 1 || got.Lines[0].SKU != "ABC" || got.Lines[0].Quantity != 1 { t.Fatalf("lines = %+v", got.Lines) } } func TestIsCapturedPredicateGatesStep(t *testing.T) { pool := newPool(t) reg := flow.NewRegistry() flow.RegisterBuiltinHooks(reg) RegisterFlowActions(reg, pool, NewMockProvider()) eng := flow.NewEngine(reg, nil) ran := false reg.Action("mark", func(_ context.Context, _ *flow.State, _ json.RawMessage) error { ran = true; return nil }) // Capture order 9001 via place-and-pay. def, _ := EmbeddedFlow("place-and-pay") paid := flow.NewState(9001, nil) paid.Vars[PlaceOrderVar] = placeMsg() if _, err := eng.Run(context.Background(), def, paid); err != nil { t.Fatal(err) } gated := &flow.Definition{Name: "g", Steps: []flow.Step{{Name: "m", Action: "mark", When: "is_captured"}}} if _, err := eng.Run(context.Background(), gated, flow.NewState(9001, nil)); err != nil { t.Fatal(err) } if !ran { t.Fatal("is_captured should pass for a captured order") } // Fresh (unplaced) order → is_captured false → step skipped. ran = false if _, err := eng.Run(context.Background(), gated, flow.NewState(9999, nil)); err != nil { t.Fatal(err) } if ran { t.Fatal("is_captured should skip for a new order") } } type fakePublisher struct{ msgs []string } func (f *fakePublisher) Publish(_, routingKey string, body []byte) error { f.msgs = append(f.msgs, routingKey+":"+string(body)) return nil } func TestAmqpEmitHookPublishes(t *testing.T) { fp := &fakePublisher{} reg := flow.NewRegistry() RegisterEmitHook(reg, fp) reg.Action("noopact", func(_ context.Context, _ *flow.State, _ json.RawMessage) error { return nil }) eng := flow.NewEngine(reg, nil) def := &flow.Definition{Name: "e", Steps: []flow.Step{{ Name: "s", Action: "noopact", Hooks: flow.Hooks{After: []flow.HookRef{{Type: "amqp_emit", Params: json.RawMessage(`{"routingKey":"k"}`)}}}, }}} if _, err := eng.Run(context.Background(), def, flow.NewState(7, nil)); err != nil { t.Fatal(err) } if len(fp.msgs) != 1 || !strings.HasPrefix(fp.msgs[0], "k:") { t.Fatalf("expected one message routed to k, got %v", fp.msgs) } } type fakeEmailSender struct { defaultFrom mail.Address msgs []EmailMessage } func (f *fakeEmailSender) DefaultFrom() mail.Address { return f.defaultFrom } func (f *fakeEmailSender) Send(_ context.Context, msg EmailMessage) error { f.msgs = append(f.msgs, msg) return nil } func TestSendEmailHookRendersFulfillmentTemplate(t *testing.T) { pool := newPool(t) reg := flow.NewRegistry() RegisterFlowActions(reg, pool, NewMockProvider()) sender := &fakeEmailSender{defaultFrom: mail.Address{Name: "Ops", Address: "ops@example.com"}} templates := newEmailTemplateCatalog(map[string]EmailTemplate{ "ship": { Subject: "Order {{ .Order.OrderReference }} shipped", Text: "Tracking {{ .Fulfillment.TrackingNumber }} for {{ .Order.CustomerEmail }}", }, }) RegisterEmailHook(reg, pool, sender, templates) eng := flow.NewEngine(reg, nil) const id = 701 st := flow.NewState(id, nil) po := placeMsg() po.CustomerEmail = "alice@example.com" po.CustomerName = "Alice" st.Vars[PlaceOrderVar] = po def, err := EmbeddedFlow("place-and-pay") if err != nil { t.Fatal(err) } if _, err := eng.Run(context.Background(), def, st); err != nil { t.Fatal(err) } ship := &flow.Definition{Name: "ship", Steps: []flow.Step{{ Name: "fulfill", Action: "create_fulfillment", Params: json.RawMessage(`{"carrier":"postnord","trackingNumber":"TRACK-123","trackingUri":"https://track.example/TRACK-123","lines":[{"reference":"l1","quantity":1}]}`), Hooks: flow.Hooks{After: []flow.HookRef{{Type: "send_email", Params: json.RawMessage(`{"template":"ship"}`)}}}, }}} if _, err := eng.Run(context.Background(), ship, flow.NewState(id, nil)); err != nil { t.Fatal(err) } if len(sender.msgs) != 1 { t.Fatalf("sent %d emails, want 1", len(sender.msgs)) } got := sender.msgs[0] if got.From.Address != "ops@example.com" { t.Fatalf("from = %q, want ops@example.com", got.From.Address) } if len(got.To) != 1 || got.To[0].Address != "alice@example.com" { t.Fatalf("to = %+v, want alice@example.com", got.To) } if got.Subject != "Order ref-1 shipped" { t.Fatalf("subject = %q", got.Subject) } if !strings.Contains(got.TextBody, "TRACK-123") { t.Fatalf("text body = %q, want tracking number", got.TextBody) } } func TestFulfillmentWebhookPostsOrderPayload(t *testing.T) { pool := newPool(t) reg := flow.NewRegistry() RegisterFlowActions(reg, pool, NewMockProvider()) var gotMethod, gotPath, gotHeader string var gotBody map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotMethod = r.Method gotPath = r.URL.Path gotHeader = r.Header.Get("X-Integration") if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { t.Fatalf("decode webhook body: %v", err) } w.WriteHeader(http.StatusAccepted) })) defer srv.Close() RegisterFulfillmentWebhookHook(reg, pool, srv.Client()) eng := flow.NewEngine(reg, nil) const id = 702 st := flow.NewState(id, nil) po := placeMsg() po.CustomerEmail = "dropship@example.com" st.Vars[PlaceOrderVar] = po def, err := EmbeddedFlow("place-and-pay") if err != nil { t.Fatal(err) } if _, err := eng.Run(context.Background(), def, st); err != nil { t.Fatal(err) } ship := &flow.Definition{Name: "ship", Steps: []flow.Step{{ Name: "fulfill", Action: "create_fulfillment", Params: json.RawMessage(`{"carrier":"dropshipper","trackingNumber":"DS-123","lines":[{"reference":"l1","quantity":1}]}`), Hooks: flow.Hooks{After: []flow.HookRef{{ Type: "fulfillment_webhook", Params: json.RawMessage(fmt.Sprintf(`{ "url": %q, "method": "POST", "headers": { "X-Integration": "dropship {{ .Order.OrderReference }}" } }`, srv.URL+`/hooks/{{ .OrderID }}`)), }}}, }}} if _, err := eng.Run(context.Background(), ship, flow.NewState(id, nil)); err != nil { t.Fatal(err) } if gotMethod != http.MethodPost { t.Fatalf("method = %q, want POST", gotMethod) } if gotPath != "/hooks/"+OrderId(id).String() { t.Fatalf("path = %q", gotPath) } if gotHeader != "dropship ref-1" { t.Fatalf("header = %q", gotHeader) } if gotBody["orderId"] != OrderId(id).String() { t.Fatalf("body orderId = %v", gotBody["orderId"]) } fulfillment, ok := gotBody["fulfillment"].(map[string]any) if !ok { t.Fatalf("body fulfillment missing: %#v", gotBody["fulfillment"]) } if fulfillment["trackingNumber"] != "DS-123" { t.Fatalf("trackingNumber = %v", fulfillment["trackingNumber"]) } }