Files

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);
});
});