mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
test(scanner): refactor E2E scaffolding and extract shared test harness
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
name: E2E Runner (reusable)
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
suite-script:
|
||||
description: "pnpm root script to run (e.g. test:e2e:scanner)"
|
||||
required: true
|
||||
type: string
|
||||
check-name:
|
||||
description: "Name shown in the GitHub checks UI"
|
||||
required: true
|
||||
type: string
|
||||
artifact-name:
|
||||
description: "Name for the uploaded artifact"
|
||||
required: true
|
||||
type: string
|
||||
junit-glob:
|
||||
description: "Glob pattern for the JUnit XML report"
|
||||
required: true
|
||||
type: string
|
||||
job-timeout-minutes:
|
||||
description: "Job timeout in minutes"
|
||||
required: false
|
||||
type: number
|
||||
default: 45
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24"
|
||||
PNPM_VERSION: "10"
|
||||
E2E_DATABASE_URL: postgres://projectx:projectx@localhost:5432/projectx_e2e
|
||||
NODE_ENV: test
|
||||
JWT_SECRET: scanner-e2e-secret-1234
|
||||
JWT_EXPIRES_IN: 15m
|
||||
JWT_REFRESH_EXPIRES_IN: 7d
|
||||
BOOKS_PATH: ../local/data
|
||||
APP_URL: http://localhost:5173
|
||||
|
||||
jobs:
|
||||
run-suite:
|
||||
name: ${{ inputs.check-name }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: ${{ inputs.job-timeout-minutes }}
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: projectx
|
||||
POSTGRES_PASSWORD: projectx
|
||||
POSTGRES_DB: projectx
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd="pg_isready -U projectx"
|
||||
--health-interval=5s
|
||||
--health-timeout=5s
|
||||
--health-retries=20
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Set up Node.js and pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
|
||||
- name: Run e2e suite
|
||||
shell: bash
|
||||
run: pnpm run ${{ inputs.suite-script }}
|
||||
|
||||
- name: Publish test report annotations
|
||||
if: always()
|
||||
uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2
|
||||
with:
|
||||
check_name: ${{ inputs.check-name }}
|
||||
files: ${{ inputs.junit-glob }}
|
||||
fail_on: nothing
|
||||
comment_mode: off
|
||||
|
||||
- name: Upload e2e artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: ${{ inputs.artifact-name }}
|
||||
path: test-results/server
|
||||
retention-days: 7
|
||||
@@ -5,6 +5,22 @@ on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 4 * * *"
|
||||
push:
|
||||
paths:
|
||||
- "server/src/modules/scanner/**"
|
||||
- "server/test/scanner*"
|
||||
- "server/test/e2e/**"
|
||||
- "server/src/db/schema/**"
|
||||
- "scripts/test/**"
|
||||
- "scripts/db/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- "server/src/modules/scanner/**"
|
||||
- "server/test/scanner*"
|
||||
- "server/test/e2e/**"
|
||||
- "server/src/db/schema/**"
|
||||
- "scripts/test/**"
|
||||
- "scripts/db/**"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -14,64 +30,12 @@ permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24"
|
||||
PNPM_VERSION: "10"
|
||||
E2E_DATABASE_URL: postgres://projectx:projectx@localhost:5432/projectx_e2e
|
||||
NODE_ENV: test
|
||||
JWT_SECRET: scanner-e2e-secret-1234
|
||||
JWT_EXPIRES_IN: 15m
|
||||
JWT_REFRESH_EXPIRES_IN: 7d
|
||||
BOOKS_PATH: ../local/data
|
||||
APP_URL: http://localhost:5173
|
||||
|
||||
jobs:
|
||||
scanner-file-ops-e2e:
|
||||
name: Scanner File Ops E2E
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: projectx
|
||||
POSTGRES_PASSWORD: projectx
|
||||
POSTGRES_DB: projectx
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd="pg_isready -U projectx"
|
||||
--health-interval=5s
|
||||
--health-timeout=5s
|
||||
--health-retries=20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Set up Node.js and pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
uses: ./.github/workflows/e2e-runner.yml
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
|
||||
- name: Run scanner file ops e2e suite
|
||||
shell: bash
|
||||
run: pnpm run test:e2e:scanner:file-ops
|
||||
|
||||
- name: Publish test report annotations
|
||||
if: always()
|
||||
uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2
|
||||
with:
|
||||
check_name: Server Scanner File Ops E2E
|
||||
files: test-results/server/scanner-file-ops-e2e-junit.xml
|
||||
fail_on: nothing
|
||||
comment_mode: off
|
||||
|
||||
- name: Upload scanner file ops e2e artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: scanner-file-ops-e2e-report
|
||||
path: test-results/server
|
||||
retention-days: 7
|
||||
suite-script: test:e2e:scanner:file-ops
|
||||
check-name: Server Scanner File Ops E2E
|
||||
artifact-name: scanner-file-ops-e2e-report
|
||||
junit-glob: test-results/server/scanner-file-ops-e2e-junit.xml
|
||||
job-timeout-minutes: 60
|
||||
|
||||
@@ -3,6 +3,22 @@ run-name: ${{ format('Scanner E2E - {0}', github.ref_name) }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- "server/src/modules/scanner/**"
|
||||
- "server/test/scanner*"
|
||||
- "server/test/e2e/**"
|
||||
- "server/src/db/schema/**"
|
||||
- "scripts/test/**"
|
||||
- "scripts/db/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- "server/src/modules/scanner/**"
|
||||
- "server/test/scanner*"
|
||||
- "server/test/e2e/**"
|
||||
- "server/src/db/schema/**"
|
||||
- "scripts/test/**"
|
||||
- "scripts/db/**"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -12,64 +28,12 @@ permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24"
|
||||
PNPM_VERSION: "10"
|
||||
E2E_DATABASE_URL: postgres://projectx:projectx@localhost:5432/projectx_e2e
|
||||
NODE_ENV: test
|
||||
JWT_SECRET: scanner-e2e-secret-1234
|
||||
JWT_EXPIRES_IN: 15m
|
||||
JWT_REFRESH_EXPIRES_IN: 7d
|
||||
BOOKS_PATH: ../local/data
|
||||
APP_URL: http://localhost:5173
|
||||
|
||||
jobs:
|
||||
scanner-e2e:
|
||||
name: Scanner E2E
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: projectx
|
||||
POSTGRES_PASSWORD: projectx
|
||||
POSTGRES_DB: projectx
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd="pg_isready -U projectx"
|
||||
--health-interval=5s
|
||||
--health-timeout=5s
|
||||
--health-retries=20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Set up Node.js and pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
uses: ./.github/workflows/e2e-runner.yml
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
|
||||
- name: Run scanner e2e suite
|
||||
shell: bash
|
||||
run: pnpm run test:e2e:scanner
|
||||
|
||||
- name: Publish test report annotations
|
||||
if: always()
|
||||
uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2
|
||||
with:
|
||||
check_name: Server Scanner E2E
|
||||
files: test-results/server/scanner-e2e-junit.xml
|
||||
fail_on: nothing
|
||||
comment_mode: off
|
||||
|
||||
- name: Upload scanner e2e artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: scanner-e2e-report
|
||||
path: test-results/server
|
||||
retention-days: 7
|
||||
suite-script: test:e2e:scanner
|
||||
check-name: Server Scanner E2E
|
||||
artifact-name: scanner-e2e-report
|
||||
junit-glob: test-results/server/scanner-e2e-junit.xml
|
||||
job-timeout-minutes: 45
|
||||
|
||||
@@ -26,5 +26,8 @@ local/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Test results (generated artifacts - uploaded to CI, not committed)
|
||||
test-results/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
||||
+2
-2
@@ -33,8 +33,8 @@
|
||||
"db:reset": "bash ./scripts/db/reset.sh",
|
||||
"db:seed": "bash ./scripts/db/seed.sh",
|
||||
"db:prepare:e2e": "bash ./scripts/db/prepare-e2e.sh",
|
||||
"test:e2e:scanner": "bash ./scripts/test/scanner-e2e.sh",
|
||||
"test:e2e:scanner:file-ops": "bash ./scripts/test/scanner-file-ops-e2e.sh",
|
||||
"test:e2e:scanner": "bash ./scripts/test/run-e2e-suite.sh test:e2e:scanner",
|
||||
"test:e2e:scanner:file-ops": "bash ./scripts/test/run-e2e-suite.sh test:e2e:scanner:file-ops",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Usage: run-e2e-suite.sh <server-script-name> [extra vitest args...]
|
||||
#
|
||||
# Example:
|
||||
# bash scripts/test/run-e2e-suite.sh test:e2e:scanner
|
||||
# bash scripts/test/run-e2e-suite.sh test:e2e:scanner -- --testNamePattern="book-per-folder-disc-folder-flattening"
|
||||
# bash scripts/test/run-e2e-suite.sh test:e2e:scanner:file-ops
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Usage: $0 <server-script-name> [extra vitest args...]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SUITE_SCRIPT="$1"
|
||||
shift
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
E2E_DATABASE_URL="${E2E_DATABASE_URL:-${DATABASE_URL:-postgres://projectx:projectx@localhost:5432/projectx_e2e}}"
|
||||
|
||||
JUNIT_DIR="$ROOT_DIR/test-results/server"
|
||||
mkdir -p "$JUNIT_DIR"
|
||||
|
||||
if [[ "${CI:-}" != "true" ]]; then
|
||||
echo "Starting local PostgreSQL (dev compose)..."
|
||||
pnpm run db:up
|
||||
fi
|
||||
|
||||
echo "Resetting and migrating dedicated e2e database..."
|
||||
E2E_DATABASE_URL="$E2E_DATABASE_URL" pnpm run db:prepare:e2e
|
||||
|
||||
echo "Running e2e suite: $SUITE_SCRIPT..."
|
||||
DATABASE_URL="$E2E_DATABASE_URL" pnpm --filter server "$SUITE_SCRIPT" "$@"
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
E2E_DATABASE_URL="${E2E_DATABASE_URL:-${DATABASE_URL:-postgres://projectx:projectx@localhost:5432/projectx_e2e}}"
|
||||
JUNIT_OUTPUT="${JUNIT_OUTPUT:-$ROOT_DIR/test-results/server/scanner-e2e-junit.xml}"
|
||||
|
||||
if [[ "${CI:-}" != "true" ]]; then
|
||||
echo "Starting local PostgreSQL (dev compose)..."
|
||||
pnpm run db:up
|
||||
fi
|
||||
|
||||
echo "Resetting and migrating dedicated e2e database..."
|
||||
E2E_DATABASE_URL="$E2E_DATABASE_URL" pnpm run db:prepare:e2e
|
||||
|
||||
mkdir -p "$(dirname "$JUNIT_OUTPUT")"
|
||||
|
||||
echo "Running scanner e2e suite..."
|
||||
DATABASE_URL="$E2E_DATABASE_URL" JUNIT_OUTPUT="$JUNIT_OUTPUT" pnpm --filter server test:e2e:scanner
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
E2E_DATABASE_URL="${E2E_DATABASE_URL:-${DATABASE_URL:-postgres://projectx:projectx@localhost:5432/projectx_e2e}}"
|
||||
JUNIT_OUTPUT="${JUNIT_OUTPUT:-$ROOT_DIR/test-results/server/scanner-file-ops-e2e-junit.xml}"
|
||||
|
||||
if [[ "${CI:-}" != "true" ]]; then
|
||||
echo "Starting local PostgreSQL (dev compose)..."
|
||||
pnpm run db:up
|
||||
fi
|
||||
|
||||
echo "Resetting and migrating dedicated e2e database..."
|
||||
E2E_DATABASE_URL="$E2E_DATABASE_URL" pnpm run db:prepare:e2e
|
||||
|
||||
mkdir -p "$(dirname "$JUNIT_OUTPUT")"
|
||||
|
||||
echo "Running scanner file operations e2e suite..."
|
||||
DATABASE_URL="$E2E_DATABASE_URL" JUNIT_OUTPUT="$JUNIT_OUTPUT" pnpm --filter server test:e2e:scanner:file-ops
|
||||
@@ -16,7 +16,8 @@ import { classifyFile } from './lib/classify';
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
type EventType = 'delete' | 'create';
|
||||
|
||||
const DEBOUNCE_MS = 500;
|
||||
export const WATCHER_DEBOUNCE_MS = 500;
|
||||
const DEBOUNCE_MS = WATCHER_DEBOUNCE_MS;
|
||||
const SCAN_DEBOUNCE_MS = 3_000;
|
||||
const DIR_CREATE_SUPPRESS_MS = 6_000;
|
||||
const CROSS_LIBRARY_RESCAN_DEBOUNCE_MS = 1_500;
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { expect } from 'vitest';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { count, eq, isNull, sql } from 'drizzle-orm';
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { DEFAULT_FORMAT_PRIORITY } from '@projectx/types';
|
||||
|
||||
import { AppModule } from '../../src/app.module';
|
||||
import { DB } from '../../src/db';
|
||||
import * as schema from '../../src/db/schema';
|
||||
import { books, bookFiles, libraries, libraryFolders, scanJobs } from '../../src/db/schema';
|
||||
import { MetadataService } from '../../src/modules/metadata/metadata.service';
|
||||
|
||||
export type Db = NodePgDatabase<typeof schema>;
|
||||
type OrganizationMode = 'book_per_file' | 'book_per_folder';
|
||||
|
||||
const ADMIN_SETUP_DTO = {
|
||||
username: 'scanner-e2e-admin',
|
||||
name: 'Scanner E2E Admin',
|
||||
email: 'scanner-e2e-admin@example.com',
|
||||
password: 'ScannerE2E123',
|
||||
};
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function makeMetadataNoopMock(): Pick<
|
||||
MetadataService,
|
||||
'extractAndSave' | 'refreshCoverForBook' | 'extractAudioFileDuration' | 'aggregateAudioDuration' | 'extractAudioChaptersAndNarrators'
|
||||
> {
|
||||
return {
|
||||
extractAndSave: () => Promise.resolve(undefined),
|
||||
refreshCoverForBook: () => Promise.resolve(false),
|
||||
extractAudioFileDuration: () => Promise.resolve(undefined),
|
||||
aggregateAudioDuration: () => Promise.resolve(undefined),
|
||||
extractAudioChaptersAndNarrators: () => Promise.resolve(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
async function getAdminToken(app: NestFastifyApplication): Promise<string> {
|
||||
const setupResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/setup',
|
||||
payload: ADMIN_SETUP_DTO,
|
||||
});
|
||||
|
||||
if (setupResponse.statusCode === 201) {
|
||||
const body = setupResponse.json() as { accessToken?: string };
|
||||
if (!body.accessToken) throw new Error('Setup succeeded but accessToken was missing');
|
||||
return body.accessToken;
|
||||
}
|
||||
|
||||
if (setupResponse.statusCode === 409) {
|
||||
throw new Error('Initial setup already completed. Use the dedicated e2e database reset command before running e2e tests.');
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected setup response: ${setupResponse.statusCode} ${setupResponse.body}`);
|
||||
}
|
||||
|
||||
export interface E2EContext {
|
||||
app: NestFastifyApplication;
|
||||
db: Db;
|
||||
adminToken: string;
|
||||
}
|
||||
|
||||
export async function createE2EContext(): Promise<E2EContext> {
|
||||
const moduleFixture = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
})
|
||||
.overrideProvider(MetadataService)
|
||||
.useValue(makeMetadataNoopMock())
|
||||
.compile();
|
||||
|
||||
const app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
app.setGlobalPrefix('api/v1');
|
||||
await app.register(fastifyCookie as never);
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
|
||||
const db = app.get<Db>(DB);
|
||||
const adminToken = await getAdminToken(app);
|
||||
return { app, db, adminToken };
|
||||
}
|
||||
|
||||
export async function closeE2EContext(ctx: E2EContext): Promise<void> {
|
||||
await ctx.app.close();
|
||||
}
|
||||
|
||||
export interface SeedLibraryInput {
|
||||
rootPath: string;
|
||||
mode: OrganizationMode;
|
||||
allowedFormats?: string[];
|
||||
excludePatterns?: string[];
|
||||
watch?: boolean;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export async function seedLibrary(db: Db, input: SeedLibraryInput): Promise<{ libraryId: number; libraryFolderId: number }> {
|
||||
const [library] = await db
|
||||
.insert(libraries)
|
||||
.values({
|
||||
name: input.name ?? `e2e-${input.mode}-${randomUUID()}`,
|
||||
watch: input.watch ?? false,
|
||||
organizationMode: input.mode,
|
||||
allowedFormats: input.allowedFormats ?? [],
|
||||
excludePatterns: input.excludePatterns ?? [],
|
||||
formatPriority: [...DEFAULT_FORMAT_PRIORITY],
|
||||
})
|
||||
.returning({ id: libraries.id });
|
||||
|
||||
const [libraryFolder] = await db
|
||||
.insert(libraryFolders)
|
||||
.values({
|
||||
libraryId: library.id,
|
||||
path: input.rootPath,
|
||||
})
|
||||
.returning({ id: libraryFolders.id });
|
||||
|
||||
return { libraryId: library.id, libraryFolderId: libraryFolder.id };
|
||||
}
|
||||
|
||||
export async function triggerLibraryScan(ctx: E2EContext, libraryId: number): Promise<number> {
|
||||
const response = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/scanner/libraries/${libraryId}/scan`,
|
||||
headers: { authorization: `Bearer ${ctx.adminToken}` },
|
||||
});
|
||||
|
||||
if (response.statusCode !== 202) {
|
||||
throw new Error(`Scan endpoint failed: ${response.statusCode} ${response.body}`);
|
||||
}
|
||||
|
||||
const body = response.json() as { jobId?: number };
|
||||
if (!body.jobId) {
|
||||
throw new Error(`Scan endpoint returned no jobId: ${response.body}`);
|
||||
}
|
||||
return body.jobId;
|
||||
}
|
||||
|
||||
export async function triggerAndWaitForLibraryScan(ctx: E2EContext, libraryId: number, timeoutMs = 30_000): Promise<typeof scanJobs.$inferSelect> {
|
||||
const jobId = await triggerLibraryScan(ctx, libraryId);
|
||||
return waitForScanCompletion(ctx.db, jobId, timeoutMs);
|
||||
}
|
||||
|
||||
export async function waitForScanCompletion(db: Db, jobId: number, timeoutMs = 30_000, pollMs = 100): Promise<typeof scanJobs.$inferSelect> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const [job] = await db.select().from(scanJobs).where(eq(scanJobs.id, jobId)).limit(1);
|
||||
if (!job) throw new Error(`Scan job ${jobId} not found`);
|
||||
|
||||
if (job.status === 'completed') return job;
|
||||
if (job.status === 'failed') {
|
||||
throw new Error(`Scan job ${jobId} failed: ${job.errorMessage ?? 'Unknown error'}`);
|
||||
}
|
||||
|
||||
await sleep(pollMs);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for scan job ${jobId} to complete`);
|
||||
}
|
||||
|
||||
export interface LibraryBookState {
|
||||
folderPath: string;
|
||||
status: string;
|
||||
primaryPath: string | null;
|
||||
filePaths: string[];
|
||||
}
|
||||
|
||||
export async function loadLibraryBookState(db: Db, libraryId: number): Promise<LibraryBookState[]> {
|
||||
const bookRows = await db
|
||||
.select({
|
||||
id: books.id,
|
||||
folderPath: books.folderPath,
|
||||
status: books.status,
|
||||
primaryFileId: books.primaryFileId,
|
||||
})
|
||||
.from(books)
|
||||
.where(eq(books.libraryId, libraryId));
|
||||
|
||||
const states: LibraryBookState[] = [];
|
||||
|
||||
for (const row of bookRows) {
|
||||
const fileRows = await db.select({ absolutePath: bookFiles.absolutePath }).from(bookFiles).where(eq(bookFiles.bookId, row.id));
|
||||
|
||||
let primaryPath: string | null = null;
|
||||
if (row.primaryFileId !== null) {
|
||||
const [primary] = await db.select({ absolutePath: bookFiles.absolutePath }).from(bookFiles).where(eq(bookFiles.id, row.primaryFileId)).limit(1);
|
||||
primaryPath = primary?.absolutePath ?? null;
|
||||
}
|
||||
|
||||
states.push({
|
||||
folderPath: row.folderPath,
|
||||
status: row.status,
|
||||
primaryPath,
|
||||
filePaths: fileRows.map((fileRow) => fileRow.absolutePath).sort(),
|
||||
});
|
||||
}
|
||||
|
||||
return states.sort((a, b) => a.folderPath.localeCompare(b.folderPath));
|
||||
}
|
||||
|
||||
export interface IntegritySnapshot {
|
||||
orphanBookMetadata: number;
|
||||
orphanBookFiles: number;
|
||||
orphanBookAuthors: number;
|
||||
orphanBookGenres: number;
|
||||
orphanBookTags: number;
|
||||
orphanBookNarrators: number;
|
||||
orphanCollectionBooks: number;
|
||||
orphanUserBookStatus: number;
|
||||
orphanReadingProgress: number;
|
||||
orphanReadingSessions: number;
|
||||
orphanAudiobookProgressBook: number;
|
||||
orphanAudiobookProgressFile: number;
|
||||
orphanBookmarks: number;
|
||||
orphanAnnotations: number;
|
||||
orphanReaderPreferences: number;
|
||||
booksWithoutMetadata: number;
|
||||
invalidPrimaryFileRef: number;
|
||||
duplicatePresentFilePathPerLibrary: number;
|
||||
}
|
||||
|
||||
export async function loadIntegritySnapshot(db: Db): Promise<IntegritySnapshot> {
|
||||
const [orphanBookMetadata] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.bookMetadata)
|
||||
.leftJoin(schema.books, eq(schema.bookMetadata.bookId, schema.books.id))
|
||||
.where(isNull(schema.books.id));
|
||||
|
||||
const [orphanBookFiles] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.bookFiles)
|
||||
.leftJoin(schema.books, eq(schema.bookFiles.bookId, schema.books.id))
|
||||
.where(isNull(schema.books.id));
|
||||
|
||||
const [orphanBookAuthors] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.bookAuthors)
|
||||
.leftJoin(schema.books, eq(schema.bookAuthors.bookId, schema.books.id))
|
||||
.where(isNull(schema.books.id));
|
||||
|
||||
const [orphanBookGenres] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.bookGenres)
|
||||
.leftJoin(schema.books, eq(schema.bookGenres.bookId, schema.books.id))
|
||||
.where(isNull(schema.books.id));
|
||||
|
||||
const [orphanBookTags] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.bookTags)
|
||||
.leftJoin(schema.books, eq(schema.bookTags.bookId, schema.books.id))
|
||||
.where(isNull(schema.books.id));
|
||||
|
||||
const [orphanBookNarrators] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.bookNarrators)
|
||||
.leftJoin(schema.books, eq(schema.bookNarrators.bookId, schema.books.id))
|
||||
.where(isNull(schema.books.id));
|
||||
|
||||
const [orphanCollectionBooks] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.collectionBooks)
|
||||
.leftJoin(schema.books, eq(schema.collectionBooks.bookId, schema.books.id))
|
||||
.where(isNull(schema.books.id));
|
||||
|
||||
const [orphanUserBookStatus] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.userBookStatus)
|
||||
.leftJoin(schema.books, eq(schema.userBookStatus.bookId, schema.books.id))
|
||||
.where(isNull(schema.books.id));
|
||||
|
||||
const [orphanReadingProgress] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.readingProgress)
|
||||
.leftJoin(schema.bookFiles, eq(schema.readingProgress.bookFileId, schema.bookFiles.id))
|
||||
.where(isNull(schema.bookFiles.id));
|
||||
|
||||
const [orphanReadingSessions] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.readingSessions)
|
||||
.leftJoin(schema.bookFiles, eq(schema.readingSessions.bookFileId, schema.bookFiles.id))
|
||||
.where(isNull(schema.bookFiles.id));
|
||||
|
||||
const [orphanAudiobookProgressBook] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.audiobookProgress)
|
||||
.leftJoin(schema.books, eq(schema.audiobookProgress.bookId, schema.books.id))
|
||||
.where(isNull(schema.books.id));
|
||||
|
||||
const [orphanAudiobookProgressFile] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.audiobookProgress)
|
||||
.leftJoin(schema.bookFiles, eq(schema.audiobookProgress.currentFileId, schema.bookFiles.id))
|
||||
.where(isNull(schema.bookFiles.id));
|
||||
|
||||
const [orphanBookmarks] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.bookmarks)
|
||||
.leftJoin(schema.books, eq(schema.bookmarks.bookId, schema.books.id))
|
||||
.where(isNull(schema.books.id));
|
||||
|
||||
const [orphanAnnotations] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.annotations)
|
||||
.leftJoin(schema.books, eq(schema.annotations.bookId, schema.books.id))
|
||||
.where(isNull(schema.books.id));
|
||||
|
||||
const [orphanReaderPreferences] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.readerPreferences)
|
||||
.leftJoin(schema.bookFiles, eq(schema.readerPreferences.bookFileId, schema.bookFiles.id))
|
||||
.where(isNull(schema.bookFiles.id));
|
||||
|
||||
const [booksWithoutMetadata] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.books)
|
||||
.leftJoin(schema.bookMetadata, eq(schema.bookMetadata.bookId, schema.books.id))
|
||||
.where(isNull(schema.bookMetadata.bookId));
|
||||
|
||||
const invalidPrimaryRows = await db
|
||||
.select({ bookId: schema.books.id })
|
||||
.from(schema.books)
|
||||
.leftJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.books.primaryFileId))
|
||||
.where(
|
||||
sql`${schema.books.status} = 'present' AND ${schema.books.primaryFileId} IS NOT NULL AND (${schema.bookFiles.id} IS NULL OR ${schema.bookFiles.bookId} <> ${schema.books.id})`,
|
||||
);
|
||||
|
||||
const duplicatePresentRows = await db
|
||||
.select({
|
||||
libraryId: schema.books.libraryId,
|
||||
absolutePath: schema.bookFiles.absolutePath,
|
||||
fileCount: sql<number>`count(*)`,
|
||||
})
|
||||
.from(schema.bookFiles)
|
||||
.innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId))
|
||||
.where(eq(schema.books.status, 'present'))
|
||||
.groupBy(schema.books.libraryId, schema.bookFiles.absolutePath)
|
||||
.having(sql`count(*) > 1`);
|
||||
|
||||
return {
|
||||
orphanBookMetadata: Number(orphanBookMetadata.value),
|
||||
orphanBookFiles: Number(orphanBookFiles.value),
|
||||
orphanBookAuthors: Number(orphanBookAuthors.value),
|
||||
orphanBookGenres: Number(orphanBookGenres.value),
|
||||
orphanBookTags: Number(orphanBookTags.value),
|
||||
orphanBookNarrators: Number(orphanBookNarrators.value),
|
||||
orphanCollectionBooks: Number(orphanCollectionBooks.value),
|
||||
orphanUserBookStatus: Number(orphanUserBookStatus.value),
|
||||
orphanReadingProgress: Number(orphanReadingProgress.value),
|
||||
orphanReadingSessions: Number(orphanReadingSessions.value),
|
||||
orphanAudiobookProgressBook: Number(orphanAudiobookProgressBook.value),
|
||||
orphanAudiobookProgressFile: Number(orphanAudiobookProgressFile.value),
|
||||
orphanBookmarks: Number(orphanBookmarks.value),
|
||||
orphanAnnotations: Number(orphanAnnotations.value),
|
||||
orphanReaderPreferences: Number(orphanReaderPreferences.value),
|
||||
booksWithoutMetadata: Number(booksWithoutMetadata.value),
|
||||
invalidPrimaryFileRef: invalidPrimaryRows.length,
|
||||
duplicatePresentFilePathPerLibrary: duplicatePresentRows.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function assertNoIntegrityViolations(db: Db): Promise<void> {
|
||||
const snapshot = await loadIntegritySnapshot(db);
|
||||
for (const [key, value] of Object.entries(snapshot)) {
|
||||
expect(value, `integrity violation: ${key}`).toBe(0);
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForCondition(check: () => Promise<void>, timeoutMs = 20_000, pollMs = 200): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown = null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await check();
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
await sleep(pollMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for condition: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { DEFAULT_FORMAT_PRIORITY } from '@projectx/types';
|
||||
|
||||
import { AppModule } from '../../src/app.module';
|
||||
import { DB } from '../../src/db';
|
||||
import * as schema from '../../src/db/schema';
|
||||
import { bookFiles, books, libraries, libraryFolders, scanJobs } from '../../src/db/schema';
|
||||
import { MetadataService } from '../../src/modules/metadata/metadata.service';
|
||||
import { FileWatcherService } from '../../src/modules/scanner/file-watcher.service';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
type OrganizationMode = 'book_per_file' | 'book_per_folder';
|
||||
|
||||
const ADMIN_SETUP_DTO = {
|
||||
username: 'scanner-e2e-admin',
|
||||
name: 'Scanner E2E Admin',
|
||||
email: 'scanner-e2e-admin@example.com',
|
||||
password: 'ScannerE2E123',
|
||||
};
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function makeMetadataNoopMock(): Pick<
|
||||
MetadataService,
|
||||
'extractAndSave' | 'refreshCoverForBook' | 'extractAudioFileDuration' | 'aggregateAudioDuration' | 'extractAudioChaptersAndNarrators'
|
||||
> {
|
||||
return {
|
||||
extractAndSave: () => Promise.resolve(undefined),
|
||||
refreshCoverForBook: () => Promise.resolve(false),
|
||||
extractAudioFileDuration: () => Promise.resolve(undefined),
|
||||
aggregateAudioDuration: () => Promise.resolve(undefined),
|
||||
extractAudioChaptersAndNarrators: () => Promise.resolve(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
async function getAdminToken(app: NestFastifyApplication): Promise<string> {
|
||||
const setupResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/setup',
|
||||
payload: ADMIN_SETUP_DTO,
|
||||
});
|
||||
|
||||
if (setupResponse.statusCode === 201) {
|
||||
const body = setupResponse.json() as { accessToken?: string };
|
||||
if (!body.accessToken) throw new Error('Setup succeeded but accessToken was missing');
|
||||
return body.accessToken;
|
||||
}
|
||||
|
||||
if (setupResponse.statusCode === 409) {
|
||||
throw new Error('Initial setup already completed. Use the dedicated e2e database reset command before running scanner e2e tests.');
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected setup response: ${setupResponse.statusCode} ${setupResponse.body}`);
|
||||
}
|
||||
|
||||
export interface ScannerE2EContext {
|
||||
app: NestFastifyApplication;
|
||||
db: Db;
|
||||
adminToken: string;
|
||||
}
|
||||
|
||||
export async function createScannerE2EContext(): Promise<ScannerE2EContext> {
|
||||
const moduleFixture = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
})
|
||||
.overrideProvider(MetadataService)
|
||||
.useValue(makeMetadataNoopMock())
|
||||
.compile();
|
||||
|
||||
const app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
app.setGlobalPrefix('api/v1');
|
||||
await app.register(fastifyCookie as never);
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
|
||||
const db = app.get<Db>(DB);
|
||||
const adminToken = await getAdminToken(app);
|
||||
return { app, db, adminToken };
|
||||
}
|
||||
|
||||
export async function closeScannerE2EContext(ctx: ScannerE2EContext): Promise<void> {
|
||||
await ctx.app.close();
|
||||
}
|
||||
|
||||
export interface SeedLibraryInput {
|
||||
rootPath: string;
|
||||
mode: OrganizationMode;
|
||||
allowedFormats?: string[];
|
||||
excludePatterns?: string[];
|
||||
watch?: boolean;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export async function seedLibrary(db: Db, input: SeedLibraryInput): Promise<{ libraryId: number; libraryFolderId: number }> {
|
||||
const [library] = await db
|
||||
.insert(libraries)
|
||||
.values({
|
||||
name: input.name ?? `scanner-e2e-${input.mode}-${randomUUID()}`,
|
||||
watch: input.watch ?? false,
|
||||
organizationMode: input.mode,
|
||||
allowedFormats: input.allowedFormats ?? [],
|
||||
excludePatterns: input.excludePatterns ?? [],
|
||||
formatPriority: [...DEFAULT_FORMAT_PRIORITY],
|
||||
})
|
||||
.returning({ id: libraries.id });
|
||||
|
||||
const [libraryFolder] = await db
|
||||
.insert(libraryFolders)
|
||||
.values({
|
||||
libraryId: library.id,
|
||||
path: input.rootPath,
|
||||
})
|
||||
.returning({ id: libraryFolders.id });
|
||||
|
||||
return { libraryId: library.id, libraryFolderId: libraryFolder.id };
|
||||
}
|
||||
|
||||
export async function triggerLibraryScan(ctx: ScannerE2EContext, libraryId: number): Promise<number> {
|
||||
const response = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/scanner/libraries/${libraryId}/scan`,
|
||||
headers: { authorization: `Bearer ${ctx.adminToken}` },
|
||||
});
|
||||
|
||||
if (response.statusCode !== 202) {
|
||||
throw new Error(`Scan endpoint failed: ${response.statusCode} ${response.body}`);
|
||||
}
|
||||
|
||||
const body = response.json() as { jobId?: number };
|
||||
if (!body.jobId) {
|
||||
throw new Error(`Scan endpoint returned no jobId: ${response.body}`);
|
||||
}
|
||||
return body.jobId;
|
||||
}
|
||||
|
||||
export async function triggerAndWaitForLibraryScan(
|
||||
ctx: ScannerE2EContext,
|
||||
libraryId: number,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<typeof scanJobs.$inferSelect> {
|
||||
const jobId = await triggerLibraryScan(ctx, libraryId);
|
||||
return waitForScanCompletion(ctx.db, jobId, timeoutMs);
|
||||
}
|
||||
|
||||
export async function startLibraryWatcher(ctx: ScannerE2EContext, libraryId: number, paths: string[]): Promise<void> {
|
||||
const watcher = ctx.app.get(FileWatcherService);
|
||||
await watcher.startWatcher(libraryId, paths);
|
||||
}
|
||||
|
||||
export async function stopLibraryWatcher(ctx: ScannerE2EContext, libraryId: number): Promise<void> {
|
||||
const watcher = ctx.app.get(FileWatcherService);
|
||||
await watcher.stopWatcher(libraryId);
|
||||
}
|
||||
|
||||
export async function waitForScanCompletion(db: Db, jobId: number, timeoutMs = 30_000, pollMs = 100): Promise<typeof scanJobs.$inferSelect> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const [job] = await db.select().from(scanJobs).where(eq(scanJobs.id, jobId)).limit(1);
|
||||
if (!job) throw new Error(`Scan job ${jobId} not found`);
|
||||
|
||||
if (job.status === 'completed') return job;
|
||||
if (job.status === 'failed') {
|
||||
throw new Error(`Scan job ${jobId} failed: ${job.errorMessage ?? 'Unknown error'}`);
|
||||
}
|
||||
|
||||
await sleep(pollMs);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for scan job ${jobId} to complete`);
|
||||
}
|
||||
|
||||
export interface LibraryBookState {
|
||||
folderPath: string;
|
||||
status: string;
|
||||
primaryPath: string | null;
|
||||
filePaths: string[];
|
||||
}
|
||||
|
||||
export async function loadLibraryBookState(db: Db, libraryId: number): Promise<LibraryBookState[]> {
|
||||
const bookRows = await db
|
||||
.select({
|
||||
id: books.id,
|
||||
folderPath: books.folderPath,
|
||||
status: books.status,
|
||||
primaryFileId: books.primaryFileId,
|
||||
})
|
||||
.from(books)
|
||||
.where(eq(books.libraryId, libraryId));
|
||||
|
||||
const states: LibraryBookState[] = [];
|
||||
|
||||
for (const row of bookRows) {
|
||||
const fileRows = await db.select({ absolutePath: bookFiles.absolutePath }).from(bookFiles).where(eq(bookFiles.bookId, row.id));
|
||||
|
||||
let primaryPath: string | null = null;
|
||||
if (row.primaryFileId !== null) {
|
||||
const [primary] = await db.select({ absolutePath: bookFiles.absolutePath }).from(bookFiles).where(eq(bookFiles.id, row.primaryFileId)).limit(1);
|
||||
primaryPath = primary?.absolutePath ?? null;
|
||||
}
|
||||
|
||||
states.push({
|
||||
folderPath: row.folderPath,
|
||||
status: row.status,
|
||||
primaryPath,
|
||||
filePaths: fileRows.map((fileRow) => fileRow.absolutePath).sort(),
|
||||
});
|
||||
}
|
||||
|
||||
return states.sort((a, b) => a.folderPath.localeCompare(b.folderPath));
|
||||
}
|
||||
|
||||
export async function waitForCondition(check: () => Promise<void>, timeoutMs = 20_000, pollMs = 200): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown = null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await check();
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
await sleep(pollMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for condition: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export {
|
||||
assertNoIntegrityViolations,
|
||||
closeE2EContext as closeScannerE2EContext,
|
||||
createE2EContext as createScannerE2EContext,
|
||||
loadLibraryBookState,
|
||||
seedLibrary,
|
||||
triggerAndWaitForLibraryScan,
|
||||
triggerLibraryScan,
|
||||
waitForCondition,
|
||||
waitForScanCompletion,
|
||||
type E2EContext as ScannerE2EContext,
|
||||
type LibraryBookState,
|
||||
type SeedLibraryInput,
|
||||
} from '../app-harness';
|
||||
|
||||
import { FileWatcherService } from '../../../src/modules/scanner/file-watcher.service';
|
||||
import type { E2EContext } from '../app-harness';
|
||||
|
||||
export async function startLibraryWatcher(ctx: E2EContext, libraryId: number, paths: string[]): Promise<void> {
|
||||
const watcher = ctx.app.get(FileWatcherService);
|
||||
await watcher.startWatcher(libraryId, paths);
|
||||
}
|
||||
|
||||
export async function stopLibraryWatcher(ctx: E2EContext, libraryId: number): Promise<void> {
|
||||
const watcher = ctx.app.get(FileWatcherService);
|
||||
await watcher.stopWatcher(libraryId);
|
||||
}
|
||||
@@ -6,12 +6,14 @@ import { randomUUID } from 'crypto';
|
||||
import { mkdir, realpath, rename, rm, stat, writeFile } from 'fs/promises';
|
||||
import { dirname, join, relative } from 'path';
|
||||
|
||||
import { and, count, eq, isNull, like, sql } from 'drizzle-orm';
|
||||
import { and, count, eq, like } from 'drizzle-orm';
|
||||
|
||||
import * as schema from '../src/db/schema';
|
||||
import type { FixtureEntry } from './e2e/scanner-fixture-builder';
|
||||
import { createFixtureTree, file } from './e2e/scanner-fixture-builder';
|
||||
import { WATCHER_DEBOUNCE_MS } from '../src/modules/scanner/file-watcher.service';
|
||||
import type { FixtureEntry, FixtureTree } from './e2e/scanner/scanner-fixture-builder';
|
||||
import { createFixtureTree, file } from './e2e/scanner/scanner-fixture-builder';
|
||||
import {
|
||||
assertNoIntegrityViolations,
|
||||
closeScannerE2EContext,
|
||||
createScannerE2EContext,
|
||||
loadLibraryBookState,
|
||||
@@ -22,7 +24,7 @@ import {
|
||||
waitForCondition,
|
||||
type LibraryBookState,
|
||||
type ScannerE2EContext,
|
||||
} from './e2e/scanner-harness';
|
||||
} from './e2e/scanner/scanner-harness';
|
||||
|
||||
type OrganizationMode = 'book_per_file' | 'book_per_folder';
|
||||
type TriggerMode = 'manual' | 'watcher';
|
||||
@@ -76,27 +78,6 @@ interface SeededLibrary {
|
||||
libraryId: number;
|
||||
}
|
||||
|
||||
interface IntegritySnapshot {
|
||||
orphanBookMetadata: number;
|
||||
orphanBookFiles: number;
|
||||
orphanBookAuthors: number;
|
||||
orphanBookGenres: number;
|
||||
orphanBookTags: number;
|
||||
orphanBookNarrators: number;
|
||||
orphanCollectionBooks: number;
|
||||
orphanUserBookStatus: number;
|
||||
orphanReadingProgress: number;
|
||||
orphanReadingSessions: number;
|
||||
orphanAudiobookProgressBook: number;
|
||||
orphanAudiobookProgressFile: number;
|
||||
orphanBookmarks: number;
|
||||
orphanAnnotations: number;
|
||||
orphanReaderPreferences: number;
|
||||
booksWithoutMetadata: number;
|
||||
invalidPrimaryFileRef: number;
|
||||
duplicatePresentFilePathPerLibrary: number;
|
||||
}
|
||||
|
||||
interface StatefulSnapshot {
|
||||
metadataTitle: string | null;
|
||||
metadataPublisher: string | null;
|
||||
@@ -486,6 +467,7 @@ const structuralScenarios: StructuralScenario[] = [
|
||||
a: {
|
||||
statusByFolder: { Shelf: 'present' },
|
||||
fileOwners: { 'Shelf/book.epub': 'Shelf' },
|
||||
presentCount: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -499,6 +481,7 @@ const structuralScenarios: StructuralScenario[] = [
|
||||
a: {
|
||||
statusByFolder: { 'book.epub': 'present' },
|
||||
fileOwners: { 'book.epub': 'book.epub' },
|
||||
presentCount: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -582,7 +565,7 @@ const structuralScenarios: StructuralScenario[] = [
|
||||
entries: [file('lib-a/Track.mp3')],
|
||||
operations: [
|
||||
{ type: 'deleteFile', path: 'lib-a/Track.mp3' },
|
||||
{ type: 'sleep', ms: 900 },
|
||||
{ type: 'sleep', ms: WATCHER_DEBOUNCE_MS + 400 },
|
||||
{ type: 'writeFile', path: 'lib-a/Track.mp3' },
|
||||
],
|
||||
expected: { a: { statusByFolder: { 'Track.mp3': 'present' }, presentCount: 1, missingCount: 0 } },
|
||||
@@ -627,7 +610,7 @@ const structuralScenarios: StructuralScenario[] = [
|
||||
entries: [file('lib-a/Swap/old.epub')],
|
||||
operations: [
|
||||
{ type: 'deleteDir', path: 'lib-a/Swap' },
|
||||
{ type: 'sleep', ms: 900 },
|
||||
{ type: 'sleep', ms: WATCHER_DEBOUNCE_MS + 400 },
|
||||
{ type: 'writeFile', path: 'lib-a/Swap/new.epub' },
|
||||
],
|
||||
expected: {
|
||||
@@ -650,7 +633,7 @@ const structuralScenarios: StructuralScenario[] = [
|
||||
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: 900 },
|
||||
{ type: 'sleep', ms: WATCHER_DEBOUNCE_MS + 400 },
|
||||
{ type: 'writeFile', path: 'lib-a/Book/book.epub' },
|
||||
],
|
||||
expected: {
|
||||
@@ -785,152 +768,6 @@ function assertLibraryExpectation(rootPath: string, actual: LibraryBookState[],
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIntegritySnapshot(db: ScannerE2EContext['db']): Promise<IntegritySnapshot> {
|
||||
const [orphanBookMetadata] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.bookMetadata)
|
||||
.leftJoin(schema.books, eq(schema.bookMetadata.bookId, schema.books.id))
|
||||
.where(isNull(schema.books.id));
|
||||
|
||||
const [orphanBookFiles] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.bookFiles)
|
||||
.leftJoin(schema.books, eq(schema.bookFiles.bookId, schema.books.id))
|
||||
.where(isNull(schema.books.id));
|
||||
|
||||
const [orphanBookAuthors] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.bookAuthors)
|
||||
.leftJoin(schema.books, eq(schema.bookAuthors.bookId, schema.books.id))
|
||||
.where(isNull(schema.books.id));
|
||||
|
||||
const [orphanBookGenres] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.bookGenres)
|
||||
.leftJoin(schema.books, eq(schema.bookGenres.bookId, schema.books.id))
|
||||
.where(isNull(schema.books.id));
|
||||
|
||||
const [orphanBookTags] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.bookTags)
|
||||
.leftJoin(schema.books, eq(schema.bookTags.bookId, schema.books.id))
|
||||
.where(isNull(schema.books.id));
|
||||
|
||||
const [orphanBookNarrators] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.bookNarrators)
|
||||
.leftJoin(schema.books, eq(schema.bookNarrators.bookId, schema.books.id))
|
||||
.where(isNull(schema.books.id));
|
||||
|
||||
const [orphanCollectionBooks] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.collectionBooks)
|
||||
.leftJoin(schema.books, eq(schema.collectionBooks.bookId, schema.books.id))
|
||||
.where(isNull(schema.books.id));
|
||||
|
||||
const [orphanUserBookStatus] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.userBookStatus)
|
||||
.leftJoin(schema.books, eq(schema.userBookStatus.bookId, schema.books.id))
|
||||
.where(isNull(schema.books.id));
|
||||
|
||||
const [orphanReadingProgress] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.readingProgress)
|
||||
.leftJoin(schema.bookFiles, eq(schema.readingProgress.bookFileId, schema.bookFiles.id))
|
||||
.where(isNull(schema.bookFiles.id));
|
||||
|
||||
const [orphanReadingSessions] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.readingSessions)
|
||||
.leftJoin(schema.bookFiles, eq(schema.readingSessions.bookFileId, schema.bookFiles.id))
|
||||
.where(isNull(schema.bookFiles.id));
|
||||
|
||||
const [orphanAudiobookProgressBook] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.audiobookProgress)
|
||||
.leftJoin(schema.books, eq(schema.audiobookProgress.bookId, schema.books.id))
|
||||
.where(isNull(schema.books.id));
|
||||
|
||||
const [orphanAudiobookProgressFile] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.audiobookProgress)
|
||||
.leftJoin(schema.bookFiles, eq(schema.audiobookProgress.currentFileId, schema.bookFiles.id))
|
||||
.where(isNull(schema.bookFiles.id));
|
||||
|
||||
const [orphanBookmarks] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.bookmarks)
|
||||
.leftJoin(schema.books, eq(schema.bookmarks.bookId, schema.books.id))
|
||||
.where(isNull(schema.books.id));
|
||||
|
||||
const [orphanAnnotations] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.annotations)
|
||||
.leftJoin(schema.books, eq(schema.annotations.bookId, schema.books.id))
|
||||
.where(isNull(schema.books.id));
|
||||
|
||||
const [orphanReaderPreferences] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.readerPreferences)
|
||||
.leftJoin(schema.bookFiles, eq(schema.readerPreferences.bookFileId, schema.bookFiles.id))
|
||||
.where(isNull(schema.bookFiles.id));
|
||||
|
||||
const [booksWithoutMetadata] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.books)
|
||||
.leftJoin(schema.bookMetadata, eq(schema.bookMetadata.bookId, schema.books.id))
|
||||
.where(isNull(schema.bookMetadata.bookId));
|
||||
|
||||
const invalidPrimaryRows = await db
|
||||
.select({ bookId: schema.books.id })
|
||||
.from(schema.books)
|
||||
.leftJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.books.primaryFileId))
|
||||
.where(
|
||||
sql`${schema.books.status} = 'present' AND ${schema.books.primaryFileId} IS NOT NULL AND (${schema.bookFiles.id} IS NULL OR ${schema.bookFiles.bookId} <> ${schema.books.id})`,
|
||||
);
|
||||
|
||||
const duplicatePresentRows = await db
|
||||
.select({
|
||||
libraryId: schema.books.libraryId,
|
||||
absolutePath: schema.bookFiles.absolutePath,
|
||||
fileCount: sql<number>`count(*)`,
|
||||
})
|
||||
.from(schema.bookFiles)
|
||||
.innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId))
|
||||
.where(eq(schema.books.status, 'present'))
|
||||
.groupBy(schema.books.libraryId, schema.bookFiles.absolutePath)
|
||||
.having(sql`count(*) > 1`);
|
||||
|
||||
return {
|
||||
orphanBookMetadata: Number(orphanBookMetadata.value),
|
||||
orphanBookFiles: Number(orphanBookFiles.value),
|
||||
orphanBookAuthors: Number(orphanBookAuthors.value),
|
||||
orphanBookGenres: Number(orphanBookGenres.value),
|
||||
orphanBookTags: Number(orphanBookTags.value),
|
||||
orphanBookNarrators: Number(orphanBookNarrators.value),
|
||||
orphanCollectionBooks: Number(orphanCollectionBooks.value),
|
||||
orphanUserBookStatus: Number(orphanUserBookStatus.value),
|
||||
orphanReadingProgress: Number(orphanReadingProgress.value),
|
||||
orphanReadingSessions: Number(orphanReadingSessions.value),
|
||||
orphanAudiobookProgressBook: Number(orphanAudiobookProgressBook.value),
|
||||
orphanAudiobookProgressFile: Number(orphanAudiobookProgressFile.value),
|
||||
orphanBookmarks: Number(orphanBookmarks.value),
|
||||
orphanAnnotations: Number(orphanAnnotations.value),
|
||||
orphanReaderPreferences: Number(orphanReaderPreferences.value),
|
||||
booksWithoutMetadata: Number(booksWithoutMetadata.value),
|
||||
invalidPrimaryFileRef: invalidPrimaryRows.length,
|
||||
duplicatePresentFilePathPerLibrary: duplicatePresentRows.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function assertNoIntegrityViolations(db: ScannerE2EContext['db']): Promise<void> {
|
||||
const snapshot = await loadIntegritySnapshot(db);
|
||||
for (const [key, value] of Object.entries(snapshot)) {
|
||||
expect(value, `integrity violation ${key}`).toBe(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-%'));
|
||||
@@ -1293,61 +1130,42 @@ async function writeScenarioReport(results: ScenarioRunResult[]): Promise<void>
|
||||
);
|
||||
}
|
||||
|
||||
async function runRecordedScenario(scenarioId: string, trigger: TriggerMode, results: ScenarioRunResult[], work: () => Promise<void>): Promise<void> {
|
||||
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 work();
|
||||
results.push({ id: scenarioId, trigger, status: 'passed', durationMs: Date.now() - startedAt });
|
||||
await scenario.run(context, fixture);
|
||||
results.push({ id: scenario.id, trigger: scenario.trigger, status: 'passed', durationMs: Date.now() - startedAt });
|
||||
} catch (err) {
|
||||
results.push({
|
||||
id: scenarioId,
|
||||
trigger,
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
describe('Scanner file operations (e2e)', () => {
|
||||
let context: ScannerE2EContext | null = null;
|
||||
const scenarioResults: ScenarioRunResult[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
context = await createScannerE2EContext();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
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', () => {
|
||||
it(
|
||||
'stateful-manual-delete-primary-preserves-data',
|
||||
async () => {
|
||||
if (!context) throw new Error('E2E context not initialized');
|
||||
|
||||
await runRecordedScenario('stateful-manual-delete-primary-preserves-data', 'manual', scenarioResults, async () => {
|
||||
await resetScannerFileOpsState(context.db);
|
||||
const fixture = await createFixtureTree([file('lib-a/Book/book.epub')], 'scanner-file-ops-stateful-manual-delete-');
|
||||
try {
|
||||
const statefulScenarios: StatefulScenario[] = [
|
||||
{
|
||||
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);
|
||||
@@ -1373,23 +1191,14 @@ describe('Scanner file operations (e2e)', () => {
|
||||
|
||||
await assertStatefulUnchanged(context.db, sourceBook.id, sourceBook.primaryFileId, seeded.snapshot);
|
||||
await assertNoIntegrityViolations(context.db);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
},
|
||||
SCENARIO_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'stateful-manual-cross-library-move-preserves-book-identity',
|
||||
async () => {
|
||||
if (!context) throw new Error('E2E context not initialized');
|
||||
|
||||
await runRecordedScenario('stateful-manual-cross-library-move-preserves-book-identity', 'manual', scenarioResults, async () => {
|
||||
await resetScannerFileOpsState(context.db);
|
||||
const fixture = await createFixtureTree([file('lib-a/Book/book.epub')], 'scanner-file-ops-stateful-cross-lib-manual-');
|
||||
try {
|
||||
},
|
||||
{
|
||||
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 });
|
||||
@@ -1439,25 +1248,14 @@ describe('Scanner file operations (e2e)', () => {
|
||||
|
||||
await assertStatefulUnchanged(context.db, sourceBook.id, sourceBook.primaryFileId, seeded.snapshot);
|
||||
await assertNoIntegrityViolations(context.db);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
},
|
||||
SCENARIO_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'stateful-watcher-cross-library-move-preserves-book-identity',
|
||||
async () => {
|
||||
if (!context) throw new Error('E2E context not initialized');
|
||||
|
||||
await runRecordedScenario('stateful-watcher-cross-library-move-preserves-book-identity', 'watcher', scenarioResults, async () => {
|
||||
await resetScannerFileOpsState(context.db);
|
||||
const fixture = await createFixtureTree([file('lib-a/Book/book.epub')], 'scanner-file-ops-stateful-cross-lib-watcher-');
|
||||
let sourceLibraryId: number | null = null;
|
||||
let destinationLibraryId: number | null = null;
|
||||
try {
|
||||
},
|
||||
{
|
||||
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 });
|
||||
@@ -1465,6 +1263,9 @@ describe('Scanner file operations (e2e)', () => {
|
||||
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',
|
||||
@@ -1510,32 +1311,22 @@ describe('Scanner file operations (e2e)', () => {
|
||||
await assertStatefulUnchanged(context.db, sourceBook.id, sourceBook.primaryFileId, seeded.snapshot);
|
||||
await assertNoIntegrityViolations(context.db);
|
||||
} finally {
|
||||
if (sourceLibraryId != null) {
|
||||
await stopLibraryWatcher(context, sourceLibraryId);
|
||||
if (sourceLibraryId != null) await stopLibraryWatcher(context, sourceLibraryId);
|
||||
if (destinationLibraryId != null) await stopLibraryWatcher(context, destinationLibraryId);
|
||||
}
|
||||
if (destinationLibraryId != null) {
|
||||
await stopLibraryWatcher(context, destinationLibraryId);
|
||||
}
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
},
|
||||
SCENARIO_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'stateful-watcher-rename-folder-preserves-data',
|
||||
async () => {
|
||||
if (!context) throw new Error('E2E context not initialized');
|
||||
|
||||
await runRecordedScenario('stateful-watcher-rename-folder-preserves-data', 'watcher', scenarioResults, async () => {
|
||||
await resetScannerFileOpsState(context.db);
|
||||
const fixture = await createFixtureTree([file('lib-a/Old/book.epub')], 'scanner-file-ops-stateful-watcher-rename-');
|
||||
let libraryId: number | null = null;
|
||||
try {
|
||||
},
|
||||
{
|
||||
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',
|
||||
@@ -1563,29 +1354,21 @@ describe('Scanner file operations (e2e)', () => {
|
||||
await assertStatefulUnchanged(context.db, sourceBook.id, sourceBook.primaryFileId, seeded.snapshot);
|
||||
await assertNoIntegrityViolations(context.db);
|
||||
} finally {
|
||||
if (libraryId != null) {
|
||||
await stopLibraryWatcher(context, libraryId);
|
||||
if (libraryId != null) await stopLibraryWatcher(context, libraryId);
|
||||
}
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
},
|
||||
SCENARIO_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'stateful-watcher-delete-then-restore-preserves-data',
|
||||
async () => {
|
||||
if (!context) throw new Error('E2E context not initialized');
|
||||
|
||||
await runRecordedScenario('stateful-watcher-delete-then-restore-preserves-data', 'watcher', scenarioResults, async () => {
|
||||
await resetScannerFileOpsState(context.db);
|
||||
const fixture = await createFixtureTree([file('lib-a/Book/book.epub')], 'scanner-file-ops-stateful-watcher-delete-restore-');
|
||||
let libraryId: number | null = null;
|
||||
try {
|
||||
},
|
||||
{
|
||||
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',
|
||||
@@ -1620,14 +1403,53 @@ describe('Scanner file operations (e2e)', () => {
|
||||
await assertStatefulUnchanged(context.db, sourceBook.id, sourceBook.primaryFileId, seeded.snapshot);
|
||||
await assertNoIntegrityViolations(context.db);
|
||||
} finally {
|
||||
if (libraryId != null) {
|
||||
await stopLibraryWatcher(context, libraryId);
|
||||
if (libraryId != null) await stopLibraryWatcher(context, libraryId);
|
||||
}
|
||||
await fixture.cleanup();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,9 +5,10 @@ vi.mock('../src/modules/scanner/lib/stability', () => ({
|
||||
import { mkdir, writeFile } from 'fs/promises';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
import type { FixtureEntry } from './e2e/scanner-fixture-builder';
|
||||
import { createFixtureTree, file } from './e2e/scanner-fixture-builder';
|
||||
import type { FixtureEntry } from './e2e/scanner/scanner-fixture-builder';
|
||||
import { createFixtureTree, file } from './e2e/scanner/scanner-fixture-builder';
|
||||
import {
|
||||
assertNoIntegrityViolations,
|
||||
closeScannerE2EContext,
|
||||
createScannerE2EContext,
|
||||
loadLibraryBookState,
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
waitForScanCompletion,
|
||||
type LibraryBookState,
|
||||
type ScannerE2EContext,
|
||||
} from './e2e/scanner-harness';
|
||||
} from './e2e/scanner/scanner-harness';
|
||||
|
||||
type OrganizationMode = 'book_per_file' | 'book_per_folder';
|
||||
|
||||
@@ -390,6 +391,7 @@ async function runScenario(context: ScannerE2EContext, scenario: ScannerScenario
|
||||
|
||||
const actual = await loadLibraryBookState(context.db, libraryId);
|
||||
assertScenarioOutcome(fixture.rootPath, scenario.expected, actual);
|
||||
await assertNoIntegrityViolations(context.db);
|
||||
|
||||
results.push({
|
||||
id: scenario.id,
|
||||
@@ -440,6 +442,9 @@ describe('Scanner structures (e2e)', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (context) {
|
||||
await assertNoIntegrityViolations(context.db);
|
||||
}
|
||||
await writeScenarioReport(scenarioResults);
|
||||
if (context) {
|
||||
await closeScannerE2EContext(context);
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<testsuites name="vitest tests" tests="35" failures="0" errors="0" time="4.7548795">
|
||||
<testsuite name="test/scanner.e2e-spec.ts" timestamp="2026-04-15T03:30:47.710Z" hostname="DC-KJ3DT4LQPW.local" tests="35" failures="0" errors="0" skipped="0" time="4.7548795">
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_folder > book-per-folder-flat-root-file" time="0.121739833">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_folder > book-per-folder-mixed-root-and-folder" time="0.129137875">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_folder > book-per-folder-multi-format-folder-and-root-stray" time="0.128462708">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_folder > book-per-folder-audiobook-multipart-folder" time="0.124288667">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_folder > book-per-folder-disc-folder-flattening" time="0.125028375">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_folder > book-per-folder-ebook-with-stem-audio-subfolder" time="0.12772325">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_folder > book-per-folder-root-stem-audio-not-flattened" time="0.135436417">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_folder > book-per-folder-hidden-files-and-folders-ignored" time="0.126640375">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_folder > book-per-folder-exclude-patterns" time="0.126519708">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_folder > book-per-folder-allowed-formats" time="0.123326667">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_folder > book-per-folder-zero-byte-content-file-skipped" time="0.123150792">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_folder > book-per-folder-no-primary-files" time="0.118748583">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_folder > book-per-folder-deep-nested-book-folder" time="0.122631834">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_folder > book-per-folder-same-filename-different-authors" time="0.126866875">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_folder > book-per-folder-disc-name-variants-flattened" time="0.12586425">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_folder > book-per-folder-stem-subfolder-case-mismatch-no-merge" time="0.126321">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_folder > book-per-folder-stem-subfolder-with-disc-flattening" time="0.122238708">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_folder > book-per-folder-root-multi-format-files-remain-separate" time="0.118935375">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_folder > book-per-folder-multi-book-single-folder-coalesced" time="0.113775166">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_folder > book-per-folder-allowed-formats-audio-filter" time="0.1125685">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_folder > book-per-folder-exclude-exact-root-filename" time="0.118648125">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_folder > book-per-folder-uppercase-extensions" time="0.119853458">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_folder > book-per-folder-non-disc-similar-folder-not-flattened" time="0.131077875">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_file > book-per-file-multiple-content-files" time="0.123768959">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_file > book-per-file-audio-parts" time="0.120098584">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_file > book-per-file-ignore-non-content" time="0.130401459">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_file > book-per-file-allowed-formats-and-excludes" time="0.132419792">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_file > book-per-file-deep-root-mix" time="0.132637709">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_file > book-per-file-disc-subfolders-not-grouped" time="0.126081375">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_file > book-per-file-uppercase-extensions" time="0.118420667">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_file > book-per-file-exclude-exact-filename" time="0.119933458">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_file > book-per-file-allowed-formats-case-insensitive" time="0.121673791">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_file > book-per-file-hidden-and-no-extension-ignored" time="0.118652625">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_file > book-per-file-root-and-nested-same-name" time="0.114143459">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner.e2e-spec.ts" name="Scanner structures (e2e) > organizationMode=book_per_file > book-per-file-zero-byte-file-skipped" time="0.117714958">
|
||||
</testcase>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
@@ -1,218 +0,0 @@
|
||||
{
|
||||
"generatedAt": "2026-04-15T03:30:47.698Z",
|
||||
"total": 35,
|
||||
"passed": 35,
|
||||
"failed": 0,
|
||||
"results": [
|
||||
{
|
||||
"id": "book-per-folder-flat-root-file",
|
||||
"mode": "book_per_folder",
|
||||
"status": "passed",
|
||||
"durationMs": 119
|
||||
},
|
||||
{
|
||||
"id": "book-per-folder-mixed-root-and-folder",
|
||||
"mode": "book_per_folder",
|
||||
"status": "passed",
|
||||
"durationMs": 126
|
||||
},
|
||||
{
|
||||
"id": "book-per-folder-multi-format-folder-and-root-stray",
|
||||
"mode": "book_per_folder",
|
||||
"status": "passed",
|
||||
"durationMs": 124
|
||||
},
|
||||
{
|
||||
"id": "book-per-folder-audiobook-multipart-folder",
|
||||
"mode": "book_per_folder",
|
||||
"status": "passed",
|
||||
"durationMs": 121
|
||||
},
|
||||
{
|
||||
"id": "book-per-folder-disc-folder-flattening",
|
||||
"mode": "book_per_folder",
|
||||
"status": "passed",
|
||||
"durationMs": 120
|
||||
},
|
||||
{
|
||||
"id": "book-per-folder-ebook-with-stem-audio-subfolder",
|
||||
"mode": "book_per_folder",
|
||||
"status": "passed",
|
||||
"durationMs": 123
|
||||
},
|
||||
{
|
||||
"id": "book-per-folder-root-stem-audio-not-flattened",
|
||||
"mode": "book_per_folder",
|
||||
"status": "passed",
|
||||
"durationMs": 123
|
||||
},
|
||||
{
|
||||
"id": "book-per-folder-hidden-files-and-folders-ignored",
|
||||
"mode": "book_per_folder",
|
||||
"status": "passed",
|
||||
"durationMs": 124
|
||||
},
|
||||
{
|
||||
"id": "book-per-folder-exclude-patterns",
|
||||
"mode": "book_per_folder",
|
||||
"status": "passed",
|
||||
"durationMs": 123
|
||||
},
|
||||
{
|
||||
"id": "book-per-folder-allowed-formats",
|
||||
"mode": "book_per_folder",
|
||||
"status": "passed",
|
||||
"durationMs": 121
|
||||
},
|
||||
{
|
||||
"id": "book-per-folder-zero-byte-content-file-skipped",
|
||||
"mode": "book_per_folder",
|
||||
"status": "passed",
|
||||
"durationMs": 121
|
||||
},
|
||||
{
|
||||
"id": "book-per-folder-no-primary-files",
|
||||
"mode": "book_per_folder",
|
||||
"status": "passed",
|
||||
"durationMs": 115
|
||||
},
|
||||
{
|
||||
"id": "book-per-folder-deep-nested-book-folder",
|
||||
"mode": "book_per_folder",
|
||||
"status": "passed",
|
||||
"durationMs": 120
|
||||
},
|
||||
{
|
||||
"id": "book-per-folder-same-filename-different-authors",
|
||||
"mode": "book_per_folder",
|
||||
"status": "passed",
|
||||
"durationMs": 124
|
||||
},
|
||||
{
|
||||
"id": "book-per-folder-disc-name-variants-flattened",
|
||||
"mode": "book_per_folder",
|
||||
"status": "passed",
|
||||
"durationMs": 121
|
||||
},
|
||||
{
|
||||
"id": "book-per-folder-stem-subfolder-case-mismatch-no-merge",
|
||||
"mode": "book_per_folder",
|
||||
"status": "passed",
|
||||
"durationMs": 122
|
||||
},
|
||||
{
|
||||
"id": "book-per-folder-stem-subfolder-with-disc-flattening",
|
||||
"mode": "book_per_folder",
|
||||
"status": "passed",
|
||||
"durationMs": 116
|
||||
},
|
||||
{
|
||||
"id": "book-per-folder-root-multi-format-files-remain-separate",
|
||||
"mode": "book_per_folder",
|
||||
"status": "passed",
|
||||
"durationMs": 117
|
||||
},
|
||||
{
|
||||
"id": "book-per-folder-multi-book-single-folder-coalesced",
|
||||
"mode": "book_per_folder",
|
||||
"status": "passed",
|
||||
"durationMs": 111
|
||||
},
|
||||
{
|
||||
"id": "book-per-folder-allowed-formats-audio-filter",
|
||||
"mode": "book_per_folder",
|
||||
"status": "passed",
|
||||
"durationMs": 110
|
||||
},
|
||||
{
|
||||
"id": "book-per-folder-exclude-exact-root-filename",
|
||||
"mode": "book_per_folder",
|
||||
"status": "passed",
|
||||
"durationMs": 112
|
||||
},
|
||||
{
|
||||
"id": "book-per-folder-uppercase-extensions",
|
||||
"mode": "book_per_folder",
|
||||
"status": "passed",
|
||||
"durationMs": 115
|
||||
},
|
||||
{
|
||||
"id": "book-per-folder-non-disc-similar-folder-not-flattened",
|
||||
"mode": "book_per_folder",
|
||||
"status": "passed",
|
||||
"durationMs": 126
|
||||
},
|
||||
{
|
||||
"id": "book-per-file-multiple-content-files",
|
||||
"mode": "book_per_file",
|
||||
"status": "passed",
|
||||
"durationMs": 116
|
||||
},
|
||||
{
|
||||
"id": "book-per-file-audio-parts",
|
||||
"mode": "book_per_file",
|
||||
"status": "passed",
|
||||
"durationMs": 116
|
||||
},
|
||||
{
|
||||
"id": "book-per-file-ignore-non-content",
|
||||
"mode": "book_per_file",
|
||||
"status": "passed",
|
||||
"durationMs": 125
|
||||
},
|
||||
{
|
||||
"id": "book-per-file-allowed-formats-and-excludes",
|
||||
"mode": "book_per_file",
|
||||
"status": "passed",
|
||||
"durationMs": 128
|
||||
},
|
||||
{
|
||||
"id": "book-per-file-deep-root-mix",
|
||||
"mode": "book_per_file",
|
||||
"status": "passed",
|
||||
"durationMs": 128
|
||||
},
|
||||
{
|
||||
"id": "book-per-file-disc-subfolders-not-grouped",
|
||||
"mode": "book_per_file",
|
||||
"status": "passed",
|
||||
"durationMs": 121
|
||||
},
|
||||
{
|
||||
"id": "book-per-file-uppercase-extensions",
|
||||
"mode": "book_per_file",
|
||||
"status": "passed",
|
||||
"durationMs": 116
|
||||
},
|
||||
{
|
||||
"id": "book-per-file-exclude-exact-filename",
|
||||
"mode": "book_per_file",
|
||||
"status": "passed",
|
||||
"durationMs": 118
|
||||
},
|
||||
{
|
||||
"id": "book-per-file-allowed-formats-case-insensitive",
|
||||
"mode": "book_per_file",
|
||||
"status": "passed",
|
||||
"durationMs": 118
|
||||
},
|
||||
{
|
||||
"id": "book-per-file-hidden-and-no-extension-ignored",
|
||||
"mode": "book_per_file",
|
||||
"status": "passed",
|
||||
"durationMs": 113
|
||||
},
|
||||
{
|
||||
"id": "book-per-file-root-and-nested-same-name",
|
||||
"mode": "book_per_file",
|
||||
"status": "passed",
|
||||
"durationMs": 112
|
||||
},
|
||||
{
|
||||
"id": "book-per-file-zero-byte-file-skipped",
|
||||
"mode": "book_per_file",
|
||||
"status": "passed",
|
||||
"durationMs": 112
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<testsuites name="vitest tests" tests="39" failures="0" errors="0" time="37.268869625">
|
||||
<testsuite name="test/scanner-file-ops.e2e-spec.ts" timestamp="2026-04-15T03:31:40.314Z" hostname="DC-KJ3DT4LQPW.local" tests="39" failures="0" errors="0" skipped="0" time="37.268869625">
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > manual-delete-primary-file-marks-missing" time="0.294578333">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > manual-delete-folder-marks-only-removed-book-missing" time="0.3128125">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > manual-rename-file-in-place" time="0.294205792">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > manual-rename-folder" time="0.294784667">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > manual-move-root-file-into-subfolder" time="0.30047175">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > manual-move-subfolder-file-to-root" time="0.304838667">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > manual-cross-library-file-move" time="0.524123875">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > manual-cross-library-folder-move" time="0.506195334">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > manual-multi-step-churn" time="0.294776084">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > manual-book-per-file-delete-and-move" time="0.294187041">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > manual-move-into-existing-folder-merges" time="0.295670708">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > manual-cross-library-move-into-existing-folder-merges" time="0.539346042">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > manual-delete-and-recreate-same-path" time="0.282088959">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > manual-delete-and-recreate-folder-with-new-file" time="0.26758125">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > manual-book-per-file-folder-move-multi-file" time="0.245308375">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > manual-cross-library-move-then-reseed-source" time="0.514633208">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > watcher-delete-primary-file-marks-missing" time="0.765211042">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > watcher-delete-folder" time="0.791121958">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > watcher-rename-file-in-place" time="0.781976542">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > watcher-rename-folder" time="0.796244">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > watcher-move-root-file-into-subfolder" time="3.918478333">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > watcher-move-subfolder-file-to-root" time="3.886523458">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > watcher-cross-library-file-move" time="0.8804715">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > watcher-cross-library-folder-move" time="0.881713208">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > watcher-case-only-file-rename" time="0.781255667">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > watcher-non-content-file-creates-ignored" time="0.161706292">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > watcher-non-content-file-delete-ignored" time="0.155078375">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > watcher-book-per-file-delete-then-restore" time="1.693462125">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > watcher-move-into-existing-folder-merges" time="3.746106125">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > watcher-delete-and-recreate-same-path-within-debounce" time="0.134310834">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > watcher-delete-and-recreate-folder-with-new-file" time="1.686218333">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > watcher-cross-library-move-then-reseed-source" time="4.8905555">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > watcher-create-then-delete-new-path-within-debounce" time="0.171457541">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > structural scenarios > watcher-book-per-file-cross-library-folder-move-multi-file" time="0.929455833">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > stateful consistency scenarios > stateful-manual-delete-primary-preserves-data" time="0.286941625">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > stateful consistency scenarios > stateful-manual-cross-library-move-preserves-book-identity" time="0.579881375">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > stateful consistency scenarios > stateful-watcher-cross-library-move-preserves-book-identity" time="0.976622459">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > stateful consistency scenarios > stateful-watcher-rename-folder-preserves-data" time="0.871528084">
|
||||
</testcase>
|
||||
<testcase classname="test/scanner-file-ops.e2e-spec.ts" name="Scanner file operations (e2e) > stateful consistency scenarios > stateful-watcher-delete-then-restore-preserves-data" time="1.502913459">
|
||||
</testcase>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
@@ -1,243 +0,0 @@
|
||||
{
|
||||
"generatedAt": "2026-04-15T03:31:40.294Z",
|
||||
"total": 39,
|
||||
"passed": 39,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"results": [
|
||||
{
|
||||
"id": "manual-delete-primary-file-marks-missing",
|
||||
"trigger": "manual",
|
||||
"status": "passed",
|
||||
"durationMs": 288
|
||||
},
|
||||
{
|
||||
"id": "manual-delete-folder-marks-only-removed-book-missing",
|
||||
"trigger": "manual",
|
||||
"status": "passed",
|
||||
"durationMs": 305
|
||||
},
|
||||
{
|
||||
"id": "manual-rename-file-in-place",
|
||||
"trigger": "manual",
|
||||
"status": "passed",
|
||||
"durationMs": 288
|
||||
},
|
||||
{
|
||||
"id": "manual-rename-folder",
|
||||
"trigger": "manual",
|
||||
"status": "passed",
|
||||
"durationMs": 290
|
||||
},
|
||||
{
|
||||
"id": "manual-move-root-file-into-subfolder",
|
||||
"trigger": "manual",
|
||||
"status": "passed",
|
||||
"durationMs": 294
|
||||
},
|
||||
{
|
||||
"id": "manual-move-subfolder-file-to-root",
|
||||
"trigger": "manual",
|
||||
"status": "passed",
|
||||
"durationMs": 300
|
||||
},
|
||||
{
|
||||
"id": "manual-cross-library-file-move",
|
||||
"trigger": "manual",
|
||||
"status": "passed",
|
||||
"durationMs": 517
|
||||
},
|
||||
{
|
||||
"id": "manual-cross-library-folder-move",
|
||||
"trigger": "manual",
|
||||
"status": "passed",
|
||||
"durationMs": 499
|
||||
},
|
||||
{
|
||||
"id": "manual-multi-step-churn",
|
||||
"trigger": "manual",
|
||||
"status": "passed",
|
||||
"durationMs": 287
|
||||
},
|
||||
{
|
||||
"id": "manual-book-per-file-delete-and-move",
|
||||
"trigger": "manual",
|
||||
"status": "passed",
|
||||
"durationMs": 285
|
||||
},
|
||||
{
|
||||
"id": "manual-move-into-existing-folder-merges",
|
||||
"trigger": "manual",
|
||||
"status": "passed",
|
||||
"durationMs": 285
|
||||
},
|
||||
{
|
||||
"id": "manual-cross-library-move-into-existing-folder-merges",
|
||||
"trigger": "manual",
|
||||
"status": "passed",
|
||||
"durationMs": 529
|
||||
},
|
||||
{
|
||||
"id": "manual-delete-and-recreate-same-path",
|
||||
"trigger": "manual",
|
||||
"status": "passed",
|
||||
"durationMs": 276
|
||||
},
|
||||
{
|
||||
"id": "manual-delete-and-recreate-folder-with-new-file",
|
||||
"trigger": "manual",
|
||||
"status": "passed",
|
||||
"durationMs": 264
|
||||
},
|
||||
{
|
||||
"id": "manual-book-per-file-folder-move-multi-file",
|
||||
"trigger": "manual",
|
||||
"status": "passed",
|
||||
"durationMs": 240
|
||||
},
|
||||
{
|
||||
"id": "manual-cross-library-move-then-reseed-source",
|
||||
"trigger": "manual",
|
||||
"status": "passed",
|
||||
"durationMs": 508
|
||||
},
|
||||
{
|
||||
"id": "watcher-delete-primary-file-marks-missing",
|
||||
"trigger": "watcher",
|
||||
"status": "passed",
|
||||
"durationMs": 758
|
||||
},
|
||||
{
|
||||
"id": "watcher-delete-folder",
|
||||
"trigger": "watcher",
|
||||
"status": "passed",
|
||||
"durationMs": 781
|
||||
},
|
||||
{
|
||||
"id": "watcher-rename-file-in-place",
|
||||
"trigger": "watcher",
|
||||
"status": "passed",
|
||||
"durationMs": 776
|
||||
},
|
||||
{
|
||||
"id": "watcher-rename-folder",
|
||||
"trigger": "watcher",
|
||||
"status": "passed",
|
||||
"durationMs": 789
|
||||
},
|
||||
{
|
||||
"id": "watcher-move-root-file-into-subfolder",
|
||||
"trigger": "watcher",
|
||||
"status": "passed",
|
||||
"durationMs": 3913
|
||||
},
|
||||
{
|
||||
"id": "watcher-move-subfolder-file-to-root",
|
||||
"trigger": "watcher",
|
||||
"status": "passed",
|
||||
"durationMs": 3881
|
||||
},
|
||||
{
|
||||
"id": "watcher-cross-library-file-move",
|
||||
"trigger": "watcher",
|
||||
"status": "passed",
|
||||
"durationMs": 876
|
||||
},
|
||||
{
|
||||
"id": "watcher-cross-library-folder-move",
|
||||
"trigger": "watcher",
|
||||
"status": "passed",
|
||||
"durationMs": 875
|
||||
},
|
||||
{
|
||||
"id": "watcher-case-only-file-rename",
|
||||
"trigger": "watcher",
|
||||
"status": "passed",
|
||||
"durationMs": 774
|
||||
},
|
||||
{
|
||||
"id": "watcher-non-content-file-creates-ignored",
|
||||
"trigger": "watcher",
|
||||
"status": "passed",
|
||||
"durationMs": 157
|
||||
},
|
||||
{
|
||||
"id": "watcher-non-content-file-delete-ignored",
|
||||
"trigger": "watcher",
|
||||
"status": "passed",
|
||||
"durationMs": 148
|
||||
},
|
||||
{
|
||||
"id": "watcher-book-per-file-delete-then-restore",
|
||||
"trigger": "watcher",
|
||||
"status": "passed",
|
||||
"durationMs": 1689
|
||||
},
|
||||
{
|
||||
"id": "watcher-move-into-existing-folder-merges",
|
||||
"trigger": "watcher",
|
||||
"status": "passed",
|
||||
"durationMs": 3739
|
||||
},
|
||||
{
|
||||
"id": "watcher-delete-and-recreate-same-path-within-debounce",
|
||||
"trigger": "watcher",
|
||||
"status": "passed",
|
||||
"durationMs": 130
|
||||
},
|
||||
{
|
||||
"id": "watcher-delete-and-recreate-folder-with-new-file",
|
||||
"trigger": "watcher",
|
||||
"status": "passed",
|
||||
"durationMs": 1679
|
||||
},
|
||||
{
|
||||
"id": "watcher-cross-library-move-then-reseed-source",
|
||||
"trigger": "watcher",
|
||||
"status": "passed",
|
||||
"durationMs": 4882
|
||||
},
|
||||
{
|
||||
"id": "watcher-create-then-delete-new-path-within-debounce",
|
||||
"trigger": "watcher",
|
||||
"status": "passed",
|
||||
"durationMs": 168
|
||||
},
|
||||
{
|
||||
"id": "watcher-book-per-file-cross-library-folder-move-multi-file",
|
||||
"trigger": "watcher",
|
||||
"status": "passed",
|
||||
"durationMs": 922
|
||||
},
|
||||
{
|
||||
"id": "stateful-manual-delete-primary-preserves-data",
|
||||
"trigger": "manual",
|
||||
"status": "passed",
|
||||
"durationMs": 287
|
||||
},
|
||||
{
|
||||
"id": "stateful-manual-cross-library-move-preserves-book-identity",
|
||||
"trigger": "manual",
|
||||
"status": "passed",
|
||||
"durationMs": 580
|
||||
},
|
||||
{
|
||||
"id": "stateful-watcher-cross-library-move-preserves-book-identity",
|
||||
"trigger": "watcher",
|
||||
"status": "passed",
|
||||
"durationMs": 976
|
||||
},
|
||||
{
|
||||
"id": "stateful-watcher-rename-folder-preserves-data",
|
||||
"trigger": "watcher",
|
||||
"status": "passed",
|
||||
"durationMs": 871
|
||||
},
|
||||
{
|
||||
"id": "stateful-watcher-delete-then-restore-preserves-data",
|
||||
"trigger": "watcher",
|
||||
"status": "passed",
|
||||
"durationMs": 1502
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user