Also fixes a third double-extension bug the plan missed: app.js derived each job's uuid via path.basename(file.filename, path.extname(...)), which only strips the last dot segment. For a compound extension like tar.gz this left "<uuid>.tar" as the "uuid", overflowing the uuid column (Char(36)) and crashing the request. Replaced with stripExtension(). Also bumped rateLimitMaxJobs in jobs.test.js's config override — the file's cumulative POST /api/jobs calls across all tests now exceeds the production default (20/window) once the archive tests are included, causing spurious connection resets independent of any real bug. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
448 lines
16 KiB
JavaScript
448 lines
16 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);
|
|
});
|
|
});
|