Files
ReadMeABook/tests/utils/metadata-tagger.integration.test.ts
T
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

153 lines
5.0 KiB
TypeScript

/**
* Component: Metadata Tagging Integration Tests (real ffmpeg)
* Documentation: documentation/phase3/file-organization.md
*
* Gated behind a runtime ffmpeg/ffprobe availability check. Skips cleanly
* if either binary is missing. Verifies that tag values written via the
* real argv-form spawn path are read back byte-for-byte by ffprobe — this
* is the integration-layer guarantee that the quote-bug (#171) cannot recur.
*
* Note: availability check uses spawnSync at module load so `it.skipIf` can
* see the result (test-registration is synchronous; beforeAll runs later).
*/
import { execFile, spawnSync } from 'child_process';
import { promisify } from 'util';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { tagAudioFileMetadata } from '@/lib/utils/metadata-tagger';
const execFileP = promisify(execFile);
function hasBinarySync(bin: string): boolean {
try {
const result = spawnSync(bin, ['-version'], { stdio: 'ignore' });
return result.status === 0;
} catch {
return false;
}
}
const ffmpegAvailable = hasBinarySync('ffmpeg');
const ffprobeAvailable = hasBinarySync('ffprobe');
const skipReal = !(ffmpegAvailable && ffprobeAvailable);
let workDir = '';
beforeAll(async () => {
if (skipReal) {
// eslint-disable-next-line no-console
console.warn(
`[metadata-tagger.integration] Skipping real-ffmpeg tests: ffmpeg=${ffmpegAvailable}, ffprobe=${ffprobeAvailable}`
);
return;
}
workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'rmab-tagger-it-'));
}, 30_000);
afterAll(async () => {
if (workDir) {
try {
await fs.rm(workDir, { recursive: true, force: true });
} catch {
// best-effort cleanup
}
}
});
/** Create a small silent fixture audio file in the requested format. */
async function makeFixture(ext: '.m4a' | '.mp3' | '.flac'): Promise<string> {
const out = path.join(workDir, `fixture-${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`);
const codecArgs =
ext === '.mp3'
? ['-c:a', 'libmp3lame', '-q:a', '9']
: ext === '.flac'
? ['-c:a', 'flac']
: ['-c:a', 'aac', '-b:a', '32k'];
await execFileP('ffmpeg', [
'-y',
'-f', 'lavfi',
'-i', 'anullsrc=channel_layout=mono:sample_rate=22050',
'-t', '1',
...codecArgs,
out,
]);
return out;
}
/** Read a single metadata tag back from a file via ffprobe. */
async function readTag(filePath: string, key: string): Promise<string | undefined> {
const { stdout } = await execFileP('ffprobe', [
'-v', 'error',
'-show_entries', 'format_tags',
'-of', 'json',
filePath,
]);
const parsed = JSON.parse(stdout) as { format?: { tags?: Record<string, string> } };
const tags = parsed.format?.tags ?? {};
return tags[key] ?? tags[key.toLowerCase()] ?? tags[key.toUpperCase()];
}
describe('metadata-tagger integration (real ffmpeg)', () => {
// Torture value covers the bug-class surface:
// embedded `"`, `'`, `$`, `` ` ``, `\`, em-dash.
const TORTURE = `Test "Quoted" Book — Don't $hop \`now\` \\path`;
it.skipIf(skipReal)(
'm4a: round-trips a torture-value title byte-for-byte',
async () => {
const src = await makeFixture('.m4a');
const result = await tagAudioFileMetadata(src, { title: TORTURE, author: 'Author' });
expect(result.success).toBe(true);
expect(result.taggedFilePath).toBeDefined();
const readBack = await readTag(result.taggedFilePath!, 'title');
expect(readBack).toBe(TORTURE);
},
60_000
);
it.skipIf(skipReal)(
'mp3: round-trips a torture-value title byte-for-byte',
async () => {
const src = await makeFixture('.mp3');
const result = await tagAudioFileMetadata(src, { title: TORTURE, author: 'Author' });
expect(result.success).toBe(true);
const readBack = await readTag(result.taggedFilePath!, 'title');
expect(readBack).toBe(TORTURE);
},
60_000
);
it.skipIf(skipReal)(
'flac: round-trips a torture-value title byte-for-byte',
async () => {
const src = await makeFixture('.flac');
const result = await tagAudioFileMetadata(src, { title: TORTURE, author: 'Author' });
expect(result.success).toBe(true);
const readBack = await readTag(result.taggedFilePath!, 'title');
expect(readBack).toBe(TORTURE);
},
60_000
);
it.skipIf(skipReal)(
'm4a: author does NOT inherit a stray leading `"` when title contains `"`',
async () => {
// The exact #171 symptom: a `"` in one field used to leak as a leading `"`
// on an adjacent field after shell tokenization. Verify both are clean.
const src = await makeFixture('.m4a');
const result = await tagAudioFileMetadata(src, {
title: 'Has "embedded" quotes',
author: 'Alexandre Dumas',
});
expect(result.success).toBe(true);
const readAuthor = await readTag(result.taggedFilePath!, 'album_artist');
expect(readAuthor).toBe('Alexandre Dumas');
expect(readAuthor?.startsWith('"')).toBe(false);
},
60_000
);
});