Files
ReadMeABook/tests/utils/metadata-tagger.test.ts
T
kikootwo a0f2ba680d Add rootless Podman fixes, and others
improve container startup for rootless Podman, plus related refactors and tests. Key changes:

- Add/modify Audiobookshelf-related code and wiring (src/lib/services/audiobookshelf/api.ts, library service refs) and update documentation TABLEOFCONTENTS to reference ABS implementation.
- Detect user namespace in docker/unified app-start.sh and redis-start.sh and skip gosu when running in rootless Podman to preserve UID mapping; improve startup logging and verification.
- Add utility/service files (auth-token-cache.service.ts, credential-migration.service.ts, cleanup-helpers.ts) and corresponding tests; update chapter-merger and metadata-tagger utilities/tests.
- Update many admin/auth API routes and tests to reflect changes in settings and integrations.
- Remove large AI agent and Audiobookshelf implementation guide docs (AGENTS.md and the implementation guide) and add README note about AI-assisted workflow.

These changes enable Audiobookshelf backend mode, improve compatibility with rootless container runtimes, and include cleanup/refactor work and unit tests.
2026-02-04 14:05:28 -05:00

202 lines
6.3 KiB
TypeScript

/**
* 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 execMock = vi.hoisted(() => vi.fn());
const fsMock = vi.hoisted(() => ({
access: vi.fn(),
unlink: vi.fn(),
}));
vi.mock('child_process', () => ({
exec: execMock,
}));
vi.mock('fs/promises', () => ({
default: fsMock,
...fsMock,
}));
function mockExecSuccess(stdout = 'ok') {
execMock.mockImplementation((command: string, options: any, callback?: any) => {
const cb = typeof options === 'function' ? options : callback;
cb(null, stdout, '');
});
}
function mockExecFailure(message = 'ffmpeg error') {
execMock.mockImplementation((command: string, options: any, callback?: any) => {
const cb = typeof options === 'function' ? options : callback;
cb(new Error(message), '', '');
});
}
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(execMock).not.toHaveBeenCalled();
});
it('tags an m4b file with metadata', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecSuccess('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');
const command = execMock.mock.calls[0][0] as string;
expect(command).toContain('-metadata title="Book"');
expect(command).toContain('-metadata album_artist="Author"');
expect(command).toContain('-metadata composer="Narrator"');
expect(command).toContain('-metadata date="2020"');
expect(command).toContain('-metadata ----:com.apple.iTunes:ASIN="ASIN123"');
expect(command).toContain('-f mp4');
});
it('cleans up temp files and returns errors when ffmpeg fails', async () => {
fsMock.access.mockResolvedValue(undefined);
fsMock.unlink.mockResolvedValue(undefined);
mockExecFailure('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);
mockExecSuccess('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 () => {
mockExecSuccess('ffmpeg version');
await expect(checkFfmpegAvailable()).resolves.toBe(true);
mockExecFailure('not installed');
await expect(checkFfmpegAvailable()).resolves.toBe(false);
});
describe('metadata escaping', () => {
it('does NOT escape single quotes (they are literal in double-quoted shell strings)', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: "It's Not Her",
author: "Author's Name",
});
const command = execMock.mock.calls[0][0] as string;
// Single quotes should appear as-is, NOT escaped with backslash
expect(command).toContain('-metadata title="It\'s Not Her"');
expect(command).not.toContain("It\\'s"); // No backslash-escaped single quotes
expect(command).toContain('-metadata album_artist="Author\'s Name"');
});
it('escapes double quotes in metadata values', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: 'Book "Title"',
author: 'Author',
});
const command = execMock.mock.calls[0][0] as string;
expect(command).toContain('-metadata title="Book \\"Title\\""');
});
it('escapes backticks to prevent command substitution', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: 'Book `test`',
author: 'Author',
});
const command = execMock.mock.calls[0][0] as string;
expect(command).toContain('-metadata title="Book \\`test\\`"');
});
it('escapes dollar signs to prevent variable expansion', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: 'Book $100',
author: 'Author',
});
const command = execMock.mock.calls[0][0] as string;
expect(command).toContain('-metadata title="Book \\$100"');
});
it('escapes backslashes before other characters', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: 'Path\\to\\book',
author: 'Author',
});
const command = execMock.mock.calls[0][0] as string;
expect(command).toContain('-metadata title="Path\\\\to\\\\book"');
});
it('handles complex titles with multiple special characters', async () => {
fsMock.access.mockResolvedValue(undefined);
mockExecSuccess('done');
await tagAudioFileMetadata('/tmp/book.m4b', {
title: "Don't Say \"Hello\" for $5",
author: "O'Brien",
});
const command = execMock.mock.calls[0][0] as string;
// Single quotes literal, double quotes escaped, dollar escaped
expect(command).toContain('-metadata title="Don\'t Say \\"Hello\\" for \\$5"');
expect(command).toContain('-metadata album_artist="O\'Brien"');
});
});
});