From c2748bfcc6108078123526760d373c43439f2697 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 25 Jan 2026 18:18:15 -0600 Subject: [PATCH] Add test infrastructure for hydrators using node:test - Add docker-compose.test.yml with isolated PostgreSQL on port 5433 - Add environment variable support for database connection config - Add test setup utilities and initial user hydrator tests - Add test and test:watch scripts to package.json Co-Authored-By: Claude Opus 4.5 --- docker-compose.test.yml | 13 +++ express/database.ts | 12 +-- express/framework/hydrators/hydrator.ts | 29 ++++++ express/framework/hydrators/tests/setup.ts | 44 +++++++++ .../framework/hydrators/tests/user.test.ts | 98 +++++++++++++++++++ express/package.json | 3 +- 6 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 docker-compose.test.yml create mode 100644 express/framework/hydrators/hydrator.ts create mode 100644 express/framework/hydrators/tests/setup.ts create mode 100644 express/framework/hydrators/tests/user.test.ts diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..5be9aaa --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,13 @@ +name: diachron-test + +services: + postgres: + image: postgres:17 + ports: + - "5433:5432" + environment: + POSTGRES_USER: diachron_test + POSTGRES_PASSWORD: diachron_test + POSTGRES_DB: diachron_test + tmpfs: + - /var/lib/postgresql/data diff --git a/express/database.ts b/express/database.ts index afb8bf5..c8bf361 100644 --- a/express/database.ts +++ b/express/database.ts @@ -21,13 +21,13 @@ import type { SessionData, TokenId } from "./auth/types"; import type { Domain } from "./types"; import { AuthenticatedUser, type User, type UserId } from "./user"; -// Connection configuration +// Connection configuration (supports environment variable overrides) const connectionConfig = { - host: "localhost", - port: 5432, - user: "diachron", - password: "diachron", - database: "diachron", + host: process.env.DB_HOST ?? "localhost", + port: Number(process.env.DB_PORT ?? 5432), + user: process.env.DB_USER ?? "diachron", + password: process.env.DB_PASSWORD ?? "diachron", + database: process.env.DB_NAME ?? "diachron", }; // Database schema types for Kysely diff --git a/express/framework/hydrators/hydrator.ts b/express/framework/hydrators/hydrator.ts new file mode 100644 index 0000000..5445365 --- /dev/null +++ b/express/framework/hydrators/hydrator.ts @@ -0,0 +1,29 @@ +import { Kysely, PostgresDialect } from "kysely"; +import { Pool } from "pg"; +import { connectionConfig } from "../../database"; +import type { DB } from "../../generated/db"; + +const db = new Kysely({ + dialect: new PostgresDialect({ + pool: new Pool(connectionConfig), + }), + log(event) { + if (event.level === "query") { + // FIXME: Wire this up to the logging system + console.log("SQL:", event.query.sql); + console.log("Params:", event.query.parameters); + } + }, +}); + +abstract class Hydrator { + public db: Kysely; + + protected abstract table: string; + + constructor() { + this.db = db; + } +} + +export { Hydrator, db }; diff --git a/express/framework/hydrators/tests/setup.ts b/express/framework/hydrators/tests/setup.ts new file mode 100644 index 0000000..1140f0e --- /dev/null +++ b/express/framework/hydrators/tests/setup.ts @@ -0,0 +1,44 @@ +// Test setup for hydrator tests +// Run: DB_PORT=5433 DB_USER=diachron_test DB_PASSWORD=diachron_test DB_NAME=diachron_test npx tsx --test tests/*.test.ts + +import { Pool } from "pg"; +import { connectionConfig, migrate } from "../../../database"; + +const pool = new Pool(connectionConfig); + +export async function setupTestDatabase(): Promise { + await migrate(); +} + +export async function cleanupTables(): Promise { + // Clean in reverse dependency order + await pool.query("DELETE FROM user_emails"); + await pool.query("DELETE FROM users"); +} + +export async function teardownTestDatabase(): Promise { + await pool.end(); +} + +export async function insertTestUser(data: { + id: string; + displayName: string; + status: string; + email: string; +}): Promise { + const emailId = crypto.randomUUID(); + const normalizedEmail = data.email.toLowerCase().trim(); + + await pool.query( + `INSERT INTO users (id, display_name, status) VALUES ($1, $2, $3)`, + [data.id, data.displayName, data.status], + ); + + await pool.query( + `INSERT INTO user_emails (id, user_id, email, normalized_email, is_primary) + VALUES ($1, $2, $3, $4, true)`, + [emailId, data.id, data.email, normalizedEmail], + ); +} + +export { pool }; diff --git a/express/framework/hydrators/tests/user.test.ts b/express/framework/hydrators/tests/user.test.ts new file mode 100644 index 0000000..5252eea --- /dev/null +++ b/express/framework/hydrators/tests/user.test.ts @@ -0,0 +1,98 @@ +// Tests for user hydrator +// Run with: cd express && DB_PORT=5433 DB_USER=diachron_test DB_PASSWORD=diachron_test DB_NAME=diachron_test ../cmd npx tsx --test framework/hydrators/tests/user.test.ts + +import { describe, it, before, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { + setupTestDatabase, + cleanupTables, + teardownTestDatabase, + insertTestUser, +} from "./setup"; +import { get } from "../user"; + +describe("user hydrator", () => { + before(async () => { + await setupTestDatabase(); + }); + + after(async () => { + await teardownTestDatabase(); + }); + + beforeEach(async () => { + await cleanupTables(); + }); + + describe("get", () => { + it("returns null for non-existent user", async () => { + const result = await get("00000000-0000-0000-0000-000000000000"); + assert.equal(result, null); + }); + + it("returns user when found", async () => { + const userId = "cfae0a19-6515-4813-bc2d-1e032b72b203"; + await insertTestUser({ + id: userId, + displayName: "Test User", + status: "active", + email: "test@example.com", + }); + + const result = await get(userId); + + assert.notEqual(result, null); + assert.equal(result!.id, userId); + assert.equal(result!.display_name, "Test User"); + assert.equal(result!.status, "active"); + assert.equal(result!.email, "test@example.com"); + }); + + it("validates user data with zod parser", async () => { + const userId = crypto.randomUUID(); + await insertTestUser({ + id: userId, + displayName: "Valid User", + status: "active", + email: "valid@example.com", + }); + + const result = await get(userId); + + // If we get here without throwing, parsing succeeded + assert.notEqual(result, null); + assert.equal(typeof result!.id, "string"); + assert.equal(typeof result!.email, "string"); + }); + + it("returns user with pending status", async () => { + const userId = crypto.randomUUID(); + await insertTestUser({ + id: userId, + displayName: "Pending User", + status: "pending", + email: "pending@example.com", + }); + + const result = await get(userId); + + assert.notEqual(result, null); + assert.equal(result!.status, "pending"); + }); + + it("returns user with suspended status", async () => { + const userId = crypto.randomUUID(); + await insertTestUser({ + id: userId, + displayName: "Suspended User", + status: "suspended", + email: "suspended@example.com", + }); + + const result = await get(userId); + + assert.notEqual(result, null); + assert.equal(result!.status, "suspended"); + }); + }); +}); diff --git a/express/package.json b/express/package.json index 665a771..f6317fa 100644 --- a/express/package.json +++ b/express/package.json @@ -4,7 +4,8 @@ "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "DB_PORT=5433 DB_USER=diachron_test DB_PASSWORD=diachron_test DB_NAME=diachron_test tsx --test 'framework/**/*.{test,spec}.ts'", + "test:watch": "DB_PORT=5433 DB_USER=diachron_test DB_PASSWORD=diachron_test DB_NAME=diachron_test tsx --test --watch 'framework/**/*.{test,spec}.ts'", "nodemon": "nodemon dist/index.js", "kysely-codegen": "kysely-codegen" },