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 { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js"; import { buildings } from "../src/db/schema/buildings.js"; import { floors } from "../src/db/schema/floors.js"; import { projects } from "../src/db/schema/projects.js"; import { assertProjectFloorInsertProjectCommand, createProjectBuildingDeleteProjectCommand, createProjectBuildingInsertProjectCommand, createProjectBuildingSnapshot, createProjectBuildingUpdateProjectCommand, createProjectFloorInsertProjectCommand, createProjectFloorSnapshot, createProjectFloorUpdateProjectCommand, projectFloorCommandSchemaVersion, type ProjectLocationStructureProjectCommand, } from "../src/domain/models/project-location-structure-project-command.model.js"; import { createBuildingSchema, createFloorSchema, updateFloorSchema, } from "../src/shared/validation/project-structure.schemas.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" }, { id: "project-2", name: "Foreign project" }, ]) .run(); return context; } /** Executes a user command and returns a runner for its undo and redo. */ function createRunner(context: DatabaseContext) { const repository = new ProjectLocationStructureProjectCommandRepository( context.db ); const history = new ProjectHistoryRepository(context.db); let revision = 0; const run = ( command: ProjectLocationStructureProjectCommand, source: "user" | "undo" | "redo" = "user" ) => { const step = source === "user" ? null : history.getNextCommand("project-1", source); if (source !== "user") { assert.ok(step); } const result = repository.execute({ projectId: "project-1", expectedRevision: revision, source, historyTargetChangeSetId: step?.changeSetId, command, }); revision += 1; return result; }; return { repository, run }; } function getBuilding(context: DatabaseContext, id: string) { return context.db.select().from(buildings).where(eq(buildings.id, id)).get(); } function getFloor(context: DatabaseContext, id: string) { return context.db.select().from(floors).where(eq(floors.id, id)).get(); } describe("project building structure", () => { it("creates, renames and deletes buildings with persisted undo and redo", () => { const context = createTestDatabase(); try { const { run } = createRunner(context); const building = createProjectBuildingSnapshot("project-1", " Haus A ", 0); assert.equal(building.name, "Haus A"); const insert = createProjectBuildingInsertProjectCommand(building); const inserted = run(insert); assert.deepEqual(getBuilding(context, building.id), building); const renamed = { ...building, name: "Haus B" }; const update = createProjectBuildingUpdateProjectCommand(building, renamed); const updated = run(update); assert.equal(getBuilding(context, building.id)?.name, "Haus B"); run(updated.inverse, "undo"); assert.deepEqual(getBuilding(context, building.id), building); run(inserted.inverse, "undo"); assert.equal(getBuilding(context, building.id), undefined); run(insert, "redo"); run(update, "redo"); assert.deepEqual(getBuilding(context, building.id), renamed); const deleted = run(createProjectBuildingDeleteProjectCommand(renamed)); assert.equal(getBuilding(context, building.id), undefined); run(deleted.inverse, "undo"); assert.deepEqual(getBuilding(context, building.id), renamed); } finally { context.close(); } }); it("assigns floors to buildings and blocks deleting a building in use", () => { const context = createTestDatabase(); try { const { run } = createRunner(context); const houseA = createProjectBuildingSnapshot("project-1", "Haus A", 0); const houseB = createProjectBuildingSnapshot("project-1", "Haus B", 1); run(createProjectBuildingInsertProjectCommand(houseA)); run(createProjectBuildingInsertProjectCommand(houseB)); const floor = createProjectFloorSnapshot("project-1", "EG", 0, houseA.id); const floorInsert = createProjectFloorInsertProjectCommand(floor); assert.equal(floorInsert.schemaVersion, projectFloorCommandSchemaVersion); run(floorInsert); assert.deepEqual(getFloor(context, floor.id), floor); const moved = { ...floor, buildingId: houseB.id }; const moveCommand = createProjectFloorUpdateProjectCommand(floor, moved); const movedResult = run(moveCommand); assert.equal(getFloor(context, floor.id)?.buildingId, houseB.id); assert.throws( () => run(createProjectBuildingDeleteProjectCommand(houseB)), /solange Etagen zugeordnet/ ); assert.ok(getBuilding(context, houseB.id)); run(movedResult.inverse, "undo"); assert.equal(getFloor(context, floor.id)?.buildingId, houseA.id); run(moveCommand, "redo"); assert.equal(getFloor(context, floor.id)?.buildingId, houseB.id); const unassigned = { ...moved, buildingId: null }; run(createProjectFloorUpdateProjectCommand(moved, unassigned)); assert.equal(getFloor(context, floor.id)?.buildingId, null); run(createProjectBuildingDeleteProjectCommand(houseB)); assert.equal(getBuilding(context, houseB.id), undefined); } finally { context.close(); } }); it("rejects floors that reference a missing or foreign building", () => { const context = createTestDatabase(); try { const { run } = createRunner(context); context.db .insert(buildings) .values({ id: "foreign", projectId: "project-2", name: "Fremd", sortOrder: 0 }) .run(); assert.throws( () => run( createProjectFloorInsertProjectCommand( createProjectFloorSnapshot("project-1", "EG", 0, "foreign") ) ), /foreign building/ ); assert.throws( () => run( createProjectFloorInsertProjectCommand( createProjectFloorSnapshot("project-1", "EG", 0, "missing") ) ), /foreign building/ ); assert.equal(context.db.select().from(floors).all().length, 0); const floor = createProjectFloorSnapshot("project-1", "EG", 0); run(createProjectFloorInsertProjectCommand(floor)); assert.throws( () => run( createProjectFloorUpdateProjectCommand(floor, { ...floor, buildingId: "foreign", }) ), /foreign building/ ); assert.throws( () => createProjectFloorUpdateProjectCommand(floor, { ...floor }), /only the name or the building/ ); assert.throws( () => createProjectFloorUpdateProjectCommand(floor, { ...floor, sortOrder: 5, name: "Erdgeschoss", }), /only the name or the building/ ); } finally { context.close(); } }); it("executes version 1 floor commands recorded before buildings existed", () => { const context = createTestDatabase(); try { const { run } = createRunner(context); const legacyFloor = { id: "floor-legacy", projectId: "project-1", name: "EG", sortOrder: 0, }; const legacyInsert = { schemaVersion: 1 as const, type: "project-floor.insert" as const, payload: { floor: legacyFloor }, }; assertProjectFloorInsertProjectCommand(legacyInsert); const inserted = run(legacyInsert); assert.deepEqual(getFloor(context, legacyFloor.id), { ...legacyFloor, buildingId: null, }); // The inverse is always written in the current version. assert.equal(inserted.inverse.schemaVersion, projectFloorCommandSchemaVersion); const legacyUpdate = { schemaVersion: 1 as const, type: "project-floor.update" as const, payload: { expected: legacyFloor, target: { ...legacyFloor, name: "Erdgeschoss" }, }, }; const updated = run(legacyUpdate); assert.equal(getFloor(context, legacyFloor.id)?.name, "Erdgeschoss"); run(updated.inverse, "undo"); assert.equal(getFloor(context, legacyFloor.id)?.name, "EG"); // A version 1 snapshot must not carry buildingId, a version 2 one must. assert.throws(() => assertProjectFloorInsertProjectCommand({ schemaVersion: 1, type: "project-floor.insert", payload: { floor: { ...legacyFloor, buildingId: null } }, }) ); assert.throws(() => assertProjectFloorInsertProjectCommand({ schemaVersion: 2, type: "project-floor.insert", payload: { floor: legacyFloor }, }) ); } finally { context.close(); } }); it("validates building and floor requests", () => { assert.equal( createBuildingSchema.safeParse({ expectedRevision: 0, name: "Haus A" }).success, true ); assert.equal( createBuildingSchema.safeParse({ expectedRevision: 0, name: " " }).success, false ); assert.equal( createBuildingSchema.safeParse({ expectedRevision: 0, name: "Haus A", sortOrder: 1, }).success, false ); for (const buildingId of [undefined, null, "building-1"]) { assert.equal( createFloorSchema.safeParse({ expectedRevision: 0, name: "EG", buildingId }) .success, true ); } assert.equal( updateFloorSchema.safeParse({ expectedRevision: 0, name: "EG", buildingId: "" }) .success, false ); }); });