Files
ita-ito-horaires/lib/holidays/openholidays.test.ts
T
vliaudatandClaude Opus 5 830e595393 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
2026-09-20 21:23:47 +02:00

130 lines
4.0 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { parseHolidays, withRetries, type ApiHoliday } from './openholidays';
const FR: ApiHoliday[] = [
{
id: 'a',
startDate: '2026-09-10',
endDate: '2026-09-10',
nationwide: false,
name: [{ language: 'FR', text: 'Jeûne genevois' }],
},
{
id: 'b',
startDate: '2026-12-25',
endDate: '2026-12-25',
nationwide: true,
name: [{ language: 'FR', text: 'Noël' }],
},
];
const EN: ApiHoliday[] = [
{ id: 'a', startDate: '2026-09-10', endDate: '2026-09-10', name: [{ language: 'EN', text: 'Geneva Fast' }] },
{ id: 'b', startDate: '2026-12-25', endDate: '2026-12-25', name: [{ language: 'EN', text: 'Christmas Day' }] },
];
describe('parseHolidays', () => {
it('joins the two language calls on the entry id', () => {
// Holiday names are proper nouns with established English forms; joining
// beats sending them through a translation model.
expect(parseHolidays(FR, EN)).toEqual([
{ date: '2026-09-10', nameFr: 'Jeûne genevois', nameEn: 'Geneva Fast', nationwide: false },
{ date: '2026-12-25', nameFr: 'Noël', nameEn: 'Christmas Day', nationwide: true },
]);
});
it('falls back to the French name when the English call comes back short', () => {
expect(parseHolidays(FR, [])[0]).toMatchObject({ nameFr: 'Jeûne genevois', nameEn: 'Jeûne genevois' });
});
it('expands an entry that spans several days', () => {
const spanning: ApiHoliday[] = [
{
id: 'c',
startDate: '2026-12-24',
endDate: '2026-12-26',
name: [{ language: 'FR', text: 'Fêtes' }],
},
];
expect(parseHolidays(spanning, []).map((holiday) => holiday.date)).toEqual([
'2026-12-24',
'2026-12-25',
'2026-12-26',
]);
});
it('treats a missing end date as a single day', () => {
const single: ApiHoliday[] = [
{ id: 'd', startDate: '2026-08-01', endDate: '', name: [{ language: 'FR', text: 'Fête nationale' }] },
];
expect(parseHolidays(single, [])).toHaveLength(1);
});
it('ignores a reversed range rather than spinning', () => {
const reversed: ApiHoliday[] = [
{ id: 'e', startDate: '2026-08-05', endDate: '2026-08-01', name: [{ language: 'FR', text: 'X' }] },
];
expect(parseHolidays(reversed, [])).toHaveLength(1);
});
it('drops an entry with no usable name', () => {
expect(parseHolidays([{ id: 'f', startDate: '2026-08-01', endDate: '2026-08-01' }], [])).toEqual([]);
});
it('defaults an unstated scope to nationwide', () => {
const vague: ApiHoliday[] = [
{ id: 'g', startDate: '2026-08-01', endDate: '2026-08-01', name: [{ language: 'FR', text: 'X' }] },
];
expect(parseHolidays(vague, [])[0]?.nationwide).toBe(true);
});
it('accepts an empty calendar', () => {
expect(parseHolidays([], [])).toEqual([]);
});
});
describe('withRetries', () => {
it('returns the first success without waiting', async () => {
const sleep = vi.fn(async (_ms: number) => undefined);
expect(await withRetries(async () => 'ok', 3, 100, sleep)).toBe('ok');
expect(sleep).not.toHaveBeenCalled();
});
it('retries and widens the gap', async () => {
const sleep = vi.fn(async (_ms: number) => undefined);
let attempts = 0;
const result = await withRetries(
async () => {
attempts += 1;
if (attempts < 3) {
throw new Error('transient');
}
return 'ok';
},
3,
100,
sleep,
);
expect(result).toBe('ok');
expect(sleep.mock.calls.map((call) => call[0])).toEqual([100, 200]);
});
it('gives up after the last attempt and reports the final error', async () => {
const sleep = vi.fn(async (_ms: number) => undefined);
await expect(
withRetries(
async () => {
throw new Error('toujours en panne');
},
3,
10,
sleep,
),
).rejects.toThrow('toujours en panne');
expect(sleep).toHaveBeenCalledTimes(2);
});
});