import { describe, expect, it } from 'vitest'; import { BackupError, backupFilename, buildBackup, EXPORT_FORMAT, EXPORT_VERSION, parseBackup, } from './backup.js'; import { atLocalTime, MINUTE } from './time.js'; import { buildResume } from './resume.js'; import sample from './fixtures/backup.json'; const at = (date, hhmm) => atLocalTime(date, hhmm); function fixture() { const days = [ { date: '2026-09-04', status: 'closed', plannedStartTs: at('2026-09-04', '07:30'), plannedEndTs: at('2026-09-04', '17:30'), startedTs: at('2026-09-04', '07:30'), endedTs: at('2026-09-04', '17:30'), lunchType: 'restaurant', restaurant: 'Le Relais', closePromptedTs: null, }, ]; const entries = [ { id: 7, date: '2026-09-04', seq: 0, kind: 'travel', task: 'Chargement / transport', client: 'Dupont SA', startTs: at('2026-09-04', '07:30'), endTs: at('2026-09-04', '08:15') }, { id: 8, date: '2026-09-04', seq: 1, kind: 'work', task: 'Terrassement', client: 'Dupont SA', startTs: at('2026-09-04', '08:15'), endTs: at('2026-09-04', '17:30') }, ]; return { days, entries, settings: { roundingMinutes: 15, theme: 'jour' } }; } const roundTrip = (data) => parseBackup(JSON.stringify(buildBackup(data))); describe('buildBackup', () => { it('stamps the format so a foreign file can be recognised', () => { const out = buildBackup(fixture()); expect(out.format).toBe(EXPORT_FORMAT); expect(out.version).toBe(EXPORT_VERSION); expect(typeof out.exportedAt).toBe('string'); }); it('strips row ids, which are local to one database', () => { expect(buildBackup(fixture()).entries.every((e) => !('id' in e))).toBe(true); }); it('names the file by date', () => { expect(backupFilename(at('2026-09-06', '12:00'))).toBe('pardis-2026-09-06.json'); }); }); describe('round trip', () => { it('preserves days, entries and settings', () => { const out = roundTrip(fixture()); expect(out.dayCount).toBe(1); expect(out.entryCount).toBe(2); expect(out.settings).toEqual({ roundingMinutes: 15, theme: 'jour' }); expect(out.days[0].restaurant).toBe('Le Relais'); expect(out.entries.map((e) => e.task)).toEqual([ 'Chargement / transport', 'Terrassement', ]); }); it('keeps the chain gapless', () => { const { entries } = roundTrip(fixture()); expect(entries.every((e, i) => i === 0 || entries[i - 1].endTs === e.startTs)).toBe(true); }); }); describe('parseBackup rejects what it should', () => { it('refuses non-JSON', () => { expect(() => parseBackup('not json at all')).toThrow(BackupError); }); it('refuses a JSON file that is not ours', () => { expect(() => parseBackup('{"hello":"world"}')).toThrow(/pas un export Pardis/); }); it('refuses a bare array', () => { expect(() => parseBackup('[]')).toThrow(BackupError); }); it('refuses a newer format version', () => { const raw = { ...buildBackup(fixture()), version: EXPORT_VERSION + 1 }; expect(() => parseBackup(JSON.stringify(raw))).toThrow(/version plus récente/); }); it('refuses a damaged non-empty file with no usable day', () => { const raw = { ...buildBackup(fixture()), entries: [] }; expect(() => parseBackup(JSON.stringify(raw))).toThrow(/Aucune journée/); }); it('reports missing tables rather than importing nothing', () => { const raw = { format: EXPORT_FORMAT, version: 1 }; expect(() => parseBackup(JSON.stringify(raw))).toThrow(/Export incomplet/); }); it.each([0, -1, 0.5, '1', null])('refuses unsupported version %s', (version) => { expect(() => parseBackup(JSON.stringify({ ...buildBackup(fixture()), version }))).toThrow(BackupError); }); it('rejects duplicate dates before any database write', () => { const data = fixture(); data.days.push({ ...data.days[0] }); expect(() => roundTrip(data)).toThrow(/Journée en double/); }); it('rejects impossible calendar dates and out-of-range timestamps', () => { const data = fixture(); data.days[0].date = data.entries[0].date = data.entries[1].date = '2026-02-30'; expect(() => roundTrip(data)).toThrow(/Aucune journée/); const huge = fixture(); huge.entries.forEach((e) => (e.startTs = 1e30)); expect(() => roundTrip(huge)).toThrow(/Aucune journée/); }); it.each([ { workdays: 'monday' }, { dayStart: '' }, { dayEnd: '25:00' }, { roundingMinutes: 0 }, { theme: 'other' }, { retentionDays: -1 }, [], ])('rejects settings that would break the application: %j', (settings) => { expect(() => roundTrip({ ...fixture(), settings })).toThrow(/Réglage/); }); }); describe('backup fidelity', () => { it('accepts an empty database and a settings-only backup', () => { const settings = { theme: 'nuit', workdays: [] }; expect(roundTrip({ days: [], entries: [], settings })).toMatchObject({ dayCount: 0, entryCount: 0, settings, }); }); it('preserves every worked total in the provided 20-day fixture', () => { const out = parseBackup(JSON.stringify(sample)); expect(out.dayCount).toBe(sample.days.length); expect(out.entryCount).toBe(sample.entries.length); expect(out.dropped).toBe(0); for (const day of sample.days) { const original = buildResume(day, sample.entries.filter((e) => e.date === day.date)); const restored = buildResume(out.days.find((d) => d.date === day.date), out.entries.filter((e) => e.date === day.date)); expect(restored).toEqual(original); } }); it('preserves a gap after a day was stopped and resumed', () => { const data = fixture(); data.entries[1].startTs += 30 * MINUTE; const out = roundTrip(data); expect(out.entries[1].startTs - out.entries[0].endTs).toBe(30 * MINUTE); expect(buildResume(out.days[0], out.entries)).toEqual(buildResume(data.days[0], data.entries)); }); it('closes a running entry at export time, preserving its elapsed work', () => { const data = fixture(); data.days[0].status = 'active'; data.days[0].endedTs = null; data.entries[1].endTs = null; const now = at('2026-09-04', '10:00'); const out = parseBackup(JSON.stringify(buildBackup(data, now))); expect(out.entries[1].endTs).toBe(now); expect(out.days[0].endedTs).toBe(now); expect(out.closedActive).toBe(1); expect(buildResume(out.days[0], out.entries).workedMs).toBe(150 * MINUTE); }); it('requires a usable export timestamp to finish an open entry', () => { const data = fixture(); data.days[0].status = 'active'; data.entries[1].endTs = null; const raw = { ...buildBackup(data), exportedAt: 'broken' }; expect(() => parseBackup(JSON.stringify(raw))).toThrow(/Date d’export/); }); it('ignores an invalid optional export date without breaking the preview', () => { const raw = { ...buildBackup(fixture()), exportedAt: 'broken' }; expect(parseBackup(JSON.stringify(raw)).exportedAt).toBeNull(); }); it('counts orphan entries and malformed records instead of silently dropping them', () => { const data = buildBackup(fixture()); data.entries.push(null, { ...data.entries[0], date: '2026-09-05' }); expect(parseBackup(JSON.stringify(data)).dropped).toBe(2); }); it('accepts a UTF-8 BOM and filters unknown settings keys', () => { const raw = buildBackup(fixture()); raw.settings.loaded = true; raw.settings.workdays = [5, 1, 1]; const out = parseBackup('\uFEFF' + JSON.stringify(raw)); expect(out.settings).toEqual({ roundingMinutes: 15, theme: 'jour', workdays: [1, 5] }); }); }); describe('parseBackup repairs its own files', () => { const withDays = (days, entries) => JSON.stringify({ format: EXPORT_FORMAT, version: 1, days, entries }); it('forces an imported day closed, so there is never a second active day', () => { const data = fixture(); data.days[0].status = 'active'; data.days[0].endedTs = null; data.entries[1].endTs = null; const out = roundTrip(data); expect(out.days[0].status).toBe('closed'); expect(out.days[0].endedTs).toBe(out.entries[1].endTs); }); it('renumbers seq densely and orders by start time', () => { const data = fixture(); data.entries[0].seq = 5; data.entries[1].seq = 99; const out = roundTrip(data); expect(out.entries.map((e) => e.seq)).toEqual([0, 1]); expect(out.entries[0].task).toBe('Chargement / transport'); }); it('drops an entry with no usable start time and counts it', () => { const data = fixture(); data.entries.push({ date: '2026-09-04', seq: 2, task: 'Cassé', startTs: 'nope' }); const out = roundTrip(data); expect(out.dropped).toBe(1); expect(out.entryCount).toBe(2); }); it('drops a day that carries no entries', () => { const raw = withDays( [ { date: '2026-09-04', status: 'closed' }, { date: '2026-09-05', status: 'closed' }, ], [{ date: '2026-09-04', seq: 0, kind: 'work', task: 'A', startTs: at('2026-09-04', '08:00'), endTs: at('2026-09-04', '09:00') }], ); const out = parseBackup(raw); expect(out.dayCount).toBe(1); expect(out.dropped).toBe(1); }); it('derives day bounds when the file omits them', () => { const raw = withDays( [{ date: '2026-09-04', status: 'closed' }], [ { date: '2026-09-04', seq: 0, kind: 'work', task: 'A', startTs: at('2026-09-04', '08:00'), endTs: at('2026-09-04', '09:00') }, { date: '2026-09-04', seq: 1, kind: 'work', task: 'B', startTs: at('2026-09-04', '09:00'), endTs: at('2026-09-04', '11:30') }, ], ); const out = parseBackup(raw); expect(out.days[0].startedTs).toBe(at('2026-09-04', '08:00')); expect(out.days[0].endedTs).toBe(at('2026-09-04', '11:30')); }); it('substitutes a placeholder for a nameless task', () => { const raw = withDays( [{ date: '2026-09-04', status: 'closed' }], [{ date: '2026-09-04', seq: 0, startTs: at('2026-09-04', '08:00'), endTs: at('2026-09-04', '09:00') }], ); expect(parseBackup(raw).entries[0].task).toBe('Poste'); }); it('discards a restaurant name on a panier day', () => { const data = fixture(); data.days[0].lunchType = 'panier'; expect(roundTrip(data).days[0].restaurant).toBeNull(); }); it('repairs an end time that precedes its own start', () => { const raw = withDays( [{ date: '2026-09-04', status: 'closed' }], [{ date: '2026-09-04', seq: 0, kind: 'work', task: 'A', startTs: at('2026-09-04', '10:00'), endTs: at('2026-09-04', '08:00') }], ); const out = parseBackup(raw); expect(out.entries[0].endTs).toBe(out.entries[0].startTs); }); });