mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-12 01:46:08 +00:00
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:
@@ -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 };
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
Reference in New Issue
Block a user