package promotions import ( "encoding/json" "slices" "testing" "time" "git.k6n.net/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 }