runCleanup previously had no try/catch around each expired job, so one job's markCleaned/delete failure would abort the whole pass, leaving later expired jobs' files undeleted for that run. Each iteration is now wrapped in a try/catch that logs and continues; the returned count only reflects jobs that actually completed the delete+markCleaned sequence. markDone now guards outputPath/outputMimeType/outputSizeBytes/ conversionDurationSeconds with `?? null`, matching the guard createJob already has on `quality` — Prisma treats `undefined` in a data object as "leave the column alone" rather than binding NULL like the old raw SQL did. Currently unreachable in practice since the worker always passes real values, but keeps the repository defensive and consistent. Added a cleanup.test.js case that monkey-patches prisma.conversionJob.update to reject for one job's cleanedAt update, asserting a later expired job in the same batch still gets cleaned. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
178 lines
5.7 KiB
JavaScript
178 lines
5.7 KiB
JavaScript
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);
|
||
});
|
||
});
|