mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
e28bd67fa3
Keep one cover slot per medium and show the one each surface needs: the library's shape for the face, square art on audio screens, the ebook slot for Kobo and OPDS. Legacy covers convert on upgrade or on first read, and a write that names no slot keeps working for released iOS builds. Also read the dev client origin from one constant.
1594 lines
56 KiB
TypeScript
1594 lines
56 KiB
TypeScript
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<string, BookStatus>;
|
|
absentFolders?: string[];
|
|
fileOwners?: Record<string, string>;
|
|
absentFilePaths?: string[];
|
|
presentCount?: number;
|
|
missingCount?: number;
|
|
}
|
|
|
|
interface StructuralScenario {
|
|
id: string;
|
|
trigger: TriggerMode;
|
|
libraries: LibrarySetup[];
|
|
entries: FixtureEntry[];
|
|
operations: FileOperation[];
|
|
expected: Partial<Record<LibraryKey, LibraryExpectation>>;
|
|
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<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function applyOperation(rootPath: string, operation: FileOperation): Promise<void> {
|
|
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<void> {
|
|
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<boolean> {
|
|
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<Record<LibraryKey, LibraryExpectation>>,
|
|
): Promise<void> {
|
|
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<void> {
|
|
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<typeof schema.books.$inferSelect> {
|
|
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<typeof schema.books.$inferSelect> {
|
|
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<StatefulSnapshot> {
|
|
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<void> {
|
|
const actual = await loadStatefulSnapshot(db, bookId, primaryFileId);
|
|
expect(actual).toEqual(expectedSnapshot);
|
|
}
|
|
|
|
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-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<void>;
|
|
}
|
|
|
|
async function runStatefulScenario(context: ScannerE2EContext, scenario: StatefulScenario, results: ScenarioRunResult[]): Promise<void> {
|
|
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,
|
|
);
|
|
}
|
|
});
|
|
});
|