@lunora/testing
In-memory harness for queries, mutations, and actions, plus E2E mail-catcher helpers.
@lunora/testing is the toolkit for testing a Lunora app. It ships two
surfaces: an in-memory function harness (lunoraTest) that runs your
query / mutation / action functions against a fresh node:sqlite
database — no Durable Object, no wrangler, no network — and the mail-catcher
helpers re-exported from @lunora/mail/testing for
driving end-to-end email flows.
pnpm add -D @lunora/testingnpm install -D @lunora/testingyarn add -d @lunora/testingbun add -D @lunora/testingIn-memory harness
lunoraTest(schema) runs the migrations against a fresh in-memory SQLite
database, builds the same ctx.db writer the real Durable Object builds, and
returns a harness whose methods execute a registered function's handler
directly. It mirrors the first five methods of Convex's convexTest, and all
of them share one database — so a write from a mutation is visible to a later
query.
import { defineSchema, defineTable, initLunora, v } from "lunorash/server";
import { lunoraTest } from "@lunora/testing";
import { afterEach, expect, test } from "vitest";
const schema = defineSchema({
messages: defineTable({
author: v.string(),
body: v.string(),
}),
});
// A self-contained test derives its builders from the inline schema. In your
// app you instead import `query` / `mutation` from `@/lunora/_generated/server`.
const { mutation, query } = initLunora.dataModel().create();
const send = mutation.input({ author: v.string(), body: v.string() }).mutation(({ ctx, args }) => ctx.db.insert("messages", args));
const list = query.query(({ ctx }) => ctx.db.query("messages").collect());
test("sends and lists a message", async () => {
const t = lunoraTest(schema);
await t.mutation(send, { author: "ada", body: "hi" });
expect(await t.query(list, {})).toHaveLength(1);
});Each lunoraTest(...) opens one in-memory database backed by a native handle.
Call t.close() (typically in an afterEach) to release it when a test
finishes — it is idempotent, so closing any view of the harness is safe.
const open: ReturnType<typeof lunoraTest>[] = [];
afterEach(() => {
while (open.length > 0) {
open.pop()?.close();
}
});Harness API
lunoraTest(schema) returns a TestHarness:
| Method | Description |
|---|---|
query(reference, args) | Run a registered query. Internal queries are rejected on this external surface. |
mutation(reference, args) | Run a registered mutation. Internal mutations are rejected on this external surface. |
action(reference, args) | Run a registered action. Internal actions are rejected on this external surface. |
query(inline) | Run an inline (ctx) => … with a read-only QueryCtx. |
mutation(inline) | Run an inline (ctx) => … with a read-write MutationCtx. |
action(inline) | Run an inline (ctx) => … with an ActionCtx. |
run(fn) | Direct db access at mutation level (read + write), mirroring convexTest's run. |
subscribe(reference, args) | Async iterable yielding the query result now, then re-emitting after each mutation. |
scheduler | Controls for the in-memory scheduler (list / advance / runPending). |
withIdentity(identity) | Return a harness view sharing this db but reporting the given identity on ctx.auth. |
close() | Close the underlying in-memory SQLite database. Idempotent. |
Each query / mutation / action accepts either a registered function
reference plus its args, or a bare inline handler:
test("inline access to the db", async () => {
const t = lunoraTest(schema);
await t.run(async (ctx) => ctx.db.insert("messages", { author: "grace", body: "from run" }));
const count = await t.query(async (ctx) => {
const rows = await ctx.db.query("messages").collect();
return rows.length;
});
expect(count).toBe(1);
t.close();
});Auth and identity
withIdentity(identity) returns a harness view that shares the same database
but reports the given identity on ctx.auth. userId is the subject handlers
read from ctx.auth.userId; any additional fields are returned verbatim from
ctx.auth.getIdentity() (mirroring a decoded JWT). Writes made under the
scoped view persist for every accessor.
const whoAmI = query.query(({ ctx }) => ctx.auth.userId);
test("reflects the injected identity", async () => {
const t = lunoraTest(schema);
await expect(t.query(whoAmI, {})).resolves.toBeNull();
const scoped = t.withIdentity({ userId: "u1" });
await expect(scoped.query(whoAmI, {})).resolves.toBe("u1");
// A write under the scoped view is visible from the base harness.
await scoped.mutation(send, { author: "u1", body: "scoped" });
expect(await t.query(list, {})).toHaveLength(1);
t.close();
});This is how you test row-level security: drive a procedure with and without an identity and assert on the rows it returns or the errors it throws.
Internal functions
Internal functions (internalQuery / internalMutation / internalAction)
are unreachable from the external RPC boundary in production. The harness
enforces that contract: calling one through t.query / t.mutation /
t.action throws, while calling it through ctx.runQuery / ctx.runMutation /
ctx.runAction from another function succeeds — mirroring prod's trusted
system dispatch.
import { initLunora, v } from "lunorash/server";
const { internalMutation, mutation } = initLunora.dataModel().create();
const internalSend = internalMutation.input({ author: v.string(), body: v.string() }).mutation(({ ctx, args }) => ctx.db.insert("messages", args));
// A public mutation routes through ctx.runMutation to the internal one.
const sendViaInternal = mutation.input({ author: v.string(), body: v.string() }).mutation(({ ctx, args }) => ctx.runMutation(internalSend, args));
test("internal functions are only reachable via ctx.run*", async () => {
const t = lunoraTest(schema);
// Direct call on the external surface is rejected.
await expect(t.mutation(internalSend, { author: "ada", body: "leak" })).rejects.toThrow("is an internal function");
// Reached through a public mutation, it runs.
await t.mutation(sendViaInternal, { author: "grace", body: "ok" });
expect(await t.query(list, {})).toHaveLength(1);
t.close();
});Injecting ctx.fetch
Actions can call ctx.fetch. Pass a fetch option to lunoraTest to replace
the throwing stub with your own implementation:
import { vi } from "vitest";
const fakeFetch = vi.fn<typeof globalThis.fetch>().mockResolvedValue(Response.json({ ok: true }));
const t = lunoraTest(schema, { fetch: fakeFetch });
// `ctx.fetch` inside any action now calls `fakeFetch`.Without the option, ctx.fetch throws the v1 error the first time an action
reaches for it.
Scheduler
ctx.scheduler is a working fake with a virtual clock. Jobs enqueue
synchronously but only execute when you advance the clock. Map each scheduled
function path to its registered function via the functions option so the
scheduler can dispatch it:
const { mutation } = initLunora.dataModel().create();
const sendMessage = mutation.input({ author: v.string(), body: v.string() }).mutation(({ args, ctx }) => ctx.db.insert("messages", args));
const enqueue = mutation.mutation(({ ctx }) => ctx.scheduler.runAfter(5_000, "messages:send", { author: "ada", body: "hello" }));
test("scheduled mutation runs after advance", async () => {
const t = lunoraTest(schema, {
functions: { "messages:send": sendMessage },
});
await t.mutation(enqueue, {});
expect(t.scheduler.list()).toHaveLength(1); // queued, not yet run
await t.scheduler.advance(10_000); // tick past scheduledFor
expect(await t.query(list, {})).toHaveLength(1); // sendMessage ran
t.close();
});| Control | Description |
|---|---|
t.scheduler.list() | Snapshot of pending jobs in enqueue order. |
t.scheduler.advance(ms) | Advance the virtual clock by ms, running every job due at or before the new now. Returns the count executed. |
t.scheduler.runPending() | Run all pending jobs regardless of scheduledFor. Returns the count executed. |
The virtual clock is per-harness, so parallel tests are isolated. A path not in
the functions map (or pointing at a query) produces a console.warn and is
dropped — matching production behaviour for unknown paths. Jobs run through the
same dispatch as ctx.runMutation, so they share the harness database.
Subscriptions
t.subscribe(query, args) (or t.subscribe(inline)) returns an async iterable
that yields the query's current result immediately, then re-emits after every
mutation or run:
test("subscription re-emits after a mutation", async () => {
const t = lunoraTest(schema);
const sub = t.subscribe(list, {});
expect((await sub.next()).value).toHaveLength(0); // initial snapshot
await t.mutation(send, { author: "ada", body: "hi" });
expect((await sub.next()).value).toHaveLength(1); // updated snapshot
await sub.return(); // unsubscribe
t.close();
});Subscriptions are table-agnostic — any mutation triggers a re-evaluation. The
iterable buffers at most one pending result, so if several mutations fire
between two next() calls the next read reflects the most recent state.
v1 scope
ctx.storage, ctx.vectors, and ctx.workflows are clearly-throwing stubs. A
handler that touches one fails with a descriptive error the first time it
reaches for it:
const touchesStorage = mutation.mutation(({ ctx }) => ctx.storage.bucket("avatars"));
test("stubbed surfaces throw a clear error", async () => {
const t = lunoraTest(schema);
await expect(t.mutation(touchesStorage, {})).rejects.toThrow("ctx.storage is not available in the in-memory @lunora/testing harness (v1)");
t.close();
});The stub only throws when a handler actually touches it — functions that never reach for ctx.storage / ctx.vectors / ctx.workflows still run. Real R2
storage, Vectorize, and Cloudflare Workflows are deferred to a follow-up.
Mail-catcher helpers (E2E)
In lunora dev, @lunora/mail captures every outbound
email (sign-up verification, forgot-password, magic links) into the studio's
root-shard inbox instead of hitting a real provider. The mail-catcher helpers
read that inbox over the admin RPC, so a Playwright (or any HTTP) test can drive
"request reset → read the email → follow the link → set a new password"
deterministically. They are re-exported from @lunora/testing:
| Export | Description |
|---|---|
listCapturedMail | Read the captured-mail inbox, newest first. |
waitForMail | Poll the inbox until a message matching to (and optional subjectMatch) appears. |
extractLink | Pull the first link out of a captured message (html first, then text). |
InboxOptions | Type — options for listCapturedMail. |
WaitForMailOptions | Type — options for waitForMail. |
waitForMail takes the worker baseUrl, the admin bearer token (the worker
gates introspection behind LUNORA_ADMIN_TOKEN), the recipient to, and an
optional subjectMatch. It polls (default every 250ms, timing out after 10s)
and returns the matching CapturedMail — or throws on timeout. extractLink
takes an optional match substring to disambiguate the action link from a logo
or footer URL.
import { extractLink, waitForMail } from "@lunora/testing";
import { expect, test } from "vitest";
test("password reset email carries a working link", async () => {
const baseUrl = "http://localhost:8787";
const adminToken = process.env.LUNORA_ADMIN_TOKEN!;
// Trigger the flow (e.g. POST /api/auth/forgot-password).
await fetch(`${baseUrl}/api/auth/forgot-password`, {
body: JSON.stringify({ email: "alice@example.test" }),
headers: { "content-type": "application/json" },
method: "POST",
});
const mail = await waitForMail({
adminToken,
baseUrl,
to: "alice@example.test",
subjectMatch: "Reset your password",
});
const resetLink = extractLink(mail, { match: "/reset-password" });
expect(resetLink).toContain("/reset-password");
// → visit `resetLink`, set a new password, assert success.
});Options
waitForMail accepts everything in InboxOptions plus a few polling knobs:
| Option | Default | Description |
|---|---|---|
adminToken | — | Admin bearer token (LUNORA_ADMIN_TOKEN). |
baseUrl | — | App base URL, e.g. http://localhost:8787. |
to | — | Recipient address the message must be addressed to. |
subjectMatch | — | Only match a subject containing this substring. |
limit | 50 | Newest-N messages to read per poll. |
pollMs | 250 | Poll interval in ms. |
timeoutMs | 10000 | Give up after this many ms. |
fetch | globalThis | Inject a fetch implementation (e.g. a stub). |
See also
- Concepts: queries & mutations — the
query/mutation/actionfunctions the harness runs - Concepts: row-level security — drive procedures with
withIdentityto test authorization - @lunora/mail — captures the outbound email the helpers read
- @lunora/auth — the verification / reset / magic-link flows you test end-to-end