# Testing Guide BookOrbit has three layers of tests: - **Server unit tests:** isolated tests for NestJS services, controllers, repositories, and utilities. Dependencies are mocked; no database required. - **Client unit tests:** tests for Vue composables and pure utility functions using a jsdom environment. - **End-to-end (E2E) tests:** full-stack tests that boot a real NestJS application and run HTTP requests against a PostgreSQL database. Each layer has a specific job and its own tooling. Do not test the same behavior at multiple layers. For testing expectations by change type, see [CONTRIBUTING.md](../CONTRIBUTING.md). For the development setup, see [DEVELOPMENT.md](DEVELOPMENT.md). --- ## Quick Reference | Command | What it does | | -------------------------------- | ------------------------------------ | | `pnpm run test` | All unit tests (server + client) | | `pnpm run test:server` | Server unit tests only | | `pnpm run test:client` | Client unit tests only | | `cd server && pnpm test:watch` | Server unit tests in watch mode | | `pnpm run coverage` | Coverage reports for server + client | | `pnpm run coverage:server` | Server coverage only | | `pnpm run coverage:client` | Client coverage only | | `pnpm run e2e:run -- ` | Run a specific E2E suite | | `pnpm run e2e:all` | Run all E2E suites sequentially | | `pnpm run e2e:list` | List all available E2E suite IDs | | `pnpm run e2e:db:prepare` | Reset and migrate the E2E database | --- ## Unit Tests | | Server | Client | | ---------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------- | | Config | `server/vitest.config.ts` | `client/vitest.config.ts` | | Test files | `server/src/**/*.test.ts` (e.g. `book.service.ts` → `book.service.test.ts`) | `client/src/**/*.test.ts` (e.g. `useSearch.ts` → `useSearch.test.ts`) | | Env | Node | jsdom | | Globals | Enabled (no import needed) | Disabled (import from `vitest`) | | Coverage | 85% statements/lines/functions, 70% branches (CI enforced) | 1% (not blocking) | Coverage excluded on the server: test files, `*.module.ts`, `main.ts`, `src/db/schema/**`, type definitions, interfaces, constants, enums, config files, and scripts. --- ## End-to-End Tests ### Architecture ```text scripts/e2e/ suite-registry.mjs <- source of truth: all suite metadata and changedPaths run-suite.mjs <- orchestrator: preps DB if needed, then runs vitest select-matrix.mjs <- CI smart matrix: picks suites based on changed file paths list-matrix.mjs <- full matrix: all suites (used by manual workflow dispatch) prepare-db.sh <- drops, re-creates, and migrates the bookorbit_e2e schema server/ vitest.config.e2e.ts <- separate vitest config (forks pool, sequential files) test/ e2e.setup.ts <- global setup: mocks waitForStability e2e/ app-harness.ts <- shared NestJS bootstrap and DB helpers scanner/ <- scanner-specific harness helpers *.e2e-spec.ts <- one spec file per suite (18 total) ``` **Key vitest E2E settings** (`server/vitest.config.e2e.ts`): - `pool: 'forks'`: each spec file runs in a separate process for full isolation - `fileParallelism: false`: spec files within a run execute sequentially - `setupFiles: ['test/e2e.setup.ts']`: runs before every suite; mocks `waitForStability` - `DATABASE_URL` is always overridden to `E2E_DATABASE_URL` (defaults to `bookorbit_e2e`), so E2E tests never touch the dev database ### DB modes Every suite runs in one of two modes: | Mode | `prepareDedicatedDatabase` | Database used | When to use | | ------------ | -------------------------- | ------------------------------------------------------- | ------------------------------------------------ | | default-db | `false` | `E2E_DATABASE_URL` with no reset before run | Fast smoke tests that do not write data | | dedicated-db | `true` | `bookorbit_e2e` dropped and re-migrated before each run | Any test that writes data or needs a clean state | For dedicated-db suites, `run-suite.mjs` automatically runs `pnpm run e2e:db:prepare` before launching vitest. This drops and re-creates the `bookorbit_e2e` schema and applies all migrations fresh. ### Running E2E tests ```bash # Run a single suite pnpm run e2e:run -- scanner-scenarios # Run all suites sequentially pnpm run e2e:all # List all registered suite IDs pnpm run e2e:list # Filter by test name within a suite pnpm run e2e:run -- scanner-scenarios --testNamePattern=book-per-folder-flat-root-file # Use a custom E2E database URL E2E_DATABASE_URL=postgres://... pnpm run e2e:run -- scanner-scenarios ``` The default E2E database URL is `postgres://bookorbit:bookorbit@localhost:5432/bookorbit_e2e`. PostgreSQL must be running locally (start it with `pnpm run db:up` if needed). > **Override the database:** Use `E2E_DATABASE_URL`, not `DATABASE_URL`. The vitest config > reads `E2E_DATABASE_URL` and injects it as `DATABASE_URL` into test workers; setting > `DATABASE_URL` directly has no effect. E2E tests use the same `server/.env` as development. If tests behave differently locally vs CI, check that your env vars match the values in `server/.env.example`. ### Suite reference | Suite ID | Lane | DB mode | Timeout | Description | | --------------------------------- | ----- | ------------ | ------- | --------------------------------------------------------------------------------------------------------------------- | | `guard-mechanics` | smoke | default-db | 30 min | `@Public` / `@RequirePermission` guard wiring; uses its own minimal NestJS testing module, not the shared app harness | | `scanner-scenarios` | full | dedicated-db | 45 min | File organization scenario matrix for the library scanner | | `scanner-file-operations` | full | dedicated-db | 60 min | File add/remove/rename operations and scanner reconciliation | | `auth-session-security` | full | dedicated-db | 50 min | Cookie flags, token rotation, concurrent sessions | | `auth-recovery-oidc-logout` | full | dedicated-db | 50 min | Password reset flow and OIDC logout | | `book-dock-ingest-finalize` | full | dedicated-db | 45 min | Book Dock upload ingest and finalize flow | | `metadata-write` | full | dedicated-db | 60 min | Metadata write operations (field updates, bulk edits) | | `metadata-lock` | full | dedicated-db | 60 min | Metadata lock enforcement across operations | | `migration-booklore` | full | dedicated-db | 70 min | Booklore v1 to BookOrbit data migration | | `migration-audiobookshelf` | full | dedicated-db | 90 min | Audiobookshelf live API and real-backup migration | | `migration-calibre-web-automated` | full | dedicated-db | 120 min | Calibre-Web Automated stopped-snapshot migration | | `authorization-matrix` | full | dedicated-db | 60 min | Permission enforcement across all protected routes | | `book-api-contract` | full | dedicated-db | 60 min | Book API response shape and field contract | | `app-settings-oidc-contract` | full | dedicated-db | 60 min | App settings and OIDC configuration contract | | `library-admin-workflows` | full | dedicated-db | 60 min | Library create/update/delete admin workflows | | `reader-format-delivery` | full | dedicated-db | 60 min | Book file serving and format delivery | | `opds-auth-catalog` | full | dedicated-db | 45 min | OPDS feed authentication and catalog structure | | `email-lifecycle` | full | dedicated-db | 45 min | Email provider, template, and send lifecycle | | `reader-state-isolation` | full | dedicated-db | 45 min | Reading progress, bookmarks, and annotations isolated per user | | `users-admin-lifecycle` | full | dedicated-db | 50 min | User create/update/delete admin lifecycle | JUnit XML output for each suite is written to `test-results/server/-e2e-junit.xml`. > **Timeout column** is the GitHub Actions job timeout, not a per-test vitest timeout. > Individual tests have no configured timeout and use vitest defaults. **Lanes:** The smoke lane (`guard-mechanics`) always runs on PRs. Full-lane suites run only when their `changedPaths` list in the registry matches the files changed in the PR. Nightly scheduled runs and manual dispatches always run all suites. ### App harness `server/test/e2e/app-harness.ts` is the shared infrastructure used by every E2E suite. **Bootstrapping:** ```typescript import { createE2EContext, closeE2EContext, type E2EContext, } from "./e2e/app-harness"; let ctx: E2EContext; beforeAll(async () => { ctx = await createE2EContext(); }); afterAll(async () => { await closeE2EContext(ctx); }); ``` `createE2EContext()` boots the full `AppModule` via `Test.createTestingModule`, applies the same global pipes and exception filter as production, registers cookie support, and returns: | Property | Type | What it is | | ---------------- | ------------------------ | ------------------------------------ | | `ctx.app` | `NestFastifyApplication` | The running application instance | | `ctx.db` | Drizzle ORM instance | Connected to the E2E database | | `ctx.adminToken` | `string` | A valid JWT for the seeded superuser | `MetadataService` is replaced with a no-op mock by default so E2E tests never make external metadata provider calls. To customize the mock for a specific suite, pass the result of `makeMetadataNoopMock()` and configure its `vi.fn()` methods before calling `createE2EContext()`. **Making HTTP requests:** ```typescript const response = await ctx.app.inject({ method: "GET", url: "/api/v1/books/99", headers: { authorization: `Bearer ${ctx.adminToken}` }, }); expect(response.statusCode).toBe(200); const body = response.json() as { id: number; title: string }; ``` `app.inject()` is Fastify's built-in injection; no network socket or supertest needed. **Direct database access:** ```typescript import { eq } from "drizzle-orm"; import { books } from "../../src/db/schema"; const [book] = await ctx.db.select().from(books).where(eq(books.id, 99)); expect(book.status).toBe("present"); ``` **Key helper functions:** | Function | Returns | Purpose | | --------------------------------------------------- | -------------------------------- | --------------------------------------------------------------------------- | | `seedLibrary(db, input)` | `{ libraryId, libraryFolderId }` | Insert a library and folder row directly into the DB | | `triggerLibraryScan(ctx, libraryId)` | `jobId: number` | POST to the scan endpoint | | `triggerAndWaitForLibraryScan(ctx, libraryId, ms?)` | `ScanJob` | Trigger a scan and poll until complete (default 30s timeout) | | `waitForScanCompletion(db, jobId, ms?, pollMs?)` | `ScanJob` | Poll `scanJobs` until `completed` or `failed` (100ms default poll interval) | | `waitForCondition(check, timeoutMs?, pollMs?)` | `void` | Retry an async assertion until it passes or times out (200ms poll) | | `loadLibraryBookState(db, libraryId)` | `LibraryBookState[]` | Snapshot all books and files for a library, sorted for comparison | | `loadIntegritySnapshot(db)` | `IntegritySnapshot` | Return counts of all orphan rows across relational tables | | `assertNoIntegrityViolations(db)` | `void` | Throw if any orphan rows are found | | `makeMetadataNoopMock()` | `MetadataNoopMock` | Return a mock `MetadataService` with all methods resolved to `undefined` | Suite-specific helpers extend or re-export from the app harness. See `server/test/e2e/scanner/scanner-harness.ts` for an example; it re-exports the common functions and adds `startLibraryWatcher` and `stopLibraryWatcher`. ### Adding tests to an existing suite 1. Open `server/test/.e2e-spec.ts`. 2. Locate the shared `ctx` set up in `beforeAll`/`afterAll`. 3. Add `describe`/`it` blocks using `ctx.app.inject()` for HTTP and `ctx.db` for assertions. 4. Because `fileParallelism: false`, all tests in a suite share the same app instance. Use `beforeEach`/`afterEach` to clean up any rows your tests insert to avoid cross-test interference. ### Adding a new suite **Step 1 - Create the spec file** Create `server/test/.e2e-spec.ts`. Use `createE2EContext`/`closeE2EContext` in `beforeAll`/`afterAll`. Any existing `*.e2e-spec.ts` is a reference for the standard structure. **Step 2 - Add suite helpers (optional)** For fixture builders or harness utilities specific to your suite: ```text server/test/e2e// ``` **Step 3 - Register in the suite registry** Add an entry to `scripts/e2e/suite-registry.mjs`: ```javascript "my-new-suite": { id: "my-new-suite", name: "My New Suite", timeout: 60, lane: "full", description: "Short description of what this suite covers", vitestTarget: "test/my-new-suite.e2e-spec.ts", junitOutput: `${TEST_RESULTS_DIR}/my-new-suite-e2e-junit.xml`, prepareDedicatedDatabase: true, // false only if your suite never writes data useDedicatedDatabase: true, changedPaths: [ "server/src/modules/my-feature/**", "server/test/my-new-suite.e2e-spec.ts", "server/test/e2e/my-suite/**", ...SHARED_DB_AND_HELPER_PATHS, ], }, ``` Keep `changedPaths` precise. Overly broad patterns will schedule your suite on unrelated PRs. **Step 4 - CI wiring** No workflow changes needed. `scripts/e2e/select-matrix.mjs` reads the registry automatically. --- ## Coverage ```bash pnpm run coverage # server + client pnpm run coverage:server # text + lcov output to server/coverage/ pnpm run coverage:client # text + HTML output to client/coverage/ ``` Server thresholds (85% statements/lines/functions, 70% branches) are enforced and will fail the CI test job if dropped below. Client thresholds are 1% by design. --- ## CI Integration ### Unit tests Unit tests run on every push and PR via `.github/workflows/ci.yml`. The workflow is change-aware: if only client files changed, only the client test job runs. Each job has a 20-minute timeout. JUnit XML results are published as annotations on the PR or commit, and test report artifacts are retained for 7 days. ### E2E suites E2E suites are selected by `scripts/e2e/select-matrix.mjs`: - **Nightly schedule (`CI`):** All 18 suites run. - **Pull request / push to main / manual `CI` dispatch:** E2E is skipped in `.github/workflows/ci.yml`. Each selected suite runs as its own GitHub Actions job via the reusable `.github/workflows/e2e-runner.yml` workflow, which spins up a PostgreSQL 18 + pgvector service container and calls `pnpm run e2e:run -- `. **Manual full run:** Trigger `.github/workflows/e2e.yml` from the Actions tab. It accepts an optional `vitest-args` input for filtering by test name. ### Reading CI failures | Failing job | Local command to reproduce | | ----------- | -------------------------------- | | lint | `pnpm run lint:check` | | typecheck | `pnpm run typecheck` | | tests | `pnpm run test` | | E2E suite | `pnpm run e2e:run -- ` | For E2E failures, check the JUnit artifact on the run for the full test report. If the suite uses a dedicated DB, prepare it first: ```bash pnpm run e2e:db:prepare && pnpm run e2e:run -- ```