From d7fda3757679b13b9f12c1f14d7fad8d7dcd01a9 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Thu, 23 Jul 2026 18:22:32 +0200 Subject: [PATCH] Add real SQLite transaction tests --- docs/spec/07-implementation-phases-todo.md | 7 + src/db/client.ts | 14 +- src/db/database-context.ts | 33 +++++ .../distribution-board.repository.ts | 15 ++- src/server/controllers/consumer.controller.ts | 3 +- .../distribution-board.controller.ts | 3 +- tests/distribution-board.repository.test.ts | 123 +++++++++++------- 7 files changed, 134 insertions(+), 64 deletions(-) create mode 100644 src/db/database-context.ts diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 014e7eb..75e465e 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -322,6 +322,13 @@ Acceptance criteria: - domain command behavior is not coupled to `better-sqlite3` - adding a PostgreSQL persistence adapter does not require rewriting editor domain rules +Implemented foundation: + +- database contexts can be created independently from the production singleton +- SQLite foreign-key enforcement is enabled explicitly for every context +- the distribution-board repository receives its database dependency explicitly +- distribution-board setup has real in-memory SQLite commit and rollback coverage using production migrations + ## Phase 12: Project Revisions and Persistent Undo / Redo Goal: diff --git a/src/db/client.ts b/src/db/client.ts index 456face..02cfc7e 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -1,13 +1,9 @@ -import fs from "node:fs"; import path from "node:path"; -import Database from "better-sqlite3"; -import { drizzle } from "drizzle-orm/better-sqlite3"; +import { createDatabaseContext } from "./database-context.js"; -const dataDir = path.resolve("data"); -if (!fs.existsSync(dataDir)) { - fs.mkdirSync(dataDir, { recursive: true }); -} +const defaultDatabaseContext = createDatabaseContext( + path.resolve("data", "leistungsbilanz.db") +); -const sqlite = new Database(path.resolve(dataDir, "leistungsbilanz.db")); -export const db = drizzle(sqlite); +export const db = defaultDatabaseContext.db; diff --git a/src/db/database-context.ts b/src/db/database-context.ts new file mode 100644 index 0000000..048b86e --- /dev/null +++ b/src/db/database-context.ts @@ -0,0 +1,33 @@ +import fs from "node:fs"; +import path from "node:path"; +import Database from "better-sqlite3"; +import { + drizzle, + type BetterSQLite3Database, +} from "drizzle-orm/better-sqlite3"; + +export interface DatabaseContext { + db: BetterSQLite3Database; + sqlite: Database.Database; + close: () => void; +} + +export type AppDatabase = DatabaseContext["db"]; + +export function createDatabaseContext(filename: string): DatabaseContext { + if (filename !== ":memory:") { + const parentDirectory = path.dirname(path.resolve(filename)); + if (!fs.existsSync(parentDirectory)) { + fs.mkdirSync(parentDirectory, { recursive: true }); + } + } + + const sqlite = new Database(filename); + sqlite.pragma("foreign_keys = ON"); + const database = drizzle(sqlite); + return { + db: database, + sqlite, + close: () => sqlite.close(), + }; +} diff --git a/src/db/repositories/distribution-board.repository.ts b/src/db/repositories/distribution-board.repository.ts index 9a04a44..cb9a690 100644 --- a/src/db/repositories/distribution-board.repository.ts +++ b/src/db/repositories/distribution-board.repository.ts @@ -1,20 +1,25 @@ import crypto from "node:crypto"; import { and, eq } from "drizzle-orm"; -import { db } from "../client.js"; +import type { AppDatabase } from "../database-context.js"; import { circuitLists } from "../schema/circuit-lists.js"; import { circuitSections } from "../schema/circuit-sections.js"; import { distributionBoards } from "../schema/distribution-boards.js"; import { createDefaultCircuitSectionValues } from "./circuit-section.repository.js"; export class DistributionBoardRepository { + constructor(private readonly database: AppDatabase) {} + async listByProject(projectId: string) { - return db.select().from(distributionBoards).where(eq(distributionBoards.projectId, projectId)); + return this.database + .select() + .from(distributionBoards) + .where(eq(distributionBoards.projectId, projectId)); } async create(projectId: string, name: string) { const id = crypto.randomUUID(); const board = { id, projectId, name }; - await db.insert(distributionBoards).values(board); + await this.database.insert(distributionBoards).values(board); return board; } @@ -29,7 +34,7 @@ export class DistributionBoardRepository { }; const sections = createDefaultCircuitSectionValues(id); - db.transaction((tx) => { + this.database.transaction((tx) => { tx.insert(distributionBoards).values(board).run(); tx.insert(circuitLists).values(circuitList).run(); tx.insert(circuitSections).values(sections).run(); @@ -39,7 +44,7 @@ export class DistributionBoardRepository { } async existsInProject(projectId: string, distributionBoardId: string) { - const [row] = await db + const [row] = await this.database .select({ id: distributionBoards.id }) .from(distributionBoards) .where( diff --git a/src/server/controllers/consumer.controller.ts b/src/server/controllers/consumer.controller.ts index cd92262..dddb584 100644 --- a/src/server/controllers/consumer.controller.ts +++ b/src/server/controllers/consumer.controller.ts @@ -1,4 +1,5 @@ import type { Request, Response } from "express"; +import { db } from "../../db/client.js"; import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js"; import { ConsumerRepository } from "../../db/repositories/consumer.repository.js"; import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js"; @@ -17,7 +18,7 @@ import { const circuitListRepository = new CircuitListRepository(); const consumerRepository = new ConsumerRepository(); -const distributionBoardRepository = new DistributionBoardRepository(); +const distributionBoardRepository = new DistributionBoardRepository(db); const floorRepository = new FloorRepository(); const projectDeviceRepository = new ProjectDeviceRepository(); const projectRepository = new ProjectRepository(); diff --git a/src/server/controllers/distribution-board.controller.ts b/src/server/controllers/distribution-board.controller.ts index 2d39919..f2db10b 100644 --- a/src/server/controllers/distribution-board.controller.ts +++ b/src/server/controllers/distribution-board.controller.ts @@ -1,8 +1,9 @@ import type { Request, Response } from "express"; +import { db } from "../../db/client.js"; import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js"; import { createDistributionBoardSchema } from "../../shared/validation/consumer.schemas.js"; -const distributionBoardRepository = new DistributionBoardRepository(); +const distributionBoardRepository = new DistributionBoardRepository(db); export async function listDistributionBoardsByProject(req: Request, res: Response) { const { projectId } = req.params; diff --git a/tests/distribution-board.repository.test.ts b/tests/distribution-board.repository.test.ts index 0ab4f23..0de56df 100644 --- a/tests/distribution-board.repository.test.ts +++ b/tests/distribution-board.repository.test.ts @@ -1,62 +1,60 @@ +import path from "node:path"; import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { db } from "../src/db/client.js"; +import { eq } from "drizzle-orm"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { + createDatabaseContext, + type DatabaseContext, +} from "../src/db/database-context.js"; import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; import { circuitLists } from "../src/db/schema/circuit-lists.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { distributionBoards } from "../src/db/schema/distribution-boards.js"; +import { projects } from "../src/db/schema/projects.js"; -describe("distribution board repository", () => { - it("creates board, circuit list and default sections in one synchronous transaction", () => { - const originalTransaction = db.transaction; - const insertedValues: Array<{ table: unknown; values: unknown }> = []; - let transactionCalls = 0; - - (db as unknown as { transaction: (callback: (tx: unknown) => unknown) => unknown }).transaction = - (callback) => { - transactionCalls += 1; - return callback({ - insert(table: unknown) { - return { - values(values: unknown) { - insertedValues.push({ table, values }); - return { - run() { - return { changes: Array.isArray(values) ? values.length : 1 }; - }, - }; - }, - }; - }, - }); - }; +function createTestDatabase(): DatabaseContext { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { + migrationsFolder: path.resolve("src", "db", "migrations"), + }); + context.db.insert(projects).values({ id: "project-1", name: "Test project" }).run(); + return context; +} +describe("distribution board repository integration", () => { + it("commits board, circuit list and default sections together", () => { + const context = createTestDatabase(); try { - const repository = new DistributionBoardRepository(); - const board = repository.createWithCircuitListAndDefaultSections("p1", "UV-01"); + const repository = new DistributionBoardRepository(context.db); + const board = repository.createWithCircuitListAndDefaultSections( + "project-1", + "UV-01" + ); - assert.equal(transactionCalls, 1); - assert.equal(insertedValues.length, 3); - assert.equal(insertedValues[0].table, distributionBoards); - assert.deepEqual(insertedValues[0].values, board); - assert.equal(insertedValues[1].table, circuitLists); - assert.deepEqual(insertedValues[1].values, { - id: board.id, - projectId: "p1", - distributionBoardId: board.id, - name: "UV-01 Stromkreisliste", - }); - assert.equal(insertedValues[2].table, circuitSections); + const persistedBoards = context.db + .select() + .from(distributionBoards) + .where(eq(distributionBoards.id, board.id)) + .all(); + const persistedLists = context.db + .select() + .from(circuitLists) + .where(eq(circuitLists.distributionBoardId, board.id)) + .all(); + const persistedSections = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.circuitListId, board.id)) + .all(); - const sections = insertedValues[2].values as Array<{ - circuitListId: string; - key: string; - prefix: string; - }>; - assert.equal(sections.length, 4); - assert.ok(sections.every((section) => section.circuitListId === board.id)); + assert.deepEqual(persistedBoards, [board]); + assert.equal(persistedLists.length, 1); + assert.equal(persistedLists[0].name, "UV-01 Stromkreisliste"); assert.deepEqual( - sections.map((section) => [section.key, section.prefix]), + persistedSections + .sort((left, right) => left.sortOrder - right.sortOrder) + .map((section) => [section.key, section.prefix]), [ ["lighting", "-1F"], ["single_phase", "-2F"], @@ -65,7 +63,36 @@ describe("distribution board repository", () => { ] ); } finally { - (db as unknown as { transaction: unknown }).transaction = originalTransaction; + context.close(); + } + }); + + it("rolls back board and circuit list when default section creation fails", () => { + const context = createTestDatabase(); + try { + context.sqlite.exec(` + CREATE TRIGGER fail_default_section_insert + BEFORE INSERT ON circuit_sections + BEGIN + SELECT RAISE(ABORT, 'forced section failure'); + END; + `); + const repository = new DistributionBoardRepository(context.db); + + assert.throws( + () => + repository.createWithCircuitListAndDefaultSections( + "project-1", + "UV rollback" + ), + /forced section failure/ + ); + + assert.equal(context.db.select().from(distributionBoards).all().length, 0); + assert.equal(context.db.select().from(circuitLists).all().length, 0); + assert.equal(context.db.select().from(circuitSections).all().length, 0); + } finally { + context.close(); } }); });