Merge branch 'test-coverage'
Some checks failed
CI / ci (push) Has been cancelled

Add @reviq/test-helpers package with e2e tests for admin, auth, orgs, and webauthn.
Move test utilities to shared package.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
igm
2026-01-12 13:43:28 +08:00
30 changed files with 8826 additions and 3823 deletions

View File

@@ -0,0 +1,8 @@
id: no-countall-number
language: typescript
severity: error
message: "Don't use countAll<number>() - use countAll() instead. PostgreSQL COUNT returns bigint (string), so the type annotation is misleading."
note: "Use Number() to convert the result if you need a number type."
rule:
pattern: countAll<number>()
fix: countAll()

View File

@@ -9,9 +9,7 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint . --cache", "lint": "eslint . --cache",
"clean": "rm -rf dist .eslintcache", "clean": "rm -rf dist .eslintcache",
"test:e2e": "bun test src/__tests__/e2e --no-parallel --coverage", "test": "bun test src/ --no-parallel"
"test:unit": "bun test src/__tests__/unit",
"test": "bun test --coverage src/utils"
}, },
"dependencies": { "dependencies": {
"@formatjs/intl-durationformat": "^0.9.2", "@formatjs/intl-durationformat": "^0.9.2",
@@ -34,12 +32,11 @@
"devDependencies": { "devDependencies": {
"@macalinao/eslint-config": "catalog:", "@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:", "@macalinao/tsconfig": "catalog:",
"@reviq/test-helpers": "workspace:*",
"@reviq/virtual-authenticator": "workspace:*", "@reviq/virtual-authenticator": "workspace:*",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/pg": "^8.16.0",
"@types/zxcvbn": "^4.4.5", "@types/zxcvbn": "^4.4.5",
"eslint": "catalog:", "eslint": "catalog:",
"pg": "^8.16.3",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"typescript": "catalog:" "typescript": "catalog:"
} }

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -52,17 +52,28 @@ export async function signupWithPassword(
// Hash password // Hash password
const passwordHash = await hashPassword(password); const passwordHash = await hashPassword(password);
// Create user // Create user (handle race condition if concurrent signup with same email)
const user = await db try {
.insertInto("users") const user = await db
.values({ .insertInto("users")
email, .values({
password_hash: passwordHash, email,
}) password_hash: passwordHash,
.returning(["id"]) })
.executeTakeFirstOrThrow(); .returning(["id"])
.executeTakeFirstOrThrow();
return user.id; return user.id;
} catch (error) {
// Handle duplicate email (unique constraint violation)
// Use generic error to prevent email enumeration
if (error instanceof Error && error.message.includes("users_email_key")) {
throw new ORPCError("BAD_REQUEST", {
message: "Unable to create account",
});
}
throw error;
}
} }
/** /**
@@ -146,55 +157,66 @@ export async function signupWithPasskey(
}); });
} }
// Create user and passkey in a transaction // Create user and passkey in a transaction (handle race condition if concurrent signup)
const result = await db.transaction().execute(async (trx) => { try {
// Create user const result = await db.transaction().execute(async (trx) => {
const user = await trx // Create user
.insertInto("users") const user = await trx
.values({ .insertInto("users")
email, .values({
password_hash: null, email,
}) password_hash: null,
.returning(["id"]) })
.executeTakeFirstOrThrow(); .returning(["id"])
.executeTakeFirstOrThrow();
const newUserId = user.id; const newUserId = user.id;
// Get friendly name from AAGUID // Get friendly name from AAGUID
const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid]; const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid];
const passkeyName = guidName ?? "Default"; const passkeyName = guidName ?? "Default";
// Store the passkey // Store the passkey
const { credential, credentialDeviceType, credentialBackedUp } = const { credential, credentialDeviceType, credentialBackedUp } =
registrationInfo; registrationInfo;
await trx await trx
.insertInto("passkeys") .insertInto("passkeys")
.values({ .values({
user_id: newUserId, user_id: newUserId,
credential_id: Buffer.from(credential.id, "base64url"), credential_id: Buffer.from(credential.id, "base64url"),
public_key: Buffer.from(credential.publicKey), public_key: Buffer.from(credential.publicKey),
webauthn_user_id: options.user.id, webauthn_user_id: options.user.id,
counter: BigInt(credential.counter), counter: BigInt(credential.counter),
device_type: credentialDeviceType as "singleDevice" | "multiDevice", device_type: credentialDeviceType as "singleDevice" | "multiDevice",
backup_eligible: registrationInfo.credentialBackedUp, backup_eligible: registrationInfo.credentialBackedUp,
backup_status: credentialBackedUp, backup_status: credentialBackedUp,
transports: JSON.stringify(response.response.transports ?? []), transports: JSON.stringify(response.response.transports ?? []),
rpid: rpInfo.rpID, rpid: rpInfo.rpID,
name: passkeyName, name: passkeyName,
}) })
.execute(); .execute();
// Delete the challenge // Delete the challenge
await trx await trx
.deleteFrom("webauthn_challenges") .deleteFrom("webauthn_challenges")
.where("id", "=", String(challengeId)) .where("id", "=", String(challengeId))
.execute(); .execute();
return { userId: newUserId }; return { userId: newUserId };
}); });
return result.userId; return result.userId;
} catch (error) {
// Handle duplicate email (unique constraint violation)
// Use generic error to prevent email enumeration
if (error instanceof Error && error.message.includes("users_email_key")) {
throw new ORPCError("BAD_REQUEST", {
message: "Unable to create account",
});
}
throw error;
}
} }
/** /**
@@ -241,7 +263,7 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
); );
userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo); userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo);
} else { } else {
// Should never reach here due to schema validation // Unreachable - schema validation requires password or passkeyInfo
throw new ORPCError("BAD_REQUEST", { throw new ORPCError("BAD_REQUEST", {
message: "Either password or passkeyInfo is required", message: "Either password or passkeyInfo is required",
}); });

View File

@@ -115,10 +115,11 @@ export async function countOwners(
): Promise<number> { ): Promise<number> {
const result = await db const result = await db
.selectFrom("org_members") .selectFrom("org_members")
.select((eb) => eb.fn.countAll<number>().as("count")) .select((eb) => eb.fn.countAll().as("count"))
.where("org_id", "=", orgId) .where("org_id", "=", orgId)
.where("role", "=", "owner") .where("role", "=", "owner")
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
return result.count; // PostgreSQL COUNT returns bigint (string), convert to number
return Number(result.count);
} }

View File

@@ -13,7 +13,7 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint . --cache", "lint": "eslint . --cache",
"clean": "rm -rf dist .eslintcache", "clean": "rm -rf dist .eslintcache",
"test": "bun test" "test": "bun test src/"
}, },
"dependencies": { "dependencies": {
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.0.1",

View File

@@ -35,12 +35,11 @@
"devDependencies": { "devDependencies": {
"@macalinao/eslint-config": "catalog:", "@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:", "@macalinao/tsconfig": "catalog:",
"@reviq/test-helpers": "workspace:*",
"@reviq/virtual-authenticator": "workspace:*", "@reviq/virtual-authenticator": "workspace:*",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/pg": "^8.16.0",
"@types/zxcvbn": "^4.4.5", "@types/zxcvbn": "^4.4.5",
"eslint": "catalog:", "eslint": "catalog:",
"pg": "^8.16.3",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"typescript": "catalog:", "typescript": "catalog:",
}, },
@@ -192,6 +191,24 @@
"typescript": "catalog:", "typescript": "catalog:",
}, },
}, },
"packages/testing/test-helpers": {
"name": "@reviq/test-helpers",
"version": "0.0.1",
"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:",
},
},
"packages/testing/virtual-authenticator": { "packages/testing/virtual-authenticator": {
"name": "@reviq/virtual-authenticator", "name": "@reviq/virtual-authenticator",
"version": "0.0.1", "version": "0.0.1",
@@ -201,7 +218,7 @@
"devDependencies": { "devDependencies": {
"@macalinao/eslint-config": "catalog:", "@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:", "@macalinao/tsconfig": "catalog:",
"@types/bun": "latest", "@types/bun": "catalog:",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"eslint": "catalog:", "eslint": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
@@ -439,6 +456,8 @@
"@reviq/frontend-utils": ["@reviq/frontend-utils@workspace:packages/frontend-utils"], "@reviq/frontend-utils": ["@reviq/frontend-utils@workspace:packages/frontend-utils"],
"@reviq/test-helpers": ["@reviq/test-helpers@workspace:packages/testing/test-helpers"],
"@reviq/utils": ["@reviq/utils@workspace:packages/utils"], "@reviq/utils": ["@reviq/utils@workspace:packages/utils"],
"@reviq/virtual-authenticator": ["@reviq/virtual-authenticator@workspace:packages/testing/virtual-authenticator"], "@reviq/virtual-authenticator": ["@reviq/virtual-authenticator@workspace:packages/testing/virtual-authenticator"],

5
bunfig.toml Normal file
View File

@@ -0,0 +1,5 @@
[test]
coveragePathIgnorePatterns = [
"**/dist/**",
"**/node_modules/**",
]

View File

@@ -16,6 +16,10 @@
"typecheck": "turbo typecheck", "typecheck": "turbo typecheck",
"clean": "turbo clean", "clean": "turbo clean",
"test": "turbo test", "test": "turbo test",
"test:unit": "SKIP_DB_TESTS=1 turbo test",
"test:all": "turbo test",
"test:cov": "bun test --coverage",
"test:unit:cov": "SKIP_DB_TESTS=1 bun test --coverage",
"db:codegen": "bun run --cwd packages/db-schema generate" "db:codegen": "bun run --cwd packages/db-schema generate"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -12,7 +12,7 @@
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"test": "bun test", "test": "bun test src/",
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache", "clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
"lint": "eslint . --cache" "lint": "eslint . --cache"
}, },

View File

@@ -14,7 +14,7 @@
"build": "tsc", "build": "tsc",
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache", "clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
"lint": "eslint . --cache", "lint": "eslint . --cache",
"test": "bun test" "test": "bun test src/"
}, },
"devDependencies": { "devDependencies": {
"@macalinao/eslint-config": "catalog:", "@macalinao/eslint-config": "catalog:",

View File

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

View File

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

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

@@ -64,20 +64,31 @@ export function getTestDatabaseUrl(): string {
} }
/** /**
* Parses a postgres URL to extract components * 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): { function parsePostgresUrl(url: string): {
host: string; host: string;
port: number; port: number | undefined;
user: string; user: string;
password: string; password: string;
database: string; database: string;
} { } {
const parsed = new URL(url); 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 { return {
host: parsed.hostname, host: isUnixSocket
port: Number.parseInt(parsed.port || "5432", 10), ? (socketPath ?? "/var/run/postgresql")
user: parsed.username, : 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, password: parsed.password,
database: parsed.pathname.slice(1), // Remove leading / database: parsed.pathname.slice(1), // Remove leading /
}; };

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

View File

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

View File

@@ -14,7 +14,7 @@
"build": "tsc", "build": "tsc",
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache", "clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
"lint": "eslint . --cache", "lint": "eslint . --cache",
"test": "bun test" "test": "bun test src/"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20250529.0", "@cloudflare/workers-types": "^4.20250529.0",

View File

@@ -1,6 +1,6 @@
ruleDirs: ruleDirs:
- /Users/igm/proj/reviq/publisher-dashboard/.ast-grep/rules/ - .ast-grep/rules/
testConfigs: testConfigs:
- testDir: /Users/igm/proj/reviq/publisher-dashboard/.ast-grep/rule-tests/ - testDir: .ast-grep/rule-tests/
utilDirs: utilDirs:
- /Users/igm/proj/reviq/publisher-dashboard/.ast-grep/utils/ - .ast-grep/utils/

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://turbo.build/schema.json", "$schema": "https://turbo.build/schema.json",
"globalEnv": ["DATABASE_URL", "PORT"], "globalEnv": ["DATABASE_URL", "PORT", "TEST_DATABASE_URL"],
"tasks": { "tasks": {
"build": { "build": {
"dependsOn": ["^build"], "dependsOn": ["^build"],
@@ -33,6 +33,7 @@
"test": { "test": {
"dependsOn": ["^build"], "dependsOn": ["^build"],
"inputs": ["src/**/*.ts", "src/**/*.test.ts"], "inputs": ["src/**/*.ts", "src/**/*.test.ts"],
"env": ["SKIP_DB_TESTS", "TEST_DATABASE_URL"],
"cache": false "cache": false
} }
} }