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
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
74 lines
2.3 KiB
TypeScript
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]);
|
|
});
|
|
});
|