~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
278 lines · 21.2 KB Raw History
# Server test coverage

**Goal: 80% statement coverage of `server/**/*.ts`. Met — 81.97%, then 75.02%
after groups landed.**

That drop is a denominator, not a regression: excluding spaces the server is at
**87.67%**, up from the 81.97% that met the goal. See the last ledger row.
Baseline when this started, 2026-09-03: **30.68%** (623/2030 statements).

Spaces is out of scope (see below); excluding it the rest of `server/` is at
**94.2%**. What remains uncovered outside spaces is roughly 100 statements of
`JSON.parse` fallbacks against malformed columns the indexer cannot write, and
a handful of similar defensive branches. They are listed below but are not
worth chasing — several are provably unreachable.

This file is the running state of that work. Each pass updates the ledger at the
bottom, so the next pass can start by reading it instead of re-deriving where
things stand.

## How to measure

```sh
npm run test:coverage
```

Coverage runs from `vitest.coverage.config.ts`, not `vite.config.ts` — the
reason is in the header comment of that file and in "The vitest overrides" in
AGENTS.md. Scope is `server/**/*.ts` only: `app/lib` is browser code with no DOM
test setup and `.svelte` files cannot be instrumented at all right now, so
counting either would only inflate the denominator with unreachable lines.

The config carries a **ratchet**: `thresholds.autoUpdate` rewrites the floor to
whatever the suite actually reached on each run. Coverage can go up and never
quietly slide back. The rewritten numbers are part of the commit.

## How to add tests here

Match `test/`, don't invent a second style.

- `startTestServer` from `@hatk/hatk/test` gives a real server over a real
  SQLite database. Seed rows with `db.run`, then assert on `server.fetch` of the
  XRPC route. `test/cameras.test.ts` and `test/locations.test.ts` are the
  clearest models.
- Helper modules that are pure functions get imported and called directly —
  `test/formatLocation.test.ts` and `test/tid.test.ts` are the model.
- Table names contain dots, so bracket them in SQL: `[social.grain.photo]`.
- Test what the code is _for_, not the lines. A test that only walks a branch to
  move a number is worse than no test, because it locks in whatever the code
  does today. Every test should be able to fail for a reason someone would care
  about.
- `test/spaces.live.test.ts` is skipped by default — it needs live pds.js
  instances. Leave it that way.

## Order of work

Ranked by uncovered statements, which is roughly the ratio of number-moved to
effort. Refreshed each pass from `coverage/coverage-summary.json`, so this is
current state, not the original snapshot.

| area             | uncovered | of   | covered |
| ---------------- | --------- | ---- | ------- |
| `server/xrpc`    | 233       | 1022 | 77.2%   |
| `server/spaces`  | 92        | 122  | 24.6%   |
| `server/feeds`   | 18        | 244  | 92.6%   |
| `server/og`      | 10        | 184  | 94.6%   |
| `server/hooks`   | 7         | 125  | 94.4%   |
| `server/hydrate` | 6         | 146  | 95.9%   |
| `server/helpers` | 5         | 175  | 97.1%   |

Biggest single files still outstanding:

| file                              | uncovered | covered |
| --------------------------------- | --------- | ------- |
| `server/xrpc/getLocations.ts`     | 13/102    | 87.2%   |
| `server/xrpc/mentionSearch.ts`    | 9/45      | 80.0%   |
| `server/hydrate/galleries.ts`     | 5/83      | 94.0%   |
| `server/xrpc/getCommentThread.ts` | 5/56      | 91.1%   |
| `server/xrpc/getStory.ts`         | 5/40      | 87.5%   |
| `server/xrpc/getFollowing.ts`     | 4/24      | 83.3%   |
| `server/xrpc/getGallery.ts`       | 4/14      | 71.4%   |
| `server/feeds/actor.ts`           | 3/13      | 76.9%   |

**Spaces is out of scope** (decided 2026-09-03). Everything behind
`spaces/client.ts` — the private and shared gallery handlers, `getSpaceMembers`,
`addSpaceMember`, `getPrivateBlob`, `getSpaceSupport`, and the client and DPoP
modules themselves — needs a stubbed remote PDS to test, and other things
matter more. That is 304 statements, 12.5% covered.

Excluding it, the rest of `server/` is at **85.1%**, so the 80% goal is
comfortably reachable without touching spaces. What is left there: the three OG
renderers and their font loader, `searchActorsTypeahead`, and small remainders
in `getLocations`, `mentionSearch`, `hydrate/galleries` and `helpers/country`.

## Known hard spots

Recorded as they are hit, so a later pass does not rediscover them.

- `server/og/*` renders images and loads fonts. Covering it probably means
  asserting on the generated SVG/element tree rather than a rendered bitmap.
