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

38 KiB

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.jsimage.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();:

    expect(job.quality).toBeNull();

Then add a new test right after that same test (after its closing });):

  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:

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:

  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:

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:

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:

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:

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:

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
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:

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:

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
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:

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:

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
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):

  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:

    await withTimeout(entry.convert(inputFilePath, outputFilePath), JOB_TIMEOUT_MS);

to:

    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
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);:

    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:

  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):

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:

    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:

      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:

      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
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:

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:

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):

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):

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):

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

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.