From 86b555b334d02632801e1732802f2da5f5a0f79d Mon Sep 17 00:00:00 2001 From: Anthony GAEREMYNCK <1@anthony.sh> Date: Fri, 31 Jul 2026 13:29:06 +0200 Subject: [PATCH] feat: register ebook converter in the app so the API serves the new family Also fixes a real bug found via the fb2 API test: file-type sniffs a real fb2 file's XML declaration as generic "xml", not "fb2" and not undetected, so it never reached the undetectable-format fallback added in the previous commit. Added an fb2->xml alias in normalizeFormat, same pattern as the existing azw3->mobi one. --- src/app.js | 2 ++ src/mime.js | 1 + test/api/jobs.test.js | 37 +++++++++++++++++++++++++++++++++++++ test/mime.test.js | 29 ++++++++++++++++++++++++++++- 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/app.js b/src/app.js index b5a4770..5e88837 100644 --- a/src/app.js +++ b/src/app.js @@ -12,6 +12,7 @@ import { registerIcoConverter } from './converters/ico.js'; import { registerHeicConverter } from './converters/heic.js'; import { registerFontConverter } from './converters/font.js'; import { registerDfontConverter } from './converters/dfont.js'; +import { registerEbookConverter } from './converters/ebook.js'; import { resolveInputFormat } from './mime.js'; import { deleteIfExists } from './storage.js'; import { createJob, getJobByUuid } from './jobs/jobRepository.js'; @@ -44,6 +45,7 @@ function registerAllConverters() { registerHeicConverter(); registerFontConverter(); registerDfontConverter(); + registerEbookConverter(); convertersRegistered = true; } diff --git a/src/mime.js b/src/mime.js index 5ef97bc..690b92d 100644 --- a/src/mime.js +++ b/src/mime.js @@ -66,6 +66,7 @@ function normalizeFormat(format) { if (format === 'jpg') return 'jpeg'; if (format === 'heif') return 'heic'; if (format === 'azw3') return 'mobi'; + if (format === 'fb2') return 'xml'; return format; } diff --git a/test/api/jobs.test.js b/test/api/jobs.test.js index 8f763c3..d5de62b 100644 --- a/test/api/jobs.test.js +++ b/test/api/jobs.test.js @@ -58,6 +58,43 @@ describe('GET /api/formats', () => { }); }); +describe('GET /api/formats — ebooks', () => { + it('lists the other 9 ebook formats as targets for epub, and never lists epub as its own target', async () => { + const response = await request(app).get('/api/formats').query({ source: 'epub' }); + + expect(response.body.targets).toEqual( + expect.arrayContaining(['fb2', 'lrf', 'mobi', 'pdb', 'rb', 'snb', 'tcr', 'azw3', 'pdf']) + ); + expect(response.body.targets).not.toContain('epub'); + }); +}); + +describe('POST /api/jobs — ebooks', () => { + it('creates a pending job converting an fb2 upload to epub, accepting it despite file-type sniffing its XML declaration as generic "xml" rather than "fb2"', async () => { + const fixturePath = path.join(config.storageDir, 'book.fb2'); + await fs.writeFile( + fixturePath, + '

hello

' + ); + + const response = await request(app) + .post('/api/jobs') + .field('targetFormats', JSON.stringify(['epub'])) + .attach('files', fixturePath, 'book.fb2'); + + expect(response.status).toBe(201); + expect(response.body.jobs[0].status).toBe('pending'); + + const job = await getJobByUuid(prisma, response.body.jobs[0].id); + expect(job.sourceFormat).toBe('fb2'); + expect(job.targetFormat).toBe('epub'); + expect(job.family).toBe('ebook'); + expect(job.inputMimeType).toBe('application/xml'); + + await fs.unlink(fixturePath); + }); +}); + 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'); diff --git a/test/mime.test.js b/test/mime.test.js index e51f32a..b867c4e 100644 --- a/test/mime.test.js +++ b/test/mime.test.js @@ -169,7 +169,6 @@ describe('resolveInputFormat — azw3/mobi collision', () => { describe('resolveInputFormat — undetectable ebook formats', () => { const undetectableFormats = { - fb2: 'application/x-fictionbook+xml', lrf: 'application/octet-stream', pdb: 'application/vnd.palm', rb: 'application/octet-stream', @@ -192,6 +191,34 @@ describe('resolveInputFormat — undetectable ebook formats', () => { ); }); +describe('resolveInputFormat — fb2', () => { + it('accepts real fb2 content (file-type sniffs its XML declaration as generic "xml", not "fb2")', async () => { + const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-real.fb2'); + await fs.writeFile( + fixturePath, + '

hello

' + ); + + const detected = await detectInputMime(fixturePath); + expect(detected).toEqual({ ext: 'xml', mime: 'application/xml' }); + + const result = await resolveInputFormat(fixturePath, 'fb2'); + expect(result).toEqual({ mime: 'application/xml', valid: true }); + + await fs.unlink(fixturePath); + }); + + it('falls back to trusting the declared format for fb2 content with no recognizable XML declaration', async () => { + const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-no-prolog.fb2'); + await fs.writeFile(fixturePath, 'arbitrary bytes with no recognizable magic number'); + + const result = await resolveInputFormat(fixturePath, 'fb2'); + expect(result).toEqual({ mime: 'application/x-fictionbook+xml', valid: true }); + + await fs.unlink(fixturePath); + }); +}); + describe('resolveInputFormat — dfont', () => { it('confirms file-type alone misidentifies the dfont fixture as ico (documents the bug this fix works around)', async () => { const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.dfont');