mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
01e61f3368
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.
153 lines
5.0 KiB
TypeScript
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
|
|
);
|
|
});
|