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]>
138 lines
4.7 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|