Developers
Testing
Running the tests
pnpm db:up # once: Postgres on 5433
pnpm --filter app test # the app's tests
pnpm --filter app test:watch # re-run on save
pnpm --filter app test sweeps # only files matching "sweeps"
pnpm test # every package, through TurborepoThere are 48 test files in the app, one in @repo/email (every template renders) and two in @repo/mcp (the API client and the tools). Tests sit next to what they test, as *.test.ts or *.test.tsx; the shared harness is in apps/app/test.
The test database
Anything that touches Postgres runs against resell_test on the same Docker server as your dev database, set by TEST_DATABASE_URL (default postgres://resell:resell@localhost:5433/resell_test).
test/global-setup.tsruns once per run: it creates the database if it's missing and applies every migration, usingensureDatabaseandmigrateDatabasefrom@repo/db/migrate.- It refuses any database whose name doesn't end in
_test, so a typo can't wipe real data. - Test files run one at a time (
fileParallelism: false) because they share the database. resetDb()fromtest/db.tstruncates every table. Call it inbeforeEach.
What's mocked
| What | How |
|---|---|
test/setup.ts replaces sendEmail with a spy for every file. emailsTo(address) returns the subjects sent to someone; clearEmails() resets. | |
| Outside services | vitest.config.ts blanks the PayPal, Resend, Anthropic, Channel3, Jina and R2 keys, so every "configured" flag is false and nothing can reach a real service |
| PayPal calls | Tests that need a release or refund vi.mock("./paypal") and stub releaseToSeller / refundCapture. paypal.test.ts checks the client itself against a stubbed fetch |
| Service clients | channel3.test.ts, embeddings.test.ts and the share-image tests stub fetch |
| server-only | Aliased to an empty module (test/empty.ts) |
| Next.js | Tests that go through actions or route handlers mock next/server's after and next/cache's revalidatePath |
The config also fixes NEXT_PUBLIC_ROOT_DOMAIN=localhost:5689, a test auth secret, NODE_ENV=test and CRON_SECRET=test-cron-secret, and clears PAYOUT_DEMO_MINUTES_PER_DAY and PLATFORM_FEE_BPS so timings and fees are the real defaults. The Render Workflow wrapper isn't tested; the sweeps it calls are.
Writing a test
Factories in test/factories.ts make rows with sensible defaults and take overrides. createSale() gives you a seller ("Maya Seller") with a shop and a live $100 listing with $9 shipping, plus a buyer ("Jess Buyer"). Then createOrder(sale, { ... }) and createOffer(sale, { ... }) add an order or offer in any state; { paypal: true } makes an order look like a real capture. daysAgo(n) helps with timings. test/factories-market.ts adds files, photos, follows, likes and views.
import { beforeEach, expect, it } from "vitest";
import { db, eq, orders } from "@repo/db";
import { resetDb } from "../../test/db";
import { createOrder, createSale, daysAgo } from "../../test/factories";
import { clearEmails, emailsTo } from "../../test/mail";
import { cancelUnshipped } from "./sweeps";
beforeEach(async () => {
await resetDb();
clearEmails();
});
it("cancels an order that never shipped", async () => {
const sale = await createSale();
const order = await createOrder(sale, { createdAt: daysAgo(8) });
expect(await cancelUnshipped(order.id)).toBe(true);
expect(await cancelUnshipped(order.id)).toBe(false); // twice does nothing
const [row] = await db.select().from(orders).where(eq(orders.id, order.id));
expect(row!.status).toBe("cancelled");
expect(emailsTo(sale.buyer.email).length).toBeGreaterThan(0);
});Factory people get @test.dev addresses, so notification code really "sends" into the spy. Use an @example.com address to check the path where mail is only logged.
vi.mock("./paypal")) go at the top of the file, then import the module under test with await import("./sweeps") so it picks up the mock.Testing the API
test/factories-api.ts issues a real (hashed) key with createKey(userId, { scopes }) and calls the actual /v1 route handler with api(method, path, { token, json }), the way curl or the MCP client would. See lib/server/api/routes.test.ts for a buyer making an offer and a seller answering it, end to end.
const { token } = await createKey(sale.seller.id);
const res = await api("GET", "/listings", { token });
expect(res.status).toBe(200);
expect(res.body.data[0].price).toBe(100); // dollars in the APIThrough Turborepo
pnpm test runs turbo run test: each package's own vitest run. The test task has caching turned off (results depend on the database, not just files) and passes TEST_DATABASE_URL through. On a CI machine, start Postgres with pgvector first (the docker-compose.yml service works) and set TEST_DATABASE_URL if it isn't on localhost:5433. Type checks and lint are separate: pnpm check-types and pnpm lint.
Scripts outside Next
Server modules start with import "server-only", which throws anywhere but inside Next.js. apps/app/workflows/server-only.mjs is a Node resolve hook that swaps it for an empty module. The workflow and the seed load it with --import; do the same to try server code from a script:
cd apps/app
pnpm exec tsx --env-file=.env.local --import ./workflows/server-only.mjs scripts/try-something.ts