Write automated tests for the issue tracker in this repo. Treat the acceptance criteria below as the spec. Use Vitest for pure functions and API routes against an on-disk temp SQLite database, and Playwright for keyboard and realtime flows. Mock only the Slack webhook and SMTP at the module boundary with a fetch mock and a nodemailer stub that record calls. GitHub webhooks are tested by posting signed fixture payloads to the real route. Nothing else is mocked: the database, files, jobs, sessions, and the outbox are all real and local.

## Acceptance criteria to cover

1. The admin creates an invite for `priya@example.com`; one `.eml` appears in the outbox with the link; following the link with a name and password creates a member and a session. With `ALLOWED_EMAIL_DOMAINS=example.com`, inviting `x@other.com` returns 400. Creating team ENG and one issue yields ENG-1.
2. Twenty concurrent `POST /api/issues` for one team return ENG-2 through ENG-21 with no duplicates and no gaps. Use `Promise.all`.
3. Creating an issue with `c` shows the row before the response. With the API forced to 500, the row disappears and a toast with a Retry button appears (Playwright, route interception).
4. A state change in one browser context appears in a second context within two seconds without reload, and the second context's `/api/events` stream carried the event (Playwright, two contexts).
5. Selecting three issues with `x`, pressing `a`, and choosing a user assigns all three, writes three `issue_history` rows with `field = assignee`, and creates three `assigned` notifications.
6. `cmd+k` then `eng-7` then enter navigates to `/issue/ENG-7`. `cmd+k` then `login redirect` lists matching issues, best match first.
7. Markdown rendering: a GFM table renders as `<table>`, `ENG-3` inside a fenced code block is plain text, a bare `ENG-3` outside it is a link to `/issue/ENG-3`.
8. A signed `pull_request` `opened` fixture with branch `dan/eng-15-fix-login` links the PR to ENG-15 and moves it from Todo to In Progress with `source = github`. Re-posting the same `X-GitHub-Delivery` changes nothing. A bad signature returns 401 and changes nothing. `POST /api/webhooks/github/simulate` with `{ fixture: "pr-opened" }` as admin produces the same result.
9. A signed `closed` fixture with `merged = true` moves ENG-15 to Done, sets `completed_at`, and inserts one `slack_outbox` row. A `closed` with `merged = false` sets the PR to `closed` and leaves the issue state unchanged.
10. A PR body of `Fixes ENG-15 and DES-4` links both; `ZZZ-9` for a team key that doesn't exist is ignored.
11. Cycles job: with `cycle_start_weekday = 1`, `cycle_length_weeks = 2`, timezone `America/Los_Angeles`, and an injected clock, cycle 1 starts Monday 00:00 Pacific (07:00 or 08:00 UTC depending on DST). Advancing the clock past `ends_at` completes it, records `total_scope` and `completed_scope`, and moves unfinished issues to cycle 2 with `source = cycle_rollover`. A cycle spanning the March DST change ends at Monday 00:00 Pacific, and its UTC length is 13 days 23 hours.
12. Three state changes on one issue within 30 seconds produce one Slack POST whose body mentions all three states. A Slack 500 is retried up to 3 times and the original mutation still returns 200.
13. Importing a 500-row Linear CSV fixture creates 500 issues with matching identifiers, states, priorities, labels, and assignees, and the next created issue gets `max number + 1`.
14. `/api/search?q=login` returns in under 50ms with 20,000 seeded issues; `?q=ENG-12` returns ENG-12 first.
15. A fresh data directory with no env vars boots, `GET /` redirects to `/setup`, and `POST /api/setup` creates an admin and a session. A second `POST /api/setup` returns 403.
16. `docker compose config` validates and lists `app`, `caddy`, and `backup`; the `Caddyfile` references `DOMAIN`. (Serving HTTPS on a real VPS is a manual check; don't fake it.)
17. Job lock: two concurrent `runJob("cycles")` calls execute the body once. A job that throws mid-run releases its lock, and the next run completes the work without repeating the part that already committed. A lock whose `expires_at` is in the past is taken over.
18. `pnpm backup` writes `./data/backups/<date>.db`; deleting `app.db`, copying the backup back, and rebooting returns the same row counts and the same issue identifiers.
19. With `SMTP_URL` unset, creating an invite writes one `.eml` to the outbox containing the invite link and no `sendMail` call happens. With `SMTP_URL` set, the nodemailer stub receives the message and the outbox stays empty.
20. `cmd+z` after a state change reverts the state and writes a second `issue_history` row.
21. A draft PR opened against a Todo issue links it but does not move it; marking it ready for review moves it to In Progress.

## Test layout

- Each test file opens its own database at `<os tmpdir>/linear-tests/<file>.db`, runs migrations, and deletes the file in `afterAll`. Set `DATA_DIR` to a matching temp folder so files, the outbox, and backups stay isolated. Never touch `./data`.
- `tests/unit/identifiers.test.ts`: branch, title, and body extraction; magic words; unknown team keys; case insensitivity; no matches inside fenced code blocks or code spans. Covers 7 and 10.
- `tests/unit/cycles.test.ts`: table-driven cases for 11. Each case is `{ name, timezone, startWeekday, lengthWeeks, cooldownWeeks, now, expectStartsAtUtc, expectEndsAtUtc }`. Include the March and November US DST changes, a Sunday start, and a one-week cooldown.
- `tests/unit/slack.test.ts`: message formatting for each event kind and coalescing of three events into one message. Covers the formatting half of 12.
- `tests/unit/markdown.test.ts`: rendering rules for 7, including that mentions render as names and task-list checkboxes carry a data attribute for toggling.
- `tests/unit/jobs.test.ts`: the lock claim statement against a temp database. Covers 17, with a job body that commits one team then throws.
- `tests/integration/`: API routes against the temp SQLite file. Covers 1, 2, 5, 8, 9, 10, 12, 13, 14, 15, 18, 19, 21. Sign webhook fixtures with `GITHUB_WEBHOOK_SECRET` in a helper; never hand-write a signature. Delete from every table between tests. For 18, shell out to the backup script and assert on the file.
- `tests/e2e/`: Playwright. Covers 3, 4, 6, 20. Start the app with a temp `DATA_DIR`, seed it with the seed script before each spec, and sign in through `/api/auth/login` with a seeded user. Use two browser contexts for 4 and route interception for the forced 500 in 3.
- `tests/deploy.test.ts`: covers 16 by running `docker compose config` and reading the `Caddyfile`. Skip if `docker` isn't on the path.

## Fixtures

- `fixtures/github/pr-opened.json`, `pr-synchronize.json`, `pr-ready-for-review.json`, `pr-closed-merged.json`, `pr-closed-unmerged.json`, `push.json`, each shaped like GitHub's documented payloads with `head.ref`, `title`, `body`, `merged`, `draft`, and `X-GitHub-Delivery` set in the test. The simulate button and the tests share these files.
- `fixtures/linear-export.csv`: 500 rows with Linear's column names (`ID`, `Title`, `Description`, `Status`, `Priority`, `Assignee`, `Labels`, `Project`, `Cycle Number`, `Created`, `Completed`), including rows with empty assignee, multiple labels, and a status name that doesn't exist yet.
- A `seed:perf` script that inserts 20,000 issues for criterion 14.

## Rules

- Name every test after its criterion: `test("AC8: opened PR links by branch name and moves issue to In Progress")`.
- Freeze time with `vi.useFakeTimers()` or an injected clock for cycles, the job lock expiry, and Slack coalescing. Never depend on the real date.
- Use the fixtures in `fixtures/github/` and `fixtures/linear-export.csv`. If they don't exist, create realistic ones from GitHub's documented webhook payload shape and Linear's CSV column names.
- Run the search benchmark on the seeded 20,000 issues and assert on wall time; skip it in CI if `CI_SKIP_PERF` is set.
- Add `pnpm test` and a GitHub Actions workflow that runs it. No services needed: `pnpm install`, install Playwright browsers, run the suite. SQLite and the outbox are files in the runner's temp dir.
- Run the whole suite. Fix the app where the app is wrong and the test where the test is wrong. Report per-criterion pass/fail and what you changed.
