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); }); });