- `server/spaces/*` talks to a remote PDS over DPoP. `client.ts` and `dpop.ts`
  need the transport stubbed; the live path already has
  `test/spaces.live.test.ts` and should stay there.
- `server/hooks/*` are firehose commit handlers. They take a commit event and a
  db, so they should be callable directly without a server.
- Feeds are reached at `/xrpc/dev.hatk.getFeed?feed=<name>`, and answer
  `{ items, cursor }` where each item is a hydrated gallery view. There is no
  per-feed lexicon to read; `app/lib/queries.ts` is the list of what each feed
  takes for parameters.
- `_mutes` is not created by the test harness (it lives in `server/setup`), so
  any test touching mutes has to `CREATE TABLE IF NOT EXISTS` it first. Several
  existing tests already do; copy one.
- The `actor` feed orders on `created_at` while `/recent` and the rest order on
  `sort_at` (`min(created_at, indexed_at)`). A gallery with a skewed future
  `createdAt` therefore heads a profile grid but not the home feed. This is
  asserted as-is in `test/galleryFeeds.test.ts`; it looks unintended, and
  changing it should start by deciding which of the two is right.
- H3 fixtures have to be real cells — `getResolution` throws on a made-up
  string and the city-level path swallows that in a `catch`, so an invalid cell
  silently drops out of results instead of failing. Generate them with
  `latLngToCell(lat, lng, 10)` and `cellToParent(cell, 5)`.
- `location.ts` has an unreachable guard: the `aliases.length === 0` branch
  that drops a country interpretation cannot fire, because `normalizeCountry`
  falls back to returning the upper-cased input and only returns null for an
  empty string, while `parts` is already `.filter(Boolean)`-ed. It is harmless
  defence against `normalizeCountry` changing, but it cannot be covered and
  should not be chased. The reachable version of that case — a one-word name
  that is not a country, like "Waldport" — is tested instead.
- Two `startTestServer()` instances in one file are properly isolated, so a
  test file can hold several fixtures under separate `describe` blocks rather
  than one shared world. `test/foryouFeed.test.ts` uses three.
- Every feed query embeds `blockMuteFilter`, which names `_mutes`. A fixture
  that never creates that table gets a **500 from every feed request**, not a
  silently skipped mute check. Create it in each world's `beforeAll`.
- `foryou.ts` scores with a six-hour half life, so fixtures have to be dated
  relative to `Date.now()`. Anything pinned to a calendar date scores at or
  near zero across the board and the ranking stops discriminating.
- `foryou.ts` has one dead knob: `CORATER_DECAY` is 0, so the decay branch
  never runs and the `coratersSeenCount` map it feeds is maintained but has no
  effect. Not coverable without changing the constant.
- on-commit hooks are directly callable: `defineHook` returns `{ handler }`
  untouched, so a test can build the commit context by hand — real `db` from
  `startTestServer`, a `push` stub that collects messages, and a `lookup` stub,
  which the hooks use only to get the actor's display name. No firehose needed.
- `fireOnCommitHooks` catches a rejected hook and turns it into a log line, so
  **anything that throws inside a hook fails silently**. That is how a helper
  that could not parse went unnoticed in production; see the isBlockedOrMuted
  regression test in `test/commitHooks.test.ts`. When testing a hook, assert
  that it resolves, not only what it pushed.
- `social.grain.story` rows need `media` and `aspect_ratio`; both are NOT NULL.
- `_mutes` is the **only** table the harness is missing. `_preferences`,
  `_oauth_sessions`, `_labels`, `_push_tokens` and the rest are all created —
  an earlier note here wrongly listed `_preferences` alongside `_mutes`. A
  `CREATE TABLE IF NOT EXISTS` for it is harmless but unnecessary.
- Lexicon-required parameters are enforced before a handler runs, so a handler's
  own `if (!param) return ok({})` guard for a required param is unreachable —
  the request gets a 400 instead. `getStories`, `getStoryArchive` and `getStory`
  all have one. Assert the 400; do not chase the guard.
- Child tables generated from array fields (e.g.
  `social.grain.story__labels_self_labels`) carry `parent_did` alongside
  `parent_uri`, and both are NOT NULL.
- Self-labels live in those child tables, not in `_labels`. So the SQL
  `hideLabelsFilter` does not see them, while the hydrators, which merge both,
  do. `getStoryAuthors` therefore counts a self-labelled story in `storyCount`
  even though `getStories` will not return it — asserted as it stands in
  `test/stories.test.ts`, but the two disagree.
