Add test infrastructure with coverage and DB test skipping

- Create @reviq/test-helpers package with shared test utilities
- Add describeE2E helper that auto-prefixes test names with [e2e]
- Support SKIP_DB_TESTS=1 to skip database-dependent tests
- Add unix socket support for TEST_DATABASE_URL
- Add root commands: test:unit, test:all, test:cov, test:unit:cov
- Configure bunfig.toml to exclude dist/ from coverage reports
- Clean up tsconfig.json files to remove redundant settings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
igm
2026-01-12 13:03:41 +08:00
parent 44a480179b
commit b2fba6e150
25 changed files with 3854 additions and 3688 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +0,0 @@
/**
* Test constants for WebAuthn e2e tests
*/
/** Test Relying Party configuration */
export const TEST_RP = {
rpName: "Test App",
rpID: "localhost",
origin: "http://localhost:3000",
allowedOrigins: ["http://localhost:3000"],
} as const;
/**
* Known AAGUIDs for testing passkey name assignment.
* These match the AAGUID_MAP in webauthn.ts
*/
export const KNOWN_AAGUIDS = {
ICLOUD_KEYCHAIN: "fbfc3007-154e-4ecc-8c0b-6e020557d7bd",
GOOGLE_PASSWORD_MANAGER: "ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4",
CHROME_ON_MAC: "adce0002-35bc-c60a-648b-0b25f1f05503",
WINDOWS_HELLO: "6028b017-b1d4-4c02-b4b3-afcdafc96bb2",
ONEPASSWORD: "bada5566-a7aa-401f-bd96-45619a55120d",
} as const;
/** Default AAGUID for virtual authenticator (unknown authenticator) */
export const DEFAULT_TEST_AAGUID = "00000000-0000-0000-0000-000000000000";

View File

