feat: validate and persist per-file quality in POST /api/jobs
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+34
@@ -13,6 +13,14 @@ import { deleteIfExists } from './storage.js';
|
|||||||
import { createJob, getJobByUuid } from './jobs/jobRepository.js';
|
import { createJob, getJobByUuid } from './jobs/jobRepository.js';
|
||||||
import { outputPath } from './storage.js';
|
import { outputPath } from './storage.js';
|
||||||
|
|
||||||
|
function isValidQuality(targetFormat, quality) {
|
||||||
|
if (quality === null || quality === undefined) return true;
|
||||||
|
if (!Number.isInteger(quality)) return false;
|
||||||
|
if (targetFormat === 'gif') return false;
|
||||||
|
if (targetFormat === 'png') return quality >= 0 && quality <= 9;
|
||||||
|
return quality >= 1 && quality <= 100;
|
||||||
|
}
|
||||||
|
|
||||||
let convertersRegistered = false;
|
let convertersRegistered = false;
|
||||||
|
|
||||||
function registerAllConverters() {
|
function registerAllConverters() {
|
||||||
@@ -73,6 +81,21 @@ export function createApp(config, pool) {
|
|||||||
return res.status(400).json({ error: 'targetFormats must have one entry per uploaded file' });
|
return res.status(400).json({ error: 'targetFormats must have one entry per uploaded file' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let qualities;
|
||||||
|
try {
|
||||||
|
qualities = JSON.parse(req.body.qualities ?? '[]');
|
||||||
|
} catch {
|
||||||
|
return res.status(400).json({ error: 'qualities must be a JSON array' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(qualities)) {
|
||||||
|
return res.status(400).json({ error: 'qualities must be a JSON array' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qualities.length > 0 && qualities.length !== req.files.length) {
|
||||||
|
return res.status(400).json({ error: 'qualities must have one entry per uploaded file, or be omitted' });
|
||||||
|
}
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
for (let i = 0; i < req.files.length; i += 1) {
|
for (let i = 0; i < req.files.length; i += 1) {
|
||||||
const file = req.files[i];
|
const file = req.files[i];
|
||||||
@@ -97,6 +120,16 @@ export function createApp(config, pool) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestedQuality = qualities[i] ?? null;
|
||||||
|
if (!isValidQuality(targetFormat, requestedQuality)) {
|
||||||
|
await deleteIfExists(file.path);
|
||||||
|
results.push({
|
||||||
|
file: file.originalname,
|
||||||
|
error: `Invalid quality for target format ${targetFormat}`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const expiresAt = new Date(Date.now() + config.retentionHours * 3600 * 1000);
|
const expiresAt = new Date(Date.now() + config.retentionHours * 3600 * 1000);
|
||||||
await createJob(pool, {
|
await createJob(pool, {
|
||||||
uuid,
|
uuid,
|
||||||
@@ -108,6 +141,7 @@ export function createApp(config, pool) {
|
|||||||
inputMimeType: mime,
|
inputMimeType: mime,
|
||||||
inputSizeBytes: file.size,
|
inputSizeBytes: file.size,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
|
quality: requestedQuality,
|
||||||
});
|
});
|
||||||
|
|
||||||
results.push({ file: file.originalname, id: uuid, status: 'pending' });
|
results.push({ file: file.originalname, id: uuid, status: 'pending' });
|
||||||
|
|||||||
@@ -67,6 +67,61 @@ describe('POST /api/jobs', () => {
|
|||||||
expect(job.originalFilename).toBe('photo.png');
|
expect(job.originalFilename).toBe('photo.png');
|
||||||
expect(job.inputMimeType).toBe('image/png');
|
expect(job.inputMimeType).toBe('image/png');
|
||||||
expect(job.inputSizeBytes).toBeGreaterThan(0);
|
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(pool, 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('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 () => {
|
it('rejects a file whose content does not match its extension, without failing the whole batch', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user