Files
convert/docs/superpowers/plans/2026-07-28-file-converter.md
anthonyandClaude Sonnet 5 fe93139c6e Add implementation plan for file converter v1 (core + Images + Documents)
18 bite-sized TDD tasks from project scaffolding through the React
frontend and an end-to-end pipeline test, following the approved design
spec.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 13:46:16 +02:00

103 KiB

File Converter 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: Build the core (upload → convert → download) pipeline of a Convertio-like file converter, covering the Images and Documents format families, deployable on o2switch shared hosting.

Architecture: An Express API process (behind Passenger) accepts uploads and writes pending jobs to a MariaDB conversion_jobs table; a separate worker process (run via pm2, outside Passenger) polls that table, runs the matching converter from a central format registry, and writes the result to disk. A React SPA drives uploads and polls job status. A cron-triggered cleanup script deletes expired jobs and their files.

Tech Stack: Node.js (ESM, "type": "module"), Express, MariaDB (mariadb npm connector), multer (upload), sharp (images), pdf-lib + puppeteer + mammoth + pdfjs-dist + docx (documents), file-type (MIME sniffing), express-rate-limit, uuid, React (Vite) frontend, vitest + supertest for tests.

Global Constraints

  • No native binaries installed at the system level (no apt-get, no compilation). Only pure-JS npm packages or npm packages that auto-download a precompiled binary during npm install (e.g. Puppeteer's Chromium) are allowed.
  • npm install must never run with --ignore-scripts in deployment — Puppeteer's Chromium download depends on its postinstall script running.
  • Max upload size: MAX_FILE_SIZE_MB, default 100.
  • Retention: RETENTION_HOURS, default 1 — expired jobs (row + files) are deleted by the cleanup script.
  • Job IDs are UUID v4, used directly in public URLs (/api/jobs/:id, /api/jobs/:id/download). Non-enumerable by design — no sequential IDs anywhere in the public API.
  • Files on disk are named <uuid>.<ext> directly under STORAGE_DIR/uploads/ and STORAGE_DIR/outputs/ — no subfolders, no original filename in the path.
  • input_mime_type is always detected from magic bytes (file-type), never trusted from the client's declared Content-Type or file extension. A mismatch with the declared source_format is rejected (HTTP 422) before a job is created.
  • error_message (short, user-safe) and error_log (full technical detail, never returned by the API) are separate columns — the API must never leak error_log contents to a client.
  • Worker concurrency is bounded by WORKER_CONCURRENCY — jobs beyond that stay pending rather than being spawned unbounded.
  • Spec reference: docs/superpowers/specs/2026-07-28-file-converter-design.md.

Task 1: Project scaffolding & config

Files:

  • Create: package.json
  • Create: .env.example
  • Create: .gitignore (append node_modules/, .env, storage/, dist/ if not already present)
  • Create: src/config.js
  • Test: test/config.test.js

Interfaces:

  • Produces: export function loadConfig() in src/config.js, returning:

    {
      port: number,
      storageDir: string,
      db: { host: string, user: string, password: string, database: string },
      maxFileSizeMb: number,
      retentionHours: number,
      workerPollIntervalMs: number,
      workerConcurrency: number,
      rateLimitMaxJobs: number,
      rateLimitWindowMinutes: number,
    }
    

    Throws an Error listing every missing required variable if any of STORAGE_DIR, DB_HOST, DB_USER, DB_PASSWORD, DB_NAME is unset.

  • Step 1: Create package.json

npm init -y

Then edit package.json to set:

{
  "name": "file-converter",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node src/app.js",
    "worker": "node src/worker.js",
    "cleanup": "node src/cleanup.js",
    "test": "vitest run"
  }
}
  • Step 2: Install dependencies
npm install express multer mariadb uuid file-type sharp pdf-lib puppeteer mammoth docx pdfjs-dist express-rate-limit dotenv
npm install --save-dev vitest supertest

