/** * The per-module recovery summary under `celilo backup list`. * * The listing itself is a log of attempts, and on celilo-mgr a column of * `✗ 0 B` rows on a tidy hourly rhythm was true for a day while forgejo had no * usable backup at all (celilo#685). These assert the two facts that column * structurally cannot carry: `never`, and how much has failed since. */ import { describe, expect, test } from 'bun:test'; import type { Backup } from '../../db/schema'; import { backedUpModuleIds, backupListJson, lastSuccessSummaryLines } from './backup-list'; const NOW = Date.UTC(2026, 7, 13, 12, 0, 0); const ago = (ms: number) => new Date(NOW - ms); const HOUR = 60 * 60 * 1000; function backup(partial: Partial): Backup { return { backupType: 'module_data', moduleId: 'forgejo', ...partial } as Backup; } describe('backedUpModuleIds', () => { test('deduplicates and sorts the modules a listing names', () => { expect( backedUpModuleIds([ backup({ moduleId: 'forgejo' }), backup({ moduleId: 'authentik' }), backup({ moduleId: 'forgejo' }), ]), ).toEqual(['authentik', 'forgejo']); }); test('ignores system backups, which have no module and no cadence', () => { expect( backedUpModuleIds([ backup({ backupType: 'system_state', moduleId: null }), backup({ moduleId: 'forgejo' }), ]), ).toEqual(['forgejo']); }); }); describe('lastSuccessSummaryLines', () => { test('says never, out loud, when a module has never been captured', () => { // `signal` on celilo-mgr. A listing of its attempts shows rows on a // schedule; none of them is a backup. const [line] = lastSuccessSummaryLines( [{ moduleId: 'signal', lastSuccessAt: null, consecutiveFailures: 8 }], NOW, ); expect(line).toContain('signal'); expect(line).toContain('never'); expect(line).toContain('8 failed attempts since'); }); test('reports how stale a real success is, with the failures since it', () => { const [line] = lastSuccessSummaryLines( [{ moduleId: 'forgejo', lastSuccessAt: ago(7 * 24 * HOUR), consecutiveFailures: 20 }], NOW, ); // The existing relative formatter buckets 7 days as "last week". expect(line).toContain('last week'); expect(line).toContain('20 failed attempts since'); }); test('a healthy module carries no failure clause at all', () => { const [line] = lastSuccessSummaryLines( [{ moduleId: 'authentik', lastSuccessAt: ago(3 * HOUR), consecutiveFailures: 0 }], NOW, ); expect(line).toContain('3h ago'); expect(line).not.toContain('failed'); }); test('counts one failure in the singular', () => { const [line] = lastSuccessSummaryLines( [{ moduleId: 'caddy', lastSuccessAt: ago(2 * HOUR), consecutiveFailures: 1 }], NOW, ); expect(line).toContain('1 failed attempt since'); expect(line).not.toContain('attempts'); }); }); /** * `--json`, which exists because the human output cannot be parsed back. * * `formatRelativeDate` buckets everything past six days — "last week", then * "2 weeks ago", then "last month". A person reading a screen wants that. The * web console draws one square per module per day and cannot place a row it * cannot date, and a row it leaves out renders as a day on which no backup ran. * Inventing a gap in somebody's backup coverage is the one thing that page must * never do, so these pin the exactness rather than the formatting. */ describe('backupListJson', () => { const AT = Date.UTC(2026, 6, 14, 18, 13, 6); function row(partial: Partial): Backup { return backup({ id: 'da8522ea-4319-45fb-ae06-a6d3525f4b9f', storageId: 'st-1', storagePath: '2026-07-14/forgejo.backup', status: 'completed', sizeBytes: 2_900_000_000, startedAt: new Date(AT), completedAt: new Date(AT + 1000), ...partial, }); } const noDatabase = { storageNameOf: () => 'aws-backups', historyOf: () => ({ lastSuccessAt: new Date(AT), lastAttemptAt: new Date(AT), consecutiveFailures: 0, }), }; function parse(backups: Backup[], windowDays: number | null = null) { const result = backupListJson(backups, windowDays, noDatabase); // Narrowed rather than asserted: `message` lives only on the success arm, // and a failure here should say what went wrong, not read as empty JSON. if (!result.success) throw new Error(result.error); return JSON.parse(result.message ?? '{}'); } test('a timestamp survives as an exact instant, not a bucket', () => { // The same row the human path renders as "last month". const [only] = parse([row({})].map((b) => b)).backups; expect(only.startedAt).toBe(AT); expect(only.completedAt).toBe(AT + 1000); }); test('the short id is offered so a caller need not slice a UUID', () => { expect(parse([row({})]).backups[0].shortId).toBe('da8522ea'); }); test('a failed attempt reports no size and no completion', () => { // Not zero and not "now". A failure kept nothing and never finished, and // both of those are facts a caller renders differently from a small backup. const [only] = parse([row({ status: 'failed', sizeBytes: null, completedAt: null })]).backups; expect(only.sizeBytes).toBeNull(); expect(only.completedAt).toBeNull(); }); test('the failure cause is carried, because one red square is not one problem', () => { const [only] = parse([row({ status: 'failed', errorMessage: 'ENOSPC' })]).backups; expect(only.error).toBe('ENOSPC'); }); test('windowDays is echoed, so a caller can tell "nothing ran" from "not asked"', () => { // Null means unbounded. A number means the rows are complete only that far // back, and an empty day beyond it asserts nothing. expect(parse([row({})], 30).windowDays).toBe(30); expect(parse([row({})], null).windowDays).toBeNull(); }); test('an empty listing is a valid answer, not an error', () => { const empty = parse([]); expect(empty.backups).toEqual([]); expect(empty.modules).toEqual([]); }); });