mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
94dbaf073b
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.
698 lines
24 KiB
TypeScript
698 lines
24 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(),
|
|
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');
|
|
});
|
|
});
|