Files
ReadMeABook/tests/utils/file-organizer.test.ts
T
kikootwo af0eaceb98 Add extensible notification providers + UI/API
Introduce a provider-based notification system and wire it through the API and admin UI. Added INotificationProvider + notification service implementation and providers (apprise, discord, ntfy, pushover), plus a GET /api/admin/notifications/providers endpoint to expose provider metadata. Refactored code to use provider type strings (removed enum coupling), updated masking/encryption calls, and simplified the test notification endpoint to accept backendId or type+config and call sendToBackend directly.

UI: NotificationsTab now fetches provider metadata and renders provider cards and dynamic config forms (fields driven by provider metadata). Added config field rendering, improved backend cards, and edit/delete actions.

APIs: New providers route, updated admin notification CRUD routes to validate provider types dynamically, updated test route schema. Added download-client categories POST API to fetch categories from clients and wired postImportCategory handling in download-client routes.

Other notable changes: BookDate now fetches Claude models dynamically from Anthropic's Models API; added paginated model fetch helper. Added ALLOW_WEAK_PASSWORD flag exposure to auth providers and password change logic. Doc updates and various tests added/updated. File-organization doc clarifies EPERM fix using stream-based copy.
2026-02-10 15:06:20 -05:00

909 lines
34 KiB
TypeScript

