kanji-buddy
Testing
415 tests against a real Postgres, with no database to install and no server to start.
Postgres in the process
Tests run against pglite — Postgres compiled to WebAssembly, running inside the test process. Not a mock, not SQLite pretending: real SQL, real transactions, real FILTER clauses and identity columns.
const pg = new PGlite(); // in-memory
const db = drizzle(pg, { schema, casing: "snake_case" });
await migrate(db, { migrationsFolder: "db/migrations" });
setDatabase(db); // route code now uses thisThe migrations that run are the same files production runs, so a broken migration fails the suite rather than the deploy.
How the injection works
Route handlers import db at module scope, which would normally make them untestable. db/client.ts exports a lazy Proxy instead: nothing connects until a property is accessed, so setDatabase() can substitute pglite first. That one indirection is why route handlers can be called as plain functions.
No server, no browser
import { GET } from "../app/api/v1/subjects/route";
const res = await GET(authedRequest(URL, token));
assert.equal(res.status, 200);A route handler takes a Request and returns a Response — both Web platform types — so testing one is calling a function. No supertest, no port, no fixture server.
What is covered
| Area | Examples |
|---|---|
| SRS arithmetic | srs.test.ts, progression.test.ts — stage movement, the penalty formula, unlock cascades |
| Grading | answer.test.ts — typo tolerance, the retry verdict, blacklisted synonyms |
| Quiz queue | quiz.test.ts — the rule that leaves an item in the queue until every aspect is right |
| API surface | reads, subjects, reviews, study_materials, assignments_start, user_summary |
| Isolation | tenant-isolation.test.ts — two users, every resource, every write path |
| The nudge job | nudge.test.ts, nudge.route.test.ts — policy, candidate query, sending, the cron guard |
| Vacation | vacation.test.ts — the shift on return, for future and overdue items alike, and that one user's holiday never moves another's schedule |
| Pacing | dailyLessons.test.ts — the day boundary in the account's timezone, and that the dashboard's counts agree with the allowance |
| Item Spread | itemSpread.route.test.ts — grouping, ordering, that the per-stage totals sum back to the tier they sit under, and that those tier totals match the dashboard card exactly |
| Audio | audio.test.ts — the only-versus-prefer split, including that Only returns silence rather than the other voice |
| Scripts | create-user, create-token, seed-srs, import-subjects |
The tests worth singling out
Isolation, verified by mutation. It is not enough to assert that user A sees A’s rows when A is the only user in the database — which is what the suite did before. The isolation file seeds two users and asserts A can neither read nor mutate B’s data. It was checked by deleting the user_id filter from a route: those tests fail, where previously the whole suite still passed.
The cross-check. The nudge job duplicates the dashboard’s lesson and review predicates out of necessity — one needs a fan-out query, the other a per-user one. A test runs both against the same seed and asserts the counts agree, so the copies cannot drift silently.
The bug that had no test. quiz.test.ts exists because the review queue lived inside a React component with no coverage, and shipped a bug that re-asked the same radical three times. Its first case is that exact scenario.
Credential systems staying disjoint. A valid API token must not authorise the cron route, and a cron secret must not authenticate a user. Both directions are asserted, because Vercel Cron puts its secret on the same header user tokens use.
Two aggregates that must not disagree. The Item Spread screen and the dashboard card count the same thing from different queries, so a test runs both against one seed and asserts every bucket and type total matches. If they ever diverge, one of the two is lying to the user about their own progress.
Silence is a result. The voice preference has an “only this voice” branch that must return nothing when that voice has no recording, and a “prefer” branch that must never return nothing. That distinction survives a careless edit while quietly doing the wrong thing, so both directions are asserted.
Running them
npm test # 415 tests, ~2min
npm run typecheckWhy the concurrency is pinned
The script sets --test-concurrency=4. Node defaults to one less than the core count, which on a 10-core machine meant nine pglite instances competing for memory: the suite took 3m28s and files intermittently failed at the file level — timeouts with no assertion error, a different file each run. Capping it made the suite reliable across five consecutive runs and three times faster, because the instances stop thrashing. Raise it only if you can show it stays green.
It has not gone away entirely. At four, roughly one run in eight still fails one file this way — observed three times, on nudge.test.ts and nudge.route.test.ts, each time passing in isolation immediately afterwards. The signature is the same: a file-level failure with no assertion error. Suspects are the shared setDatabase global and pglite contention. Until it is diagnosed, treat a single file-level failure as unproven rather than as a regression — re-run the file alone before believing it.
One gap
Nothing tests React rendering. There is no testing-library here, so component behaviour is verified by driving the real app in a browser against the dev branch. That is why the dev branch matters: before it existed, verifying a change meant consuming real lessons.