import path from "node:path"; import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { eq } from "drizzle-orm"; import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { createDatabaseContext, type DatabaseContext, } from "../src/db/database-context.js"; import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js"; import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js"; import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; import { projectChangeSets } from "../src/db/schema/project-change-sets.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projects } from "../src/db/schema/projects.js"; import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js"; import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js"; import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js"; import { ProjectCommandService } from "../src/domain/services/project-command.service.js"; 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(); const board = new DistributionBoardRepository( context.db ).createWithCircuitListAndDefaultSections("project-1", "UV-01"); const section = context.db .select() .from(circuitSections) .where(eq(circuitSections.circuitListId, board.id)) .get(); assert.ok(section); context.db .insert(circuits) .values({ id: "circuit-1", circuitListId: board.id, sectionId: section.id, equipmentIdentifier: "-1F1", displayName: "Bestand", sortOrder: 10, }) .run(); context.db .insert(circuitDeviceRows) .values({ id: "row-1", circuitId: "circuit-1", sortOrder: 10, name: "Leuchte", displayName: "Leuchte", quantity: 1, powerPerUnit: 0.1, simultaneityFactor: 1, }) .run(); return context; } function createService(context: DatabaseContext) { return new ProjectCommandService( new CircuitProjectCommandRepository(context.db), new CircuitDeviceRowProjectCommandRepository(context.db), new ProjectHistoryRepository(context.db) ); } function getCircuitName(context: DatabaseContext) { return context.db .select({ displayName: circuits.displayName }) .from(circuits) .where(eq(circuits.id, "circuit-1")) .get()?.displayName; } function getRowQuantity(context: DatabaseContext) { return context.db .select({ quantity: circuitDeviceRows.quantity }) .from(circuitDeviceRows) .where(eq(circuitDeviceRows.id, "row-1")) .get()?.quantity; } describe("project command service", () => { it("dispatches supported commands and persists undo/redo across service instances", () => { const context = createTestDatabase(); try { const service = createService(context); const circuitResult = service.executeUser({ projectId: "project-1", expectedRevision: 0, command: createCircuitUpdateProjectCommand("circuit-1", { displayName: "Neu", }), }); assert.equal(circuitResult.revision.revisionNumber, 1); assert.equal(circuitResult.history.undoDepth, 1); assert.equal(getCircuitName(context), "Neu"); const rowResult = service.executeUser({ projectId: "project-1", expectedRevision: 1, command: createCircuitDeviceRowUpdateProjectCommand("row-1", { quantity: 2, }), }); assert.equal(rowResult.revision.revisionNumber, 2); assert.equal(rowResult.history.undoDepth, 2); assert.equal(getRowQuantity(context), 2); const reloadedService = createService(context); const firstUndo = reloadedService.undo({ projectId: "project-1", expectedRevision: 2, }); assert.equal(firstUndo.revision.revisionNumber, 3); assert.equal(firstUndo.history.undoDepth, 1); assert.equal(firstUndo.history.redoDepth, 1); assert.equal(getRowQuantity(context), 1); const secondUndo = reloadedService.undo({ projectId: "project-1", expectedRevision: 3, }); assert.equal(secondUndo.revision.revisionNumber, 4); assert.equal(secondUndo.history.undoDepth, 0); assert.equal(secondUndo.history.redoDepth, 2); assert.equal(getCircuitName(context), "Bestand"); const redo = reloadedService.redo({ projectId: "project-1", expectedRevision: 4, }); assert.equal(redo.revision.revisionNumber, 5); assert.equal(redo.history.undoDepth, 1); assert.equal(redo.history.redoDepth, 1); assert.equal(getCircuitName(context), "Neu"); assert.deepEqual( context.db .select({ source: projectRevisions.source }) .from(projectRevisions) .all() .map((row) => row.source), ["user", "user", "undo", "undo", "redo"] ); } finally { context.close(); } }); it("rejects stale undo and preserves the domain value and stack", () => { const context = createTestDatabase(); try { const service = createService(context); const changed = service.executeUser({ projectId: "project-1", expectedRevision: 0, command: createCircuitUpdateProjectCommand("circuit-1", { displayName: "Neu", }), }); assert.throws( () => service.undo({ projectId: "project-1", expectedRevision: 0, }), (error) => error instanceof ProjectRevisionConflictError && error.actualRevision === 1 ); assert.equal(getCircuitName(context), "Neu"); assert.deepEqual( new ProjectHistoryRepository(context.db).getState("project-1"), changed.history ); assert.equal(context.db.select().from(projectRevisions).all().length, 1); assert.equal(context.db.select().from(projectChangeSets).all().length, 1); } finally { context.close(); } }); it("rejects unavailable history directions without writing a revision", () => { const context = createTestDatabase(); try { const service = createService(context); assert.throws( () => service.undo({ projectId: "project-1", expectedRevision: 0, }), (error) => error instanceof ProjectHistoryOperationUnavailableError && error.direction === "undo" ); assert.throws( () => service.redo({ projectId: "project-1", expectedRevision: 0, }), (error) => error instanceof ProjectHistoryOperationUnavailableError && error.direction === "redo" ); assert.equal(context.db.select().from(projectRevisions).all().length, 0); } finally { context.close(); } }); it("rejects unsupported and malformed commands before domain writes", () => { const context = createTestDatabase(); try { const service = createService(context); assert.throws( () => service.executeUser({ projectId: "project-1", expectedRevision: 0, command: { schemaVersion: 1, type: "unknown.command", payload: {}, }, }), /Unsupported project command type/ ); assert.throws( () => service.executeUser({ projectId: "project-1", expectedRevision: 0, command: { schemaVersion: 1, type: "circuit.update", payload: { circuitId: "circuit-1", changes: [] }, }, }), /at least one change/ ); assert.equal(getCircuitName(context), "Bestand"); assert.equal(context.db.select().from(projectRevisions).all().length, 0); } finally { context.close(); } }); });