import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import fs from 'node:fs/promises'; import { writeFileSync } from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import sharp from 'sharp'; import { getPrismaClient, closePrismaClient } from '../src/db.js'; import { loadConfig } from '../src/config.js'; import { ensureStorageDirs, uploadPath, outputPath } from '../src/storage.js'; import { createJob, getJobByUuid, getJobErrorLog } from '../src/jobs/jobRepository.js'; import { registerImageConverters } from '../src/converters/image.js'; import { registerIcoConverter } from '../src/converters/ico.js'; import { registerEbookConverter } from '../src/converters/ebook.js'; import { processPendingJobs } from '../src/worker.js'; const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })); vi.mock('node:child_process', () => ({ execFile: execFileMock })); let prisma; let config; beforeAll(async () => { registerImageConverters(); registerIcoConverter(); registerEbookConverter(); config = { ...loadConfig(), storageDir: await fs.mkdtemp(path.join(os.tmpdir(), 'converter-worker-')) }; 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(); }); async function createPendingImageJob(uuid, sourceFormat, targetFormat) { const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.png'); const inputFilePath = uploadPath(config, uuid, sourceFormat); await fs.copyFile(fixturePath, inputFilePath); const { size: inputSizeBytes } = await fs.stat(inputFilePath); await createJob(prisma, { uuid, family: 'image', sourceFormat, targetFormat, originalFilename: `photo.${sourceFormat}`, inputPath: `${uuid}.${sourceFormat}`, inputMimeType: 'image/png', inputSizeBytes, expiresAt: new Date(Date.now() + 3600 * 1000), }); } describe('processPendingJobs', () => { it('converts a pending image job to done', async () => { const uuid = '99999999-9999-4999-8999-999999999999'; await createPendingImageJob(uuid, 'png', 'webp'); const processedCount = await processPendingJobs(prisma, config); expect(processedCount).toBe(1); const job = await getJobByUuid(prisma, uuid); expect(job.status).toBe('done'); expect(job.outputPath).toBe(`${uuid}.webp`); expect(job.outputMimeType).toBe('image/webp'); expect(job.outputSizeBytes).toBeGreaterThan(0); expect(Number(job.conversionDurationSeconds)).toBeGreaterThanOrEqual(0); const stat = await fs.stat(outputPath(config, uuid, 'webp')); expect(stat.size).toBeGreaterThan(0); }); it('passes the job quality through to the converter, shrinking output for a low quality value', async () => { const lowUuid = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; const defaultUuid = 'ffffffff-ffff-4fff-8fff-ffffffffffff'; const noisyBuffer = await sharp({ create: { width: 256, height: 256, channels: 3, noise: { type: 'gaussian', mean: 128, sigma: 40 }, }, }) .png() .toBuffer(); for (const uuid of [lowUuid, defaultUuid]) { await fs.writeFile(uploadPath(config, uuid, 'png'), noisyBuffer); } await createJob(prisma, { uuid: lowUuid, family: 'image', sourceFormat: 'png', targetFormat: 'jpg', originalFilename: 'noisy.png', inputPath: `${lowUuid}.png`, inputMimeType: 'image/png', inputSizeBytes: noisyBuffer.length, expiresAt: new Date(Date.now() + 3600 * 1000), quality: 5, }); await createJob(prisma, { uuid: defaultUuid, family: 'image', sourceFormat: 'png', targetFormat: 'jpg', originalFilename: 'noisy.png', inputPath: `${defaultUuid}.png`, inputMimeType: 'image/png', inputSizeBytes: noisyBuffer.length, expiresAt: new Date(Date.now() + 3600 * 1000), }); await processPendingJobs(prisma, { ...config, workerConcurrency: 2 }); const lowJob = await getJobByUuid(prisma, lowUuid); const defaultJob = await getJobByUuid(prisma, defaultUuid); expect(lowJob.status).toBe('done'); expect(defaultJob.status).toBe('done'); expect(lowJob.outputSizeBytes).toBeLessThan(defaultJob.outputSizeBytes); }); it('marks a job failed with a safe message and a detailed log when the converter throws', async () => { const uuid = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; await createJob(prisma, { uuid, family: 'image', sourceFormat: 'png', targetFormat: 'webp', originalFilename: 'missing.png', inputPath: `${uuid}.png`, inputMimeType: 'image/png', inputSizeBytes: 11, expiresAt: new Date(Date.now() + 3600 * 1000), }); // Note: input file is intentionally never written, so sharp will throw ENOENT. await processPendingJobs(prisma, config); const job = await getJobByUuid(prisma, uuid); expect(job.status).toBe('failed'); expect(job.errorMessage).toBe('Conversion failed, please try again.'); const errorLog = await getJobErrorLog(prisma, job.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(prisma, limitedConfig); expect(processedCount).toBe(2); }); it('processes a png -> ico job honoring iconSize', async () => { const { decodeIco } = await import('icojs'); const uuid = 'bbbbbbbb-1111-4bbb-8bbb-bbbbbbbbbbb1'; const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.png'); const inputFilePath = uploadPath(config, uuid, 'png'); await fs.copyFile(fixturePath, inputFilePath); const { size: inputSizeBytes } = await fs.stat(inputFilePath); await createJob(prisma, { uuid, family: 'image', sourceFormat: 'png', targetFormat: 'ico', originalFilename: 'photo.png', inputPath: `${uuid}.png`, inputMimeType: 'image/png', inputSizeBytes, expiresAt: new Date(Date.now() + 3600 * 1000), iconSize: 32, }); await processPendingJobs(prisma, config); const job = await getJobByUuid(prisma, uuid); expect(job.status).toBe('done'); const outputFilePath = outputPath(config, uuid, 'ico'); const [image] = await decodeIco(await fs.readFile(outputFilePath), 'image/png'); expect(image.width).toBe(32); }); it('converts a pending ebook job to done using config.ebookJobTimeoutMs, not the default 60s timeout', async () => { execFileMock.mockReset(); execFileMock.mockImplementation((file, args, options, callback) => { writeFileSync(args[1], 'fake converted ebook output'); callback(null, '', ''); }); const uuid = '11111111-1111-4111-8111-111111111111'; const inputFilePath = uploadPath(config, uuid, 'epub'); await fs.writeFile(inputFilePath, 'fake epub content'); await createJob(prisma, { uuid, family: 'ebook', sourceFormat: 'epub', targetFormat: 'pdf', originalFilename: 'book.epub', inputPath: `${uuid}.epub`, inputMimeType: 'application/epub+zip', inputSizeBytes: 18, expiresAt: new Date(Date.now() + 3600 * 1000), }); await processPendingJobs(prisma, config); const job = await getJobByUuid(prisma, uuid); expect(job.status).toBe('done'); const [file, args, options] = execFileMock.mock.calls[0]; expect(file).toBe('ebook-convert'); expect(args).toEqual([inputFilePath, outputPath(config, uuid, 'pdf')]); expect(options).toEqual({ timeout: config.ebookJobTimeoutMs }); expect(config.ebookJobTimeoutMs).not.toBe(60000); }); });