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 <noreply@anthropic.com>
This commit is contained in:
13
docker-compose.test.yml
Normal file
13
docker-compose.test.yml
Normal file
@@ -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
|
||||||
@@ -21,13 +21,13 @@ import type { SessionData, TokenId } from "./auth/types";
|
|||||||
import type { Domain } from "./types";
|
import type { Domain } from "./types";
|
||||||
import { AuthenticatedUser, type User, type UserId } from "./user";
|
import { AuthenticatedUser, type User, type UserId } from "./user";
|
||||||
|
|
||||||
// Connection configuration
|
// Connection configuration (supports environment variable overrides)
|
||||||
const connectionConfig = {
|
const connectionConfig = {
|
||||||
host: "localhost",
|
host: process.env.DB_HOST ?? "localhost",
|
||||||
port: 5432,
|
port: Number(process.env.DB_PORT ?? 5432),
|
||||||
user: "diachron",
|
user: process.env.DB_USER ?? "diachron",
|
||||||
password: "diachron",
|
password: process.env.DB_PASSWORD ?? "diachron",
|
||||||
database: "diachron",
|
database: process.env.DB_NAME ?? "diachron",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Database schema types for Kysely
|
// Database schema types for Kysely
|
||||||
|
|||||||
29
express/framework/hydrators/hydrator.ts
Normal file
29
express/framework/hydrators/hydrator.ts
Normal file
@@ -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<DB>({
|
||||||
|
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<T> {
|
||||||
|
public db: Kysely<DB>;
|
||||||
|
|
||||||
|
protected abstract table: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Hydrator, db };
|
||||||
44
express/framework/hydrators/tests/setup.ts
Normal file
44
express/framework/hydrators/tests/setup.ts
Normal file
@@ -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<void> {
|
||||||
|
await migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupTables(): Promise<void> {
|
||||||
|
// Clean in reverse dependency order
|
||||||
|
await pool.query("DELETE FROM user_emails");
|
||||||
|
await pool.query("DELETE FROM users");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function teardownTestDatabase(): Promise<void> {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertTestUser(data: {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
status: string;
|
||||||
|
email: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
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 };
|
||||||
98
express/framework/hydrators/tests/user.test.ts
Normal file
98
express/framework/hydrators/tests/user.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"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",
|
"nodemon": "nodemon dist/index.js",
|
||||||
"kysely-codegen": "kysely-codegen"
|
"kysely-codegen": "kysely-codegen"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user