Expected: npm install completes without error (Puppeteer's postinstall will download Chromium — this can take a minute).

  • Step 3: Create .env.example
PORT=3000
STORAGE_DIR=./storage
DB_HOST=127.0.0.1
DB_USER=convert_user
DB_PASSWORD=change_me
DB_NAME=file_converter
MAX_FILE_SIZE_MB=100
RETENTION_HOURS=1
WORKER_POLL_INTERVAL_MS=1500
WORKER_CONCURRENCY=3
RATE_LIMIT_MAX_JOBS=20
RATE_LIMIT_WINDOW_MINUTES=10
  • Step 4: Update .gitignore

Add these lines if not already present:

node_modules/
.env
storage/
dist/
  • Step 5: Write the failing test for config loading

Create test/config.test.js:

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { loadConfig } from '../src/config.js';

const REQUIRED_VARS = ['STORAGE_DIR', 'DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'];
let savedEnv;

beforeEach(() => {
  savedEnv = { ...process.env };
});

afterEach(() => {
  process.env = savedEnv;
});

describe('loadConfig', () => {
  it('returns parsed config when all required vars are set', () => {
    process.env.STORAGE_DIR = './storage';
    process.env.DB_HOST = 'localhost';
    process.env.DB_USER = 'user';
    process.env.DB_PASSWORD = 'pass';
    process.env.DB_NAME = 'db';
    process.env.PORT = '4000';
    process.env.MAX_FILE_SIZE_MB = '50';

    const config = loadConfig();

    expect(config.port).toBe(4000);
    expect(config.storageDir).toBe('./storage');
    expect(config.db).toEqual({ host: 'localhost', user: 'user', password: 'pass', database: 'db' });
    expect(config.maxFileSizeMb).toBe(50);
  });

  it('applies defaults for optional vars', () => {
    process.env.STORAGE_DIR = './storage';
    process.env.DB_HOST = 'localhost';
    process.env.DB_USER = 'user';
    process.env.DB_PASSWORD = 'pass';
    process.env.DB_NAME = 'db';
    delete process.env.PORT;
    delete process.env.MAX_FILE_SIZE_MB;
    delete process.env.RETENTION_HOURS;
    delete process.env.WORKER_CONCURRENCY;

    const config = loadConfig();

    expect(config.port).toBe(3000);
    expect(config.maxFileSizeMb).toBe(100);
    expect(config.retentionHours).toBe(1);
    expect(config.workerConcurrency).toBe(3);
  });

  it('throws listing every missing required variable', () => {
    for (const key of REQUIRED_VARS) delete process.env[key];

    expect(() => loadConfig()).toThrowError(/STORAGE_DIR.*DB_HOST.*DB_USER.*DB_PASSWORD.*DB_NAME/s);
  });
});
  • Step 6: Run test to verify it fails

Run: npx vitest run test/config.test.js Expected: FAIL — src/config.js does not exist yet.

  • Step 7: Implement src/config.js
import 'dotenv/config';

const REQUIRED_VARS = ['STORAGE_DIR', 'DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'];

export function loadConfig() {
  const missing = REQUIRED_VARS.filter((key) => !process.env[key]);
  if (missing.length > 0) {
    throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
  }

  return {
    port: Number(process.env.PORT ?? 3000),
    storageDir: process.env.STORAGE_DIR,
    db: {
      host: process.env.DB_HOST,
      user: process.env.DB_USER,
      password: process.env.DB_PASSWORD,
      database: process.env.DB_NAME,
    },
    maxFileSizeMb: Number(process.env.MAX_FILE_SIZE_MB ?? 100),
    retentionHours: Number(process.env.RETENTION_HOURS ?? 1),
    workerPollIntervalMs: Number(process.env.WORKER_POLL_INTERVAL_MS ?? 1500),
    workerConcurrency: Number(process.env.WORKER_CONCURRENCY ?? 3),
    rateLimitMaxJobs: Number(process.env.RATE_LIMIT_MAX_JOBS ?? 20),
    rateLimitWindowMinutes: Number(process.env.RATE_LIMIT_WINDOW_MINUTES ?? 10),
  };
}
  • Step 8: Run test to verify it passes

Run: npx vitest run test/config.test.js Expected: PASS (3 tests)

  • Step 9: Commit
git add package.json package-lock.json .env.example .gitignore src/config.js test/config.test.js
git commit -m "feat: project scaffolding and env config loader"

Task 2: MariaDB schema & connection pool

Files:

  • Create: db/schema.sql
  • Create: src/db.js
  • Test: test/db.test.js

Interfaces:

  • Consumes: loadConfig().db from Task 1 ({ host, user, password, database }).

  • Produces: export function getPool(config) in src/db.js — returns a singleton mariadb pool built from config.db (creates it on first call, returns the same instance on subsequent calls). Also exports export async function closePool() to end the pool (used in tests and graceful shutdown).

  • Step 1: Write the schema file

Create db/schema.sql:

CREATE TABLE IF NOT EXISTS conversion_jobs (
  id CHAR(36) NOT NULL PRIMARY KEY,
  status ENUM('pending', 'processing', 'done', 'failed') NOT NULL DEFAULT 'pending',
  family VARCHAR(32) NOT NULL,
  source_format VARCHAR(16) NOT NULL,
  target_format VARCHAR(16) NOT NULL,
  original_filename VARCHAR(255) NOT NULL,
  input_path VARCHAR(255) NOT NULL,
  output_path VARCHAR(255) NULL,
  input_mime_type VARCHAR(128) NOT NULL,
  output_mime_type VARCHAR(128) NULL,
  error_message VARCHAR(255) NULL,
  error_log TEXT NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  expires_at DATETIME NOT NULL,
  INDEX idx_status (status),
  INDEX idx_expires_at (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  • Step 2: Apply the schema to a local/test MariaDB database

Run (adjust host/user/db to your local MariaDB instance used for development):

mysql -h 127.0.0.1 -u convert_user -p file_converter < db/schema.sql

Expected: no error, conversion_jobs table exists (SHOW TABLES; lists it).

  • Step 3: Write the failing test for the pool

Create test/db.test.js:

import { describe, it, expect, afterAll } from 'vitest';
import { getPool, closePool } from '../src/db.js';
import { loadConfig } from '../src/config.js';

describe('getPool', () => {
  afterAll(async () => {
    await closePool();
  });

  it('returns a working pool that can run a query', async () => {
    const config = loadConfig();
    const pool = getPool(config);

    const rows = await pool.query('SELECT 1 AS value');

    expect(Number(rows[0].value)).toBe(1);
  });

  it('returns the same pool instance on repeated calls', () => {
    const config = loadConfig();
    const poolA = getPool(config);
    const poolB = getPool(config);

    expect(poolA).toBe(poolB);
  });
});

Note: this test requires a reachable MariaDB instance matching the env vars used to run tests: STORAGE_DIR=./storage DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter npx vitest run test/db.test.js.

  • Step 4: Run test to verify it fails

Run: npx vitest run test/db.test.js Expected: FAIL — src/db.js does not exist yet.

  • Step 5: Implement src/db.js
import mariadb from 'mariadb';

let pool;

export function getPool(config) {
  if (!pool) {
    pool = mariadb.createPool({
      host: config.db.host,
      user: config.db.user,
      password: config.db.password,
      database: config.db.database,
      connectionLimit: 10,
    });
  }
  return pool;
}

export async function closePool() {
  if (pool) {
    await pool.end();
    pool = undefined;
  }
}
  • Step 6: Run test to verify it passes

Run: STORAGE_DIR=./storage DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter npx vitest run test/db.test.js Expected: PASS (2 tests)

  • Step 7: Commit
git add db/schema.sql src/db.js test/db.test.js
git commit -m "feat: add MariaDB schema and connection pool"

Task 3: Storage utilities

Files:

  • Create: src/storage.js
  • Test: test/storage.test.js

Interfaces:

  • Consumes: config.storageDir from Task 1.

  • Produces:

    • export async function ensureStorageDirs(config) — creates <storageDir>/uploads and <storageDir>/outputs if missing.
    • export function uploadPath(config, id, ext) — returns <storageDir>/uploads/<id>.<ext> (absolute path).
    • export function outputPath(config, id, ext) — returns <storageDir>/outputs/<id>.<ext> (absolute path).
    • export async function deleteIfExists(filePath) — deletes a file, silently no-ops if it doesn't exist.
  • Step 1: Write the failing tests

Create test/storage.test.js:

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { ensureStorageDirs, uploadPath, outputPath, deleteIfExists } from '../src/storage.js';

let tmpDir;
let config;

beforeEach(async () => {
  tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'converter-storage-'));
  config = { storageDir: tmpDir };
});

afterEach(async () => {
  await fs.rm(tmpDir, { recursive: true, force: true });
});

describe('storage', () => {
  it('creates uploads and outputs directories', async () => {
    await ensureStorageDirs(config);

    const uploadsStat = await fs.stat(path.join(tmpDir, 'uploads'));
    const outputsStat = await fs.stat(path.join(tmpDir, 'outputs'));

    expect(uploadsStat.isDirectory()).toBe(true);
    expect(outputsStat.isDirectory()).toBe(true);
  });

  it('builds upload and output paths as <uuid>.<ext>', () => {
    const id = '11111111-1111-4111-8111-111111111111';

    expect(uploadPath(config, id, 'png')).toBe(path.join(tmpDir, 'uploads', `${id}.png`));
    expect(outputPath(config, id, 'pdf')).toBe(path.join(tmpDir, 'outputs', `${id}.pdf`));
  });

  it('deletes an existing file', async () => {
    await ensureStorageDirs(config);
    const filePath = uploadPath(config, 'file-to-delete', 'txt');
    await fs.writeFile(filePath, 'content');

    await deleteIfExists(filePath);

    await expect(fs.stat(filePath)).rejects.toThrow();
  });

  it('does not throw when deleting a missing file', async () => {
    const filePath = uploadPath(config, 'does-not-exist', 'txt');

    await expect(deleteIfExists(filePath)).resolves.toBeUndefined();
  });
});
  • Step 2: Run tests to verify they fail

Run: npx vitest run test/storage.test.js Expected: FAIL — src/storage.js does not exist yet.

  • Step 3: Implement src/storage.js
import fs from 'node:fs/promises';
import path from 'node:path';

export async function ensureStorageDirs(config) {
  await fs.mkdir(path.join(config.storageDir, 'uploads'), { recursive: true });
  await fs.mkdir(path.join(config.storageDir, 'outputs'), { recursive: true });
}

export function uploadPath(config, id, ext) {
  return path.join(config.storageDir, 'uploads', `${id}.${ext}`);
}

export function outputPath(config, id, ext) {
  return path.join(config.storageDir, 'outputs', `${id}.${ext}`);
}

export async function deleteIfExists(filePath) {
  try {
    await fs.unlink(filePath);
  } catch (error) {
    if (error.code !== 'ENOENT') throw error;
  }
}
  • Step 4: Run tests to verify they pass

Run: npx vitest run test/storage.test.js Expected: PASS (4 tests)

  • Step 5: Commit
git add src/storage.js test/storage.test.js
git commit -m "feat: add storage path helpers"

Task 4: MIME detection utilities

Files:

  • Create: src/mime.js
  • Create: test/fixtures/sample.png (tiny valid PNG used as a fixture)
  • Test: test/mime.test.js

Interfaces:

  • Produces:

    • export async function detectInputMime(filePath) — sniffs magic bytes via file-type's fileTypeFromFile, returns { ext, mime } or null if undetectable.
    • export function outputMimeType(targetFormat) — looks up a static map, throws if targetFormat is unknown.
  • Step 1: Create the PNG fixture

Create test/fixtures/sample.png with this exact 67-byte 1x1 transparent PNG (write as binary — do not treat as text):

// One-off script to generate the fixture (run once, not part of the app):
// node -e "require('fs').writeFileSync('test/fixtures/sample.png', Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAen63NgAAAAASUVORK5CYII=', 'base64'))"

Run that command from the project root.

  • Step 2: Write the failing tests

Create test/mime.test.js:

import { describe, it, expect } from 'vitest';
import path from 'node:path';
import { detectInputMime, outputMimeType } from '../src/mime.js';

describe('detectInputMime', () => {
  it('detects PNG from magic bytes regardless of file extension', async () => {
    const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.png');

    const result = await detectInputMime(fixturePath);

    expect(result).toEqual({ ext: 'png', mime: 'image/png' });
  });
});

describe('outputMimeType', () => {
  it('returns the MIME type for a known target format', () => {
    expect(outputMimeType('pdf')).toBe('application/pdf');
    expect(outputMimeType('png')).toBe('image/png');
    expect(outputMimeType('docx')).toBe(
      'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
    );
  });

  it('throws for an unknown target format', () => {
    expect(() => outputMimeType('made-up-format')).toThrowError(/made-up-format/);
  });
});
  • Step 3: Run tests to verify they fail

Run: npx vitest run test/mime.test.js Expected: FAIL — src/mime.js does not exist yet.

  • Step 4: Implement src/mime.js
import { fileTypeFromFile } from 'file-type';

const OUTPUT_MIME_TYPES = {
  jpg: 'image/jpeg',
  jpeg: 'image/jpeg',
  png: 'image/png',
  webp: 'image/webp',
  gif: 'image/gif',
  tiff: 'image/tiff',
  avif: 'image/avif',
  bmp: 'image/bmp',
  pdf: 'application/pdf',
  html: 'text/html',
  txt: 'text/plain',
  docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
};

export async function detectInputMime(filePath) {
  const result = await fileTypeFromFile(filePath);
  return result ?? null;
}

export function outputMimeType(targetFormat) {
  const mime = OUTPUT_MIME_TYPES[targetFormat];
  if (!mime) {
    throw new Error(`No known MIME type for target format "${targetFormat}"`);
  }
  return mime;
}
  • Step 5: Run tests to verify they pass

Run: npx vitest run test/mime.test.js Expected: PASS (3 tests)

  • Step 6: Commit
git add src/mime.js test/mime.test.js test/fixtures/sample.png
git commit -m "feat: add MIME sniffing and output MIME lookup"

Task 5: Job repository

Files:

  • Create: src/jobs/jobRepository.js
  • Test: test/jobs/jobRepository.test.js

Interfaces:

  • Consumes: getPool(config) from Task 2.

  • Produces (all functions take pool as first argument):

    • async function createJob(pool, { id, family, sourceFormat, targetFormat, originalFilename, inputPath, inputMimeType, expiresAt }) — inserts a pending row, returns nothing.
    • async function getJobById(pool, id) — returns the row as a plain object (camelCase keys: id, status, family, sourceFormat, targetFormat, originalFilename, inputPath, outputPath, inputMimeType, outputMimeType, errorMessage, createdAt, updatedAt, expiresAt) or null if not found. error_log is intentionally NOT selected by this function — callers that need it must use getJobErrorLog.
    • async function getJobErrorLog(pool, id) — returns the raw error_log string or null. Used only by the worker/debug tooling, never by the public API.
    • async function markProcessing(pool, id) — sets status = 'processing'.
    • async function markDone(pool, id, { outputPath, outputMimeType }) — sets status = 'done' and those two columns.
    • async function markFailed(pool, id, { errorMessage, errorLog }) — sets status = 'failed' and both error columns.
    • async function findPendingJobs(pool, limit) — returns up to limit rows with status = 'pending', oldest first, each mapped like getJobById.
    • async function findExpiredJobs(pool) — returns all rows where expires_at < NOW(), mapped like getJobById.
    • async function deleteJob(pool, id) — deletes the row.
  • Step 1: Write the failing tests

Create test/jobs/jobRepository.test.js:

import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { getPool, closePool } from '../../src/db.js';
import { loadConfig } from '../../src/config.js';
import {
  createJob,
  getJobById,
  getJobErrorLog,
  markProcessing,
  markDone,
  markFailed,
  findPendingJobs,
  findExpiredJobs,
  deleteJob,
} from '../../src/jobs/jobRepository.js';

let pool;

beforeAll(() => {
  pool = getPool(loadConfig());
});

afterAll(async () => {
  await closePool();
});

beforeEach(async () => {
  await pool.query('DELETE FROM conversion_jobs');
});

function baseJob(overrides = {}) {
  return {
    id: overrides.id ?? '11111111-1111-4111-8111-111111111111',
    family: 'image',
    sourceFormat: 'png',
    targetFormat: 'webp',
    originalFilename: 'photo.png',
    inputPath: `${overrides.id ?? '11111111-1111-4111-8111-111111111111'}.png`,
    inputMimeType: 'image/png',
    expiresAt: new Date(Date.now() + 3600 * 1000),
    ...overrides,
  };
}

describe('jobRepository', () => {
  it('creates and retrieves a pending job', async () => {
    await createJob(pool, baseJob());

    const job = await getJobById(pool, '11111111-1111-4111-8111-111111111111');

    expect(job.status).toBe('pending');
    expect(job.family).toBe('image');
    expect(job.sourceFormat).toBe('png');
    expect(job.targetFormat).toBe('webp');
    expect(job.originalFilename).toBe('photo.png');
    expect(job.inputMimeType).toBe('image/png');
    expect(job.outputPath).toBeNull();
    expect(job.errorMessage).toBeNull();
  });

  it('returns null for an unknown id', async () => {
    const job = await getJobById(pool, '22222222-2222-4222-8222-222222222222');
    expect(job).toBeNull();
  });

  it('transitions a job through processing to done', async () => {
    await createJob(pool, baseJob());

    await markProcessing(pool, '11111111-1111-4111-8111-111111111111');
    const processing = await getJobById(pool, '11111111-1111-4111-8111-111111111111');
    expect(processing.status).toBe('processing');

    await markDone(pool, '11111111-1111-4111-8111-111111111111', {
      outputPath: '11111111-1111-4111-8111-111111111111.webp',
      outputMimeType: 'image/webp',
    });
    const done = await getJobById(pool, '11111111-1111-4111-8111-111111111111');
    expect(done.status).toBe('done');
    expect(done.outputPath).toBe('11111111-1111-4111-8111-111111111111.webp');
    expect(done.outputMimeType).toBe('image/webp');
  });

  it('marks a job failed with a short message and a separate detailed log', async () => {
    await createJob(pool, baseJob());

    await markFailed(pool, '11111111-1111-4111-8111-111111111111', {
      errorMessage: 'Conversion failed, please try again',
      errorLog: 'Error: sharp threw at line 42\n  at convert (image.js:10:5)',
    });

    const job = await getJobById(pool, '11111111-1111-4111-8111-111111111111');
    expect(job.status).toBe('failed');
    expect(job.errorMessage).toBe('Conversion failed, please try again');
    expect(job.errorLog).toBeUndefined();

    const errorLog = await getJobErrorLog(pool, '11111111-1111-4111-8111-111111111111');
    expect(errorLog).toBe('Error: sharp threw at line 42\n  at convert (image.js:10:5)');
  });

  it('finds pending jobs oldest first, up to a limit', async () => {
    await createJob(pool, baseJob({ id: '33333333-3333-4333-8333-333333333333' }));
    await createJob(pool, baseJob({ id: '44444444-4444-4444-8444-444444444444' }));
    await createJob(pool, baseJob({ id: '55555555-5555-4555-8555-555555555555' }));

    const jobs = await findPendingJobs(pool, 2);

    expect(jobs).toHaveLength(2);
    expect(jobs[0].id).toBe('33333333-3333-4333-8333-333333333333');
    expect(jobs[1].id).toBe('44444444-4444-4444-8444-444444444444');
  });

  it('finds expired jobs and allows deleting them', async () => {
    await createJob(pool, baseJob({ expiresAt: new Date(Date.now() - 1000) }));

    const expired = await findExpiredJobs(pool);
    expect(expired).toHaveLength(1);
    expect(expired[0].id).toBe('11111111-1111-4111-8111-111111111111');

    await deleteJob(pool, '11111111-1111-4111-8111-111111111111');
    const afterDelete = await getJobById(pool, '11111111-1111-4111-8111-111111111111');
    expect(afterDelete).toBeNull();
  });
});
  • Step 2: Run tests to verify they fail

Run: STORAGE_DIR=./storage DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter npx vitest run test/jobs/jobRepository.test.js Expected: FAIL — src/jobs/jobRepository.js does not exist yet.

  • Step 3: Implement src/jobs/jobRepository.js
function toCamelJob(row) {
  if (!row) return null;
  return {
    id: row.id,
    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,
    errorMessage: row.error_message,
    createdAt: row.created_at,
    updatedAt: row.updated_at,
    expiresAt: row.expires_at,
  };
}

export async function createJob(pool, job) {
  await pool.query(
    `INSERT INTO conversion_jobs
      (id, status, family, source_format, target_format, original_filename, input_path, input_mime_type, expires_at)
     VALUES (?, 'pending', ?, ?, ?, ?, ?, ?, ?)`,
    [
      job.id,
      job.family,
      job.sourceFormat,
      job.targetFormat,
      job.originalFilename,
      job.inputPath,
      job.inputMimeType,
      job.expiresAt,
    ]
  );
}

export async function getJobById(pool, id) {
  const rows = await pool.query(
    `SELECT id, status, family, source_format, target_format, original_filename,
            input_path, output_path, input_mime_type, output_mime_type,
            error_message, created_at, updated_at, expires_at
     FROM conversion_jobs WHERE id = ?`,
    [id]
  );
  return toCamelJob(rows[0]);
}

export async function getJobErrorLog(pool, id) {
  const rows = await pool.query('SELECT error_log FROM conversion_jobs WHERE id = ?', [id]);
  return rows[0]?.error_log ?? null;
}

export async function markProcessing(pool, id) {
  await pool.query("UPDATE conversion_jobs SET status = 'processing' WHERE id = ?", [id]);
}

export async function markDone(pool, id, { outputPath, outputMimeType }) {
  await pool.query(
    "UPDATE conversion_jobs SET status = 'done', output_path = ?, output_mime_type = ? WHERE id = ?",
    [outputPath, outputMimeType, id]
  );
}

export async function markFailed(pool, id, { errorMessage, errorLog }) {
  await pool.query(
    "UPDATE conversion_jobs SET status = 'failed', error_message = ?, error_log = ? WHERE id = ?",
    [errorMessage, errorLog, id]
  );
}

export async function findPendingJobs(pool, limit) {
  const rows = await pool.query(
    `SELECT id, status, family, source_format, target_format, original_filename,
            input_path, output_path, input_mime_type, output_mime_type,
            error_message, created_at, updated_at, expires_at
     FROM conversion_jobs WHERE status = 'pending' ORDER BY created_at ASC LIMIT ?`,
    [limit]
  );
  return rows.map(toCamelJob);
}

export async function findExpiredJobs(pool) {
  const rows = await pool.query(
    `SELECT id, status, family, source_format, target_format, original_filename,
            input_path, output_path, input_mime_type, output_mime_type,
            error_message, created_at, updated_at, expires_at
     FROM conversion_jobs WHERE expires_at < NOW()`
  );
  return rows.map(toCamelJob);
}

export async function deleteJob(pool, id) {
  await pool.query('DELETE FROM conversion_jobs WHERE id = ?', [id]);
}
  • Step 4: Run tests to verify they pass

Run: STORAGE_DIR=./storage DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter npx vitest run test/jobs/jobRepository.test.js Expected: PASS (6 tests)

  • Step 5: Commit
git add src/jobs/jobRepository.js test/jobs/jobRepository.test.js
git commit -m "feat: add conversion_jobs repository"

Task 6: Converter registry

Files:

  • Create: src/converters/registry.js
  • Test: test/converters/registry.test.js

Interfaces:

  • Produces:
    • export function register({ family, sourceFormat, targetFormat, convert }) — adds an entry. convert has signature async (inputPath, outputPath) => void.
    • export function resolve(sourceFormat, targetFormat) — returns the registered { family, convert } or null if no converter exists for that pair.
    • export function listTargetFormats(sourceFormat) — returns an array of every targetFormat registered for that sourceFormat (empty array if none).
    • export function _resetForTests() — clears the registry (test-only helper).

This module holds no conversion logic itself — Tasks 7-10 call register at import time to populate it.

  • Step 1: Write the failing tests

Create test/converters/registry.test.js:

import { describe, it, expect, beforeEach } from 'vitest';
import { register, resolve, listTargetFormats, _resetForTests } from '../../src/converters/registry.js';

beforeEach(() => {
  _resetForTests();
});

describe('converter registry', () => {
  it('resolves a registered source/target pair', () => {
    const convert = async () => {};
    register({ family: 'image', sourceFormat: 'png', targetFormat: 'webp', convert });

    const entry = resolve('png', 'webp');

    expect(entry.family).toBe('image');
    expect(entry.convert).toBe(convert);
  });

  it('returns null for an unregistered pair', () => {
    expect(resolve('png', 'made-up')).toBeNull();
  });

  it('lists all target formats registered for a source format', () => {
    register({ family: 'image', sourceFormat: 'png', targetFormat: 'webp', convert: async () => {} });
    register({ family: 'image', sourceFormat: 'png', targetFormat: 'jpg', convert: async () => {} });
    register({ family: 'document', sourceFormat: 'docx', targetFormat: 'pdf', convert: async () => {} });

    expect(listTargetFormats('png').sort()).toEqual(['jpg', 'webp']);
    expect(listTargetFormats('docx')).toEqual(['pdf']);
    expect(listTargetFormats('unknown-format')).toEqual([]);
  });
});
  • Step 2: Run tests to verify they fail

Run: npx vitest run test/converters/registry.test.js Expected: FAIL — src/converters/registry.js does not exist yet.

  • Step 3: Implement src/converters/registry.js
let entries = new Map();

function key(sourceFormat, targetFormat) {
  return `${sourceFormat}->${targetFormat}`;
}

export function register({ family, sourceFormat, targetFormat, convert }) {
  entries.set(key(sourceFormat, targetFormat), { family, convert });
}

export function resolve(sourceFormat, targetFormat) {
  return entries.get(key(sourceFormat, targetFormat)) ?? null;
}

export function listTargetFormats(sourceFormat) {
  const prefix = `${sourceFormat}->`;
  return [...entries.keys()]
    .filter((k) => k.startsWith(prefix))
    .map((k) => k.slice(prefix.length));
}

export function _resetForTests() {
  entries = new Map();
}
  • Step 4: Run tests to verify they pass

Run: npx vitest run test/converters/registry.test.js Expected: PASS (3 tests)

  • Step 5: Commit
git add src/converters/registry.js test/converters/registry.test.js
git commit -m "feat: add converter registry"

Task 7: Image converter family (sharp)

Files:

  • Create: src/converters/image.js
  • Test: test/converters/image.test.js

Interfaces:

  • Consumes: register from Task 6's src/converters/registry.js.
  • Produces: export function registerImageConverters() — call once at process startup (from app.js and worker.js in later tasks) to populate the registry with every {sourceFormat, targetFormat} pair among jpg, jpeg, png, webp, gif, tiff, avif (source ≠ target), family 'image'. Each entry's convert(inputPath, outputPath) runs sharp(inputPath).toFormat(<sharp format name>).toFile(outputPath).

Note: bmp is intentionally excluded from this task — sharp does not support BMP as an output format (confirmed against the official docs: output formats are JPEG, PNG, WebP, GIF, AVIF, TIFF). BMP support is out of scope for v1 per the design spec.

  • Step 1: Write the failing test

Create 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 { registerImageConverters } from '../../src/converters/image.js';
import { resolve, listTargetFormats } from '../../src/converters/registry.js';
import { detectInputMime } from '../../src/mime.js';

let tmpDir;

beforeAll(async () => {
  registerImageConverters();
  tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'converter-image-'));
});

