From 67bdfbf3de45989c977285239007b7aa818e93ff Mon Sep 17 00:00:00 2001 From: Anthony GAEREMYNCK <1@anthony.sh> Date: Fri, 31 Jul 2026 10:15:14 +0200 Subject: [PATCH] feat: wire ICO/HEIC/HEIF converters and iconSize validation into the API Co-Authored-By: Claude Sonnet 5 --- src/app.js | 40 +++++++++++++++++- test/api/jobs.test.js | 94 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/src/app.js b/src/app.js index c8cf67d..b22a2f2 100644 --- a/src/app.js +++ b/src/app.js @@ -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' }); diff --git a/test/api/jobs.test.js b/test/api/jobs.test.js index ffed02b..829df11 100644 --- a/test/api/jobs.test.js +++ b/test/api/jobs.test.js @@ -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');