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>
3055 lines
103 KiB
Markdown
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
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.
|