# Image Compression Level Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Let users choose a compression/quality level for image conversions, applied per uploaded file and interpreted according to the destination format's own semantics (sharp `quality` for jpeg/webp/avif/tiff, sharp `compressionLevel` for png, an opt-in JPEG-embed quality for image→pdf, no control for gif). **Architecture:** A `quality` value flows: frontend slider (`App.jsx`) → `qualities` JSON array alongside `targetFormats` (`api.js`) → parsed and validated per-file in `POST /api/jobs` (`app.js`) → stored on the job row (`jobRepository.js`) → read by the worker and passed as a third `options` argument to each converter's `convert(inputPath, outputPath, options)` (`worker.js` → `image.js` / `imageToPdf.js`). **Tech Stack:** Node.js (ESM), Express, `mariadb` driver, `sharp@0.35.3`, `pdf-lib@1.17.1`, Vitest + Supertest for backend tests, React 19 (no test framework) for the frontend. ## Global Constraints - No new dependency may be added — everything needed (`sharp`, `pdf-lib`, `mariadb`) is already installed. - No validation library exists in this project (no joi/zod/express-validator) — new validation must be plain inline JS, matching the existing style in `src/app.js`. - `db/schema.sql` is the source of truth for schema but is applied by hand — there is no migration runner. Any schema change requires a manual `ALTER TABLE` against the local dev DB (and later against the o2switch DB at deploy time). - Local tests must run against `.env.local` values passed as inline env vars, never against `.env` (production creds). Use: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run` - Two pre-existing test failures are unrelated to this work and must not be "fixed" as part of it: `test/cleanup.test.js` ("deletes an expired pending job...") and `test/jobs/jobRepository.test.js` ("finds expired jobs and allows deleting them"). Verify against `main` if they reappear. - Spec reference: `docs/superpowers/specs/2026-07-30-image-compression-level-design.md`. --- ### Task 1: Database column and job repository support **Files:** - Modify: `db/schema.sql` - Modify: `src/jobs/jobRepository.js:1-108` - Test: `test/jobs/jobRepository.test.js` **Interfaces:** - Produces: `createJob(pool, job)` now accepts an optional `job.quality` (number or omitted/null); rows returned by `getJobByUuid`, `findPendingJobs`, `findExpiredJobs` gain a `quality` field (number or `null`), used by Task 4 (`worker.js`) and Task 5 (`app.js`). - [ ] **Step 1: Write the failing tests** Edit `test/jobs/jobRepository.test.js`. In the existing "creates and retrieves a pending job" test, add one assertion right after `expect(job.cleanedAt).toBeNull();`: ```js expect(job.quality).toBeNull(); ``` Then add a new test right after that same test (after its closing `});`): ```js it('stores and retrieves a numeric quality value', async () => { await createJob(pool, baseJob({ uuid: '66666666-6666-4666-8666-666666666666', quality: 82 })); const job = await getJobByUuid(pool, '66666666-6666-4666-8666-666666666666'); expect(job.quality).toBe(82); }); ``` - [ ] **Step 2: Run the tests to verify they fail** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/jobs/jobRepository.test.js` Expected: FAIL — `job.quality` is `undefined` in both tests (`expected undefined to be null` and `expected undefined to be 82`), since `createJob`/`toCamelJob` don't read or write a `quality` field yet. - [ ] **Step 3: Alter the local database** Run this against the local dev DB described in `CLAUDE.md` (`.env.local` credentials), using the project's own `mariadb` driver so no separate DB client needs to be installed: ```bash DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter node -e " const mariadb = require('mariadb'); const pool = mariadb.createPool({ host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME }); pool.query('ALTER TABLE conversion_jobs ADD COLUMN quality SMALLINT UNSIGNED NULL') .then(() => { console.log('quality column added'); return pool.end(); }) .catch((err) => { console.error(err); process.exit(1); }); " ``` Expected output: `quality column added`. - [ ] **Step 4: Update `db/schema.sql`** In `db/schema.sql`, add the column right after `output_size_bytes`: ```sql output_size_bytes INT UNSIGNED NULL, quality SMALLINT UNSIGNED NULL, conversion_duration_seconds DECIMAL(10,3) NULL, ``` - [ ] **Step 5: Update `src/jobs/jobRepository.js`** Replace `toCamelJob`: ```js function toCamelJob(row) { if (!row) return null; return { id: row.id, uuid: row.uuid, status: row.status, family: row.family, sourceFormat: row.source_format, targetFormat: row.target_format, originalFilename: row.original_filename, inputPath: row.input_path, outputPath: row.output_path, inputMimeType: row.input_mime_type, outputMimeType: row.output_mime_type, inputSizeBytes: row.input_size_bytes, outputSizeBytes: row.output_size_bytes, quality: row.quality, conversionDurationSeconds: row.conversion_duration_seconds, errorMessage: row.error_message, createdAt: row.created_at, updatedAt: row.updated_at, expiresAt: row.expires_at, cleanedAt: row.cleaned_at, }; } ``` Replace `createJob`: ```js export async function createJob(pool, job) { await pool.query( `INSERT INTO conversion_jobs (uuid, status, family, source_format, target_format, original_filename, input_path, input_mime_type, input_size_bytes, expires_at, quality) VALUES (?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ job.uuid, job.family, job.sourceFormat, job.targetFormat, job.originalFilename, job.inputPath, job.inputMimeType, job.inputSizeBytes, job.expiresAt, job.quality ?? null, ] ); } ``` Replace the column list in `getJobByUuid`: ```js export async function getJobByUuid(pool, uuid) { const rows = await pool.query( `SELECT id, uuid, status, family, source_format, target_format, original_filename, input_path, output_path, input_mime_type, output_mime_type, input_size_bytes, output_size_bytes, quality, conversion_duration_seconds, error_message, created_at, updated_at, expires_at, cleaned_at FROM conversion_jobs WHERE uuid = ?`, [uuid] ); return toCamelJob(rows[0]); } ``` Replace the column list in `findPendingJobs`: ```js export async function findPendingJobs(pool, limit) { const rows = await pool.query( `SELECT id, uuid, status, family, source_format, target_format, original_filename, input_path, output_path, input_mime_type, output_mime_type, input_size_bytes, output_size_bytes, quality, conversion_duration_seconds, error_message, created_at, updated_at, expires_at, cleaned_at FROM conversion_jobs WHERE status = 'pending' ORDER BY created_at ASC LIMIT ?`, [limit] ); return rows.map(toCamelJob); } ``` Replace the column list in `findExpiredJobs`: ```js export async function findExpiredJobs(pool) { const rows = await pool.query( `SELECT id, uuid, status, family, source_format, target_format, original_filename, input_path, output_path, input_mime_type, output_mime_type, input_size_bytes, output_size_bytes, quality, conversion_duration_seconds, error_message, created_at, updated_at, expires_at, cleaned_at FROM conversion_jobs WHERE expires_at < NOW() AND cleaned_at IS NULL` ); return rows.map(toCamelJob); } ``` `markProcessing`, `markDone`, `markFailed`, `getJobErrorLog`, `markCleaned` are unchanged. - [ ] **Step 6: Run the tests to verify they pass** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/jobs/jobRepository.test.js` Expected: PASS for all tests except the pre-existing, unrelated failure noted in Global Constraints ("finds expired jobs and allows deleting them"). - [ ] **Step 7: Commit** ```bash git add db/schema.sql src/jobs/jobRepository.js test/jobs/jobRepository.test.js git commit -m "feat: add quality column to conversion_jobs" ``` --- ### Task 2: Quality/compressionLevel support in image-to-image conversion **Files:** - Modify: `src/converters/image.js:1-25` - Test: `test/converters/image.test.js` **Interfaces:** - Consumes: nothing new from Task 1. - Produces: `buildFormatOptions(targetFormat, quality)` (exported pure function) and every registered `convert(inputPath, outputPath, options = {})` now accepts an optional third argument shaped `{ quality }`, consumed by Task 4 (`worker.js`). - [ ] **Step 1: Write the failing tests** Replace the full contents of `test/converters/image.test.js`: ```js import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; import sharp from 'sharp'; import { registerImageConverters, buildFormatOptions } from '../../src/converters/image.js'; import { resolve, listTargetFormats } from '../../src/converters/registry.js'; import { detectInputMime } from '../../src/mime.js'; let tmpDir; let noisyPngPath; beforeAll(async () => { registerImageConverters(); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'converter-image-')); noisyPngPath = path.join(tmpDir, 'noisy.png'); await sharp({ create: { width: 256, height: 256, channels: 3, noise: { type: 'gaussian', mean: 128, sigma: 40 }, }, }) .png() .toFile(noisyPngPath); }); afterAll(async () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); describe('buildFormatOptions', () => { it('returns an empty object when quality is not set', () => { expect(buildFormatOptions('jpeg', null)).toEqual({}); }); it('maps quality to the quality option for jpeg/webp/avif/tiff', () => { expect(buildFormatOptions('jpeg', 40)).toEqual({ quality: 40 }); expect(buildFormatOptions('webp', 40)).toEqual({ quality: 40 }); expect(buildFormatOptions('avif', 40)).toEqual({ quality: 40 }); expect(buildFormatOptions('tiff', 40)).toEqual({ quality: 40 }); }); it('maps quality to compressionLevel for png', () => { expect(buildFormatOptions('png', 3)).toEqual({ compressionLevel: 3 }); }); it('ignores quality for gif', () => { expect(buildFormatOptions('gif', 5)).toEqual({}); }); }); describe('image converters', () => { it('registers every pair among the supported formats', () => { const targets = listTargetFormats('png').sort(); expect(targets).toEqual(['avif', 'gif', 'jpeg', 'jpg', 'tiff', 'webp']); }); it('converts a PNG fixture to WebP', async () => { const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); const outputPath = path.join(tmpDir, 'output.webp'); const entry = resolve('png', 'webp'); await entry.convert(inputPath, outputPath); const detected = await detectInputMime(outputPath); expect(detected.mime).toBe('image/webp'); }); it('converts a PNG fixture to JPG', async () => { const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); const outputPath = path.join(tmpDir, 'output.jpg'); const entry = resolve('png', 'jpg'); await entry.convert(inputPath, outputPath); const detected = await detectInputMime(outputPath); expect(detected.mime).toBe('image/jpeg'); }); it('produces a smaller JPG at lower quality than at higher quality', async () => { const lowPath = path.join(tmpDir, 'low.jpg'); const highPath = path.join(tmpDir, 'high.jpg'); const entry = resolve('png', 'jpg'); await entry.convert(noisyPngPath, lowPath, { quality: 10 }); await entry.convert(noisyPngPath, highPath, { quality: 95 }); const [lowStat, highStat] = await Promise.all([fs.stat(lowPath), fs.stat(highPath)]); expect(lowStat.size).toBeLessThan(highStat.size); }); it('produces a smaller-or-equal PNG at a higher compressionLevel', async () => { const jpgPath = path.join(tmpDir, 'noisy.jpg'); await sharp(noisyPngPath).jpeg({ quality: 90 }).toFile(jpgPath); const fastPath = path.join(tmpDir, 'fast.png'); const slowPath = path.join(tmpDir, 'slow.png'); const entry = resolve('jpg', 'png'); await entry.convert(jpgPath, fastPath, { quality: 0 }); await entry.convert(jpgPath, slowPath, { quality: 9 }); const [fastStat, slowStat] = await Promise.all([fs.stat(fastPath), fs.stat(slowPath)]); expect(slowStat.size).toBeLessThanOrEqual(fastStat.size); }); }); ``` - [ ] **Step 2: Run the tests to verify they fail** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/image.test.js` Expected: FAIL — either a module-load error because `buildFormatOptions` is not an exported member of `src/converters/image.js` yet, or (depending on how Vitest's ESM interop resolves the missing export) `buildFormatOptions is not a function` at the first `describe('buildFormatOptions', ...)` test. The two new size-comparison tests at the bottom would also fail on their own once the module loads, because `entry.convert` currently ignores its third argument. - [ ] **Step 3: Implement** Replace the full contents of `src/converters/image.js`: ```js import sharp from 'sharp'; import { register } from './registry.js'; const IMAGE_FORMATS = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'tiff', 'avif']; function sharpFormatName(format) { return format === 'jpg' ? 'jpeg' : format; } export function buildFormatOptions(targetFormat, quality) { if (quality == null) return {}; if (targetFormat === 'png') return { compressionLevel: quality }; if (targetFormat === 'gif') return {}; return { quality }; } export function registerImageConverters() { for (const sourceFormat of IMAGE_FORMATS) { for (const targetFormat of IMAGE_FORMATS) { if (sourceFormat === targetFormat) continue; register({ family: 'image', sourceFormat, targetFormat, convert: async (inputPath, outputPath, { quality } = {}) => { await sharp(inputPath) .toFormat(sharpFormatName(targetFormat), buildFormatOptions(targetFormat, quality)) .toFile(outputPath); }, }); } } } ``` - [ ] **Step 4: Run the tests to verify they pass** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/image.test.js` Expected: PASS (all tests in this file). - [ ] **Step 5: Commit** ```bash git add src/converters/image.js test/converters/image.test.js git commit -m "feat: support quality/compressionLevel options in image converters" ``` --- ### Task 3: Opt-in JPEG-embed quality for image-to-PDF conversion **Files:** - Modify: `src/converters/imageToPdf.js:1-32` - Test: `test/converters/imageToPdf.test.js` **Interfaces:** - Consumes: nothing new from Task 1 or 2 (independent converter module). - Produces: `convert(inputPath, outputPath, options = {})` for the `*->pdf` registry entries now accepts `{ quality }`, consumed by Task 4 (`worker.js`). When `quality` is `null`/omitted, behavior is byte-for-byte the same as before (PNG embed). - [ ] **Step 1: Write the failing test** Replace the full contents of `test/converters/imageToPdf.test.js`: ```js import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; import sharp from 'sharp'; import { registerImageToPdfConverter } from '../../src/converters/imageToPdf.js'; import { resolve, listTargetFormats } from '../../src/converters/registry.js'; import { detectInputMime } from '../../src/mime.js'; let tmpDir; beforeAll(async () => { registerImageToPdfConverter(); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'converter-image-to-pdf-')); }); afterAll(async () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); describe('image to PDF converter', () => { it('registers pdf as a target for every image format', () => { expect(listTargetFormats('png')).toContain('pdf'); expect(listTargetFormats('jpg')).toContain('pdf'); }); it('converts a PNG fixture into a valid PDF', async () => { const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); const outputPath = path.join(tmpDir, 'output.pdf'); const entry = resolve('png', 'pdf'); await entry.convert(inputPath, outputPath); const detected = await detectInputMime(outputPath); expect(detected.mime).toBe('application/pdf'); }); it('embeds a JPEG instead of a PNG when a quality is given, shrinking the output for a noisy image', async () => { const inputPath = path.join(tmpDir, 'noisy.png'); await sharp({ create: { width: 256, height: 256, channels: 3, noise: { type: 'gaussian', mean: 128, sigma: 40 }, }, }) .png() .toFile(inputPath); const defaultOutputPath = path.join(tmpDir, 'default.pdf'); const compressedOutputPath = path.join(tmpDir, 'compressed.pdf'); const entry = resolve('png', 'pdf'); await entry.convert(inputPath, defaultOutputPath); await entry.convert(inputPath, compressedOutputPath, { quality: 20 }); const compressedDetected = await detectInputMime(compressedOutputPath); expect(compressedDetected.mime).toBe('application/pdf'); const [defaultStat, compressedStat] = await Promise.all([ fs.stat(defaultOutputPath), fs.stat(compressedOutputPath), ]); expect(compressedStat.size).toBeLessThan(defaultStat.size); }); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/imageToPdf.test.js` Expected: FAIL on the new test — `compressedStat.size` is not less than `defaultStat.size` because `quality` is currently ignored (both paths embed the same PNG). - [ ] **Step 3: Implement** Replace the full contents of `src/converters/imageToPdf.js`: ```js import fs from 'node:fs/promises'; import sharp from 'sharp'; import { PDFDocument } from 'pdf-lib'; import { register } from './registry.js'; const IMAGE_FORMATS = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'tiff', 'avif']; async function convert(inputPath, outputPath, { quality } = {}) { const imageBuffer = quality == null ? await sharp(inputPath).png().toBuffer() : await sharp(inputPath).jpeg({ quality }).toBuffer(); const metadata = await sharp(imageBuffer).metadata(); const pdfDoc = await PDFDocument.create(); const page = pdfDoc.addPage([metadata.width, metadata.height]); const embeddedImage = quality == null ? await pdfDoc.embedPng(imageBuffer) : await pdfDoc.embedJpg(imageBuffer); page.drawImage(embeddedImage, { x: 0, y: 0, width: metadata.width, height: metadata.height, }); const pdfBytes = await pdfDoc.save(); await fs.writeFile(outputPath, pdfBytes); } export function registerImageToPdfConverter() { for (const sourceFormat of IMAGE_FORMATS) { register({ family: 'image', sourceFormat, targetFormat: 'pdf', convert }); } } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/imageToPdf.test.js` Expected: PASS (all tests in this file). - [ ] **Step 5: Commit** ```bash git add src/converters/imageToPdf.js test/converters/imageToPdf.test.js git commit -m "feat: opt-in JPEG-embed quality for image-to-PDF conversion" ``` --- ### Task 4: Thread job quality through the worker **Files:** - Modify: `src/worker.js:36` - Test: `test/worker.test.js` **Interfaces:** - Consumes: `job.quality` from Task 1 (`jobRepository.js`), `convert(inputPath, outputPath, { quality })` from Tasks 2 and 3. - Produces: nothing new for later tasks — this is the last hop before the converter. - [ ] **Step 1: Write the failing test** Add `import sharp from 'sharp';` to the top of `test/worker.test.js`, alongside the other imports. Add this test to `test/worker.test.js`, right after the "converts a pending image job to done" test (inside the `describe('processPendingJobs', ...)` block): ```js it('passes the job quality through to the converter, shrinking output for a low quality value', async () => { const lowUuid = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; const defaultUuid = 'ffffffff-ffff-4fff-8fff-ffffffffffff'; const noisyBuffer = await sharp({ create: { width: 256, height: 256, channels: 3, noise: { type: 'gaussian', mean: 128, sigma: 40 }, }, }) .png() .toBuffer(); for (const uuid of [lowUuid, defaultUuid]) { await fs.writeFile(uploadPath(config, uuid, 'png'), noisyBuffer); } await createJob(pool, { uuid: lowUuid, family: 'image', sourceFormat: 'png', targetFormat: 'jpg', originalFilename: 'noisy.png', inputPath: `${lowUuid}.png`, inputMimeType: 'image/png', inputSizeBytes: noisyBuffer.length, expiresAt: new Date(Date.now() + 3600 * 1000), quality: 5, }); await createJob(pool, { uuid: defaultUuid, family: 'image', sourceFormat: 'png', targetFormat: 'jpg', originalFilename: 'noisy.png', inputPath: `${defaultUuid}.png`, inputMimeType: 'image/png', inputSizeBytes: noisyBuffer.length, expiresAt: new Date(Date.now() + 3600 * 1000), }); await processPendingJobs(pool, { ...config, workerConcurrency: 2 }); const lowJob = await getJobByUuid(pool, lowUuid); const defaultJob = await getJobByUuid(pool, defaultUuid); expect(lowJob.status).toBe('done'); expect(defaultJob.status).toBe('done'); expect(lowJob.outputSizeBytes).toBeLessThan(defaultJob.outputSizeBytes); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/worker.test.js` Expected: FAIL — `lowJob.outputSizeBytes` is not less than `defaultJob.outputSizeBytes`, because `worker.js:36` calls `entry.convert(inputFilePath, outputFilePath)` without forwarding `job.quality`, so both jobs are encoded at sharp's default JPEG quality (80) regardless of the `quality` column value. - [ ] **Step 3: Implement** In `src/worker.js`, change line 36 from: ```js await withTimeout(entry.convert(inputFilePath, outputFilePath), JOB_TIMEOUT_MS); ``` to: ```js await withTimeout(entry.convert(inputFilePath, outputFilePath, { quality: job.quality }), JOB_TIMEOUT_MS); ``` - [ ] **Step 4: Run the test to verify it passes** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/worker.test.js` Expected: PASS (all tests in this file). - [ ] **Step 5: Commit** ```bash git add src/worker.js test/worker.test.js git commit -m "feat: pass job quality through to converters in the worker" ``` --- ### Task 5: Parse and validate `qualities` in the job creation API **Files:** - Modify: `src/app.js:60-117` - Test: `test/api/jobs.test.js` **Interfaces:** - Consumes: `createJob(pool, { ..., quality })` from Task 1. - Produces: nothing new for later tasks — this is the last backend hop before storage; Task 6 (frontend) is the consumer of this endpoint's contract (`qualities` field on `POST /api/jobs`). - [ ] **Step 1: Write the failing tests** In `test/api/jobs.test.js`, add this assertion to the existing "creates a pending job for a valid image upload" test, right after `expect(job.inputSizeBytes).toBeGreaterThan(0);`: ```js expect(job.quality).toBeNull(); ``` Then add these new tests inside `describe('POST /api/jobs', ...)`, after the "creates a pending job for a valid image upload" test: ```js 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); }); ``` - [ ] **Step 2: Run the tests to verify they fail** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/api/jobs.test.js` Expected: FAIL on the three new tests — `job.quality` is `null` instead of `45` in the first (since `app.js` never reads `req.body.qualities` yet, so `createJob` always receives no `quality` and defaults it to `null` per Task 1), and `response.body.jobs[0].error` is `undefined` instead of matching `/Invalid quality/` in the other two (the out-of-range and gif+quality cases both succeed as pending jobs today because nothing validates `qualities` yet). The pre-existing test's new `expect(job.quality).toBeNull()` assertion already passes at this point — that's expected, it's a regression guard for Task 1's default, not a new behavior. - [ ] **Step 3: Implement** In `src/app.js`, add this helper function above `createApp` (after the imports, before `let convertersRegistered`): ```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; } ``` In the `POST /api/jobs` handler, right after the existing `targetFormats` length check (`if (!Array.isArray(targetFormats) || targetFormats.length !== req.files.length) { ... }`), add: ```js 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' }); } ``` In the per-file loop, right after the `registryEntry` unsupported-conversion check (after its `continue;` and closing `}`), add: ```js 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; } ``` Finally, add `quality: requestedQuality` to the `createJob(pool, { ... })` call: ```js await createJob(pool, { uuid, family: registryEntry.family, sourceFormat, targetFormat, originalFilename: file.originalname, inputPath: file.filename, inputMimeType: mime, inputSizeBytes: file.size, expiresAt, quality: requestedQuality, }); ``` - [ ] **Step 4: Run the tests to verify they pass** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/api/jobs.test.js` Expected: PASS (all tests in this file). - [ ] **Step 5: Run the full backend test suite** Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run` Expected: PASS, except the two pre-existing, unrelated failures listed in Global Constraints. - [ ] **Step 6: Commit** ```bash git add src/app.js test/api/jobs.test.js git commit -m "feat: validate and persist per-file quality in POST /api/jobs" ``` --- ### Task 6: Frontend compression controls **Files:** - Modify: `frontend/src/App.jsx:1-81` - Modify: `frontend/src/api.js:1-29` **Interfaces:** - Consumes: the `qualities` field accepted by `POST /api/jobs` from Task 5. - Produces: nothing further downstream — this is the last task. There is no frontend test framework in this repo (`frontend/package.json` has no vitest/RTL/jest), so this task is verified manually in the browser instead of with an automated test cycle. - [ ] **Step 1: Update `frontend/src/api.js`** Replace `uploadFiles`: ```js export async function uploadFiles(items) { const formData = new FormData(); const targetFormats = []; const qualities = []; for (const item of items) { formData.append('files', item.file); targetFormats.push(item.targetFormat); qualities.push(item.quality ?? null); } formData.append('targetFormats', JSON.stringify(targetFormats)); formData.append('qualities', JSON.stringify(qualities)); const response = await fetch('/api/jobs', { method: 'POST', body: formData }); const data = await response.json(); return data.jobs; } ``` - [ ] **Step 2: Update `frontend/src/App.jsx`** Replace the full contents of `frontend/src/App.jsx`: ```jsx import { useState } from 'react'; import { fetchFormats, uploadFiles } from './api.js'; import { FileCard } from './FileCard.jsx'; import './App.css'; const DEFAULT_QUALITY = { jpg: 80, jpeg: 80, webp: 80, avif: 50, tiff: 80, png: 6, }; const QUALITY_FORMATS = ['jpg', 'jpeg', 'webp', 'avif', 'tiff']; function extensionOf(fileName) { return fileName.split('.').pop().toLowerCase(); } function defaultQualityFor(targetFormat) { return DEFAULT_QUALITY[targetFormat] ?? null; } export default function App() { const [pendingFiles, setPendingFiles] = useState([]); const [submittedJobs, setSubmittedJobs] = useState([]); async function handleFilesSelected(fileList) { const files = Array.from(fileList); const withTargets = await Promise.all( files.map(async (file) => { const targets = await fetchFormats(extensionOf(file.name)); const targetFormat = targets[0] ?? null; return { file, targets, targetFormat, quality: defaultQualityFor(targetFormat) }; }) ); setPendingFiles(withTargets); } function updateTargetFormat(index, targetFormat) { setPendingFiles((current) => current.map((item, i) => i === index ? { ...item, targetFormat, quality: defaultQualityFor(targetFormat) } : item ) ); } function updateQuality(index, quality) { setPendingFiles((current) => current.map((item, i) => (i === index ? { ...item, quality } : item))); } async function handleConvert() { const validItems = pendingFiles.filter((item) => item.targetFormat); const jobs = await uploadFiles(validItems); setSubmittedJobs((current) => [...current, ...jobs]); setPendingFiles([]); } return (