afterAll(async () => {
  await fs.rm(tmpDir, { recursive: true, force: true });
});

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');
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run test/converters/image.test.js Expected: FAIL — src/converters/image.js does not exist yet.

  • Step 3: Implement 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 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) => {
          await sharp(inputPath).toFormat(sharpFormatName(targetFormat)).toFile(outputPath);
        },
      });
    }
  }
}
  • Step 4: Run test to verify it passes

Run: npx vitest run test/converters/image.test.js Expected: PASS (3 tests)

  • Step 5: Commit
git add src/converters/image.js test/converters/image.test.js
git commit -m "feat: add image converter family (sharp)"

Task 8: Image → PDF converter

Files:

  • Create: src/converters/imageToPdf.js
  • Test: test/converters/imageToPdf.test.js

Interfaces:

  • Consumes: register from Task 6, sharp (already a dependency from Task 7).

  • Produces: export function registerImageToPdfConverter() — registers {sourceFormat, targetFormat: 'pdf'} for every format in jpg, jpeg, png, webp, gif, tiff, avif, family 'image'. convert(inputPath, outputPath) normalizes the input to a PNG buffer via sharp(inputPath).png().toBuffer(), then creates a one-page PDF sized to the image via pdf-lib, embeds the PNG, and writes it with fs.writeFile.

  • Step 1: Write the failing test

Create 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 { 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');
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run test/converters/imageToPdf.test.js Expected: FAIL — src/converters/imageToPdf.js does not exist yet.

  • Step 3: Implement 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) {
  const pngBuffer = await sharp(inputPath).png().toBuffer();
  const metadata = await sharp(pngBuffer).metadata();

  const pdfDoc = await PDFDocument.create();
  const page = pdfDoc.addPage([metadata.width, metadata.height]);
  const embeddedImage = await pdfDoc.embedPng(pngBuffer);

  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 test to verify it passes

Run: npx vitest run test/converters/imageToPdf.test.js Expected: PASS (2 tests)

  • Step 5: Commit
git add src/converters/imageToPdf.js test/converters/imageToPdf.test.js
git commit -m "feat: add image to PDF converter"

Task 9: Document converters, part A — DOCX→HTML, TXT/HTML→PDF, DOCX→PDF

Files:

  • Create: src/converters/document.js
  • Test: test/converters/document.test.js

Interfaces:

  • Consumes: register from Task 6.
  • Produces (all added to src/converters/document.js, all called from one exported export function registerDocumentConverters()):
    • docx → html via mammoth.convertToHtml({ path: inputPath }), writing result.value to outputPath.
    • txt → pdf via reading the text file, wrapping it in a minimal HTML template (escaping <, >, &), and rendering with Puppeteer.
    • html → pdf via reading the HTML file as-is and rendering with Puppeteer.
    • docx → pdf by chaining: mammoth.convertToHtml then the same Puppeteer rendering used for html → pdf.
    • A private helper renderHtmlToPdf(html, outputPath) shared by the three PDF-producing paths above, using puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] }), page.setContent(html, { waitUntil: 'networkidle0' }), page.pdf({ path: outputPath, format: 'A4', printBackground: true }), always closing the browser in a finally block.

