Events gain nullable presale_closure_enabled / presale_close_minutes_before overrides; null inherits the new site_settings defaults (enabled, 120 min). A shared resolver computes the effective cutoff, which the public event API exposes as presaleClosesAt and the public booking endpoint enforces. Door and admin ticket creation are not gated. Admin: toggle + duration picker in the event modal (pre-filled from the site default) and a matching default card on Settings › General. Public: the event page shows "Registration Closed" after the cutoff and the checkout page redirects back. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
76 lines
3.1 KiB
TypeScript
76 lines
3.1 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
resolvePresaleClosure,
|
|
isPresaleClosed,
|
|
DEFAULT_PRESALE_CLOSE_MINUTES,
|
|
} from './presale.js';
|
|
|
|
const START = '2030-01-01T20:00:00.000Z';
|
|
const startMs = new Date(START).getTime();
|
|
const minutes = (n: number) => n * 60_000;
|
|
|
|
describe('resolvePresaleClosure', () => {
|
|
it('falls back to the built-in defaults when neither event nor settings specify anything', () => {
|
|
const r = resolvePresaleClosure({ startDatetime: START }, null);
|
|
expect(r.enabled).toBe(true);
|
|
expect(r.minutesBefore).toBe(DEFAULT_PRESALE_CLOSE_MINUTES);
|
|
expect(r.closesAt?.getTime()).toBe(startMs - minutes(DEFAULT_PRESALE_CLOSE_MINUTES));
|
|
});
|
|
|
|
it('inherits from site settings when the event has null overrides', () => {
|
|
const r = resolvePresaleClosure(
|
|
{ startDatetime: START, presaleClosureEnabled: null, presaleCloseMinutesBefore: null },
|
|
{ presaleClosureEnabled: true, presaleCloseMinutesBefore: 30 }
|
|
);
|
|
expect(r.minutesBefore).toBe(30);
|
|
expect(r.closesAt?.getTime()).toBe(startMs - minutes(30));
|
|
});
|
|
|
|
it('lets the event override the site settings', () => {
|
|
const r = resolvePresaleClosure(
|
|
{ startDatetime: START, presaleClosureEnabled: true, presaleCloseMinutesBefore: 1440 },
|
|
{ presaleClosureEnabled: false, presaleCloseMinutesBefore: 30 }
|
|
);
|
|
expect(r.enabled).toBe(true);
|
|
expect(r.closesAt?.getTime()).toBe(startMs - minutes(1440));
|
|
});
|
|
|
|
it('returns closesAt null when closure is disabled (site-wide or per event)', () => {
|
|
expect(resolvePresaleClosure({ startDatetime: START }, { presaleClosureEnabled: false }).closesAt).toBeNull();
|
|
expect(
|
|
resolvePresaleClosure({ startDatetime: START, presaleClosureEnabled: false }, { presaleClosureEnabled: true }).closesAt
|
|
).toBeNull();
|
|
});
|
|
|
|
it('accepts PostgreSQL 0/1 integers and string minutes', () => {
|
|
const r = resolvePresaleClosure(
|
|
{ startDatetime: new Date(START), presaleClosureEnabled: 1, presaleCloseMinutesBefore: '45' },
|
|
{ presaleClosureEnabled: 0, presaleCloseMinutesBefore: 10 }
|
|
);
|
|
expect(r.enabled).toBe(true);
|
|
expect(r.minutesBefore).toBe(45);
|
|
const off = resolvePresaleClosure({ startDatetime: START, presaleClosureEnabled: 0 }, { presaleClosureEnabled: 1 });
|
|
expect(off.enabled).toBe(false);
|
|
});
|
|
|
|
it('closes at the event start when minutes is 0', () => {
|
|
const r = resolvePresaleClosure({ startDatetime: START, presaleCloseMinutesBefore: 0 }, null);
|
|
expect(r.closesAt?.getTime()).toBe(startMs);
|
|
});
|
|
});
|
|
|
|
describe('isPresaleClosed', () => {
|
|
const event = { startDatetime: START, presaleClosureEnabled: true, presaleCloseMinutesBefore: 60 };
|
|
|
|
it('is open before the cutoff and closed from the cutoff onwards', () => {
|
|
const cutoff = startMs - minutes(60);
|
|
expect(isPresaleClosed(event, null, cutoff - 1)).toBe(false);
|
|
expect(isPresaleClosed(event, null, cutoff)).toBe(true);
|
|
expect(isPresaleClosed(event, null, startMs + minutes(5))).toBe(true);
|
|
});
|
|
|
|
it('never closes when closure is disabled', () => {
|
|
expect(isPresaleClosed({ ...event, presaleClosureEnabled: false }, null, startMs + minutes(60))).toBe(false);
|
|
});
|
|
});
|