From ba5756e122fc3361061c9ff67013f5bf17f94af7 Mon Sep 17 00:00:00 2001 From: Anthony GAEREMYNCK <1@anthony.sh> Date: Thu, 30 Jul 2026 09:17:55 +0200 Subject: [PATCH] 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 --- src/app.js | 44 +++++++++++++++ test/api/jobStatus.test.js | 108 +++++++++++++++++++++++++++++++++++++ vitest.config.js | 7 +++ 3 files changed, 159 insertions(+) create mode 100644 test/api/jobStatus.test.js create mode 100644 vitest.config.js diff --git a/src/app.js b/src/app.js index ae0ba35..5ba6557 100644 --- a/src/app.js +++ b/src/app.js @@ -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; } diff --git a/test/api/jobStatus.test.js b/test/api/jobStatus.test.js new file mode 100644 index 0000000..344a549 --- /dev/null +++ b/test/api/jobStatus.test.js @@ -0,0 +1,108 @@ +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); + }); +}); + +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'); + }); +}); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..f3b9767 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + fileParallelism: false, + }, +});