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",
"lint": "eslint . --cache",
"clean": "rm -rf dist .eslintcache",
"test:e2e": "bun test src/__tests__/e2e --no-parallel --coverage",
"test:unit": "bun test src/__tests__/unit",
"test": "bun test --coverage src/utils"
"test": "bun test src/ --no-parallel"
},
"dependencies": {
"@formatjs/intl-durationformat": "^0.9.2",
@@ -34,12 +32,11 @@
"devDependencies": {
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@reviq/test-helpers": "workspace:*",
"@reviq/virtual-authenticator": "workspace:*",
"@types/bun": "catalog:",
"@types/pg": "^8.16.0",
"@types/zxcvbn": "^4.4.5",
"eslint": "catalog:",
"pg": "^8.16.3",
"pino-pretty": "^13.1.3",
"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
const passwordHash = await hashPassword(password);
// Create user
const user = await db
.insertInto("users")
.values({
email,
password_hash: passwordHash,
})
.returning(["id"])
.executeTakeFirstOrThrow();
// Create user (handle race condition if concurrent signup with same email)
try {
const user = await db
.insertInto("users")
.values({
email,
password_hash: passwordHash,
})
.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
const result = await db.transaction().execute(async (trx) => {
// Create user
const user = await trx
.insertInto("users")
.values({
email,
password_hash: null,
})
.returning(["id"])
.executeTakeFirstOrThrow();
// Create user and passkey in a transaction (handle race condition if concurrent signup)
try {
const result = await db.transaction().execute(async (trx) => {
// Create user
const user = await trx
.insertInto("users")
.values({
email,
password_hash: null,
})
.returning(["id"])
.executeTakeFirstOrThrow();
const newUserId = user.id;
const newUserId = user.id;
// Get friendly name from AAGUID
const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid];
const passkeyName = guidName ?? "Default";
// Get friendly name from AAGUID
const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid];
const passkeyName = guidName ?? "Default";
// Store the passkey
const { credential, credentialDeviceType, credentialBackedUp } =
registrationInfo;
// Store the passkey
const { credential, credentialDeviceType, credentialBackedUp } =
registrationInfo;
await trx
.insertInto("passkeys")
.values({
user_id: newUserId,
credential_id: Buffer.from(credential.id, "base64url"),
public_key: Buffer.from(credential.publicKey),
webauthn_user_id: options.user.id,
counter: BigInt(credential.counter),
device_type: credentialDeviceType as "singleDevice" | "multiDevice",
backup_eligible: registrationInfo.credentialBackedUp,
backup_status: credentialBackedUp,
transports: JSON.stringify(response.response.transports ?? []),
rpid: rpInfo.rpID,
name: passkeyName,
})
.execute();
await trx
.insertInto("passkeys")
.values({
user_id: newUserId,
credential_id: Buffer.from(credential.id, "base64url"),
public_key: Buffer.from(credential.publicKey),
webauthn_user_id: options.user.id,
counter: BigInt(credential.counter),
device_type: credentialDeviceType as "singleDevice" | "multiDevice",
backup_eligible: registrationInfo.credentialBackedUp,
backup_status: credentialBackedUp,
transports: JSON.stringify(response.response.transports ?? []),
rpid: rpInfo.rpID,
name: passkeyName,
})
.execute();
// Delete the challenge
await trx
.deleteFrom("webauthn_challenges")
.where("id", "=", String(challengeId))
.execute();
// Delete the challenge
await trx
.deleteFrom("webauthn_challenges")
.where("id", "=", String(challengeId))
.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);
} else {
// Should never reach here due to schema validation
// Unreachable - schema validation requires password or passkeyInfo
throw new ORPCError("BAD_REQUEST", {
message: "Either password or passkeyInfo is required",
});

View File

@@ -115,10 +115,11 @@ export async function countOwners(
): Promise<number> {
const result = await db
.selectFrom("org_members")
.select((eb) => eb.fn.countAll<number>().as("count"))
.select((eb) => eb.fn.countAll().as("count"))
.where("org_id", "=", orgId)
.where("role", "=", "owner")
.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",
"lint": "eslint . --cache",
"clean": "rm -rf dist .eslintcache",
"test": "bun test"
"test": "bun test src/"
},
"dependencies": {
"@noble/hashes": "^2.0.1",

View File

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

View File

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

View File

@@ -14,7 +14,7 @@
"build": "tsc",
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
"lint": "eslint . --cache",
"test": "bun test"
"test": "bun test src/"
},
"devDependencies": {
"@macalinao/eslint-config": "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"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,14 +1,7 @@
{
"extends": "@macalinao/tsconfig/tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"types": ["node", "bun"]
},
"include": ["src/**/*"],
"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): {
host: string;
port: number;
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: parsed.hostname,
port: Number.parseInt(parsed.port || "5432", 10),
user: parsed.username,
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 /
};

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"]
}

View File

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

View File

@@ -1,6 +1,6 @@
ruleDirs:
- /Users/igm/proj/reviq/publisher-dashboard/.ast-grep/rules/
- .ast-grep/rules/
testConfigs:
- testDir: /Users/igm/proj/reviq/publisher-dashboard/.ast-grep/rule-tests/
- testDir: .ast-grep/rule-tests/
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",
"globalEnv": ["DATABASE_URL", "PORT"],
"globalEnv": ["DATABASE_URL", "PORT", "TEST_DATABASE_URL"],
"tasks": {
"build": {
"dependsOn": ["^build"],
@@ -33,6 +33,7 @@
"test": {
"dependsOn": ["^build"],
"inputs": ["src/**/*.ts", "src/**/*.test.ts"],
"env": ["SKIP_DB_TESTS", "TEST_DATABASE_URL"],
"cache": false
}
}