Convertisseur de fichiers

handleFilesSelected(event.target.files)} /> {pendingFiles.length > 0 && (
)}
); } ``` - [ ] **Step 3: Manual verification in the browser** Start the backend (in one terminal): ```bash DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 node src/server.js ``` Start the worker (in a second terminal): ```bash DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 node src/worker.js ``` Start the frontend dev server, which proxies `/api` to `localhost:3000` per `frontend/vite.config.js` (in a third terminal): ```bash npm run dev --prefix frontend ``` Open the printed local URL and check: - Uploading a `.png` and selecting `jpg` as the target shows a 1-100 quality slider defaulted to 80; dragging it changes the displayed number. - Selecting `png` as the target shows a 0-9 compression slider defaulted to 6. - Selecting `gif` as the target shows no slider. - Selecting `pdf` as the target shows an unchecked "Compresser en JPEG" checkbox with no slider until checked; checking it reveals a 1-100 slider defaulted to 90. - Switching the target format after adjusting the slider resets the slider to the new format's default (or hides it). - Clicking "Convertir" completes the job and the download succeeds, for at least one case with a non-default slider value. - [ ] **Step 4: Commit** ```bash git add frontend/src/App.jsx frontend/src/api.js git commit -m "feat: add per-format compression controls to the frontend" ``` --- ## Deployment note After all tasks are merged, the same `ALTER TABLE conversion_jobs ADD COLUMN quality SMALLINT UNSIGNED NULL;` from Task 1 Step 3 must be run against the o2switch production database before deploying the new backend code, since `db/schema.sql` is not auto-applied there.