import { randomUUID } from 'crypto'; import { mkdir, realpath, rename, rm, stat, writeFile } from 'fs/promises'; import { dirname, join, relative } from 'path'; import { and, count, eq, like } from 'drizzle-orm'; import * as schema from '../src/db/schema'; import { WATCHER_DEBOUNCE_MS } from '../src/modules/scanner/file-watcher.service'; import type { FixtureEntry, FixtureTree } from './e2e/scanner/scanner-fixture-builder'; import { jpeg, writeEpubWithCover, writeImage } from './e2e/scanner/cover-slot-fixtures'; import { createFixtureTree, file } from './e2e/scanner/scanner-fixture-builder'; import { assertNoIntegrityViolations, closeScannerE2EContext, createScannerE2EContext, loadLibraryBookState, seedLibrary, startLibraryWatcher, stopLibraryWatcher, triggerAndWaitForLibraryScan, waitForCondition, type LibraryBookState, type ScannerE2EContext, } from './e2e/scanner/scanner-harness'; type OrganizationMode = 'book_per_file' | 'book_per_folder'; type TriggerMode = 'manual' | 'watcher'; type ScenarioStatus = 'passed' | 'failed' | 'skipped'; type LibraryKey = 'a' | 'b'; type BookStatus = 'present' | 'missing'; interface LibrarySetup { key: LibraryKey; rootDir: string; mode: OrganizationMode; } type FileOperation = | { type: 'deleteFile'; path: string } | { type: 'deleteDir'; path: string } | { type: 'move'; from: string; to: string } | { type: 'writeFile'; path: string; content?: string } | { type: 'sleep'; ms: number }; interface LibraryExpectation { statusByFolder: Record; absentFolders?: string[]; fileOwners?: Record; absentFilePaths?: string[]; presentCount?: number; missingCount?: number; } interface StructuralScenario { id: string; trigger: TriggerMode; libraries: LibrarySetup[]; entries: FixtureEntry[]; operations: FileOperation[]; expected: Partial>; requiresCaseRename?: boolean; } interface ScenarioRunResult { id: string; trigger: TriggerMode; status: ScenarioStatus; durationMs: number; error?: string; } interface SeededLibrary { key: LibraryKey; rootPath: string; libraryId: number; } interface StatefulSnapshot { metadataTitle: string | null; metadataPublisher: string | null; bookAuthors: number; bookGenres: number; bookTags: number; bookNarrators: number; collectionBooks: number; userBookStatus: number; readingProgress: number; readingSessions: number; audiobookProgress: number; bookmarks: number; annotations: number; readerPreferences: number; } const SCENARIO_TIMEOUT_MS = 120_000; const structuralScenarios: StructuralScenario[] = [ { id: 'manual-delete-primary-file-marks-missing', trigger: 'manual', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], entries: [file('lib-a/Book/book.epub')], operations: [{ type: 'deleteFile', path: 'lib-a/Book/book.epub' }], expected: { a: { statusByFolder: { Book: 'missing' }, presentCount: 0, missingCount: 1 } }, }, { id: 'manual-delete-folder-marks-only-removed-book-missing', trigger: 'manual', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], entries: [file('lib-a/Shelf/BookOne/one.epub'), file('lib-a/Shelf/BookTwo/two.epub')], operations: [{ type: 'deleteDir', path: 'lib-a/Shelf/BookOne' }], expected: { a: { statusByFolder: { 'Shelf/BookOne': 'missing', 'Shelf/BookTwo': 'present', }, presentCount: 1, missingCount: 1, }, }, }, { id: 'manual-rename-file-in-place', trigger: 'manual', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], entries: [file('lib-a/Book/book.epub')], operations: [{ type: 'move', from: 'lib-a/Book/book.epub', to: 'lib-a/Book/book-renamed.epub' }], expected: { a: { statusByFolder: { Book: 'present' }, fileOwners: { 'Book/book-renamed.epub': 'Book' }, absentFilePaths: ['Book/book.epub'], presentCount: 1, missingCount: 0, }, }, }, { id: 'manual-rename-folder', trigger: 'manual', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], entries: [file('lib-a/Old/book.epub')], operations: [{ type: 'move', from: 'lib-a/Old', to: 'lib-a/New' }], expected: { a: { statusByFolder: { New: 'present' }, absentFolders: ['Old'], fileOwners: { 'New/book.epub': 'New' }, presentCount: 1, missingCount: 0, }, }, }, { id: 'manual-case-only-author-folder-rename', trigger: 'manual', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], entries: [file('lib-a/Bell Hooks/Book/book.epub')], operations: [{ type: 'move', from: 'lib-a/Bell Hooks', to: 'lib-a/bell hooks' }], expected: { a: { statusByFolder: { 'bell hooks/Book': 'present' }, absentFolders: ['Bell Hooks/Book'], fileOwners: { 'bell hooks/Book/book.epub': 'bell hooks/Book' }, presentCount: 1, missingCount: 0, }, }, requiresCaseRename: true, }, { id: 'manual-move-root-file-into-subfolder', trigger: 'manual', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], entries: [file('lib-a/book.epub')], operations: [{ type: 'move', from: 'lib-a/book.epub', to: 'lib-a/Shelf/book.epub' }], expected: { a: { statusByFolder: { Shelf: 'present' }, absentFolders: ['book.epub'], fileOwners: { 'Shelf/book.epub': 'Shelf' }, presentCount: 1, missingCount: 0, }, }, }, { id: 'manual-move-subfolder-file-to-root', trigger: 'manual', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], entries: [file('lib-a/Shelf/book.epub')], operations: [{ type: 'move', from: 'lib-a/Shelf/book.epub', to: 'lib-a/book.epub' }], expected: { a: { statusByFolder: { 'book.epub': 'present' }, absentFolders: ['Shelf'], fileOwners: { 'book.epub': 'book.epub' }, presentCount: 1, missingCount: 0, }, }, }, { id: 'manual-cross-library-file-move', trigger: 'manual', libraries: [ { key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }, { key: 'b', rootDir: 'lib-b', mode: 'book_per_folder' }, ], entries: [file('lib-a/Book/book.epub')], operations: [{ type: 'move', from: 'lib-a/Book/book.epub', to: 'lib-b/Inbox/book.epub' }], expected: { a: { statusByFolder: {}, absentFolders: ['Book'], presentCount: 0, missingCount: 0 }, b: { statusByFolder: { Inbox: 'present' }, fileOwners: { 'Inbox/book.epub': 'Inbox' }, presentCount: 1, missingCount: 0, }, }, }, { id: 'manual-cross-library-folder-move', trigger: 'manual', libraries: [ { key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }, { key: 'b', rootDir: 'lib-b', mode: 'book_per_folder' }, ], entries: [file('lib-a/Series/Book/book.epub')], operations: [{ type: 'move', from: 'lib-a/Series/Book', to: 'lib-b/Incoming/Book' }], expected: { a: { statusByFolder: {}, absentFolders: ['Series/Book'], presentCount: 0, missingCount: 0 }, b: { statusByFolder: { 'Incoming/Book': 'present' }, fileOwners: { 'Incoming/Book/book.epub': 'Incoming/Book' }, presentCount: 1, missingCount: 0, }, }, }, { id: 'manual-multi-step-churn', trigger: 'manual', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], entries: [file('lib-a/A/One/one.epub'), file('lib-a/A/Two/two.epub')], operations: [ { type: 'move', from: 'lib-a/A/One/one.epub', to: 'lib-a/A/One/one-renamed.epub' }, { type: 'move', from: 'lib-a/A/Two', to: 'lib-a/B/Two' }, { type: 'deleteFile', path: 'lib-a/A/One/one-renamed.epub' }, { type: 'writeFile', path: 'lib-a/C/three.epub' }, ], expected: { a: { statusByFolder: { 'A/One': 'missing', 'B/Two': 'present', C: 'present', }, absentFolders: ['A/Two'], fileOwners: { 'B/Two/two.epub': 'B/Two', 'C/three.epub': 'C', }, presentCount: 2, missingCount: 1, }, }, }, { id: 'manual-book-per-file-delete-and-move', trigger: 'manual', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_file' }], entries: [file('lib-a/track1.mp3'), file('lib-a/track2.mp3')], operations: [ { type: 'move', from: 'lib-a/track1.mp3', to: 'lib-a/Archive/track1.mp3' }, { type: 'deleteFile', path: 'lib-a/track2.mp3' }, ], expected: { a: { statusByFolder: { 'track2.mp3': 'missing', 'Archive/track1.mp3': 'present', }, absentFolders: ['track1.mp3'], fileOwners: { 'Archive/track1.mp3': 'Archive/track1.mp3' }, presentCount: 1, missingCount: 1, }, }, }, { id: 'manual-move-into-existing-folder-merges', trigger: 'manual', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], entries: [file('lib-a/Source/source.epub'), file('lib-a/Destination/dest.epub')], operations: [{ type: 'move', from: 'lib-a/Source/source.epub', to: 'lib-a/Destination/source.epub' }], expected: { a: { statusByFolder: { Source: 'missing', Destination: 'present', }, fileOwners: { 'Destination/dest.epub': 'Destination', 'Destination/source.epub': 'Destination', }, absentFilePaths: ['Source/source.epub'], presentCount: 1, missingCount: 1, }, }, }, { id: 'manual-cross-library-move-into-existing-folder-merges', trigger: 'manual', libraries: [ { key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }, { key: 'b', rootDir: 'lib-b', mode: 'book_per_folder' }, ], entries: [file('lib-a/Source/book.epub'), file('lib-b/Inbox/existing.epub')], operations: [{ type: 'move', from: 'lib-a/Source/book.epub', to: 'lib-b/Inbox/book.epub' }], expected: { a: { statusByFolder: { Source: 'missing' }, presentCount: 0, missingCount: 1, }, b: { statusByFolder: { Inbox: 'present' }, fileOwners: { 'Inbox/existing.epub': 'Inbox', 'Inbox/book.epub': 'Inbox', }, presentCount: 1, missingCount: 0, }, }, }, { id: 'manual-delete-and-recreate-same-path', trigger: 'manual', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], entries: [file('lib-a/Book/book.epub')], operations: [ { type: 'deleteFile', path: 'lib-a/Book/book.epub' }, { type: 'writeFile', path: 'lib-a/Book/book.epub' }, ], expected: { a: { statusByFolder: { Book: 'present' }, presentCount: 1, missingCount: 0 } }, }, { id: 'manual-delete-and-recreate-folder-with-new-file', trigger: 'manual', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], entries: [file('lib-a/Swap/old.epub')], operations: [ { type: 'deleteDir', path: 'lib-a/Swap' }, { type: 'writeFile', path: 'lib-a/Swap/new.epub' }, ], expected: { a: { statusByFolder: { Swap: 'present' }, fileOwners: { 'Swap/new.epub': 'Swap' }, absentFilePaths: ['Swap/old.epub'], presentCount: 1, missingCount: 0, }, }, }, { id: 'manual-book-per-file-folder-move-multi-file', trigger: 'manual', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_file' }], entries: [file('lib-a/Batch/one.mp3'), file('lib-a/Batch/two.mp3')], operations: [{ type: 'move', from: 'lib-a/Batch', to: 'lib-a/Archive/Batch' }], expected: { a: { statusByFolder: { 'Archive/Batch/one.mp3': 'present', 'Archive/Batch/two.mp3': 'present', }, absentFolders: ['Batch/one.mp3', 'Batch/two.mp3'], fileOwners: { 'Archive/Batch/one.mp3': 'Archive/Batch/one.mp3', 'Archive/Batch/two.mp3': 'Archive/Batch/two.mp3', }, presentCount: 2, missingCount: 0, }, }, }, { id: 'manual-cross-library-move-then-reseed-source', trigger: 'manual', libraries: [ { key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }, { key: 'b', rootDir: 'lib-b', mode: 'book_per_folder' }, ], entries: [file('lib-a/Book/book.epub')], operations: [ { type: 'move', from: 'lib-a/Book/book.epub', to: 'lib-b/Inbox/book.epub' }, { type: 'writeFile', path: 'lib-a/Book/book.epub' }, ], expected: { a: { statusByFolder: { Book: 'present' }, fileOwners: { 'Book/book.epub': 'Book' }, presentCount: 1, missingCount: 0, }, b: { statusByFolder: { Inbox: 'present' }, fileOwners: { 'Inbox/book.epub': 'Inbox' }, presentCount: 1, missingCount: 0, }, }, }, { id: 'watcher-delete-primary-file-marks-missing', trigger: 'watcher', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], entries: [file('lib-a/Book/book.epub')], operations: [{ type: 'deleteFile', path: 'lib-a/Book/book.epub' }], expected: { a: { statusByFolder: { Book: 'missing' }, presentCount: 0, missingCount: 1 } }, }, { id: 'watcher-delete-folder', trigger: 'watcher', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], entries: [file('lib-a/Shelf/One/one.epub'), file('lib-a/Shelf/Two/two.epub')], operations: [{ type: 'deleteDir', path: 'lib-a/Shelf/One' }], expected: { a: { statusByFolder: { 'Shelf/One': 'missing', 'Shelf/Two': 'present', }, presentCount: 1, missingCount: 1, }, }, }, { id: 'watcher-rename-file-in-place', trigger: 'watcher', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], entries: [file('lib-a/Book/book.epub')], operations: [{ type: 'move', from: 'lib-a/Book/book.epub', to: 'lib-a/Book/book-renamed.epub' }], expected: { a: { statusByFolder: { Book: 'present' }, fileOwners: { 'Book/book-renamed.epub': 'Book' }, absentFilePaths: ['Book/book.epub'], presentCount: 1, missingCount: 0, }, }, }, { id: 'watcher-rename-folder', trigger: 'watcher', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], entries: [file('lib-a/Old/book.epub')], operations: [{ type: 'move', from: 'lib-a/Old', to: 'lib-a/New' }], expected: { a: { statusByFolder: { New: 'present' }, absentFolders: ['Old'], fileOwners: { 'New/book.epub': 'New' }, presentCount: 1, missingCount: 0, }, }, }, { id: 'watcher-move-root-file-into-subfolder', trigger: 'watcher', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], entries: [file('lib-a/book.epub')], operations: [{ type: 'move', from: 'lib-a/book.epub', to: 'lib-a/Shelf/book.epub' }], expected: { a: { statusByFolder: { Shelf: 'present' }, fileOwners: { 'Shelf/book.epub': 'Shelf' }, presentCount: 1, missingCount: 0, }, }, }, { id: 'watcher-move-subfolder-file-to-root', trigger: 'watcher', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], entries: [file('lib-a/Shelf/book.epub')], operations: [{ type: 'move', from: 'lib-a/Shelf/book.epub', to: 'lib-a/book.epub' }], expected: { a: { statusByFolder: { 'book.epub': 'present' }, fileOwners: { 'book.epub': 'book.epub' }, presentCount: 1, missingCount: 0, }, }, }, { id: 'watcher-cross-library-file-move', trigger: 'watcher', libraries: [ { key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }, { key: 'b', rootDir: 'lib-b', mode: 'book_per_folder' }, ], entries: [file('lib-a/Book/book.epub')], operations: [{ type: 'move', from: 'lib-a/Book/book.epub', to: 'lib-b/Inbox/book.epub' }], expected: { a: { statusByFolder: {}, absentFolders: ['Book'], presentCount: 0, missingCount: 0 }, b: { statusByFolder: { Inbox: 'present' }, fileOwners: { 'Inbox/book.epub': 'Inbox' }, presentCount: 1, missingCount: 0, }, }, }, { id: 'watcher-cross-library-root-files-burst', trigger: 'watcher', libraries: [ { key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }, { key: 'b', rootDir: 'lib-b', mode: 'book_per_folder' }, ], entries: [file('lib-a/one.epub'), file('lib-a/two.epub'), file('lib-a/three.epub')], operations: [ { type: 'move', from: 'lib-a/one.epub', to: 'lib-b/one.epub' }, { type: 'move', from: 'lib-a/two.epub', to: 'lib-b/two.epub' }, { type: 'move', from: 'lib-a/three.epub', to: 'lib-b/three.epub' }, ], expected: { a: { statusByFolder: {}, absentFolders: ['one.epub', 'two.epub', 'three.epub'], presentCount: 0, missingCount: 0, }, b: { statusByFolder: { 'one.epub': 'present', 'two.epub': 'present', 'three.epub': 'present', }, fileOwners: { 'one.epub': 'one.epub', 'two.epub': 'two.epub', 'three.epub': 'three.epub', }, presentCount: 3, missingCount: 0, }, }, }, { id: 'watcher-cross-library-folder-move', trigger: 'watcher', libraries: [ { key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }, { key: 'b', rootDir: 'lib-b', mode: 'book_per_folder' }, ], entries: [file('lib-a/Series/Book/book.epub')], operations: [{ type: 'move', from: 'lib-a/Series/Book', to: 'lib-b/Incoming/Book' }], expected: { a: { statusByFolder: {}, absentFolders: ['Series/Book'], presentCount: 0, missingCount: 0 }, b: { statusByFolder: { 'Incoming/Book': 'present' }, fileOwners: { 'Incoming/Book/book.epub': 'Incoming/Book' }, presentCount: 1, missingCount: 0, }, }, }, { id: 'watcher-cross-library-grouping-folder-move', trigger: 'watcher', libraries: [ { key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }, { key: 'b', rootDir: 'lib-b', mode: 'book_per_folder' }, ], entries: [file('lib-a/AJ Carter/One/one.epub'), file('lib-a/AJ Carter/Two/two.epub')], operations: [{ type: 'move', from: 'lib-a/AJ Carter', to: 'lib-b/AJ Carter' }], expected: { a: { statusByFolder: {}, absentFolders: ['AJ Carter/One', 'AJ Carter/Two'], presentCount: 0, missingCount: 0, }, b: { statusByFolder: { 'AJ Carter/One': 'present', 'AJ Carter/Two': 'present', }, fileOwners: { 'AJ Carter/One/one.epub': 'AJ Carter/One', 'AJ Carter/Two/two.epub': 'AJ Carter/Two', }, presentCount: 2, missingCount: 0, }, }, }, { id: 'watcher-case-only-file-rename', trigger: 'watcher', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], entries: [file('lib-a/Book/Book.epub')], operations: [{ type: 'move', from: 'lib-a/Book/Book.epub', to: 'lib-a/Book/book.epub' }], expected: { a: { statusByFolder: { Book: 'present' }, fileOwners: { 'Book/book.epub': 'Book' }, absentFilePaths: ['Book/Book.epub'], presentCount: 1, }, }, requiresCaseRename: true, }, { // A folder image can fill an empty cover slot, so a new one rescans its book folder. id: 'watcher-folder-image-create-rescans-book', trigger: 'watcher', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], entries: [file('lib-a/Book/book.epub')], operations: [ { type: 'writeFile', path: 'lib-a/Book/cover.jpg' }, { type: 'writeFile', path: 'lib-a/Book/metadata.opf' }, ], expected: { a: { statusByFolder: { Book: 'present' }, fileOwners: { 'Book/book.epub': 'Book', 'Book/cover.jpg': 'Book', 'Book/metadata.opf': 'Book' }, presentCount: 1, missingCount: 0, }, }, }, { id: 'watcher-folder-image-delete-keeps-book', trigger: 'watcher', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], entries: [file('lib-a/Book/book.epub'), file('lib-a/Book/cover.jpg')], operations: [{ type: 'deleteFile', path: 'lib-a/Book/cover.jpg' }], expected: { a: { statusByFolder: { Book: 'present' }, absentFilePaths: ['Book/cover.jpg'], presentCount: 1, missingCount: 0 } }, }, { id: 'watcher-book-per-file-delete-then-restore', trigger: 'watcher', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_file' }], entries: [file('lib-a/Track.mp3')], operations: [ { type: 'deleteFile', path: 'lib-a/Track.mp3' }, { type: 'sleep', ms: WATCHER_DEBOUNCE_MS + 400 }, { type: 'writeFile', path: 'lib-a/Track.mp3' }, ], expected: { a: { statusByFolder: { 'Track.mp3': 'present' }, presentCount: 1, missingCount: 0 } }, }, { id: 'watcher-move-into-existing-folder-merges', trigger: 'watcher', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], entries: [file('lib-a/Source/source.epub'), file('lib-a/Destination/dest.epub')], operations: [{ type: 'move', from: 'lib-a/Source/source.epub', to: 'lib-a/Destination/source.epub' }], expected: { a: { statusByFolder: { Source: 'missing', Destination: 'present', }, fileOwners: { 'Destination/dest.epub': 'Destination', 'Destination/source.epub': 'Destination', }, absentFilePaths: ['Source/source.epub'], presentCount: 1, missingCount: 1, }, }, }, { id: 'watcher-delete-and-recreate-same-path-within-debounce', trigger: 'watcher', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], entries: [file('lib-a/Book/book.epub')], operations: [ { type: 'deleteFile', path: 'lib-a/Book/book.epub' }, { type: 'writeFile', path: 'lib-a/Book/book.epub' }, ], expected: { a: { statusByFolder: { Book: 'present' }, presentCount: 1, missingCount: 0 } }, }, { id: 'watcher-delete-and-recreate-folder-with-new-file', trigger: 'watcher', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], entries: [file('lib-a/Swap/old.epub')], operations: [ { type: 'deleteDir', path: 'lib-a/Swap' }, { type: 'sleep', ms: WATCHER_DEBOUNCE_MS + 400 }, { type: 'writeFile', path: 'lib-a/Swap/new.epub' }, ], expected: { a: { statusByFolder: { Swap: 'present' }, fileOwners: { 'Swap/new.epub': 'Swap' }, absentFilePaths: ['Swap/old.epub'], presentCount: 1, missingCount: 0, }, }, }, { id: 'watcher-cross-library-move-then-reseed-source', trigger: 'watcher', libraries: [ { key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }, { key: 'b', rootDir: 'lib-b', mode: 'book_per_folder' }, ], entries: [file('lib-a/Book/book.epub')], operations: [ { type: 'move', from: 'lib-a/Book/book.epub', to: 'lib-b/Inbox/book.epub' }, { type: 'sleep', ms: WATCHER_DEBOUNCE_MS + 400 }, { type: 'writeFile', path: 'lib-a/Book/book.epub' }, ], expected: { a: { statusByFolder: { Book: 'present' }, fileOwners: { 'Book/book.epub': 'Book' }, presentCount: 1, missingCount: 0, }, b: { statusByFolder: { Inbox: 'present' }, fileOwners: { 'Inbox/book.epub': 'Inbox' }, presentCount: 1, missingCount: 0, }, }, }, { id: 'watcher-create-then-delete-new-path-within-debounce', trigger: 'watcher', libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], entries: [], operations: [ { type: 'writeFile', path: 'lib-a/Temp/new.epub' }, { type: 'deleteFile', path: 'lib-a/Temp/new.epub' }, ], expected: { a: { statusByFolder: {}, presentCount: 0, missingCount: 0, }, }, }, { id: 'watcher-book-per-file-cross-library-folder-move-multi-file', trigger: 'watcher', libraries: [ { key: 'a', rootDir: 'lib-a', mode: 'book_per_file' }, { key: 'b', rootDir: 'lib-b', mode: 'book_per_file' }, ], entries: [file('lib-a/Batch/one.mp3'), file('lib-a/Batch/two.mp3')], operations: [{ type: 'move', from: 'lib-a/Batch', to: 'lib-b/Import/Batch' }], expected: { a: { statusByFolder: {}, absentFolders: ['Batch/one.mp3', 'Batch/two.mp3'], presentCount: 0, missingCount: 0, }, b: { statusByFolder: { 'Import/Batch/one.mp3': 'present', 'Import/Batch/two.mp3': 'present', }, fileOwners: { 'Import/Batch/one.mp3': 'Import/Batch/one.mp3', 'Import/Batch/two.mp3': 'Import/Batch/two.mp3', }, presentCount: 2, missingCount: 0, }, }, }, ]; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } async function applyOperation(rootPath: string, operation: FileOperation): Promise { switch (operation.type) { case 'deleteFile': await rm(join(rootPath, operation.path), { force: true }); break; case 'deleteDir': await rm(join(rootPath, operation.path), { recursive: true, force: true }); break; case 'move': { const from = join(rootPath, operation.from); const to = join(rootPath, operation.to); await mkdir(dirname(to), { recursive: true }); await rename(from, to); break; } case 'writeFile': { const outputPath = join(rootPath, operation.path); await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, operation.content ?? `${operation.path}\n`.repeat(600)); break; } case 'sleep': await sleep(operation.ms); break; } } function assertLibraryExpectation(rootPath: string, actual: LibraryBookState[], expectation: LibraryExpectation): void { const byFolder = new Map(actual.map((book) => [relative(rootPath, book.folderPath), book])); for (const [folderPath, status] of Object.entries(expectation.statusByFolder)) { const book = byFolder.get(folderPath); expect(book, `expected book for folder "${folderPath}"`).toBeDefined(); expect(book!.status).toBe(status); } for (const folderPath of expectation.absentFolders ?? []) { expect(byFolder.has(folderPath), `folder "${folderPath}" should be absent`).toBe(false); } if (expectation.presentCount !== undefined) { expect(actual.filter((book) => book.status === 'present')).toHaveLength(expectation.presentCount); } if (expectation.missingCount !== undefined) { expect(actual.filter((book) => book.status === 'missing')).toHaveLength(expectation.missingCount); } for (const [filePath, expectedOwnerFolder] of Object.entries(expectation.fileOwners ?? {})) { const absoluteFilePath = join(rootPath, filePath); const owners = actual .filter((book) => book.filePaths.includes(absoluteFilePath)) .map((book) => relative(rootPath, book.folderPath)) .sort(); expect(owners).toEqual([expectedOwnerFolder]); } for (const filePath of expectation.absentFilePaths ?? []) { const absoluteFilePath = join(rootPath, filePath); const owners = actual.filter((book) => book.filePaths.includes(absoluteFilePath)); expect(owners, `file "${filePath}" should not be owned by any book`).toHaveLength(0); } } async function resetScannerFileOpsState(db: ScannerE2EContext['db']): Promise { await db.delete(schema.libraries).where(like(schema.libraries.name, 'scanner-file-ops-%')); await db.delete(schema.users).where(like(schema.users.username, 'state-user-%')); } async function supportsCaseOnlyRename(rootPath: string): Promise { const probeDir = join(rootPath, 'case-probe'); const upperPath = join(probeDir, 'CaseProbe.epub'); const lowerPath = join(probeDir, 'caseprobe.epub'); await mkdir(probeDir, { recursive: true }); await writeFile(upperPath, 'probe'); try { await rename(upperPath, lowerPath); const renamed = await stat(lowerPath).catch(() => null); return renamed?.isFile() ?? false; } catch { return false; } finally { await rm(probeDir, { recursive: true, force: true }); } } function seededLibraryByKey(seeded: SeededLibrary[], key: LibraryKey): SeededLibrary { const lib = seeded.find((entry) => entry.key === key); if (!lib) throw new Error(`Missing seeded library for key "${key}"`); return lib; } async function assertScenarioExpectations( context: ScannerE2EContext, seeded: SeededLibrary[], expected: Partial>, ): Promise { for (const key of Object.keys(expected) as LibraryKey[]) { const expectation = expected[key]; if (!expectation) continue; const lib = seededLibraryByKey(seeded, key); const actual = await loadLibraryBookState(context.db, lib.libraryId); assertLibraryExpectation(lib.rootPath, actual, expectation); } } async function runStructuralScenario(context: ScannerE2EContext, scenario: StructuralScenario, results: ScenarioRunResult[]): Promise { const fixture = await createFixtureTree(scenario.entries, `scanner-file-ops-${scenario.id}-`); const startedAt = Date.now(); const seeded: SeededLibrary[] = []; const startedWatchers: number[] = []; try { await resetScannerFileOpsState(context.db); if (scenario.requiresCaseRename && !(await supportsCaseOnlyRename(fixture.rootPath))) { results.push({ id: scenario.id, trigger: scenario.trigger, status: 'skipped', durationMs: Date.now() - startedAt, }); return; } for (const library of scenario.libraries) { const requestedRootPath = join(fixture.rootPath, library.rootDir); await mkdir(requestedRootPath, { recursive: true }); const rootPath = await realpath(requestedRootPath); const seededLibrary = await seedLibrary(context.db, { rootPath, mode: library.mode, watch: false, name: `scanner-file-ops-${scenario.id}-${library.key}-${randomUUID()}`, }); seeded.push({ key: library.key, rootPath, libraryId: seededLibrary.libraryId, }); } for (const library of seeded) { await triggerAndWaitForLibraryScan(context, library.libraryId); } if (scenario.trigger === 'watcher') { for (const library of seeded) { await startLibraryWatcher(context, library.libraryId, [library.rootPath]); startedWatchers.push(library.libraryId); } } for (const operation of scenario.operations) { await applyOperation(fixture.rootPath, operation); } if (scenario.trigger === 'manual') { for (const library of seeded) { await triggerAndWaitForLibraryScan(context, library.libraryId); } await assertScenarioExpectations(context, seeded, scenario.expected); await assertNoIntegrityViolations(context.db); } else { await waitForCondition(async () => { await assertScenarioExpectations(context, seeded, scenario.expected); await assertNoIntegrityViolations(context.db); }, 40_000); } results.push({ id: scenario.id, trigger: scenario.trigger, status: 'passed', durationMs: Date.now() - startedAt, }); } catch (err) { results.push({ id: scenario.id, trigger: scenario.trigger, status: 'failed', durationMs: Date.now() - startedAt, error: err instanceof Error ? err.message : String(err), }); throw err; } finally { for (const libraryId of startedWatchers) { await stopLibraryWatcher(context, libraryId); } await fixture.cleanup(); } } async function getBookByFolderPath(db: ScannerE2EContext['db'], libraryId: number, folderPath: string): Promise { const [book] = await db .select() .from(schema.books) .where(and(eq(schema.books.libraryId, libraryId), eq(schema.books.folderPath, folderPath))) .limit(1); if (!book) { throw new Error(`Book not found for library=${libraryId} folderPath="${folderPath}"`); } return book; } async function getBookById(db: ScannerE2EContext['db'], bookId: number): Promise { const [book] = await db.select().from(schema.books).where(eq(schema.books.id, bookId)).limit(1); if (!book) { throw new Error(`Book id=${bookId} not found`); } return book; } async function createRegularUsers(db: ScannerE2EContext['db']): Promise<[number, number]> { const suffix = randomUUID(); const users = await db .insert(schema.users) .values([ { username: `state-user-a-${suffix}`, name: 'State User A', email: `state-user-a-${suffix}@example.com`, passwordHash: `hash-${suffix}-a`, isSuperuser: false, }, { username: `state-user-b-${suffix}`, name: 'State User B', email: `state-user-b-${suffix}@example.com`, passwordHash: `hash-${suffix}-b`, isSuperuser: false, }, ]) .returning({ id: schema.users.id }); return [users[0]!.id, users[1]!.id]; } async function loadStatefulSnapshot(db: ScannerE2EContext['db'], bookId: number, primaryFileId: number): Promise { const [metadata] = await db .select({ title: schema.bookMetadata.title, publisher: schema.bookMetadata.publisher }) .from(schema.bookMetadata) .where(eq(schema.bookMetadata.bookId, bookId)) .limit(1); const [bookAuthors] = await db.select({ value: count() }).from(schema.bookAuthors).where(eq(schema.bookAuthors.bookId, bookId)); const [bookGenres] = await db.select({ value: count() }).from(schema.bookGenres).where(eq(schema.bookGenres.bookId, bookId)); const [bookTags] = await db.select({ value: count() }).from(schema.bookTags).where(eq(schema.bookTags.bookId, bookId)); const [bookNarrators] = await db.select({ value: count() }).from(schema.bookNarrators).where(eq(schema.bookNarrators.bookId, bookId)); const [collectionBooks] = await db.select({ value: count() }).from(schema.collectionBooks).where(eq(schema.collectionBooks.bookId, bookId)); const [userBookStatus] = await db.select({ value: count() }).from(schema.userBookStatus).where(eq(schema.userBookStatus.bookId, bookId)); const [readingProgress] = await db .select({ value: count() }) .from(schema.readingProgress) .where(eq(schema.readingProgress.bookFileId, primaryFileId)); const [readingSessions] = await db .select({ value: count() }) .from(schema.readingSessions) .where(eq(schema.readingSessions.bookFileId, primaryFileId)); const [audiobookProgress] = await db.select({ value: count() }).from(schema.audiobookProgress).where(eq(schema.audiobookProgress.bookId, bookId)); const [bookmarks] = await db.select({ value: count() }).from(schema.bookmarks).where(eq(schema.bookmarks.bookId, bookId)); const [annotations] = await db.select({ value: count() }).from(schema.annotations).where(eq(schema.annotations.bookId, bookId)); const [readerPreferences] = await db .select({ value: count() }) .from(schema.readerPreferences) .where(eq(schema.readerPreferences.bookFileId, primaryFileId)); return { metadataTitle: metadata?.title ?? null, metadataPublisher: metadata?.publisher ?? null, bookAuthors: Number(bookAuthors.value), bookGenres: Number(bookGenres.value), bookTags: Number(bookTags.value), bookNarrators: Number(bookNarrators.value), collectionBooks: Number(collectionBooks.value), userBookStatus: Number(userBookStatus.value), readingProgress: Number(readingProgress.value), readingSessions: Number(readingSessions.value), audiobookProgress: Number(audiobookProgress.value), bookmarks: Number(bookmarks.value), annotations: Number(annotations.value), readerPreferences: Number(readerPreferences.value), }; } async function seedStatefulData(db: ScannerE2EContext['db'], bookId: number, primaryFileId: number): Promise<{ snapshot: StatefulSnapshot }> { const [userA, userB] = await createRegularUsers(db); const suffix = randomUUID(); const now = new Date(); await db .update(schema.bookMetadata) .set({ title: 'Stateful Scanner Fixture', publisher: 'Stateful Publishing', seriesName: 'Scanner Stateful Series', seriesIndex: 1, }) .where(eq(schema.bookMetadata.bookId, bookId)); const [author] = await db .insert(schema.authors) .values({ name: `State Author ${suffix}` }) .returning({ id: schema.authors.id }); await db.insert(schema.bookAuthors).values({ bookId, authorId: author.id, displayOrder: 0 }); const [genre] = await db .insert(schema.genres) .values({ name: `State Genre ${suffix}` }) .returning({ id: schema.genres.id }); await db.insert(schema.bookGenres).values({ bookId, genreId: genre.id }); const [tag] = await db .insert(schema.tags) .values({ name: `State Tag ${suffix}` }) .returning({ id: schema.tags.id }); await db.insert(schema.bookTags).values({ bookId, tagId: tag.id }); const [narrator] = await db .insert(schema.narrators) .values({ name: `State Narrator ${suffix}` }) .returning({ id: schema.narrators.id }); await db.insert(schema.bookNarrators).values({ bookId, narratorId: narrator.id, displayOrder: 0 }); const [collection] = await db .insert(schema.collections) .values({ userId: userA, name: `State Collection ${suffix}`, description: 'Stateful collection fixture' }) .returning({ id: schema.collections.id }); await db.insert(schema.collectionBooks).values({ collectionId: collection.id, bookId }); await db.insert(schema.userBookStatus).values([ { userId: userA, bookId, status: 'reading', source: 'manual' }, { userId: userB, bookId, status: 'on_hold', source: 'manual' }, ]); await db.insert(schema.readingProgress).values([ { userId: userA, bookFileId: primaryFileId, percentage: 42.5, positionSeconds: 220.5, pageNumber: 12 }, { userId: userB, bookFileId: primaryFileId, percentage: 17.25, positionSeconds: 80.25, pageNumber: 4 }, ]); await db.insert(schema.readingSessions).values([ { userId: userA, bookFileId: primaryFileId, bookId, sessionId: `session-${suffix}-a`, startedAt: new Date(now.getTime() - 300_000), endedAt: new Date(now.getTime() - 240_000), durationSeconds: 60, progressDelta: 2.5, endProgress: 42.5, }, { userId: userB, bookFileId: primaryFileId, bookId, sessionId: `session-${suffix}-b`, startedAt: new Date(now.getTime() - 180_000), endedAt: new Date(now.getTime() - 120_000), durationSeconds: 60, progressDelta: 1.5, endProgress: 17.25, }, ]); await db.insert(schema.audiobookProgress).values({ userId: userA, bookId, currentFileId: primaryFileId, percentage: 42.5, positionSeconds: 220.5 }); await db.insert(schema.bookmarks).values([ { userId: userA, bookId, title: 'State Bookmark A', cfi: 'epubcfi(/6/2[chapter1]!/4/2/14)' }, { userId: userB, bookId, title: 'State Bookmark B', cfi: 'epubcfi(/6/4[chapter2]!/4/6/8)' }, ]); await db.insert(schema.annotations).values([ { userId: userA, bookId, cfi: 'epubcfi(/6/2[chapter1]!/4/2/14)', text: 'State annotation A', color: 'yellow', style: 'highlight', }, { userId: userB, bookId, cfi: 'epubcfi(/6/4[chapter2]!/4/6/8)', text: 'State annotation B', color: 'green', style: 'underline', }, ]); await db.insert(schema.readerPreferences).values({ userId: userA, bookFileId: primaryFileId, settings: { fontSize: 17, lineHeight: 1.6 } }); return { snapshot: await loadStatefulSnapshot(db, bookId, primaryFileId), }; } async function assertStatefulUnchanged( db: ScannerE2EContext['db'], bookId: number, primaryFileId: number, expectedSnapshot: StatefulSnapshot, ): Promise { const actual = await loadStatefulSnapshot(db, bookId, primaryFileId); expect(actual).toEqual(expectedSnapshot); } async function writeScenarioReport(results: ScenarioRunResult[]): Promise { 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-file-ops-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, skipped: results.filter((result) => result.status === 'skipped').length, results, }, null, 2, ), ); } interface StatefulScenario { id: string; trigger: TriggerMode; fixturePrefix: string; fixtureEntries: FixtureEntry[]; run: (context: ScannerE2EContext, fixture: FixtureTree) => Promise; } async function runStatefulScenario(context: ScannerE2EContext, scenario: StatefulScenario, results: ScenarioRunResult[]): Promise { const startedAt = Date.now(); await resetScannerFileOpsState(context.db); const fixture = await createFixtureTree(scenario.fixtureEntries, scenario.fixturePrefix); try { await scenario.run(context, fixture); results.push({ id: scenario.id, trigger: scenario.trigger, status: 'passed', durationMs: Date.now() - startedAt }); } catch (err) { results.push({ id: scenario.id, trigger: scenario.trigger, status: 'failed', durationMs: Date.now() - startedAt, error: err instanceof Error ? err.message : String(err), }); throw err; } finally { await fixture.cleanup(); } } const statefulScenarios: StatefulScenario[] = [ { id: 'stateful-mixed-media-cover-slots', trigger: 'manual', fixturePrefix: 'scanner-file-ops-cover-slots-', fixtureEntries: [file('lib-a/Book/01.mp3')], run: async (context, fixture) => { await writeEpubWithCover(join(fixture.rootPath, 'lib-a/Book/book.epub'), await jpeg(200, 300)); await writeImage(join(fixture.rootPath, 'lib-a/Book/folder.jpg'), 500, 500); const rootA = await realpath(join(fixture.rootPath, 'lib-a')); const library = await seedLibrary(context.db, { rootPath: rootA, mode: 'book_per_folder', watch: false, name: `scanner-file-ops-cover-slots-${randomUUID()}`, }); await triggerAndWaitForLibraryScan(context, library.libraryId); const book = await getBookByFolderPath(context.db, library.libraryId, join(rootA, 'Book')); const slots = async () => Object.fromEntries( (await context.db.select().from(schema.bookCovers).where(eq(schema.bookCovers.bookId, book.id))).map((slot) => [slot.medium, slot]), ); // The EPUB's own art is the book cover; the mp3 has none, so the square folder image is the audiobook cover. expect(await slots()).toMatchObject({ ebook: { origin: 'embedded', source: 'extracted', width: 200, height: 300, dormantSince: null }, audio: { origin: 'folder_image', source: 'extracted', width: 500, height: 500, dormantSince: null }, }); const coverDir = join(context.isolatedAppData!.path, 'covers', String(book.id)); await expect(stat(join(coverDir, 'ebook', 'thumbnail.jpg'))).resolves.toBeDefined(); await expect(stat(join(coverDir, 'audio', 'thumbnail.jpg'))).resolves.toBeDefined(); await applyOperation(fixture.rootPath, { type: 'deleteFile', path: 'lib-a/Book/01.mp3' }); await triggerAndWaitForLibraryScan(context, library.libraryId); expect((await slots()).audio?.dormantSince).toBeInstanceOf(Date); const [metadata] = await context.db.select().from(schema.bookMetadata).where(eq(schema.bookMetadata.bookId, book.id)); expect(metadata?.coverSource).toBe('extracted'); await applyOperation(fixture.rootPath, { type: 'writeFile', path: 'lib-a/Book/01.mp3' }); await triggerAndWaitForLibraryScan(context, library.libraryId); expect((await slots()).audio).toMatchObject({ origin: 'folder_image', dormantSince: null }); await assertNoIntegrityViolations(context.db); }, }, { id: 'stateful-manual-delete-primary-preserves-data', trigger: 'manual', fixturePrefix: 'scanner-file-ops-stateful-manual-delete-', fixtureEntries: [file('lib-a/Book/book.epub')], run: async (context, fixture) => { const requestedRootA = join(fixture.rootPath, 'lib-a'); await mkdir(requestedRootA, { recursive: true }); const rootA = await realpath(requestedRootA); const library = await seedLibrary(context.db, { rootPath: rootA, mode: 'book_per_folder', watch: false, name: `scanner-file-ops-stateful-manual-delete-${randomUUID()}`, }); await triggerAndWaitForLibraryScan(context, library.libraryId); const sourceBook = await getBookByFolderPath(context.db, library.libraryId, join(rootA, 'Book')); if (sourceBook.primaryFileId == null) throw new Error('Expected primary file id to be present'); const seeded = await seedStatefulData(context.db, sourceBook.id, sourceBook.primaryFileId); await applyOperation(fixture.rootPath, { type: 'deleteFile', path: 'lib-a/Book/book.epub' }); await triggerAndWaitForLibraryScan(context, library.libraryId); const sourceAfter = await getBookById(context.db, sourceBook.id); expect(sourceAfter.status).toBe('missing'); await assertStatefulUnchanged(context.db, sourceBook.id, sourceBook.primaryFileId, seeded.snapshot); await assertNoIntegrityViolations(context.db); }, }, { id: 'stateful-manual-cross-library-move-preserves-book-identity', trigger: 'manual', fixturePrefix: 'scanner-file-ops-stateful-cross-lib-manual-', fixtureEntries: [file('lib-a/Book/book.epub')], run: async (context, fixture) => { const requestedRootA = join(fixture.rootPath, 'lib-a'); const requestedRootB = join(fixture.rootPath, 'lib-b'); await mkdir(requestedRootA, { recursive: true }); await mkdir(requestedRootB, { recursive: true }); const rootA = await realpath(requestedRootA); const rootB = await realpath(requestedRootB); const sourceLibrary = await seedLibrary(context.db, { rootPath: rootA, mode: 'book_per_folder', watch: false, name: `scanner-file-ops-stateful-source-${randomUUID()}`, }); const destinationLibrary = await seedLibrary(context.db, { rootPath: rootB, mode: 'book_per_folder', watch: false, name: `scanner-file-ops-stateful-destination-${randomUUID()}`, }); await triggerAndWaitForLibraryScan(context, sourceLibrary.libraryId); await triggerAndWaitForLibraryScan(context, destinationLibrary.libraryId); const sourceBook = await getBookByFolderPath(context.db, sourceLibrary.libraryId, join(rootA, 'Book')); if (sourceBook.primaryFileId == null) throw new Error('Expected source primary file id to be present'); const seeded = await seedStatefulData(context.db, sourceBook.id, sourceBook.primaryFileId); await applyOperation(fixture.rootPath, { type: 'move', from: 'lib-a/Book/book.epub', to: 'lib-b/Inbox/book.epub' }); await triggerAndWaitForLibraryScan(context, sourceLibrary.libraryId); await triggerAndWaitForLibraryScan(context, destinationLibrary.libraryId); const movedBook = await getBookById(context.db, sourceBook.id); expect(movedBook.libraryId).toBe(destinationLibrary.libraryId); expect(movedBook.status).toBe('present'); expect(movedBook.folderPath).toBe(join(rootB, 'Inbox')); expect(movedBook.primaryFileId).toBe(sourceBook.primaryFileId); const destinationBook = await getBookByFolderPath(context.db, destinationLibrary.libraryId, join(rootB, 'Inbox')); expect(destinationBook.id).toBe(sourceBook.id); expect(destinationBook.status).toBe('present'); expect(destinationBook.primaryFileId).toBe(sourceBook.primaryFileId); const sourceState = await loadLibraryBookState(context.db, sourceLibrary.libraryId); expect(sourceState).toHaveLength(0); await assertStatefulUnchanged(context.db, sourceBook.id, sourceBook.primaryFileId, seeded.snapshot); await assertNoIntegrityViolations(context.db); }, }, { id: 'stateful-watcher-cross-library-move-preserves-book-identity', trigger: 'watcher', fixturePrefix: 'scanner-file-ops-stateful-cross-lib-watcher-', fixtureEntries: [file('lib-a/Book/book.epub')], run: async (context, fixture) => { const requestedRootA = join(fixture.rootPath, 'lib-a'); const requestedRootB = join(fixture.rootPath, 'lib-b'); await mkdir(requestedRootA, { recursive: true }); await mkdir(requestedRootB, { recursive: true }); const rootA = await realpath(requestedRootA); const rootB = await realpath(requestedRootB); let sourceLibraryId: number | null = null; let destinationLibraryId: number | null = null; try { const sourceLibrary = await seedLibrary(context.db, { rootPath: rootA, mode: 'book_per_folder', watch: false, name: `scanner-file-ops-stateful-source-watcher-${randomUUID()}`, }); sourceLibraryId = sourceLibrary.libraryId; const destinationLibrary = await seedLibrary(context.db, { rootPath: rootB, mode: 'book_per_folder', watch: false, name: `scanner-file-ops-stateful-destination-watcher-${randomUUID()}`, }); destinationLibraryId = destinationLibrary.libraryId; await triggerAndWaitForLibraryScan(context, sourceLibrary.libraryId); await triggerAndWaitForLibraryScan(context, destinationLibrary.libraryId); const sourceBook = await getBookByFolderPath(context.db, sourceLibrary.libraryId, join(rootA, 'Book')); if (sourceBook.primaryFileId == null) throw new Error('Expected source primary file id to be present'); const seeded = await seedStatefulData(context.db, sourceBook.id, sourceBook.primaryFileId); await startLibraryWatcher(context, sourceLibrary.libraryId, [rootA]); await startLibraryWatcher(context, destinationLibrary.libraryId, [rootB]); await applyOperation(fixture.rootPath, { type: 'move', from: 'lib-a/Book/book.epub', to: 'lib-b/Inbox/book.epub' }); await waitForCondition(async () => { const movedBook = await getBookById(context.db, sourceBook.id); expect(movedBook.libraryId).toBe(destinationLibrary.libraryId); expect(movedBook.status).toBe('present'); expect(movedBook.folderPath).toBe(join(rootB, 'Inbox')); expect(movedBook.primaryFileId).toBe(sourceBook.primaryFileId); const destinationBook = await getBookByFolderPath(context.db, destinationLibrary.libraryId, join(rootB, 'Inbox')); expect(destinationBook.id).toBe(sourceBook.id); expect(destinationBook.primaryFileId).toBe(sourceBook.primaryFileId); const sourceState = await loadLibraryBookState(context.db, sourceLibrary.libraryId); expect(sourceState).toHaveLength(0); }, 40_000); await assertStatefulUnchanged(context.db, sourceBook.id, sourceBook.primaryFileId, seeded.snapshot); await assertNoIntegrityViolations(context.db); } finally { if (sourceLibraryId != null) await stopLibraryWatcher(context, sourceLibraryId); if (destinationLibraryId != null) await stopLibraryWatcher(context, destinationLibraryId); } }, }, { id: 'stateful-watcher-rename-folder-preserves-data', trigger: 'watcher', fixturePrefix: 'scanner-file-ops-stateful-watcher-rename-', fixtureEntries: [file('lib-a/Old/book.epub')], run: async (context, fixture) => { const requestedRootA = join(fixture.rootPath, 'lib-a'); await mkdir(requestedRootA, { recursive: true }); const rootA = await realpath(requestedRootA); let libraryId: number | null = null; try { const library = await seedLibrary(context.db, { rootPath: rootA, mode: 'book_per_folder', watch: false, name: `scanner-file-ops-stateful-watcher-rename-${randomUUID()}`, }); libraryId = library.libraryId; await triggerAndWaitForLibraryScan(context, library.libraryId); const sourceBook = await getBookByFolderPath(context.db, library.libraryId, join(rootA, 'Old')); if (sourceBook.primaryFileId == null) throw new Error('Expected source primary file id to be present'); const seeded = await seedStatefulData(context.db, sourceBook.id, sourceBook.primaryFileId); await startLibraryWatcher(context, library.libraryId, [rootA]); await applyOperation(fixture.rootPath, { type: 'move', from: 'lib-a/Old', to: 'lib-a/New' }); await waitForCondition(async () => { const book = await getBookById(context.db, sourceBook.id); expect(book.status).toBe('present'); expect(book.folderPath).toBe(join(rootA, 'New')); }, 40_000); await assertStatefulUnchanged(context.db, sourceBook.id, sourceBook.primaryFileId, seeded.snapshot); await assertNoIntegrityViolations(context.db); } finally { if (libraryId != null) await stopLibraryWatcher(context, libraryId); } }, }, { id: 'stateful-watcher-delete-then-restore-preserves-data', trigger: 'watcher', fixturePrefix: 'scanner-file-ops-stateful-watcher-delete-restore-', fixtureEntries: [file('lib-a/Book/book.epub')], run: async (context, fixture) => { const requestedRootA = join(fixture.rootPath, 'lib-a'); await mkdir(requestedRootA, { recursive: true }); const rootA = await realpath(requestedRootA); let libraryId: number | null = null; try { const library = await seedLibrary(context.db, { rootPath: rootA, mode: 'book_per_folder', watch: false, name: `scanner-file-ops-stateful-watcher-delete-restore-${randomUUID()}`, }); libraryId = library.libraryId; await triggerAndWaitForLibraryScan(context, library.libraryId); const sourceBook = await getBookByFolderPath(context.db, library.libraryId, join(rootA, 'Book')); if (sourceBook.primaryFileId == null) throw new Error('Expected source primary file id to be present'); const seeded = await seedStatefulData(context.db, sourceBook.id, sourceBook.primaryFileId); await startLibraryWatcher(context, library.libraryId, [rootA]); await applyOperation(fixture.rootPath, { type: 'deleteFile', path: 'lib-a/Book/book.epub' }); await waitForCondition(async () => { const book = await getBookById(context.db, sourceBook.id); expect(book.status).toBe('missing'); }, 40_000); await applyOperation(fixture.rootPath, { type: 'writeFile', path: 'lib-a/Book/book.epub' }); await waitForCondition(async () => { const book = await getBookById(context.db, sourceBook.id); expect(book.status).toBe('present'); }, 40_000); await assertStatefulUnchanged(context.db, sourceBook.id, sourceBook.primaryFileId, seeded.snapshot); await assertNoIntegrityViolations(context.db); } finally { if (libraryId != null) await stopLibraryWatcher(context, libraryId); } }, }, ]; describe('Scanner file operations (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('structural scenarios', () => { for (const scenario of structuralScenarios) { it( scenario.id, async () => { if (!context) throw new Error('E2E context not initialized'); await runStructuralScenario(context, scenario, scenarioResults); }, SCENARIO_TIMEOUT_MS, ); } }); describe('stateful consistency scenarios', () => { for (const scenario of statefulScenarios) { it( scenario.id, async () => { if (!context) throw new Error('E2E context not initialized'); await runStatefulScenario(context, scenario, scenarioResults); }, SCENARIO_TIMEOUT_MS, ); } }); });