dorfteich/apps/api/src/import-export/conversion-payload-prune.e2e.db.test.ts
Claude Fable 5 ff505bc752
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 5m12s
CI / Build container images (pull_request) Successful in 3m28s
CI / Auth e2e pack (pull_request) Successful in 8m33s
CI / Import/export fidelity gate (pull_request) Successful in 1m2s
CD / Build and push images (push) Successful in 29s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m22s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Failing after 5m9s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
#233: prune conversion job payloads for every job kind
The raw input/result bytes of import/export conversion jobs were kept
forever; a deleted classified page could live on inside its last export.
A new daily conversion-payload-prune job nulls both once a finished
(succeeded or failed) job passes conversion.payloadRetentionDays
(instance setting, default 30) — the row survives for status/audit.
PENDING and RUNNING rows keep their payload, so the worker's stale-lock
recovery path is untouched; a hand-requeued pruned job fails finally
via conversionInputOf instead of crashing the worker.

The input column becomes nullable; the migration backfills by clearing
payloads of jobs already finished longer ago than the default period
(recent results stay downloadable until they age out).

Job-count fence in system.spec: 7 -> 8 (new scheduler registration).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 22:12:32 +02:00

121 lines
4.4 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 { UsersService } from '../users/users.service';
import { ConversionJobService } from './conversion-job.service';
const DAY = 24 * 60 * 60 * 1000;
/**
* Conversion payload retention (issue #233): finished jobs past
* `conversion.payloadRetentionDays` lose their raw input/result bytes while
* the row survives for status; pending and stale-RUNNING rows (the worker's
* lock-recovery path) keep their payload untouched.
*/
describe.skipIf(!hasTestDb)('conversion payload prune (e2e, issue #233)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let ownerId: string;
const suffix = uniqueSuffix();
const bytes = () => new Uint8Array(Buffer.from(`payload-${suffix}`));
async function jobRow(
status: 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED',
ageDays: number,
lockedAt: Date | null = null,
) {
return prisma.conversionJob.create({
data: {
ownerId,
kind: `test_prune_${suffix}`,
sourceFormat: 'markdown',
targetFormat: 'html',
input: bytes(),
status,
result: status === 'SUCCEEDED' ? bytes() : null,
resultMimeType: status === 'SUCCEEDED' ? 'text/html' : null,
errorCode: status === 'FAILED' ? 'conversion_failed' : null,
lockedAt,
updatedAt: new Date(Date.now() - ageDays * DAY),
},
});
}
beforeAll(async () => {
prisma = createTestPrisma();
// A short period so ages are unambiguous; written straight to the row
// BEFORE the app boots (the settings cache is in-process and fills on
// first read). The key is cleaned afterAll.
await prisma.instanceSetting.upsert({
where: { key: 'conversion.payloadRetentionDays' },
create: { key: 'conversion.payloadRetentionDays', value: 10 },
update: { value: 10 },
});
app = await createTestApp();
const user = await app.get(UsersService).createUser({
username: `pia-prune-${suffix}`,
email: `pia-prune-${suffix}@example.org`,
displayName: `Pia Prune ${suffix}`,
password: 'bytes verschwinden fristgerecht 1',
locale: 'en',
});
ownerId = user.id;
});
afterAll(async () => {
await prisma.instanceSetting.deleteMany({
where: { key: 'conversion.payloadRetentionDays' },
});
await prisma.conversionJob.deleteMany({ where: { kind: `test_prune_${suffix}` } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
});
it('prunes finished jobs past the period, keeps everything else', async () => {
const oldSucceeded = await jobRow('SUCCEEDED', 15);
const oldFailed = await jobRow('FAILED', 15);
const freshSucceeded = await jobRow('SUCCEEDED', 5);
const oldPending = await jobRow('PENDING', 15);
// A crashed run the worker's stale-lock recovery will pick up again —
// its input must survive or the retry would fail (#233 acceptance).
const oldStaleRunning = await jobRow('RUNNING', 15, new Date(Date.now() - 15 * DAY));
// >=: the shared suite database may hold other files' aged rows.
const pruned = await app.get(ConversionJobService).pruneExpiredPayloads();
expect(pruned).toBeGreaterThanOrEqual(2);
const byId = new Map(
(await prisma.conversionJob.findMany({ where: { kind: `test_prune_${suffix}` } })).map(
(job) => [job.id, job],
),
);
// The finished rows survive with status and error code, only bytes-free.
expect(byId.get(oldSucceeded.id)).toMatchObject({
status: 'SUCCEEDED',
input: null,
result: null,
resultMimeType: null,
});
expect(byId.get(oldFailed.id)).toMatchObject({
status: 'FAILED',
errorCode: 'conversion_failed',
input: null,
result: null,
});
expect(byId.get(freshSucceeded.id)!.input).not.toBeNull();
expect(byId.get(freshSucceeded.id)!.result).not.toBeNull();
expect(byId.get(oldPending.id)!.input).not.toBeNull();
expect(byId.get(oldStaleRunning.id)!.input).not.toBeNull();
});
it('is a no-op when nothing is due', async () => {
expect(await app.get(ConversionJobService).pruneExpiredPayloads()).toBe(0);
});
});