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 { getPool, closePool } from '../src/db.js'; import { loadConfig } from '../src/config.js'; import { ensureStorageDirs, uploadPath, outputPath } from '../src/storage.js'; import { createJob, getJobById, getJobErrorLog } from '../src/jobs/jobRepository.js'; import { registerImageConverters } from '../src/converters/image.js'; import { processPendingJobs } from '../src/worker.js'; let pool; let config; beforeAll(async () => { registerImageConverters(); config = { ...loadConfig(), storageDir: await fs.mkdtemp(path.join(os.tmpdir(), 'converter-worker-')) }; await ensureStorageDirs(config); pool = getPool(config); }); afterAll(async () => { await closePool(); await fs.rm(config.storageDir, { recursive: true, force: true }); }); beforeEach(async () => { await pool.query('DELETE FROM conversion_jobs'); }); async function createPendingImageJob(id, sourceFormat, targetFormat) { const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.png'); const inputFilePath = uploadPath(config, id, sourceFormat); await fs.copyFile(fixturePath, inputFilePath); await createJob(pool, { id, family: 'image', sourceFormat, targetFormat, originalFilename: `photo.${sourceFormat}`, inputPath: `${id}.${sourceFormat}`, inputMimeType: 'image/png', expiresAt: new Date(Date.now() + 3600 * 1000), }); } describe('processPendingJobs', () => { it('converts a pending image job to done', async () => { const id = '99999999-9999-4999-8999-999999999999'; await createPendingImageJob(id, 'png', 'webp'); const processedCount = await processPendingJobs(pool, config); expect(processedCount).toBe(1); const job = await getJobById(pool, id); expect(job.status).toBe('done'); expect(job.outputPath).toBe(`${id}.webp`); expect(job.outputMimeType).toBe('image/webp'); const stat = await fs.stat(outputPath(config, id, 'webp')); expect(stat.size).toBeGreaterThan(0); }); it('marks a job failed with a safe message and a detailed log when the converter throws', async () => { const id = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; await createJob(pool, { id, family: 'image', sourceFormat: 'png', targetFormat: 'webp', originalFilename: 'missing.png', inputPath: `${id}.png`, inputMimeType: 'image/png', expiresAt: new Date(Date.now() + 3600 * 1000), }); // Note: input file is intentionally never written, so sharp will throw ENOENT. await processPendingJobs(pool, config); const job = await getJobById(pool, id); expect(job.status).toBe('failed'); expect(job.errorMessage).toBe('Conversion failed, please try again.'); const errorLog = await getJobErrorLog(pool, id); expect(errorLog).toMatch(/input file is missing/i); }); it('only picks up as many jobs as workerConcurrency allows', async () => { await createPendingImageJob('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'png', 'webp'); await createPendingImageJob('cccccccc-cccc-4ccc-8ccc-cccccccccccc', 'png', 'webp'); await createPendingImageJob('dddddddd-dddd-4ddd-8ddd-dddddddddddd', 'png', 'webp'); const limitedConfig = { ...config, workerConcurrency: 2 }; const processedCount = await processPendingJobs(pool, limitedConfig); expect(processedCount).toBe(2); }); });