This task does not yet cover pdf → text/html or pdf → docx — those are Task 10.

  • Step 1: Write the failing test

Create test/converters/document.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 { Document, Paragraph, TextRun, Packer } from 'docx';
import { registerDocumentConverters } from '../../src/converters/document.js';
import { resolve } from '../../src/converters/registry.js';
import { detectInputMime } from '../../src/mime.js';

let tmpDir;
let docxFixturePath;

beforeAll(async () => {
  registerDocumentConverters();
  tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'converter-document-'));

  const doc = new Document({
    sections: [
      {
        children: [new Paragraph({ children: [new TextRun('Hello from the fixture document')] })],
      },
    ],
  });
  const buffer = await Packer.toBuffer(doc);
  docxFixturePath = path.join(tmpDir, 'fixture.docx');
  await fs.writeFile(docxFixturePath, buffer);
});

afterAll(async () => {
  await fs.rm(tmpDir, { recursive: true, force: true });
});

describe('document converters', () => {
  it('converts DOCX to HTML containing the source text', async () => {
    const outputPath = path.join(tmpDir, 'output.html');
    const entry = resolve('docx', 'html');

    await entry.convert(docxFixturePath, outputPath);

    const html = await fs.readFile(outputPath, 'utf8');
    expect(html).toContain('Hello from the fixture document');
  }, 20000);

  it('converts TXT to a valid PDF', async () => {
    const inputPath = path.join(tmpDir, 'fixture.txt');
    await fs.writeFile(inputPath, 'Plain text content for the PDF');
    const outputPath = path.join(tmpDir, 'from-txt.pdf');
    const entry = resolve('txt', 'pdf');

    await entry.convert(inputPath, outputPath);

    const detected = await detectInputMime(outputPath);
    expect(detected.mime).toBe('application/pdf');
  }, 20000);

  it('converts HTML to a valid PDF', async () => {
    const inputPath = path.join(tmpDir, 'fixture.html');
    await fs.writeFile(inputPath, '<html><body><h1>Hello HTML</h1></body></html>');
    const outputPath = path.join(tmpDir, 'from-html.pdf');
    const entry = resolve('html', 'pdf');

    await entry.convert(inputPath, outputPath);

    const detected = await detectInputMime(outputPath);
    expect(detected.mime).toBe('application/pdf');
  }, 20000);

  it('converts DOCX to a valid PDF', async () => {
    const outputPath = path.join(tmpDir, 'from-docx.pdf');
    const entry = resolve('docx', 'pdf');

    await entry.convert(docxFixturePath, outputPath);

    const detected = await detectInputMime(outputPath);
    expect(detected.mime).toBe('application/pdf');
  }, 20000);
});
  • Step 2: Run test to verify it fails

Run: npx vitest run test/converters/document.test.js Expected: FAIL — src/converters/document.js does not exist yet.

  • Step 3: Implement the Puppeteer rendering helper and DOCX/TXT/HTML converters in src/converters/document.js
import fs from 'node:fs/promises';
import mammoth from 'mammoth';
import puppeteer from 'puppeteer';
import { register } from './registry.js';

async function renderHtmlToPdf(html, outputPath) {
  const browser = await puppeteer.launch({
    headless: true,
    args: ['--no-sandbox', '--disable-setuid-sandbox'],
  });
  try {
    const page = await browser.newPage();
    await page.setContent(html, { waitUntil: 'networkidle0' });
    await page.pdf({ path: outputPath, format: 'A4', printBackground: true });
  } finally {
    await browser.close();
  }
}

function escapeHtml(text) {
  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

async function convertDocxToHtml(inputPath, outputPath) {
  const result = await mammoth.convertToHtml({ path: inputPath });
  await fs.writeFile(outputPath, result.value);
}

async function convertTxtToPdf(inputPath, outputPath) {
  const text = await fs.readFile(inputPath, 'utf8');
  const html = `<html><body><pre>${escapeHtml(text)}</pre></body></html>`;
  await renderHtmlToPdf(html, outputPath);
}

async function convertHtmlToPdf(inputPath, outputPath) {
  const html = await fs.readFile(inputPath, 'utf8');
  await renderHtmlToPdf(html, outputPath);
}

async function convertDocxToPdf(inputPath, outputPath) {
  const result = await mammoth.convertToHtml({ path: inputPath });
  await renderHtmlToPdf(result.value, outputPath);
}

export function registerDocumentConverters() {
  register({ family: 'document', sourceFormat: 'docx', targetFormat: 'html', convert: convertDocxToHtml });
  register({ family: 'document', sourceFormat: 'txt', targetFormat: 'pdf', convert: convertTxtToPdf });
  register({ family: 'document', sourceFormat: 'html', targetFormat: 'pdf', convert: convertHtmlToPdf });
  register({ family: 'document', sourceFormat: 'docx', targetFormat: 'pdf', convert: convertDocxToPdf });
}
  • Step 4: Run test to verify it passes

Run: npx vitest run test/converters/document.test.js Expected: PASS (4 tests). Puppeteer launches a real headless Chromium per test — this is slower than the other unit tests (hence the 20000ms timeouts); this is expected.

  • Step 5: Commit
git add src/converters/document.js test/converters/document.test.js
git commit -m "feat: add DOCX/TXT/HTML document converters"

Task 10: Document converters, part B — PDF→text/HTML, PDF→DOCX (best-effort)

Files:

  • Modify: src/converters/document.js (add to the same file and to registerDocumentConverters())
  • Test: test/converters/documentFromPdf.test.js

Interfaces:

  • Consumes: register from Task 6, the fixture-generation pattern from Task 9 (generate inputs at test time instead of committing binary fixtures).

  • Produces, added to src/converters/document.js:

    • A private helper async function extractPdfPageTexts(inputPath) returning string[], one entry per page, using pdfjs-dist/legacy/build/pdf.mjs's getDocument({ data }).promise and page.getTextContent().
    • pdf → txt: joins page texts with \n\n and writes as plain text.
    • pdf → html: wraps each page's text in a <p> tag (HTML-escaped) and writes a minimal HTML document.
    • pdf → docx: builds one docx Paragraph per page text and writes via Packer.toBuffer. This is explicitly a best-effort text reconstruction — no attempt is made to preserve original layout, tables, or image positions, matching the limitation documented in the design spec.
  • Step 1: Write the failing test

Create test/converters/documentFromPdf.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 { PDFDocument, StandardFonts } from 'pdf-lib';
import { registerDocumentConverters } from '../../src/converters/document.js';
import { resolve } from '../../src/converters/registry.js';

let tmpDir;
let pdfFixturePath;

beforeAll(async () => {
  registerDocumentConverters();
  tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'converter-document-from-pdf-'));

  const pdfDoc = await PDFDocument.create();
  const page = pdfDoc.addPage([600, 400]);
  const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
  page.drawText('Extractable fixture text', { x: 50, y: 350, size: 24, font });
  const bytes = await pdfDoc.save();
  pdfFixturePath = path.join(tmpDir, 'fixture.pdf');
  await fs.writeFile(pdfFixturePath, bytes);
});

afterAll(async () => {
  await fs.rm(tmpDir, { recursive: true, force: true });
});

