Developers

Testing

Code tests with Vitest, against a real Postgres test database, with every outside service switched off. No browser.

Running the tests

Terminal
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 Turborepo

There 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.ts runs once per run: it creates the database if it's missing and applies every migration, using ensureDatabase and migrateDatabase from @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() from test/db.ts truncates every table. Call it in beforeEach.

What's mocked

WhatHow
Emailtest/setup.ts replaces sendEmail with a spy for every file. emailsTo(address) returns the subjects sent to someone; clearEmails() resets.
Outside servicesvitest.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 callsTests 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 clientschannel3.test.ts, embeddings.test.ts and the share-image tests stub fetch
server-onlyAliased to an empty module (test/empty.ts)
Next.jsTests 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.

lib/server/sweeps.test.ts
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.

Mocks that depend on module state (like 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.

Example
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 API

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

Terminal
cd apps/app
pnpm exec tsx --env-file=.env.local --import ./workflows/server-only.mjs scripts/try-something.ts