PackagesTesting

@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/testing
npm install -D @lunora/testing
yarn add -d @lunora/testing
bun add -D @lunora/testing

In-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:

MethodDescription
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.
schedulerControls 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();
});
ControlDescription
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:

ExportDescription
listCapturedMailRead the captured-mail inbox, newest first.
waitForMailPoll the inbox until a message matching to (and optional subjectMatch) appears.
extractLinkPull the first link out of a captured message (html first, then text).
InboxOptionsType — options for listCapturedMail.
WaitForMailOptionsType — 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:

OptionDefaultDescription
adminTokenAdmin bearer token (LUNORA_ADMIN_TOKEN).
baseUrlApp base URL, e.g. http://localhost:8787.
toRecipient address the message must be addressed to.
subjectMatchOnly match a subject containing this substring.
limit50Newest-N messages to read per poll.
pollMs250Poll interval in ms.
timeoutMs10000Give up after this many ms.
fetchglobalThisInject a fetch implementation (e.g. a stub).

See also