337 lines
10 KiB
Go
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"])
|
|
}
|
|
}
|