Files
hope-events/backend/tests/pricing.test.js
T
joshuaandClaude Sonnet 5 54b89d4f4b Add calendar export, SEO, error monitoring, backups, audit trail, and a starter test suite
Six site improvements picked from a "what could be better" review, plus a Jest
test suite covering the two areas with the trickiest money-handling history
in this project (early-bird pricing tranches, donation-leg accounting):

- "Add to calendar" .ics download on event pages and in confirmation emails
- sitemap.xml, robots.txt, and Open Graph/Twitter metadata for public pages
- Sentry error monitoring (backend + frontend), a no-op until SENTRY_DSN is set
- Nightly local pg_dump backups with a Site Settings tab to browse/trigger/download
- Admin audit trail for refunds, donations, manual registrations, event and
  settings changes, and staff-initiated cancellations
- Jest tests reproducing and guarding against the 1.8.0 tranche-pricing bug
  and the 1.4.2 donation-balance-inflation bug

Wallet passes (Google/Apple) were scoped out of this round — Apple Wallet
needs a paid Apple Developer account the project doesn't have yet, and the
user preferred shipping both together later rather than Google alone now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:50:11 +02:00

147 lines
5.0 KiB
JavaScript

const {
getEffectiveUnitPrice,
computeOptionLineTotal,
computeRegistrationTotalDue,
attachComputedTotals,
} = require('../src/utils/pricing');
const DAY = 24 * 60 * 60 * 1000;
const NOW = new Date('2026-06-01T00:00:00Z');
const PAST = new Date(NOW.getTime() - DAY);
const FUTURE = new Date(NOW.getTime() + DAY);
describe('getEffectiveUnitPrice', () => {
test('returns base price when there are no early-bird tiers', () => {
const option = { price: 100, earlyBirdTiers: [] };
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(100);
});
test('returns tier price when the tier deadline is still in the future', () => {
const option = {
price: 100,
earlyBirdTiers: [{ id: 't1', price: 50, deadline: FUTURE }],
};
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(50);
});
test('falls back to base price once the tier deadline has passed', () => {
const option = {
price: 100,
earlyBirdTiers: [{ id: 't1', price: 50, deadline: PAST }],
};
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(100);
});
test('requires the deadline to be after both referenceTime and atTime', () => {
const midDeadline = new Date(NOW.getTime() - DAY / 2);
const option = {
price: 100,
earlyBirdTiers: [{ id: 't1', price: 50, deadline: midDeadline }],
};
// referenceTime (PAST) is before the deadline, but atTime (NOW) is after it — tier no longer applies
expect(getEffectiveUnitPrice(option, PAST, NOW)).toBe(100);
});
test('with multiple applicable tiers, picks the one with the earliest deadline', () => {
const soonerDeadline = new Date(NOW.getTime() + DAY);
const laterDeadline = new Date(NOW.getTime() + 2 * DAY);
const option = {
price: 100,
earlyBirdTiers: [
{ id: 'later', price: 80, deadline: laterDeadline },
{ id: 'sooner', price: 60, deadline: soonerDeadline },
],
};
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(60);
});
test('returns 0 for a missing eventOption', () => {
expect(getEffectiveUnitPrice(null, null, NOW)).toBe(0);
});
});
describe('computeOptionLineTotal', () => {
test('the 1.8.0 regression: buying more after an early-bird tier expires only re-prices the new quantity', () => {
// 5 tickets bought at R50 (early-bird), then 1 more bought after the price rose to R100.
// Must total 5*50 + 1*100 = 350, not 6*100 = 600.
const ro = {
quantity: 6,
priceSnapshot: 100,
tranches: [
{ quantity: 5, priceSnapshot: 50, createdAt: PAST },
{ quantity: 1, priceSnapshot: 100, createdAt: NOW },
],
};
expect(computeOptionLineTotal(ro, null, NOW)).toBe(350);
});
test('sums a single tranche correctly', () => {
const ro = { quantity: 3, priceSnapshot: 40, tranches: [{ quantity: 3, priceSnapshot: 40 }] };
expect(computeOptionLineTotal(ro, null, NOW)).toBe(120);
});
test('legacy fallback: no tranches, uses priceSnapshot directly', () => {
const ro = { quantity: 4, priceSnapshot: 25, tranches: [] };
expect(computeOptionLineTotal(ro, null, NOW)).toBe(100);
});
test('legacy fallback: no tranches and no priceSnapshot, re-evaluates from tier deadlines', () => {
const ro = {
quantity: 2,
priceSnapshot: null,
tranches: [],
eventOption: { price: 100, earlyBirdTiers: [{ id: 't1', price: 70, deadline: FUTURE }] },
};
expect(computeOptionLineTotal(ro, null, NOW)).toBe(140);
});
});
describe('computeRegistrationTotalDue', () => {
test('sums tranche-aware totals across multiple RegistrationOptions', () => {
const registration = {
registrationOptions: [
{
quantity: 6,
priceSnapshot: 100,
tranches: [
{ quantity: 5, priceSnapshot: 50, createdAt: PAST },
{ quantity: 1, priceSnapshot: 100, createdAt: NOW },
],
},
{
quantity: 2,
priceSnapshot: 20,
tranches: [{ quantity: 2, priceSnapshot: 20, createdAt: PAST }],
},
],
payments: [],
};
// 350 (first option, see 1.8.0 regression case) + 40 (second option)
expect(computeRegistrationTotalDue(registration, NOW)).toBe(390);
});
test('returns 0 for a registration with no options', () => {
expect(computeRegistrationTotalDue({ registrationOptions: [] }, NOW)).toBe(0);
expect(computeRegistrationTotalDue(null, NOW)).toBe(0);
});
});
describe('attachComputedTotals', () => {
test('mutates the registration with per-line and total computed amounts', () => {
const registration = {
registrationOptions: [
{ quantity: 2, priceSnapshot: 50, tranches: [{ quantity: 2, priceSnapshot: 50 }] },
],
payments: [],
};
const result = attachComputedTotals(registration);
expect(result).toBe(registration); // mutated in place
expect(result.registrationOptions[0].lineTotal).toBe(100);
expect(result.totalDueComputed).toBe(100);
});
test('handles a null registration gracefully', () => {
expect(attachComputedTotals(null)).toBeNull();
});
});