~ 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

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.

areauncoveredofcovered
server/xrpc233102277.2%
server/spaces9212224.6%
server/feeds1824492.6%
server/og1018494.6%
server/hooks712594.4%
server/hydrate614695.9%
server/helpers517597.1%

Biggest single files still outstanding:

fileuncoveredcovered
server/xrpc/getLocations.ts13/10287.2%
server/xrpc/mentionSearch.ts9/4580.0%
server/hydrate/galleries.ts5/8394.0%
server/xrpc/getCommentThread.ts5/5691.1%
server/xrpc/getStory.ts5/4087.5%
server/xrpc/getFollowing.ts4/2483.3%
server/xrpc/getGallery.ts4/1471.4%
server/feeds/actor.ts3/1376.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

datestatementsdeltawhat landed
2026-09-0330.68%—baseline; coverage tooling wired up
2026-09-0333.34%+2.66recent, following, hashtag, actor and camera feeds — test/galleryFeeds.test.ts
2026-09-0337.88%+4.54the location feed, all three of its lookup paths — test/locationFeed.test.ts
2026-09-0342.61%+4.73the foryou feed: scoring, cold start, windowing — test/foryouFeed.test.ts. server/feeds now 92.6%
2026-09-0348.17%+5.56the three on-commit hooks and their four helpers — test/commitHooks.test.ts. Found and fixed a live push-notification outage
2026-09-0354.48%+6.31the story surface: getStories, getStoryArchive, getStoryAuthors, getStory and the shared hydrator — test/stories.test.ts
2026-09-0362.95%+8.47the collage layout — test/collageLayout.test.ts; blocks, mutes, favorites and suggested follows — test/actorLists.test.ts
2026-09-0366.79%+3.84every getNotifications source, its preference filters and paging — test/notificationSources.test.ts. Found and fixed silent mention notifications
2026-09-0370.24%+3.45getActorProfile and the mute procedures — test/actorProfile.test.ts; the on-login hook — test/onLoginHook.test.ts
2026-09-0374.23%+3.99mentionSearch — test/mentionSearch.test.ts; deleteGallery and deleteAccount — test/deletion.test.ts
2026-09-0375.76%+1.53the four search-backed endpoints, once hatk alpha.82 made them testable — test/search.test.ts
2026-09-0380.14%+4.38the three OG cards and the font loader — test/ogCards.test.ts. Goal met. Excluding spaces, 92.1%
2026-09-0481.62%+1.48resolveActor and the country helpers — test/resolveAndCountry.test.ts; getGallery and EXIF formatting — test/galleryDetail.test.ts. Excluding spaces, 93.8%
2026-09-0481.97%+0.35mentionSearch scoped to one account's galleries, the last search path left
2026-09-1675.02%−6.95groups 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