Files
convert/docs/superpowers/plans/2026-07-28-file-converter.md
T
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

3055 lines
103 KiB
Markdown

# 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:
```js
{
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`**
```bash
npm init -y
```
Then edit `package.json` to set:
```json
{
"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**
```bash
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`**
```dotenv
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:
```gitignore
node_modules/
.env
storage/
dist/
```
- [ ] **Step 5: Write the failing test for config loading**
Create `test/config.test.js`:
```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`**
```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**
```bash
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`:
```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):
```bash
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`:
```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`**
```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**
```bash
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`:
```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`**
```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**
```bash
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):
```js
// 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`:
```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`**
```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**
```bash
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`:
```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`**
```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**
```bash
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`:
```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`**
```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**
```bash
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`:
```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`**
```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**
```bash
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`:
```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`**
```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**
```bash
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`:
```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`**
```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**
```bash
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`:
```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):
```js
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import { Document, Paragraph, TextRun, Packer } from 'docx';
```
Add these functions to the same file:
```js
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:
```js
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**
```bash
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`:
```js
import { resolveInputFormat } from '../src/mime.js';
```
(add this import alongside the existing ones at the top of the file), then add:
```js
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`:
```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**
```bash
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`:
```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`**
```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**
```bash
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/:id` → `200` 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`:
```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`:
```js
import fs from 'node:fs';
import { outputPath } from './storage.js';
```
Add this helper function and the two routes inside `createApp`, before `return app;`:
```js
function contentDispositionHeader(filename) {
const asciiFallback = filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, "'");
const encoded = encodeURIComponent(filename);
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`;
}
```
```js
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):
```js
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:
```js
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**
```bash
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`**
```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:
```json
"start": "node src/app.js",
```
to:
```json
"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):
```bash
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**
```bash
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`:
```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`**
```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**
```bash
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`:
```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`**
```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):
```json
"cleanup": "node src/cleanup.js"
```
- [ ] **Step 6: Create the pm2 ecosystem file for the worker**
Create `ecosystem.config.cjs`:
```js
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`:
```markdown
# 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**
```bash
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:
```bash
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`:
```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`**
```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`**
```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`**
```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**
```css
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):
```js
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):
```js
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`:
```json
"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**
```bash
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**
```bash
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`:
```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**
```bash
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.