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
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
86 lines
2.9 KiB
TypeScript
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);
|
|
});
|
|
});
|