Files
ReadMeABook/tests/utils/metadata-tagger.test.ts
kikootwo 01e61f3368 Improve metadata-tagger tests and add integration
Switch unit tests to mock child_process.execFile and assert argv structure instead of a single shell command string. Add helpers (lastCallArgv, expectMetadataArg) and expand coverage to catch the #171 quote-regression, validate sanitization of invisible/whitespace/null chars, ensure no shell-quoting is introduced, and cover all format branches (m4b/mp3/flac). Add a new integration test suite that runs real ffmpeg/ffprobe (skips if binaries missing) to verify metadata round-trips byte-for-byte. Update metadata-tagger implementation (binary change) to use the argv-style spawn/execFile path expected by the tests.
2026-05-18 14:13:07 -04:00

553 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Component: Metadata Tagging Utility Tests
* Documentation: documentation/phase3/file-organization.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { checkFfmpegAvailable, tagAudioFileMetadata, tagMultipleFiles } from '@/lib/utils/metadata-tagger';
const execFileMock = vi.hoisted(() => vi.fn());
const fsMock = vi.hoisted(() => ({
access: vi.fn(),
unlink: vi.fn(),
}));
vi.mock('child_process', () => ({
execFile: execFileMock,
}));
vi.mock('fs/promises', () => ({
default: fsMock,
...fsMock,
}));
function mockExecFileSuccess(stdout = 'ok') {
execFileMock.mockImplementation((file: string, args: string[], options: any, callback?: any) => {
const cb = typeof options === 'function' ? options : callback;
cb(null, stdout, '');
});
}
function mockExecFileFailure(message = 'ffmpeg error') {
execFileMock.mockImplementation((file: string, args: string[], options: any, callback?: any) => {
const cb = typeof options === 'function' ? options : callback;
cb(new Error(message), '', '');
});
}
function lastCallArgv(): string[] {
const call = execFileMock.mock.calls.at(-1);
if (!call) throw new Error('execFile was not called');
return call[1] as string[];
}
function lastCallCommand(): string {
const call = execFileMock.mock.calls.at(-1);
if (!call) throw new Error('execFile was not called');
return call[0] as string;
}
/**
* Assert that argv contains the adjacent pair `['-metadata', '${key}=${value}']`.
* This is the regression-catching assertion — it verifies the metadata payload
* is a single argv element with no embedded shell quoting.
*/
function expectMetadataArg(argv: string[], key: string, value: string): void {
const expectedPayload = `${key}=${value}`;
for (let i = 0; i < argv.length - 1; i++) {
if (argv[i] === '-metadata' && argv[i + 1] === expectedPayload) {
return;
}
}
throw new Error(
`Expected argv to contain ['-metadata', '${expectedPayload}'] as adjacent elements.\nArgv: ${JSON.stringify(argv)}`
);
}
describe('metadata tagger', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns an error for unsupported file formats', async () => {
fsMock.access.mockResolvedValue(undefined);
const result = await tagAudioFileMetadata('/tmp/book.wav', {
title: 'Book',
author: 'Author',
});
expect(result.success).toBe(false);
expect(result.error).toContain('Unsupported file format');
expect(execFileMock).not.toHaveBeenCalled();
});
it('tags an m4b file with metadata using argv form', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecFileSuccess('done');
const result = await tagAudioFileMetadata('/tmp/book.m4b', {
title: 'Book',
author: 'Author',
narrator: 'Narrator',
year: 2020,
asin: 'ASIN123',
});
expect(result.success).toBe(true);
expect(result.taggedFilePath).toBe('/tmp/book.m4b.tmp');
expect(lastCallCommand()).toBe('ffmpeg');
const argv = lastCallArgv();
expectMetadataArg(argv, 'title', 'Book');
expectMetadataArg(argv, 'album', 'Book');
expectMetadataArg(argv, 'album_artist', 'Author');
expectMetadataArg(argv, 'artist', 'Author');
expectMetadataArg(argv, 'composer', 'Narrator');
expectMetadataArg(argv, 'date', '2020');
expectMetadataArg(argv, '----:com.apple.iTunes:ASIN', 'ASIN123');
expect(argv).toContain('/tmp/book.m4b');
expect(argv).toContain('/tmp/book.m4b.tmp');
const fIdx = argv.indexOf('-f');
expect(fIdx).toBeGreaterThanOrEqual(0);
expect(argv[fIdx + 1]).toBe('mp4');
});
it('tags an mp3 file with mp3 output format', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecFileSuccess('done');
await tagAudioFileMetadata('/tmp/book.mp3', {
title: 'Book',
author: 'Author',
asin: 'ASIN999',
});
const argv = lastCallArgv();
expectMetadataArg(argv, 'title', 'Book');
expectMetadataArg(argv, 'ASIN', 'ASIN999');
const fIdx = argv.indexOf('-f');
expect(argv[fIdx + 1]).toBe('mp3');
});
it('tags a flac file with flac output format', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecFileSuccess('done');
await tagAudioFileMetadata('/tmp/book.flac', {
title: 'Book',
author: 'Author',
});
const argv = lastCallArgv();
expectMetadataArg(argv, 'title', 'Book');
expectMetadataArg(argv, 'albumartist', 'Author');
const fIdx = argv.indexOf('-f');
expect(argv[fIdx + 1]).toBe('flac');
});
it('cleans up temp files and returns errors when ffmpeg fails', async () => {
fsMock.access.mockResolvedValue(undefined);
fsMock.unlink.mockResolvedValue(undefined);
mockExecFileFailure('exec failed');
const result = await tagAudioFileMetadata('/tmp/book.mp3', {
title: 'Book',
author: 'Author',
asin: 'ASIN123',
});
expect(result.success).toBe(false);
expect(result.error).toContain('ffmpeg failed');
expect(fsMock.unlink).toHaveBeenCalledWith('/tmp/book.mp3.tmp');
});
it('tags multiple files in sequence', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecFileSuccess('done');
const results = await tagMultipleFiles(['/tmp/one.m4a', '/tmp/two.m4a'], {
title: 'Book',
author: 'Author',
});
expect(results).toHaveLength(2);
expect(results.every((result) => result.success)).toBe(true);
});
it('checks ffmpeg availability', async () => {
mockExecFileSuccess('ffmpeg version');
await expect(checkFfmpegAvailable()).resolves.toBe(true);
mockExecFileFailure('not installed');
await expect(checkFfmpegAvailable()).resolves.toBe(false);
});
describe('series metadata', () => {
it('writes show/episode_id for m4b when series/seriesPart provided', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecFileSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: 'Book',
author: 'Author',
series: 'The Mistborn Saga',
seriesPart: '1',
});
const argv = lastCallArgv();
expectMetadataArg(argv, 'show', 'The Mistborn Saga');
expectMetadataArg(argv, 'episode_id', '1');
});
it('writes SERIES/SERIES-PART for mp3 when series/seriesPart provided', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecFileSuccess('done');
await tagAudioFileMetadata('/tmp/book.mp3', {
title: 'Book',
author: 'Author',
series: 'The Mistborn Saga',
seriesPart: '1.5',
});
const argv = lastCallArgv();
expectMetadataArg(argv, 'SERIES', 'The Mistborn Saga');
expectMetadataArg(argv, 'SERIES-PART', '1.5');
});
it('writes SERIES/SERIES-PART for flac when series/seriesPart provided', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecFileSuccess('done');
await tagAudioFileMetadata('/tmp/book.flac', {
title: 'Book',
author: 'Author',
series: 'The Mistborn Saga',
seriesPart: '2',
});
const argv = lastCallArgv();
expectMetadataArg(argv, 'SERIES', 'The Mistborn Saga');
expectMetadataArg(argv, 'SERIES-PART', '2');
});
it('omits series tags when fields are absent', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecFileSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: 'Book',
author: 'Author',
});
const argv = lastCallArgv();
const payloads = argv.filter((_, i) => i > 0 && argv[i - 1] === '-metadata');
expect(payloads.some((p) => p.startsWith('show='))).toBe(false);
expect(payloads.some((p) => p.startsWith('episode_id='))).toBe(false);
expect(payloads.some((p) => p.startsWith('SERIES='))).toBe(false);
expect(payloads.some((p) => p.startsWith('SERIES-PART='))).toBe(false);
});
});
describe('quote regression (#171) — embedded `"` flows through untouched', () => {
const tricky = 'Test "Quoted" Book';
it('m4b: embedded double-quote in title is a single clean argv element', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecFileSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: tricky,
author: 'Author',
});
const argv = lastCallArgv();
expectMetadataArg(argv, 'title', tricky);
expectMetadataArg(argv, 'album', tricky);
// Adjacent fields must not have inherited a stray leading `"`.
expectMetadataArg(argv, 'album_artist', 'Author');
expectMetadataArg(argv, 'artist', 'Author');
});
it('mp3: embedded double-quote in author is a single clean argv element', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecFileSuccess('done');
await tagAudioFileMetadata('/tmp/book.mp3', {
title: 'Book',
author: 'Alexandre "Dumas',
});
const argv = lastCallArgv();
expectMetadataArg(argv, 'album_artist', 'Alexandre "Dumas');
expectMetadataArg(argv, 'artist', 'Alexandre "Dumas');
// Title must NOT have inherited a leading quote (the exact #171 symptom).
expectMetadataArg(argv, 'title', 'Book');
});
it('flac: embedded double-quote in series flows through cleanly', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecFileSuccess('done');
await tagAudioFileMetadata('/tmp/book.flac', {
title: 'Book',
author: 'Author',
series: 'The "Final" Saga',
seriesPart: '1',
});
const argv = lastCallArgv();
expectMetadataArg(argv, 'SERIES', 'The "Final" Saga');
expectMetadataArg(argv, 'SERIES-PART', '1');
});
});
describe('special characters flow through as literals (no shell escaping)', () => {
it('single quotes are untouched', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecFileSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: "It's Not Her",
author: "O'Brien",
});
const argv = lastCallArgv();
expectMetadataArg(argv, 'title', "It's Not Her");
expectMetadataArg(argv, 'album_artist', "O'Brien");
});
it('dollar signs are untouched (no shell variable expansion)', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecFileSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: 'Book $100',
author: 'Author',
});
const argv = lastCallArgv();
expectMetadataArg(argv, 'title', 'Book $100');
});
it('backticks are untouched (no command substitution)', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecFileSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: 'Book `test`',
author: 'Author',
});
const argv = lastCallArgv();
expectMetadataArg(argv, 'title', 'Book `test`');
});
it('backslashes are untouched', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecFileSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: 'Path\\to\\book',
author: 'Author',
});
const argv = lastCallArgv();
expectMetadataArg(argv, 'title', 'Path\\to\\book');
});
it('combined torture-test value flows through as one literal argv element', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecFileSuccess('done');
const tortureTitle = `Don't Say "Hello" for $5 \`backtick\` and \\back`;
await tagAudioFileMetadata('/tmp/book.m4b', {
title: tortureTitle,
author: "O'Brien",
});
const argv = lastCallArgv();
expectMetadataArg(argv, 'title', tortureTitle);
expectMetadataArg(argv, 'album_artist', "O'Brien");
});
});
describe('input sanitization (boundary defense for invisible chars)', () => {
it('strips leading BOM from title', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecFileSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: 'Alexandre Dumas Title',
author: 'Author',
});
const argv = lastCallArgv();
expectMetadataArg(argv, 'title', 'Alexandre Dumas Title');
});
it('strips leading NBSP from author', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecFileSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: 'Book',
author: ' Alexandre Dumas',
});
const argv = lastCallArgv();
expectMetadataArg(argv, 'album_artist', 'Alexandre Dumas');
expectMetadataArg(argv, 'artist', 'Alexandre Dumas');
});
it('strips leading zero-width chars', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecFileSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: 'Book Title',
author: 'Author',
});
const argv = lastCallArgv();
expectMetadataArg(argv, 'title', 'Book Title');
});
it('trims surrounding whitespace', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecFileSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: ' Spaced Book ',
author: '\tAuthor Name\t',
});
const argv = lastCallArgv();
expectMetadataArg(argv, 'title', 'Spaced Book');
expectMetadataArg(argv, 'album_artist', 'Author Name');
});
it('strips null bytes and line breaks anywhere in value', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecFileSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: 'Book\x00With\nBreaks\r',
author: 'Author',
});
const argv = lastCallArgv();
expectMetadataArg(argv, 'title', 'BookWithBreaks');
});
it('leaves clean inputs untouched', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecFileSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: 'Perfectly Clean Title',
author: 'Clean Author',
narrator: 'Clean Narrator',
asin: 'B0009JKV9W',
series: 'Clean Series',
seriesPart: '1',
});
const argv = lastCallArgv();
expectMetadataArg(argv, 'title', 'Perfectly Clean Title');
expectMetadataArg(argv, 'album_artist', 'Clean Author');
expectMetadataArg(argv, 'composer', 'Clean Narrator');
expectMetadataArg(argv, '----:com.apple.iTunes:ASIN', 'B0009JKV9W');
expectMetadataArg(argv, 'show', 'Clean Series');
expectMetadataArg(argv, 'episode_id', '1');
});
it('sanitization applies across all three format branches', async () => {
fsMock.access.mockResolvedValue(undefined);
for (const filePath of ['/tmp/book.m4b', '/tmp/book.mp3', '/tmp/book.flac']) {
mockExecFileSuccess('done');
await tagAudioFileMetadata(filePath, {
title: ' Dirty Title ',
author: ' Dirty Author',
});
const argv = lastCallArgv();
expectMetadataArg(argv, 'title', 'Dirty Title');
// m4b/mp3 use album_artist; flac uses albumartist (no underscore).
const authorKey = filePath.endsWith('.flac') ? 'albumartist' : 'album_artist';
expectMetadataArg(argv, authorKey, 'Dirty Author');
}
});
});
describe('argv structural integrity', () => {
it('no -metadata payload starts or ends with `"` (the #171 bug class)', async () => {
fsMock.access.mockResolvedValue(undefined);
const torture = {
title: 'Has "quotes" $dollar `tick` \\back',
author: 'A "B" C',
narrator: 'N "ar" tor',
asin: 'ASIN"123',
series: 'Ser "ies"',
seriesPart: '"1"',
year: 2020,
};
for (const filePath of ['/tmp/book.m4b', '/tmp/book.mp3', '/tmp/book.flac']) {
mockExecFileSuccess('done');
await tagAudioFileMetadata(filePath, torture);
const argv = lastCallArgv();
for (let i = 0; i < argv.length - 1; i++) {
if (argv[i] === '-metadata') {
const payload = argv[i + 1];
// Split key=value; the VALUE part must not have leading/trailing wrapping quotes.
const eqIdx = payload.indexOf('=');
const value = eqIdx >= 0 ? payload.substring(eqIdx + 1) : payload;
// The seriesPart torture value is literally '"1"', so we check that the value
// matches one of the original torture values (no extra wrapping was added).
if (value.startsWith('"') && value.endsWith('"')) {
// Only acceptable when the original input was already wrapped (seriesPart='"1"').
expect(value).toBe('"1"');
}
}
}
}
});
it('input and output paths are passed as single argv elements (no quote wrapping)', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecFileSuccess('done');
await tagAudioFileMetadata('/path with spaces/book.m4b', {
title: 'Book',
author: 'Author',
});
const argv = lastCallArgv();
const iIdx = argv.indexOf('-i');
expect(iIdx).toBeGreaterThanOrEqual(0);
expect(argv[iIdx + 1]).toBe('/path with spaces/book.m4b');
expect(argv[argv.length - 1]).toBe('/path with spaces/book.m4b.tmp');
});
it('execFile is called with command "ffmpeg" (not a shell)', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecFileSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: 'Book',
author: 'Author',
});
expect(execFileMock).toHaveBeenCalled();
expect(lastCallCommand()).toBe('ffmpeg');
});
});
});