import { describe, test, expect } from 'vitest'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { cartLinesDiscountsGenerateRun as run } from '../function-reference.js'; const P = (n) => `gid://shopify/Product/${n}`; const V = (n) => `gid://shopify/ProductVariant/${n}`; const C = (n) => `gid://shopify/Collection/${n}`; function line(id, { v, p, price, qty = 1, cols = [], tag = false, gc = false, sub = false }) { return { id: `gid://shopify/CartLine/${id}`, quantity: qty, cost: { amountPerQuantity: { amount: String(price) } }, sellingPlanAllocation: sub ? { sellingPlan: { id: 'gid://shopify/SellingPlan/1' } } : null, merchandise: { __typename: 'ProductVariant', id: V(v), product: { id: P(p), isGiftCard: gc, hasAnyTag: tag, inCollections: cols.map((c) => ({ collectionId: C(c), isMember: true })) }, }, }; } function input(lines, config, extra = {}) { return { cart: { lines }, localization: { country: { isoCode: extra.country || 'CA' } }, presentmentCurrencyRate: extra.rate ?? '1.0', shop: { localTime: { pastGuard: extra.pastGuard ?? false } }, discount: { discountClasses: ['PRODUCT'], metafield: config === null ? null : { jsonValue: { v: 1, ex: { subs: true, gc: true }, ...config } } }, }; } const amounts = (result) => Object.fromEntries( (result.operations[0]?.productDiscountsAdd.candidates || []).map((c) => [c.targets[0].cartLine.id.split('/').pop(), c.value.fixedAmount.amount])); const messages = (result) => (result.operations[0]?.productDiscountsAdd.candidates || []).map((c) => c.message); const fixed20 = { id: 'o1', n: 'Puja Kit', t: 'fixed', dt: 'percent', dv: 20, l: [{ v: '11', q: 1 }, { p: '2', q: 2 }] }; const mix3for20 = { id: 'o2', n: 'Any 3 Sweets for $20', t: 'mix', dt: 'fixed_price', dv: 20, q: 3, sc: { c: ['50'] } }; const b2g1 = { id: 'o3', n: 'Buy 2 Get 1', t: 'bxgy', bq: 2, gq: 1, gp: 100, bs: { c: ['60'] }, gs: { c: ['60'] } }; describe('bundle discount function', () => { test('no configuration -> no operations', () => { expect(run(input([line(1, { v: 11, p: 1, price: 10 })], null))).toEqual({ operations: [] }); }); test('fixed bundle: percent off spread over lines, product-level line takes any variant', () => { const r = run(input([line(1, { v: 11, p: 1, price: 10 }), line(2, { v: 21, p: 2, price: 5, qty: 2 })], { offers: [fixed20] })); expect(amounts(r)).toEqual({ 1: '2.00', 2: '2.00' }); expect(messages(r)).toEqual(['Puja Kit', 'Puja Kit']); expect(r.operations[0].productDiscountsAdd.selectionStrategy).toBe('ALL'); }); test('fixed bundle incomplete -> nothing', () => { const r = run(input([line(1, { v: 11, p: 1, price: 10 }), line(2, { v: 21, p: 2, price: 5 })], { offers: [fixed20] })); expect(r.operations).toEqual([]); }); test('mix & match uses cheapest matching items, bundle price', () => { const r = run(input([ line(1, { v: 1, p: 1, price: 12, cols: [50] }), line(2, { v: 2, p: 2, price: 8, qty: 2, cols: [50] }), line(3, { v: 3, p: 3, price: 9, cols: [50] }), ], { offers: [mix3for20] })); // cheapest 3 = 8 + 8 + 9 = 25 -> price 20 -> 5 off, spread 1.60/1.60/1.80 expect(amounts(r)).toEqual({ 2: '3.20', 3: '1.80' }); }); test('bxgy: most expensive are "buy", cheapest remaining is "get" (parity allocation over all units)', () => { const r = run(input([ line(1, { v: 1, p: 1, price: 30, cols: [60] }), line(2, { v: 2, p: 2, price: 20, cols: [60] }), line(3, { v: 3, p: 3, price: 10, cols: [60] }), ], { offers: [b2g1] })); // discount 10 spread over 60 -> 5 / 3.33 / 1.67 expect(amounts(r)).toEqual({ 1: '5.00', 2: '3.33', 3: '1.67' }); }); test('bxgy get-units-only allocation setting', () => { const r = run(input([ line(1, { v: 1, p: 1, price: 30, cols: [60] }), line(2, { v: 2, p: 2, price: 20, cols: [60] }), line(3, { v: 3, p: 3, price: 10, cols: [60] }), ], { ba: 'get', offers: [b2g1] })); expect(amounts(r)).toEqual({ 3: '10.00' }); }); test('priority + exclusivity: an item counts towards one offer only', () => { const both = { id: 'a', n: 'First', t: 'mix', dt: 'percent', dv: 10, q: 2, sc: { c: ['50'] } }; const second = { id: 'b', n: 'Second', t: 'mix', dt: 'percent', dv: 50, q: 2, sc: { c: ['50'] } }; const r = run(input([line(1, { v: 1, p: 1, price: 10, qty: 3, cols: [50] })], { offers: [both, second] })); // first offer takes 2 units (2.00), one unit left -> second can't apply expect(amounts(r)).toEqual({ 1: '2.00' }); expect(messages(r)).toEqual(['First']); }); test('two offers on one line are merged into one candidate', () => { const a = { id: 'a', n: 'A', t: 'mix', dt: 'amount', dv: 1, q: 1, mu: 1, sc: { p: ['1'] } }; const b = { id: 'b', n: 'B', t: 'mix', dt: 'amount', dv: 2, q: 1, mu: 1, sc: { p: ['1'] } }; const r = run(input([line(1, { v: 1, p: 1, price: 10, qty: 2 })], { offers: [a, b] })); expect(amounts(r)).toEqual({ 1: '3.00' }); expect(messages(r)).toEqual(['A + B']); }); test('repeats while possible, max uses caps it', () => { const lines = [line(1, { v: 1, p: 1, price: 10, qty: 7, cols: [50] })]; const unlimited = run(input(lines, { offers: [{ ...mix3for20, dt: 'amount', dv: 5 }] })); expect(amounts(unlimited)).toEqual({ 1: '10.00' }); // 2 uses const capped = run(input(lines, { offers: [{ ...mix3for20, dt: 'amount', dv: 5, mu: 1 }] })); expect(amounts(capped)).toEqual({ 1: '5.00' }); }); test('amount and fixed price are converted to the presentment currency', () => { const r = run(input([line(1, { v: 1, p: 1, price: 15, qty: 3, cols: [50] })], { offers: [mix3for20] }, { rate: '1.5' })); // regular 45, bundle price 20 * 1.5 = 30 -> 15 off expect(amounts(r)).toEqual({ 1: '15.00' }); }); test('exclusions: tag, gift card, subscription, zero price', () => { const offers = [{ id: 'm', n: 'M', t: 'mix', dt: 'percent', dv: 10, q: 1, sc: { p: ['1'] } }]; const r = run(input([ line(1, { v: 1, p: 1, price: 10, tag: true }), line(2, { v: 2, p: 1, price: 10, gc: true }), line(3, { v: 3, p: 1, price: 10, sub: true }), line(4, { v: 4, p: 1, price: 0 }), line(5, { v: 5, p: 1, price: 10 }), ], { offers })); expect(amounts(r)).toEqual({ 5: '1.00' }); }); test('country targeting', () => { const offers = [{ ...mix3for20, cc: ['US'] }]; const lines = [line(1, { v: 1, p: 1, price: 10, qty: 3, cols: [50] })]; expect(run(input(lines, { offers }, { country: 'CA' })).operations).toEqual([]); expect(amounts(run(input(lines, { offers }, { country: 'US' })))).toEqual({ 1: '10.00' }); }); test('schedule guard: once the guard time passed, offers ending by then are dropped', () => { const g = '2026-11-09T04:59:59Z'; const offers = [{ ...mix3for20, e: g }, { ...b2g1, id: 'o4' }]; const lines = [line(1, { v: 1, p: 1, price: 10, qty: 3, cols: [50] })]; expect(amounts(run(input(lines, { g, offers }, { pastGuard: false })))).toEqual({ 1: '10.00' }); expect(run(input(lines, { g, offers }, { pastGuard: true })).operations).toEqual([]); }); test('large cart: 200 lines x 5 qty with 50 offers stays fast', () => { const lines = Array.from({ length: 200 }, (_, i) => line(i + 1, { v: i + 1, p: i + 1, price: 1 + (i % 37), qty: 5, cols: [i % 10] })); const offers = Array.from({ length: 50 }, (_, i) => ({ id: `x${i}`, n: `Offer ${i}`, t: i % 2 ? 'mix' : 'bxgy', dt: 'percent', dv: 10, q: 3, sc: { c: [String(i % 10)] }, bq: 2, gq: 1, gp: 100, bs: { c: [String(i % 10)] }, gs: { c: [String(i % 10)] } })); const t0 = performance.now(); const r = run(input(lines, { offers })); expect(performance.now() - t0).toBeLessThan(500); expect(r.operations[0].productDiscountsAdd.candidates.length).toBeGreaterThan(0); }); }); test('engine parity vectors (spec/test-vectors.json)', () => { const script = fileURLToPath(new URL('./run-vectors.mjs', import.meta.url)); const out = execFileSync(process.execPath, [script], { encoding: 'utf8' }); expect(out).toContain('all 19 vectors pass'); });