/** * Component: Chapter Merger Utility Tests * Documentation: documentation/features/chapter-merging.md */ import { EventEmitter } from 'events'; import path from 'path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { analyzeChapterFiles, checkDiskSpace, detectChapterFiles, estimateOutputSize, formatDuration, mergeChapters, probeAudioFile, } from '@/lib/utils/chapter-merger'; const execState = vi.hoisted(() => { const state = { handler: null as null | ((command: string) => { stdout?: string; error?: Error }), }; const custom = Symbol.for('nodejs.util.promisify.custom'); const exec = vi.fn(); (exec as any)[custom] = (command: string) => new Promise((resolve, reject) => { const result = state.handler ? state.handler(command) : { stdout: '' }; if (result.error) { reject(result.error); return; } resolve({ stdout: result.stdout ?? '', stderr: '' }); }); return { exec, state }; }); const spawnMock = vi.hoisted(() => vi.fn()); const fsMock = vi.hoisted(() => ({ access: vi.fn(), stat: vi.fn(), writeFile: vi.fn(), mkdir: vi.fn(), unlink: vi.fn(), constants: { R_OK: 4 }, })); vi.mock('child_process', () => ({ exec: execState.exec, spawn: spawnMock, })); vi.mock('fs/promises', () => ({ default: fsMock, ...fsMock, })); function createSpawnProcess(exitCode = 0, stderrData = '') { const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter; kill: () => void; }; proc.stderr = new EventEmitter(); proc.kill = vi.fn(); setImmediate(() => { if (stderrData) { proc.stderr.emit('data', Buffer.from(stderrData)); } proc.emit('close', exitCode); }); return proc; } function mockExecImplementation(handlers: (command: string) => { stdout?: string; error?: Error }) { execState.state.handler = handlers; } describe('chapter merger', () => { beforeEach(() => { vi.clearAllMocks(); execState.state.handler = null; }); it('detects when chapter merging should be skipped', async () => { await expect(detectChapterFiles(['one.mp3', 'two.mp3'])).resolves.toBe(false); await expect(detectChapterFiles(['one.mp3', 'two.m4b', 'three.mp3'])).resolves.toBe(false); await expect(detectChapterFiles(['one.wav', 'two.wav', 'three.wav'])).resolves.toBe(false); }); it('detects eligible chapter files', async () => { await expect(detectChapterFiles(['one.mp3', 'two.mp3', 'three.mp3'])).resolves.toBe(true); }); it('orders chapters by metadata when track numbers are sequential', async () => { const files = ['/tmp/b.mp3', '/tmp/a.mp3', '/tmp/c.mp3']; const probeMap: Record = { '/tmp/b.mp3': { duration: 60, bitrate: 128000, track: 1 }, '/tmp/a.mp3': { duration: 60, bitrate: 128000, track: 2 }, '/tmp/c.mp3': { duration: 60, bitrate: 128000, track: 3 }, }; mockExecImplementation((command) => { const matches = command.match(/"([^"]+)"/g) ?? []; const filePath = matches.length > 0 ? matches[matches.length - 1].replace(/"/g, '') : ''; const probe = probeMap[filePath]; if (!probe) { throw new Error(`Missing probe data for ${filePath}`); } const payload = { format: { duration: String(probe.duration), bit_rate: String(probe.bitrate), tags: { track: String(probe.track) }, }, }; return { stdout: JSON.stringify(payload) }; }); const ordered = await analyzeChapterFiles(files); expect(ordered.map((file) => path.basename(file.path))).toEqual(['b.mp3', 'a.mp3', 'c.mp3']); expect(ordered[0].chapterTitle).toBe('Chapter 1'); }); it('orders chapters by filename when track numbers are missing', async () => { const files = ['/tmp/02 - Middle.mp3', '/tmp/01 - Start.mp3', '/tmp/03 - End.mp3']; const probeMap: Record = { '/tmp/02 - Middle.mp3': { duration: 60, bitrate: 128000 }, '/tmp/01 - Start.mp3': { duration: 60, bitrate: 128000 }, '/tmp/03 - End.mp3': { duration: 60, bitrate: 128000 }, }; mockExecImplementation((command) => { const matches = command.match(/"([^"]+)"/g) ?? []; const filePath = matches.length > 0 ? matches[matches.length - 1].replace(/"/g, '') : ''; const probe = probeMap[filePath]; if (!probe) { throw new Error(`Missing probe data for ${filePath}`); } const payload = { format: { duration: String(probe.duration), bit_rate: String(probe.bitrate), tags: {}, }, }; return { stdout: JSON.stringify(payload) }; }); const ordered = await analyzeChapterFiles(files); expect(ordered.map((file) => path.basename(file.path))).toEqual([ '01 - Start.mp3', '02 - Middle.mp3', '03 - End.mp3', ]); expect(ordered[0].chapterTitle).toBe('Start'); expect(ordered[1].chapterTitle).toBe('Middle'); }); it('falls back to chapter numbers when metadata title is the book title', async () => { const files = ['/tmp/01.mp3', '/tmp/02.mp3', '/tmp/03.mp3']; const probeMap: Record = { '/tmp/01.mp3': { duration: 60, bitrate: 128000, track: 1, title: 'Book Title' }, '/tmp/02.mp3': { duration: 60, bitrate: 128000, track: 2, title: 'Book Title' }, '/tmp/03.mp3': { duration: 60, bitrate: 128000, track: 3, title: 'Book Title' }, }; mockExecImplementation((command) => { const matches = command.match(/"([^"]+)"/g) ?? []; const filePath = matches.length > 0 ? matches[matches.length - 1].replace(/"/g, '') : ''; const probe = probeMap[filePath]; if (!probe) { throw new Error(`Missing probe data for ${filePath}`); } const payload = { format: { duration: String(probe.duration), bit_rate: String(probe.bitrate), tags: { track: String(probe.track), title: probe.title }, }, }; return { stdout: JSON.stringify(payload) }; }); const ordered = await analyzeChapterFiles(files); expect(ordered[0].chapterTitle).toBe('Chapter 1'); expect(ordered[1].chapterTitle).toBe('Chapter 2'); }); it('uses filename order when track numbers are not sequential', async () => { const files = ['/tmp/02 - Two.mp3', '/tmp/01 - One.mp3', '/tmp/03 - Three.mp3']; const probeMap: Record = { '/tmp/02 - Two.mp3': { duration: 60, bitrate: 128000, track: 2 }, '/tmp/01 - One.mp3': { duration: 60, bitrate: 128000, track: 1 }, '/tmp/03 - Three.mp3': { duration: 60, bitrate: 128000, track: 4 }, }; mockExecImplementation((command) => { const matches = command.match(/"([^"]+)"/g) ?? []; const filePath = matches.length > 0 ? matches[matches.length - 1].replace(/"/g, '') : ''; const probe = probeMap[filePath]; if (!probe) { throw new Error(`Missing probe data for ${filePath}`); } const payload = { format: { duration: String(probe.duration), bit_rate: String(probe.bitrate), tags: { track: String(probe.track) }, }, }; return { stdout: JSON.stringify(payload) }; }); const ordered = await analyzeChapterFiles(files); expect(ordered.map((file) => path.basename(file.path))).toEqual([ '01 - One.mp3', '02 - Two.mp3', '03 - Three.mp3', ]); }); it('formats durations for logs', () => { expect(formatDuration(65000)).toBe('1m 5s'); expect(formatDuration(3601000)).toBe('1h 0m 1s'); }); it('estimates output size with overhead', async () => { fsMock.stat.mockImplementation(async (filePath: string) => { if (filePath === '/tmp/one.mp3') return { size: 100 }; if (filePath === '/tmp/two.mp3') return { size: 200 }; throw new Error('missing'); }); const size = await estimateOutputSize(['/tmp/one.mp3', '/tmp/two.mp3', '/tmp/missing.mp3']); expect(size).toBe(330); }); it('checks disk space when df output is available', async () => { mockExecImplementation(() => ({ stdout: '1024\n' })); const space = await checkDiskSpace('/tmp'); expect(space).toBe(1024 * 1024); }); it('returns null when disk space cannot be determined', async () => { mockExecImplementation(() => ({ error: new Error('df missing') })); const space = await checkDiskSpace('/tmp'); expect(space).toBeNull(); }); it('returns an error when no chapters are provided', async () => { const result = await mergeChapters([], { title: 'Book', author: 'Author', outputPath: '/tmp/output.m4b', }); expect(result.success).toBe(false); expect(result.error).toContain('No chapters'); }); it('merges chapters and returns success details', async () => { const outputPath = '/tmp/output.m4b'; const chapters = [ { path: '/tmp/one.mp3', filename: 'one.mp3', duration: 60000, bitrate: 128, chapterTitle: 'One' }, { path: '/tmp/two.mp3', filename: 'two.mp3', duration: 60000, bitrate: 128, chapterTitle: 'Two' }, ]; fsMock.access.mockResolvedValue(undefined); fsMock.mkdir.mockResolvedValue(undefined); fsMock.writeFile.mockResolvedValue(undefined); fsMock.unlink.mockResolvedValue(undefined); fsMock.stat.mockImplementation(async (filePath: string) => { if (filePath === outputPath) { return { size: 2 * 1024 * 1024 }; } return { size: 500 * 1024 }; }); mockExecImplementation((command) => { if (command.startsWith('ffmpeg -encoders')) { return { stdout: 'aac encoder' }; } if (command.startsWith('ffprobe')) { const payload = { format: { duration: '120', bit_rate: '128000', tags: {}, }, }; return { stdout: JSON.stringify(payload) }; } if (command.startsWith('ffmpeg -v error')) { return { stdout: '' }; } return { error: new Error(`Unexpected command: ${command}`) }; }); spawnMock.mockReturnValue(createSpawnProcess(0)); const result = await mergeChapters(chapters, { title: 'Book', author: 'Author', outputPath, }); expect(result.success).toBe(true); expect(result.chapterCount).toBe(2); expect(result.totalDuration).toBe(120000); expect(spawnMock).toHaveBeenCalled(); }); it('parses probe metadata including track numbers', async () => { mockExecImplementation(() => ({ stdout: JSON.stringify({ format: { duration: '90', bit_rate: '256000', tags: { track: '1/10', title: 'Chapter One' }, }, }), })); const probe = await probeAudioFile('/tmp/chapter.mp3'); expect(probe.duration).toBe(90000); expect(probe.bitrate).toBe(256); expect(probe.trackNumber).toBe(1); expect(probe.title).toBe('Chapter One'); }); it('returns failure when ffmpeg merge fails', async () => { const chapters = [ { path: '/tmp/one.mp3', filename: 'one.mp3', duration: 60000, bitrate: 128, chapterTitle: 'One' }, ]; fsMock.access.mockResolvedValue(undefined); fsMock.stat.mockResolvedValue({ size: 500 * 1024 }); fsMock.mkdir.mockResolvedValue(undefined); fsMock.writeFile.mockResolvedValue(undefined); fsMock.unlink.mockResolvedValue(undefined); mockExecImplementation((command) => { if (command.startsWith('ffmpeg -encoders')) { return { stdout: 'aac encoder' }; } return { stdout: '' }; }); spawnMock.mockReturnValue(createSpawnProcess(1, 'Error: merge failed')); const result = await mergeChapters(chapters, { title: 'Book', author: 'Author', outputPath: '/tmp/output.m4b', }); expect(result.success).toBe(false); expect(result.error).toMatch(/FFmpeg merge failed/i); }); it('returns failure when output validation fails', async () => { const outputPath = '/tmp/output.m4b'; const chapters = [ { path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' }, { path: '/tmp/two.m4a', filename: 'two.m4a', duration: 60000, bitrate: 128, chapterTitle: 'Two' }, ]; fsMock.access.mockResolvedValue(undefined); fsMock.stat.mockImplementation(async (filePath: string) => { if (filePath === outputPath) { return { size: 2 * 1024 * 1024 }; } return { size: 500 * 1024 }; }); fsMock.mkdir.mockResolvedValue(undefined); fsMock.writeFile.mockResolvedValue(undefined); fsMock.unlink.mockResolvedValue(undefined); mockExecImplementation((command) => { if (command.startsWith('ffprobe')) { return { stdout: JSON.stringify({ format: { duration: '30', bit_rate: '128000', tags: {}, }, }), }; } return { stdout: '' }; }); spawnMock.mockReturnValue(createSpawnProcess(0)); const result = await mergeChapters(chapters, { title: 'Book', author: 'Author', outputPath, }); expect(result.success).toBe(false); expect(result.error).toMatch(/Merge validation failed/i); }); it('returns failure when file integrity validation fails', async () => { const outputPath = '/tmp/output.m4b'; const chapters = [ { path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' }, { path: '/tmp/two.m4a', filename: 'two.m4a', duration: 60000, bitrate: 128, chapterTitle: 'Two' }, ]; fsMock.access.mockResolvedValue(undefined); fsMock.stat.mockResolvedValue({ size: 500 * 1024 }); fsMock.mkdir.mockResolvedValue(undefined); fsMock.writeFile.mockResolvedValue(undefined); fsMock.unlink.mockResolvedValue(undefined); mockExecImplementation((command) => { if (command.startsWith('ffprobe')) { return { stdout: JSON.stringify({ format: { duration: '120', bit_rate: '128000', tags: {}, }, }), }; } if (command.startsWith('ffmpeg -v error')) { return { error: new Error('decode failed') }; } return { stdout: '' }; }); spawnMock.mockReturnValue(createSpawnProcess(0)); const result = await mergeChapters(chapters, { title: 'Book', author: 'Author', outputPath, }); expect(result.success).toBe(false); expect(result.error).toMatch(/File integrity test failed/i); }); it('returns failure when merged file size is too small', async () => { const outputPath = '/tmp/output.m4b'; const chapters = [ { path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' }, { path: '/tmp/two.m4a', filename: 'two.m4a', duration: 60000, bitrate: 128, chapterTitle: 'Two' }, ]; fsMock.access.mockResolvedValue(undefined); fsMock.stat.mockImplementation(async (filePath: string) => { if (filePath === outputPath) { return { size: 200 * 1024 }; } return { size: 500 * 1024 }; }); fsMock.mkdir.mockResolvedValue(undefined); fsMock.writeFile.mockResolvedValue(undefined); fsMock.unlink.mockResolvedValue(undefined); mockExecImplementation((command) => { if (command.startsWith('ffprobe')) { return { stdout: JSON.stringify({ format: { duration: '120', bit_rate: '128000', tags: {}, }, }), }; } if (command.startsWith('ffmpeg -v error')) { return { stdout: '' }; } return { stdout: '' }; }); spawnMock.mockReturnValue(createSpawnProcess(0)); const result = await mergeChapters(chapters, { title: 'Book', author: 'Author', outputPath, }); expect(result.success).toBe(false); expect(result.error).toMatch(/File size too small/i); }); it('returns failure when validation encounters an error', async () => { const outputPath = '/tmp/output.m4b'; const chapters = [ { path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' }, ]; fsMock.access.mockResolvedValue(undefined); fsMock.stat.mockResolvedValue({ size: 500 * 1024 }); fsMock.mkdir.mockResolvedValue(undefined); fsMock.writeFile.mockResolvedValue(undefined); fsMock.unlink.mockResolvedValue(undefined); mockExecImplementation((command) => { if (command.startsWith('ffprobe')) { return { error: new Error('probe failed') }; } return { stdout: '' }; }); spawnMock.mockReturnValue(createSpawnProcess(0)); const result = await mergeChapters(chapters, { title: 'Book', author: 'Author', outputPath, }); expect(result.success).toBe(false); expect(result.error).toMatch(/Validation error/i); }); it('logs encoding estimates for long MP3 audiobooks', async () => { const outputPath = '/tmp/output.m4b'; const chapters = [ { path: '/tmp/one.mp3', filename: 'one.mp3', duration: 3600000, bitrate: 128, chapterTitle: 'One' }, { path: '/tmp/two.mp3', filename: 'two.mp3', duration: 3600000, bitrate: 128, chapterTitle: 'Two' }, ]; const logger = { info: vi.fn().mockResolvedValue(undefined), warn: vi.fn().mockResolvedValue(undefined), error: vi.fn().mockResolvedValue(undefined), }; fsMock.access.mockResolvedValue(undefined); fsMock.stat.mockImplementation(async (filePath: string) => { if (filePath === outputPath) { return { size: 120 * 1024 * 1024 }; } return { size: 500 * 1024 }; }); fsMock.mkdir.mockResolvedValue(undefined); fsMock.writeFile.mockResolvedValue(undefined); fsMock.unlink.mockResolvedValue(undefined); mockExecImplementation((command) => { if (command.startsWith('ffmpeg -encoders')) { return { stdout: 'libfdk_aac' }; } if (command.startsWith('ffprobe')) { return { stdout: JSON.stringify({ format: { duration: '7200', bit_rate: '128000', tags: {}, }, }), }; } if (command.startsWith('ffmpeg -v error')) { return { stdout: '' }; } return { stdout: '' }; }); spawnMock.mockReturnValue(createSpawnProcess(0)); const result = await mergeChapters(chapters, { title: 'Book', author: 'Author', outputPath, }, logger); expect(result.success).toBe(true); expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('long audiobook')); }); it('returns failure when output file is not created', async () => { const outputPath = '/tmp/output.m4b'; const chapters = [ { path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' }, ]; fsMock.access.mockImplementation(async (filePath: string) => { if (filePath === outputPath) { throw new Error('missing'); } return undefined; }); fsMock.stat.mockResolvedValue({ size: 500 * 1024 }); fsMock.mkdir.mockResolvedValue(undefined); fsMock.writeFile.mockResolvedValue(undefined); fsMock.unlink.mockResolvedValue(undefined); mockExecImplementation(() => ({ stdout: '' })); spawnMock.mockReturnValue(createSpawnProcess(0)); const result = await mergeChapters(chapters, { title: 'Book', author: 'Author', outputPath, }); expect(result.success).toBe(false); expect(result.error).toMatch(/Merged file not created/i); }); describe('metadata escaping', () => { it('does NOT escape single quotes in metadata (they are literal in double-quoted shell strings)', async () => { const outputPath = '/tmp/output.m4b'; const chapters = [ { path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' }, ]; fsMock.access.mockResolvedValue(undefined); fsMock.stat.mockImplementation(async (filePath: string) => { if (filePath === outputPath) { return { size: 2 * 1024 * 1024 }; } return { size: 500 * 1024 }; }); fsMock.mkdir.mockResolvedValue(undefined); fsMock.writeFile.mockResolvedValue(undefined); fsMock.unlink.mockResolvedValue(undefined); mockExecImplementation((command) => { if (command.startsWith('ffprobe')) { return { stdout: JSON.stringify({ format: { duration: '60', bit_rate: '128000', tags: {} }, }), }; } if (command.startsWith('ffmpeg -v error')) { return { stdout: '' }; } return { stdout: '' }; }); spawnMock.mockReturnValue(createSpawnProcess(0)); await mergeChapters(chapters, { title: "It's Not Her", author: "O'Brien", narrator: "Jane's Voice", outputPath, }); // Get the args passed to spawn const spawnCall = spawnMock.mock.calls[0]; const args = spawnCall[1] as string[]; // Find the title metadata arg (format after parsing: title="It's Not Her) const titleArg = args.find((arg: string) => arg.startsWith('title=')); const albumArtistArg = args.find((arg: string) => arg.startsWith('album_artist=')); const composerArg = args.find((arg: string) => arg.startsWith('composer=')); // Single quotes should appear as-is ('s), NOT escaped with backslash (\'s) // The args contain the value with opening quote: title="It's Not Her expect(titleArg).toContain("It's Not Her"); expect(titleArg).not.toContain("\\'"); // No escaped single quotes expect(albumArtistArg).toContain("O'Brien"); expect(albumArtistArg).not.toContain("\\'"); expect(composerArg).toContain("Jane's Voice"); expect(composerArg).not.toContain("\\'"); // Verify no backslash-escaped single quotes anywhere in args const allArgsJoined = args.join(' '); expect(allArgsJoined).not.toContain("\\'"); }); it('properly escapes double quotes and special shell characters', async () => { const outputPath = '/tmp/output.m4b'; const chapters = [ { path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' }, ]; fsMock.access.mockResolvedValue(undefined); fsMock.stat.mockImplementation(async (filePath: string) => { if (filePath === outputPath) { return { size: 2 * 1024 * 1024 }; } return { size: 500 * 1024 }; }); fsMock.mkdir.mockResolvedValue(undefined); fsMock.writeFile.mockResolvedValue(undefined); fsMock.unlink.mockResolvedValue(undefined); mockExecImplementation((command) => { if (command.startsWith('ffprobe')) { return { stdout: JSON.stringify({ format: { duration: '60', bit_rate: '128000', tags: {} }, }), }; } if (command.startsWith('ffmpeg -v error')) { return { stdout: '' }; } return { stdout: '' }; }); spawnMock.mockReturnValue(createSpawnProcess(0)); await mergeChapters(chapters, { title: 'Book "Quoted" $100', author: 'Author', outputPath, }); // Get the args passed to spawn const spawnCall = spawnMock.mock.calls[0]; const args = spawnCall[1] as string[]; // Find the title arg - double quotes and $ should be escaped const titleArg = args.find((arg: string) => arg.startsWith('title=')); // Verify escaping is present for double quotes and dollar signs expect(titleArg).toContain('\\"Quoted\\"'); expect(titleArg).toContain('\\$100'); }); }); });