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:
12
packages/testing/test-helpers/eslint.config.js
Normal file
12
packages/testing/test-helpers/eslint.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { configs } from "@macalinao/eslint-config";
|
||||
|
||||
export default [
|
||||
...configs.fast,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
33
packages/testing/test-helpers/package.json
Normal file
33
packages/testing/test-helpers/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
18
packages/testing/test-helpers/src/index.ts
Normal file
18
packages/testing/test-helpers/src/index.ts
Normal 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";
|
||||
18
packages/testing/test-helpers/src/skip-db-tests.ts
Normal file
18
packages/testing/test-helpers/src/skip-db-tests.ts
Normal 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);
|
||||
}
|
||||
26
packages/testing/test-helpers/src/test-constants.ts
Normal file
26
packages/testing/test-helpers/src/test-constants.ts
Normal 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";
|
||||
312
packages/testing/test-helpers/src/test-db.ts
Normal file
312
packages/testing/test-helpers/src/test-db.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
60
packages/testing/test-helpers/src/test-transaction.ts
Normal file
60
packages/testing/test-helpers/src/test-transaction.ts
Normal 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;
|
||||
}
|
||||
6
packages/testing/test-helpers/tsconfig.json
Normal file
6
packages/testing/test-helpers/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["bun"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user