Files
dockhand-upstream/tests/backups/helpers.test.ts
T

119 lines
4.2 KiB
TypeScript

/**
* Unit tests for backups/helpers.ts — currently the fail-fast withTimeout used to
* bound helper-image resolution so a stalled pull can never hang a backup.
*/
import { describe, it, expect } from 'bun:test';
import { withTimeout, resolveEnabledOnScheduleChange } from '../../src/lib/server/backups/helpers';
import { BackupError } from '../../src/lib/server/backups/models';
describe('withTimeout', () => {
it('resolves with the value when the promise settles before the deadline', async () => {
const v = await withTimeout(Promise.resolve(42), 1000, 'nope');
expect(v).toBe(42);
});
it('rejects with a BackupError(DOCKER) when the promise outlives the deadline', async () => {
const slow = new Promise((r) => setTimeout(r, 200));
let err: unknown;
try {
await withTimeout(slow, 20, 'timed out pulling helper image "x"');
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(BackupError);
expect((err as BackupError).code).toBe('DOCKER');
expect((err as Error).message).toContain('timed out pulling helper image');
});
it('propagates the underlying rejection (not a timeout) when it loses the race', async () => {
const boom = Promise.reject(new Error('registry auth failed'));
let err: unknown;
try {
await withTimeout(boom, 1000, 'timeout message');
} catch (e) {
err = e;
}
expect((err as Error).message).toBe('registry auth failed');
expect(err).not.toBeInstanceOf(BackupError);
});
it('clears its timer on success (no dangling timeout keeps the loop alive)', async () => {
// If the timer weren't cleared, a rejection would fire later and surface as an
// unhandled rejection. Resolve fast, then wait past the deadline to prove quiet.
await withTimeout(Promise.resolve('ok'), 30, 'should never fire');
await new Promise((r) => setTimeout(r, 60));
expect(true).toBe(true); // reaching here without an unhandled rejection is the assertion
});
});
describe('resolveEnabledOnScheduleChange', () => {
// THE BUG: run-once persists a manual, paused config (schedule=null, enabled=false).
// Editing it to add a cron used to keep it paused because the UI sent the stale
// enabled=false. Adding a schedule must auto-enable.
it('auto-enables when a manual config (no schedule) gains a cron, even if the request says enabled=false', () => {
expect(resolveEnabledOnScheduleChange({
requestedEnabled: false,
existingSchedule: null,
newSchedule: '0 2 * * *'
})).toBe(true);
});
it('auto-enables manual -> scheduled when existing schedule is an empty string', () => {
expect(resolveEnabledOnScheduleChange({
requestedEnabled: false,
existingSchedule: '',
newSchedule: '*/5 * * * *'
})).toBe(true);
});
it('does NOT force-enable a config that was ALREADY scheduled (respects a deliberate pause)', () => {
expect(resolveEnabledOnScheduleChange({
requestedEnabled: false,
existingSchedule: '0 2 * * *',
newSchedule: '0 3 * * *'
})).toBe(false);
});
it('honours an explicit enabled=true request unchanged', () => {
expect(resolveEnabledOnScheduleChange({
requestedEnabled: true,
existingSchedule: '0 2 * * *',
newSchedule: '0 2 * * *'
})).toBe(true);
});
it('leaves enabled UNCHANGED (undefined) when the request omits the flag and there is no manual->scheduled transition', () => {
// undefined must pass through so the DB layer keeps the existing value — a PUT
// that omits `enabled` must never silently pause the config.
expect(resolveEnabledOnScheduleChange({
requestedEnabled: undefined,
existingSchedule: '0 2 * * *',
newSchedule: '0 2 * * *'
})).toBeUndefined();
});
it('still auto-enables on manual->scheduled even when the request omits the flag', () => {
expect(resolveEnabledOnScheduleChange({
requestedEnabled: undefined,
existingSchedule: null,
newSchedule: '0 2 * * *'
})).toBe(true);
});
it('does not enable when the new schedule is also empty (manual stays manual)', () => {
expect(resolveEnabledOnScheduleChange({
requestedEnabled: false,
existingSchedule: null,
newSchedule: null
})).toBe(false);
});
it('treats a whitespace-only cron as no schedule (no false auto-enable)', () => {
expect(resolveEnabledOnScheduleChange({
requestedEnabled: false,
existingSchedule: null,
newSchedule: ' '
})).toBe(false);
});
});