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

View File

@@ -0,0 +1,12 @@
import { configs } from "@macalinao/eslint-config";
export default [
...configs.fast,
{
languageOptions: {
parserOptions: {
tsconfigRootDir: import.meta.dirname,
},
},
},
];

View File

@@ -0,0 +1,33 @@
{
"name": "@reviq/test-helpers",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
"lint": "eslint . --cache"
},
"dependencies": {
"@reviq/db": "workspace:*",
"@reviq/db-schema": "workspace:*",
"kysely": "^0.28.2",
"pg": "^8.16.3"
},
"devDependencies": {
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@types/bun": "catalog:",
"@types/pg": "^8.16.0",
"eslint": "catalog:",
"typescript": "catalog:"
}
}

View File

@@ -0,0 +1,18 @@
export { describeE2E, SKIP_DB_TESTS } from "./skip-db-tests.js";
export {
DEFAULT_TEST_AAGUID,
KNOWN_AAGUIDS,
TEST_RP,
} from "./test-constants.js";
export {
createTestDb,
createTestUser,
destroySharedDb,
destroyTestDb,
getSharedDb,
getTestDatabaseUrl,
initTestDb,
runMigrations,
truncateAllTables,
} from "./test-db.js";
export { withTestTransaction } from "./test-transaction.js";

View File

@@ -0,0 +1,18 @@
import { describe } from "bun:test";
/**
* Skip flag for database-dependent tests.
* Set SKIP_DB_TESTS=1 to skip e2e tests that require a database.
*/
export const SKIP_DB_TESTS: boolean = process.env.SKIP_DB_TESTS === "1";
const _describeSkipIf = describe.skipIf(SKIP_DB_TESTS);
/**
* Use for describe blocks that require database access.
* Automatically prefixes name with [e2e].
* Skips tests when SKIP_DB_TESTS=1 is set.
*/
export function describeE2E(name: string, fn: () => void): void {
_describeSkipIf(`[e2e] ${name}`, fn);
}

View File

@@ -0,0 +1,26 @@
/**
* 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

@@ -0,0 +1,312 @@
/**
* 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.
* Supports both TCP and unix socket connections.
*
* Unix socket URL format: postgresql:///dbname?host=/var/run/postgresql
*/
function parsePostgresUrl(url: string): {
host: string;
port: number | undefined;
user: string;
password: string;
database: string;
} {
const parsed = new URL(url);
// Unix socket: hostname is empty, socket path in `host` query param
const isUnixSocket = !parsed.hostname;
const socketPath = parsed.searchParams.get("host");
return {
host: isUnixSocket
? (socketPath ?? "/var/run/postgresql")
: parsed.hostname,
port: isUnixSocket ? undefined : Number.parseInt(parsed.port || "5432", 10),
// eslint-disable-next-line turbo/no-undeclared-env-vars, @typescript-eslint/prefer-nullish-coalescing -- USER is a system env var, and we want empty string to fall back
user: parsed.username || process.env.USER || "postgres",
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

@@ -0,0 +1,60 @@
/**
* 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;
}

View File

@@ -0,0 +1,6 @@
{
"extends": "@macalinao/tsconfig/tsconfig.base.json",
"compilerOptions": {
"types": ["bun"]
}
}

View File

@@ -3,14 +3,19 @@
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./src/index.ts"
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
"lint": "eslint . --cache",
"test": "bun test"
"test": "bun test src/"
},
"dependencies": {
"@simplewebauthn/types": "^12.0.0"
@@ -18,7 +23,7 @@
"devDependencies": {
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@types/bun": "latest",
"@types/bun": "catalog:",
"@types/node": "^25.0.3",
"eslint": "catalog:",
"typescript": "catalog:"

View File

@@ -1,15 +1,7 @@
{
"extends": "@macalinao/tsconfig/tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"composite": true,
"types": ["node", "bun"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}