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
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.
startTestServerfrom@hatk/hatk/testgives a real server over a real SQLite database. Seed rows withdb.run, then assert onserver.fetchof the XRPC route.test/cameras.test.tsandtest/locations.test.tsare the clearest models.- Helper modules that are pure functions get imported and called directly —
test/formatLocation.test.tsandtest/tid.test.tsare 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.tsis 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.tsanddpop.tsneed the transport stubbed; the live path already hastest/spaces.live.test.tsand 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.tsis the list of what each feed takes for parameters. _mutesis not created by the test harness (it lives inserver/setup), so any test touching mutes has toCREATE TABLE IF NOT EXISTSit first. Several existing tests already do; copy one.- The
actorfeed orders oncreated_atwhile/recentand the rest order onsort_at(min(created_at, indexed_at)). A gallery with a skewed futurecreatedAttherefore heads a profile grid but not the home feed. This is asserted as-is intest/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 —
getResolutionthrows on a made-up string and the city-level path swallows that in acatch, so an invalid cell silently drops out of results instead of failing. Generate them withlatLngToCell(lat, lng, 10)andcellToParent(cell, 5). location.tshas an unreachable guard: thealiases.length === 0branch that drops a country interpretation cannot fire, becausenormalizeCountryfalls back to returning the upper-cased input and only returns null for an empty string, whilepartsis already.filter(Boolean)-ed. It is harmless defence againstnormalizeCountrychanging, 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 separatedescribeblocks rather than one shared world.test/foryouFeed.test.tsuses 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'sbeforeAll. foryou.tsscores with a six-hour half life, so fixtures have to be dated relative toDate.now(). Anything pinned to a calendar date scores at or near zero across the board and the ranking stops discriminating.foryou.tshas one dead knob:CORATER_DECAYis 0, so the decay branch never runs and thecoratersSeenCountmap it feeds is maintained but has no effect. Not coverable without changing the constant.- on-commit hooks are directly callable:
defineHookreturns{ handler }untouched, so a test can build the commit context by hand — realdbfromstartTestServer, apushstub that collects messages, and alookupstub, which the hooks use only to get the actor's display name. No firehose needed. fireOnCommitHookscatches 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 intest/commitHooks.test.ts. When testing a hook, assert that it resolves, not only what it pushed.social.grain.storyrows needmediaandaspect_ratio; both are NOT NULL._mutesis the only table the harness is missing._preferences,_oauth_sessions,_labels,_push_tokensand the rest are all created — an earlier note here wrongly listed_preferencesalongside_mutes. ACREATE TABLE IF NOT EXISTSfor 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,getStoryArchiveandgetStoryall have one. Assert the 400; do not chase the guard. - Child tables generated from array fields (e.g.
social.grain.story__labels_self_labels) carryparent_didalongsideparent_uri, and both are NOT NULL. - Self-labels live in those child tables, not in
_labels. So the SQLhideLabelsFilterdoes not see them, while the hydrators, which merge both, do.getStoryAuthorstherefore counts a self-labelled story instoryCounteven thoughgetStorieswill not return it — asserted as it stands intest/stories.test.ts, but the two disagree. getStoryArchiveapplies 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.hydrateStorieswraps itsmediaparse in a try/catch that cannot help:ctx.blobUrlparses 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 neighbouringaspect_ratiofallback 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
defineOGexposes it as{ generate }, so they can be called directly and asserted on without rasterising anything. Their context wantslookup,blobUrl,getRecordsandfetchImage, none of whichstartTestServer().feedContext()supplies — seetest/ogCards.test.tsfor 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.tsis the exception:calculateCollageLayoutis a pure function over a seeded PRNG, and it is where most ofserver/oglives.searchActorsTypeaheadcallspublic.api.bsky.appdirectly and merges the result with a localsearch(). It needsfetchstubbed and the hatk search index, so it is left for later — do not let it make a live request in tests.getActorFavoritesis 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, whenxis 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.tsalready 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.tskeeps a deliberately small fixture for the moderation filtering and the unseen count;test/notificationSources.test.tsneeds 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 }). Seetest/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, sofetchhas to be stubbed withvi.stubGlobal. - Its two backgrounded calls —
ensureRepoand the space-support probe — are fire-and-forget with.catch()attached, so neither can fail a test, but afetchstub will see the probe's request too. - Anything that writes to a PDS —
deleteRecord,createRecord,putRecord— cannot be driven throughserver.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;defineProcedurereturns{ handler }untouched, exactly likedefineHook.test/deletion.test.tsdoes this, passing adeleteRecordthat records the calls rather than making them. ctx.searchworks as of@hatk/hatkalpha.82, which was published for this (hatk2d836b4):createTestContextnever built the FTS shadow tables, so every query failed its BM25 phase withno 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 meansloadFixturesand a YAML fixture directory; a rawdb.runinsert leaves the row present but unfindable, the same as in production.test/search.test.tsandtest/fixtures/search/are the only place in the suite that works this way — everything else usesdb.runbecause it does not need the index. - Stubbing
fetchin a test that also usesserver.fetchhas 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 itseed(collection, rows)silently does nothing. And the missing OAuth config is deliberate: the harness exposesloadXrpc(name).handlerprecisely so procedures that write to a PDS can be driven directly, which is whattest/deletion.test.tsdoes (by importing the module;loadXrpcwould be the more idiomatic route). deleteGallerybuilds the gallery uri fromviewer.did, so naming someone else's rkey just addresses a gallery of yours that does not exist. The query'sAND did = $2cannot 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'sAND did = $2is redundant because the uri is built fromviewer.did;getGallery's takedown check is redundant because hatk'sgetRecordsalready 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.tsmultiplies on the way in andhydrate/galleries.tsdivides 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 |