Write automated tests for the self-hosted map stack in this repo. Treat the acceptance criteria below as the spec. Use Vitest for the `/v1` routes, the style route, the tile fallback route, the scheduler, and pure helpers. Use Playwright for the demo page and the pixel checks on static images. Every test file gets its own on-disk SQLite database: create a temp dir, set `DATA_DIR` to it, run the migrations, and delete it in `afterAll`. Never share `app.db` between files and never use `:memory:` (WAL and `busy_timeout` behave differently). Run Photon, Valhalla, and the renderer as real containers from `docker-compose.test.yml` using the small sample region; mock nothing that has a container. Mock only the outside downloads (the Protomaps build, the Geofabrik PBF, the Photon extract) by pointing the scripts at fixture files, and never call a real SMTP server. Freeze time with `vi.useFakeTimers()` wherever a UTC day boundary matters.

## Acceptance criteria to cover

1. After `tiles:register` against the sample archive, the `tilesets` row and `GET /v1/tilesets/<slug>` both report the bounds, `minzoom`, `maxzoom`, and `vector_layers` read from the PMTiles header, and the URL starts with `pmtiles://` on `PUBLIC_URL`.
2. `GET /tiles/<slug>.pmtiles` with `Range: bytes=0-16383` returns 206, `Content-Range`, `Accept-Ranges: bytes`, `Cache-Control: public, max-age=86400`, and an `ETag`; `If-None-Match` with that ETag returns 304; a range past the end returns 416. Run this against the app's fallback route in Vitest and against Caddy in the compose e2e run.
3. A style fetch with no key returns 401. Revoking a key makes the next style fetch return 403 with no delay. `allowed_origins = ["https://example.com"]` gives 403 for `Origin: https://evil.com` and 200 for `Origin: https://example.com`. A tile range request with no key returns 206.
4. One style fetch plus 200 tile range requests increments `usage_daily` for `map_load` by exactly 1 and never writes a `tile` row that counts against limits.
5. The demo page loads and renders labels at zoom 12 and 16; every network request host equals `PUBLIC_URL`. Assert on the captured request list.
6. Geocoding a known house address returns `[lon, lat]` within 100 m of the fixture's truth and `match_level = "house"`. A `bbox` that excludes it removes it. `proximity` near the duplicate-name town moves that town's result to index 0.
7. Reverse for a fixture point returns the fixture's street. `lat = 95` returns 400 `invalid_coordinates`. Swapped lon/lat (ocean) returns 404 `no_result`. `lon = "abc"` returns 400.
8. A 1,000-row batch job (fixture `fixtures/addresses-1000.csv`, with 50 rows that can't match) produces an output CSV with 1,000 rows in original order, all original columns plus `lon`, `lat`, `match_level`, `matched_address`, `error`, 50 rows with `error = "no_match"` and empty coordinates, and a `geocode` usage increase of 1,000. The download link is signed and returns 403 after advancing fake time 16 minutes. Kill the app process after `done_rows >= 400`, restart it, and assert the output still has exactly 1,000 rows with no duplicates.
9. A route between two fixture points returns a `LineString` whose endpoints are within 50 m of `from` and `to`, positive `distance_m` and `duration_s`, and at least one step. `walking` duration exceeds `driving` duration for the 5 km fixture. An ocean `to` returns 404 `no_route`. Decoding uses precision 6: assert the first coordinate is inside the region bbox.
10. `/v1/static/<center>,14/200x200.png?markers=<center>,ff0000` is a 200 × 200 PNG, pixel (100, 100) has `r > 200, g < 60, b < 60`, and `DATA_DIR/files/renders/<hash>.png` exists afterward. The second request returns `X-Cache: HIT` in under 100 ms. `@2x` yields 400 × 400. Width 2000 returns 400. 51 markers returns 400.
11. `recordUsage` with `at = "2026-09-10T23:59:30Z"` and `at = "2026-09-11T00:00:30Z"` yields rows on `2026-09-10` and `2026-09-11`. Run this test twice, once with `TZ=America/Los_Angeles` and once with `TZ=Asia/Tokyo`, and assert identical rows.
12. A key with `monthly_limits.geocode = 100` returns 200 for 100 requests and 429 on the 101st with `Retry-After` equal to the seconds until the first of next month at 00:00 UTC under fake time. Advancing fake time into the next month makes request 102 return 200. Exactly one `.eml` in the outbox mentions the key crossing 80%.
13. `docker compose -f docker-compose.test.yml down && up -d` keeps the sample archive, the Photon index, the Valhalla graph, and `app.db`; the health checks pass without any download and the demo page works.
14. A fresh clone with no env vars: `pnpm dev` boots, `GET /` redirects to `/setup`, and posting the form creates one `users` row and a session cookie. With `ADMIN_EMAIL` and `ADMIN_PASSWORD` set, the admin exists before the first request.
15. In the compose e2e run, `https://<test host>/` returns 200 with a valid certificate chain (use Caddy's internal CA for the test and trust it in the Playwright context).
16. Job lock: start two app instances against the same `DATA_DIR` with one queued job. Exactly one `job_locks` row for `geocode_runner` exists, only one instance writes rows, and the second logs that it skipped. Kill the holder, advance fake time past `expires_at`, and assert the survivor takes the lock and finishes with 1,000 rows and no duplicates.
17. `pnpm backup` writes `DATA_DIR/backups/<date>.db`; delete `app.db`, copy the backup back, restart, and assert every table has the same row count and the same ids as before. Fifteen backups prune to fourteen.
18. With `SMTP_URL` unset, inviting a teammate and finishing a batch job each write one `.eml` to `DATA_DIR/outbox/` with the right `To:` and a link that starts with `PUBLIC_URL`. With `SMTP_URL` set to a stubbed nodemailer transport, the outbox stays empty and the stub receives both messages.

## Fixtures

- `fixtures/region.sample.json`: a tiny bbox (one small town) so Photon and Valhalla start fast. Every coordinate fixture below lives inside it.
- `fixtures/sample.pmtiles`: the archive for that bbox, checked in (a few MB), so `tiles:build` never runs in tests.
- `fixtures/addresses.json`: 10 house addresses with true `[lon, lat]`, plus one street name that exists in two towns for the proximity test.
- `fixtures/addresses-1000.csv`: 950 matchable rows and 50 junk rows, with a UTF-8 BOM, quoted commas, and CRLF line endings on purpose.
- `fixtures/routes.json`: a 5 km driving pair, a walking pair, and one pair with `to` in the ocean.
- `fixtures/valhalla-shape.json`: one real Valhalla `shape` string and its decoded coordinates, for the polyline6 unit test.
- `fixtures/keys.json`: a browser key with origins, a server key with no origins, a revoked key, and an expired temp key, all with known hashes.

## Container setup

- `docker-compose.test.yml` uses the sample region and named volumes so a second run doesn't rebuild Photon or Valhalla.
- `tests/setup/global.ts` waits for `GET /api?q=test` on Photon, `GET /status` on Valhalla, and `GET /healthz` on the renderer, up to 10 minutes, then copies `fixtures/sample.pmtiles` into the test `DATA_DIR/tiles/` and runs `tiles:register`.
- The app under test runs with `PUBLIC_URL` pointing at itself in Vitest and at Caddy in the e2e run.

## Layout

- `tests/unit/`: pure helpers. `parseLonLat`, `validateCoords`, bbox parsing, the polyline6 decoder (assert on a known Valhalla shape string), the UTC day truncation, the `Retry-After` calculation, the origin glob matcher, the signed URL signer and verifier, the static cache key hash (parameter order must not change the hash).
- `tests/integration/`: the style route, tile fallback, `/v1` routes, scheduler, backup, and mail against a temp SQLite and the test containers, for 1 through 4, 6 through 12, 14, and 16 through 18.
- `tests/e2e/`: Playwright against the compose stack for 5, 13, 15, and the pixel check in 10.

## Rules

- Name every test after its criterion: `test("AC2: range request on the archive returns 206")`.
- Coordinates in fixtures are `[lon, lat]`. Add a unit test that fails if any fixture has `|first| > 90 && |second| <= 90` swapped by mistake.
- Never depend on the real clock or the machine's timezone. Inject `now` or use fake timers.
- Add `pnpm test` and a GitHub Actions workflow that starts the compose test stack, waits for the health endpoints, and runs the suite. No services needed beyond Docker on the runner: no accounts, no hosted anything, no secrets in CI.
- Run the suite. Fix the app where the app is wrong and the test where the test is wrong. Report per-criterion pass/fail and what changed.
