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'; let app; let prisma; let config; beforeAll(async () => { config = { ...loadConfig(), storageDir: await fs.mkdtemp(path.join(os.tmpdir(), 'converter-api-')) }; 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); }); });