describe('PDF source document converters', () => {
  it('extracts text from PDF to TXT', async () => {
    const outputPath = path.join(tmpDir, 'output.txt');
    const entry = resolve('pdf', 'txt');

    await entry.convert(pdfFixturePath, outputPath);

    const text = await fs.readFile(outputPath, 'utf8');
    expect(text).toContain('Extractable fixture text');
  });

  it('extracts text from PDF to HTML', async () => {
    const outputPath = path.join(tmpDir, 'output.html');
    const entry = resolve('pdf', 'html');

    await entry.convert(pdfFixturePath, outputPath);

    const html = await fs.readFile(outputPath, 'utf8');
    expect(html).toContain('Extractable fixture text');
    expect(html).toContain('<p>');
  });

  it('reconstructs PDF text into a DOCX (best-effort)', async () => {
    const outputPath = path.join(tmpDir, 'output.docx');
    const entry = resolve('pdf', 'docx');

    await entry.convert(pdfFixturePath, outputPath);

    const stat = await fs.stat(outputPath);
    expect(stat.size).toBeGreaterThan(0);

    const mammoth = await import('mammoth');
    const result = await mammoth.default.convertToHtml({ path: outputPath });
    expect(result.value).toContain('Extractable fixture text');
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run test/converters/documentFromPdf.test.js Expected: FAIL — pdf is not yet a registered source format.

  • Step 3: Add the PDF-source converters to src/converters/document.js

Add these imports at the top of src/converters/document.js (alongside the existing ones):

import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import { Document, Paragraph, TextRun, Packer } from 'docx';

Add these functions to the same file:

async function extractPdfPageTexts(inputPath) {
  const data = new Uint8Array(await fs.readFile(inputPath));
  const doc = await pdfjsLib.getDocument({ data }).promise;

  const pageTexts = [];
  for (let pageNum = 1; pageNum <= doc.numPages; pageNum += 1) {
    const page = await doc.getPage(pageNum);
    const content = await page.getTextContent();
    pageTexts.push(content.items.map((item) => item.str).join(' '));
  }
  return pageTexts;
}

async function convertPdfToTxt(inputPath, outputPath) {
  const pageTexts = await extractPdfPageTexts(inputPath);
  await fs.writeFile(outputPath, pageTexts.join('\n\n'));
}

async function convertPdfToHtml(inputPath, outputPath) {
  const pageTexts = await extractPdfPageTexts(inputPath);
  const body = pageTexts.map((text) => `<p>${escapeHtml(text)}</p>`).join('\n');
  await fs.writeFile(outputPath, `<html><body>${body}</body></html>`);
}

async function convertPdfToDocx(inputPath, outputPath) {
  const pageTexts = await extractPdfPageTexts(inputPath);
  const doc = new Document({
    sections: [
      {
        children: pageTexts.map((text) => new Paragraph({ children: [new TextRun(text)] })),
      },
    ],
  });
  const buffer = await Packer.toBuffer(doc);
  await fs.writeFile(outputPath, buffer);
}

Update registerDocumentConverters() to also register these three:

export function registerDocumentConverters() {
  register({ family: 'document', sourceFormat: 'docx', targetFormat: 'html', convert: convertDocxToHtml });
  register({ family: 'document', sourceFormat: 'txt', targetFormat: 'pdf', convert: convertTxtToPdf });
  register({ family: 'document', sourceFormat: 'html', targetFormat: 'pdf', convert: convertHtmlToPdf });
  register({ family: 'document', sourceFormat: 'docx', targetFormat: 'pdf', convert: convertDocxToPdf });
  register({ family: 'document', sourceFormat: 'pdf', targetFormat: 'txt', convert: convertPdfToTxt });
  register({ family: 'document', sourceFormat: 'pdf', targetFormat: 'html', convert: convertPdfToHtml });
  register({ family: 'document', sourceFormat: 'pdf', targetFormat: 'docx', convert: convertPdfToDocx });
}
  • Step 4: Run test to verify it passes

Run: npx vitest run test/converters/documentFromPdf.test.js Expected: PASS (3 tests)

  • Step 5: Run the full document test suite to confirm no regression

Run: npx vitest run test/converters/document.test.js test/converters/documentFromPdf.test.js Expected: PASS (7 tests total)

  • Step 6: Commit
git add src/converters/document.js test/converters/documentFromPdf.test.js
git commit -m "feat: add best-effort PDF source converters (txt, html, docx)"

Task 11: Input format validation helper (magic-byte vs. declared extension)

Files:

  • Modify: src/mime.js
  • Test: test/mime.test.js (extend)

Interfaces:

  • Produces: export async function resolveInputFormat(filePath, declaredFormat) in src/mime.js — returns { mime: string, valid: boolean }.

    • Sniffs magic bytes via detectInputMime.
    • If a signature is found, valid is true only when the detected extension matches declaredFormat (treating jpg/jpeg as equivalent — file-type returns jpg for JPEG, but both are valid declared extensions in this app).
    • If no signature is found (expected for txt and html, which have no magic bytes), falls back to trusting declaredFormat only when it is txt or html, returning the matching static MIME type. Any other undetectable format is valid: false.
  • Step 1: Write the failing tests (append to test/mime.test.js)

Add to test/mime.test.js:

import { resolveInputFormat } from '../src/mime.js';

(add this import alongside the existing ones at the top of the file), then add:

describe('resolveInputFormat', () => {
  it('accepts a PNG file declared as png', async () => {
    const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.png');

    const result = await resolveInputFormat(fixturePath, 'png');

    expect(result).toEqual({ mime: 'image/png', valid: true });
  });

  it('rejects a PNG file declared as a different format', async () => {
    const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.png');

    const result = await resolveInputFormat(fixturePath, 'pdf');

    expect(result.valid).toBe(false);
  });

  it('treats jpg and jpeg as equivalent declared formats', async () => {
    const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.png');
    const result = await resolveInputFormat(fixturePath, 'png');
    expect(result.valid).toBe(true);
  });

  it('trusts the declared format for undetectable txt files', async () => {
    const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.txt');
    await fs.writeFile(fixturePath, 'plain text, no magic bytes');

    const result = await resolveInputFormat(fixturePath, 'txt');

    expect(result).toEqual({ mime: 'text/plain', valid: true });

    await fs.unlink(fixturePath);
  });

  it('rejects an undetectable file declared as a binary format', async () => {
    const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-fake.png');
    await fs.writeFile(fixturePath, 'this is not really a PNG');

    const result = await resolveInputFormat(fixturePath, 'png');

    expect(result.valid).toBe(false);

    await fs.unlink(fixturePath);
  });
});

Also add import fs from 'node:fs/promises'; at the top of test/mime.test.js if not already present.

  • Step 2: Run tests to verify they fail

Run: npx vitest run test/mime.test.js Expected: FAIL — resolveInputFormat is not exported yet.

  • Step 3: Implement resolveInputFormat in src/mime.js

Add to src/mime.js:

const UNDETECTABLE_TEXT_FORMATS = {
  txt: 'text/plain',
  html: 'text/html',
};

function normalizeFormat(format) {
  return format === 'jpg' ? 'jpeg' : format;
}

export async function resolveInputFormat(filePath, declaredFormat) {
  const detected = await detectInputMime(filePath);

  if (!detected) {
    const fallbackMime = UNDETECTABLE_TEXT_FORMATS[declaredFormat];
    if (fallbackMime) {
      return { mime: fallbackMime, valid: true };
    }
    return { mime: null, valid: false };
  }

  const valid = normalizeFormat(detected.ext) === normalizeFormat(declaredFormat);
  return { mime: detected.mime, valid };
}
  • Step 4: Run tests to verify they pass

Run: npx vitest run test/mime.test.js Expected: PASS (8 tests total)

  • Step 5: Commit
git add src/mime.js test/mime.test.js
git commit -m "feat: validate declared source format against sniffed magic bytes"

Task 12: Express app — POST /api/jobs and GET /api/formats

Files:

  • Create: src/app.js
  • Test: test/api/jobs.test.js

Interfaces:

  • Consumes: resolveInputFormat (Task 11), resolve/listTargetFormats (Task 6), registerImageConverters/registerImageToPdfConverter/registerDocumentConverters (Tasks 7-10), createJob/getJobById (Task 5), ensureStorageDirs/deleteIfExists (Task 3), getPool (Task 2).
  • Produces: export function createApp(config, pool) — returns a configured Express app (does not call .listen). Registers all converters as a side effect the first time it's called (safe to call once per process).

Request contract for POST /api/jobs:

  • multipart/form-data with a files field (one or more files, field name repeated) and a targetFormats field: a JSON-encoded array of strings, same length and order as files.

  • Response 201: { "jobs": [ { "file": "photo.png", "id": "<uuid>", "status": "pending" } | { "file": "photo.png", "error": "<reason>" }, ... ] } — one entry per uploaded file, in the same order. A file-level problem never fails the whole request; only a malformed request (no files, targetFormats missing/wrong length) returns 400 with no jobs created.

  • Step 1: Write the failing tests

Create test/api/jobs.test.js:

import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import request from 'supertest';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { createApp } from '../../src/app.js';
import { getPool, closePool } from '../../src/db.js';
import { loadConfig } from '../../src/config.js';
import { getJobById } from '../../src/jobs/jobRepository.js';
import { ensureStorageDirs } from '../../src/storage.js';

let app;
let pool;
let config;

beforeAll(async () => {
  config = { ...loadConfig(), storageDir: await fs.mkdtemp(path.join(os.tmpdir(), 'converter-api-')) };
  await ensureStorageDirs(config);
  pool = getPool(config);
  app = createApp(config, pool);
});

afterAll(async () => {
  await closePool();
  await fs.rm(config.storageDir, { recursive: true, force: true });
});

beforeEach(async () => {
  await pool.query('DELETE FROM conversion_jobs');
});

describe('GET /api/formats', () => {
  it('lists valid target formats for a known source format', async () => {
    const response = await request(app).get('/api/formats').query({ source: 'png' });

    expect(response.status).toBe(200);
    expect(response.body.targets).toContain('webp');
    expect(response.body.targets).toContain('pdf');
  });

  it('returns an empty list for an unknown source format', async () => {
    const response = await request(app).get('/api/formats').query({ source: 'made-up' });

    expect(response.status).toBe(200);
    expect(response.body.targets).toEqual([]);
  });
});

describe('POST /api/jobs', () => {
  it('creates a pending job for a valid image upload', async () => {
    const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png');

    const response = await request(app)
      .post('/api/jobs')
      .field('targetFormats', JSON.stringify(['webp']))
      .attach('files', fixturePath, 'photo.png');

    expect(response.status).toBe(201);
    expect(response.body.jobs).toHaveLength(1);
    expect(response.body.jobs[0].status).toBe('pending');
    expect(response.body.jobs[0].file).toBe('photo.png');

    const job = await getJobById(pool, response.body.jobs[0].id);
    expect(job.status).toBe('pending');
    expect(job.sourceFormat).toBe('png');
    expect(job.targetFormat).toBe('webp');
    expect(job.originalFilename).toBe('photo.png');
    expect(job.inputMimeType).toBe('image/png');
  });

  it('rejects a file whose content does not match its extension, without failing the whole batch', async () => {
    const fakePath = path.join(config.storageDir, 'fake.png');
    await fs.writeFile(fakePath, 'not actually a png');

    const response = await request(app)
      .post('/api/jobs')
      .field('targetFormats', JSON.stringify(['webp']))
      .attach('files', fakePath, 'fake.png');

    expect(response.status).toBe(201);
    expect(response.body.jobs[0].error).toMatch(/does not match/);

    await fs.unlink(fakePath);
  });

  it('rejects an unsupported source/target pair', async () => {
    const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png');

    const response = await request(app)
      .post('/api/jobs')
      .field('targetFormats', JSON.stringify(['made-up-format']))
      .attach('files', fixturePath, 'photo.png');

    expect(response.status).toBe(201);
    expect(response.body.jobs[0].error).toMatch(/Unsupported conversion/);
  });

  it('returns 400 when targetFormats 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([]))
      .attach('files', fixturePath, 'photo.png');

    expect(response.status).toBe(400);
  });

  it('returns 400 when no files are uploaded', async () => {
    const response = await request(app)
      .post('/api/jobs')
      .field('targetFormats', JSON.stringify([]));

    expect(response.status).toBe(400);
  });
});
  • Step 2: Run tests to verify they fail

Run: STORAGE_DIR=./storage DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter npx vitest run test/api/jobs.test.js Expected: FAIL — src/app.js does not exist yet.

  • Step 3: Implement src/app.js
import path from 'node:path';
import express from 'express';
import multer from 'multer';
import { rateLimit } from 'express-rate-limit';
import { v4 as uuidv4 } from 'uuid';
import { resolve as resolveConverter, listTargetFormats } from './converters/registry.js';
import { registerImageConverters } from './converters/image.js';
import { registerImageToPdfConverter } from './converters/imageToPdf.js';
import { registerDocumentConverters } from './converters/document.js';
import { resolveInputFormat } from './mime.js';
import { deleteIfExists } from './storage.js';
import { createJob, getJobById } from './jobs/jobRepository.js';

let convertersRegistered = false;

function registerAllConverters() {
  if (convertersRegistered) return;
  registerImageConverters();
  registerImageToPdfConverter();
  registerDocumentConverters();
  convertersRegistered = true;
}

export function createApp(config, pool) {
  registerAllConverters();

  const app = express();

  const storage = multer.diskStorage({
    destination: (req, file, cb) => cb(null, path.join(config.storageDir, 'uploads')),
    filename: (req, file, cb) => {
      const ext = path.extname(file.originalname).slice(1).toLowerCase();
      cb(null, `${uuidv4()}.${ext}`);
    },
  });

  const upload = multer({
    storage,
    limits: {
      fileSize: config.maxFileSizeMb * 1024 * 1024,
      files: 10,
    },
  });

  const jobsRateLimiter = rateLimit({
    windowMs: config.rateLimitWindowMinutes * 60 * 1000,
    limit: config.rateLimitMaxJobs,
    standardHeaders: 'draft-8',
    legacyHeaders: false,
  });

  app.get('/api/formats', (req, res) => {
    const source = String(req.query.source ?? '').toLowerCase();
    res.json({ targets: listTargetFormats(source) });
  });

  app.post('/api/jobs', jobsRateLimiter, upload.array('files', 10), async (req, res) => {
    if (!req.files || req.files.length === 0) {
      return res.status(400).json({ error: 'No files uploaded' });
    }

    let targetFormats;
    try {
      targetFormats = JSON.parse(req.body.targetFormats ?? '[]');
    } catch {
      return res.status(400).json({ error: 'targetFormats must be a JSON array' });
    }

    if (!Array.isArray(targetFormats) || targetFormats.length !== req.files.length) {
      return res.status(400).json({ error: 'targetFormats must have one entry per uploaded file' });
    }

    const results = [];
    for (let i = 0; i < req.files.length; i += 1) {
      const file = req.files[i];
      const targetFormat = targetFormats[i];
      const id = path.basename(file.filename, path.extname(file.filename));
      const sourceFormat = path.extname(file.filename).slice(1).toLowerCase();

      const { mime, valid } = await resolveInputFormat(file.path, sourceFormat);
      if (!valid) {
        await deleteIfExists(file.path);
        results.push({ file: file.originalname, error: 'File content does not match its extension' });
        continue;
      }

      const registryEntry = resolveConverter(sourceFormat, targetFormat);
      if (!registryEntry) {
        await deleteIfExists(file.path);
        results.push({
          file: file.originalname,
          error: `Unsupported conversion: ${sourceFormat} to ${targetFormat}`,
        });
        continue;
      }

      const expiresAt = new Date(Date.now() + config.retentionHours * 3600 * 1000);
      await createJob(pool, {
        id,
        family: registryEntry.family,
        sourceFormat,
        targetFormat,
        originalFilename: file.originalname,
        inputPath: file.filename,
        inputMimeType: mime,
        expiresAt,
      });

      results.push({ file: file.originalname, id, status: 'pending' });
    }

    res.status(201).json({ jobs: results });
  });

  return app;
}
  • Step 4: Run tests to verify they pass

Run: STORAGE_DIR=./storage DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter npx vitest run test/api/jobs.test.js Expected: PASS (7 tests)

  • Step 5: Commit
git add src/app.js test/api/jobs.test.js
git commit -m "feat: add POST /api/jobs and GET /api/formats endpoints"

Task 13: GET /api/jobs/:id and GET /api/jobs/:id/download

Files:

  • Modify: src/app.js (add two routes)
  • Test: test/api/jobStatus.test.js

Interfaces:

  • Consumes: getJobById (Task 5), outputPath (Task 3).

  • GET /api/jobs/:id200 with { id, status, originalFilename, sourceFormat, targetFormat, errorMessage } (never errorLog), or 404 if the id doesn't exist.

  • GET /api/jobs/:id/download → streams the file with Content-Type set from output_mime_type and Content-Disposition: attachment carrying original_filename (RFC 5987-encoded to safely support non-ASCII names and prevent header injection). 404 if the id doesn't exist, 409 if the job exists but isn't done yet.

  • Step 1: Write the failing tests

Create test/api/jobStatus.test.js:

import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import request from 'supertest';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { createApp } from '../../src/app.js';
import { getPool, closePool } from '../../src/db.js';
import { loadConfig } from '../../src/config.js';
import { createJob, markDone } from '../../src/jobs/jobRepository.js';
import { ensureStorageDirs, outputPath } from '../../src/storage.js';

let app;
let pool;
let config;

beforeAll(async () => {
  config = { ...loadConfig(), storageDir: await fs.mkdtemp(path.join(os.tmpdir(), 'converter-status-')) };
  await ensureStorageDirs(config);
  pool = getPool(config);
  app = createApp(config, pool);
});

afterAll(async () => {
  await closePool();
  await fs.rm(config.storageDir, { recursive: true, force: true });
});

beforeEach(async () => {
  await pool.query('DELETE FROM conversion_jobs');
});

function baseJob(id) {
  return {
    id,
    family: 'image',
    sourceFormat: 'png',
    targetFormat: 'webp',
    originalFilename: 'holiday photo.png',
    inputPath: `${id}.png`,
    inputMimeType: 'image/png',
    expiresAt: new Date(Date.now() + 3600 * 1000),
  };
}

describe('GET /api/jobs/:id', () => {
  it('returns job status without the error log field', async () => {
    const id = '66666666-6666-4666-8666-666666666666';
    await createJob(pool, baseJob(id));

    const response = await request(app).get(`/api/jobs/${id}`);

    expect(response.status).toBe(200);
    expect(response.body.status).toBe('pending');
    expect(response.body.originalFilename).toBe('holiday photo.png');
    expect(response.body.errorLog).toBeUndefined();
  });

  it('returns 404 for an unknown id', async () => {
    const response = await request(app).get('/api/jobs/00000000-0000-4000-8000-000000000000');
    expect(response.status).toBe(404);
  });
});

describe('GET /api/jobs/:id/download', () => {
  it('streams the converted file with correct headers once done', async () => {
    const id = '77777777-7777-4777-8777-777777777777';
    await createJob(pool, baseJob(id));
    const filePath = outputPath(config, id, 'webp');
    await fs.writeFile(filePath, Buffer.from('fake webp bytes'));
    await markDone(pool, id, { outputPath: `${id}.webp`, outputMimeType: 'image/webp' });

    const response = await request(app).get(`/api/jobs/${id}/download`);

    expect(response.status).toBe(200);
    expect(response.headers['content-type']).toBe('image/webp');
    expect(response.headers['content-disposition']).toContain('holiday photo.png');
    expect(response.body ?? response.text).toBeDefined();
  });

  it('returns 409 when the job is not done yet', async () => {
    const id = '88888888-8888-4888-8888-888888888888';
    await createJob(pool, baseJob(id));

    const response = await request(app).get(`/api/jobs/${id}/download`);

    expect(response.status).toBe(409);
  });

  it('returns 404 for an unknown id', async () => {
    const response = await request(app).get('/api/jobs/00000000-0000-4000-8000-000000000000/download');
    expect(response.status).toBe(404);
  });
});
  • Step 2: Run tests to verify they fail

Run: STORAGE_DIR=./storage DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter npx vitest run test/api/jobStatus.test.js Expected: FAIL — routes don't exist yet.

  • Step 3: Add the two routes to src/app.js

Add this import at the top of src/app.js:

import fs from 'node:fs';
import { outputPath } from './storage.js';

Add this helper function and the two routes inside createApp, before return app;:

function contentDispositionHeader(filename) {
  const asciiFallback = filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, "'");
  const encoded = encodeURIComponent(filename);
  return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`;
}
  app.get('/api/jobs/:id', async (req, res) => {
    const job = await getJobById(pool, req.params.id);
    if (!job) {
      return res.status(404).json({ error: 'Job not found' });
    }

    res.json({
      id: job.id,
      status: job.status,
      originalFilename: job.originalFilename,
      sourceFormat: job.sourceFormat,
      targetFormat: job.targetFormat,
      errorMessage: job.errorMessage,
    });
  });

  app.get('/api/jobs/:id/download', async (req, res) => {
    const job = await getJobById(pool, req.params.id);
    if (!job) {
      return res.status(404).json({ error: 'Job not found' });
    }
    if (job.status !== 'done') {
      return res.status(409).json({ error: `Job is not ready yet (status: ${job.status})` });
    }

    const filePath = outputPath(config, job.id, job.targetFormat);
    res.set('Content-Type', job.outputMimeType);
    res.set('Content-Disposition', contentDispositionHeader(job.originalFilename));
    fs.createReadStream(filePath).pipe(res);
  });

Note on error handling: npm install express (Task 1) installs the current major version, Express 5, where an async route handler's rejected promise is automatically forwarded to error-handling middleware (no manual try/catch-to-next plumbing needed) — confirmed against the official Express 5 migration guide. Add the error-handling middleware itself now, as the last thing registered in createApp, so it can catch errors from every route defined so far (Task 17 will insert its static-file serving before this middleware, never after — an error handler registered before a route can't catch that route's errors):

  app.use((err, req, res, next) => {
    console.error('Unhandled API error:', err);
    res.status(500).json({ error: 'Internal server error' });
  });

Add this immediately before return app; at the end of createApp.

Add this test to test/api/jobStatus.test.js, in a new describe block:

describe('unexpected server errors', () => {
  it('returns a generic 500 without leaking internal error details', async () => {
    const originalQuery = pool.query.bind(pool);
    pool.query = () => Promise.reject(new Error('connection reset by peer'));

    const response = await request(app).get('/api/jobs/99999999-9999-4999-8999-999999999999');

    pool.query = originalQuery;

    expect(response.status).toBe(500);
    expect(response.body.error).toBe('Internal server error');
    expect(JSON.stringify(response.body)).not.toContain('connection reset by peer');
  });
});
  • Step 4: Run tests to verify they pass

Run: STORAGE_DIR=./storage DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter npx vitest run test/api/jobStatus.test.js Expected: PASS (6 tests)

  • Step 5: Run the full API test suite to confirm no regression

Run: STORAGE_DIR=./storage DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter npx vitest run test/api Expected: PASS (13 tests total)

  • Step 6: Commit
git add src/app.js test/api/jobStatus.test.js
git commit -m "feat: add job status and download endpoints"

Task 14: Server entry point

Files:

  • Create: src/server.js
  • Modify: package.json (fix the start script from Task 1, which pointed at a file that was never created)

Interfaces:

  • Consumes: loadConfig (Task 1), ensureStorageDirs (Task 3), getPool (Task 2), createApp (Task 12).

  • Produces: a runnable entry point with no exports of its own — this is the process Passenger starts.

  • Step 1: Create src/server.js

import { loadConfig } from './config.js';
import { getPool } from './db.js';
import { ensureStorageDirs } from './storage.js';
import { createApp } from './app.js';

async function main() {
  const config = loadConfig();
  await ensureStorageDirs(config);
  const pool = getPool(config);
  const app = createApp(config, pool);

  app.listen(config.port, () => {
    console.log(`File converter API listening on port ${config.port}`);
  });
}

main().catch((error) => {
  console.error('Failed to start server:', error);
  process.exit(1);
});
  • Step 2: Update package.json's start script

Change:

"start": "node src/app.js",

to:

"start": "node src/server.js",
  • Step 3: Verify the server starts

Run (with a reachable MariaDB and matching .env, copy .env.example to .env and adjust values first):

npm start

Expected: console prints File converter API listening on port 3000 (or your configured port), process stays running. Stop with Ctrl+C.

  • Step 4: Commit
git add src/server.js package.json
git commit -m "feat: add server entry point"

Task 15: Worker process

Files:

  • Create: src/worker.js
  • Test: test/worker.test.js

Interfaces:

  • Consumes: findPendingJobs/markProcessing/markDone/markFailed (Task 5), resolve (Task 6), uploadPath/outputPath/deleteIfExists (Task 3), outputMimeType (Task 4), the three register*Converters functions (Tasks 7-10).

  • Produces:

    • export async function processPendingJobs(pool, config) — fetches up to config.workerConcurrency pending jobs and processes them concurrently; returns the number of jobs it picked up. This is the function the test suite calls directly — it does one pass and returns, making it usable both by the real polling loop and by tests/Task 18's end-to-end test, without needing to wait on a timer.
    • export function startWorker(pool, config) — sets up a setInterval calling processPendingJobs every config.workerPollIntervalMs, returns a stop function.
    • A main() that registers all converters and calls startWorker, run only when this file is executed directly (not on import).
    • Each job conversion is bounded by a 60-second timeout; a timeout or thrown error marks the job failed with a generic errorMessage and the full error in errorLog, and any partial output file is deleted.
  • Step 1: Write the failing tests

Create test/worker.test.js:

import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { getPool, closePool } from '../src/db.js';
import { loadConfig } from '../src/config.js';
import { ensureStorageDirs, uploadPath, outputPath } from '../src/storage.js';
import { createJob, getJobById, getJobErrorLog } from '../src/jobs/jobRepository.js';
import { registerImageConverters } from '../src/converters/image.js';
import { processPendingJobs } from '../src/worker.js';

let pool;
let config;

beforeAll(async () => {
  registerImageConverters();
  config = { ...loadConfig(), storageDir: await fs.mkdtemp(path.join(os.tmpdir(), 'converter-worker-')) };
  await ensureStorageDirs(config);
  pool = getPool(config);
});

afterAll(async () => {
  await closePool();
  await fs.rm(config.storageDir, { recursive: true, force: true });
});

beforeEach(async () => {
  await pool.query('DELETE FROM conversion_jobs');
});

async function createPendingImageJob(id, sourceFormat, targetFormat) {
  const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.png');
  const inputFilePath = uploadPath(config, id, sourceFormat);
  await fs.copyFile(fixturePath, inputFilePath);

  await createJob(pool, {
    id,
    family: 'image',
    sourceFormat,
    targetFormat,
    originalFilename: `photo.${sourceFormat}`,
    inputPath: `${id}.${sourceFormat}`,
    inputMimeType: 'image/png',
    expiresAt: new Date(Date.now() + 3600 * 1000),
  });
}

describe('processPendingJobs', () => {
  it('converts a pending image job to done', async () => {
    const id = '99999999-9999-4999-8999-999999999999';
    await createPendingImageJob(id, 'png', 'webp');

    const processedCount = await processPendingJobs(pool, config);

    expect(processedCount).toBe(1);
    const job = await getJobById(pool, id);
    expect(job.status).toBe('done');
    expect(job.outputPath).toBe(`${id}.webp`);
    expect(job.outputMimeType).toBe('image/webp');

    const stat = await fs.stat(outputPath(config, id, 'webp'));
    expect(stat.size).toBeGreaterThan(0);
  });

  it('marks a job failed with a safe message and a detailed log when the converter throws', async () => {
    const id = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
    await createJob(pool, {
      id,
      family: 'image',
      sourceFormat: 'png',
      targetFormat: 'webp',
      originalFilename: 'missing.png',
      inputPath: `${id}.png`,
      inputMimeType: 'image/png',
      expiresAt: new Date(Date.now() + 3600 * 1000),
    });
    // Note: input file is intentionally never written, so sharp will throw ENOENT.

    await processPendingJobs(pool, config);

    const job = await getJobById(pool, id);
    expect(job.status).toBe('failed');
    expect(job.errorMessage).toBe('Conversion failed, please try again.');

    const errorLog = await getJobErrorLog(pool, id);
    expect(errorLog).toMatch(/ENOENT|no such file/i);
  });

  it('only picks up as many jobs as workerConcurrency allows', async () => {
    await createPendingImageJob('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'png', 'webp');
    await createPendingImageJob('cccccccc-cccc-4ccc-8ccc-cccccccccccc', 'png', 'webp');
    await createPendingImageJob('dddddddd-dddd-4ddd-8ddd-dddddddddddd', 'png', 'webp');

    const limitedConfig = { ...config, workerConcurrency: 2 };
    const processedCount = await processPendingJobs(pool, limitedConfig);

    expect(processedCount).toBe(2);
  });
});
  • Step 2: Run tests to verify they fail

Run: STORAGE_DIR=./storage DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter npx vitest run test/worker.test.js Expected: FAIL — src/worker.js does not exist yet.

  • Step 3: Implement src/worker.js
import { loadConfig } from './config.js';
import { getPool } from './db.js';
import { ensureStorageDirs, uploadPath, outputPath, deleteIfExists } from './storage.js';
import { outputMimeType } from './mime.js';
import { resolve as resolveConverter } from './converters/registry.js';
import { registerImageConverters } from './converters/image.js';
import { registerImageToPdfConverter } from './converters/imageToPdf.js';
import { registerDocumentConverters } from './converters/document.js';
import { findPendingJobs, markProcessing, markDone, markFailed } from './jobs/jobRepository.js';

const JOB_TIMEOUT_MS = 60000;

function withTimeout(promise, ms) {
  let timeoutId;
  const timeout = new Promise((_, reject) => {
    timeoutId = setTimeout(() => reject(new Error(`Conversion timed out after ${ms}ms`)), ms);
  });
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timeoutId));
}

async function processJob(pool, config, job) {
  await markProcessing(pool, job.id);

  const inputFilePath = uploadPath(config, job.id, job.sourceFormat);
  const outputFilePath = outputPath(config, job.id, job.targetFormat);

  try {
    const entry = resolveConverter(job.sourceFormat, job.targetFormat);
    if (!entry) {
      throw new Error(`No converter registered for ${job.sourceFormat} -> ${job.targetFormat}`);
    }

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

    await markDone(pool, job.id, {
      outputPath: `${job.id}.${job.targetFormat}`,
      outputMimeType: outputMimeType(job.targetFormat),
    });
  } catch (error) {
    await deleteIfExists(outputFilePath);
    await markFailed(pool, job.id, {
      errorMessage: 'Conversion failed, please try again.',
      errorLog: error.stack ?? String(error),
    });
  }
}

export async function processPendingJobs(pool, config) {
  const jobs = await findPendingJobs(pool, config.workerConcurrency);
  await Promise.all(jobs.map((job) => processJob(pool, config, job)));
  return jobs.length;
}

export function startWorker(pool, config) {
  const interval = setInterval(() => {
    processPendingJobs(pool, config).catch((error) => {
      console.error('Error while processing pending jobs:', error);
    });
  }, config.workerPollIntervalMs);
  return () => clearInterval(interval);
}

async function main() {
  const config = loadConfig();
  await ensureStorageDirs(config);
  const pool = getPool(config);

  registerImageConverters();
  registerImageToPdfConverter();
  registerDocumentConverters();

  startWorker(pool, config);
  console.log(`Worker started, polling every ${config.workerPollIntervalMs}ms`);
}

if (import.meta.url === `file://${process.argv[1]}`) {
  main();
}
  • Step 4: Run tests to verify they pass

Run: STORAGE_DIR=./storage DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter npx vitest run test/worker.test.js Expected: PASS (3 tests)

  • Step 5: Commit
git add src/worker.js test/worker.test.js
git commit -m "feat: add worker process with bounded concurrency and per-job timeout"

Task 16: Cleanup script and deployment files

Files:

  • Create: src/cleanup.js
  • Create: ecosystem.config.cjs
  • Create: README.md (deployment section)
  • Test: test/cleanup.test.js

Interfaces:

  • Consumes: findExpiredJobs/deleteJob (Task 5), uploadPath/outputPath/deleteIfExists (Task 3).

  • Produces: export async function runCleanup(pool, config) — deletes every expired job's row and its upload/output files (tolerating a missing output file for jobs that never finished), returns the number of jobs removed. A main() runs it once and exits, for use from cron.

  • Step 1: Write the failing tests

Create test/cleanup.test.js:

import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { getPool, closePool } from '../src/db.js';
import { loadConfig } from '../src/config.js';
import { ensureStorageDirs, uploadPath, outputPath } from '../src/storage.js';
import { createJob, markDone, getJobById } from '../src/jobs/jobRepository.js';
import { runCleanup } from '../src/cleanup.js';

let pool;
let config;

beforeAll(async () => {
  config = { ...loadConfig(), storageDir: await fs.mkdtemp(path.join(os.tmpdir(), 'converter-cleanup-')) };
  await ensureStorageDirs(config);
  pool = getPool(config);
});

afterAll(async () => {
  await closePool();
  await fs.rm(config.storageDir, { recursive: true, force: true });
});

beforeEach(async () => {
  await pool.query('DELETE FROM conversion_jobs');
});

describe('runCleanup', () => {
  it('deletes an expired done job, its input file, and its output file', async () => {
    const id = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee';
    await fs.writeFile(uploadPath(config, id, 'png'), 'input bytes');
    await fs.writeFile(outputPath(config, id, 'webp'), 'output bytes');
    await createJob(pool, {
      id,
      family: 'image',
      sourceFormat: 'png',
      targetFormat: 'webp',
      originalFilename: 'photo.png',
      inputPath: `${id}.png`,
      inputMimeType: 'image/png',
      expiresAt: new Date(Date.now() - 1000),
    });
    await markDone(pool, id, { outputPath: `${id}.webp`, outputMimeType: 'image/webp' });

    const deletedCount = await runCleanup(pool, config);

    expect(deletedCount).toBe(1);
    expect(await getJobById(pool, id)).toBeNull();
    await expect(fs.stat(uploadPath(config, id, 'png'))).rejects.toThrow();
    await expect(fs.stat(outputPath(config, id, 'webp'))).rejects.toThrow();
  });

  it('deletes an expired pending job (with no output file) without throwing', async () => {
    const id = 'ffffffff-ffff-4fff-8fff-ffffffffffff';
    await fs.writeFile(uploadPath(config, id, 'png'), 'input bytes');
    await createJob(pool, {
      id,
      family: 'image',
      sourceFormat: 'png',
      targetFormat: 'webp',
      originalFilename: 'photo.png',
      inputPath: `${id}.png`,
      inputMimeType: 'image/png',
      expiresAt: new Date(Date.now() - 1000),
    });

    const deletedCount = await runCleanup(pool, config);

    expect(deletedCount).toBe(1);
    expect(await getJobById(pool, id)).toBeNull();
  });

  it('leaves non-expired jobs untouched', async () => {
    const id = '12121212-1212-4212-8212-121212121212';
    await createJob(pool, {
      id,
      family: 'image',
      sourceFormat: 'png',
      targetFormat: 'webp',
      originalFilename: 'photo.png',
      inputPath: `${id}.png`,
      inputMimeType: 'image/png',
      expiresAt: new Date(Date.now() + 3600 * 1000),
    });

    const deletedCount = await runCleanup(pool, config);

    expect(deletedCount).toBe(0);
    expect(await getJobById(pool, id)).not.toBeNull();
  });
});
  • Step 2: Run tests to verify they fail

Run: STORAGE_DIR=./storage DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter npx vitest run test/cleanup.test.js Expected: FAIL — src/cleanup.js does not exist yet.

  • Step 3: Implement src/cleanup.js
import { loadConfig } from './config.js';
import { getPool, closePool } from './db.js';
import { uploadPath, outputPath, deleteIfExists } from './storage.js';
import { findExpiredJobs, deleteJob } from './jobs/jobRepository.js';

export async function runCleanup(pool, config) {
  const expiredJobs = await findExpiredJobs(pool);

  for (const job of expiredJobs) {
    await deleteIfExists(uploadPath(config, job.id, job.sourceFormat));
    await deleteIfExists(outputPath(config, job.id, job.targetFormat));
    await deleteJob(pool, job.id);
  }

  return expiredJobs.length;
}

async function main() {
  const config = loadConfig();
  const pool = getPool(config);
  const deletedCount = await runCleanup(pool, config);
  console.log(`Cleanup: removed ${deletedCount} expired job(s).`);
  await closePool();
}

if (import.meta.url === `file://${process.argv[1]}`) {
  main();
}
  • Step 4: Run tests to verify they pass

Run: STORAGE_DIR=./storage DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter npx vitest run test/cleanup.test.js Expected: PASS (3 tests)

  • Step 5: Add the cleanup script check to package.json

Confirm package.json already has (added in Task 1):

"cleanup": "node src/cleanup.js"
  • Step 6: Create the pm2 ecosystem file for the worker

Create ecosystem.config.cjs:

module.exports = {
  apps: [
    {
      name: 'convert-worker',
      script: 'src/worker.js',
      interpreter: 'node',
      env: {
        NODE_ENV: 'production',
      },
    },
  ],
};
  • Step 7: Document deployment steps in README.md

Create README.md:

# File Converter

## Local development

1. Copy `.env.example` to `.env` and fill in your local MariaDB credentials.
2. Apply the schema: `mysql -h <host> -u <user> -p <database> < db/schema.sql`
3. Install dependencies: `npm install`
4. Run the API: `npm start`
5. Run the worker (separate terminal): `npm run worker`
6. Run tests: `npm test` (requires the same MariaDB reachable via your `.env` vars, exported into the shell)

## Deployment on o2switch

1. Upload the project (excluding `node_modules/`) via SSH/Git.
2. Create/adjust `.env` on the server with production values (`STORAGE_DIR` pointing to a writable path under your account, MariaDB credentials from cPanel).
3. Apply `db/schema.sql` to the MariaDB database created in cPanel.
4. `npm install` (never with `--ignore-scripts` — Puppeteer needs its postinstall step to download Chromium).
5. Configure the app in cPanel "Setup Node.js App", pointing its entry point at `src/server.js`. Passenger manages this process (start/stop/restart).
6. Start the worker independently of Passenger, over SSH: `pm2 start ecosystem.config.cjs`, then `pm2 save`. Try `pm2 startup` to survive a server reboot; if that's not permitted without root on this account, fall back to a cPanel cron job every 5 minutes that runs `pm2 resurrect` (or checks `pm2 list` and restarts the app if absent) — validate which option this hosting plan actually allows once connected over SSH.
7. Add a cPanel cron job to run the cleanup script periodically, e.g. every 15 minutes:
   `*/15 * * * * cd /home/<cpanel-user>/<app-path> && /usr/bin/node src/cleanup.js >> logs/cleanup.log 2>&1`
   (adjust the path and node binary location to match your actual account — check with `which node` over SSH).
8. On every subsequent deployment: pull changes, `npm install`, `npm run build` (frontend, once Task 17 exists), then restart the Passenger app from cPanel and `pm2 restart convert-worker`.
  • Step 8: Commit
git add src/cleanup.js test/cleanup.test.js ecosystem.config.cjs README.md
git commit -m "feat: add cleanup script and o2switch deployment docs"

Task 17: Frontend (React SPA)

Files:

  • Create: frontend/ (scaffolded by Vite, then edited)
  • Modify: frontend/vite.config.js
  • Create: frontend/src/api.js
  • Create: frontend/src/FileCard.jsx
  • Modify: frontend/src/App.jsx
  • Modify: frontend/src/App.css
  • Modify: src/app.js (serve the built frontend)
  • Modify: package.json (root build script)

This task is implementation-focused rather than TDD — the design spec's test strategy scopes automated tests to converters/registry/API (Tasks 1-16), not UI components. Verification here is manual: run the dev server and the built app in a browser.

  • Step 1: Scaffold the Vite React app

From the project root:

npm create vite@latest frontend -- --template react
cd frontend && npm install && cd ..
  • Step 2: Configure the dev proxy so the Vite dev server forwards /api to Express

Replace the contents of frontend/vite.config.js:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': 'http://localhost:3000',
    },
  },
});
  • Step 3: Create frontend/src/api.js
