Files

602 lines
22 KiB
JavaScript

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,
'<?xml version="1.0" encoding="utf-8"?><FictionBook><body><p>hello</p></body></FictionBook>'
);
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');
});
});