chore: reorganize docs, improve README, and add FOSS project hygiene

This commit is contained in:
neon
2026-05-13 01:35:58 -06:00
parent a64013a79d
commit 266b57c38a
17 changed files with 209 additions and 50 deletions
+73
View File
@@ -0,0 +1,73 @@
# AI Usage Policy
BookOrbit is maintained by real people. Every pull request, issue, and discussion is read and reviewed by humans who give their time to this project. Submitting unreviewed, untested AI output shifts the burden of that review onto maintainers - that is the problem this policy addresses. It is not a problem with AI tools themselves.
## The Rules
- **All AI use must be disclosed.** If any part of your contribution was written, generated, or significantly shaped by an AI tool, say so in the PR description. There are no exceptions and no minimum threshold. See [Disclosure Format](#disclosure-format) below.
- **You must understand every line you submit.** If you cannot explain what a piece of code does, why it is structured the way it is, and how it fits into the surrounding system - do not submit it. "The AI wrote it" is not an answer during review.
- **AI-generated tests must validate real behavior.** Tests that mock away the logic under test, assert on implementation details instead of outcomes, or trivially pass regardless of what the code does will be rejected. Read every assertion before submitting.
- **No AI-generated media.** Images, icons, audio, video, and other non-text assets must be human-created or properly licensed. Text and code are the only acceptable forms of AI-generated content, subject to the rules above.
- **Clean up before submitting.** Remove AI boilerplate, placeholder comments, dead code, and empty catch blocks. Submit only what you would be comfortable having reviewed line by line.
## Disclosure Format
Include this in your PR description whenever AI tools were used:
```
AI tools used: [tool name, e.g. Claude, Copilot, Cursor]
Extent: [what it helped with, e.g. "generated the DTO scaffolding and test boilerplate; all service logic and ownership checks written manually"]
```
Be honest. A brief, accurate note is better than a vague one that obscures how much the AI contributed.
## Consequences
PRs that contain undisclosed AI use or that are clearly unreviewed AI output will be closed immediately with a short explanation. There is no detailed feedback given for this - that burden belongs to the contributor, not the reviewer.
You are always welcome to resubmit once the work is genuinely reviewed, tested, and properly disclosed. The door is not closed permanently.
## Watch Out for These
AI tools make specific, recurring mistakes on this codebase. Be especially careful here.
**Drizzle ORM hallucinations.** LLMs are trained heavily on Prisma and will generate Prisma-style calls - `.findMany()`, `.findUnique()`, `.create()` - that do not exist in Drizzle. Drizzle uses a query builder. Verify every database call against the [Drizzle documentation](https://orm.drizzle.team/docs/overview) and against how the rest of the codebase does it.
**NestJS decorator and module mistakes.** AI frequently invents decorator options, misuses pipes, omits required module imports, or generates module wiring that looks plausible but is wrong. Check every decorator and every module registration.
**Ownership and multi-tenancy logic.** BookOrbit is a multi-user app. Every query that touches user-owned data must filter by `userId`. AI-generated service methods routinely omit this. A missed ownership check is a data exposure bug, not a style issue. This applies to every module that manages libraries, books, collections, reading sessions, annotations, and preferences.
**Authorization patterns.** We have specific conventions for permission gates: `@RequirePermission(...)` on the backend, `usePermissions()` on the frontend, and the `SmartScopeService` superuser-bypass pattern. AI does not know these patterns and will generate code that bypasses them or reimplements them incorrectly. Write auth and permission logic yourself.
**Tests that prove nothing.** AI-generated tests often mock the exact thing being tested, assert on shallow output without triggering the real path, or simply confirm that a function returns without throwing. Before submitting a test, ask: would this test fail if the implementation were broken? If the answer is no, the test is not worth having.
## How to Use AI Well Here
AI is a genuinely useful tool when applied with judgment. Here is what works well on this project:
- Use AI to scaffold boilerplate: DTOs, module wiring, Vue component structure, test file setup. Review and adjust everything it produces.
- Use AI to explain unfamiliar patterns, library APIs, or architectural concepts. Understanding-driven use is the best use.
- Write authentication, authorization, and ownership-critical logic yourself. These are the areas where a mistake has real consequences for real users.
- When AI generates a service method, read every line with the question: does this correctly scope to the current user? Does it throw the right exception for unauthorized access?
- Run the full stack after every AI-assisted change. `pnpm dev` plus manual verification is not optional.
- Use AI to generate a test structure, then replace every assertion with one you wrote and understand yourself.
## A Note on Maintainers
These rules apply to outside contributors. Maintainers are exempt. They have demonstrated judgment and accountability over time, and are trusted to apply discretion in how they use AI tools in their own work.
## AI is Welcome Here
To be clear: this policy is not an anti-AI stance. Maintainers use AI tools. Many parts of this codebase were written with AI assistance.
The problem this policy addresses is unqualified, unreviewed output being submitted as a contribution - work that the contributor has not run, does not understand, and cannot stand behind. That is a burden on maintainers regardless of whether it came from a human or a machine.
Use the tools. Do the work.
---
_This policy was written with reference to the [Ghostty AI Policy](https://github.com/ghostty-org/ghostty/blob/main/AI_POLICY.md), which we found thoughtful and worth building from._
+131
View File
@@ -0,0 +1,131 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or advances
of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email address,
without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for
moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail
address, posting via an official social media account, or acting as an
appointed representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at [INSERT CONTACT
METHOD]. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+274
View File
@@ -0,0 +1,274 @@
# Commit Message Guidelines
Every commit tells a story. A well-written commit message makes it easy to understand why a change was made, trace regressions, and produce a meaningful changelog. These guidelines define the format for all commits to this repository.
## Format
```
<type>(<scope>): <summary>
[body]
[footer]
```
The **header** is required on every commit and must follow the format described below.
The **body** is required on all commits except `docs`. When present, it must be at least 20 characters and written in imperative mood.
The **footer** is optional. Use it to reference issues, close PRs, or document breaking changes.
---
## Header
```
<type>(<scope>): <summary>
│ │ │
│ │ └─ imperative mood, lowercase, no period at the end
│ │
│ └─ Scope (optional): auth|books|library|metadata|kobo|opds|reader|
│ collections|annotations|authors|series|cover|
│ users|stats|notifications|settings|scanner|
│ email|audit|smart-scope|types|docker|deps|server|client
│
└─ Type: feat|fix|db|perf|refactor|style|docs|test|build|ci|chore|security|revert
```
The `<type>` and `<summary>` fields are required. The `(<scope>)` field is optional.
### Type
The type signals the intent of the change at a glance.
| Type | When to use |
| ---------- | -------------------------------------------------------------------------------------------------------- |
| `feat` | Introduces a new feature or user-visible capability |
| `fix` | Patches a bug in existing behavior |
| `db` | Adds or modifies a Drizzle schema, generates a migration, or changes seed data |
| `perf` | Improves performance without changing observable behavior |
| `refactor` | Restructures code without fixing bugs or adding features |
| `style` | Visual/CSS-only changes: Tailwind tweaks, spacing, color token adjustments |
| `docs` | Documentation changes only |
| `test` | Adds or corrects tests without touching production code |
| `build` | Changes to the build system, tooling config, or external dependencies (Dockerfile, tsconfig, pnpm) |
| `ci` | CI/CD pipeline changes - GitHub Actions workflows and scripts |
| `chore` | Routine maintenance that doesn't fit any other category |
| `security` | Addresses a security vulnerability; preferred over `fix` so security patches are identifiable in history |
| `revert` | Reverts a previous commit - the body must reference the reverted SHA and explain why |
> If a change genuinely spans multiple types (for example, it adds a feature and fixes a bug), split it into two commits. If that isn't practical, use the type that best represents the dominant intent.
### Scope
Scope is optional but strongly encouraged. It narrows the type to the affected area of the codebase, making the log significantly more readable.
Use one scope from the table below. If a change affects multiple areas, use the most dominant one.
| Scope | What it covers |
| --------------- | ------------------------------------------------------------- |
| `auth` | Authentication, authorization, JWT, OIDC, sessions |
| `library` | Library management, file system scanning |
| `books` | Book data, CRUD, book-level state |
| `authors` | Author data and management |
| `series` | Book series grouping and management |
| `cover` | Book cover image storage, retrieval, and optimization |
| `metadata` | Metadata providers, fetch logic, quality scoring, preferences |
| `kobo` | Kobo device sync and integration |
| `opds` | OPDS catalog endpoints |
| `reader` | In-app reader, reading position, reader preferences |
| `collections` | Collections and shelf management |
| `annotations` | Highlights, bookmarks, notes |
| `users` | User management, profiles, roles |
| `stats` | Reading sessions, statistics, progress tracking |
| `notifications` | In-app and email notifications |
| `settings` | Application settings and configuration |
| `scanner` | File scanner and upload pipeline |
| `email` | Email providers, templates, and send preferences |
| `audit` | Audit logging and event tracking |
| `smart-scope` | Custom Smart Scopes and collection views |
| `types` | Shared types in `packages/types/` |
| `docker` | Dockerfile and Docker Compose files |
| `deps` | Dependency upgrades (pair with `build` or `chore`) |
| `server` | Backend-wide changes with no single-module scope |
| `client` | Frontend-wide changes with no single-module scope |
### Summary
The summary is a short, imperative description of what the commit does.
Rules:
- Use the imperative mood: "add", "fix", "remove" (not "added" or "adds")
- Start with a lowercase letter
- No period at the end
- Keep it under 72 characters
- Describe **what** the commit does, not **why**; that belongs in the body
---
## Body
Write a body when the **why** behind the change isn't obvious from the header alone.
When to write a body:
- The motivation isn't clear from the summary
- The change has side effects, tradeoffs, or gotchas worth calling out
- You're reverting a commit - reference the SHA and explain the reason
- The approach taken deserves a brief explanation
Format:
- Separate from the header with a blank line
- Use the imperative mood, consistent with the summary
- Wrap lines at ~100 characters
- Explain **why** this change exists, not **what** changed; the diff already shows that
---
## Footer
Use the footer to link related issues or PRs, or to announce breaking changes.
### Linking issues and PRs
```
Fixes #123
Closes #456
Ref #789
```
`Fixes` and `Closes` automatically close the linked issue when the PR is merged. Use `Ref` for a related issue that should stay open.
### Breaking changes
If the commit introduces a breaking change, open the footer with `BREAKING CHANGE:` followed by a brief summary, a blank line, and clear migration instructions.
```
BREAKING CHANGE: rename BOOKS_PATH env var to LIBRARY_PATH
Update your .env or Docker environment to use LIBRARY_PATH instead.
The old name is no longer recognized and the app will not start without it.
```
Breaking changes must also be explicitly called out in the PR description, including the upgrade steps a user would need to follow.
---
## Examples
### `feat`
```
feat(kobo): sync reading position on device reconnect
```
```
feat(opds): add pagination to catalog feed endpoints
The OPDS feed now supports ?page= and ?limit= query params.
Default page size is 20. Clients that don't send pagination params
get the same behavior as before.
Closes #301
```
### `fix`
```
fix(reader): epub chapter navigation skips last page
```
```
fix(stats): reading session duration wrong after timezone change
Duration was computed using local time offsets rather than UTC,
causing sessions that crossed a DST boundary to report incorrect
elapsed time.
Fixes #412
```
### `db`
```
db(books): add language column to books table
```
```
db(users): add user_preferences table for per-user app settings
Generates a migration. Existing users get default values on deploy.
```
### `perf`
```
perf(library): avoid redundant file stat calls during rescan
```
### `refactor`
```
refactor(metadata): extract provider selection into its own service
```
### `style`
```
style(reader): tighten chapter list spacing in sidebar
```
### `docs`
```
docs: add Kobo device setup guide to README
```
### `test`
```
test(collections): cover concurrent shelf update race condition
```
### `build`
```
build(docker): switch to slim node base image in final stage
```
### `ci`
```
ci: add e2e smoke suite to pull request workflow
```
### `chore`
```
chore(deps): upgrade drizzle-orm to 0.38
```
### `security`
```
security(auth): reject tokens signed with legacy algorithm
Previous tokens signed with HS256 were still accepted after the
migration to RS256. This change enforces RS256-only validation.
BREAKING CHANGE: all existing sessions will be invalidated on deploy.
Users will need to log in again.
```
### `revert`
```
revert: revert "feat(kobo): sync reading position on device reconnect"
Reverts commit a1b2c3d.
Broke sync for devices running firmware < 4.32. Reverting until
a compatibility shim is in place.
```
+247
View File
@@ -0,0 +1,247 @@
# Contributing to BookOrbit
Thanks for your interest in contributing to BookOrbit. Whether you are fixing a typo or building a major feature, this guide walks you through the process from start to finish.
**Companion docs:**
- [DEVELOPMENT.md](DEVELOPMENT.md) - Local setup, architecture, commands, and technical reference.
- [TESTING.md](TESTING.md) - Test architecture, E2E suites, coverage thresholds, and harness details.
- [COMMIT_GUIDELINES.md](COMMIT_GUIDELINES.md) - Commit message format, types, scopes, and examples.
---
## Where to Start
Not sure what to work on? Look for issues tagged with:
- [`good first issue`](../../labels/good%20first%20issue) - Scoped tasks with clear acceptance criteria. Great for your first contribution.
- [`help wanted`](../../labels/help%20wanted) - Larger tasks where maintainer bandwidth is limited.
If nothing there catches your eye, browse the open issues or propose something new.
### Documentation Contributions
Doc improvements are always welcome and have a lighter process:
- No tests required.
- Use the `docs/` branch prefix (e.g. `docs/fix-setup-instructions`).
- Focus areas: fixing inaccuracies, improving examples, adding missing guides, clarifying confusing sections.
The rest of this guide still applies (issue first, PR template, etc.), but expect a faster review cycle.
---
## Security Issues
Do **not** open public issues for security vulnerabilities. Use GitHub's [private vulnerability reporting](../../security/advisories/new) instead. See [SECURITY.md](.github/SECURITY.md) for details.
---
## Contribution Workflow
This is the end-to-end journey from idea to merged PR. Each phase builds on the previous one.
### Phase 1: Find or Create an Issue
> **Issue first, PR second.** Every pull request must link to an approved issue. PRs without one will be closed.
- Browse [open issues](../../issues) for something you want to tackle.
- If you have a new idea, open one using the [bug report](../../issues/new?template=bug_report.yml) or [feature request](../../issues/new?template=feature_request.yml) template.
- Not sure if your idea fits the roadmap? Ask in a new issue before investing time.
### Phase 2: Get Maintainer Approval
Wait for explicit approval before writing code. Approval looks like:
- A :+1: reaction on your issue.
- A comment such as "go ahead", "approved", or "let's do it".
This step prevents wasted effort on changes that conflict with the project direction. Small bug fixes with clear reproduction are usually approved quickly.
### Phase 3: Fork and Clone
[Fork the repository](../../fork), then clone your fork:
```bash
git clone https://github.com/<your-username>/bookorbit.git
cd bookorbit
git remote add upstream https://github.com/bookorbit/bookorbit.git
```
> **Trusted contributors** with direct push access can skip the fork and work on branches in the main repository. The rest of this guide assumes the fork workflow.
### Phase 4: Set Up Your Environment
If this is your first time:
```bash
pnpm setup
```
This handles everything: dependencies, PostgreSQL, migrations, and seed data. See [DEVELOPMENT.md](DEVELOPMENT.md) for prerequisites, manual setup steps, and troubleshooting.
### Phase 5: Create Your Branch
Always branch from the latest `main`:
```bash
git fetch upstream
git checkout -b <prefix>/<short-description> upstream/main
```
Use these branch prefixes:
| Prefix | Use for | Example |
| ----------- | -------------------------------------- | -------------------------------- |
| `feat/` | New features | `feat/reading-progress-sync` |
| `fix/` | Bug fixes | `fix/pdf-page-count-off-by-one` |
| `refactor/` | Code restructuring | `refactor/library-scan-pipeline` |
| `docs/` | Documentation | `docs/opds-setup-guide` |
| `chore/` | Maintenance (deps, tooling, CI, build) | `chore/bump-drizzle-v1` |
### Phase 6: Implement Your Change
Start the dev server and get to work:
```bash
pnpm dev
```
Refer to [DEVELOPMENT.md](DEVELOPMENT.md) for architecture details, project structure, database workflows, and conventions. A few ground rules:
- **One logical change per PR.** Do not bundle a bug fix with a refactor or unrelated cleanup.
- **No unapproved dependencies.** Propose new dependencies in the linked issue first (see [Adding Dependencies](#adding-dependencies)).
- **Keep the scope tight.** If you discover something unrelated that needs fixing, open a separate issue for it.
### Phase 7: Write and Run Tests
Testing expectations depend on what you changed:
| Change type | What is expected |
| ----------------------- | ----------------------------------------------- |
| Bug fix | Regression test proving the bug is fixed |
| New backend feature/API | Server unit tests for the new behavior |
| New frontend logic | Client unit tests (composables, utilities) |
| UI-only change | Manual verification evidence (screenshot/video) |
| Refactor | Existing tests stay green, no new tests needed |
Run the full test suite:
```bash
pnpm test
```
If your change touches an area covered by an existing E2E suite, run it locally before pushing: `pnpm run e2e:run -- <suite-id>`. Use `pnpm run e2e:list` to see available suites.
For E2E tests, suite IDs, coverage thresholds, and the test harness, see [TESTING.md](TESTING.md).
### Phase 8: Verify Code Quality
The project has two automated gates that catch issues before they reach CI:
- **Pre-commit hook** runs `lint-staged` on your staged files (ESLint + Prettier).
- **Pre-push hook** runs `pnpm verify:fast` (lint + typecheck + tests). Your push is blocked if this fails.
Before marking your PR ready for review, run the full quality gate yourself:
```bash
pnpm verify
```
See the [Code Quality](DEVELOPMENT.md#code-quality) section in DEVELOPMENT.md for individual commands.
### Phase 9: Commit Your Work
Follow the format in [COMMIT_GUIDELINES.md](COMMIT_GUIDELINES.md). The short version:
```
<type>(<scope>): <summary>
```
- **Type:** `feat`, `fix`, `db`, `perf`, `refactor`, `style`, `docs`, `test`, `build`, `ci`, `chore`, `security`, `revert`
- **Scope** (optional): `auth`, `books`, `library`, `metadata`, `kobo`, `scanner`, etc.
- **Summary:** Imperative mood, lowercase, no period. Describe what the commit does, not what you did.
Examples: `feat(kobo): add shelf sync endpoint`, `fix(reader): correct page count for multi-volume PDFs`
### Phase 10: Open a Pull Request
Push your branch and open a PR against `main`:
```bash
git push origin feat/your-feature-name
```
When creating the PR:
- **Fill out the PR template.** It asks for a summary, testing evidence, screenshots (if UI changed), and non-obvious decisions.
- **Link your issue** with `Closes #123` in the description.
- **Open as a draft** if you want early direction feedback before the code is complete.
- **Disclose AI usage** if applicable. See [AI_POLICY.md](AI_POLICY.md) for the format and expectations.
### Phase 11: Respond to Review
- Expect feedback within a few days, sometimes sooner.
- Push additional commits to address review comments. Do not force-push during review unless a reviewer asks you to.
- Do not resolve review threads yourself. Let the reviewer confirm the fix.
- If feedback is unclear, ask for clarification in the thread.
### Phase 12: After Merge
Your changes ship in a future release when maintainers cut one. Keep your fork in sync for your next contribution:
```bash
git fetch upstream
git checkout main
git merge upstream/main
git push origin main
```
---
## Quick-Reference Checklist
Before marking your PR ready for review, confirm:
- [ ] Linked to an approved issue
- [ ] Branch is up to date with `main`
- [ ] One logical change per PR
- [ ] `pnpm verify` passes locally
- [ ] Tests included per the [testing expectations table](#phase-7-write-and-run-tests)
- [ ] Full-stack behavior manually validated
- [ ] UI changes include screenshot or recording
- [ ] PR template fully completed
- [ ] No unintended files (build artifacts, `.env`, personal configs)
- [ ] No unapproved new dependencies
---
## Policies
### Adding Dependencies
Do not add dependencies without prior discussion in the linked issue. If proposing one, document:
- What problem it solves.
- Why existing dependencies or a small custom implementation are insufficient.
- Maintenance status and community activity of the package.
### Breaking Changes and Self-Hosted Upgrades
BookOrbit is self-hosted. Any change that can break existing deployments must include a clear upgrade path before merge.
If your PR introduces a breaking change (schema, API contract, environment variable, config format):
- Call it out explicitly in the PR description.
- Document the required upgrade steps.
- Include Drizzle migrations for schema changes. Never hand-write migration SQL.
### AI-Assisted Contributions
See [AI_POLICY.md](AI_POLICY.md). The short version: disclose all AI usage in your PR description and make sure you understand every line of the diff.
---
## License
By contributing, you agree that your contributions are licensed under the same license as the project (AGPL-3.0).
+516
View File
@@ -0,0 +1,516 @@
# Development Guide
Everything you need to work in this codebase: architecture, setup, workflows, conventions, and commands.
For the contribution process (issues, PRs, review), see [CONTRIBUTING.md](CONTRIBUTING.md). For test architecture and E2E suite details, see [TESTING.md](TESTING.md).
---
## Architecture Overview
BookOrbit is a pnpm monorepo with three workspaces: a Vue 3 single-page app, a NestJS API server, and a shared types package.
```mermaid
graph LR
Client["Client<br/><small>Vue 3 SPA</small><br/><small>localhost:5173</small>"]
Server["Server<br/><small>NestJS + Fastify</small><br/><small>localhost:3000/api</small>"]
DB["PostgreSQL<br/><small>Drizzle ORM</small>"]
WS["WebSocket<br/><small>Socket.IO</small>"]
Types["Shared Types<br/><small>@bookorbit/types</small>"]
Client -- "HTTP (fetch)" --> Server
Client <-. "real-time events" .-> WS
Server --> DB
Server --> WS
Types -.- Client
Types -.- Server
```
| Layer | Tech | Location |
| ------------ | ------------------------------- | ----------------- |
| Frontend | Vue 3, Tailwind CSS v4, Vite | `client/` |
| Backend | NestJS 11, Fastify, Drizzle ORM | `server/` |
| Database | PostgreSQL 16, pgvector | Docker container |
| Shared types | TypeScript | `packages/types/` |
| Real-time | Socket.IO | Server + Client |
---
## Project Structure
```text
bookorbit/
├── client/ # Vue 3 frontend
│ └── src/
│ ├── features/ # Feature modules (composables + components)
│ ├── components/ui/ # Shared UI components
│ ├── lib/ # API client, utilities
│ ├── router/ # Vue Router config
│ └── assets/ # Tailwind theme, static assets
├── server/ # NestJS backend
│ └── src/
│ ├── modules/ # Feature modules (controller + service + dto)
│ ├── db/
│ │ ├── schema/ # Drizzle table definitions (one file per domain)
│ │ └── migrations/ # Generated SQL migrations
│ ├── common/ # Guards, filters, decorators, pipes
│ └── config/ # Typed config (app, db, auth)
│ └── test/
│ ├── e2e/ # E2E test helpers and harness
│ └── *.e2e-spec.ts # E2E test suites
├── packages/
│ └── types/ # Shared TypeScript types
│ └── src/ # Exported via @bookorbit/types
├── scripts/ # Bootstrap, DB reset, E2E orchestration
├── docker/ # Postgres init scripts (extensions)
├── .github/ # CI workflows, PR/issue templates
└── .husky/ # Git hooks (pre-commit, pre-push)
```
> **Shared types** live in `packages/types/` and are imported as `@bookorbit/types`. Never duplicate types between client and server.
---
## Prerequisites
- **Node.js** >= 24 - [nodejs.org](https://nodejs.org)
- **pnpm** >= 9 - `npm install -g pnpm` (or see [pnpm.io](https://pnpm.io/installation))
- **Docker** - [docs.docker.com/get-started/get-docker](https://docs.docker.com/get-started/get-docker/)
---
## First-Time Setup
### Option A: One Command
```bash
pnpm setup
```
This runs all the steps below automatically. If it succeeds, skip to [Running the App](#running-the-app).
---
### Option B: Step by Step
#### Step 1 - Clone the repository
```bash
git clone https://github.com/<your-username>/bookorbit.git
cd bookorbit
```
If you are an external contributor, fork first and clone your fork. See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
#### Step 2 - Create your environment file
```bash
cp server/.env.example server/.env
```
Only do this once. If `server/.env` already exists, skip this step. Copying again will overwrite any local changes you have made.
The defaults in `.env.example` work out of the box for local development. You only need to edit this file if you want to change ports, point to a different database, or configure optional features like email.
See the [Key Environment Variables](#key-environment-variables) section for what each variable does.
#### Step 3 - Install dependencies
```bash
pnpm install
```
This installs dependencies for all three workspaces (server, client, packages/types) in one shot.
#### Step 4 - Start PostgreSQL
```bash
docker compose -f docker-compose.dev.yml up -d --wait
```
This starts a PostgreSQL 16 container (with the `pgvector` extension) on port 5432. The `--wait` flag blocks until the database is healthy. Data is persisted in a named Docker volume across restarts.
> **Already have PostgreSQL running?** You can point `DATABASE_URL` in `server/.env` at your existing instance, but you will still need the `uuid-ossp`, `pg_trgm`, and `vector` extensions installed. Using Docker is the easiest path.
#### Step 5 - Apply database migrations
```bash
pnpm run db:migrate
```
This creates all tables and indexes. Migrations are generated by Drizzle Kit and live in `server/src/db/migrations/`.
#### Step 6 - Seed baseline data
```bash
pnpm run db:seed
```
This populates the database with the data required for the app to function (default roles, permissions, initial settings). You only need to run this once.
#### Step 7 - Start the app
```bash
pnpm dev
```
If everything worked, you will see the server, client, and types watcher start up. The app is ready when you see output from all three processes.
---
### Verifying Your Setup
| Check | Expected |
| ---------------------------------------- | ------------------------------------------ |
| Open http://localhost:5173 | BookOrbit login page loads |
| Open http://localhost:3000/api/v1/health | Returns `{"status":"ok"}` |
| `docker ps` | Shows `bookorbit-dev-db` container running |
---
## Running the App
```bash
pnpm dev
```
This starts the server, client, and types watcher concurrently with hot reload.
| Service | URL |
| ------- | ------------------------- |
| Client | http://localhost:5173 |
| API | http://localhost:3000/api |
If PostgreSQL is not running, start it first with `pnpm run db:up`.
<details>
<summary>Running workspaces separately</summary>
```bash
# Terminal 1: Server
cd server && pnpm start:dev
# Terminal 2: Client
cd client && pnpm dev
```
If you are editing shared types (`packages/types/`), also start the types watcher:
```bash
# Terminal 3: Types (only needed when editing packages/types/)
pnpm --filter @bookorbit/types dev
```
</details>
---
## Key Environment Variables
These are the variables you are most likely to need during development. See `server/.env.example` for the full list with comments.
| Variable | Purpose | Default (dev) |
| --------------- | ---------------------------------- | --------------------------------------------------------- |
| `DATABASE_URL` | PostgreSQL connection string | `postgres://bookorbit:bookorbit@localhost:5432/bookorbit` |
| `PORT` | Server listen port | `3000` |
| `NODE_ENV` | Runtime mode | `development` |
| `JWT_SECRET` | Signing key for auth tokens | `change-me-in-production` |
| `APP_DATA_PATH` | Storage for covers, avatars, cache | `../local/data` |
| `APP_URL` | Base URL for email links | `http://localhost:5173` |
---
## Database
### Schema Organization
Database tables are defined as Drizzle `pgTable()` calls in `server/src/db/schema/`, one file per domain (e.g. `books.ts`, `auth.ts`, `kobo.ts`). All files are re-exported from `server/src/db/schema/index.ts`.
Type inference uses `typeof table.$inferSelect` and `$inferInsert`. Never write manual type aliases for DB rows.
### Common Commands
| Command | What it does |
| -------------------------------------- | ----------------------------------------------- |
| `pnpm run db:up` | Start PostgreSQL via Docker |
| `pnpm run db:migrate` | Apply pending migrations |
| `cd server && pnpm db:generate <name>` | Generate a migration from schema changes |
| `pnpm run db:seed` | Seed baseline data |
| `pnpm run db:reset` | Drop everything, re-migrate, re-seed (dev only) |
| `cd server && pnpm db:studio` | Open Drizzle Studio (visual DB browser) |
### Making a Schema Change
1. Edit the relevant file in `server/src/db/schema/`.
2. Generate a migration: `cd server && pnpm db:generate describe-your-change`
3. Review the generated SQL in `server/src/db/migrations/`.
4. Apply it: `pnpm run db:migrate`
Never hand-write migration SQL. Always let Drizzle Kit generate it from schema diffs.
### Migration Strategy
**Before production launch (current phase):** We maintain a single baseline migration (`0000_*.sql`). When adding new tables or columns, wipe the migrations folder and regenerate:
```bash
rm -rf server/src/db/migrations
docker compose -f docker-compose.dev.yml down -v && docker compose -f docker-compose.dev.yml up -d --wait
cd server && pnpm db:generate baseline && pnpm run db:migrate
```
**After production launch:** Never wipe migrations. Every schema change gets its own incremental migration.
### Full Reset
When local migration state is broken or you need a clean slate:
```bash
pnpm run db:reset
```
If that is insufficient (e.g. corrupted Docker volumes), stop the container with `-v` to drop volumes, then start fresh:
```bash
docker compose -f docker-compose.dev.yml down -v
pnpm run db:up
pnpm run db:migrate
pnpm run db:seed
```
---
## Testing
| Command | What it runs |
| -------------------------------- | -------------------------------- |
| `pnpm run test` | Server + client unit tests |
| `pnpm run test:server` | Server unit tests only |
| `pnpm run test:client` | Client unit tests only |
| `cd server && pnpm test:watch` | Server tests in watch mode |
| `pnpm run e2e:run -- <suite-id>` | Run a single E2E suite |
| `pnpm run e2e:all` | Run all E2E suites sequentially |
| `pnpm run e2e:list` | List available E2E suite IDs |
| `pnpm run coverage` | Unit tests with coverage reports |
Server unit tests use Vitest with `@nestjs/testing`. Client tests use Vitest with `@vue/test-utils` in a jsdom environment. Most E2E suites boot the full app against a dedicated test database; a few smoke-style suites run against the dev database.
For test architecture, suite details, coverage thresholds, the E2E harness, and examples, see [TESTING.md](TESTING.md).
---
## Code Quality
### Quality Gates
| Command | What it checks | When to use |
| ------------------------ | -------------------------------------------- | ---------------------------- |
| `pnpm run verify:fast` | Lint + typecheck + tests | Quick check while developing |
| `pnpm run verify` | Lint + typecheck + tests (full) | Before opening a PR |
| `pnpm run verify:strict` | Format check + lint + full typecheck + tests | Strictest gate |
### Individual Checks
| Command | What it does |
| ----------------------- | -------------------------------------- |
| `pnpm run lint:check` | ESLint across server + client |
| `pnpm run lint:fix` | ESLint auto-fix across server + client |
| `pnpm run typecheck` | TypeScript type checking |
| `pnpm run format` | Prettier format across server + client |
| `pnpm run format:check` | Prettier check (no write) |
### Git Hooks
Two hooks run automatically via [Husky](https://typicode.github.io/husky/):
- **Pre-commit:** Runs `lint-staged` on staged files. This auto-fixes lint issues and formats code with Prettier. You do not need to format manually before committing.
- **Pre-push:** Runs `pnpm verify:fast`. Your push is blocked if lint, typecheck, or tests fail.
---
## Must-Know Conventions
These are the rules that most commonly cause PR rejections. Following them from the start will save you review cycles.
### Backend
- **One module per feature.** Each domain lives in `server/src/modules/<feature>/` with its own controller, service, module, and `dto/` subfolder.
- **DTOs for all input.** Request bodies are validated via `class-validator` decorators on DTO classes. The global `ValidationPipe` rejects unknown fields.
- **Throw NestJS exceptions.** Use `NotFoundException`, `BadRequestException`, `ForbiddenException`, etc. Never throw raw `Error`.
- **Multi-user ownership.** User-owned data needs a `userId` foreign key. Services must check ownership and throw `ForbiddenException` for non-owners.
### Frontend
- **Composition API only.** Every component uses `<script setup lang="ts">`. Never use the Options API.
- **Bare method references in templates.** Write `@click="handleFoo"`, not `@click="handleFoo()"` or `@click="() => handleFoo()"`. This is enforced by ESLint and will block commits.
- **Composables over stores.** Feature-local state lives in composables (`features/<name>/composables/use*.ts`). Use Pinia only when state genuinely needs to be shared app-wide.
- **Native fetch only.** HTTP calls use the native `fetch` API. No axios or other HTTP clients.
---
## CI/CD Pipeline
### What Runs on Pull Requests
The CI workflow (`.github/workflows/ci.yml`) triggers on every PR to `main`:
1. **Dependency review** - Checks for supply chain issues in new dependencies.
2. **Change detection** - Determines which workspaces (server/client) have changed files.
3. **Lint** - Runs ESLint and format checks on changed workspaces.
4. **Type check** - Runs TypeScript checking on changed workspaces.
5. **Tests** - Runs unit tests on changed workspaces with coverage.
6. **E2E** - Selects and runs relevant E2E suites based on changed file paths.
All jobs are conditional: if you only changed client code, server lint/typecheck/tests are skipped.
### What Runs on Main
The same pipeline plus:
- **Container image build** (`.github/workflows/container-image.yml`) - Builds a Docker image and pushes to GitHub Container Registry.
### Releases
Releases are created manually via `workflow_dispatch` on the `release.yml` workflow. The release workflow:
1. Verifies CI has passed for the current `main` commit.
2. Runs semantic-release to determine the next version from commit history.
3. Creates a Git tag and GitHub Release with auto-generated release notes.
4. Builds, scans, and publishes a Docker image with version and `latest` tags.
PR titles are validated by commitlint to ensure they follow the [commit guidelines](COMMIT_GUIDELINES.md). Only releasable types (`feat`, `fix`, `perf`, `security`, `db`, `style`) trigger version bumps.
For the full release runbook, see [`docs/RELEASE_PROCESS.md`](docs/RELEASE_PROCESS.md).
### E2E in CI
E2E suites are dispatched automatically based on which files changed. On nightly builds and manual dispatches, all suites run. Each suite runs in its own job with a dedicated PostgreSQL service container.
### Reading CI Failures
- **Red lint job** - Run `pnpm run lint:check` locally to see the errors.
- **Red typecheck job** - Run `pnpm run typecheck` locally.
- **Red test job** - Run `pnpm run test` locally. Check the test report artifact for details.
- **Red E2E job** - Run `pnpm run e2e:run -- <suite-id>` locally. Ensure your E2E database is prepared with `pnpm run e2e:db:prepare`.
---
## Troubleshooting
**Migrations fail after pulling new changes**
```bash
pnpm run db:migrate
```
If that fails (e.g. conflicting migration state), do a full reset:
```bash
pnpm run db:reset
```
**Type errors after pulling**
Rebuild dependencies and types:
```bash
pnpm install
```
The shared types package rebuilds automatically during install.
**Pre-push hook is failing**
Run the same command the hook uses to see the full error output:
```bash
pnpm run verify:fast
```
**E2E tests failing locally**
Make sure the E2E database is prepared:
```bash
pnpm run e2e:db:prepare
pnpm run e2e:run -- <suite-id>
```
**Port already in use**
The server defaults to port 3000 (configured via `PORT` in `server/.env`) and the client to 5173 (Vite default). If either is occupied, find the process that owns it and stop it, or change the server port in `server/.env`.
**Hot reload not picking up changes**
Restart the dev server. If you changed `package.json` or installed new dependencies, run `pnpm install` first.
**Docker volumes corrupted**
Nuclear option. This destroys all local data. Stop the container with `-v` to drop volumes, then rebuild:
```bash
docker compose -f docker-compose.dev.yml down -v
pnpm run db:up
pnpm run db:migrate
pnpm run db:seed
```
---
## Command Reference
All commands are run from the repository root unless noted otherwise.
### Setup
| Command | Description |
| -------------- | ---------------------------------------------------- |
| `pnpm setup` | One-command bootstrap (env, deps, DB, migrate, seed) |
| `pnpm install` | Install all workspace dependencies |
### Development
| Command | Description |
| --------------------- | --------------------------------------------- |
| `pnpm dev` | Start server + client + types with hot reload |
| `pnpm run dev:server` | Start server only |
| `pnpm run dev:client` | Start client only |
### Database
| Command | Description |
| -------------------------------------- | ---------------------------------------- |
| `pnpm run db:up` | Start PostgreSQL via Docker |
| `pnpm run db:migrate` | Apply pending migrations |
| `cd server && pnpm db:generate <name>` | Generate migration from schema changes |
| `pnpm run db:seed` | Seed baseline data |
| `pnpm run db:reset` | Drop, re-migrate, and re-seed (dev only) |
| `cd server && pnpm db:studio` | Open Drizzle Studio (DB browser) |
### Testing
| Command | Description |
| -------------------------------- | -------------------------------- |
| `pnpm run test` | Run server + client unit tests |
| `pnpm run test:server` | Run server unit tests |
| `pnpm run test:client` | Run client unit tests |
| `cd server && pnpm test:watch` | Server tests in watch mode |
| `pnpm run coverage` | Unit tests with coverage reports |
| `pnpm run coverage:server` | Server coverage only |
| `pnpm run coverage:client` | Client coverage only |
| `pnpm run e2e:run -- <suite-id>` | Run a single E2E suite |
| `pnpm run e2e:all` | Run all E2E suites |
| `pnpm run e2e:list` | List available suite IDs |
| `pnpm run e2e:db:prepare` | Prepare the E2E database |
### Code Quality
| Command | Description |
| ------------------------ | -------------------------------------------- |
| `pnpm run verify` | Full quality gate (lint + typecheck + tests) |
| `pnpm run verify:fast` | Quick quality gate |
| `pnpm run verify:strict` | Strictest gate (adds format check) |
| `pnpm run lint:check` | ESLint check (server + client) |
| `pnpm run lint:fix` | ESLint auto-fix (server + client) |
| `pnpm run typecheck` | TypeScript type checking |
| `pnpm run format` | Prettier format (server + client) |
| `pnpm run format:check` | Prettier check without writing |
@@ -22,7 +22,7 @@ All PRs to `main` must use **squash merge**. The squash commit message becomes t
### PR title format
PR titles must follow the [commit guidelines](../COMMIT_GUIDELINES.md):
PR titles must follow the [commit guidelines](COMMIT_GUIDELINES.md):
```
<type>(<scope>): <summary>
+341
View File
@@ -0,0 +1,341 @@
# 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. Don't cover the same thing 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 don't 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 customise 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`:
- **Pull request:** The smoke suite (`guard-mechanics`) always runs. Full-lane suites run only
when their `changedPaths` match the PR diff.
- **Push to main / nightly / manual dispatch:** All 18 suites run.
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>
```