Files
bookorbit/server/test/scanner-scenarios.e2e-spec.ts
T
neon faee78370d feat(library): rebuild the folder picker as a tree with a live plan
Pick folders from a lazily loaded tree with find-by-name and typed paths, beside a plan showing
the books each organization mode would import, overlaps with other libraries, and folders no
library reads yet. New ManageLibraries /path routes walk folders within entry and time bounds.
Fix the scanner filing a disc-named library folder such as /mnt/disk1 under the folder above it,
and move books already filed there onto their primary file on the next scan.
2026-10-24 04:04:00 +00:00

545 lines
21 KiB
TypeScript

import { mkdir, writeFile } from 'fs/promises';
import { dirname, join } from 'path';
import { eq } from 'drizzle-orm';
import { bookFiles, books } from '../src/db/schema';
import type { FixtureEntry } from './e2e/scanner/scanner-fixture-builder';
import { createFixtureTree, file } from './e2e/scanner/scanner-fixture-builder';
import {
assertNoIntegrityViolations,
closeScannerE2EContext,
createScannerE2EContext,
loadLibraryBookState,
seedLibrary,
triggerLibraryScan,
waitForScanCompletion,
type LibraryBookState,
type ScannerE2EContext,
} from './e2e/scanner/scanner-harness';
type OrganizationMode = 'book_per_file' | 'book_per_folder';
interface ExpectedBook {
folderPath: string;
filePaths: string[];
primaryPath?: string | null;
status?: 'present' | 'missing';
}
interface ScannerScenario {
id: string;
mode: OrganizationMode;
entries: FixtureEntry[];
expected: ExpectedBook[];
allowedFormats?: string[];
excludePatterns?: string[];
}
interface ScenarioRunResult {
id: string;
mode: OrganizationMode;
status: 'passed' | 'failed';
durationMs: number;
error?: string;
}
const SCENARIO_TIMEOUT_MS = 60_000;
const bookPerFolderScenarios: ScannerScenario[] = [
{
id: 'book-per-folder-flat-root-file',
mode: 'book_per_folder',
entries: [file('book.epub')],
expected: [{ folderPath: 'book.epub', filePaths: ['book.epub'], primaryPath: 'book.epub' }],
},
{
id: 'book-per-folder-mixed-root-and-folder',
mode: 'book_per_folder',
entries: [file('root.pdf'), file('Author/Book/book.epub')],
expected: [
{ folderPath: 'root.pdf', filePaths: ['root.pdf'], primaryPath: 'root.pdf' },
{ folderPath: 'Author/Book', filePaths: ['Author/Book/book.epub'], primaryPath: 'Author/Book/book.epub' },
],
},
{
id: 'book-per-folder-multi-format-folder-and-root-stray',
mode: 'book_per_folder',
entries: [file('Series/Book/book.epub'), file('Series/Book/book.mobi'), file('Series/Book/cover.jpg'), file('stray.cbz')],
expected: [
{
folderPath: 'Series/Book',
filePaths: ['Series/Book/book.epub', 'Series/Book/book.mobi', 'Series/Book/cover.jpg'],
primaryPath: 'Series/Book/book.epub',
},
{ folderPath: 'stray.cbz', filePaths: ['stray.cbz'], primaryPath: 'stray.cbz' },
],
},
{
id: 'book-per-folder-audiobook-multipart-folder',
mode: 'book_per_folder',
entries: [file('Audio/track-1.mp3'), file('Audio/track-2.mp3'), file('Audio/cover.jpg')],
expected: [
{
folderPath: 'Audio',
filePaths: ['Audio/cover.jpg', 'Audio/track-1.mp3', 'Audio/track-2.mp3'],
primaryPath: 'Audio/track-1.mp3',
},
],
},
{
id: 'book-per-folder-disc-folder-flattening',
mode: 'book_per_folder',
entries: [file('AudioBook/CD 1/01.mp3'), file('AudioBook/Disc 2/02.mp3'), file('AudioBook/cover.jpg')],
expected: [
{
folderPath: 'AudioBook',
filePaths: ['AudioBook/CD 1/01.mp3', 'AudioBook/Disc 2/02.mp3', 'AudioBook/cover.jpg'],
primaryPath: 'AudioBook/CD 1/01.mp3',
},
],
},
{
id: 'book-per-folder-ebook-with-stem-audio-subfolder',
mode: 'book_per_folder',
entries: [file('TheBook/TheBook.epub'), file('TheBook/TheBook/01.mp3'), file('TheBook/TheBook/02.mp3')],
expected: [
{
folderPath: 'TheBook',
filePaths: ['TheBook/TheBook.epub', 'TheBook/TheBook/01.mp3', 'TheBook/TheBook/02.mp3'],
primaryPath: 'TheBook/TheBook.epub',
},
],
},
{
id: 'book-per-folder-root-stem-audio-not-flattened',
mode: 'book_per_folder',
entries: [file('BookTitle.epub'), file('BookTitle/01.mp3'), file('BookTitle/02.mp3')],
expected: [
{ folderPath: 'BookTitle.epub', filePaths: ['BookTitle.epub'], primaryPath: 'BookTitle.epub' },
{ folderPath: 'BookTitle', filePaths: ['BookTitle/01.mp3', 'BookTitle/02.mp3'], primaryPath: 'BookTitle/01.mp3' },
],
},
{
id: 'book-per-folder-hidden-files-and-folders-ignored',
mode: 'book_per_folder',
entries: [file('.hidden/ghost.epub'), file('.root-hidden.epub'), file('Visible/book.epub')],
expected: [{ folderPath: 'Visible', filePaths: ['Visible/book.epub'], primaryPath: 'Visible/book.epub' }],
},
{
id: 'book-per-folder-exclude-patterns',
mode: 'book_per_folder',
entries: [file('skip-folder/book.epub'), file('keep-folder/book.epub'), file('keep-folder/cover.jpg')],
excludePatterns: ['skip*'],
expected: [{ folderPath: 'keep-folder', filePaths: ['keep-folder/book.epub', 'keep-folder/cover.jpg'], primaryPath: 'keep-folder/book.epub' }],
},
{
id: 'book-per-folder-allowed-formats',
mode: 'book_per_folder',
entries: [file('book.epub'), file('book.pdf')],
allowedFormats: ['epub'],
expected: [{ folderPath: 'book.epub', filePaths: ['book.epub'], primaryPath: 'book.epub' }],
},
{
id: 'book-per-folder-zero-byte-content-file-skipped',
mode: 'book_per_folder',
entries: [file('zero.epub', '')],
expected: [{ folderPath: 'zero.epub', filePaths: [], primaryPath: null }],
},
{
id: 'book-per-folder-no-primary-files',
mode: 'book_per_folder',
entries: [file('Only/cover.jpg'), file('Only/book.opf')],
expected: [],
},
{
id: 'book-per-folder-deep-nested-book-folder',
mode: 'book_per_folder',
entries: [file('A/B/C/book.epub')],
expected: [{ folderPath: 'A/B/C', filePaths: ['A/B/C/book.epub'], primaryPath: 'A/B/C/book.epub' }],
},
{
id: 'book-per-folder-same-filename-different-authors',
mode: 'book_per_folder',
entries: [file('Author One/Shared/book.epub'), file('Author Two/Shared/book.epub')],
expected: [
{ folderPath: 'Author One/Shared', filePaths: ['Author One/Shared/book.epub'], primaryPath: 'Author One/Shared/book.epub' },
{ folderPath: 'Author Two/Shared', filePaths: ['Author Two/Shared/book.epub'], primaryPath: 'Author Two/Shared/book.epub' },
],
},
{
id: 'book-per-folder-disc-name-variants-flattened',
mode: 'book_per_folder',
entries: [file('Saga/Disk03/03.mp3'), file('Saga/PartA/04.mp3'), file('Saga/SideB/05.mp3'), file('Saga/front.jpg')],
expected: [
{
folderPath: 'Saga',
filePaths: ['Saga/Disk03/03.mp3', 'Saga/PartA/04.mp3', 'Saga/SideB/05.mp3', 'Saga/front.jpg'],
primaryPath: 'Saga/Disk03/03.mp3',
},
],
},
{
id: 'book-per-folder-stem-subfolder-case-mismatch-no-merge',
mode: 'book_per_folder',
entries: [file('Novel/Book.epub'), file('Novel/book/01.mp3'), file('Novel/book/02.mp3')],
expected: [
{ folderPath: 'Novel', filePaths: ['Novel/Book.epub'], primaryPath: 'Novel/Book.epub' },
{ folderPath: 'Novel/book', filePaths: ['Novel/book/01.mp3', 'Novel/book/02.mp3'], primaryPath: 'Novel/book/01.mp3' },
],
},
{
id: 'book-per-folder-stem-subfolder-with-disc-flattening',
mode: 'book_per_folder',
entries: [file('Title/Title.epub'), file('Title/Title/CD 1/01.mp3'), file('Title/Title/Disc 2/02.mp3'), file('Title/cover.jpg')],
expected: [
{
folderPath: 'Title',
filePaths: ['Title/Title.epub', 'Title/Title/CD 1/01.mp3', 'Title/Title/Disc 2/02.mp3', 'Title/cover.jpg'],
primaryPath: 'Title/Title.epub',
},
],
},
{
id: 'book-per-folder-root-multi-format-files-remain-separate',
mode: 'book_per_folder',
entries: [file('book.epub'), file('book.mobi')],
expected: [
{ folderPath: 'book.epub', filePaths: ['book.epub'], primaryPath: 'book.epub' },
{ folderPath: 'book.mobi', filePaths: ['book.mobi'], primaryPath: 'book.mobi' },
],
},
{
id: 'book-per-folder-multi-book-single-folder-coalesced',
mode: 'book_per_folder',
entries: [file('Anthology/Book One.epub'), file('Anthology/Book Two.epub'), file('Anthology/cover.jpg')],
expected: [{ folderPath: 'Anthology', filePaths: ['Anthology/Book One.epub', 'Anthology/Book Two.epub', 'Anthology/cover.jpg'] }],
},
{
id: 'book-per-folder-allowed-formats-audio-filter',
mode: 'book_per_folder',
entries: [file('Audio/book.mp3'), file('Audio/book.m4b'), file('Audio/cover.jpg')],
allowedFormats: ['m4b'],
expected: [{ folderPath: 'Audio', filePaths: ['Audio/book.m4b', 'Audio/cover.jpg'], primaryPath: 'Audio/book.m4b' }],
},
{
id: 'book-per-folder-exclude-exact-root-filename',
mode: 'book_per_folder',
entries: [file('skip.epub'), file('Keep/book.epub')],
excludePatterns: ['skip.epub'],
expected: [{ folderPath: 'Keep', filePaths: ['Keep/book.epub'], primaryPath: 'Keep/book.epub' }],
},
{
id: 'book-per-folder-uppercase-extensions',
mode: 'book_per_folder',
entries: [file('Caps/BOOK.EPUB'), file('Caps/COVER.JPG')],
expected: [{ folderPath: 'Caps', filePaths: ['Caps/BOOK.EPUB', 'Caps/COVER.JPG'], primaryPath: 'Caps/BOOK.EPUB' }],
},
{
id: 'book-per-folder-non-disc-similar-folder-not-flattened',
mode: 'book_per_folder',
entries: [file('AudioBook/Discography/01.mp3'), file('AudioBook/cover.jpg')],
expected: [{ folderPath: 'AudioBook/Discography', filePaths: ['AudioBook/Discography/01.mp3'], primaryPath: 'AudioBook/Discography/01.mp3' }],
},
];
const bookPerFileScenarios: ScannerScenario[] = [
{
id: 'book-per-file-multiple-content-files',
mode: 'book_per_file',
entries: [file('Series/book1.epub'), file('Series/book2.epub'), file('Series/book3.mobi'), file('Series/cover.jpg')],
expected: [
{ folderPath: 'Series/book1.epub', filePaths: ['Series/book1.epub'], primaryPath: 'Series/book1.epub' },
{ folderPath: 'Series/book2.epub', filePaths: ['Series/book2.epub'], primaryPath: 'Series/book2.epub' },
{ folderPath: 'Series/book3.mobi', filePaths: ['Series/book3.mobi'], primaryPath: 'Series/book3.mobi' },
],
},
{
id: 'book-per-file-audio-parts',
mode: 'book_per_file',
entries: [file('Audio/01.mp3'), file('Audio/02.mp3')],
expected: [
{ folderPath: 'Audio/01.mp3', filePaths: ['Audio/01.mp3'], primaryPath: 'Audio/01.mp3' },
{ folderPath: 'Audio/02.mp3', filePaths: ['Audio/02.mp3'], primaryPath: 'Audio/02.mp3' },
],
},
{
id: 'book-per-file-ignore-non-content',
mode: 'book_per_file',
entries: [file('Book/cover.jpg'), file('Book/book.epub')],
expected: [{ folderPath: 'Book/book.epub', filePaths: ['Book/book.epub'], primaryPath: 'Book/book.epub' }],
},
{
id: 'book-per-file-allowed-formats-and-excludes',
mode: 'book_per_file',
entries: [file('keep.epub'), file('keep.pdf'), file('skip-folder/skip.epub')],
allowedFormats: ['epub'],
excludePatterns: ['skip*'],
expected: [{ folderPath: 'keep.epub', filePaths: ['keep.epub'], primaryPath: 'keep.epub' }],
},
{
id: 'book-per-file-deep-root-mix',
mode: 'book_per_file',
entries: [file('root.epub'), file('A/B/C/book.pdf'), file('A/B/C/cover.jpg')],
expected: [
{ folderPath: 'root.epub', filePaths: ['root.epub'], primaryPath: 'root.epub' },
{ folderPath: 'A/B/C/book.pdf', filePaths: ['A/B/C/book.pdf'], primaryPath: 'A/B/C/book.pdf' },
],
},
{
id: 'book-per-file-disc-subfolders-not-grouped',
mode: 'book_per_file',
entries: [file('Saga/CD 1/01.mp3'), file('Saga/Disc 2/02.mp3'), file('Saga/cover.jpg')],
expected: [
{ folderPath: 'Saga/CD 1/01.mp3', filePaths: ['Saga/CD 1/01.mp3'], primaryPath: 'Saga/CD 1/01.mp3' },
{ folderPath: 'Saga/Disc 2/02.mp3', filePaths: ['Saga/Disc 2/02.mp3'], primaryPath: 'Saga/Disc 2/02.mp3' },
],
},
{
id: 'book-per-file-uppercase-extensions',
mode: 'book_per_file',
entries: [file('Novel/Book.EPUB'), file('Novel/Book.PDF')],
expected: [
{ folderPath: 'Novel/Book.EPUB', filePaths: ['Novel/Book.EPUB'], primaryPath: 'Novel/Book.EPUB' },
{ folderPath: 'Novel/Book.PDF', filePaths: ['Novel/Book.PDF'], primaryPath: 'Novel/Book.PDF' },
],
},
{
id: 'book-per-file-exclude-exact-filename',
mode: 'book_per_file',
entries: [file('keep.epub'), file('skip.epub')],
excludePatterns: ['skip.epub'],
expected: [{ folderPath: 'keep.epub', filePaths: ['keep.epub'], primaryPath: 'keep.epub' }],
},
{
id: 'book-per-file-allowed-formats-case-insensitive',
mode: 'book_per_file',
entries: [file('keep.EPUB'), file('drop.PDF')],
allowedFormats: ['epub'],
expected: [{ folderPath: 'keep.EPUB', filePaths: ['keep.EPUB'], primaryPath: 'keep.EPUB' }],
},
{
id: 'book-per-file-hidden-and-no-extension-ignored',
mode: 'book_per_file',
entries: [file('.hidden/ghost.epub'), file('visible/README'), file('visible/book.mobi')],
expected: [{ folderPath: 'visible/book.mobi', filePaths: ['visible/book.mobi'], primaryPath: 'visible/book.mobi' }],
},
{
id: 'book-per-file-root-and-nested-same-name',
mode: 'book_per_file',
entries: [file('book.epub'), file('Series/book.epub')],
expected: [
{ folderPath: 'book.epub', filePaths: ['book.epub'], primaryPath: 'book.epub' },
{ folderPath: 'Series/book.epub', filePaths: ['Series/book.epub'], primaryPath: 'Series/book.epub' },
],
},
{
id: 'book-per-file-zero-byte-file-skipped',
mode: 'book_per_file',
entries: [file('empty.epub', ''), file('valid.epub')],
expected: [
{ folderPath: 'empty.epub', filePaths: [], primaryPath: null },
{ folderPath: 'valid.epub', filePaths: ['valid.epub'], primaryPath: 'valid.epub' },
],
},
];
function toAbsolutePath(rootPath: string, relativePath: string): string {
return join(rootPath, relativePath);
}
function assertScenarioOutcome(rootPath: string, expected: ExpectedBook[], actual: LibraryBookState[]): void {
const resolvedExpected = expected.map((book) => ({
folderPath: toAbsolutePath(rootPath, book.folderPath),
status: book.status ?? 'present',
primaryPath: book.primaryPath === undefined ? undefined : book.primaryPath === null ? null : toAbsolutePath(rootPath, book.primaryPath),
filePaths: book.filePaths.map((path) => toAbsolutePath(rootPath, path)).sort(),
}));
const expectedFolderPaths = resolvedExpected.map((book) => book.folderPath).sort();
const actualFolderPaths = actual.map((book) => book.folderPath).sort();
expect(actualFolderPaths).toEqual(expectedFolderPaths);
const actualByFolderPath = new Map(actual.map((book) => [book.folderPath, book]));
for (const expectedBook of resolvedExpected) {
const actualBook = actualByFolderPath.get(expectedBook.folderPath);
expect(actualBook).toBeDefined();
expect(actualBook!.status).toBe(expectedBook.status);
expect(actualBook!.filePaths).toEqual(expectedBook.filePaths);
if (expectedBook.primaryPath !== undefined) {
expect(actualBook!.primaryPath).toBe(expectedBook.primaryPath);
}
}
}
async function runScenario(context: ScannerE2EContext, scenario: ScannerScenario, results: ScenarioRunResult[]): Promise<void> {
const fixture = await createFixtureTree(scenario.entries, `scanner-e2e-${scenario.id}-`);
const startedAt = Date.now();
try {
const { libraryId } = await seedLibrary(context.db, {
rootPath: fixture.rootPath,
mode: scenario.mode,
allowedFormats: scenario.allowedFormats,
excludePatterns: scenario.excludePatterns,
});
const jobId = await triggerLibraryScan(context, libraryId);
await waitForScanCompletion(context.db, jobId);
const actual = await loadLibraryBookState(context.db, libraryId);
assertScenarioOutcome(fixture.rootPath, scenario.expected, actual);
await assertNoIntegrityViolations(context.db);
results.push({
id: scenario.id,
mode: scenario.mode,
status: 'passed',
durationMs: Date.now() - startedAt,
});
} catch (err) {
results.push({
id: scenario.id,
mode: scenario.mode,
status: 'failed',
durationMs: Date.now() - startedAt,
error: err instanceof Error ? err.message : String(err),
});
throw err;
} finally {
await fixture.cleanup();
}
}
async function writeScenarioReport(results: ScenarioRunResult[]): Promise<void> {
const reportDir = process.env.JUNIT_OUTPUT ? dirname(process.env.JUNIT_OUTPUT) : join(process.cwd(), '..', 'test-results', 'server');
await mkdir(reportDir, { recursive: true });
const reportPath = join(reportDir, 'scanner-e2e-scenarios.json');
await writeFile(
reportPath,
JSON.stringify(
{
generatedAt: new Date().toISOString(),
total: results.length,
passed: results.filter((result) => result.status === 'passed').length,
failed: results.filter((result) => result.status === 'failed').length,
results,
},
null,
2,
),
);
}
describe('Scanner structures (e2e)', () => {
let context: ScannerE2EContext | null = null;
const scenarioResults: ScenarioRunResult[] = [];
beforeAll(async () => {
context = await createScannerE2EContext();
});
afterAll(async () => {
if (context) {
await assertNoIntegrityViolations(context.db);
}
await writeScenarioReport(scenarioResults);
if (context) {
await closeScannerE2EContext(context);
}
});
describe('organizationMode=book_per_folder', () => {
for (const scenario of bookPerFolderScenarios) {
it(
scenario.id,
async () => {
if (!context) throw new Error('E2E context not initialized');
await runScenario(context, scenario, scenarioResults);
},
SCENARIO_TIMEOUT_MS,
);
}
});
describe('a library folder named like a disc folder', () => {
// unRAID mounts its drives as /mnt/disk1; such a folder was once flattened into the folder above it.
const entries = [file('disk1/Dune.epub'), file('disk1/Emma.epub'), file('disk1/Saga/Saga 001.cbz')];
const expected = (library: string) =>
[join(library, 'Dune.epub'), join(library, 'Emma.epub'), join(library, 'Saga')].map((folderPath) => ({ folderPath, status: 'present' }));
it(
'reads its loose files as books of their own, inside the library',
async () => {
if (!context) throw new Error('E2E context not initialized');
const fixture = await createFixtureTree(entries, 'scanner-e2e-disc-named-root-');
try {
const library = join(fixture.rootPath, 'disk1');
const { libraryId } = await seedLibrary(context.db, { rootPath: library, mode: 'book_per_folder' });
await waitForScanCompletion(context.db, await triggerLibraryScan(context, libraryId));
const state = await loadLibraryBookState(context.db, libraryId);
expect(state.map(({ folderPath, status }) => ({ folderPath, status })).sort((a, b) => a.folderPath.localeCompare(b.folderPath))).toEqual(
expected(library),
);
await assertNoIntegrityViolations(context.db);
} finally {
await fixture.cleanup();
}
},
SCENARIO_TIMEOUT_MS,
);
it(
'regroups a library scanned while it was flattened, keeping the old book on its primary file',
async () => {
if (!context) throw new Error('E2E context not initialized');
const fixture = await createFixtureTree(entries, 'scanner-e2e-disc-named-root-repair-');
try {
const library = join(fixture.rootPath, 'disk1');
const { libraryId } = await seedLibrary(context.db, { rootPath: library, mode: 'book_per_folder' });
await waitForScanCompletion(context.db, await triggerLibraryScan(context, libraryId));
// Rebuild what the flattening scan stored: one book above the library holding every loose file.
const rows = await context.db.select({ id: books.id, folderPath: books.folderPath }).from(books).where(eq(books.libraryId, libraryId));
const dune = rows.find((row) => row.folderPath === join(library, 'Dune.epub'))!;
const emma = rows.find((row) => row.folderPath === join(library, 'Emma.epub'))!;
await context.db.update(bookFiles).set({ bookId: dune.id }).where(eq(bookFiles.bookId, emma.id));
await context.db.delete(books).where(eq(books.id, emma.id));
await context.db.update(books).set({ folderPath: fixture.rootPath }).where(eq(books.id, dune.id));
await waitForScanCompletion(context.db, await triggerLibraryScan(context, libraryId));
const state = await loadLibraryBookState(context.db, libraryId);
expect(state.map(({ folderPath, status }) => ({ folderPath, status })).sort((a, b) => a.folderPath.localeCompare(b.folderPath))).toEqual(
expected(library),
);
const [kept] = await context.db.select({ folderPath: books.folderPath }).from(books).where(eq(books.id, dune.id));
expect(kept?.folderPath).toBe(join(library, 'Dune.epub'));
expect(state.find((book) => book.folderPath === join(library, 'Emma.epub'))?.filePaths).toEqual([join(library, 'Emma.epub')]);
await assertNoIntegrityViolations(context.db);
} finally {
await fixture.cleanup();
}
},
SCENARIO_TIMEOUT_MS,
);
});
describe('organizationMode=book_per_file', () => {
for (const scenario of bookPerFileScenarios) {
it(
scenario.id,
async () => {
if (!context) throw new Error('E2E context not initialized');
await runScenario(context, scenario, scenarioResults);
},
SCENARIO_TIMEOUT_MS,
);
}
});
});