dorfteich/apps/backup/src/backup-set.test.ts
Claude Fable 5 8dbff86537
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m9s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m47s
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 9s
CI / Auth e2e pack (push) Successful in 5m25s
CI / Import/export fidelity gate (push) Successful in 45s
Add backup sidecar: nightly dump, volume archive, prune, status, restore (#83)
New apps/backup service (ADR 0015): nightly pg_dump -Fc plus one tar of the
uploads/plugins volumes as a consistent restore set on a new backups volume,
retention prune that never removes the newest complete set, atomic
status.json for the readiness/admin consumers (#85/#86), and a failure mail
sent directly via nodemailer (the api may be the broken part) with de/en
texts in the shared mails catalog. BACKUP_RUN_ONCE=1 gives the on-demand
path; deploy/backup/restore.sh automates the documented restore runbook.
The pure secret-store helpers moved to @dorfteich/shared so the sidecar
resolves the wizard-written SMTP relay exactly like the api.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 17:29:15 +02:00

74 lines
2.3 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
archiveFileName,
backupIdTime,
dumpFileName,
expiredSets,
listSets,
newBackupId,
} from './backup-set.js';
const DAY = 24 * 60 * 60 * 1000;
function setFiles(id: string): string[] {
return [dumpFileName(id), archiveFileName(id)];
}
describe('backup ids', () => {
it('round-trips through the id format', () => {
const now = new Date('2026-07-11T03:00:00Z');
const id = newBackupId(now);
expect(id).toBe('20260711-030000');
expect(backupIdTime(id)?.toISOString()).toBe(now.toISOString());
});
it('rejects malformed ids', () => {
expect(backupIdTime('not-a-backup')).toBeNull();
expect(backupIdTime('20260711')).toBeNull();
});
});
describe('listSets', () => {
it('groups files by id and flags completeness', () => {
const sets = listSets([
'status.json',
'db-20260701-030000.dump.partial',
...setFiles('20260710-030000'),
'db-20260711-030000.dump',
]);
expect(sets.map((set) => set.id)).toEqual(['20260710-030000', '20260711-030000']);
expect(sets[0]?.complete).toBe(true);
expect(sets[1]?.complete).toBe(false);
});
});
describe('expiredSets', () => {
const now = new Date('2026-07-31T04:00:00Z');
it('removes only sets past retention', () => {
const fresh = newBackupId(new Date(now.getTime() - 2 * DAY));
const old = newBackupId(new Date(now.getTime() - 10 * DAY));
const newest = newBackupId(new Date(now.getTime() - 1 * DAY));
const sets = listSets([...setFiles(old), ...setFiles(fresh), ...setFiles(newest)]);
const expired = expiredSets(sets, now, 7);
expect(expired.map((set) => set.id)).toEqual([old]);
});
it('never removes the newest complete set, even when expired', () => {
const older = newBackupId(new Date(now.getTime() - 40 * DAY));
const newestComplete = newBackupId(new Date(now.getTime() - 20 * DAY));
// A newer but incomplete set (crashed run) must not shield the complete one.
const newerIncomplete = newBackupId(new Date(now.getTime() - 9 * DAY));
const sets = listSets([
...setFiles(older),
...setFiles(newestComplete),
dumpFileName(newerIncomplete),
]);
const expired = expiredSets(sets, now, 7);
expect(expired.map((set) => set.id)).toEqual([older, newerIncomplete]);
});
});