diff --git a/app/admin/feries/HolidayManager.tsx b/app/admin/feries/HolidayManager.tsx new file mode 100644 index 0000000..bc94b9a --- /dev/null +++ b/app/admin/feries/HolidayManager.tsx @@ -0,0 +1,134 @@ +'use client'; + +import { useState, useTransition } from 'react'; + +import { runHolidaySync, setHolidayClosure } from './actions'; + +export type HolidayRow = { + id: string; + date: string; + label: string; + nameFr: string; + nameEn: string; + nationwide: boolean; + isAutoClosed: boolean; +}; + +export default function HolidayManager({ + canEdit, + holidays, + lastSuccessAt, + lastError, + subdivisionCode, +}: { + canEdit: boolean; + holidays: HolidayRow[]; + lastSuccessAt: string | null; + lastError: string | null; + subdivisionCode: string; +}) { + const [pending, startTransition] = useTransition(); + const [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null); + const [closures, setClosures] = useState>( + Object.fromEntries(holidays.map((holiday) => [holiday.id, holiday.isAutoClosed])), + ); + + function toggle(id: string, next: boolean) { + setClosures((current) => ({ ...current, [id]: next })); + setMessage(null); + startTransition(async () => { + const result = await setHolidayClosure(id, next); + if (!result.ok) { + // Put the switch back: the database is the truth, not the checkbox. + setClosures((current) => ({ ...current, [id]: !next })); + setMessage({ kind: 'error', text: result.error }); + } + }); + } + + function sync() { + setMessage(null); + startTransition(async () => { + const result = await runHolidaySync(); + setMessage( + result.ok + ? { kind: 'ok', text: `Calendrier à jour. ${result.value.summary}` } + : { kind: 'error', text: result.error }, + ); + }); + } + + return ( +
+ {message ? ( +

+ {message.text} +

+ ) : null} + +
+ + Canton {subdivisionCode} + + + {lastSuccessAt + ? `Dernière synchronisation réussie : ${lastSuccessAt}` + : 'Jamais synchronisé.'} + + {lastError ? ( + Dernière tentative en échec : {lastError} + ) : null} + + {canEdit ? ( + + ) : null} +
+ + {holidays.length === 0 ? ( +

+ Aucun jour férié en cache. Lancez une synchronisation. +

+ ) : ( + + )} +
+ ); +} diff --git a/app/admin/feries/actions.ts b/app/admin/feries/actions.ts new file mode 100644 index 0000000..0044fb4 --- /dev/null +++ b/app/admin/feries/actions.ts @@ -0,0 +1,73 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; + +import { diffOf, recordAudit } from '@/lib/audit'; +import { requireAdmin } from '@/lib/auth/actions'; +import { prisma } from '@/lib/db'; +import { syncHolidays } from '@/lib/holidays/sync'; + +export type ActionResult = { ok: true; value: T } | { ok: false; error: string }; + +/** Whether the shop closes for a given public holiday. Some are trading days. */ +export async function setHolidayClosure(id: string, isAutoClosed: boolean): Promise { + const gate = await requireAdmin(); + if (!gate.ok) { + return { ok: false, error: gate.error }; + } + + const existing = await prisma.publicHoliday.findUnique({ where: { id } }); + if (!existing) { + return { ok: false, error: 'Ce jour férié n’est plus dans le calendrier.' }; + } + + await prisma.publicHoliday.update({ where: { id }, data: { isAutoClosed } }); + + await recordAudit({ + userEmail: gate.value.email, + action: 'holiday.set_closure', + entity: 'PublicHoliday', + entityId: id, + diff: diffOf( + { férié: existing.nameFr, fermé: existing.isAutoClosed }, + { férié: existing.nameFr, fermé: isAutoClosed }, + ), + }); + + revalidatePath('/admin'); + revalidatePath('/admin/feries'); + return { ok: true, value: undefined }; +} + +export async function runHolidaySync(): Promise> { + const gate = await requireAdmin(); + if (!gate.ok) { + return { ok: false, error: gate.error }; + } + + const result = await syncHolidays(); + if (!result.ok) { + return { ok: false, error: `Synchronisation impossible : ${result.error}` }; + } + + await recordAudit({ + userEmail: gate.value.email, + action: 'holiday.sync', + entity: 'PublicHoliday', + diff: { + ajoutés: { before: null, after: result.created }, + corrigés: { before: null, after: result.updated }, + retirés: { before: null, after: result.removed }, + }, + }); + + revalidatePath('/admin'); + revalidatePath('/admin/feries'); + + return { + ok: true, + value: { + summary: `${result.created} ajouté(s), ${result.updated} corrigé(s), ${result.removed} retiré(s).`, + }, + }; +} diff --git a/app/admin/feries/page.tsx b/app/admin/feries/page.tsx new file mode 100644 index 0000000..91bafb2 --- /dev/null +++ b/app/admin/feries/page.tsx @@ -0,0 +1,57 @@ +import { auth } from '@/lib/auth'; +import { prisma } from '@/lib/db'; +import { SYNC_KEY } from '@/lib/holidays/sync'; +import { toCivilDate } from '@/lib/schedule/civil'; +import { loadSettings } from '@/lib/schedule/context'; +import { formatDateFr } from '@/lib/schedule/format'; + +import HolidayManager, { type HolidayRow } from './HolidayManager'; + +export const metadata = { title: 'Jours fériés — ITA ITO' }; + +export default async function HolidaysPage() { + const [session, settings] = await Promise.all([auth(), loadSettings()]); + const today = toCivilDate(new Date(), settings.timezone); + + const [rows, state] = await Promise.all([ + prisma.publicHoliday.findMany({ + where: { + subdivisionCode: settings.subdivisionCode, + date: { gte: new Date(`${today}T00:00:00.000Z`) }, + }, + orderBy: { date: 'asc' }, + }), + prisma.syncState.findUnique({ where: { key: SYNC_KEY } }), + ]); + + const holidays: HolidayRow[] = rows.map((row) => { + const date = row.date.toISOString().slice(0, 10); + return { + id: row.id, + date, + label: formatDateFr(date), + nameFr: row.nameFr, + nameEn: row.nameEn, + nationwide: row.nationwide, + isAutoClosed: row.isAutoClosed, + }; + }); + + return ( +
+

Jours fériés

+

+ Calendrier glissant sur douze mois, synchronisé chaque nuit depuis openholidaysapi.org. Tous + les fériés ne ferment pas la boutique : la case décide, pas l’API. +

+ + +
+ ); +} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index b33efd6..6910ef6 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -10,6 +10,7 @@ const NAV = [ { href: '/admin/exceptions', label: 'Exceptions' }, { href: '/admin/vacances', label: 'Vacances' }, { href: '/admin/messages', label: 'Messages' }, + { href: '/admin/feries', label: 'Fériés' }, ]; /** diff --git a/app/api/admin/holidays/sync/route.ts b/app/api/admin/holidays/sync/route.ts new file mode 100644 index 0000000..9bec261 --- /dev/null +++ b/app/api/admin/holidays/sync/route.ts @@ -0,0 +1,20 @@ +import { guard } from '@/lib/auth/guard'; +import { syncHolidays } from '@/lib/holidays/sync'; + +export const dynamic = 'force-dynamic'; + +/** + * Manual trigger for the holiday sync, as the spec asks for. + * + * Behind /api/admin, so the middleware requires a session and refuses a write + * from a read-only account; the guard repeats both checks inside the handler. + */ +export async function POST(request: Request) { + const gate = await guard(request); + if ('response' in gate) { + return gate.response; + } + + const result = await syncHolidays(); + return Response.json(result, { status: result.ok ? 200 : 502 }); +} diff --git a/instrumentation.ts b/instrumentation.ts new file mode 100644 index 0000000..ab5e2f2 --- /dev/null +++ b/instrumentation.ts @@ -0,0 +1,71 @@ +/** + * Background jobs. + * + * node-cron inside the application process, rather than an external cron + * hitting an HTTP endpoint. The choice is deliberate and worth recording: this + * runs as a single container for a single shop, so there is no second instance + * to coordinate with, and an endpoint that triggers work is an endpoint that + * has to be protected, rate-limited and documented. The trade-off is that the + * jobs die with the process — acceptable for work that is idempotent and runs + * nightly, and the UI shows the last successful sync either way. + */ + +export async function register(): Promise { + // Runs in the edge runtime and during the build too; neither wants a cron. + if (process.env.NEXT_RUNTIME !== 'nodejs') { + return; + } + + const [{ default: cron }, { syncHolidays }, { prisma }, { loadSettings }] = await Promise.all([ + import('node-cron'), + import('@/lib/holidays/sync'), + import('@/lib/db'), + import('@/lib/schedule/context'), + ]); + + const settings = await loadSettings().catch(() => null); + const timezone = settings?.timezone ?? 'Europe/Zurich'; + + // 03:00 local: the rolling twelve-month holiday window. + cron.schedule( + '0 3 * * *', + () => { + void syncHolidays() + .then((result) => { + if (result.ok) { + console.info( + `[cron] fériés synchronisés : ${result.created} ajoutés, ${result.updated} corrigés, ${result.removed} retirés`, + ); + } else { + // Not fatal: the local cache stands and the UI shows how stale it is. + console.warn(`[cron] synchronisation des fériés en échec : ${result.error}`); + } + }) + .catch((error: unknown) => { + console.error('[cron] synchronisation des fériés interrompue', error); + }); + }, + { timezone }, + ); + + // 03:15 local: firmware logs are diagnostics, not data. + cron.schedule( + '15 3 * * *', + () => { + const cutoff = new Date(Date.now() - 30 * 86_400_000); + void prisma.deviceLog + .deleteMany({ where: { createdAt: { lt: cutoff } } }) + .then(({ count }) => { + if (count > 0) { + console.info(`[cron] ${count} entrées de journal appareil purgées`); + } + }) + .catch((error: unknown) => { + console.error('[cron] purge des journaux interrompue', error); + }); + }, + { timezone }, + ); + + console.info(`[cron] tâches planifiées (fuseau ${timezone})`); +} diff --git a/lib/holidays/openholidays.test.ts b/lib/holidays/openholidays.test.ts new file mode 100644 index 0000000..805f164 --- /dev/null +++ b/lib/holidays/openholidays.test.ts @@ -0,0 +1,129 @@ +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); + }); +}); diff --git a/lib/holidays/openholidays.ts b/lib/holidays/openholidays.ts new file mode 100644 index 0000000..30e99cb --- /dev/null +++ b/lib/holidays/openholidays.ts @@ -0,0 +1,124 @@ +/** + * Client for openholidaysapi.org. + * + * The API answers in one language at a time, so the calendar is fetched twice + * and the two answers are joined on the entry id. That is cheaper and far more + * reliable than sending holiday names through the translation service: these + * are proper nouns with established English forms, and "Jeûne genevois" is not + * something a model should be improvising. + */ + +import { addCivilDays, compareCivil, type CivilDate } from '@/lib/schedule/civil'; + +export type ApiHoliday = { + id: string; + startDate: string; + endDate: string; + nationwide?: boolean; + name?: { language: string; text: string }[]; +}; + +export type ParsedHoliday = { + date: CivilDate; + nameFr: string; + nameEn: string; + nationwide: boolean; +}; + +export type FetchOptions = { + baseUrl: string; + countryIsoCode: string; + subdivisionCode: string; + validFrom: CivilDate; + validTo: CivilDate; + signal?: AbortSignal; +}; + +/** A single entry can span days; each day becomes its own row. */ +export function parseHolidays(french: ApiHoliday[], english: ApiHoliday[]): ParsedHoliday[] { + const englishById = new Map(english.map((entry) => [entry.id, textOf(entry, 'EN')])); + const parsed: ParsedHoliday[] = []; + + for (const entry of french) { + const nameFr = textOf(entry, 'FR'); + if (!nameFr || !entry.startDate) { + continue; + } + // Falling back to the French name keeps the screen bilingual-ish rather + // than blank if the English call ever comes back short. + const nameEn = englishById.get(entry.id) || nameFr; + const end = entry.endDate && compareCivil(entry.endDate, entry.startDate) >= 0 ? entry.endDate : entry.startDate; + + let cursor = entry.startDate; + // Bounded: a malformed range must not spin. + for (let guard = 0; compareCivil(cursor, end) <= 0 && guard < 31; guard += 1) { + parsed.push({ date: cursor, nameFr, nameEn, nationwide: entry.nationwide ?? true }); + cursor = addCivilDays(cursor, 1); + } + } + + return parsed; +} + +export async function fetchHolidays(options: FetchOptions): Promise { + const [french, english] = await Promise.all([ + request(options, 'FR'), + request(options, 'EN'), + ]); + return parseHolidays(french, english); +} + +async function request(options: FetchOptions, language: 'FR' | 'EN'): Promise { + const url = new URL('/PublicHolidays', options.baseUrl); + url.searchParams.set('countryIsoCode', options.countryIsoCode); + url.searchParams.set('subdivisionCode', options.subdivisionCode); + url.searchParams.set('languageIsoCode', language); + url.searchParams.set('validFrom', options.validFrom); + url.searchParams.set('validTo', options.validTo); + + const response = await fetch(url, { + signal: options.signal ?? AbortSignal.timeout(15_000), + headers: { accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`openholidaysapi a répondu ${response.status}`); + } + + const body: unknown = await response.json(); + return Array.isArray(body) ? (body as ApiHoliday[]) : []; +} + +function textOf(entry: ApiHoliday, language: string): string { + const names = entry.name ?? []; + const match = names.find((name) => name.language?.toUpperCase() === language); + return (match?.text ?? names[0]?.text ?? '').trim(); +} + +/** + * Retries with a widening gap. + * + * The daily sync can afford to wait; failing on a transient blip and leaving + * the shop with a stale calendar for a day cannot be afforded. + */ +export async function withRetries( + work: () => Promise, + attempts = 3, + delayMs = 500, + sleep: (ms: number) => Promise = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), +): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + return await work(); + } catch (error) { + lastError = error; + if (attempt < attempts - 1) { + await sleep(delayMs * 2 ** attempt); + } + } + } + + throw lastError instanceof Error ? lastError : new Error(String(lastError)); +} diff --git a/lib/holidays/sync.ts b/lib/holidays/sync.ts new file mode 100644 index 0000000..c0bf9c3 --- /dev/null +++ b/lib/holidays/sync.ts @@ -0,0 +1,132 @@ +/** + * Keeps a rolling twelve-month window of public holidays in the local cache. + * + * Idempotent by construction: running it twice leaves exactly what running it + * once did. The one field it never touches on an existing row is + * `isAutoClosed` — that is the shop's decision, not the API's, and a nightly + * job quietly reopening a day the owner had closed would be the worst kind of + * bug: invisible until someone finds the door locked. + */ + +import { prisma } from '@/lib/db'; +import { addCivilDays, toCivilDate, type CivilDate } from '@/lib/schedule/civil'; +import { loadSettings } from '@/lib/schedule/context'; + +import { fetchHolidays, withRetries, type ParsedHoliday } from './openholidays'; + +export const SYNC_KEY = 'holidays'; + +export type SyncOutcome = + | { ok: true; created: number; updated: number; removed: number; from: CivilDate; to: CivilDate } + | { ok: false; error: string }; + +export type SyncDeps = { + fetcher?: (options: { + baseUrl: string; + countryIsoCode: string; + subdivisionCode: string; + validFrom: CivilDate; + validTo: CivilDate; + }) => Promise; + now?: Date; +}; + +export async function syncHolidays(deps: SyncDeps = {}): Promise { + const now = deps.now ?? new Date(); + const settings = await loadSettings(); + const today = toCivilDate(now, settings.timezone); + const from = today; + const to = addCivilDays(today, 365); + + const options = { + baseUrl: process.env.HOLIDAYS_API_URL ?? 'https://openholidaysapi.org', + countryIsoCode: settings.countryIsoCode, + subdivisionCode: settings.subdivisionCode, + validFrom: from, + validTo: to, + }; + + let holidays: ParsedHoliday[]; + try { + const fetcher = deps.fetcher ?? ((input) => fetchHolidays(input)); + holidays = await withRetries(() => fetcher(options)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await prisma.syncState.upsert({ + where: { key: SYNC_KEY }, + update: { lastErrorAt: now, lastError: message.slice(0, 500) }, + create: { key: SYNC_KEY, lastErrorAt: now, lastError: message.slice(0, 500) }, + }); + // The local cache is left exactly as it was: a stale calendar beats none. + return { ok: false, error: message }; + } + + const existing = await prisma.publicHoliday.findMany({ + where: { + subdivisionCode: settings.subdivisionCode, + date: { gte: asDate(from), lte: asDate(to) }, + }, + }); + const existingByDate = new Map(existing.map((row) => [asCivil(row.date), row])); + + let created = 0; + let updated = 0; + + for (const holiday of holidays) { + const current = existingByDate.get(holiday.date); + if (current) { + const changed = + current.nameFr !== holiday.nameFr || + current.nameEn !== holiday.nameEn || + current.nationwide !== holiday.nationwide; + if (changed) { + updated += 1; + } + await prisma.publicHoliday.update({ + where: { id: current.id }, + data: { + nameFr: holiday.nameFr, + nameEn: holiday.nameEn, + nationwide: holiday.nationwide, + syncedAt: now, + // isAutoClosed deliberately absent: the shop owns that choice. + }, + }); + } else { + created += 1; + await prisma.publicHoliday.create({ + data: { + date: asDate(holiday.date), + nameFr: holiday.nameFr, + nameEn: holiday.nameEn, + nationwide: holiday.nationwide, + subdivisionCode: settings.subdivisionCode, + syncedAt: now, + }, + }); + } + } + + // Days the API no longer lists — a cancelled or corrected holiday. + const fetchedDates = new Set(holidays.map((holiday) => holiday.date)); + const stale = existing.filter((row) => !fetchedDates.has(asCivil(row.date))); + if (stale.length > 0) { + await prisma.publicHoliday.deleteMany({ where: { id: { in: stale.map((row) => row.id) } } }); + } + + await prisma.syncState.upsert({ + where: { key: SYNC_KEY }, + update: { lastSuccessAt: now, lastErrorAt: null, lastError: null }, + create: { key: SYNC_KEY, lastSuccessAt: now }, + }); + + return { ok: true, created, updated, removed: stale.length, from, to }; +} + +function asCivil(value: Date): CivilDate { + return value.toISOString().slice(0, 10); +} + +function asDate(value: CivilDate): Date { + return new Date(`${value}T00:00:00.000Z`); +} diff --git a/package.json b/package.json index e702a42..692286f 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "seed": "tsx prisma/seed.ts", "screen:preview": "tsx scripts/preview.ts", "brand": "tsx scripts/brand.ts", - "e2e": "playwright test" + "e2e": "playwright test", + "holidays:sync": "tsx scripts/sync-holidays.ts" }, "dependencies": { "@fontsource-variable/inter": "^5.3.0", diff --git a/scripts/sync-holidays.ts b/scripts/sync-holidays.ts new file mode 100644 index 0000000..ae81f84 --- /dev/null +++ b/scripts/sync-holidays.ts @@ -0,0 +1,47 @@ +/** + * Synchronises the public-holiday cache from the command line. + * + * npm run holidays:sync + * + * The nightly job does this on its own; this exists for the first run after a + * deployment, and for checking the API is reachable from a new host without + * having to wait until three in the morning to find out. + */ + +import 'dotenv/config'; + +import { prisma } from '../lib/db'; +import { syncHolidays } from '../lib/holidays/sync'; + +async function main(): Promise { + const result = await syncHolidays(); + + if (!result.ok) { + console.error(`Échec : ${result.error}`); + console.error('Le cache local est intact.'); + process.exitCode = 1; + return; + } + + console.log( + `Fenêtre ${result.from} → ${result.to} : ` + + `${result.created} ajouté(s), ${result.updated} corrigé(s), ${result.removed} retiré(s).`, + ); + + const upcoming = await prisma.publicHoliday.findMany({ orderBy: { date: 'asc' }, take: 6 }); + for (const row of upcoming) { + console.log( + ` ${row.date.toISOString().slice(0, 10)} ${row.nameFr.padEnd(24)} ${row.nameEn.padEnd(24)} ` + + `${row.isAutoClosed ? 'fermé' : 'ouvert'}`, + ); + } +} + +main() + .catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }) + .finally(() => { + void prisma.$disconnect(); + }); diff --git a/tests/integration/holidays-sync.test.ts b/tests/integration/holidays-sync.test.ts new file mode 100644 index 0000000..343a854 --- /dev/null +++ b/tests/integration/holidays-sync.test.ts @@ -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); + }); +});