185 lines
7.9 KiB
TypeScript
185 lines
7.9 KiB
TypeScript
/**
|
|
* Unit tests for backups/models.ts — pure DTO parsers, tag builders, result
|
|
* helpers. No mocks. Covers the coverage-checklist row for models.ts, and the
|
|
* behavioral guarantee that a garbage/partial/absent restic summary NEVER throws and
|
|
* a missing summary yields null (so the service fails the backup).
|
|
*/
|
|
import { describe, it, expect } from 'bun:test';
|
|
import {
|
|
parseResticLine,
|
|
parseResticBackupSummary,
|
|
parseResticProgress,
|
|
parseSnapshots,
|
|
resticOk,
|
|
resticPartial,
|
|
buildSnapshotTags,
|
|
retentionTagFilter,
|
|
instanceTagFilter,
|
|
readEnvIdFromTags,
|
|
readConfigIdFromTags,
|
|
BackupError,
|
|
TIMEOUTS,
|
|
type ResticRun,
|
|
} from '../../src/lib/server/backups/models';
|
|
|
|
describe('parseResticLine', () => {
|
|
it('parses a JSON object line', () => {
|
|
expect(parseResticLine('{"a":1}')).toEqual({ a: 1 });
|
|
});
|
|
it('returns null for non-JSON / empty / non-object', () => {
|
|
expect(parseResticLine('')).toBeNull();
|
|
expect(parseResticLine('not json')).toBeNull();
|
|
expect(parseResticLine('[1,2]')).toBeNull(); // array, not object
|
|
expect(parseResticLine(' ')).toBeNull();
|
|
expect(parseResticLine('12')).toBeNull();
|
|
});
|
|
it('never throws on garbage', () => {
|
|
expect(() => parseResticLine('{unterminated')).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('parseResticBackupSummary (no summary ⇒ null, never throw)', () => {
|
|
it('extracts the summary with all fields', () => {
|
|
const stdout = [
|
|
'{"message_type":"status","percent_done":0.5}',
|
|
'{"message_type":"summary","snapshot_id":"abcd1234","files_new":3,"files_changed":1,"files_unmodified":10,"data_added":2048,"total_bytes_processed":4096,"total_files_processed":14}',
|
|
].join('\n');
|
|
expect(parseResticBackupSummary(stdout)).toEqual({
|
|
snapshotId: 'abcd1234', filesNew: 3, filesChanged: 1, filesUnmodified: 10,
|
|
dataAdded: 2048, totalBytesProcessed: 4096, totalFilesProcessed: 14,
|
|
});
|
|
});
|
|
it('coerces missing numeric fields to 0 (truncated summary never throws)', () => {
|
|
const s = parseResticBackupSummary('{"message_type":"summary","snapshot_id":"x"}');
|
|
expect(s).toEqual({ snapshotId: 'x', filesNew: 0, filesChanged: 0, filesUnmodified: 0, dataAdded: 0, totalBytesProcessed: 0, totalFilesProcessed: 0 });
|
|
});
|
|
it('returns null when there is NO summary line (unknown outcome ⇒ fail)', () => {
|
|
expect(parseResticBackupSummary('{"message_type":"status","percent_done":1}')).toBeNull();
|
|
expect(parseResticBackupSummary('')).toBeNull();
|
|
expect(parseResticBackupSummary('total garbage\nmore garbage')).toBeNull();
|
|
});
|
|
it('returns null when summary lacks a snapshot_id', () => {
|
|
expect(parseResticBackupSummary('{"message_type":"summary","files_new":1}')).toBeNull();
|
|
expect(parseResticBackupSummary('{"message_type":"summary","snapshot_id":""}')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('parseResticProgress', () => {
|
|
it('parses a status line', () => {
|
|
expect(parseResticProgress('{"message_type":"status","percent_done":0.42,"bytes_done":10,"total_bytes":100}'))
|
|
.toEqual({ percentDone: 0.42, bytesDone: 10, totalBytes: 100 });
|
|
});
|
|
it('returns null for non-status / garbage', () => {
|
|
expect(parseResticProgress('{"message_type":"summary"}')).toBeNull();
|
|
expect(parseResticProgress('garbage')).toBeNull();
|
|
});
|
|
it('defaults numerics to 0', () => {
|
|
expect(parseResticProgress('{"message_type":"status"}')).toEqual({ percentDone: 0, bytesDone: 0, totalBytes: 0 });
|
|
});
|
|
});
|
|
|
|
describe('parseSnapshots', () => {
|
|
it('parses and sorts newest-first', () => {
|
|
const stdout = JSON.stringify([
|
|
{ id: 'old', short_id: 'old', time: '2024-01-01T00:00:00Z', hostname: 'h', tags: ['a'], paths: ['/v'] },
|
|
{ id: 'new', short_id: 'new', time: '2024-06-01T00:00:00Z', hostname: 'h', tags: [], paths: [] },
|
|
]);
|
|
const snaps = parseSnapshots(stdout);
|
|
expect(snaps.map((s) => s.id)).toEqual(['new', 'old']);
|
|
expect(snaps[1].tags).toEqual(['a']);
|
|
});
|
|
it('returns [] on garbage or non-array (unparseable ≠ populated repo)', () => {
|
|
expect(parseSnapshots('not json')).toEqual([]);
|
|
expect(parseSnapshots('{"not":"array"}')).toEqual([]);
|
|
expect(parseSnapshots('')).toEqual([]);
|
|
});
|
|
it('skips malformed entries, keeps valid ones', () => {
|
|
const stdout = JSON.stringify([{ notid: 1 }, { id: 'good', time: '2024-01-01' }]);
|
|
const snaps = parseSnapshots(stdout);
|
|
expect(snaps.map((s) => s.id)).toEqual(['good']);
|
|
expect(snaps[0].shortId).toBe('good'.slice(0, 8));
|
|
});
|
|
it('filters non-string tags/paths defensively', () => {
|
|
const stdout = JSON.stringify([{ id: 'x', tags: ['ok', 5, null], paths: ['/p', {}] }]);
|
|
expect(parseSnapshots(stdout)[0].tags).toEqual(['ok']);
|
|
expect(parseSnapshots(stdout)[0].paths).toEqual(['/p']);
|
|
});
|
|
});
|
|
|
|
describe('resticOk / resticPartial (undefined exit is not ok)', () => {
|
|
const run = (exitCode: number | undefined): ResticRun => ({ exitCode, stdout: '', stderr: '' });
|
|
it('ok only for exit 0', () => {
|
|
expect(resticOk(run(0))).toBe(true);
|
|
expect(resticOk(run(1))).toBe(false);
|
|
expect(resticOk(run(3))).toBe(false);
|
|
expect(resticOk(run(undefined))).toBe(false); // the critical case
|
|
});
|
|
it('partial only for exit 3', () => {
|
|
expect(resticPartial(run(3))).toBe(true);
|
|
expect(resticPartial(run(0))).toBe(false);
|
|
expect(resticPartial(run(undefined))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('snapshot tags — stable ids, never normalized names', () => {
|
|
it('builds instance/configid/envid + decoration tags', () => {
|
|
const tags = buildSnapshotTags({ instanceId: 'inst', configId: 7, environmentId: 3, targetName: 'web', type: 'container' });
|
|
expect(tags).toContain('dockhand:instance=inst');
|
|
expect(tags).toContain('dockhand:configid=7');
|
|
expect(tags).toContain('dockhand:envid=3');
|
|
expect(tags).toContain('dockhand:type=container');
|
|
expect(tags).toContain('dockhand:name=web');
|
|
});
|
|
it('uses "local" for a null environment', () => {
|
|
const tags = buildSnapshotTags({ instanceId: 'i', configId: 1, environmentId: null, targetName: 't', type: 'stack' });
|
|
expect(tags).toContain('dockhand:envid=local');
|
|
});
|
|
it('retention filter ANDs instance + configid (collision-proof scope)', () => {
|
|
expect(retentionTagFilter('inst', 7)).toBe('dockhand:instance=inst,dockhand:configid=7');
|
|
});
|
|
it('instance filter is instance-only', () => {
|
|
expect(instanceTagFilter('inst')).toBe('dockhand:instance=inst');
|
|
});
|
|
});
|
|
|
|
describe('reading ids back from tags (fail closed on missing/malformed)', () => {
|
|
it('reads envid: local, numeric, or null', () => {
|
|
expect(readEnvIdFromTags(['dockhand:envid=local'])).toBe('local');
|
|
expect(readEnvIdFromTags(['dockhand:envid=5'])).toBe(5);
|
|
expect(readEnvIdFromTags(['dockhand:envid=0'])).toBeNull(); // 0 not a valid id
|
|
expect(readEnvIdFromTags(['dockhand:envid=abc'])).toBeNull();
|
|
expect(readEnvIdFromTags(['other'])).toBeNull(); // absent ⇒ fail closed
|
|
expect(readEnvIdFromTags([])).toBeNull();
|
|
});
|
|
it('reads configid or null', () => {
|
|
expect(readConfigIdFromTags(['dockhand:configid=9'])).toBe(9);
|
|
expect(readConfigIdFromTags(['dockhand:configid=x'])).toBeNull();
|
|
expect(readConfigIdFromTags([])).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('BackupError', () => {
|
|
it('carries a code + optional context', () => {
|
|
const e = new BackupError('INTEGRITY', 'snapshot unreadable', { snapshotId: 'x' });
|
|
expect(e).toBeInstanceOf(Error);
|
|
expect(e.code).toBe('INTEGRITY');
|
|
expect(e.message).toBe('snapshot unreadable');
|
|
expect(e.context).toEqual({ snapshotId: 'x' });
|
|
});
|
|
});
|
|
|
|
describe('TIMEOUTS — unbounded by default (#1382)', () => {
|
|
it('both tiers default to 0 (no timeout) so a long backup/list is never killed', () => {
|
|
// A fixed cap killed healthy long-running ops mid-run; a stuck op is handled by
|
|
// manual cancel + the helper reaper instead. Operators can still opt into a cap via
|
|
// the single RESTIC_TIMEOUT env (not set in the test env).
|
|
expect(TIMEOUTS.interactive).toBe(0);
|
|
expect(TIMEOUTS.data).toBe(0);
|
|
});
|
|
|
|
it('both tiers read the SAME env, so there is one RESTIC_TIMEOUT to set', () => {
|
|
// Folded from two envs into one: interactive and data must stay in lockstep.
|
|
expect(TIMEOUTS.interactive).toBe(TIMEOUTS.data);
|
|
});
|
|
});
|