Files
ApiCore/ApiCore-Backend/test/app-backup-job.e2e-spec.ts
Nick SzaunigandClaude Opus 5.5 cdd30cf3ee feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed:
- webhooks with delivery table, retries and stable delivery id
- calendar handlers; a finished task removes its Outlook event
- tasks, automations, inspections, varroa, treatments, feedings,
  harvests, colonies, wintering, stock and bottling record domain events
  in the same transaction; legacy post-commit calls removed
- row locks through one helper (lockRowForUpdate, lockRowsForShare,
  lockRowsForUpdate)
- job framework with JobRun, cli exit codes, cleanup, task templates,
  daily evaluation, hourly app-backup and the rekey job
- system API and page "Verwaltung > System" with retry
- @nestjs/schedule removed, guard tests against in-process schedulers

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed:
- inspection photo offline without metadata
- bulk capture for treatments, feedings and varroa (backend + frontend)
- offline stand mode with service worker, offline pages and packs
- offline session, cold start and logout on shared devices

Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md.
Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema
drift; frontend lint, typecheck, 913 tests, build. Not rolled out.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 11:36:38 +02:00

138 lines
4.7 KiB
TypeScript

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<string[]> =>
(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();
}
});
});