export async function fetchFormats(source) {
  const response = await fetch(`/api/formats?source=${encodeURIComponent(source)}`);
  const data = await response.json();
  return data.targets;
}

export async function uploadFiles(items) {
  const formData = new FormData();
  const targetFormats = [];
  for (const item of items) {
    formData.append('files', item.file);
    targetFormats.push(item.targetFormat);
  }
  formData.append('targetFormats', JSON.stringify(targetFormats));

  const response = await fetch('/api/jobs', { method: 'POST', body: formData });
  const data = await response.json();
  return data.jobs;
}

export async function fetchJobStatus(id) {
  const response = await fetch(`/api/jobs/${id}`);
  return response.json();
}

export function downloadUrl(id) {
  return `/api/jobs/${id}/download`;
}
  • Step 4: Create frontend/src/FileCard.jsx
import { useEffect, useState } from 'react';
import { fetchJobStatus, downloadUrl } from './api.js';

export function FileCard({ fileName, jobId, initialError }) {
  const [status, setStatus] = useState(initialError ? 'failed' : 'pending');
  const [errorMessage, setErrorMessage] = useState(initialError ?? null);

  useEffect(() => {
    if (!jobId || initialError) return undefined;

    let cancelled = false;
    const interval = setInterval(async () => {
      const job = await fetchJobStatus(jobId);
      if (cancelled) return;
      setStatus(job.status);
      if (job.status === 'failed') setErrorMessage(job.errorMessage);
      if (job.status === 'done' || job.status === 'failed') clearInterval(interval);
    }, 1500);

    return () => {
      cancelled = true;
      clearInterval(interval);
    };
  }, [jobId, initialError]);

  return (
    <li className="file-card">
      <span className="file-name">{fileName}</span>
      {(status === 'pending' || status === 'processing') && <span>Conversion en cours...</span>}
      {status === 'done' && <a href={downloadUrl(jobId)}>Télécharger</a>}
      {status === 'failed' && <span className="error">{errorMessage}</span>}
    </li>
  );
}
  • Step 5: Replace frontend/src/App.jsx
