Rust discount function (one automatic discount evaluating all offers in priority order, parity with the Odoo engine), React Router admin with Polaris, theme app extension and app proxy pages, webhooks incl. GDPR, managed-pricing limits, and a Docker Compose stack (Postgres + Caddy HTTPS) for a single VPS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
163 lines
8.0 KiB
JavaScript
163 lines
8.0 KiB
JavaScript
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');
|
|
});
|