feat: wire ICO/HEIC/HEIF converters and iconSize validation into the API

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 10:15:14 +02:00
co-authored by Claude Sonnet 5
parent be43eac13a
commit 67bdfbf3de
2 changed files with 133 additions and 1 deletions
+39 -1
View File
@@ -8,19 +8,29 @@ import { resolve as resolveConverter, listTargetFormats } from './converters/reg
import { registerImageConverters } from './converters/image.js';
import { registerImageToPdfConverter } from './converters/imageToPdf.js';
import { registerDocumentConverters } from './converters/document.js';
import { registerIcoConverter } from './converters/ico.js';
import { registerHeicConverter } from './converters/heic.js';
import { resolveInputFormat } from './mime.js';
import { deleteIfExists } from './storage.js';
import { createJob, getJobByUuid } from './jobs/jobRepository.js';
import { outputPath } from './storage.js';
const VALID_ICON_SIZES = [16, 32, 48, 256, 512];
function isValidQuality(targetFormat, quality) {
if (quality === null || quality === undefined) return true;
if (!Number.isInteger(quality)) return false;
if (targetFormat === 'gif') return false;
if (targetFormat === 'gif' || targetFormat === 'ico') return false;
if (targetFormat === 'png') return quality >= 0 && quality <= 9;
return quality >= 1 && quality <= 100;
}
function isValidIconSize(targetFormat, iconSize) {
if (iconSize === null || iconSize === undefined) return true;
if (targetFormat !== 'ico') return false;
return VALID_ICON_SIZES.includes(iconSize);
}
let convertersRegistered = false;
function registerAllConverters() {
@@ -28,6 +38,8 @@ function registerAllConverters() {
registerImageConverters();
registerImageToPdfConverter();
registerDocumentConverters();
registerIcoConverter();
registerHeicConverter();
convertersRegistered = true;
}
@@ -96,6 +108,21 @@ export function createApp(config, prisma) {
return res.status(400).json({ error: 'qualities must have one entry per uploaded file, or be omitted' });
}
let iconSizes;
try {
iconSizes = JSON.parse(req.body.iconSizes ?? '[]');
} catch {
return res.status(400).json({ error: 'iconSizes must be a JSON array' });
}
if (!Array.isArray(iconSizes)) {
return res.status(400).json({ error: 'iconSizes must be a JSON array' });
}
if (iconSizes.length > 0 && iconSizes.length !== req.files.length) {
return res.status(400).json({ error: 'iconSizes must have one entry per uploaded file, or be omitted' });
}
const results = [];
for (let i = 0; i < req.files.length; i += 1) {
const file = req.files[i];
@@ -130,6 +157,16 @@ export function createApp(config, prisma) {
continue;
}
const requestedIconSize = iconSizes[i] ?? null;
if (!isValidIconSize(targetFormat, requestedIconSize)) {
await deleteIfExists(file.path);
results.push({
file: file.originalname,
error: `Invalid iconSize for target format ${targetFormat}`,
});
continue;
}
const expiresAt = new Date(Date.now() + config.retentionHours * 3600 * 1000);
await createJob(prisma, {
uuid,
@@ -142,6 +179,7 @@ export function createApp(config, prisma) {
inputSizeBytes: file.size,
expiresAt,
quality: requestedQuality,
iconSize: requestedIconSize,
});
results.push({ file: file.originalname, id: uuid, status: 'pending' });
+94
View File
@@ -44,6 +44,18 @@ describe('GET /api/formats', () => {
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('POST /api/jobs', () => {
@@ -112,6 +124,88 @@ describe('POST /api/jobs', () => {
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 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');