kanji-buddy

Testing

164 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.

tests/helpers/db.ts
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 this

The 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

tests/subjects.route.test.ts
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

AreaExamples
SRS arithmeticsrs.test.ts, progression.test.ts — stage movement, the penalty formula, unlock cascades
Gradinganswer.test.ts — typo tolerance, the retry verdict, blacklisted synonyms
Quiz queuequiz.test.ts — the rule that leaves an item in the queue until every aspect is right
API surfacereads, subjects, reviews, study_materials, assignments_start, user_summary
Isolationtenant-isolation.test.ts — two users, every resource, every write path
The nudge jobnudge.test.ts, nudge.route.test.ts — policy, candidate query, sending, the cron guard
Scriptscreate-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.

Running them

npm test          # 164 tests, ~70s
npm run typecheck

Why 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 (~70s), because the instances stop thrashing. Raise it only if you can show it stays green.

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.