import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { BACKUP_STATUS_FILE, type BackupStatus } from '@dorfteich/shared'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { backupFreshnessCheck, backupRemoteFreshnessCheck } from './backup-freshness'; import { ReadinessService } from './readiness.service'; import type { BackupTargetService } from '../backup/backup-target.service'; import type { AppConfig } from '../config/app-config.service'; import type { PrismaService } from '../prisma/prisma.service'; import type { InstanceSettingsService } from '../settings/instance-settings.service'; const NOW = new Date('2026-07-12T12:00:00Z'); const HOUR = 3_600_000; let dir: string; function writeStatus(overrides: Partial): void { const finishedAt = new Date(NOW.getTime() - 3 * HOUR).toISOString(); const status: BackupStatus = { schemaVersion: 1, updatedAt: finishedAt, retentionDays: 7, lastRun: { backupId: '20260712-090000', startedAt: finishedAt, finishedAt, durationMs: 1000, outcome: 'succeeded', sizes: { dumpBytes: 1, archiveBytes: 1 }, }, lastSuccess: { backupId: '20260712-090000', finishedAt, sizes: { dumpBytes: 1, archiveBytes: 1 }, }, ...overrides, }; writeFileSync(join(dir, BACKUP_STATUS_FILE), JSON.stringify(status)); } beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'dorfteich-freshness-')); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); describe('backupFreshnessCheck', () => { it('warns when no status was ever recorded', () => { const check = backupFreshnessCheck(join(dir, 'nowhere'), NOW); expect(check).toMatchObject({ name: 'backup', status: 'warn' }); expect(check.detail).toContain('no backup status'); }); it('warns on an unreadable status file', () => { writeFileSync(join(dir, BACKUP_STATUS_FILE), '{torn'); expect(backupFreshnessCheck(dir, NOW).status).toBe('warn'); }); it('warns when no backup ever succeeded, with the last error', () => { writeStatus({ lastSuccess: null, lastRun: { backupId: '20260712-090000', startedAt: NOW.toISOString(), finishedAt: NOW.toISOString(), durationMs: 5, outcome: 'failed', error: 'pg_dump failed: connection refused', }, }); const check = backupFreshnessCheck(dir, NOW); expect(check.status).toBe('warn'); expect(check.detail).toContain('connection refused'); }); it('warns when the last success is older than 26 h', () => { const old = new Date(NOW.getTime() - 30 * HOUR).toISOString(); writeStatus({ lastSuccess: { backupId: '20260711-060000', finishedAt: old, sizes: { dumpBytes: 1, archiveBytes: 1 }, }, }); const check = backupFreshnessCheck(dir, NOW); expect(check.status).toBe('warn'); expect(check.detail).toContain('30 h old (max 26 h)'); }); it('is ok on a fresh success', () => { writeStatus({}); expect(backupFreshnessCheck(dir, NOW)).toEqual({ name: 'backup', status: 'ok' }); }); it('stays ok but reports a failed run newer than the fresh success', () => { writeStatus({ lastRun: { backupId: '20260712-110000', startedAt: NOW.toISOString(), finishedAt: NOW.toISOString(), durationMs: 5, outcome: 'failed', error: 'disk full', }, }); const check = backupFreshnessCheck(dir, NOW); expect(check.status).toBe('ok'); expect(check.detail).toContain('disk full'); }); }); describe('ReadinessService report (issue #85 degraded semantics)', () => { // Fakes: the database probe is the only prisma call the report makes. const prismaUp = { $queryRaw: async () => [{ pending: 0n }] } as unknown as PrismaService; const prismaDown = { $queryRaw: async () => { throw new Error('connection refused'); }, } as unknown as PrismaService; const noTarget = { resolveTarget: async () => null } as unknown as BackupTargetService; const configuredTarget = { resolveTarget: async () => ({ baseUrl: 'https://cloud.example.com', username: 'u', password: 'p', folder: 'f', }), } as unknown as BackupTargetService; const dailySettings = { get: async () => 'daily' } as unknown as InstanceSettingsService; function configWith(backupsDir: string): AppConfig { return { env: { // Unreachable sidecars: converter/renderer report warn — irrelevant // here, the assertions pin the database/migrations/backup checks. PANDOC_URL: 'http://127.0.0.1:59998', GOTENBERG_URL: 'http://127.0.0.1:59998', BACKUPS_DIR: backupsDir, }, } as AppConfig; } it('reports degraded (not unready) on a stale backup with healthy hard checks', async () => { const old = new Date(Date.now() - 40 * HOUR).toISOString(); writeStatus({ lastSuccess: { backupId: '20260710-030000', finishedAt: old, sizes: { dumpBytes: 1, archiveBytes: 1 }, }, }); const report = await new ReadinessService( prismaUp, configWith(dir), noTarget, dailySettings, ).report(); expect(report.status).toBe('degraded'); const byName = Object.fromEntries(report.checks.map((c) => [c.name, c.status])); expect(byName).toMatchObject({ database: 'ok', migrations: 'ok', backup: 'warn' }); }); it('reports unready only on hard failures', async () => { const report = await new ReadinessService( prismaDown, configWith(dir), noTarget, dailySettings, ).report(); expect(report.status).toBe('unready'); expect(report.checks.find((c) => c.name === 'database')?.status).toBe('failed'); }); it('adds the off-host check only while a target is configured', async () => { writeStatus({}); const withoutTarget = await new ReadinessService( prismaUp, configWith(dir), noTarget, dailySettings, ).report(); expect(withoutTarget.checks.some((c) => c.name === 'backup_remote')).toBe(false); const withTarget = await new ReadinessService( prismaUp, configWith(dir), configuredTarget, dailySettings, ).report(); const remote = withTarget.checks.find((c) => c.name === 'backup_remote'); // status.json has no remote section yet → the copy is missing → warn. expect(remote).toMatchObject({ status: 'warn' }); expect(withTarget.status).toBe('degraded'); }); }); describe('backupRemoteFreshnessCheck (issue #103)', () => { const fresh = new Date(NOW.getTime() - 3 * HOUR).toISOString(); it('is ok (with detail) when the schedule is manual-only', () => { expect(backupRemoteFreshnessCheck(dir, NOW, 'off')).toMatchObject({ name: 'backup_remote', status: 'ok', }); }); it('warns when no upload ever succeeded, with the last error', () => { writeStatus({ remote: { lastUpload: { backupId: '20260712-090000', finishedAt: fresh, outcome: 'failed', error: 'authentication failed', }, lastSuccessfulUpload: null, }, }); const check = backupRemoteFreshnessCheck(dir, NOW, 'daily'); expect(check.status).toBe('warn'); expect(check.detail).toContain('authentication failed'); }); it('applies the weekly bound to weekly schedules', () => { const sixDaysOld = new Date(NOW.getTime() - 6 * 24 * HOUR).toISOString(); writeStatus({ remote: { lastUpload: { backupId: '20260706-090000', finishedAt: sixDaysOld, outcome: 'succeeded', sizeBytes: 5, }, lastSuccessfulUpload: { backupId: '20260706-090000', finishedAt: sixDaysOld, sizeBytes: 5, }, }, }); expect(backupRemoteFreshnessCheck(dir, NOW, 'weekly').status).toBe('ok'); expect(backupRemoteFreshnessCheck(dir, NOW, 'daily').status).toBe('warn'); }); it('is ok on a fresh upload', () => { writeStatus({ remote: { lastUpload: { backupId: '20260712-090000', finishedAt: fresh, outcome: 'succeeded', sizeBytes: 5, }, lastSuccessfulUpload: { backupId: '20260712-090000', finishedAt: fresh, sizeBytes: 5 }, }, }); expect(backupRemoteFreshnessCheck(dir, NOW, 'daily')).toEqual({ name: 'backup_remote', status: 'ok', }); }); });