package promotions import ( "testing" "time" "git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/mats/platform/money" ) func timeZero() time.Time { return time.Date(2024, 6, 10, 12, 0, 0, 0, time.UTC) } // volymrabattRule mirrors the seeded data/promotions.json rule so the test // exercises the same tier configuration shipped to the cart service. func volymrabattRule() PromotionRule { return PromotionRule{ ID: "volymrabatt", Name: "Volymrabatt", Status: StatusActive, Priority: 100, Conditions: Conditions{ BaseCondition{ID: "min", Type: CondCartTotal, Operator: OpGreaterOrEqual, Value: cvNum(2000000)}, }, Actions: []Action{ { ID: "tiers", Type: ActionTieredDiscount, Config: map[string]interface{}{ "tiers": []interface{}{ map[string]interface{}{"minTotal": 2000000.0, "maxTotal": 4000000.0, "percent": 5.0}, map[string]interface{}{"minTotal": 4000000.0, "maxTotal": 6000000.0, "percent": 9.0}, map[string]interface{}{"minTotal": 6000000.0, "maxTotal": 9000000.0, "percent": 14.0}, }, }, }, }, } } // cartWithTotal builds a grain whose single line item produces the requested // gross (incl. 25% VAT) total in öre, then runs UpdateTotals. func cartWithTotal(incVat int64) *cart.CartGrain { g := cart.NewCartGrain(1, timeZero()) g.Items = []*cart.CartItem{ {Id: 1, Sku: "SKU", Quantity: 1, Price: *cart.NewPriceFromIncVat(incVat, 25)}, } g.UpdateTotals() return g } func TestVolymrabattTiers(t *testing.T) { rules := []PromotionRule{volymrabattRule()} cases := []struct { name string total int64 wantDiscount money.Cents wantNet money.Cents }{ {"below threshold (19 999 kr)", 1999900, 0, 1999900}, {"low tier 5% (20 000 kr)", 2000000, 100000, 1900000}, {"low tier boundary just under 40 000", 3999900, 199995, 3799905}, {"mid tier 9% at 40 000 boundary", 4000000, 360000, 3640000}, {"mid tier 9% (50 000 kr)", 5000000, 450000, 4550000}, {"high tier 14% at 60 000 boundary", 6000000, 840000, 5160000}, {"high tier 14% (90 000 kr)", 9000000, 1260000, 7740000}, {"above top tier (100 000 kr) -> no discount", 10000000, 0, 10000000}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { svc := NewPromotionService(nil) g := cartWithTotal(c.total) results, _ := svc.EvaluateAll(rules, NewContextFromCart(g, WithNow(timeZero()))) ctx := NewContextFromCart(g, WithNow(timeZero())) svc.ApplyResults(g, results, ctx) if got := g.TotalDiscount.IncVat; got != c.wantDiscount { t.Errorf("discount = %d, want %d", got, c.wantDiscount) } if got := g.TotalPrice.IncVat; got != c.wantNet { t.Errorf("net total = %d, want %d", got, c.wantNet) } }) } } // findApplied returns the first appliedPromotions entry for ruleID. func findApplied(g *cart.CartGrain, ruleID string) (cart.AppliedPromotion, bool) { for _, ap := range g.AppliedPromotions { if ap.PromotionId == ruleID { return ap, true } } return cart.AppliedPromotion{}, false } // TestApplyResultsRecordsEffects covers the applied/pending breakdown the result // now exposes: an applied effect attributed to its rule, a pending nudge with // progress when below the threshold, and progress-toward-the-next-tier on an // already-applied tiered discount. func TestApplyResultsRecordsEffects(t *testing.T) { rules := []PromotionRule{volymrabattRule()} apply := func(total int64) *cart.CartGrain { svc := NewPromotionService(nil) g := cartWithTotal(total) results, _ := svc.EvaluateAll(rules, NewContextFromCart(g, WithNow(timeZero()))) svc.ApplyResults(g, results, NewContextFromCart(g, WithNow(timeZero()))) return g } t.Run("applied effect records discount + next-tier progress", func(t *testing.T) { g := apply(5000000) // qualifies at the 9% tier ap, ok := findApplied(g, "volymrabatt") if !ok { t.Fatal("no volymrabatt entry recorded") } if ap.Pending { t.Error("applied effect should not be pending") } if ap.Discount == nil || ap.Discount.IncVat != 450000 { t.Errorf("discount = %v, want IncVat 450000", ap.Discount) } // 50 000 kr sits in the 9% tier; the next tier (14%) starts at 60 000 kr. if got := ap.Progress["remaining"]; got != int64(1000000) { t.Errorf("progress remaining = %v, want 1000000", got) } if got := ap.Progress["nextPercent"]; got != 14.0 { t.Errorf("progress nextPercent = %v, want 14", got) } }) t.Run("pending nudge below threshold", func(t *testing.T) { g := apply(1500000) // below the 20 000 kr entry tier if g.TotalDiscount.IncVat != 0 { t.Errorf("nothing should be applied, discount = %d", g.TotalDiscount.IncVat) } ap, ok := findApplied(g, "volymrabatt") if !ok { t.Fatal("expected a pending volymrabatt nudge") } if !ap.Pending || ap.Discount != nil { t.Errorf("expected pending with no discount, got pending=%v discount=%v", ap.Pending, ap.Discount) } if got := ap.Progress["remaining"]; got != int64(500000) { t.Errorf("progress remaining = %v, want 500000", got) } }) } func TestBuyXGetYEffect(t *testing.T) { rule := PromotionRule{ ID: "buy2get1", Name: "Buy 2 Get 1 Free", Status: StatusActive, Actions: []Action{ { ID: "buy2get1-action", Type: ActionBuyXGetY, Config: map[string]interface{}{ "buy": 2.0, "get": 1.0, "discount": 100.0, }, }, }, } svc := NewPromotionService(nil) // Cart with 3 items of price 1000 each. Buy 2 get 1 free -> 1 is free -> 1000 discount. g := cart.NewCartGrain(1, timeZero()) g.Items = []*cart.CartItem{ {Id: 1, Sku: "SKU1", Quantity: 3, Price: *cart.NewPriceFromIncVat(1000, 25)}, } g.UpdateTotals() results, _ := svc.EvaluateAll([]PromotionRule{rule}, NewContextFromCart(g, WithNow(timeZero()))) svc.ApplyResults(g, results, NewContextFromCart(g, WithNow(timeZero()))) if got := g.TotalDiscount.IncVat; got != 1000 { t.Errorf("discount = %d, want 1000", got) } if got := g.TotalPrice.IncVat; got != 2000 { t.Errorf("total price = %d, want 2000", got) } } func TestBundleDiscountEffect(t *testing.T) { rule := PromotionRule{ ID: "bundle-deal", Name: "Bundle Deal", Status: StatusActive, Actions: []Action{ { ID: "bundle-action", Type: ActionBundleDiscount, BundleConfig: &BundleConfig{ Containers: []BundleContainer{ { ID: "shoes", Name: "Shoes", Quantity: 1, SelectionType: "any", QualifyingRules: BundleQualifyingRules{ Type: "category", Value: "shoes", }, }, { ID: "socks", Name: "Socks", Quantity: 2, SelectionType: "any", QualifyingRules: BundleQualifyingRules{ Type: "category", Value: "socks", }, }, }, Pricing: BundlePricing{ Type: "fixed_price", Value: 40.0, // 40 kr -> 4000 ore }, RequireAllContainers: true, }, }, }, } svc := NewPromotionService(nil) // Cart with 1 shoe (5000) and 2 socks (1000 each). Total original = 7000. Bundle fixed price is 4000. Discount = 3000. g := cart.NewCartGrain(1, timeZero()) g.Items = []*cart.CartItem{ {Id: 1, Sku: "shoe-1", Quantity: 1, Price: *cart.NewPriceFromIncVat(5000, 25), Meta: &cart.ItemMeta{Category: "shoes"}}, {Id: 2, Sku: "socks-1", Quantity: 2, Price: *cart.NewPriceFromIncVat(1000, 25), Meta: &cart.ItemMeta{Category: "socks"}}, } g.UpdateTotals() results, _ := svc.EvaluateAll([]PromotionRule{rule}, NewContextFromCart(g, WithNow(timeZero()))) svc.ApplyResults(g, results, NewContextFromCart(g, WithNow(timeZero()))) if got := g.TotalDiscount.IncVat; got != 3000 { t.Errorf("discount = %d, want 3000", got) } if got := g.TotalPrice.IncVat; got != 4000 { t.Errorf("total price = %d, want 4000", got) } }