Files
convert/docs/superpowers/plans/2026-07-30-image-compression-level.md
T

1044 lines
38 KiB
Markdown

# 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 (
<main>
<h1>Convertisseur de fichiers</h1>
<input type="file" multiple onChange={(event) => handleFilesSelected(event.target.files)} />
{pendingFiles.length > 0 && (
<div>
<ul>
{pendingFiles.map((item, index) => (
<li key={`${item.file.name}-${index}`}>
{item.file.name}
{item.targets.length > 0 ? (
<>
<select
value={item.targetFormat ?? ''}
onChange={(event) => updateTargetFormat(index, event.target.value)}
>
{item.targets.map((target) => (
<option key={target} value={target}>
{target}
</option>
))}
</select>
{QUALITY_FORMATS.includes(item.targetFormat) && (
<label>
Qualité ({item.quality})
<input
type="range"
min="1"
max="100"
value={item.quality}
onChange={(event) => updateQuality(index, Number(event.target.value))}
/>
</label>
)}
{item.targetFormat === 'png' && (
<label>
Compression ({item.quality})
<input
type="range"
min="0"
max="9"
value={item.quality}
onChange={(event) => updateQuality(index, Number(event.target.value))}
/>
</label>
)}
{item.targetFormat === 'pdf' && (
<label>
<input
type="checkbox"
checked={item.quality !== null}
onChange={(event) => updateQuality(index, event.target.checked ? 90 : null)}
/>
Compresser en JPEG
{item.quality !== null && (
<input
type="range"
min="1"
max="100"
value={item.quality}
onChange={(event) => updateQuality(index, Number(event.target.value))}
/>
)}
</label>
)}
</>
) : (
<span className="error">Format non supporté</span>
)}
</li>
))}
</ul>
<button onClick={handleConvert}>Convertir</button>
</div>
)}
<ul className="job-list">
{submittedJobs.map((job, index) =>
job.id ? (
<FileCard key={job.id} fileName={job.file} jobId={job.id} />
) : (
<FileCard key={`${job.file}-${index}`} fileName={job.file} initialError={job.error} />
)
)}
</ul>
</main>
);
}
```
- [ ] **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.