feat: add job status and download endpoints

Also adds vitest.config.js with fileParallelism: false. Every DB-backed
test file wipes and reseeds the shared conversion_jobs table in
beforeEach; running test files in parallel (Vitest's default) let one
file's DELETE race another file's just-inserted row against the same
live MariaDB instance, causing intermittent cross-file failures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 09:17:55 +02:00
co-authored by Claude Sonnet 5
parent 280b249b05
commit ba5756e122
3 changed files with 159 additions and 0 deletions
+44
View File
@@ -1,4 +1,5 @@
import path from 'node:path';
import fs from 'node:fs';
import express from 'express';
import multer from 'multer';
import { rateLimit } from 'express-rate-limit';
@@ -10,6 +11,7 @@ import { registerDocumentConverters } from './converters/document.js';
import { resolveInputFormat } from './mime.js';
import { deleteIfExists } from './storage.js';
import { createJob, getJobById } from './jobs/jobRepository.js';
import { outputPath } from './storage.js';
let convertersRegistered = false;
@@ -112,5 +114,47 @@ export function createApp(config, pool) {
res.status(201).json({ jobs: results });
});
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,
});
});
function contentDispositionHeader(filename) {
const asciiFallback = filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, "'");
const encoded = encodeURIComponent(filename);
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`;
}
app.get('/api/jobs/:id/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);
});
app.use((err, req, res, next) => {
console.error('Unhandled API error:', err);
res.status(500).json({ error: 'Internal server error' });
});
return app;
}