mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
e89f4ce0e4
Validate BO-<issue>-<slug> branch names and lint PR titles and commit headers in CI and release workflows. Require GitHub closing-keyword issue references and verify referenced issues exist. Align contributing, release, commit, testing, and policy docs with the enforced behavior and tone.
341 lines
18 KiB
Markdown
341 lines
18 KiB
Markdown
# 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 -- <suite-id>` | 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 |
|
|
| `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/<suite-id>-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/<suite-id>.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/<suite-id>.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/<suite-name>/
|
|
```
|
|
|
|
**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 16 + pgvector service
|
|
container and calls `pnpm run e2e:run -- <suite-id>`.
|
|
|
|
**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 -- <suite-id>` |
|
|
|
|
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 -- <suite-id>
|
|
```
|