test: add unit, API, component and end-to-end test suites

The repo had no automated tests. Every change was verified by hand.

Adds four layers, 2023 tests in total, runnable with one command:

- 1191 unit tests over the pure logic in lib/, including the full
  computeProjectAccess permission matrix and the billing gate
- 167 component and hook tests in jsdom, covering the hooks that hold
  real logic rather than presentational wrappers
- 647 API integration tests against a real Postgres, with only auth()
  mocked, including a data-driven sweep asserting that none of the 60
  route modules answers 2xx to an unauthenticated caller
- 18 Playwright specs driving a real browser against a real build

Infrastructure: vitest.config.ts with three projects, a disposable
Postgres and MinIO in docker-compose.test.yml, factories and helpers
under tests/, scripts/test.sh as the single entry point, a pre-push
hook running bun run verify, and CI split into check, test and e2e jobs.

The test database is built with prisma db push plus a replay of the
hand-written SQL, because prisma migrate deploy cannot build this schema
from empty: the migration history has no captured baseline. This mirrors
what scripts/docker-db-bootstrap.ts already does in production, and
tests/setup/db-global.ts carries a drift guard so a new migration fails
the run until someone reviews it.

Production code is unchanged apart from one pure-function extraction out
of use-video-player.ts, which was too large to test in jsdom.