/**
* 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(),
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 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(),
})),
forJob: vi.fn(() => ({
info: vi.fn().mockResolvedValue(undefined),
warn: vi.fn().mockResolvedValue(undefined),
error: vi.fn().mockResolvedValue(undefined),
debug: 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(),
}));
const copyFileMock = vi.hoisted(() => ({
copyFile: vi.fn(),
}));
vi.mock('fs/promises', () => ({
default: fsMock,
...fsMock,
}));
vi.mock('@/lib/utils/copy-file', () => copyFileMock);
vi.mock('axios', () => ({
default: axiosMock,
...axiosMock,
}));
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);
copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
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',
},
'{author}/{title} ({year}) {asin}',
{ 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(loggerMock.RMABLogger.forJob).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',
}, '{author}/{title}');
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);
copyFileMock.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',
}, '{author}/{title}');
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);
copyFileMock.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',
}, '{author}/{title}');
const expectedDir = path.join('/media', 'Author', 'Book');
expect(result.success).toBe(true);
expect(result.targetPath).toBe(expectedDir);
expect(copyFileMock.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);
copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
}, '{author}/{title}');
expect(result.success).toBe(true);
expect(result.errors).toContain('Metadata tagging skipped: ffmpeg not available');
expect(metadataMock.tagMultipleFiles).not.toHaveBeenCalled();
expect(copyFileMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
});
it('downloads remote cover art when no local cover exists', async () => {
configState.values.set('metadata_tagging_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 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);
copyFileMock.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',
}, '{author}/{title} {asin}');
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' })
);
// NOTE: Ebook downloads are now handled as first-class requests through the job queue
// The file organizer no longer downloads ebooks inline
expect(ebookMock.downloadEbook).not.toHaveBeenCalled();
expect(copyFileMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
expect(result.filesMovedCount).toBe(1);
});
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);
copyFileMock.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',
}, '{author}/{title}');
expect(result.success).toBe(true);
expect(result.errors.join(' ')).toContain('Failed to download cover art');
expect(copyFileMock.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);
copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
}, '{author}/{title}');
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);
copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
fsMock.unlink.mockResolvedValue(undefined);
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
}, '{author}/{title}');
expect(result.success).toBe(true);
expect(result.errors.join(' ')).toContain('Failed to tag 1 file(s) with metadata');
});
// NOTE: The ebook sidecar test was removed because ebook downloads are now
// handled as first-class requests through the job queue, not inline during
// file organization. See organize-files.processor.ts createEbookRequestIfEnabled().
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('returns failure 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',
}, '{author}/{title}');
expect(result.success).toBe(false);
expect(result.audioFiles).toEqual([]);
expect(result.errors.join(' ')).toContain('Source file not found');
expect(result.errors.join(' ')).toContain('No audio files were successfully copied');
expect(copyFileMock.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',
}, '{author}/{title}');
expect(result.success).toBe(true);
expect(result.audioFiles).toEqual([targetPath]);
expect(result.filesMovedCount).toBe(0);
expect(copyFileMock.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);
copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
}, '{author}/{title}');
expect(result.success).toBe(true);
expect(result.errors.join(' ')).toContain('Metadata tagging failed');
expect(copyFileMock.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');
});
it('returns failure when all audio file copies fail (EPERM)', async () => {
configState.values.set('metadata_tagging_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);
copyFileMock.copyFile.mockRejectedValue(
Object.assign(new Error('EPERM: operation not permitted, copyfile'), { code: 'EPERM' })
);
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
}, '{author}/{title}');
expect(result.success).toBe(false);
expect(result.audioFiles).toEqual([]);
expect(result.filesMovedCount).toBe(0);
expect(result.targetPath).toBe(expectedDir);
expect(result.errors.join(' ')).toContain('EPERM');
expect(result.errors.join(' ')).toContain('No audio files were successfully copied');
});
it('falls back to untagged file when tagged copy fails', async () => {
configState.values.set('metadata_tagging_enabled', 'true');
metadataMock.checkFfmpegAvailable.mockResolvedValue(true);
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
const taggedPath = `${sourcePath}.tmp`;
metadataMock.tagMultipleFiles.mockResolvedValue([
{ success: true, filePath: sourcePath, taggedFilePath: taggedPath },
]);
const expectedDir = path.join('/media', 'Author', 'Book');
const targetFile = path.join(expectedDir, 'book.m4b');
fsMock.access.mockImplementation(async (filePath: string) => {
const normalized = path.normalize(filePath);
if (normalized === path.normalize(taggedPath)) return undefined;
if (normalized === path.normalize(sourcePath)) return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
copyFileMock.copyFile.mockImplementation(async (src: string, dest: string) => {
// Tagged file copy fails with EPERM
if (path.normalize(src) === path.normalize(taggedPath)) {
throw Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' });
}
// Original file copy succeeds
return 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',
}, '{author}/{title}',
{ jobId: 'job-fallback', context: 'test' }
);
expect(result.success).toBe(true);
expect(result.audioFiles).toEqual([targetFile]);
expect(result.filesMovedCount).toBe(1);
// Tagged temp file should be cleaned up
expect(fsMock.unlink).toHaveBeenCalledWith(taggedPath);
// Fallback copy should use the original source
expect(copyFileMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
// Should record that tagged copy failed
expect(result.errors.join(' ')).toContain('Tagged copy failed');
expect(result.errors.join(' ')).toContain('without metadata tags');
});
it('returns failure when tagged copy and fallback both fail', async () => {
configState.values.set('metadata_tagging_enabled', 'true');
metadataMock.checkFfmpegAvailable.mockResolvedValue(true);
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
const taggedPath = `${sourcePath}.tmp`;
metadataMock.tagMultipleFiles.mockResolvedValue([
{ success: true, filePath: sourcePath, taggedFilePath: taggedPath },
]);
fsMock.access.mockImplementation(async (filePath: string) => {
const normalized = path.normalize(filePath);
if (normalized === path.normalize(taggedPath)) return undefined;
if (normalized === path.normalize(sourcePath)) return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
// Both tagged and original copies fail
copyFileMock.copyFile.mockRejectedValue(
Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' })
);
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',
}, '{author}/{title}',
{ jobId: 'job-both-fail', context: 'test' }
);
expect(result.success).toBe(false);
expect(result.audioFiles).toEqual([]);
expect(result.filesMovedCount).toBe(0);
expect(result.errors.join(' ')).toContain('EPERM');
expect(result.errors.join(' ')).toContain('No audio files were successfully copied');
// Should still clean up tagged temp file
expect(fsMock.unlink).toHaveBeenCalledWith(taggedPath);
});
it('reports partial success when some files copy and others fail', async () => {
configState.values.set('metadata_tagging_enabled', 'false');
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');
const source1 = path.join('/downloads', 'book', 'disc1.mp3');
const source2 = path.join('/downloads', 'book', 'disc2.mp3');
const expectedDir = path.join('/media', 'Author', 'Book');
fsMock.access.mockImplementation(async (filePath: string) => {
if (path.normalize(filePath).startsWith(sourceRoot)) return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
copyFileMock.copyFile.mockImplementation(async (src: string) => {
// First file succeeds, second fails
if (path.normalize(src) === path.normalize(source2)) {
throw Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' });
}
return undefined;
});
fsMock.chmod.mockResolvedValue(undefined);
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
}, '{author}/{title}');
// Should succeed because at least one file was copied
expect(result.success).toBe(true);
expect(result.audioFiles).toEqual([path.join(expectedDir, 'disc1.mp3')]);
expect(result.filesMovedCount).toBe(1);
expect(result.errors.join(' ')).toContain('Failed to copy disc2.mp3');
});
it('succeeds with cover art when audio files were copied', async () => {
configState.values.set('metadata_tagging_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');
fsMock.access.mockImplementation(async (filePath: string) => {
if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
copyFileMock.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',
coverArtUrl: 'https://images.example/cover.jpg',
}, '{author}/{title}');
expect(result.success).toBe(true);
expect(result.audioFiles).toEqual([path.join(expectedDir, 'book.m4b')]);
expect(result.coverArtFile).toBe(path.join(expectedDir, 'cover.jpg'));
});
it('returns failure even when cover art succeeds but audio copy fails', async () => {
configState.values.set('metadata_tagging_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');
fsMock.access.mockImplementation(async (filePath: string) => {
if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
copyFileMock.copyFile.mockRejectedValue(
Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' })
);
fsMock.writeFile.mockResolvedValue(undefined);
axiosMock.get.mockResolvedValue({ data: Buffer.from('cover') });
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
coverArtUrl: 'https://images.example/cover.jpg',
}, '{author}/{title}');
// Audio copy failed → should be failure despite cover art being available
expect(result.success).toBe(false);
expect(result.audioFiles).toEqual([]);
expect(result.filesMovedCount).toBe(0);
expect(result.errors.join(' ')).toContain('EPERM');
expect(result.errors.join(' ')).toContain('No audio files were successfully copied');
});
});