feat: sync Geneva public holidays and let the shop choose which close it
The rolling twelve-month window is fetched from openholidaysapi.org each night at 03:00 local, and can be triggered from the page, from POST /api/admin/holidays/sync, or from `npm run holidays:sync` for the first run after a deployment. The calendar is fetched twice, once per language, and the two answers joined on the entry id. Holiday names are proper nouns with established English forms — "Jeûne genevois" is not something a translation model should be improvising, and this costs one extra HTTP call. Two properties are load-bearing and tested against a real database. The sync is idempotent: running it twice leaves exactly what running it once did, verified live as well as against a mock. And it never touches `isAutoClosed` on an existing row — that is the shop's decision, not the API's, and a nightly job quietly reopening a day the owner had closed would be invisible until someone found the door locked. When the API is down the local cache is left untouched and the failure is recorded with its timestamp, so the page can say how stale the calendar is rather than showing nothing. Retries widen the gap between attempts; the nightly job can afford to wait, the shop cannot afford a stale calendar for a day. node-cron runs inside the application process rather than an external cron hitting an endpoint: one container, one shop, no second instance to coordinate with, and no trigger endpoint to protect and document. The reasoning is recorded next to the schedule. Verified against the live API: nine Geneva holidays, both languages, including the cantonal Restauration de la République. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import type { ParsedHoliday } from '@/lib/holidays/openholidays';
|
||||
import { SYNC_KEY, syncHolidays } from '@/lib/holidays/sync';
|
||||
|
||||
import { hasDatabase, resetDatabase } from './helpers';
|
||||
|
||||
const NOW = new Date('2026-09-22T03:00:00Z');
|
||||
|
||||
const CALENDAR: ParsedHoliday[] = [
|
||||
{ date: '2026-09-25', nameFr: 'Jeûne genevois', nameEn: 'Geneva Fast', nationwide: false },
|
||||
{ date: '2026-12-25', nameFr: 'Noël', nameEn: 'Christmas Day', nationwide: true },
|
||||
];
|
||||
|
||||
function fetcher(holidays: ParsedHoliday[]) {
|
||||
return async () => holidays;
|
||||
}
|
||||
|
||||
describe.skipIf(!hasDatabase)('syncHolidays', () => {
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
});
|
||||
|
||||
it('fills an empty cache and records the success', async () => {
|
||||
const result = await syncHolidays({ now: NOW, fetcher: fetcher(CALENDAR) });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, created: 2, updated: 0, removed: 0 });
|
||||
expect(await prisma.publicHoliday.count()).toBe(2);
|
||||
|
||||
const state = await prisma.syncState.findUnique({ where: { key: SYNC_KEY } });
|
||||
expect(state?.lastSuccessAt).toEqual(NOW);
|
||||
expect(state?.lastError).toBeNull();
|
||||
});
|
||||
|
||||
it('is idempotent: twice leaves what once did', async () => {
|
||||
await syncHolidays({ now: NOW, fetcher: fetcher(CALENDAR) });
|
||||
const first = await prisma.publicHoliday.findMany({ orderBy: { date: 'asc' } });
|
||||
|
||||
const second = await syncHolidays({ now: NOW, fetcher: fetcher(CALENDAR) });
|
||||
expect(second).toMatchObject({ ok: true, created: 0, updated: 0, removed: 0 });
|
||||
|
||||
const after = await prisma.publicHoliday.findMany({ orderBy: { date: 'asc' } });
|
||||
expect(after.map((row) => row.id)).toEqual(first.map((row) => row.id));
|
||||
expect(after.map((row) => row.nameFr)).toEqual(first.map((row) => row.nameFr));
|
||||
});
|
||||
|
||||
it('never reopens a day the shop chose to close', async () => {
|
||||
await syncHolidays({ now: NOW, fetcher: fetcher(CALENDAR) });
|
||||
|
||||
// The owner decides to trade through Christmas.
|
||||
await prisma.publicHoliday.updateMany({
|
||||
where: { nameFr: 'Noël' },
|
||||
data: { isAutoClosed: false },
|
||||
});
|
||||
|
||||
await syncHolidays({ now: NOW, fetcher: fetcher(CALENDAR) });
|
||||
|
||||
// A nightly job quietly undoing that would be invisible until someone
|
||||
// found the door locked.
|
||||
const christmas = await prisma.publicHoliday.findFirstOrThrow({ where: { nameFr: 'Noël' } });
|
||||
expect(christmas.isAutoClosed).toBe(false);
|
||||
});
|
||||
|
||||
it('corrects a name the API has changed', async () => {
|
||||
await syncHolidays({ now: NOW, fetcher: fetcher(CALENDAR) });
|
||||
|
||||
const corrected = CALENDAR.map((holiday) =>
|
||||
holiday.nameFr === 'Noël' ? { ...holiday, nameEn: 'Christmas' } : holiday,
|
||||
);
|
||||
const result = await syncHolidays({ now: NOW, fetcher: fetcher(corrected) });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, updated: 1 });
|
||||
const christmas = await prisma.publicHoliday.findFirstOrThrow({ where: { nameFr: 'Noël' } });
|
||||
expect(christmas.nameEn).toBe('Christmas');
|
||||
});
|
||||
|
||||
it('removes a holiday the API no longer lists', async () => {
|
||||
await syncHolidays({ now: NOW, fetcher: fetcher(CALENDAR) });
|
||||
|
||||
const result = await syncHolidays({ now: NOW, fetcher: fetcher([CALENDAR[0]!]) });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, removed: 1 });
|
||||
expect(await prisma.publicHoliday.count()).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the cache and records the failure when the API is down', async () => {
|
||||
await syncHolidays({ now: NOW, fetcher: fetcher(CALENDAR) });
|
||||
|
||||
const later = new Date('2026-09-23T03:00:00Z');
|
||||
const result = await syncHolidays({
|
||||
now: later,
|
||||
fetcher: async () => {
|
||||
throw new Error('503 Service Unavailable');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: false });
|
||||
// A stale calendar beats no calendar.
|
||||
expect(await prisma.publicHoliday.count()).toBe(2);
|
||||
|
||||
const state = await prisma.syncState.findUnique({ where: { key: SYNC_KEY } });
|
||||
expect(state?.lastErrorAt).toEqual(later);
|
||||
expect(state?.lastError).toContain('503');
|
||||
// The last success is still on record, so the UI can say how stale it is.
|
||||
expect(state?.lastSuccessAt).toEqual(NOW);
|
||||
});
|
||||
|
||||
it('clears the error once the API comes back', async () => {
|
||||
await syncHolidays({
|
||||
now: NOW,
|
||||
fetcher: async () => {
|
||||
throw new Error('panne');
|
||||
},
|
||||
});
|
||||
|
||||
const later = new Date('2026-09-23T03:00:00Z');
|
||||
await syncHolidays({ now: later, fetcher: fetcher(CALENDAR) });
|
||||
|
||||
const state = await prisma.syncState.findUnique({ where: { key: SYNC_KEY } });
|
||||
expect(state?.lastError).toBeNull();
|
||||
expect(state?.lastErrorAt).toBeNull();
|
||||
expect(state?.lastSuccessAt).toEqual(later);
|
||||
});
|
||||
|
||||
it('accepts an empty calendar without emptying the window twice', async () => {
|
||||
const result = await syncHolidays({ now: NOW, fetcher: fetcher([]) });
|
||||
expect(result).toMatchObject({ ok: true, created: 0, removed: 0 });
|
||||
expect(await prisma.publicHoliday.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user