mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
94dbaf073b
Introduced a Vitest-based backend unit testing framework with supporting scripts, helpers, and GitHub Actions integration. Refactored the admin settings page to a modular architecture, splitting monolithic logic into feature-specific tabs and hooks for improved maintainability and testability. Updated documentation to reflect the new testing setup and settings architecture, and added new dependencies for testing utilities.
624 lines
20 KiB
TypeScript
624 lines
20 KiB
TypeScript
/**
|
|
* 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<string, { duration: number; bitrate: number; track: number }> = {
|
|
'/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<string, { duration: number; bitrate: number; title?: string }> = {
|
|
'/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<string, { duration: number; bitrate: number; track: number; title: string }> = {
|
|
'/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<string, { duration: number; bitrate: number; track: number }> = {
|
|
'/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);
|
|
});
|
|
});
|