120 lines
5.3 KiB
TypeScript
120 lines
5.3 KiB
TypeScript
/**
|
|
* Unit tests for backups/retention.ts — pure retention planning. Proves the two
|
|
* safety properties: retention is OFF by default, and a policy that would delete
|
|
* every snapshot is refused.
|
|
*/
|
|
import { describe, it, expect } from 'bun:test';
|
|
import {
|
|
parseRetention,
|
|
retentionActive,
|
|
buildForgetArgs,
|
|
checkWouldWipe,
|
|
NO_RETENTION,
|
|
} from '../../src/lib/server/backups/retention';
|
|
|
|
describe('parseRetention — safe default is "none"', () => {
|
|
it('null / empty / garbage → none', () => {
|
|
expect(parseRetention(null)).toEqual(NO_RETENTION);
|
|
expect(parseRetention(undefined)).toEqual(NO_RETENTION);
|
|
expect(parseRetention('')).toEqual(NO_RETENTION);
|
|
expect(parseRetention(' ')).toEqual(NO_RETENTION);
|
|
expect(parseRetention('not json')).toEqual(NO_RETENTION);
|
|
expect(parseRetention('{}')).toEqual(NO_RETENTION);
|
|
expect(parseRetention({})).toEqual(NO_RETENTION);
|
|
});
|
|
it('all-zero / non-positive values → none (never prune on a broken config)', () => {
|
|
expect(parseRetention({ keepLast: 0 })).toEqual(NO_RETENTION);
|
|
expect(parseRetention({ keepDaily: -1, keepWeekly: 0 })).toEqual(NO_RETENTION);
|
|
expect(parseRetention({ keepLast: 'abc' })).toEqual(NO_RETENTION);
|
|
});
|
|
it('keepLast → keep-last mode', () => {
|
|
expect(parseRetention({ keepLast: 5 })).toEqual({ mode: 'keep-last', last: 5 });
|
|
expect(parseRetention('{"keepLast":3}')).toEqual({ mode: 'keep-last', last: 3 });
|
|
});
|
|
it('time buckets → time mode, only positive fields kept', () => {
|
|
expect(parseRetention({ keepDaily: 7, keepWeekly: 4, keepMonthly: 6, keepYearly: 2 }))
|
|
.toEqual({ mode: 'time', daily: 7, weekly: 4, monthly: 6, yearly: 2 });
|
|
expect(parseRetention({ keepDaily: 7, keepWeekly: 0 })).toEqual({ mode: 'time', daily: 7 });
|
|
});
|
|
it('keepLast takes precedence when both present', () => {
|
|
expect(parseRetention({ keepLast: 5, keepDaily: 7 })).toEqual({ mode: 'keep-last', last: 5 });
|
|
});
|
|
});
|
|
|
|
describe('retentionActive', () => {
|
|
it('is false only for none', () => {
|
|
expect(retentionActive(NO_RETENTION)).toBe(false);
|
|
expect(retentionActive({ mode: 'keep-last', last: 1 })).toBe(true);
|
|
expect(retentionActive({ mode: 'time', daily: 1 })).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('buildForgetArgs', () => {
|
|
const filter = 'dockhand:instance=i,dockhand:configid=7';
|
|
|
|
it('returns null for none (nothing to prune)', () => {
|
|
expect(buildForgetArgs(NO_RETENTION, filter)).toBeNull();
|
|
});
|
|
it('builds a keep-last forget scoped by the config tag, WITHOUT --prune', () => {
|
|
const args = buildForgetArgs({ mode: 'keep-last', last: 5 }, filter);
|
|
// NO --prune: post-backup retention only drops snapshot references (fast). Reclaiming
|
|
// space is a separate scheduled prune, so a backup never hangs on "Applying retention".
|
|
// --retry-lock is short (1m): forget runs inside the per-repo serializer, so the
|
|
// only lock it can meet is an orphan (dead helper) or another instance sharing
|
|
// the repo — waiting 5m for either was the cause of the 300s backup-test timeout.
|
|
expect(args).toEqual([
|
|
'forget', '--retry-lock', '1m', '--group-by', '', '--tag', filter, '--keep-last', '5',
|
|
]);
|
|
expect(args).not.toContain('--prune');
|
|
});
|
|
it('builds a time prune with only the set buckets', () => {
|
|
const args = buildForgetArgs({ mode: 'time', daily: 7, monthly: 6 }, filter);
|
|
expect(args).toContain('--keep-daily');
|
|
expect(args).toContain('7');
|
|
expect(args).toContain('--keep-monthly');
|
|
expect(args).toContain('6');
|
|
expect(args).not.toContain('--keep-weekly');
|
|
expect(args).not.toContain('--keep-yearly');
|
|
});
|
|
it('dry-run mode adds --dry-run and --json, omits --prune', () => {
|
|
const args = buildForgetArgs({ mode: 'keep-last', last: 1 }, filter, { dryRun: true, json: true })!;
|
|
expect(args).toContain('--dry-run');
|
|
expect(args).toContain('--json');
|
|
expect(args).not.toContain('--prune');
|
|
});
|
|
it('always scopes by the config tag filter and uses empty group-by', () => {
|
|
const args = buildForgetArgs({ mode: 'keep-last', last: 1 }, filter)!;
|
|
const tagIdx = args.indexOf('--tag');
|
|
expect(args[tagIdx + 1]).toBe(filter);
|
|
const gIdx = args.indexOf('--group-by');
|
|
expect(args[gIdx + 1]).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('checkWouldWipe — refuse a delete-everything policy', () => {
|
|
it('flags a policy that keeps nothing and removes everything', () => {
|
|
const dryRun = JSON.stringify([{ keep: [], remove: [{ id: 'a' }, { id: 'b' }] }]);
|
|
expect(checkWouldWipe(dryRun)).toEqual({ wouldWipe: true, keep: 0, remove: 2, total: 2 });
|
|
});
|
|
it('does NOT flag a normal prune (keeps some)', () => {
|
|
const dryRun = JSON.stringify([{ keep: [{ id: 'a' }], remove: [{ id: 'b' }] }]);
|
|
expect(checkWouldWipe(dryRun)).toEqual({ wouldWipe: false, keep: 1, remove: 1, total: 2 });
|
|
});
|
|
it('does NOT flag when nothing is old enough to remove', () => {
|
|
const dryRun = JSON.stringify([{ keep: [{ id: 'a' }], remove: [] }]);
|
|
expect(checkWouldWipe(dryRun).wouldWipe).toBe(false);
|
|
});
|
|
it('sums across multiple groups', () => {
|
|
const dryRun = JSON.stringify([
|
|
{ keep: [], remove: [{ id: 'a' }] },
|
|
{ keep: [], remove: [{ id: 'b' }] },
|
|
]);
|
|
expect(checkWouldWipe(dryRun)).toEqual({ wouldWipe: true, keep: 0, remove: 2, total: 2 });
|
|
});
|
|
it('empty / garbage / non-array → not a wipe (fail safe: no false positive)', () => {
|
|
expect(checkWouldWipe('').wouldWipe).toBe(false);
|
|
expect(checkWouldWipe('not json').wouldWipe).toBe(false);
|
|
expect(checkWouldWipe('{"not":"array"}').wouldWipe).toBe(false);
|
|
});
|
|
});
|