Several tests pin behaviour that looks wrong, each marked KNOWN BUG in
place. TESTING.md section 12 records where the plan turned out to be
wrong, and AGENTS.md now states which layer a change needs a test in.
This commit is contained in:
yusufipk
2026-07-26 11:17:26 +07:00
parent 52b2c8d2a9
commit 1d099c68f2
101 changed files with 27625 additions and 122 deletions
+109
View File
@@ -0,0 +1,109 @@
// Test-database lifecycle helpers.
//
// Importing this module imports `@/lib/db`, which reads DATABASE_URL at module
// load. Anything that imports this must have loaded `tests/helpers/env.ts`
// first; `tests/setup/api.ts` does exactly that.
import { db } from '@/lib/db';
// Prisma owns this table and emptying it would make `prisma db push` believe the
// database has never been set up.
const PRESERVED_TABLES = new Set(['_prisma_migrations']);
let cachedTableNames: string[] | null = null;
let cachedResetStatement: string | null = null;
/**
* Every base table in the `public` schema, read from `information_schema` so
* the list can never drift out of sync with `prisma/schema.prisma`. A model
* added tomorrow is emptied tomorrow, with no edit here.
*/
export async function listResettableTables(): Promise<string[]> {
if (cachedTableNames) return cachedTableNames;
const rows = await db.$queryRaw<Array<{ table_name: string }>>`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
ORDER BY table_name
`;
const names = rows.map((row) => row.table_name).filter((name) => !PRESERVED_TABLES.has(name));
if (names.length === 0) {
throw new Error(
'resetDb() found no tables in the public schema. The test database was ' +
'probably never migrated. Check that tests/setup/db-global.ts ran ' +
'`prisma db push` against DATABASE_URL.'
);
}
cachedTableNames = names;
return names;
}
/**
* Builds the single statement that empties the database.
*
* Two decisions worth explaining, because the obvious implementation is both
* slower and wrong:
*
* - DELETE, not TRUNCATE. `TRUNCATE` rewrites the relation file for every
* table and every index, which measured at ~36ms per call against this
* container even with fsync off and the data directory on tmpfs. Across a
* suite that resets after every test that is most of the runtime. The
* equivalent DELETE costs ~3ms.
*
* - One statement, via data-modifying CTEs, rather than one DELETE per table.
* Prisma's foreign keys are NOT DEFERRABLE, so a sequence of separate
* DELETEs has to run in child-before-parent order or it trips a constraint.
* Inside a single statement the FK triggers all fire after the whole
* statement has run, by which point every row is already gone, so no
* ordering is needed and a newly added table cannot break the order.
*
* The trailing `setval` calls stand in for TRUNCATE's `RESTART IDENTITY`, so a
* test can still rely on `rate_limits.id` starting from 1.
*/
async function buildResetStatement(): Promise<string> {
if (cachedResetStatement) return cachedResetStatement;
const tables = await listResettableTables();
const sequences = await db.$queryRaw<Array<{ sequence_name: string }>>`
SELECT sequence_name
FROM information_schema.sequences
WHERE sequence_schema = 'public'
ORDER BY sequence_name
`;
const deletes = tables
.map((table, index) => `"d${index}" AS (DELETE FROM "public"."${table}")`)
.join(', ');
const projection =
sequences.length > 0
? sequences.map((row) => `setval('"public"."${row.sequence_name}"', 1, false)`).join(', ')
: '1';
cachedResetStatement = `WITH ${deletes} SELECT ${projection}`;
return cachedResetStatement;
}
/**
* Empties the test database. Registered as `afterEach` in tests/setup/api.ts,
* so every test starts from zero rows and no test may depend on another test's
* data or on file execution order.
*/
export async function resetDb(): Promise<void> {
await db.$executeRawUnsafe(await buildResetStatement());
}
/** Row count for a table, for assertions like "nothing was written". */
export async function countRows(table: string): Promise<number> {
const rows = await db.$queryRawUnsafe<Array<{ count: bigint }>>(
`SELECT COUNT(*)::bigint AS count FROM "public"."${table}"`
);
return Number(rows[0]?.count ?? 0);
}
export { db };
+86
View File
@@ -0,0 +1,86 @@
// Loads `.env.test` into `process.env`.
//
// This module exists so it can be the *first* import of both
// `tests/setup/api.ts` and `tests/setup/db-global.ts`. ESM evaluates imports in
// source order, so putting `import '../helpers/env';` above everything else
// guarantees DATABASE_URL is set before `@/lib/db` is reached: that module reads
// `process.env.DATABASE_URL` once at import time and memoizes the pg pool on
// `globalThis`, so a late load would silently point every test at the wrong
// database (or at no database at all).
//
// It must therefore never import from `@/lib/*`.
//
// Contract: an already-exported variable always wins. `.env.test` fills the
// gaps. That is what lets CI export DATABASE_URL for a service container
// without needing a `.env.test` file at all.
import fs from 'node:fs';
import path from 'node:path';
import { config as loadDotenv } from 'dotenv';
/**
* Walks up from the working directory until it finds the checkout.
*
* This deliberately avoids `import.meta.url`, which would be the obvious way to
* resolve a path relative to this file: Playwright transpiles TypeScript to
* CommonJS unless package.json declares `"type": "module"`, and in CommonJS
* `import.meta` is a *syntax* error, so the e2e suite could not import this
* module at all. `__dirname` has the mirror-image problem under Vitest's ESM.
*
* The marker is prisma/schema.prisma as well as package.json, so a stray
* package.json inside node_modules cannot be mistaken for the checkout.
*/
function findRepoRoot(): string {
let current = path.resolve(process.cwd());
for (;;) {
if (
fs.existsSync(path.join(current, 'package.json')) &&
fs.existsSync(path.join(current, 'prisma', 'schema.prisma'))
) {
return current;
}
const parent = path.dirname(current);
if (parent === current) {
throw new Error(
`Could not locate the OpenFrame checkout from ${process.cwd()}: no ancestor ` +
'directory holds both package.json and prisma/schema.prisma. Run the test ' +
'suites from the repository root.'
);
}
current = parent;
}
}
export const REPO_ROOT = findRepoRoot();
export const TEST_ENV_PATH = path.join(REPO_ROOT, '.env.test');
let loaded = false;
export function loadTestEnv(): void {
if (loaded) return;
loaded = true;
if (fs.existsSync(TEST_ENV_PATH)) {
loadDotenv({ path: TEST_ENV_PATH, quiet: true });
}
if (!process.env.DATABASE_URL) {
throw new Error(
'DATABASE_URL is not set for the api test project. Either create .env.test ' +
'(cp .env.test.example .env.test) or export DATABASE_URL before running ' +
'bun run test:api.'
);
}
// Vitest sets this already, but db-global.ts also spawns the Prisma CLI and
// lib/rate-limit.ts throws when DISABLE_RATE_LIMIT is set in production.
// @types/node declares NODE_ENV as read-only, hence the cast.
if (!process.env.NODE_ENV) {
(process.env as Record<string, string | undefined>).NODE_ENV = 'test';
}
}
loadTestEnv();
+47
View File
@@ -0,0 +1,47 @@
// Capture for the mocked `nodemailer` transport installed in
// tests/setup/api.ts. Every sendMail() call lands here instead of on the wire.
//
// The store hangs off globalThis because the vi.mock factory in the setup file
// and the test file that asserts on it are separate module instances in some
// Vitest isolation modes; a plain module-level array would not be shared.
export interface CapturedMail {
from?: string;
to?: string;
subject?: string;
html?: string;
text?: string;
}
const globalForMail = globalThis as unknown as { __openframeCapturedMail?: CapturedMail[] };
function store(): CapturedMail[] {
globalForMail.__openframeCapturedMail ??= [];
return globalForMail.__openframeCapturedMail;
}
export function recordSentMail(message: unknown): void {
const record = (message ?? {}) as Record<string, unknown>;
store().push({
from: typeof record.from === 'string' ? record.from : undefined,
to: typeof record.to === 'string' ? record.to : undefined,
subject: typeof record.subject === 'string' ? record.subject : undefined,
html: typeof record.html === 'string' ? record.html : undefined,
text: typeof record.text === 'string' ? record.text : undefined,
});
}
/** Everything sent since the last reset, oldest first. */
export function sentMail(): readonly CapturedMail[] {
return store();
}
/** Messages addressed to one recipient, case-insensitive. */
export function mailTo(address: string): readonly CapturedMail[] {
const needle = address.toLowerCase();
return store().filter((mail) => mail.to?.toLowerCase() === needle);
}
export function resetSentMail(): void {
store().length = 0;
}
+125
View File
@@ -0,0 +1,125 @@
// Builders for calling App Router route handlers directly.
//
// There are no `use server` actions in this repo, so every mutation goes through
// an exported function in app/api/**/route.ts. Those are plain functions: given
// a NextRequest and a context whose `params` is a promise (the convention
// AGENTS.md mandates), they can be invoked with no server running.
import { NextRequest } from 'next/server';
const DEFAULT_ORIGIN = 'http://localhost:3000';
export interface ApiRequestInit {
method?: string;
/** Serialised as JSON, with content-type set unless you override it. */
body?: unknown;
/** Sent verbatim. Use for multipart bodies and for malformed-JSON tests. */
rawBody?: BodyInit;
headers?: Record<string, string>;
/** Names and values are used verbatim, so keep values cookie-safe. */
cookies?: Record<string, string>;
searchParams?: Record<string, string | number | boolean | undefined>;
}
/**
* Builds a NextRequest for `url`, which may be a path (resolved against
* http://localhost:3000) or an absolute URL.
*
* The method defaults to GET, or to POST when a body is supplied, because the
* fetch spec rejects a GET request that carries one.
*/
export function apiRequest(url: string, init: ApiRequestInit = {}): NextRequest {
const target = new URL(url, DEFAULT_ORIGIN);
for (const [key, value] of Object.entries(init.searchParams ?? {})) {
if (value === undefined) continue;
target.searchParams.set(key, String(value));
}
const headers = new Headers(init.headers);
let body: BodyInit | undefined;
if (init.rawBody !== undefined) {
body = init.rawBody;
} else if (init.body !== undefined) {
body = JSON.stringify(init.body);
if (!headers.has('content-type')) {
headers.set('content-type', 'application/json');
}
}
const cookieEntries = Object.entries(init.cookies ?? {});
if (cookieEntries.length > 0) {
headers.set('cookie', cookieEntries.map(([name, value]) => `${name}=${value}`).join('; '));
}
const method = init.method ?? (body === undefined ? 'GET' : 'POST');
return new NextRequest(target, { method, headers, body });
}
/**
* A route handler as exported from app/api/**\/route.ts.
*
* `undefined` is in the return type because several handlers return a value
* whose type TypeScript widens to `NextResponse | undefined` (the early-return
* branches out of a discriminated result object). callRoute turns that into a
* loud failure rather than propagating it.
*/
export type RouteHandler<P> = (
request: NextRequest,
context: { params: Promise<P> }
) => Promise<Response | undefined> | Response | undefined;
/**
* Invokes a route handler, wrapping `params` in the resolved promise the App
* Router passes in. Handlers that take no params can be called with two
* arguments.
*/
export async function callRoute<P extends Record<string, string | string[]>>(
handler: RouteHandler<P>,
request: NextRequest,
params: P = {} as P
): Promise<Response> {
const response = await handler(request, { params: Promise.resolve(params) });
if (!response) {
throw new Error(
`Route handler for ${request.method} ${request.url} returned no response. ` +
'Next.js would turn that into a 500.'
);
}
return response;
}
/** Parses a JSON response body. Fails loudly with the raw text when it is not JSON. */
export async function readJson<T = any>(response: Response): Promise<T> {
const text = await response.text();
try {
return JSON.parse(text) as T;
} catch {
throw new Error(
`Expected a JSON body but got status ${response.status} with: ${text.slice(0, 500)}`
);
}
}
/** `data` out of a `successResponse()` envelope. */
export async function readData<T = any>(response: Response): Promise<T> {
const payload = await readJson<{ data: T }>(response);
return payload.data;
}
/** `error` out of an `errorResponse()` envelope. */
export async function readError(response: Response): Promise<string> {
const payload = await readJson<{ error?: string }>(response);
return payload.error ?? '';
}
/** Builds a multipart body for the upload routes. */
export function multipart(fields: Record<string, string | Blob>): FormData {
const form = new FormData();
for (const [name, value] of Object.entries(fields)) {
form.append(name, value);
}
return form;
}
+66
View File
@@ -0,0 +1,66 @@
// Drives the `auth()` mock installed by tests/setup/api.ts.
//
// Only `auth` is faked. `checkProjectAccess`, `checkWorkspaceAccess` and
// `computeProjectAccess` are the real implementations running against the real
// test database, because they are the code under test: a suite that stubbed them
// out would assert nothing about authorization.
import type { Mock } from 'vitest';
import type { Session } from 'next-auth';
import { auth } from '@/lib/auth';
type AuthMock = Mock<() => Promise<Session | null>>;
/** The vi.fn() standing in for `auth()`. */
export function authMock(): AuthMock {
const mock = auth as unknown as AuthMock;
if (typeof mock?.mockResolvedValue !== 'function') {
throw new Error(
'auth() is not mocked. tests/helpers/session.ts only works inside the `api` ' +
'Vitest project, whose setupFiles include tests/setup/api.ts.'
);
}
return mock;
}
export interface SessionUserInput {
id: string;
email?: string | null;
name?: string | null;
image?: string | null;
isAdmin?: boolean;
}
/**
* Makes every subsequent `auth()` call in the route under test resolve to a
* session for this user. Accepts a factory-created user row directly.
*/
export function signedInAs(user: SessionUserInput): Session {
const session = {
user: {
id: user.id,
email: user.email ?? null,
name: user.name ?? null,
image: user.image ?? null,
isAdmin: user.isAdmin ?? false,
},
expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
} as unknown as Session;
authMock().mockResolvedValue(session);
return session;
}
/** Makes every subsequent `auth()` call resolve to null. */
export function signedOut(): void {
authMock().mockResolvedValue(null);
}
/**
* A session whose `user.id` points at no row in the database. Distinct from
* signedOut(): it is the shape a route sees when a JWT outlives its user, and it
* separates "no session" handling from "unknown user" handling.
*/
export function signedInAsGhost(id = 'ghost-user-id-that-does-not-exist'): Session {
return signedInAs({ id, email: '[email protected]', name: 'Ghost' });
}