import { INestApplicationContext } from '@nestjs/common'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import { PermissionCatalogSyncService } from '../src/auth/permissions/permission-catalog-sync.service'; import { BackupSchedulerService } from '../src/backup/backup-scheduler.service'; import { createJobsContext, runCli } from '../src/jobs/run-cli'; import { PrismaService } from '../src/prisma/prisma.service'; // Whole-database exports of the shared test database - well beyond the 5 s // default when other specs run alongside. const EXPORT_TIMEOUT_MS = 120_000; // Once a year (1 January, 03:00 Berlin): a second run right after the first // is never due, whatever hour the test runs in. const YEARLY = '0 3 1 1 *'; const backupFiles = async (directory: string): Promise => (await fs.readdir(directory)).filter( (name) => name.startsWith('apicore-backup-') && name.endsWith('.json'), ); /** * `node dist/cli.js app-backup` against the test database: runCli with the * real JobsModule context, backups into a temporary directory. */ describe('app-backup job (e2e)', () => { const testStart = new Date(Date.now() - 1000); const originalEnv = { ...process.env }; let prisma: PrismaService; let root: string; beforeAll(async () => { prisma = new PrismaService(); await prisma.$connect(); root = await fs.mkdtemp(path.join(os.tmpdir(), 'apicore-app-backup-')); process.env.BACKUP_CRON_SCHEDULE = YEARLY; process.env.BACKUP_RETENTION_COUNT = '5'; }); afterAll(async () => { const runs = await prisma.jobRun.findMany({ where: { job: 'app-backup', startedAt: { gte: testStart } }, select: { id: true }, }); const runIds = runs.map((run) => run.id); await prisma.notification.deleteMany({ where: { relatedType: 'JobRun', relatedId: { in: runIds } }, }); await prisma.jobRun.deleteMany({ where: { id: { in: runIds } } }); await prisma.$disconnect(); await fs.rm(root, { recursive: true, force: true }); process.env = { ...originalEnv }; }); it('runs in the job context without the in-process cron and without the catalog sync', async () => { const app = await createJobsContext(); try { expect(app.get(BackupSchedulerService)).toBeInstanceOf( BackupSchedulerService, ); expect(() => app.get(PermissionCatalogSyncService, { strict: false }), ).toThrow(); } finally { await app.close(); } }); it( 'writes one backup for two runs in the same hour', async () => { const directory = path.join(root, 'twice'); process.env.BACKUP_STORAGE_PATH = directory; await expect(runCli(['app-backup'])).resolves.toBe(0); await expect(runCli(['app-backup'])).resolves.toBe(0); const files = await backupFiles(directory); expect(files).toHaveLength(1); const runs = await prisma.jobRun.findMany({ where: { job: 'app-backup', startedAt: { gte: testStart } }, orderBy: [{ startedAt: 'asc' }, { id: 'asc' }], }); expect(runs.map((run) => run.status)).toEqual(['SUCCEEDED', 'SUCCEEDED']); const { size } = await fs.stat(path.join(directory, files[0])); expect(runs[0].result).toEqual({ written: true, fileName: files[0], sizeBytes: size, pruned: 0, }); expect(runs[1].result).toEqual({ written: false, reason: 'not-due' }); }, EXPORT_TIMEOUT_MS, ); it( 'writes one backup when two job pods check at the same time', async () => { const directory = path.join(root, 'concurrent'); process.env.BACKUP_STORAGE_PATH = directory; // Two contexts, two connection pools: like two pods. const pods: INestApplicationContext[] = await Promise.all([ createJobsContext(), createJobsContext(), ]); try { const results = await Promise.all( pods.map((pod) => pod.get(BackupSchedulerService).runIfDue()), ); expect(results.map((result) => result.written).sort()).toEqual([ false, true, ]); expect(results).toContainEqual({ written: false, reason: 'not-due' }); expect(await backupFiles(directory)).toHaveLength(1); } finally { await Promise.all(pods.map((pod) => pod.close())); } }, EXPORT_TIMEOUT_MS, ); it('succeeds without writing anything while no path is configured', async () => { delete process.env.BACKUP_STORAGE_PATH; const app = await createJobsContext(); try { await expect(app.get(BackupSchedulerService).runIfDue()).resolves.toEqual( { written: false, reason: 'no-path' }, ); } finally { await app.close(); } }); });