Files
go-cart-actor/pkg/order/capabilities_test.go
T
mats 46be260fcc
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
refactor
2026-06-29 12:34:58 +02:00

337 lines
10 KiB
Go

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"])
}
}