Files
pardis/src/lib/backup.test.js

279 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('timelog-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 TimeLog/);
});
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);
});
});