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); }); });