@@ -1,301 +0,0 @@
/**
* Test database utilities for e2e tests
*/
import type { Database } from "@reviq/db-schema";
import type { Kysely } from "kysely";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { createDb } from "@reviq/db";
import { sql } from "kysely";
import pg from "pg";
const { Client } = pg;
/** Tables to truncate between tests (in order that respects foreign keys) */
const TABLES_TO_TRUNCATE = [
"sessions",
"api_tokens",
"login_requests",
"passkeys",
"user_devices",
"webauthn_challenges",
"email_verifications",
"password_resets",
"org_invites",
"org_sites",
"org_members",
"orgs",
"users",
] as const;
/**
* Creates a test database connection.
* Requires TEST_DATABASE_URL env var to be set.
*
* @throws Error if TEST_DATABASE_URL is not set
*/
export function createTestDb(): Kysely<Database> {
return createDb(getTestDatabaseUrl());
}
/**
* Gets the test database URL from environment.
* Requires TEST_DATABASE_URL to be set.
* Adds sslmode=disable for local development.
*
* @throws Error if TEST_DATABASE_URL is not set
*/
export function getTestDatabaseUrl(): string {
const url = Bun.env.TEST_DATABASE_URL;
if (!url) {
throw new Error(
"Test database URL not configured. Set TEST_DATABASE_URL environment variable.",
);
}
// Add sslmode=disable for local postgres
const parsed = new URL(url);
if (!parsed.searchParams.has("sslmode")) {
parsed.searchParams.set("sslmode", "disable");
}
return parsed.toString();
}
/**
* Parses a postgres URL to extract components
*/
function parsePostgresUrl(url: string): {
host: string;
port: number;
user: string;
password: string;
database: string;
} {
const parsed = new URL(url);
return {
host: parsed.hostname,
port: Number.parseInt(parsed.port || "5432", 10),
user: parsed.username,
password: parsed.password,
database: parsed.pathname.slice(1), // Remove leading /
};
}
/** Valid database name pattern: alphanumeric, underscores, hyphens only */
const VALID_DB_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
/**
* Creates the test database if it doesn't exist.
*/
async function ensureTestDatabaseExists(): Promise<void> {
const testDbUrl = getTestDatabaseUrl();
const { host, port, user, password, database } = parsePostgresUrl(testDbUrl);
// Validate database name to prevent SQL injection
if (!VALID_DB_NAME_PATTERN.test(database)) {
throw new Error(
`Invalid database name: "${database}". Only alphanumeric characters, underscores, and hyphens are allowed.`,
);
}
// Connect to 'postgres' database to create the test database
const client = new Client({
host,
port,
user,
password,
database: "postgres",
});
try {
await client.connect();
// Check if database exists
const result = await client.query(
"SELECT 1 FROM pg_database WHERE datname = $1",
[database],
);
if (result.rows.length === 0) {
// Create the database
// Database name is validated above, safe to use in query
await client.query(`CREATE DATABASE "${database}"`);
console.log(`Created test database: ${database}`);
}
} finally {
await client.end();
}
}
/**
* Finds the repository root by looking for db/migrations directory.
* Walks up from the current directory until found.
*
* @throws Error if repo root cannot be found
*/
function findRepoRoot(): string {
let current = import.meta.dir;
// Walk up to 10 levels to find the repo root
for (let i = 0; i < 10; i++) {
const migrationsPath = join(current, "db", "migrations");
if (existsSync(migrationsPath)) {
return current;
}
const parent = join(current, "..");
if (parent === current) {
break; // Reached filesystem root
}
current = parent;
}
throw new Error(
"Could not find repository root (looking for db/migrations directory)",
);
}
/**
* Runs database migrations using dbmate CLI.
* Creates the database if it doesn't exist.
* Should be called once before running test suite.
*/
export async function runMigrations(): Promise<void> {
const testDbUrl = getTestDatabaseUrl();
// Ensure the database exists first
await ensureTestDatabaseExists();
const repoRoot = findRepoRoot();
const proc = Bun.spawn(["dbmate", "up"], {
env: { ...process.env, DATABASE_URL: testDbUrl },
cwd: repoRoot,
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
throw new Error(
`Migration failed with code ${String(exitCode)}: ${stderr}`,
);
}
}
/**
* Truncates all tables to reset database state.
* Uses TRUNCATE ... CASCADE to handle foreign keys.
*/
export async function truncateAllTables(db: Kysely<Database>): Promise<void> {
// Use a single TRUNCATE statement with CASCADE for efficiency
const tableList = TABLES_TO_TRUNCATE.join(", ");
await sql`TRUNCATE ${sql.raw(tableList)} RESTART IDENTITY CASCADE`.execute(
db,
);
}
/**
* Creates a test user for e2e tests.
* Returns the created user with ID.
*/
export async function createTestUser(
db: Kysely<Database>,
overrides: Partial<{
email: string;
displayName: string;
fullName: string;
passwordHash: string;
emailVerifiedAt: Date;
isSuperuser: boolean;
}> = {},
): Promise<{ id: number; email: string }> {
const email = overrides.email ?? `test-${String(Date.now())}@example.com`;
const result = await db
.insertInto("users")
.values({
email,
display_name: overrides.displayName ?? "Test User",
full_name: overrides.fullName ?? null,
password_hash: overrides.passwordHash ?? null,
email_verified_at: overrides.emailVerifiedAt ?? null,
is_superuser: overrides.isSuperuser ?? false,
})
.returning(["id", "email"])
.executeTakeFirstOrThrow();
return result;
}
/**
* Destroys the database connection pool.
* Call this in afterAll() to clean up.
*/
export async function destroyTestDb(db: Kysely<Database>): Promise<void> {
await db.destroy();
}
// ============================================================================
// Shared Database Singleton (for transaction-based test isolation)
// ============================================================================
let sharedDb: Kysely<Database> | null = null;
/**
* Initialize the shared test database once.
* Runs migrations and truncates all tables to start with a clean slate.
* Subsequent calls return the existing connection.
*
* Use this with `withTestTransaction()` for fast test isolation.
*
* @example
* ```typescript
* beforeAll(async () => {
* await initTestDb();
* });
*
* test("does something", async () => {
* await withTestTransaction(getSharedDb(), async (db) => {
* // test code using db
* });
* });
* ```
*/
export async function initTestDb(): Promise<Kysely<Database>> {
if (!sharedDb) {
await runMigrations();
sharedDb = createTestDb();
await truncateAllTables(sharedDb); // Clean slate once at start
}
return sharedDb;
}
/**
* Get the shared test database connection.
* Must call `initTestDb()` first.
*
* @throws Error if database not initialized
*/
export function getSharedDb(): Kysely<Database> {
if (!sharedDb) {
throw new Error(
"Test DB not initialized. Call initTestDb() in beforeAll first.",
);
}
return sharedDb;
}
/**
* Destroy the shared test database connection.
* Call this in a global afterAll if needed.
*/
export async function destroySharedDb(): Promise<void> {
if (sharedDb) {
await sharedDb.destroy();
sharedDb = null;
}
}

View File

@@ -1,60 +0,0 @@
/**
* Transaction-based test isolation helper
*
* Wraps test code in a transaction that auto-rollbacks, providing
* fast test isolation without truncating tables between tests.
*/
import type { Database } from "@reviq/db-schema";
import type { Kysely } from "kysely";
/**
* Signal used to trigger transaction rollback after test completes
*/
class RollbackSignal extends Error {
constructor() {
super("RollbackSignal");
this.name = "RollbackSignal";
}
}
/**
* Runs a test function inside a transaction that auto-rollbacks.
*
* The transaction implements the same interface as Kysely<Database>,
* so it can be passed to context builders and used for all queries.
* After the test completes, the transaction is rolled back, providing
* instant cleanup without truncating tables.
*
* @example
* ```typescript
* test("creates user", async () => {
* await withTestTransaction(getSharedDb(), async (db) => {
* const user = await createTestUser(db, { email: "test@example.com" });
* const ctx = createAPIContext({ db });
* // ... test code
* }); // Auto-rollback here
* });
* ```
*/
export async function withTestTransaction<T>(
db: Kysely<Database>,
testFn: (trx: Kysely<Database>) => Promise<T>,
): Promise<T | undefined> {
let result: T | undefined;
try {
await db.transaction().execute(async (trx) => {
result = await testFn(trx);
// Force rollback by throwing after test completes successfully
throw new RollbackSignal();
});
} catch (e) {
// Swallow the rollback signal - this is expected behavior
if (!(e instanceof RollbackSignal)) {
throw e;
}
}
return result;
}