- `getStoryArchive` applies its LIMIT in SQL and filters hidden stories
  afterwards in the hydrator, so **a page can come back shorter than the limit**
  and the cursor comes from the pre-filter row. A client has to page until the
  cursor is gone, not until a page looks short.
- `hydrateStories` wraps its `media` parse in a try/catch that cannot help:
  `ctx.blobUrl` parses the blob ref itself and throws on the raw string the
  catch assigns, so a story row with non-JSON media is a 500 either way. The
  neighbouring `aspect_ratio` fallback does work and is tested. Only reachable
  if something writes a malformed blob column, which the indexer does not.
- OG renderers return a satori element tree rather than a bitmap, and
  `defineOG` exposes it as `{ generate }`, so they can be called directly and
  asserted on without rasterising anything. Their context wants `lookup`,
  `blobUrl`, `getRecords` and `fetchImage`, none of which
  `startTestServer().feedContext()` supplies — see `test/ogCards.test.ts` for a
  set of stubs that work. Walk the returned tree for its strings and its
  `<img src>`s rather than asserting on the tree's shape, which is layout and
  changes freely.
- What each card draws is not what it tells a crawler. The profile card's
  description and the story card's handle appear only in `meta`, never on the
  card itself. Check both.
- `server/og/collage.ts` is the exception: `calculateCollageLayout` is a pure
  function over a seeded PRNG, and it is where most of `server/og` lives.
- `searchActorsTypeahead` calls `public.api.bsky.app` directly and merges the
  result with a local `search()`. It needs `fetch` stubbed and the hatk search
  index, so it is left for later — do not let it make a live request in tests.
- `getActorFavorites` is private to its own actor: it returns an empty list to
  everyone else rather than erroring, so a test has to name the viewer.
- `x NOT IN (subquery)` is NULL, not true, when `x` is NULL — so a nullable
  column filtered that way silently drops its NULL rows, _but only once the
  subquery is non-empty_. That made a real bug hide: mention notifications
  worked for anyone who had never commented and stopped the moment they did.
  `server/hydrate/comments.ts` already uses the safe idiom
  (`reply_to IS NULL OR ...`); prefer it anywhere a nullable column is filtered.
- getNotifications has two test files on purpose. `test/notifications.test.ts`
  keeps a deliberately small fixture for the moderation filtering and the
  unseen count; `test/notificationSources.test.ts` needs a much bigger one to
  reach all nine sources. Do not merge them — the small fixture's assertions
  are exact counts that any addition breaks.
- Procedures (POST) work through the same harness as queries: pass an init
  object as `server.fetchAs(did, path, { method, headers, body })`. See
  `test/actorProfile.test.ts`, which drives muteActor and then reads the flag
  back off getActorProfile.
- The on-login hook is callable exactly like the on-commit ones, but its
  context is bigger: `did`, `db`, `lookup`, `ensureRepo`, `createRecord`,
  `putRecord`, `deleteRecord`. It also reads the Bluesky profile straight off
  the user's PDS, so `fetch` has to be stubbed with `vi.stubGlobal`.
- Its two backgrounded calls — `ensureRepo` and the space-support probe — are
  fire-and-forget with `.catch()` attached, so neither can fail a test, but a
  `fetch` stub will see the probe's request too.
- Anything that writes to a PDS — `deleteRecord`, `createRecord`, `putRecord` —
  cannot be driven through `server.fetch`: the harness has no OAuth config and
  the request fails with "No OAuth config — cannot write to PDS" before
  reaching the handler's logic. Call the procedure directly instead;
  `defineProcedure` returns `{ handler }` untouched, exactly like `defineHook`.
  `test/deletion.test.ts` does this, passing a `deleteRecord` that records the
  calls rather than making them.
- `ctx.search` works as of `@hatk/hatk` alpha.82, which was published for this
  (hatk `2d836b4`): `createTestContext` never built the FTS shadow tables, so
  every query failed its BM25 phase with `no such table: _fts_...` — caught and
  recorded rather than thrown — and SQLite has no fuzzy phase to recover with.
  Every search answered empty in every project built on hatk.
- A record is only searchable if it was written through `insertRecord`, which is
  what maintains the index. That means `loadFixtures` and a YAML fixture
  directory; a raw `db.run` insert leaves the row present but unfindable, the
  same as in production. `test/search.test.ts` and `test/fixtures/search/` are
  the only place in the suite that works this way — everything else uses
  `db.run` because it does not need the index.
- Stubbing `fetch` in a test that also uses `server.fetch` has to pass
  non-matching requests through to the real one; the harness drives its own
  requests over real HTTP, so a stub that swallows everything breaks the request
  under test.
