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 { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-structure-project-command.repository.js"; import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; import { circuitLists } from "../src/db/schema/circuit-lists.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projects } from "../src/db/schema/projects.js"; import { createCircuitGroupDeleteProjectCommand, createCircuitGroupInsertProjectCommand, createCircuitGroupReorderProjectCommand, createCircuitGroupUpdateProjectCommand, type CircuitGroupSnapshot, } from "../src/domain/models/circuit-group-structure-project-command.model.js"; import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.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: "Projekt" }, { id: "project-2", name: "Fremdprojekt" }, ]) .run(); new DistributionBoardFixtureRepository( context.db ).createWithCircuitListAndDefaultSections("project-1", "UV-01"); new DistributionBoardFixtureRepository( context.db ).createWithCircuitListAndDefaultSections("project-2", "UV-02"); return context; } function getProjectList(context: DatabaseContext, projectId: string) { const list = context.db .select() .from(circuitLists) .where(eq(circuitLists.projectId, projectId)) .get(); assert.ok(list); return list; } function groupSnapshot(circuitListId: string): CircuitGroupSnapshot { return { id: "group-lighting-2", circuitListId, key: "lighting_2", displayName: "Beleuchtung 2", prefix: "-1F2.", sortOrder: 40, category: "lighting", groupNumber: 2, }; } describe("circuit-group structure project command", () => { it("inserts, renames and restores a group through persistent history", () => { const context = createTestDatabase(); try { const expected = groupSnapshot( getProjectList(context, "project-1").id ); const target = { ...expected, displayName: "Beleuchtung Besprechungsräume", }; const repository = new CircuitGroupStructureProjectCommandRepository(context.db); repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitGroupInsertProjectCommand(expected), }); const updated = repository.execute({ projectId: "project-1", expectedRevision: 1, source: "user", command: createCircuitGroupUpdateProjectCommand( expected, target ), }); assert.equal( context.db .select() .from(circuitSections) .where(eq(circuitSections.id, expected.id)) .get()?.displayName, target.displayName ); repository.execute({ projectId: "project-1", expectedRevision: 2, source: "undo", historyTargetChangeSetId: new ProjectHistoryRepository( context.db ).getNextCommand("project-1", "undo")?.changeSetId, command: updated.inverse, }); assert.deepEqual( context.db .select() .from(circuitSections) .where(eq(circuitSections.id, expected.id)) .get(), expected ); } finally { context.close(); } }); it("deletes only an exact empty group and restores the same UUID", () => { const context = createTestDatabase(); try { const snapshot = groupSnapshot( getProjectList(context, "project-1").id ); const repository = new CircuitGroupStructureProjectCommandRepository(context.db); repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitGroupInsertProjectCommand(snapshot), }); const deleted = repository.execute({ projectId: "project-1", expectedRevision: 1, source: "user", command: createCircuitGroupDeleteProjectCommand(snapshot), }); assert.equal( context.db .select() .from(circuitSections) .where(eq(circuitSections.id, snapshot.id)) .get(), undefined ); repository.execute({ projectId: "project-1", expectedRevision: 2, source: "undo", historyTargetChangeSetId: new ProjectHistoryRepository( context.db ).getNextCommand("project-1", "undo")?.changeSetId, command: deleted.inverse, }); assert.deepEqual( context.db .select() .from(circuitSections) .where(eq(circuitSections.id, snapshot.id)) .get(), snapshot ); } finally { context.close(); } }); it("rejects foreign lists, invalid prefixes, stale updates and populated deletes", () => { const context = createTestDatabase(); try { const snapshot = groupSnapshot( getProjectList(context, "project-1").id ); const repository = new CircuitGroupStructureProjectCommandRepository(context.db); assert.throws( () => createCircuitGroupInsertProjectCommand({ ...snapshot, prefix: "-1F9.", }), /prefix does not match/ ); assert.throws( () => repository.execute({ projectId: "project-2", expectedRevision: 0, source: "user", command: createCircuitGroupInsertProjectCommand(snapshot), }), /does not belong/ ); repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitGroupInsertProjectCommand(snapshot), }); context.db .insert(circuits) .values({ id: "group-circuit", circuitListId: snapshot.circuitListId, sectionId: snapshot.id, equipmentIdentifier: "-1F2.1", displayName: "Beleuchtung", sortOrder: 10, }) .run(); assert.throws( () => repository.execute({ projectId: "project-1", expectedRevision: 1, source: "user", command: createCircuitGroupDeleteProjectCommand(snapshot), }), /Only empty circuit groups/ ); context.db .update(circuitSections) .set({ displayName: "Direkt geändert" }) .where(eq(circuitSections.id, snapshot.id)) .run(); assert.throws( () => repository.execute({ projectId: "project-1", expectedRevision: 1, source: "user", command: createCircuitGroupUpdateProjectCommand(snapshot, { ...snapshot, displayName: "Ziel", }), }), /changed before update/ ); assert.equal( context.db.select().from(projectRevisions).all().length, 1 ); } finally { context.close(); } }); it("rolls back group insertion when history persistence fails", () => { const context = createTestDatabase(); try { const snapshot = groupSnapshot( getProjectList(context, "project-1").id ); context.sqlite.exec(` CREATE TRIGGER fail_group_history BEFORE INSERT ON project_history_stack_entries BEGIN SELECT RAISE(ABORT, 'forced group history failure'); END; `); assert.throws( () => new CircuitGroupStructureProjectCommandRepository( context.db ).execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitGroupInsertProjectCommand(snapshot), }), /forced group history failure/ ); assert.equal( context.db .select() .from(circuitSections) .where(eq(circuitSections.id, snapshot.id)) .get(), undefined ); } finally { context.close(); } }); it("reorders every group atomically without changing group identity", () => { const context = createTestDatabase(); try { const circuitListId = getProjectList(context, "project-1").id; const groups = context.db .select() .from(circuitSections) .where(eq(circuitSections.circuitListId, circuitListId)) .all(); const expectedPrefixes = new Map( groups.map((group) => [group.id, group.prefix]) ); const assignments = groups.map((group, index) => ({ groupId: group.id, expectedSortOrder: group.sortOrder, targetSortOrder: (groups.length - index) * 10, })); const repository = new CircuitGroupStructureProjectCommandRepository(context.db); const reordered = repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitGroupReorderProjectCommand( circuitListId, assignments ), }); for (const assignment of assignments) { const group = context.db .select() .from(circuitSections) .where(eq(circuitSections.id, assignment.groupId)) .get(); assert.equal(group?.sortOrder, assignment.targetSortOrder); assert.equal( group?.prefix, expectedPrefixes.get(assignment.groupId) ); } repository.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: new ProjectHistoryRepository( context.db ).getNextCommand("project-1", "undo")?.changeSetId, command: reordered.inverse, }); for (const assignment of assignments) { assert.equal( context.db .select() .from(circuitSections) .where(eq(circuitSections.id, assignment.groupId)) .get()?.sortOrder, assignment.expectedSortOrder ); } } finally { context.close(); } }); it("rejects incomplete reorders and rolls back late failures", () => { const context = createTestDatabase(); try { const circuitListId = getProjectList(context, "project-1").id; const groups = context.db .select() .from(circuitSections) .where(eq(circuitSections.circuitListId, circuitListId)) .all(); const assignments = groups.map((group, index) => ({ groupId: group.id, expectedSortOrder: group.sortOrder, targetSortOrder: (groups.length - index) * 10, })); const repository = new CircuitGroupStructureProjectCommandRepository(context.db); assert.throws( () => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitGroupReorderProjectCommand( circuitListId, assignments.slice(1) ), }), /include every unchanged group/ ); context.sqlite.exec(` CREATE TRIGGER fail_group_reorder_history BEFORE INSERT ON project_history_stack_entries BEGIN SELECT RAISE(ABORT, 'forced group reorder history failure'); END; `); assert.throws( () => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitGroupReorderProjectCommand( circuitListId, assignments ), }), /forced group reorder history failure/ ); for (const assignment of assignments) { assert.equal( context.db .select() .from(circuitSections) .where(eq(circuitSections.id, assignment.groupId)) .get()?.sortOrder, assignment.expectedSortOrder ); } assert.equal( context.db.select().from(projectRevisions).all().length, 0 ); } finally { context.close(); } }); });