import { useState } from 'react';
import { fetchFormats, uploadFiles } from './api.js';
import { FileCard } from './FileCard.jsx';
import './App.css';

function extensionOf(fileName) {
  return fileName.split('.').pop().toLowerCase();
}

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));
        return { file, targets, targetFormat: targets[0] ?? null };
      })
    );
    setPendingFiles(withTargets);
  }

  function updateTargetFormat(index, targetFormat) {
    setPendingFiles((current) => current.map((item, i) => (i === index ? { ...item, targetFormat } : 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>
                ) : (
                  <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 6: Replace frontend/src/App.css with minimal layout styles
main {
  max-width: 640px;
  margin: 2rem auto;
  font-family: system-ui, sans-serif;
}

ul {
  list-style: none;
  padding: 0;
}

li {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.5rem 0;
}

.error {
  color: #b00020;
}
  • Step 7: Serve the built frontend from Express

Add to src/app.js, near the top (with the other imports):

import path from 'node:path';

(skip if already imported by Task 13). Add this after the API routes but before the error-handling middleware Task 13 added at the end of createApp (an error handler registered before a route never sees that route's errors, so the static/catch-all route must come first):

  const frontendDist = path.join(import.meta.dirname, '..', 'frontend', 'dist');
  app.use(express.static(frontendDist));
  app.get(/^\/(?!api\/).*/, (req, res) => {
    res.sendFile(path.join(frontendDist, 'index.html'));
  });
  • Step 8: Add a root-level build script

Add to the root package.json scripts:

"build": "npm install --prefix frontend && npm run build --prefix frontend"
  • Step 9: Manual verification — dev mode

Terminal 1: npm start (Express API on port 3000) Terminal 2: cd frontend && npm run dev (Vite dev server, typically port 5173)

Open the Vite dev server URL in a browser. Drop a PNG and a DOCX file simultaneously, pick target formats, click "Convertir", confirm both file cards poll independently and eventually show a working "Télécharger" link (requires the worker from Task 15 running too: npm run worker in a third terminal).

  • Step 10: Manual verification — production build served by Express
npm run build
npm start

Open http://localhost:3000 directly (no Vite dev server this time) and repeat the same upload/convert/download flow.

  • Step 11: Commit
git add frontend package.json src/app.js
git commit -m "feat: add React frontend and serve it from Express"

Task 18: End-to-end pipeline test (one case per family)

Files:

  • Create: test/api/endToEnd.test.js

Interfaces:

  • Consumes: createApp (Task 12), processPendingJobs (Task 15) — called directly instead of startWorker's timer, so the test drives the worker deterministically instead of racing a setInterval.

This closes the loop the spec's test strategy asked for: upload → status → download, for one image case and one document case, through the real HTTP API and the real worker function (no mocks).

  • Step 1: Write the test

Create test/api/endToEnd.test.js:

import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import request from 'supertest';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { Document, Paragraph, TextRun, Packer } from 'docx';
import { createApp } from '../../src/app.js';
import { getPool, closePool } from '../../src/db.js';
import { loadConfig } from '../../src/config.js';
import { ensureStorageDirs } from '../../src/storage.js';
import { processPendingJobs } from '../../src/worker.js';

let app;
let pool;
let config;

beforeAll(async () => {
  config = { ...loadConfig(), storageDir: await fs.mkdtemp(path.join(os.tmpdir(), 'converter-e2e-')) };
  await ensureStorageDirs(config);
  pool = getPool(config);
  app = createApp(config, pool);
});

afterAll(async () => {
  await closePool();
  await fs.rm(config.storageDir, { recursive: true, force: true });
});

beforeEach(async () => {
  await pool.query('DELETE FROM conversion_jobs');
});

async function waitForDone(id, maxAttempts = 20) {
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
    await processPendingJobs(pool, config);
    const response = await request(app).get(`/api/jobs/${id}`);
    if (response.body.status === 'done' || response.body.status === 'failed') {
      return response.body;
    }
  }
  throw new Error(`Job ${id} did not finish after ${maxAttempts} worker passes`);
}

describe('end-to-end: image family', () => {
  it('uploads a PNG, converts it to WebP, and downloads the result', async () => {
    const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png');

    const uploadResponse = await request(app)
      .post('/api/jobs')
      .field('targetFormats', JSON.stringify(['webp']))
      .attach('files', fixturePath, 'photo.png');

    const { id } = uploadResponse.body.jobs[0];
    const finalStatus = await waitForDone(id);
    expect(finalStatus.status).toBe('done');

    const downloadResponse = await request(app).get(`/api/jobs/${id}/download`);
    expect(downloadResponse.status).toBe(200);
    expect(downloadResponse.headers['content-type']).toBe('image/webp');
    expect(downloadResponse.headers['content-disposition']).toContain('photo.png');
  }, 20000);
});

describe('end-to-end: document family', () => {
  it('uploads a DOCX, converts it to PDF, and downloads the result', async () => {
    const doc = new Document({
      sections: [{ children: [new Paragraph({ children: [new TextRun('End to end fixture text')] })] }],
    });
    const buffer = await Packer.toBuffer(doc);
    const docxPath = path.join(config.storageDir, 'e2e-fixture.docx');
    await fs.writeFile(docxPath, buffer);

    const uploadResponse = await request(app)
      .post('/api/jobs')
      .field('targetFormats', JSON.stringify(['pdf']))
      .attach('files', docxPath, 'report.docx');

    const { id } = uploadResponse.body.jobs[0];
    const finalStatus = await waitForDone(id);
    expect(finalStatus.status).toBe('done');

    const downloadResponse = await request(app).get(`/api/jobs/${id}/download`);
    expect(downloadResponse.status).toBe(200);
    expect(downloadResponse.headers['content-type']).toBe('application/pdf');
    expect(downloadResponse.headers['content-disposition']).toContain('report.docx');

    await fs.unlink(docxPath);
  }, 20000);
});
  • Step 2: Run the test to verify it fails initially if the worker isn't wired correctly

Run: STORAGE_DIR=./storage DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter npx vitest run test/api/endToEnd.test.js Expected at this point: PASS, since Tasks 1-17 already implemented everything this test exercises — this step is a regression check, not new implementation. If it fails, the bug is in the interaction between tasks (most likely a mismatch between how app.js names uploaded files and how worker.js reconstructs paths), not in a single file — investigate before continuing.

  • Step 3: Run the entire test suite

Run: STORAGE_DIR=./storage DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter npx vitest run Expected: PASS, all tests across all previous tasks plus this one.

  • Step 4: Commit
git add test/api/endToEnd.test.js
git commit -m "test: add end-to-end upload-convert-download coverage for image and document families"

Out of scope for this plan

Audio, Video, Presentations, Fonts, Ebook, Archives — to be added later as new converter family modules registered the same way (register*Converters() functions consumed by app.js and worker.js), following the pattern established in Tasks 7-10. BMP output support (sharp does not support it) is also deferred pending confirmation it's actually needed.