bundle-offers-app/engine/tests/compile.test.js
metatroncubeswdev 365e45406a Bundle Offers Shopify app: discount function, admin, storefront, deploy stack
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>
2026-10-13 21:35:58 -04:00

93 lines
5.3 KiB
JavaScript

// 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']);
});