import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import request from 'supertest'; import fs from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; import { createApp } from '../../src/app.js'; import { getPrismaClient, closePrismaClient } from '../../src/db.js'; import { loadConfig } from '../../src/config.js'; import { getJobByUuid } from '../../src/jobs/jobRepository.js'; import { ensureStorageDirs } from '../../src/storage.js'; import AdmZip from 'adm-zip'; let app; let prisma; let config; beforeAll(async () => { config = { ...loadConfig(), storageDir: await fs.mkdtemp(path.join(os.tmpdir(), 'converter-api-')), // This file's cumulative POST /api/jobs calls across all tests exceeds the // production-tuned default (20/window) once the archive tests are included. rateLimitMaxJobs: 1000, }; await ensureStorageDirs(config); prisma = getPrismaClient(config); app = createApp(config, prisma); }); afterAll(async () => { await closePrismaClient(); await fs.rm(config.storageDir, { recursive: true, force: true }); }); beforeEach(async () => { await prisma.conversionJob.deleteMany(); }); describe('GET /api/formats', () => { it('lists valid target formats for a known source format', async () => { const response = await request(app).get('/api/formats').query({ source: 'png' }); expect(response.status).toBe(200); expect(response.body.targets).toContain('webp'); expect(response.body.targets).toContain('pdf'); }); it('returns an empty list for an unknown source format', async () => { const response = await request(app).get('/api/formats').query({ source: 'made-up' }); expect(response.status).toBe(200); expect(response.body.targets).toEqual([]); }); it('lists ico as a target for png, and does not list heic/heif as a target for anything', async () => { const icoTargets = await request(app).get('/api/formats').query({ source: 'png' }); expect(icoTargets.body.targets).toContain('ico'); const heicTargets = await request(app).get('/api/formats').query({ source: 'heic' }); expect(heicTargets.body.targets).toContain('png'); const pngTargets = await request(app).get('/api/formats').query({ source: 'png' }); expect(pngTargets.body.targets).not.toContain('heic'); expect(pngTargets.body.targets).not.toContain('heif'); }); }); describe('GET /api/formats — ebooks', () => { it('lists the other 9 ebook formats as targets for epub, and never lists epub as its own target', async () => { const response = await request(app).get('/api/formats').query({ source: 'epub' }); expect(response.body.targets).toEqual( expect.arrayContaining(['fb2', 'lrf', 'mobi', 'pdb', 'rb', 'snb', 'tcr', 'azw3', 'pdf']) ); expect(response.body.targets).not.toContain('epub'); }); }); describe('POST /api/jobs — ebooks', () => { it('creates a pending job converting an fb2 upload to epub, accepting it despite file-type sniffing its XML declaration as generic "xml" rather than "fb2"', async () => { const fixturePath = path.join(config.storageDir, 'book.fb2'); await fs.writeFile( fixturePath, '

hello

' ); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['epub'])) .attach('files', fixturePath, 'book.fb2'); expect(response.status).toBe(201); expect(response.body.jobs[0].status).toBe('pending'); const job = await getJobByUuid(prisma, response.body.jobs[0].id); expect(job.sourceFormat).toBe('fb2'); expect(job.targetFormat).toBe('epub'); expect(job.family).toBe('ebook'); expect(job.inputMimeType).toBe('application/xml'); await fs.unlink(fixturePath); }); }); describe('POST /api/jobs', () => { it('creates a pending job for a valid image upload', async () => { const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['webp'])) .attach('files', fixturePath, 'photo.png'); expect(response.status).toBe(201); expect(response.body.jobs).toHaveLength(1); expect(response.body.jobs[0].status).toBe('pending'); expect(response.body.jobs[0].file).toBe('photo.png'); const job = await getJobByUuid(prisma, response.body.jobs[0].id); expect(job.status).toBe('pending'); expect(job.sourceFormat).toBe('png'); expect(job.targetFormat).toBe('webp'); expect(job.originalFilename).toBe('photo.png'); expect(job.inputMimeType).toBe('image/png'); expect(job.inputSizeBytes).toBeGreaterThan(0); expect(job.quality).toBeNull(); }); it('creates a pending job with a quality level for a supported format', async () => { const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['jpg'])) .field('qualities', JSON.stringify([45])) .attach('files', fixturePath, 'photo.png'); expect(response.status).toBe(201); expect(response.body.jobs[0].status).toBe('pending'); const job = await getJobByUuid(prisma, response.body.jobs[0].id); expect(job.quality).toBe(45); }); it('rejects an out-of-range quality without failing the whole batch', async () => { const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['jpg'])) .field('qualities', JSON.stringify([500])) .attach('files', fixturePath, 'photo.png'); expect(response.status).toBe(201); expect(response.body.jobs[0].error).toMatch(/Invalid quality/); }); it('rejects a quality value for a format that does not support one', async () => { const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['gif'])) .field('qualities', JSON.stringify([50])) .attach('files', fixturePath, 'photo.png'); expect(response.status).toBe(201); expect(response.body.jobs[0].error).toMatch(/Invalid quality/); }); it('creates a pending job with an iconSize for an ico target', async () => { const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['ico'])) .field('iconSizes', JSON.stringify([48])) .attach('files', fixturePath, 'photo.png'); expect(response.status).toBe(201); expect(response.body.jobs[0].status).toBe('pending'); const job = await getJobByUuid(prisma, response.body.jobs[0].id); expect(job.iconSize).toBe(48); }); it('rejects an iconSize outside the fixed set', async () => { const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['ico'])) .field('iconSizes', JSON.stringify([100])) .attach('files', fixturePath, 'photo.png'); expect(response.status).toBe(201); expect(response.body.jobs[0].error).toMatch(/Invalid iconSize/); }); it('rejects an iconSize for a non-ico target', async () => { const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['webp'])) .field('iconSizes', JSON.stringify([48])) .attach('files', fixturePath, 'photo.png'); expect(response.status).toBe(201); expect(response.body.jobs[0].error).toMatch(/Invalid iconSize/); }); it('rejects a quality value for an ico target', async () => { const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['ico'])) .field('qualities', JSON.stringify([50])) .attach('files', fixturePath, 'photo.png'); expect(response.status).toBe(201); expect(response.body.jobs[0].error).toMatch(/Invalid quality/); }); it('creates a pending job converting an OTF upload to ttf', async () => { const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.otf'); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['ttf'])) .attach('files', fixturePath, 'font.otf'); expect(response.status).toBe(201); expect(response.body.jobs[0].status).toBe('pending'); const job = await getJobByUuid(prisma, response.body.jobs[0].id); expect(job.sourceFormat).toBe('otf'); expect(job.targetFormat).toBe('ttf'); expect(job.family).toBe('font'); expect(job.inputMimeType).toBe('font/otf'); }); it('creates a pending job converting a dfont upload despite file-type misidentifying its content as ico', async () => { const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.dfont'); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['ttf'])) .attach('files', fixturePath, 'font.dfont'); expect(response.status).toBe(201); expect(response.body.jobs[0].status).toBe('pending'); const job = await getJobByUuid(prisma, response.body.jobs[0].id); expect(job.sourceFormat).toBe('dfont'); expect(job.inputMimeType).toBe('application/x-dfont'); }); it('lists ttf/otf/woff as targets for each other via GET /api/formats, and never lists dfont as a target', async () => { const ttfTargets = await request(app).get('/api/formats').query({ source: 'ttf' }); expect(ttfTargets.body.targets).toEqual(expect.arrayContaining(['otf', 'woff'])); const otfTargets = await request(app).get('/api/formats').query({ source: 'otf' }); expect(otfTargets.body.targets).not.toContain('dfont'); }); it('creates a pending job converting a HEIC upload to jpg', async () => { const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.heic'); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['jpg'])) .attach('files', fixturePath, 'photo.heic'); expect(response.status).toBe(201); expect(response.body.jobs[0].status).toBe('pending'); const job = await getJobByUuid(prisma, response.body.jobs[0].id); expect(job.sourceFormat).toBe('heic'); }); it('creates a pending job converting a HEIF-declared upload to png', async () => { const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.heif'); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['png'])) .attach('files', fixturePath, 'photo.heif'); expect(response.status).toBe(201); expect(response.body.jobs[0].status).toBe('pending'); }); it('returns 400 when qualities length does not match the number of files', async () => { const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['jpg'])) .field('qualities', JSON.stringify([10, 20])) .attach('files', fixturePath, 'photo.png'); expect(response.status).toBe(400); }); it('rejects a file whose content does not match its extension, without failing the whole batch', async () => { const fakePath = path.join(config.storageDir, 'fake.png'); await fs.writeFile(fakePath, 'not actually a png'); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['webp'])) .attach('files', fakePath, 'fake.png'); expect(response.status).toBe(201); expect(response.body.jobs[0].error).toMatch(/does not match/); await fs.unlink(fakePath); }); it('rejects an unsupported source/target pair', async () => { const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['made-up-format'])) .attach('files', fixturePath, 'photo.png'); expect(response.status).toBe(201); expect(response.body.jobs[0].error).toMatch(/Unsupported conversion/); }); it('returns 400 when targetFormats length does not match the number of files', async () => { const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify([])) .attach('files', fixturePath, 'photo.png'); expect(response.status).toBe(400); }); it('returns 400 when no files are uploaded', async () => { const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify([])); expect(response.status).toBe(400); }); }); function buildZipFixture(destPath) { const zip = new AdmZip(); zip.addFile('hello.txt', Buffer.from('hello world')); zip.writeZip(destPath); } describe('POST /api/jobs — archives', () => { it('creates a pending job converting a zip upload to tar.gz', async () => { const fixturePath = path.join(config.storageDir, 'archive.zip'); buildZipFixture(fixturePath); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['tar.gz'])) .attach('files', fixturePath, 'archive.zip'); expect(response.status).toBe(201); expect(response.body.jobs[0].status).toBe('pending'); const job = await getJobByUuid(prisma, response.body.jobs[0].id); expect(job.sourceFormat).toBe('zip'); expect(job.targetFormat).toBe('tar.gz'); expect(job.family).toBe('archive'); await fs.unlink(fixturePath); }); it('creates a pending job with a compression level for a same-format zip -> zip conversion', async () => { const fixturePath = path.join(config.storageDir, 'recompress.zip'); buildZipFixture(fixturePath); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['zip'])) .field('qualities', JSON.stringify([9])) .attach('files', fixturePath, 'recompress.zip'); expect(response.status).toBe(201); const job = await getJobByUuid(prisma, response.body.jobs[0].id); expect(job.targetFormat).toBe('zip'); expect(job.quality).toBe(9); await fs.unlink(fixturePath); }); it('rejects an out-of-range compression level for a zip target', async () => { const fixturePath = path.join(config.storageDir, 'bad-level.zip'); buildZipFixture(fixturePath); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['zip'])) .field('qualities', JSON.stringify([15])) .attach('files', fixturePath, 'bad-level.zip'); expect(response.status).toBe(201); expect(response.body.jobs[0].error).toMatch(/Invalid quality/); await fs.unlink(fixturePath); }); it('rejects a compression level for a tar target (no compression to control)', async () => { const fixturePath = path.join(config.storageDir, 'for-tar.zip'); buildZipFixture(fixturePath); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['tar'])) .field('qualities', JSON.stringify([5])) .attach('files', fixturePath, 'for-tar.zip'); expect(response.status).toBe(201); expect(response.body.jobs[0].error).toMatch(/Invalid quality/); await fs.unlink(fixturePath); }); it('parses a compound .tar.gz original filename as a single source format, not "gz"', async () => { const zlib = await import('node:zlib'); const fixturePath = path.join(config.storageDir, 'backup.tar.gz'); await fs.writeFile(fixturePath, zlib.gzipSync(Buffer.from('irrelevant payload for this check'))); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['zip'])) .attach('files', fixturePath, 'backup.tar.gz'); expect(response.status).toBe(201); const job = await getJobByUuid(prisma, response.body.jobs[0].id); expect(job.sourceFormat).toBe('tar.gz'); await fs.unlink(fixturePath); }); }); describe('POST /api/jobs — audio', () => { it('creates a pending job converting a wav upload to mp3 with a valid bitrate', async () => { const fixturePath = path.join(config.storageDir, 'clip.wav'); // Minimal RIFF/WAVE header: 'RIFF' + 4-byte size (unchecked) + 'WAVE'. await fs.writeFile(fixturePath, Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE')])); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['mp3'])) .field('qualities', JSON.stringify([192])) .attach('files', fixturePath, 'clip.wav'); expect(response.status).toBe(201); expect(response.body.jobs[0].status).toBe('pending'); const job = await getJobByUuid(prisma, response.body.jobs[0].id); expect(job.sourceFormat).toBe('wav'); expect(job.targetFormat).toBe('mp3'); expect(job.family).toBe('audio'); expect(job.quality).toBe(192); await fs.unlink(fixturePath); }); it('rejects a bitrate outside the fixed chip set for an mp3 target', async () => { const fixturePath = path.join(config.storageDir, 'bad-bitrate.wav'); await fs.writeFile(fixturePath, Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE')])); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['mp3'])) .field('qualities', JSON.stringify([200])) .attach('files', fixturePath, 'bad-bitrate.wav'); expect(response.status).toBe(201); expect(response.body.jobs[0].error).toMatch(/Invalid quality/); await fs.unlink(fixturePath); }); it('rejects a quality value for a flac target (lossless, no bitrate control)', async () => { const fixturePath = path.join(config.storageDir, 'to-flac.wav'); await fs.writeFile(fixturePath, Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE')])); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['flac'])) .field('qualities', JSON.stringify([192])) .attach('files', fixturePath, 'to-flac.wav'); expect(response.status).toBe(201); expect(response.body.jobs[0].error).toMatch(/Invalid quality/); await fs.unlink(fixturePath); }); it('creates a pending job converting flac to wav with no quality value', async () => { const fixturePath = path.join(config.storageDir, 'clip.flac'); await fs.writeFile(fixturePath, Buffer.from('fLaC')); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['wav'])) .attach('files', fixturePath, 'clip.flac'); expect(response.status).toBe(201); const job = await getJobByUuid(prisma, response.body.jobs[0].id); expect(job.sourceFormat).toBe('flac'); expect(job.targetFormat).toBe('wav'); expect(job.quality).toBeNull(); await fs.unlink(fixturePath); }); it('lists the other 5 audio formats as targets for mp3, and never lists mp3 as its own target', async () => { const response = await request(app).get('/api/formats').query({ source: 'mp3' }); expect(response.body.targets).toEqual(expect.arrayContaining(['wav', 'ogg', 'flac', 'aac', 'm4a'])); expect(response.body.targets).not.toContain('mp3'); }); }); describe('POST /api/jobs — video', () => { it('creates a pending job converting an mp4 upload to webm with a valid resolution', async () => { const fixturePath = path.join(config.storageDir, 'clip.mp4'); await fs.writeFile( fixturePath, Buffer.concat([Buffer.from([0, 0, 0, 0x18]), Buffer.from('ftyp'), Buffer.from('isom')]) ); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['webm'])) .field('qualities', JSON.stringify([720])) .attach('files', fixturePath, 'clip.mp4'); expect(response.status).toBe(201); expect(response.body.jobs[0].status).toBe('pending'); const job = await getJobByUuid(prisma, response.body.jobs[0].id); expect(job.sourceFormat).toBe('mp4'); expect(job.targetFormat).toBe('webm'); expect(job.family).toBe('video'); expect(job.quality).toBe(720); await fs.unlink(fixturePath); }); it('creates a pending job with no quality value (original resolution)', async () => { const fixturePath = path.join(config.storageDir, 'clip-original.mp4'); await fs.writeFile( fixturePath, Buffer.concat([Buffer.from([0, 0, 0, 0x18]), Buffer.from('ftyp'), Buffer.from('isom')]) ); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['mkv'])) .attach('files', fixturePath, 'clip-original.mp4'); expect(response.status).toBe(201); const job = await getJobByUuid(prisma, response.body.jobs[0].id); expect(job.quality).toBeNull(); await fs.unlink(fixturePath); }); it('rejects a resolution outside the fixed 480/720/1080 set', async () => { const fixturePath = path.join(config.storageDir, 'bad-resolution.mp4'); await fs.writeFile( fixturePath, Buffer.concat([Buffer.from([0, 0, 0, 0x18]), Buffer.from('ftyp'), Buffer.from('isom')]) ); const response = await request(app) .post('/api/jobs') .field('targetFormats', JSON.stringify(['webm'])) .field('qualities', JSON.stringify([360])) .attach('files', fixturePath, 'bad-resolution.mp4'); expect(response.status).toBe(201); expect(response.body.jobs[0].error).toMatch(/Invalid quality/); await fs.unlink(fixturePath); }); it('lists the other 4 video formats as targets for mp4, and never lists mp4 as its own target', async () => { const response = await request(app).get('/api/formats').query({ source: 'mp4' }); expect(response.body.targets).toEqual(expect.arrayContaining(['webm', 'mov', 'avi', 'mkv'])); expect(response.body.targets).not.toContain('mp4'); }); });