Add backend unit test framework and modularize settings UI

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.
This commit is contained in:
kikootwo
2026-01-15 16:49:59 -05:00
parent b3f89d67bb
commit 94dbaf073b
127 changed files with 23549 additions and 2868 deletions
+164
View File
@@ -0,0 +1,164 @@
/**
* Component: API Utility Functions Tests
* Documentation: documentation/frontend/utilities.md
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const jwtState = vi.hoisted(() => ({
isTokenExpired: vi.fn(),
}));
vi.mock('@/lib/utils/jwt-client', () => ({
isTokenExpired: jwtState.isTokenExpired,
}));
describe('api utilities', () => {
const originalWindow = globalThis.window;
const storage = new Map<string, string>();
let fetchMock: ReturnType<typeof vi.fn>;
const localStorageMock = {
getItem: (key: string) => (storage.has(key) ? storage.get(key)! : null),
setItem: (key: string, value: string) => {
storage.set(key, String(value));
},
removeItem: (key: string) => {
storage.delete(key);
},
clear: () => {
storage.clear();
},
};
const createResponse = (status: number, body: unknown, ok = status >= 200 && status < 300) => ({
ok,
status,
json: vi.fn().mockResolvedValue(body),
});
beforeEach(() => {
vi.resetModules();
storage.clear();
fetchMock = vi.fn();
(globalThis as any).localStorage = localStorageMock;
(globalThis as any).fetch = fetchMock;
jwtState.isTokenExpired.mockReset();
jwtState.isTokenExpired.mockReturnValue(false);
});
afterEach(() => {
globalThis.window = originalWindow;
});
it('adds authorization headers when access token exists', async () => {
const { fetchWithAuth } = await import('@/lib/utils/api');
localStorageMock.setItem('accessToken', 'token-1');
fetchMock.mockResolvedValue(createResponse(200, {}));
await fetchWithAuth('/api/data', { headers: { 'X-Test': '1' } });
const [, init] = fetchMock.mock.calls[0];
expect(init.headers).toEqual({
'X-Test': '1',
'Authorization': 'Bearer token-1',
});
});
it('refreshes tokens on 401 and retries the request', async () => {
const { fetchWithAuth } = await import('@/lib/utils/api');
localStorageMock.setItem('accessToken', 'token-old');
localStorageMock.setItem('refreshToken', 'refresh-1');
let call = 0;
fetchMock.mockImplementation(async (url: string) => {
if (url === '/api/auth/refresh') {
return createResponse(200, { accessToken: 'token-new' }, true);
}
call += 1;
if (call === 1) {
return createResponse(401, {}, false);
}
return createResponse(200, { ok: true }, true);
});
const response = await fetchWithAuth('/api/data');
expect(response.status).toBe(200);
expect(localStorageMock.getItem('accessToken')).toBe('token-new');
const retryCall = fetchMock.mock.calls.find((entry: any[]) => entry[0] === '/api/data' && entry[1]?.headers?.Authorization === 'Bearer token-new');
expect(retryCall).toBeDefined();
});
it('logs out when refresh token is expired', async () => {
const { fetchWithAuth } = await import('@/lib/utils/api');
jwtState.isTokenExpired.mockReturnValue(true);
localStorageMock.setItem('accessToken', 'token-old');
localStorageMock.setItem('refreshToken', 'refresh-1');
localStorageMock.setItem('user', 'user');
globalThis.window = { location: { pathname: '/requests', href: '' } } as any;
fetchMock.mockResolvedValue(createResponse(401, {}, false));
await fetchWithAuth('/api/data');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(localStorageMock.getItem('accessToken')).toBeNull();
expect(localStorageMock.getItem('refreshToken')).toBeNull();
expect(globalThis.window.location.href).toBe('/login?redirect=%2Frequests');
});
it('logs out when refreshed token still yields 401', async () => {
const { fetchWithAuth } = await import('@/lib/utils/api');
localStorageMock.setItem('accessToken', 'token-old');
localStorageMock.setItem('refreshToken', 'refresh-1');
localStorageMock.setItem('user', 'user');
globalThis.window = { location: { pathname: '/requests', href: '' } } as any;
let call = 0;
fetchMock.mockImplementation(async (url: string) => {
if (url === '/api/auth/refresh') {
return createResponse(200, { accessToken: 'token-new' }, true);
}
call += 1;
if (call === 1) {
return createResponse(401, {}, false);
}
return createResponse(401, {}, false);
});
await fetchWithAuth('/api/data');
expect(localStorageMock.getItem('accessToken')).toBeNull();
expect(localStorageMock.getItem('refreshToken')).toBeNull();
expect(globalThis.window.location.href).toBe('/login?redirect=%2Frequests');
});
it('fetches JSON data successfully', async () => {
const { fetchJSON } = await import('@/lib/utils/api');
fetchMock.mockResolvedValue(createResponse(200, { ok: true }, true));
const result = await fetchJSON('/api/data');
expect(result).toEqual({ ok: true });
});
it('throws a useful error when JSON request fails', async () => {
const { fetchJSON } = await import('@/lib/utils/api');
fetchMock.mockResolvedValue(createResponse(500, { message: 'bad' }, false));
await expect(fetchJSON('/api/data')).rejects.toThrow('bad');
});
});
+152
View File
@@ -0,0 +1,152 @@
/**
* Component: Audiobook Matcher Tests
* Documentation: documentation/integrations/audible.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
const prismaMock = createPrismaMock();
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
describe('audiobook-matcher', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns ASIN exact match from dedicated field', async () => {
prismaMock.plexLibrary.findMany.mockResolvedValue([
{
plexGuid: 'guid-1',
plexRatingKey: 'rating-1',
title: 'Test Book',
author: 'Test Author',
asin: 'B00TEST123',
isbn: null,
},
]);
const { findPlexMatch } = await import('@/lib/utils/audiobook-matcher');
const match = await findPlexMatch({
asin: 'B00TEST123',
title: 'Test Book',
author: 'Test Author',
});
expect(match?.plexGuid).toBe('guid-1');
});
it('rejects candidates with mismatched ASINs in plexGuid', async () => {
prismaMock.plexLibrary.findMany.mockResolvedValue([
{
plexGuid: 'com.plexapp.agents.audible://B00WRONG999',
plexRatingKey: null,
title: 'Test Book',
author: 'Test Author',
asin: null,
isbn: null,
},
]);
const { findPlexMatch } = await import('@/lib/utils/audiobook-matcher');
const match = await findPlexMatch({
asin: 'B00RIGHT123',
title: 'Test Book',
author: 'Test Author',
});
expect(match).toBeNull();
});
it('uses narrator matching when author match is weak', async () => {
prismaMock.plexLibrary.findMany.mockResolvedValue([
{
plexGuid: 'guid-narrator',
plexRatingKey: null,
title: 'Great Book',
author: 'Jane Narrator',
asin: null,
isbn: null,
},
]);
const { findPlexMatch } = await import('@/lib/utils/audiobook-matcher');
const match = await findPlexMatch({
asin: 'B00TEST999',
title: 'Great Book',
author: 'Different Author',
narrator: 'Jane Narrator',
});
expect(match?.plexGuid).toBe('guid-narrator');
});
it('matches library items by ASIN, ISBN, then fuzzy match', async () => {
const items = [
{ id: '1', externalId: 'g1', title: 'Alpha', author: 'Author A', asin: 'ASIN1' },
{ id: '2', externalId: 'g2', title: 'Beta', author: 'Author B', isbn: '978-1-23456-789-7' },
{ id: '3', externalId: 'g3', title: 'Gamma Book', author: 'Author C' },
];
const { matchAudiobook } = await import('@/lib/utils/audiobook-matcher');
const asinMatch = matchAudiobook({ title: 'x', author: 'y', asin: 'ASIN1' }, items);
expect(asinMatch?.externalId).toBe('g1');
const isbnMatch = matchAudiobook({ title: 'x', author: 'y', isbn: '9781234567897' }, items);
expect(isbnMatch?.externalId).toBe('g2');
const fuzzyMatch = matchAudiobook({ title: 'Gamma Book', author: 'Author C' }, items);
expect(fuzzyMatch?.externalId).toBe('g3');
});
it('enriches audiobooks with availability and request status', async () => {
prismaMock.plexLibrary.findMany
.mockResolvedValueOnce([
{
plexGuid: 'guid-1',
plexRatingKey: null,
title: 'Book One',
author: 'Author One',
asin: 'ASIN1',
isbn: null,
},
])
.mockResolvedValueOnce([]);
prismaMock.audiobook.findMany.mockResolvedValue([
{
id: 'a1',
audibleAsin: 'ASIN1',
requests: [
{
id: 'r1',
status: 'downloading',
userId: 'other-user',
user: { plexUsername: 'OtherUser' },
},
],
},
]);
const { enrichAudiobooksWithMatches } = await import('@/lib/utils/audiobook-matcher');
const results = await enrichAudiobooksWithMatches(
[
{ asin: 'ASIN1', title: 'Book One', author: 'Author One' },
{ asin: 'ASIN2', title: 'Book Two', author: 'Author Two' },
],
'current-user'
);
expect(results[0].isAvailable).toBe(true);
expect(results[0].isRequested).toBe(true);
expect(results[0].requestedByUsername).toBe('OtherUser');
expect(results[1].isAvailable).toBe(false);
expect(results[1].isRequested).toBe(false);
});
});
+623
View File
@@ -0,0 +1,623 @@
/**
* 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);
});
});
+66
View File
@@ -0,0 +1,66 @@
/**
* Component: Cron Utilities Tests
* Documentation: documentation/backend/services/scheduler.md
*/
import { describe, expect, it } from 'vitest';
import { cronToHuman, isValidCron, customScheduleToCron, cronToCustomSchedule } from '@/lib/utils/cron';
describe('cron utilities', () => {
it('converts known presets to human text', () => {
expect(cronToHuman('*/15 * * * *')).toBe('Every 15 minutes');
expect(cronToHuman('0 */6 * * *')).toBe('Every 6 hours');
expect(cronToHuman('0 * * * *')).toBe('Every hour');
});
it('converts daily schedule to human text', () => {
expect(cronToHuman('30 14 * * *')).toBe('Daily at 2:30 PM');
expect(cronToHuman('*/1 * * * *')).toBe('Every 1 minute');
});
it('converts weekly and monthly schedules to human text', () => {
expect(cronToHuman('15 9 * * 1')).toBe('Weekly on Monday at 9:15 AM');
expect(cronToHuman('0 0 15 * *')).toBe('Monthly on day 15 at 12:00 AM');
});
it('returns raw cron for invalid expressions', () => {
expect(cronToHuman('bad cron')).toBe('bad cron');
});
it('validates cron expressions', () => {
expect(isValidCron('*/5 * * * *')).toBe(true);
expect(isValidCron('invalid')).toBe(false);
expect(isValidCron('0 0 0 * *')).toBe(false);
expect(isValidCron('0 0 1-5 * *')).toBe(true);
expect(isValidCron('0 0 1,15 * *')).toBe(true);
expect(isValidCron('*/0 * * * *')).toBe(false);
});
it('converts custom schedules to cron', () => {
expect(customScheduleToCron({ type: 'minutes', interval: 10 })).toBe('*/10 * * * *');
expect(customScheduleToCron({ type: 'hours', interval: 24 })).toBe('0 0 * * *');
expect(customScheduleToCron({ type: 'daily', time: { hour: 9, minute: 15 } })).toBe('15 9 * * *');
expect(customScheduleToCron({ type: 'weekly', time: { hour: 6, minute: 30 }, dayOfWeek: 2 })).toBe('30 6 * * 2');
expect(customScheduleToCron({ type: 'monthly', time: { hour: 5, minute: 0 }, dayOfMonth: 10 })).toBe('0 5 10 * *');
expect(customScheduleToCron({ type: 'custom', customCron: '5 4 * * *' })).toBe('5 4 * * *');
});
it('parses cron into custom schedules', () => {
expect(cronToCustomSchedule('*/15 * * * *')).toEqual({ type: 'minutes', interval: 15 });
expect(cronToCustomSchedule('0 */3 * * *')).toEqual({ type: 'hours', interval: 3 });
expect(cronToCustomSchedule('0 7 * * *')).toEqual({ type: 'daily', time: { hour: 7, minute: 0 } });
expect(cronToCustomSchedule('0 6 * * 2')).toEqual({
type: 'weekly',
time: { hour: 6, minute: 0 },
dayOfWeek: 2,
});
expect(cronToCustomSchedule('0 2 12 * *')).toEqual({
type: 'monthly',
time: { hour: 2, minute: 0 },
dayOfMonth: 12,
});
expect(cronToCustomSchedule('bad')).toEqual({ type: 'custom', customCron: 'bad' });
});
});
+697
View File
@@ -0,0 +1,697 @@
/**
* Component: File Organization System Tests
* Documentation: documentation/phase3/file-organization.md
*/
import path from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FileOrganizer, getFileOrganizer } from '@/lib/utils/file-organizer';
const fsMock = vi.hoisted(() => ({
access: vi.fn(),
stat: vi.fn(),
mkdir: vi.fn(),
copyFile: vi.fn(),
chmod: vi.fn(),
unlink: vi.fn(),
writeFile: vi.fn(),
rm: vi.fn(),
readdir: vi.fn(),
constants: { R_OK: 4 },
}));
const axiosMock = vi.hoisted(() => ({
get: vi.fn(),
}));
const jobLoggerMock = vi.hoisted(() => ({
createJobLogger: vi.fn(),
}));
const metadataMock = vi.hoisted(() => ({
tagMultipleFiles: vi.fn(),
checkFfmpegAvailable: vi.fn(),
}));
const chapterMock = vi.hoisted(() => ({
detectChapterFiles: vi.fn(),
analyzeChapterFiles: vi.fn(),
mergeChapters: vi.fn(),
formatDuration: vi.fn((ms: number) => `${ms}`),
estimateOutputSize: vi.fn(),
checkDiskSpace: vi.fn(),
}));
const loggerMock = vi.hoisted(() => ({
RMABLogger: {
create: vi.fn(() => ({
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
const configState = vi.hoisted(() => ({
values: new Map<string, string>(),
}));
const prismaMock = vi.hoisted(() => ({
configuration: {
findUnique: vi.fn(async ({ where: { key } }: { where: { key: string } }) => {
const value = configState.values.get(key);
return value !== undefined ? { value } : null;
}),
},
}));
const ebookMock = vi.hoisted(() => ({
downloadEbook: vi.fn(),
}));
vi.mock('fs/promises', () => ({
default: fsMock,
...fsMock,
}));
vi.mock('axios', () => ({
default: axiosMock,
...axiosMock,
}));
vi.mock('@/lib/utils/job-logger', () => jobLoggerMock);
vi.mock('@/lib/utils/metadata-tagger', () => metadataMock);
vi.mock('@/lib/utils/chapter-merger', () => chapterMock);
vi.mock('@/lib/utils/logger', () => loggerMock);
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/services/ebook-scraper', () => ebookMock);
describe('file organizer', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
vi.clearAllMocks();
configState.values.clear();
process.env = { ...originalEnv };
});
it('organizes a single file and copies cached cover art', async () => {
configState.values.set('metadata_tagging_enabled', 'false');
configState.values.set('ebook_sidecar_enabled', 'false');
fsMock.stat.mockResolvedValue({ isFile: () => true });
fsMock.access.mockImplementation(async (filePath: string) => {
if (filePath === '/downloads/book.m4b') return undefined;
if (filePath === '/app/cache/thumbnails/cover.jpg') return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
const logger = {
info: vi.fn().mockResolvedValue(undefined),
warn: vi.fn().mockResolvedValue(undefined),
error: vi.fn().mockResolvedValue(undefined),
};
jobLoggerMock.createJobLogger.mockReturnValue(logger);
const organizer = new FileOrganizer('/media', '/tmp');
const result = await organizer.organize(
'/downloads/book.m4b',
{
title: 'Book: Title',
author: 'Author/Name',
year: 2020,
asin: 'ASIN123',
coverArtUrl: '/api/cache/thumbnails/cover.jpg',
},
{ jobId: 'job-1', context: 'organize' }
);
const expectedDir = path.join('/media', 'AuthorName', 'Book Title (2020) ASIN123');
const expectedAudio = path.join(expectedDir, 'book.m4b');
expect(result.success).toBe(true);
expect(result.targetPath).toBe(expectedDir);
expect(result.audioFiles).toEqual([expectedAudio]);
expect(result.coverArtFile).toBe(path.join(expectedDir, 'cover.jpg'));
expect(result.filesMovedCount).toBe(1);
expect(jobLoggerMock.createJobLogger).toHaveBeenCalledWith('job-1', 'organize');
expect(metadataMock.tagMultipleFiles).not.toHaveBeenCalled();
});
it('returns errors when no audiobook files are found', async () => {
const organizer = new FileOrganizer('/media', '/tmp');
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
audioFiles: [],
coverFile: undefined,
isFile: false,
});
const result = await organizer.organize('/downloads/empty', {
title: 'Book',
author: 'Author',
});
expect(result.success).toBe(false);
expect(result.errors).toContain('No audiobook files found in download');
});
it('falls back when chapter merge fails and continues organizing', async () => {
configState.values.set('chapter_merging_enabled', 'true');
configState.values.set('metadata_tagging_enabled', 'false');
configState.values.set('ebook_sidecar_enabled', 'false');
chapterMock.detectChapterFiles.mockResolvedValue(true);
chapterMock.estimateOutputSize.mockResolvedValue(100);
chapterMock.checkDiskSpace.mockResolvedValue(1000);
chapterMock.analyzeChapterFiles.mockResolvedValue([
{ path: '/downloads/book/disc1.mp3', filename: 'disc1.mp3', duration: 1000, chapterTitle: 'One' },
]);
chapterMock.mergeChapters.mockResolvedValue({ success: false, error: 'merge failed' });
const downloadRoot = path.normalize(path.join('/downloads', 'book'));
fsMock.access.mockImplementation(async (filePath: string) => {
if (path.normalize(filePath).startsWith(downloadRoot)) return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
const organizer = new FileOrganizer('/media', '/tmp');
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
audioFiles: ['disc1.mp3', 'disc2.mp3'],
coverFile: undefined,
isFile: false,
});
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
});
expect(result.success).toBe(true);
expect(result.filesMovedCount).toBe(2);
expect(result.errors.join(' ')).toContain('Chapter merge failed');
expect(chapterMock.mergeChapters).toHaveBeenCalled();
});
it('uses tagged files when metadata tagging succeeds', async () => {
configState.values.set('metadata_tagging_enabled', 'true');
configState.values.set('ebook_sidecar_enabled', 'false');
metadataMock.checkFfmpegAvailable.mockResolvedValue(true);
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
metadataMock.tagMultipleFiles.mockResolvedValue([
{
success: true,
filePath: sourcePath,
taggedFilePath: '/tmp/tagged.m4b',
},
]);
const downloadRoot = path.normalize(path.join('/downloads', 'book'));
fsMock.access.mockImplementation(async (filePath: string) => {
if (path.normalize(filePath) === path.normalize('/tmp/tagged.m4b')) return undefined;
if (path.normalize(filePath).startsWith(downloadRoot)) return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
fsMock.unlink.mockResolvedValue(undefined);
const organizer = new FileOrganizer('/media', '/tmp');
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
audioFiles: ['book.m4b'],
coverFile: undefined,
isFile: false,
});
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
});
const expectedDir = path.join('/media', 'Author', 'Book');
expect(result.success).toBe(true);
expect(result.targetPath).toBe(expectedDir);
expect(fsMock.copyFile).toHaveBeenCalledWith('/tmp/tagged.m4b', path.join(expectedDir, 'book.m4b'));
expect(fsMock.unlink).toHaveBeenCalledWith('/tmp/tagged.m4b');
});
it('skips metadata tagging when ffmpeg is unavailable', async () => {
configState.values.set('metadata_tagging_enabled', 'true');
configState.values.set('ebook_sidecar_enabled', 'false');
metadataMock.checkFfmpegAvailable.mockResolvedValue(false);
const organizer = new FileOrganizer('/media', '/tmp');
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
audioFiles: ['book.m4b'],
coverFile: undefined,
isFile: false,
});
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
const expectedDir = path.join('/media', 'Author', 'Book');
const targetFile = path.join(expectedDir, 'book.m4b');
fsMock.access.mockImplementation(async (filePath: string) => {
if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
});
expect(result.success).toBe(true);
expect(result.errors).toContain('Metadata tagging skipped: ffmpeg not available');
expect(metadataMock.tagMultipleFiles).not.toHaveBeenCalled();
expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
});
it('downloads remote cover art and ebook sidecar when enabled', async () => {
configState.values.set('metadata_tagging_enabled', 'false');
configState.values.set('ebook_sidecar_enabled', 'true');
configState.values.set('ebook_sidecar_preferred_format', 'epub');
configState.values.set('ebook_sidecar_base_url', 'https://ebooks.example');
configState.values.set('ebook_sidecar_flaresolverr_url', 'http://flaresolverr');
ebookMock.downloadEbook.mockResolvedValue({
success: true,
filePath: '/media/Author/Book/book.epub',
});
const organizer = new FileOrganizer('/media', '/tmp');
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
audioFiles: ['book.m4b'],
coverFile: undefined,
isFile: false,
});
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
const expectedDir = path.join('/media', 'Author', 'Book ASIN123');
const targetFile = path.join(expectedDir, 'book.m4b');
fsMock.access.mockImplementation(async (filePath: string) => {
if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
fsMock.writeFile.mockResolvedValue(undefined);
axiosMock.get.mockResolvedValue({ data: Buffer.from('cover') });
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
asin: 'ASIN123',
coverArtUrl: 'https://images.example/cover.jpg',
});
expect(result.success).toBe(true);
expect(result.coverArtFile).toBe(path.join(expectedDir, 'cover.jpg'));
expect(axiosMock.get).toHaveBeenCalledWith(
'https://images.example/cover.jpg',
expect.objectContaining({ responseType: 'arraybuffer' })
);
expect(ebookMock.downloadEbook).toHaveBeenCalledWith(
'ASIN123',
'Book',
'Author',
expectedDir,
'epub',
'https://ebooks.example',
undefined,
'http://flaresolverr'
);
expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
expect(result.filesMovedCount).toBe(2);
});
it('records an error when cover art download fails', async () => {
configState.values.set('metadata_tagging_enabled', 'false');
configState.values.set('ebook_sidecar_enabled', 'false');
const organizer = new FileOrganizer('/media', '/tmp');
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
audioFiles: ['book.m4b'],
coverFile: undefined,
isFile: false,
});
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
const expectedDir = path.join('/media', 'Author', 'Book');
const targetFile = path.join(expectedDir, 'book.m4b');
fsMock.access.mockImplementation(async (filePath: string) => {
if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
axiosMock.get.mockRejectedValue(new Error('cover failed'));
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
coverArtUrl: 'https://images.example/cover.jpg',
});
expect(result.success).toBe(true);
expect(result.errors.join(' ')).toContain('Failed to download cover art');
expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
});
it('continues when chapter analysis returns no valid chapters', async () => {
configState.values.set('chapter_merging_enabled', 'true');
configState.values.set('metadata_tagging_enabled', 'false');
configState.values.set('ebook_sidecar_enabled', 'false');
chapterMock.detectChapterFiles.mockResolvedValue(true);
chapterMock.estimateOutputSize.mockResolvedValue(100);
chapterMock.checkDiskSpace.mockResolvedValue(1000);
chapterMock.analyzeChapterFiles.mockResolvedValue([]);
const organizer = new FileOrganizer('/media', '/tmp');
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
audioFiles: ['disc1.mp3', 'disc2.mp3'],
coverFile: undefined,
isFile: false,
});
const sourceRoot = path.normalize('/downloads/book');
fsMock.access.mockImplementation(async (filePath: string) => {
if (path.normalize(filePath).startsWith(sourceRoot)) return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
});
expect(result.success).toBe(true);
expect(result.filesMovedCount).toBe(2);
expect(chapterMock.mergeChapters).not.toHaveBeenCalled();
});
it('records errors when some metadata tagging operations fail', async () => {
configState.values.set('metadata_tagging_enabled', 'true');
configState.values.set('ebook_sidecar_enabled', 'false');
metadataMock.checkFfmpegAvailable.mockResolvedValue(true);
metadataMock.tagMultipleFiles.mockResolvedValue([
{ success: true, filePath: '/downloads/book/one.m4b', taggedFilePath: '/tmp/one-tagged.m4b' },
{ success: false, filePath: '/downloads/book/two.m4b', error: 'bad tags' },
]);
const organizer = new FileOrganizer('/media', '/tmp');
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
audioFiles: ['one.m4b', 'two.m4b'],
coverFile: undefined,
isFile: false,
});
const sourceRoot = path.normalize('/downloads/book');
fsMock.access.mockImplementation(async (filePath: string) => {
if (path.normalize(filePath) === path.normalize('/tmp/one-tagged.m4b')) return undefined;
if (path.normalize(filePath).startsWith(sourceRoot)) return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
fsMock.unlink.mockResolvedValue(undefined);
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
});
expect(result.success).toBe(true);
expect(result.errors.join(' ')).toContain('Failed to tag 1 file(s) with metadata');
});
it('records ebook sidecar errors when download throws', async () => {
configState.values.set('metadata_tagging_enabled', 'false');
configState.values.set('ebook_sidecar_enabled', 'true');
ebookMock.downloadEbook.mockRejectedValue(new Error('ebook down'));
const organizer = new FileOrganizer('/media', '/tmp');
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
audioFiles: ['book.m4b'],
coverFile: undefined,
isFile: false,
});
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
fsMock.access.mockImplementation(async (filePath: string) => {
if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
});
expect(result.success).toBe(true);
expect(result.errors).toContain('E-book sidecar failed');
});
it('finds audio files and cover art in nested folders', async () => {
const organizer = new FileOrganizer('/media', '/tmp');
fsMock.stat.mockResolvedValue({ isFile: () => false });
const subDir = path.join('/downloads', 'sub');
fsMock.readdir.mockImplementation(async (dir: string) => {
if (dir === '/downloads') {
return [
{ name: 'disc1.mp3', isDirectory: () => false },
{ name: 'sub', isDirectory: () => true },
];
}
if (dir === subDir) {
return [
{ name: 'disc2.mp3', isDirectory: () => false },
{ name: 'cover.jpg', isDirectory: () => false },
];
}
return [];
});
const result = await (organizer as any).findAudiobookFiles('/downloads');
expect(result.audioFiles).toEqual([
'disc1.mp3',
path.join('sub', 'disc2.mp3'),
]);
expect(result.coverFile).toBe(path.join('sub', 'cover.jpg'));
expect(result.isFile).toBe(false);
});
it('returns no audio files for unsupported single files', async () => {
const organizer = new FileOrganizer('/media', '/tmp');
fsMock.stat.mockResolvedValue({ isFile: () => true });
const result = await (organizer as any).findAudiobookFiles('/downloads/readme.txt');
expect(result.audioFiles).toEqual([]);
expect(result.isFile).toBe(true);
});
it('adds errors when source audio files are missing', async () => {
configState.values.set('metadata_tagging_enabled', 'false');
configState.values.set('ebook_sidecar_enabled', 'false');
const organizer = new FileOrganizer('/media', '/tmp');
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
audioFiles: ['book.m4b'],
coverFile: undefined,
isFile: false,
});
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
fsMock.access.mockImplementation(async (filePath: string) => {
if (path.normalize(filePath) === path.normalize(sourcePath)) {
throw new Error('missing');
}
return undefined;
});
fsMock.mkdir.mockResolvedValue(undefined);
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
});
expect(result.success).toBe(true);
expect(result.errors.join(' ')).toContain('Source file not found');
expect(fsMock.copyFile).not.toHaveBeenCalled();
});
it('skips copying when target files already exist', async () => {
configState.values.set('metadata_tagging_enabled', 'false');
configState.values.set('ebook_sidecar_enabled', 'false');
const organizer = new FileOrganizer('/media', '/tmp');
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
audioFiles: ['book.m4b'],
coverFile: undefined,
isFile: false,
});
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
const targetDir = path.join('/media', 'Author', 'Book');
const targetPath = path.join(targetDir, 'book.m4b');
fsMock.access.mockImplementation(async (filePath: string) => {
if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined;
if (path.normalize(filePath) === path.normalize(targetPath)) return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
});
expect(result.success).toBe(true);
expect(result.audioFiles).toEqual([targetPath]);
expect(result.filesMovedCount).toBe(0);
expect(fsMock.copyFile).not.toHaveBeenCalled();
});
it('continues when metadata tagging throws', async () => {
configState.values.set('metadata_tagging_enabled', 'true');
configState.values.set('ebook_sidecar_enabled', 'false');
metadataMock.checkFfmpegAvailable.mockRejectedValue(new Error('ffmpeg error'));
const organizer = new FileOrganizer('/media', '/tmp');
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
audioFiles: ['book.m4b'],
coverFile: undefined,
isFile: false,
});
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
fsMock.access.mockImplementation(async (filePath: string) => {
if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
});
expect(result.success).toBe(true);
expect(result.errors.join(' ')).toContain('Metadata tagging failed');
expect(fsMock.copyFile).toHaveBeenCalled();
});
it('validates paths and reports multiple issues', async () => {
fsMock.access.mockResolvedValue(undefined);
fsMock.stat.mockResolvedValue({ isDirectory: () => false });
fsMock.writeFile.mockRejectedValue(new Error('not writable'));
const organizer = new FileOrganizer('/media', '/tmp');
const result = await organizer.validate('/media');
expect(result.isValid).toBe(false);
expect(result.issues).toContain('Path is not a directory');
expect(result.issues).toContain('Directory is not writable');
});
it('returns validation errors when path is missing', async () => {
fsMock.access.mockRejectedValue(new Error('missing'));
const organizer = new FileOrganizer('/media', '/tmp');
const result = await organizer.validate('/missing');
expect(result.isValid).toBe(false);
expect(result.issues.join(' ')).toContain('Path does not exist');
});
it('throws when the download directory cannot be read', async () => {
fsMock.stat.mockRejectedValue(new Error('bad path'));
const organizer = new FileOrganizer('/media', '/tmp');
await expect((organizer as any).findAudiobookFiles('/downloads/bad')).rejects.toThrow('bad path');
});
it('returns an empty list when walkDirectory fails', async () => {
fsMock.readdir.mockRejectedValue(new Error('no perms'));
const organizer = new FileOrganizer('/media', '/tmp');
const files = await (organizer as any).walkDirectory('/downloads');
expect(files).toEqual([]);
});
it('cleans up download directories safely', async () => {
fsMock.rm.mockRejectedValue(new Error('rm failed'));
const organizer = new FileOrganizer('/media', '/tmp');
await expect(organizer.cleanup('/downloads/book')).resolves.toBeUndefined();
expect(fsMock.rm).toHaveBeenCalledWith('/downloads/book', { recursive: true, force: true });
});
it('cleans up download directories on success', async () => {
fsMock.rm.mockResolvedValue(undefined);
const organizer = new FileOrganizer('/media', '/tmp');
await expect(organizer.cleanup('/downloads/book')).resolves.toBeUndefined();
expect(fsMock.rm).toHaveBeenCalledWith('/downloads/book', { recursive: true, force: true });
});
it('validates writable directories without issues', async () => {
fsMock.access.mockResolvedValue(undefined);
fsMock.stat.mockResolvedValue({ isDirectory: () => true });
fsMock.writeFile.mockResolvedValue(undefined);
fsMock.unlink.mockResolvedValue(undefined);
const organizer = new FileOrganizer('/media', '/tmp');
const result = await organizer.validate('/media');
expect(result.isValid).toBe(true);
expect(result.issues).toEqual([]);
expect(fsMock.unlink).toHaveBeenCalledWith(path.join('/media', '.test-write'));
});
it('builds organizer settings from configuration', async () => {
configState.values.set('media_dir', '/media/custom');
process.env.TEMP_DIR = '/tmp/custom';
const organizer = await getFileOrganizer();
expect((organizer as any).mediaDir).toBe('/media/custom');
expect((organizer as any).tempDir).toBe('/tmp/custom');
});
});
+47
View File
@@ -0,0 +1,47 @@
/**
* Component: Job Logger Utility Tests
* Documentation: documentation/backend/services/jobs.md
*/
import { describe, expect, it, vi } from 'vitest';
const infoMock = vi.fn();
const warnMock = vi.fn();
const errorMock = vi.fn();
const forJobMock = vi.fn(() => ({
info: infoMock,
warn: warnMock,
error: errorMock,
}));
vi.mock('@/lib/utils/logger', () => ({
RMABLogger: {
forJob: forJobMock,
},
}));
describe('JobLogger', () => {
it('logs info, warn, and error messages via RMABLogger', async () => {
const { JobLogger } = await import('@/lib/utils/job-logger');
const logger = new JobLogger('job-1', 'Context');
await logger.info('info message', { foo: 'bar' });
await logger.warn('warn message');
await logger.error('error message', { error: 'boom' });
expect(forJobMock).toHaveBeenCalledWith('job-1', 'Context');
expect(infoMock).toHaveBeenCalledWith('info message', { foo: 'bar' });
expect(warnMock).toHaveBeenCalledWith('warn message', undefined);
expect(errorMock).toHaveBeenCalledWith('error message', { error: 'boom' });
});
it('creates a job logger via helper', async () => {
const { createJobLogger } = await import('@/lib/utils/job-logger');
const logger = createJobLogger('job-2', 'Context2');
await logger.info('message');
expect(forJobMock).toHaveBeenCalledWith('job-2', 'Context2');
expect(infoMock).toHaveBeenCalledWith('message', undefined);
});
});
+107
View File
@@ -0,0 +1,107 @@
/**
* Component: Client-Side JWT Utilities Tests
* Documentation: documentation/frontend/routing-auth.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const loggerState = vi.hoisted(() => ({
error: vi.fn(),
create: vi.fn(),
}));
vi.mock('@/lib/utils/logger', () => ({
RMABLogger: {
create: loggerState.create,
},
}));
const base64Url = (value: unknown) =>
Buffer.from(JSON.stringify(value))
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
const createToken = (payload: Record<string, unknown>) => {
const header = base64Url({ alg: 'HS256', typ: 'JWT' });
const body = base64Url(payload);
return `${header}.${body}.signature`;
};
describe('jwt client utilities', () => {
const originalAtob = globalThis.atob;
beforeEach(() => {
vi.resetModules();
loggerState.error.mockClear();
loggerState.create.mockReturnValue({ error: loggerState.error });
globalThis.atob = (input: string) => Buffer.from(input, 'base64').toString('binary');
});
it('decodes a valid JWT payload', async () => {
const { decodeJWT } = await import('@/lib/utils/jwt-client');
const token = createToken({ sub: 'user', exp: 2000, role: 'user' });
const decoded = decodeJWT(token);
expect(decoded?.sub).toBe('user');
expect(decoded?.exp).toBe(2000);
});
it('returns null for invalid tokens', async () => {
const { decodeJWT } = await import('@/lib/utils/jwt-client');
expect(decodeJWT('not-a-token')).toBeNull();
});
it('logs an error when decoding fails', async () => {
const { decodeJWT } = await import('@/lib/utils/jwt-client');
const decoded = decodeJWT('header.badbase64.signature');
expect(decoded).toBeNull();
expect(loggerState.error).toHaveBeenCalled();
});
it('checks token expiry correctly', async () => {
const { isTokenExpired } = await import('@/lib/utils/jwt-client');
const now = 1700000000;
vi.spyOn(Date, 'now').mockReturnValue(now * 1000);
const fresh = createToken({ exp: now + 60 });
const expired = createToken({ exp: now - 60 });
expect(isTokenExpired(fresh)).toBe(false);
expect(isTokenExpired(expired)).toBe(true);
expect(isTokenExpired('invalid')).toBe(true);
});
it('returns expiry and refresh windows', async () => {
const { getRefreshTimeMs, getTokenExpiryMs } = await import('@/lib/utils/jwt-client');
const now = 1700000000;
vi.spyOn(Date, 'now').mockReturnValue(now * 1000);
const token = createToken({ exp: now + 600 });
const expiryMs = getTokenExpiryMs(token);
const refreshMs = getRefreshTimeMs(token);
expect(expiryMs).toBe(600 * 1000);
expect(refreshMs).toBe(300 * 1000);
const shortToken = createToken({ exp: now + 60 });
expect(getRefreshTimeMs(shortToken)).toBe(0);
expect(getTokenExpiryMs('invalid')).toBeNull();
});
afterEach(() => {
if (originalAtob) {
globalThis.atob = originalAtob;
} else {
delete (globalThis as any).atob;
}
});
});
+70
View File
@@ -0,0 +1,70 @@
/**
* Component: JWT Utilities Tests
* Documentation: documentation/backend/services/auth.md
*/
import { describe, expect, it } from 'vitest';
import jwt from 'jsonwebtoken';
import {
decodeToken,
generateAccessToken,
generateRefreshToken,
verifyAccessToken,
verifyRefreshToken,
} from '@/lib/utils/jwt';
describe('JWT utilities', () => {
it('generates and verifies access tokens', () => {
const token = generateAccessToken({
sub: 'user-1',
plexId: 'plex-1',
username: 'user',
role: 'admin',
});
const payload = verifyAccessToken(token);
expect(payload?.sub).toBe('user-1');
expect(payload?.role).toBe('admin');
});
it('returns null for invalid access tokens', () => {
const payload = verifyAccessToken('bad-token');
expect(payload).toBeNull();
});
it('generates and verifies refresh tokens', () => {
const token = generateRefreshToken('user-2');
const payload = verifyRefreshToken(token);
expect(payload?.sub).toBe('user-2');
expect(payload?.type).toBe('refresh');
});
it('returns null when refresh token type does not match', () => {
const invalid = jwt.sign(
{ sub: 'user-3', type: 'access' },
'change-this-to-another-random-secret-key',
{ expiresIn: '7d' }
);
const payload = verifyRefreshToken(invalid);
expect(payload).toBeNull();
});
it('decodes tokens without verification', () => {
const token = generateAccessToken({
sub: 'user-4',
plexId: 'plex-4',
username: 'user',
role: 'user',
});
const decoded = decodeToken(token) as { sub?: string } | null;
expect(decoded?.sub).toBe('user-4');
expect(decodeToken('not-a-jwt')).toBeNull();
});
});
+116
View File
@@ -0,0 +1,116 @@
/**
* 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);
});
});
+50
View File
@@ -0,0 +1,50 @@
/**
* Component: Path Mapper Tests
* Documentation: documentation/phase3/qbittorrent.md
*/
import { describe, expect, it } from 'vitest';
import { PathMapper } from '@/lib/utils/path-mapper';
describe('PathMapper', () => {
it('returns original path when mapping is disabled', () => {
const result = PathMapper.transform('/remote/path/book', {
enabled: false,
remotePath: '/remote/path',
localPath: '/local/path',
});
expect(result).toBe('/remote/path/book');
});
it('transforms remote path to local path when enabled', () => {
const result = PathMapper.transform('/remote/mnt/d/done/Book', {
enabled: true,
remotePath: '/remote/mnt/d/done',
localPath: '/downloads',
});
expect(result.replace(/\\/g, '/')).toBe('/downloads/Book');
});
it('returns original path when remote prefix does not match', () => {
const result = PathMapper.transform('/other/path/book', {
enabled: true,
remotePath: '/remote/path',
localPath: '/local/path',
});
expect(result).toBe('/other/path/book');
});
it('validates mapping configuration when enabled', () => {
expect(() =>
PathMapper.validate({ enabled: true, remotePath: '', localPath: '/local' })
).toThrow('Remote path cannot be empty');
expect(() =>
PathMapper.validate({ enabled: true, remotePath: '/remote', localPath: '' })
).toThrow('Local path cannot be empty');
});
});
+154
View File
@@ -0,0 +1,154 @@
/**
* Component: Intelligent Ranking Algorithm Tests
* Documentation: documentation/phase3/ranking-algorithm.md
*/
import { describe, expect, it } from 'vitest';
import { RankingAlgorithm, rankTorrents } from '@/lib/utils/ranking-algorithm';
const MB = 1024 * 1024;
describe('ranking-algorithm', () => {
const baseTorrent = {
indexer: 'IndexerA',
title: 'Great Book - Author Name',
size: 30 * MB,
seeders: 10,
leechers: 1,
publishDate: new Date('2024-01-01T00:00:00Z'),
downloadUrl: 'magnet:?xt=urn:btih:abc',
guid: 'guid-1',
};
it('filters out results below 20 MB', () => {
const small = { ...baseTorrent, guid: 'small', size: 10 * MB };
const big = { ...baseTorrent, guid: 'big', size: 25 * MB };
const ranked = rankTorrents(
[small, big],
{ title: 'Great Book', author: 'Author Name' }
);
expect(ranked).toHaveLength(1);
expect(ranked[0].guid).toBe('big');
});
it('prefers strong title/author matches over weaker ones', () => {
const good = { ...baseTorrent, guid: 'good', title: 'Great Book - Author Name' };
const bad = { ...baseTorrent, guid: 'bad', title: 'Different Title - Other Author' };
const ranked = rankTorrents(
[bad, good],
{ title: 'Great Book', author: 'Author Name' }
);
expect(ranked[0].guid).toBe('good');
});
it('treats undefined seeders as full availability score (usenet)', () => {
const algorithm = new RankingAlgorithm();
const torrent = { ...baseTorrent, seeders: undefined };
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'Great Book',
author: 'Author Name',
});
expect(breakdown.seederScore).toBe(15);
});
it('assigns full size score for >= 1.0 MB/min', () => {
const algorithm = new RankingAlgorithm();
const torrent = { ...baseTorrent, size: 150 * MB };
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'Great Book',
author: 'Author Name',
durationMinutes: 100,
});
expect(breakdown.sizeScore).toBe(15);
});
it('applies word coverage filter for partial title matches', () => {
const algorithm = new RankingAlgorithm();
const torrent = { ...baseTorrent, title: 'The Wild Robot' };
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'The Wild Robot on the Island',
author: 'Peter Brown',
});
expect(breakdown.matchScore).toBe(0);
});
it('adds seeder availability notes and weak match notes', () => {
const algorithm = new RankingAlgorithm();
const baseBreakdown = {
formatScore: 0,
sizeScore: 0,
seederScore: 0,
matchScore: 30,
totalScore: 30,
notes: [],
};
const noSeeders = (algorithm as any).generateNotes(
{ ...baseTorrent, seeders: 0 },
baseBreakdown,
120
);
expect(noSeeders.some((note: string) => note.includes('No seeders'))).toBe(true);
expect(noSeeders.some((note: string) => note.includes('Weak title/author match'))).toBe(true);
const lowSeeders = (algorithm as any).generateNotes(
{ ...baseTorrent, seeders: 3 },
baseBreakdown,
120
);
expect(lowSeeders.some((note: string) => note.includes('Low seeders'))).toBe(true);
const highSeeders = (algorithm as any).generateNotes(
{ ...baseTorrent, seeders: 50 },
baseBreakdown,
120
);
expect(highSeeders.some((note: string) => note.includes('Excellent availability'))).toBe(true);
});
it('adds format and size quality notes for MP3 files', () => {
const algorithm = new RankingAlgorithm();
const breakdown = {
formatScore: 0,
sizeScore: 0,
seederScore: 0,
matchScore: 50,
totalScore: 50,
notes: [],
};
const highQuality = (algorithm as any).generateNotes(
{ ...baseTorrent, format: 'MP3', size: 70 * MB },
breakdown,
60
);
expect(highQuality.some((note: string) => note.includes('Acceptable format'))).toBe(true);
expect(highQuality.some((note: string) => note.includes('High quality'))).toBe(true);
const standardQuality = (algorithm as any).generateNotes(
{ ...baseTorrent, format: 'MP3', size: 30 * MB },
breakdown,
60
);
expect(standardQuality.some((note: string) => note.includes('Standard quality'))).toBe(true);
const lowQuality = (algorithm as any).generateNotes(
{ ...baseTorrent, format: 'MP3', size: 20 * MB },
breakdown,
60
);
expect(lowQuality.some((note: string) => note.includes('Low quality'))).toBe(true);
});
});
+42
View File
@@ -0,0 +1,42 @@
/**
* Component: Torrent Category Utils Tests
* Documentation: documentation/phase3/prowlarr.md
*/
import { describe, expect, it } from 'vitest';
import {
DEFAULT_CATEGORIES,
TORRENT_CATEGORIES,
areAllChildrenSelected,
getChildIds,
getParentId,
isParentCategory,
} from '@/lib/utils/torrent-categories';
describe('torrent categories', () => {
it('returns child ids for parent categories', () => {
expect(getChildIds(3000)).toContain(3030);
expect(getChildIds(8000)).toEqual([]);
});
it('returns parent id for child categories', () => {
expect(getParentId(3030)).toBe(3000);
expect(getParentId(9999)).toBeNull();
});
it('checks if all children are selected', () => {
const childIds = getChildIds(3000);
expect(areAllChildrenSelected(3000, childIds)).toBe(true);
expect(areAllChildrenSelected(3000, [])).toBe(false);
});
it('detects parent categories', () => {
expect(isParentCategory(3000)).toBe(true);
expect(isParentCategory(3030)).toBe(false);
});
it('keeps default categories stable', () => {
expect(DEFAULT_CATEGORIES).toEqual([3030]);
expect(TORRENT_CATEGORIES.length).toBeGreaterThan(0);
});
});
+67
View File
@@ -0,0 +1,67 @@
/**
* Component: URL Utilities Tests
* Documentation: documentation/backend/services/environment.md
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getBaseUrl, getCallbackUrl } from '@/lib/utils/url';
const envBackup = { ...process.env };
describe('URL utilities', () => {
beforeEach(() => {
process.env = { ...envBackup };
});
afterEach(() => {
process.env = { ...envBackup };
});
it('prefers PUBLIC_URL and trims trailing slashes', () => {
process.env.PUBLIC_URL = 'https://example.com/';
process.env.NEXTAUTH_URL = 'https://next.example.com';
process.env.BASE_URL = 'https://base.example.com';
const url = getBaseUrl();
expect(url).toBe('https://example.com');
});
it('falls back to NEXTAUTH_URL when PUBLIC_URL is not set', () => {
delete process.env.PUBLIC_URL;
process.env.NEXTAUTH_URL = 'https://next.example.com/';
const url = getBaseUrl();
expect(url).toBe('https://next.example.com');
});
it('uses BASE_URL and keeps invalid scheme values', () => {
delete process.env.PUBLIC_URL;
delete process.env.NEXTAUTH_URL;
process.env.BASE_URL = 'example.com/';
const url = getBaseUrl();
expect(url).toBe('example.com');
});
it('defaults to localhost in production when no env vars are set', () => {
delete process.env.PUBLIC_URL;
delete process.env.NEXTAUTH_URL;
delete process.env.BASE_URL;
process.env.NODE_ENV = 'production';
const url = getBaseUrl();
expect(url).toBe('http://localhost:3030');
});
it('builds callback URLs with normalized paths', () => {
process.env.PUBLIC_URL = 'https://example.com';
const url = getCallbackUrl('api/auth/oidc/callback');
expect(url).toBe('https://example.com/api/auth/oidc/callback');
});
});