// The compiler output must match spec/offer-config.schema.json, respect plan limits and running-only, and // produce the expected discounts when fed to the function (JS reference of the Rust function). import { test, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import Ajv from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; import { compile, toShopLocal, NO_GUARD } from '../../app/lib/compile.ts'; import { cartLinesDiscountsGenerateRun as run } from '../function-reference.js'; const schema = JSON.parse(readFileSync(new URL('../../spec/offer-config.schema.json', import.meta.url), 'utf8')); const ajv = new Ajv({ strict: false }); addFormats(ajv); const validate = ajv.compile(schema); const now = new Date('2026-10-25T12:00:00Z'); let n = 0; function offer(over) { n += 1; return { id: `id${n}`, code: `o${n}`, handle: `offer-${n}`, name: `Offer ${n}`, badge: null, sequence: 10, active: true, published: true, countries: [], type: 'mix', discountType: 'percent', discountValue: 10, mixQty: 2, buyQty: 2, getQty: 1, getPercent: 100, startsAt: null, endsAt: null, maxPerOrder: 0, showHome: true, showProduct: true, showCartHints: true, imageUrl: null, createdAt: new Date(2026, 0, n), lines: [], scopes: [{ role: 'mix', kind: 'collection', refId: '50' }], ...over, }; } const shop = (planCode = 'growth', extra = {}) => ({ planCode, currency: 'CAD', timezone: 'America/Toronto', settings: {}, monthOrders: 0, ...extra }); test('config is valid against the schema and lists running offers in priority order', () => { const offers = [ offer({ sequence: 20, name: 'Later' }), offer({ sequence: 5, type: 'fixed', discountType: 'fixed_price', discountValue: 25, scopes: [], lines: [{ productId: '1', variantId: '11', quantity: 1 }, { productId: '2', variantId: null, quantity: 2 }] }), offer({ type: 'bxgy', scopes: [{ role: 'buy', kind: 'product', refId: '7' }, { role: 'get', kind: 'variant', refId: '71', parentId: '7' }] }), offer({ published: false }), offer({ startsAt: new Date('2026-11-01T00:00:00Z') }), offer({ endsAt: new Date('2026-10-01T00:00:00Z') }), ]; const r = compile(shop(), offers, now); expect(r.error).toBeNull(); expect(validate(r.config), JSON.stringify(validate.errors)).toBe(true); expect(r.config.offers.map((o) => o.n)).toEqual([offers[1].name, offers[2].name, 'Later']); expect(r.config.offers[0].l).toEqual([{ v: '11', q: 1 }, { p: '2', q: 2 }]); expect(r.nextSyncAt).toEqual(new Date('2026-11-01T00:00:00Z')); expect(r.vars).toEqual({ collectionIds: ['gid://shopify/Collection/50'], excludeTags: ['no-bundle'], guardAt: NO_GUARD }); }); test('tie on sequence: newer start first, then newer offer first (Odoo order)', () => { const a = offer({ name: 'A', startsAt: new Date('2026-10-01T00:00:00Z') }); const b = offer({ name: 'B', startsAt: new Date('2026-10-10T00:00:00Z') }); const c = offer({ name: 'C' }); const d = offer({ name: 'D' }); expect(compile(shop(), [a, b, c, d], now).config.offers.map((o) => o.n)).toEqual(['D', 'C', 'B', 'A']); }); test('plan limits are enforced when compiling', () => { const offers = [offer({ type: 'fixed', scopes: [], lines: [{ productId: '1', variantId: null, quantity: 1 }] }), offer({}), offer({})]; const free = compile(shop('free'), offers, now); expect(free.config.offers.length).toBe(1); expect(free.config.offers[0].t).toBe('fixed'); expect(Object.keys(free.excluded).length).toBe(2); expect(compile(shop('starter'), offers, now).config.offers.length).toBe(3); const exceeded = compile(shop('free', { monthOrders: 50 }), offers, now); expect(exceeded.config.offers).toEqual([]); }); test('guard: earliest end, passed to the function in shop local time', () => { const end = new Date('2026-11-09T04:59:59Z'); const r = compile(shop(), [offer({ endsAt: end }), offer({ endsAt: new Date('2026-12-01T00:00:00Z') })], now); expect(r.config.g).toBe('2026-11-09T04:59:59Z'); expect(r.vars.guardAt).toBe('2026-11-08T23:59:59'); // Toronto, UTC-5 in November expect(toShopLocal(new Date('2026-07-01T12:00:00Z'), 'Asia/Kolkata')).toBe('2026-07-01T17:30:00'); }); test('config size is limited', () => { const many = Array.from({ length: 200 }, () => offer({ name: 'A fairly long festival offer name for the size check' })); const r = compile(shop(), many, now); expect(r.configBytes).toBeGreaterThan(10_000); expect(r.error).toMatch(/Too many running offers/); }); test('compiled config drives the function end to end', () => { const r = compile(shop(), [offer({ discountType: 'amount', discountValue: 5, mixQty: 2 })], now); const input = { cart: { lines: [1, 2].map((i) => ({ id: `gid://shopify/CartLine/${i}`, quantity: 1, cost: { amountPerQuantity: { amount: '10.0' } }, sellingPlanAllocation: null, merchandise: { __typename: 'ProductVariant', id: `gid://shopify/ProductVariant/${i}`, product: { id: `gid://shopify/Product/${i}`, isGiftCard: false, hasAnyTag: false, inCollections: [{ collectionId: 'gid://shopify/Collection/50', isMember: true }] } } })) }, localization: { country: { isoCode: 'CA' } }, presentmentCurrencyRate: '1.0', shop: { localTime: { pastGuard: false } }, discount: { discountClasses: ['PRODUCT'], metafield: { jsonValue: r.config } }, }; const out = run(input).operations[0].productDiscountsAdd.candidates.map((c) => c.value.fixedAmount.amount); expect(out).toEqual(['2.50', '2.50']); });