- Two things that look like harness limits but are not. `server.seed()` takes
  _options_ and returns helpers bound to a live PDS — it is for integration
  tests against the docker-compose stack, not for seeding the in-memory
  database, so calling it `seed(collection, rows)` silently does nothing.
  And the missing OAuth config is deliberate: the harness exposes
  `loadXrpc(name).handler` precisely so procedures that write to a PDS can be
  driven directly, which is what `test/deletion.test.ts` does (by importing the
  module; `loadXrpc` would be the more idiomatic route).
- `deleteGallery` builds the gallery uri from `viewer.did`, so naming someone
  else's rkey just addresses a gallery of yours that does not exist. The
  query's `AND did = $2` cannot be violated through the interface and removing
  it fails nothing — the construction is what makes it safe, not the condition.
- Two guards in the codebase cannot be reached through their own interface and
  fail nothing when removed. `deleteGallery`'s `AND did = $2` is redundant
  because the uri is built from `viewer.did`; `getGallery`'s takedown check is
  redundant because hatk's `getRecords` already excludes taken-down repos, so
  the row never arrives. Both are recorded in their tests as defence in depth
  rather than dressed up as load-bearing. Do not chase either with a test.
- EXIF numbers are stored scaled by a million — atproto lexicons have no
  decimal type, so `app/lib/utils/image-resize.ts` multiplies on the way in and
  `hydrate/galleries.ts` divides on the way out. ISO is scaled too despite
  already being a whole number, which is easy to get wrong in a fixture.

## Ledger

| date       | statements | delta | what landed                                                                                                                                                                                                                                                                                      |
| ---------- | ---------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 2026-09-03 | 30.68%     | —     | baseline; coverage tooling wired up                                                                                                                                                                                                                                                              |
| 2026-09-03 | 33.34%     | +2.66 | `recent`, `following`, `hashtag`, `actor` and `camera` feeds — `test/galleryFeeds.test.ts`                                                                                                                                                                                                       |
| 2026-09-03 | 37.88%     | +4.54 | the `location` feed, all three of its lookup paths — `test/locationFeed.test.ts`                                                                                                                                                                                                                 |
| 2026-09-03 | 42.61%     | +4.73 | the `foryou` feed: scoring, cold start, windowing — `test/foryouFeed.test.ts`. `server/feeds` now 92.6%                                                                                                                                                                                          |
| 2026-09-03 | 48.17%     | +5.56 | the three on-commit hooks and their four helpers — `test/commitHooks.test.ts`. Found and fixed a live push-notification outage                                                                                                                                                                   |
| 2026-09-03 | 54.48%     | +6.31 | the story surface: getStories, getStoryArchive, getStoryAuthors, getStory and the shared hydrator — `test/stories.test.ts`                                                                                                                                                                       |
| 2026-09-03 | 62.95%     | +8.47 | the collage layout — `test/collageLayout.test.ts`; blocks, mutes, favorites and suggested follows — `test/actorLists.test.ts`                                                                                                                                                                    |
| 2026-09-03 | 66.79%     | +3.84 | every getNotifications source, its preference filters and paging — `test/notificationSources.test.ts`. Found and fixed silent mention notifications                                                                                                                                              |
| 2026-09-03 | 70.24%     | +3.45 | getActorProfile and the mute procedures — `test/actorProfile.test.ts`; the on-login hook — `test/onLoginHook.test.ts`                                                                                                                                                                            |
| 2026-09-03 | 74.23%     | +3.99 | mentionSearch — `test/mentionSearch.test.ts`; deleteGallery and deleteAccount — `test/deletion.test.ts`                                                                                                                                                                                          |
| 2026-09-03 | 75.76%     | +1.53 | the four search-backed endpoints, once hatk alpha.82 made them testable — `test/search.test.ts`                                                                                                                                                                                                  |
| 2026-09-03 | **80.14%** | +4.38 | the three OG cards and the font loader — `test/ogCards.test.ts`. **Goal met.** Excluding spaces, 92.1%                                                                                                                                                                                           |
| 2026-09-04 | 81.62%     | +1.48 | resolveActor and the country helpers — `test/resolveAndCountry.test.ts`; getGallery and EXIF formatting — `test/galleryDetail.test.ts`. Excluding spaces, 93.8%                                                                                                                                  |
| 2026-09-04 | 81.97%     | +0.35 | mentionSearch scoped to one account's galleries, the last search path left                                                                                                                                                                                                                       |
| 2026-09-16 | 75.02%     | −6.95 | groups merged in: +438 statements of space-credential code, none of it testable from here. Nothing regressed — excluding spaces the server is at **87.67%**, up from 81.97%. `test/groups.test.ts` covers the indexed half: declarations, roster, profile, rules, the groups feed and `groupsOf` |