449 lines
11 KiB
Go
449 lines
11 KiB
Go
package promotions
|
|
|
|
import (
|
|
"encoding/json"
|
|
"slices"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.tornberg.me/go-cart-actor/pkg/cart"
|
|
)
|
|
|
|
// --- Helpers ---------------------------------------------------------------
|
|
|
|
func cvNum(n float64) ConditionValue {
|
|
b, _ := json.Marshal(n)
|
|
return ConditionValue{Raw: b}
|
|
}
|
|
|
|
func cvString(s string) ConditionValue {
|
|
b, _ := json.Marshal(s)
|
|
return ConditionValue{Raw: b}
|
|
}
|
|
|
|
func cvStrings(ss []string) ConditionValue {
|
|
b, _ := json.Marshal(ss)
|
|
return ConditionValue{Raw: b}
|
|
}
|
|
|
|
// testTracer captures trace events for assertions.
|
|
type testTracer struct {
|
|
events []traceEvent
|
|
}
|
|
|
|
type traceEvent struct {
|
|
event string
|
|
data map[string]any
|
|
}
|
|
|
|
func (t *testTracer) Trace(event string, data map[string]any) {
|
|
t.events = append(t.events, traceEvent{event: event, data: data})
|
|
}
|
|
|
|
func (t *testTracer) HasEvent(name string) bool {
|
|
return slices.ContainsFunc(t.events, func(e traceEvent) bool { return e.event == name })
|
|
}
|
|
|
|
func (t *testTracer) Count(name string) int {
|
|
c := 0
|
|
for _, e := range t.events {
|
|
if e.event == name {
|
|
c++
|
|
}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// makeCart creates a cart with given total and items (each item quantity & price IncVat).
|
|
func makeCart(totalOverride int64, items []struct {
|
|
sku string
|
|
category string
|
|
qty int
|
|
priceInc int64
|
|
}) *cart.CartGrain {
|
|
g := cart.NewCartGrain(1, time.Now())
|
|
for _, it := range items {
|
|
p := cart.NewPriceFromIncVat(it.priceInc, 0.25)
|
|
g.Items = append(g.Items, &cart.CartItem{
|
|
Id: uint32(len(g.Items) + 1),
|
|
Sku: it.sku,
|
|
Price: *p,
|
|
TotalPrice: cart.Price{
|
|
IncVat: p.IncVat * int64(it.qty),
|
|
VatRates: p.VatRates,
|
|
},
|
|
Quantity: it.qty,
|
|
Meta: &cart.ItemMeta{
|
|
Category: it.category,
|
|
},
|
|
})
|
|
}
|
|
// Recalculate totals
|
|
g.UpdateTotals()
|
|
if totalOverride >= 0 {
|
|
g.TotalPrice.IncVat = totalOverride
|
|
}
|
|
return g
|
|
}
|
|
|
|
// --- Tests -----------------------------------------------------------------
|
|
|
|
func TestEvaluateRuleBasicAND(t *testing.T) {
|
|
g := makeCart(12000, []struct {
|
|
sku string
|
|
category string
|
|
qty int
|
|
priceInc int64
|
|
}{
|
|
{"SKU-1", "summer", 2, 3000},
|
|
{"SKU-2", "winter", 1, 6000},
|
|
})
|
|
|
|
ctx := NewContextFromCart(g,
|
|
WithCustomerSegment("vip"),
|
|
WithOrderCount(10),
|
|
WithCustomerLifetimeValue(1234.56),
|
|
WithNow(time.Date(2024, 6, 10, 12, 0, 0, 0, time.UTC)),
|
|
)
|
|
|
|
// Conditions: cart_total >= 10000 AND item_quantity >= 3 AND customer_segment = vip
|
|
conds := Conditions{
|
|
ConditionGroup{
|
|
ID: "grp",
|
|
Type: "group",
|
|
Operator: LogicAND,
|
|
Conditions: Conditions{
|
|
BaseCondition{
|
|
ID: "c_cart_total",
|
|
Type: CondCartTotal,
|
|
Operator: OpGreaterOrEqual,
|
|
Value: cvNum(10000),
|
|
},
|
|
BaseCondition{
|
|
ID: "c_item_qty",
|
|
Type: CondItemQuantity,
|
|
Operator: OpGreaterOrEqual,
|
|
Value: cvNum(3),
|
|
},
|
|
BaseCondition{
|
|
ID: "c_segment",
|
|
Type: CondCustomerSegment,
|
|
Operator: OpEquals,
|
|
Value: cvString("vip"),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
rule := PromotionRule{
|
|
ID: "rule-AND",
|
|
Name: "VIP Summer",
|
|
Description: "Test rule",
|
|
Status: StatusActive,
|
|
Priority: 1,
|
|
StartDate: "2024-06-01",
|
|
EndDate: ptr("2024-06-30"),
|
|
Conditions: conds,
|
|
Actions: []Action{
|
|
{ID: "a1", Type: ActionPercentageDiscount, Value: 10.0},
|
|
},
|
|
UsageLimit: ptrInt(100),
|
|
UsageCount: 5,
|
|
CreatedAt: time.Now().Format(time.RFC3339),
|
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
|
CreatedBy: "tester",
|
|
}
|
|
|
|
tracer := &testTracer{}
|
|
svc := NewPromotionService(tracer)
|
|
res := svc.EvaluateRule(rule, ctx)
|
|
if !res.Applicable {
|
|
t.Fatalf("expected rule to be applicable, failedReason=%s", res.FailedReason)
|
|
}
|
|
if len(res.MatchedActions) != 1 {
|
|
t.Fatalf("expected 1 action, got %d", len(res.MatchedActions))
|
|
}
|
|
if !tracer.HasEvent("rule_applicable") {
|
|
t.Errorf("expected tracing event rule_applicable")
|
|
}
|
|
}
|
|
|
|
func TestEvaluateRuleUsageLimitExhausted(t *testing.T) {
|
|
rule := PromotionRule{
|
|
ID: "limit",
|
|
Name: "Limit",
|
|
Status: StatusActive,
|
|
Priority: 1,
|
|
StartDate: "2024-01-01",
|
|
EndDate: nil,
|
|
Conditions: Conditions{},
|
|
UsageLimit: ptrInt(5),
|
|
UsageCount: 5,
|
|
}
|
|
|
|
ctx := &PromotionEvalContext{Now: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)}
|
|
tracer := &testTracer{}
|
|
svc := NewPromotionService(tracer)
|
|
res := svc.EvaluateRule(rule, ctx)
|
|
if res.Applicable {
|
|
t.Fatalf("expected rule NOT applicable due to usage limit")
|
|
}
|
|
if res.FailedReason != "usage_limit_exhausted" {
|
|
t.Fatalf("expected failedReason usage_limit_exhausted, got %s", res.FailedReason)
|
|
}
|
|
if !tracer.HasEvent("rule_skip_usage_limit") {
|
|
t.Errorf("tracer missing rule_skip_usage_limit event")
|
|
}
|
|
}
|
|
|
|
func TestEvaluateRuleDateWindow(t *testing.T) {
|
|
// Start in future
|
|
rule := PromotionRule{
|
|
ID: "date",
|
|
Name: "DateWindow",
|
|
Status: StatusActive,
|
|
Priority: 1,
|
|
StartDate: "2025-01-01",
|
|
EndDate: ptr("2025-01-31"),
|
|
Conditions: Conditions{},
|
|
}
|
|
ctx := &PromotionEvalContext{Now: time.Date(2024, 12, 15, 12, 0, 0, 0, time.UTC)}
|
|
tracer := &testTracer{}
|
|
svc := NewPromotionService(tracer)
|
|
res := svc.EvaluateRule(rule, ctx)
|
|
if res.Applicable {
|
|
t.Fatalf("expected rule NOT applicable (before start)")
|
|
}
|
|
if res.FailedReason != "before_start" {
|
|
t.Fatalf("expected failedReason before_start, got %s", res.FailedReason)
|
|
}
|
|
if !tracer.HasEvent("rule_skip_before_start") {
|
|
t.Errorf("missing rule_skip_before_start trace event")
|
|
}
|
|
}
|
|
|
|
func TestEvaluateProductCategoryIN(t *testing.T) {
|
|
g := makeCart(-1, []struct {
|
|
sku string
|
|
category string
|
|
qty int
|
|
priceInc int64
|
|
}{
|
|
{"A", "shoes", 1, 5000},
|
|
{"B", "bags", 1, 7000},
|
|
})
|
|
|
|
ctx := NewContextFromCart(g)
|
|
conds := Conditions{
|
|
BaseCondition{
|
|
ID: "c_category",
|
|
Type: CondProductCategory,
|
|
Operator: OpIn,
|
|
Value: cvStrings([]string{"shoes", "hats"}),
|
|
},
|
|
}
|
|
rule := PromotionRule{
|
|
ID: "cat-in",
|
|
Status: StatusActive,
|
|
Priority: 10,
|
|
StartDate: "2024-01-01",
|
|
EndDate: nil,
|
|
Conditions: conds,
|
|
Actions: []Action{
|
|
{ID: "discount", Type: ActionFixedDiscount, Value: 1000},
|
|
},
|
|
}
|
|
tracer := &testTracer{}
|
|
svc := NewPromotionService(tracer)
|
|
res := svc.EvaluateRule(rule, ctx)
|
|
if !res.Applicable {
|
|
t.Fatalf("expected category IN rule to apply")
|
|
}
|
|
if !tracer.HasEvent("rule_applicable") {
|
|
t.Errorf("tracing missing rule_applicable")
|
|
}
|
|
}
|
|
|
|
func TestEvaluateGroupOR(t *testing.T) {
|
|
g := makeCart(3000, []struct {
|
|
sku string
|
|
category string
|
|
qty int
|
|
priceInc int64
|
|
}{
|
|
{"ONE", "x", 1, 3000},
|
|
})
|
|
|
|
ctx := NewContextFromCart(g)
|
|
|
|
// OR group: (cart_total >= 10000) OR (item_quantity >= 1)
|
|
group := ConditionGroup{
|
|
ID: "grp-or",
|
|
Type: "group",
|
|
Operator: LogicOR,
|
|
Conditions: Conditions{
|
|
BaseCondition{
|
|
ID: "c_total",
|
|
Type: CondCartTotal,
|
|
Operator: OpGreaterOrEqual,
|
|
Value: cvNum(10000),
|
|
},
|
|
BaseCondition{
|
|
ID: "c_qty",
|
|
Type: CondItemQuantity,
|
|
Operator: OpGreaterOrEqual,
|
|
Value: cvNum(1),
|
|
},
|
|
},
|
|
}
|
|
rule := PromotionRule{
|
|
ID: "or-rule",
|
|
Status: StatusActive,
|
|
Priority: 5,
|
|
StartDate: "2024-01-01",
|
|
EndDate: nil,
|
|
Conditions: Conditions{group},
|
|
Actions: []Action{{ID: "a", Type: ActionFixedDiscount, Value: 500}},
|
|
}
|
|
tracer := &testTracer{}
|
|
svc := NewPromotionService(tracer)
|
|
res := svc.EvaluateRule(rule, ctx)
|
|
if !res.Applicable {
|
|
t.Fatalf("expected OR rule to apply (second condition true)")
|
|
}
|
|
// Ensure group pass event
|
|
if !tracer.HasEvent("cond_group_or_pass") {
|
|
t.Errorf("expected cond_group_or_pass trace event")
|
|
}
|
|
}
|
|
|
|
func TestEvaluateAllPriorityOrdering(t *testing.T) {
|
|
ctx := &PromotionEvalContext{
|
|
CartTotalIncVat: 20000,
|
|
TotalItemQuantity: 2,
|
|
Items: []PromotionItem{
|
|
{SKU: "X", Quantity: 2, Category: "general", PriceIncVat: 10000},
|
|
},
|
|
Now: time.Date(2024, 5, 10, 10, 0, 0, 0, time.UTC),
|
|
}
|
|
|
|
// Rule A: priority 5
|
|
ruleA := PromotionRule{
|
|
ID: "A",
|
|
Status: StatusActive,
|
|
Priority: 5,
|
|
StartDate: "2024-01-01",
|
|
EndDate: nil,
|
|
Conditions: Conditions{},
|
|
Actions: []Action{
|
|
{ID: "actionA", Type: ActionFixedDiscount, Value: 100},
|
|
},
|
|
}
|
|
|
|
// Rule B: priority 1
|
|
ruleB := PromotionRule{
|
|
ID: "B",
|
|
Status: StatusActive,
|
|
Priority: 1,
|
|
StartDate: "2024-01-01",
|
|
EndDate: nil,
|
|
Conditions: Conditions{},
|
|
Actions: []Action{
|
|
{ID: "actionB", Type: ActionFixedDiscount, Value: 200},
|
|
},
|
|
}
|
|
|
|
tracer := &testTracer{}
|
|
svc := NewPromotionService(tracer)
|
|
results, actions := svc.EvaluateAll([]PromotionRule{ruleA, ruleB}, ctx)
|
|
if len(results) != 2 {
|
|
t.Fatalf("expected 2 results, got %d", len(results))
|
|
}
|
|
if len(actions) != 2 {
|
|
t.Fatalf("expected 2 actions, got %d", len(actions))
|
|
}
|
|
|
|
// Actions should follow priority order: ruleB (1) then ruleA (5)
|
|
if actions[0].ID != "actionB" || actions[1].ID != "actionA" {
|
|
t.Fatalf("actions order invalid: %+v", actions)
|
|
}
|
|
if tracer.Count("actions_add") != 2 {
|
|
t.Errorf("expected 2 actions_add trace events, got %d", tracer.Count("actions_add"))
|
|
}
|
|
if !tracer.HasEvent("evaluation_complete") {
|
|
t.Errorf("missing evaluation_complete trace")
|
|
}
|
|
}
|
|
|
|
func TestDayOfWeekAndTimeOfDay(t *testing.T) {
|
|
// Wednesday 14:30 UTC
|
|
now := time.Date(2024, 6, 12, 14, 30, 0, 0, time.UTC) // 2024-06-12 is Wednesday
|
|
ctx := &PromotionEvalContext{
|
|
Now: now,
|
|
}
|
|
|
|
condDay := BaseCondition{
|
|
ID: "dow",
|
|
Type: CondDayOfWeek,
|
|
Operator: OpIn,
|
|
Value: cvStrings([]string{"wed", "fri"}),
|
|
}
|
|
condTime := BaseCondition{
|
|
ID: "tod",
|
|
Type: CondTimeOfDay,
|
|
Operator: OpEquals, // operator is ignored for time-of-day internally
|
|
Value: cvString("13:00-15:00"),
|
|
}
|
|
|
|
rule := PromotionRule{
|
|
ID: "day-time",
|
|
Status: StatusActive,
|
|
Priority: 1,
|
|
StartDate: "2024-01-01",
|
|
EndDate: nil,
|
|
Conditions: Conditions{condDay, condTime},
|
|
Actions: []Action{{ID: "a", Type: ActionPercentageDiscount, Value: 15}},
|
|
}
|
|
|
|
tracer := &testTracer{}
|
|
svc := NewPromotionService(tracer)
|
|
res := svc.EvaluateRule(rule, ctx)
|
|
if !res.Applicable {
|
|
t.Fatalf("expected rule to apply for Wednesday 14:30 in window 13-15")
|
|
}
|
|
}
|
|
|
|
func TestDateRangeCondition(t *testing.T) {
|
|
now := time.Date(2024, 3, 15, 9, 0, 0, 0, time.UTC)
|
|
ctx := &PromotionEvalContext{Now: now}
|
|
|
|
condRange := BaseCondition{
|
|
ID: "date_range",
|
|
Type: CondDateRange,
|
|
Operator: OpEquals, // not used
|
|
Value: cvStrings([]string{"2024-03-01", "2024-03-31"}),
|
|
}
|
|
|
|
rule := PromotionRule{
|
|
ID: "range",
|
|
Status: StatusActive,
|
|
Priority: 1,
|
|
StartDate: "2024-01-01",
|
|
EndDate: nil,
|
|
Conditions: Conditions{condRange},
|
|
Actions: []Action{{ID: "a", Type: ActionFixedDiscount, Value: 500}},
|
|
}
|
|
|
|
tracer := &testTracer{}
|
|
svc := NewPromotionService(tracer)
|
|
res := svc.EvaluateRule(rule, ctx)
|
|
if !res.Applicable {
|
|
t.Fatalf("expected date range rule to apply for 2024-03-15")
|
|
}
|
|
}
|
|
|
|
// --- Utilities -------------------------------------------------------------
|
|
|
|
func ptr(s string) *string { return &s }
|
|
func ptrInt(i int) *int { return &i }
|