dorfteich/apps/api/src/scheduler/scheduler.service.db.test.ts
Claude Sonnet 5 a645763679
All checks were successful
CD / Build and push images (push) Successful in 2m5s
CI / Lint, typecheck, test (push) Successful in 1m45s
CI / Auth e2e pack (push) Successful in 1m50s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 10s
Add page trash: soft delete, restore, and purge job (#31)
Backend: a generic maintenance-job scheduler (SchedulerService, `jobs`
table) that any later maintenance job registers with instead of
growing its own timer loop. Due-ness and the run-mutex both live in
the DB row (`lastRunAt` survives a restart; claiming a due job is one
atomic `UPDATE ... WHERE status != 'RUNNING'`), and an injectable
ClockService lets tests simulate retention elapsing without waiting or
faking the global clock.

Trash endpoints: GET /ponds/:id/trash (list), POST /pages/:id/restore,
DELETE /pages/:id/purge (manual, bypasses retention) — all sharing the
same purge logic as the scheduled daily job (default 30-day retention,
new trash.retentionDays instance setting). Purging deletes a page's
content cache, update log, and attachment files/quota; page_versions
is a placeholder until M3 exists. Direct navigation to a trashed page
now 404s with a distinguishable `page_trashed` code for editors (a
plain 404 for everyone else) instead of the generic not-found.

Attachment.pageId — added in #27 but never wired up — now gets set on
every page state save to whichever page's document currently embeds
the file, which is what lets purge find a page's files.

Frontend: a per-pond trash view (restore/purge), a "move to trash"
action with confirmation in the page menu, and a trash link in the
sidebar for pond owners. Also fixes react-query retrying 4xx responses
for several seconds by default, which was masking the trash-hint 404
in the UI (and would have affected any other not-found/permission
error the same way).

Closes #31
2026-07-08 12:48:17 +02:00

86 lines
2.9 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { JobDefinition, SchedulerService } from './scheduler.service';
describe.skipIf(!hasTestDb)('SchedulerService (db, issue #31)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let scheduler: SchedulerService;
const jobName = `test-job-${uniqueSuffix()}`;
beforeAll(async () => {
prisma = createTestPrisma();
app = await createTestApp();
scheduler = app.get(SchedulerService);
});
afterAll(async () => {
await prisma.job.deleteMany({ where: { name: { startsWith: 'test-job-' } } });
await prisma.$disconnect();
await app.close();
});
it('never runs the same job twice concurrently (locking)', async () => {
let runs = 0;
const job: JobDefinition = {
name: jobName,
cadenceSeconds: 3600,
run: async () => {
runs += 1;
await new Promise((resolve) => setTimeout(resolve, 150));
},
};
// Two overlapping ticks racing the same due job — only one may win.
await Promise.all([scheduler.runIfDue(job), scheduler.runIfDue(job)]);
expect(runs).toBe(1);
const row = await prisma.job.findUniqueOrThrow({ where: { name: jobName } });
expect(row.status).toBe('IDLE');
});
it('persists lastRunAt so a fresh scheduler instance respects cadence (restart survival)', async () => {
const job: JobDefinition = { name: jobName, cadenceSeconds: 3600, run: async () => {} };
const before = await prisma.job.findUniqueOrThrow({ where: { name: jobName } });
// A brand-new NestJS application = a brand-new SchedulerService with
// empty in-memory state, exactly like a real process restart. It must
// still treat the job as "not due yet" because that lives in the DB.
const restarted = await createTestApp();
try {
const freshScheduler = restarted.get(SchedulerService);
await freshScheduler.runIfDue(job);
const after = await prisma.job.findUniqueOrThrow({ where: { name: jobName } });
expect(after.lastRunAt?.getTime()).toBe(before.lastRunAt?.getTime());
} finally {
await restarted.close();
}
});
it('recovers a stale RUNNING lock instead of blocking forever', async () => {
const staleName = `test-job-stale-${uniqueSuffix()}`;
await prisma.job.create({
data: {
name: staleName,
cadenceSeconds: 60,
status: 'RUNNING',
lockedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2h ago
lastRunAt: new Date(Date.now() - 2 * 60 * 60 * 1000),
},
});
let ran = false;
await scheduler.runIfDue({
name: staleName,
cadenceSeconds: 60,
run: async () => {
ran = true;
},
});
expect(ran).toBe(true);
});
});