import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import fs from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; import { getPrismaClient, closePrismaClient } from '../src/db.js'; import { loadConfig } from '../src/config.js'; import { ensureStorageDirs, uploadPath, outputPath } from '../src/storage.js'; import { createJob, markDone, getJobByUuid } from '../src/jobs/jobRepository.js'; import { runCleanup } from '../src/cleanup.js'; let prisma; let config; beforeAll(async () => { config = { ...loadConfig(), storageDir: await fs.mkdtemp(path.join(os.tmpdir(), 'converter-cleanup-')) }; await ensureStorageDirs(config); prisma = getPrismaClient(config); }); afterAll(async () => { await closePrismaClient(); await fs.rm(config.storageDir, { recursive: true, force: true }); }); beforeEach(async () => { await prisma.conversionJob.deleteMany(); }); describe('runCleanup', () => { it('marks an expired done job cleaned and removes its input and output files, keeping the row', async () => { const uuid = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; await fs.writeFile(uploadPath(config, uuid, 'png'), 'input bytes'); await fs.writeFile(outputPath(config, uuid, 'webp'), 'output bytes'); await createJob(prisma, { uuid, family: 'image', sourceFormat: 'png', targetFormat: 'webp', originalFilename: 'photo.png', inputPath: `${uuid}.png`, inputMimeType: 'image/png', inputSizeBytes: 11, expiresAt: new Date(Date.now() - 1000), }); const created = await getJobByUuid(prisma, uuid); await markDone(prisma, created.id, { outputPath: `${uuid}.webp`, outputMimeType: 'image/webp', outputSizeBytes: 12, conversionDurationSeconds: 0.5, }); const cleanedCount = await runCleanup(prisma, config); expect(cleanedCount).toBe(1); const job = await getJobByUuid(prisma, uuid); expect(job).not.toBeNull(); expect(job.cleanedAt).not.toBeNull(); await expect(fs.stat(uploadPath(config, uuid, 'png'))).rejects.toThrow(); await expect(fs.stat(outputPath(config, uuid, 'webp'))).rejects.toThrow(); }); it('marks an expired pending job (with no output file) cleaned without throwing', async () => { const uuid = 'ffffffff-ffff-4fff-8fff-ffffffffffff'; await fs.writeFile(uploadPath(config, uuid, 'png'), 'input bytes'); await createJob(prisma, { uuid, family: 'image', sourceFormat: 'png', targetFormat: 'webp', originalFilename: 'photo.png', inputPath: `${uuid}.png`, inputMimeType: 'image/png', inputSizeBytes: 11, expiresAt: new Date(Date.now() - 1000), }); const cleanedCount = await runCleanup(prisma, config); expect(cleanedCount).toBe(1); const job = await getJobByUuid(prisma, uuid); expect(job).not.toBeNull(); expect(job.cleanedAt).not.toBeNull(); }); it('leaves non-expired jobs untouched', async () => { const uuid = '12121212-1212-4212-8212-121212121212'; await createJob(prisma, { uuid, family: 'image', sourceFormat: 'png', targetFormat: 'webp', originalFilename: 'photo.png', inputPath: `${uuid}.png`, inputMimeType: 'image/png', inputSizeBytes: 11, expiresAt: new Date(Date.now() + 3600 * 1000), }); const cleanedCount = await runCleanup(prisma, config); expect(cleanedCount).toBe(0); const job = await getJobByUuid(prisma, uuid); expect(job).not.toBeNull(); expect(job.cleanedAt).toBeNull(); }); it('isolates one job’s failure so later expired jobs in the same batch still get cleaned', async () => { const failingUuid = '14141414-1414-4414-8414-141414141414'; const okUuid = '15151515-1515-4515-8515-151515151515'; await createJob(prisma, { uuid: failingUuid, family: 'image', sourceFormat: 'png', targetFormat: 'webp', originalFilename: 'photo.png', inputPath: `${failingUuid}.png`, inputMimeType: 'image/png', inputSizeBytes: 11, expiresAt: new Date(Date.now() - 1000), }); await createJob(prisma, { uuid: okUuid, family: 'image', sourceFormat: 'png', targetFormat: 'webp', originalFilename: 'photo.png', inputPath: `${okUuid}.png`, inputMimeType: 'image/png', inputSizeBytes: 11, expiresAt: new Date(Date.now() - 1000), }); const failingJob = await getJobByUuid(prisma, failingUuid); const originalUpdate = prisma.conversionJob.update.bind(prisma.conversionJob); prisma.conversionJob.update = (args) => { if (args.where?.id === failingJob.id) { return Promise.reject(new Error('simulated markCleaned failure')); } return originalUpdate(args); }; let cleanedCount; try { cleanedCount = await runCleanup(prisma, config); } finally { prisma.conversionJob.update = originalUpdate; } expect(cleanedCount).toBe(1); const failing = await getJobByUuid(prisma, failingUuid); expect(failing.cleanedAt).toBeNull(); const ok = await getJobByUuid(prisma, okUuid); expect(ok.cleanedAt).not.toBeNull(); }); it('does not re-process an already cleaned expired job', async () => { const uuid = '13131313-1313-4313-8313-131313131313'; await createJob(prisma, { uuid, family: 'image', sourceFormat: 'png', targetFormat: 'webp', originalFilename: 'photo.png', inputPath: `${uuid}.png`, inputMimeType: 'image/png', inputSizeBytes: 11, expiresAt: new Date(Date.now() - 1000), }); const firstRun = await runCleanup(prisma, config); const secondRun = await runCleanup(prisma, config); expect(firstRun).toBe(1); expect(secondRun).toBe(0); }); });