leistungsbilanz-ts/tests/distribution-board-component-structure-project-command.repository.test.ts
Julian Appel b45dc5002d Fix code-review findings across domain, persistence, server and frontend
Full-codebase review turned up five real correctness/security bugs and
a dozen smaller inconsistencies; all are fixed here with matching test
coverage:

- BMK uniqueness silently allowed German-umlaut duplicates ("Ä1" vs
  "ä1") because the DB's normalized index only folds ASCII case. Added
  a shared Unicode-aware pre-check used by every circuit/component
  insert and rename path (one of which had no pre-check at all).
- CircuitDeviceRow.simultaneityFactor had no upper bound at the row
  level (command model and snapshot/restore schema), unlike every
  sibling entity, letting a bad value silently corrupt power totals.
- Grid cell editing silently misread German thousands-separator input
  ("1.500" parsed as 1.5); "." is now rejected outright with a clear
  message instead of guessing.
- The editor's shared command runner (runCommand/applyHistory) had no
  re-entrancy guard, so a double click/drop could fire the same
  command twice and race a BMK collision or revision conflict. Added a
  synchronous ref guard plus isSaving on the buttons that lacked it.
- GET .../next-identifier leaked circuit-numbering state for sections
  in other projects (no ownership check, 400 instead of 404). Moved
  under /projects/:projectId and scoped it.

Also: added the missing circuits.section_id / circuit_device_rows.
circuit_id indexes (migration 0006), gave FormModal a focus trap /
Escape-to-close / focus restore and rebuilt ProjectSettingsModal on
top of it instead of duplicated markup, removed dead code (3 orphaned
domain model files, an unused persistence helper, a wrapper only used
by its own test), pointed the project page at GET /projects/:id
instead of listing+filtering client-side, closed the gap between the
documented 18 MB CSV limit and the ~17.17 MiB actually enforced, added
missing upper bounds on several free-text fields, filled in nine
missing German labels in the revision timeline, replaced a
key-order-fragile JSON.stringify equality check with a real field
comparison, made an implicit sort-order assumption in three
renumbering helpers explicit, cleared the sidebar's target selection
when it no longer resolves after a tree reload, and fixed
updateGlobalDevice to check-then-write instead of write-then-check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 21:31:16 +02:00

700 lines
21 KiB
TypeScript

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 { DistributionBoardComponentStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-component-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 { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import {
createDistributionBoardComponentInsertProjectCommand,
createDistributionBoardComponentUpdateProjectCommand,
type DistributionBoardComponentSnapshot,
} from "../src/domain/models/distribution-board-component-structure-project-command.model.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.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");
return context;
}
function getListAndSection(context: DatabaseContext) {
const list = context.db.select().from(circuitLists).get();
assert.ok(list);
const section = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, list.id))
.get();
assert.ok(section);
return { list, section };
}
describe("distribution-board component structure project command", () => {
it("inserts an auxiliary component and preserves it through undo and redo", () => {
const context = createTestDatabase();
try {
const { list } = getListAndSection(context);
const snapshot: DistributionBoardComponentSnapshot = {
component: {
id: "component-aux",
circuitListId: list.id,
sectionId: null,
equipmentIdentifier: "-K1",
name: "KNX Schaltaktor",
role: "auxiliary",
placement: "footer",
sortOrder: 10,
},
protectionDevice: null,
};
const repository =
new DistributionBoardComponentStructureProjectCommandRepository(
context.db
);
const history = new ProjectHistoryRepository(context.db);
const command =
createDistributionBoardComponentInsertProjectCommand(snapshot);
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.deepEqual(
context.db
.select()
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.id, "component-aux"))
.get(),
snapshot.component
);
const undo = history.getNextCommand("project-1", "undo");
assert.ok(undo);
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: undo.changeSetId,
command: inserted.inverse,
});
assert.equal(
context.db
.select()
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.id, "component-aux"))
.get(),
undefined
);
const redo = history.getNextCommand("project-1", "redo");
assert.ok(redo);
repository.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId: redo.changeSetId,
command,
});
assert.ok(
context.db
.select()
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.id, "component-aux"))
.get()
);
} finally {
context.close();
}
});
it("stores and restores a group RCD with its protection data atomically", () => {
const context = createTestDatabase();
try {
const { list, section } = getListAndSection(context);
const snapshot: DistributionBoardComponentSnapshot = {
component: {
id: "component-rcd",
circuitListId: list.id,
sectionId: section.id,
equipmentIdentifier: "-1Q1.0",
name: "Gruppen-FI",
role: "group_residual_current_protection",
placement: "group",
sortOrder: 10,
},
protectionDevice: {
componentId: "component-rcd",
type: "FI",
ratedCurrentA: 40,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: "A",
ratedResidualCurrentMa: 30,
},
};
const repository =
new DistributionBoardComponentStructureProjectCommandRepository(
context.db
);
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command:
createDistributionBoardComponentInsertProjectCommand(
snapshot
),
});
assert.deepEqual(
context.db
.select()
.from(distributionBoardComponentProtectionDevices)
.get(),
snapshot.protectionDevice
);
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: new ProjectHistoryRepository(
context.db
).getNextCommand("project-1", "undo")?.changeSetId,
command: inserted.inverse,
});
assert.equal(
context.db
.select()
.from(distributionBoardComponentProtectionDevices)
.get(),
undefined
);
} finally {
context.close();
}
});
it("updates an auxiliary component and restores its name, BMK and order", () => {
const context = createTestDatabase();
try {
const { list } = getListAndSection(context);
const expected: DistributionBoardComponentSnapshot = {
component: {
id: "component-update-aux",
circuitListId: list.id,
sectionId: null,
equipmentIdentifier: "-K20",
name: "KNX Aktor",
role: "auxiliary",
placement: "footer",
sortOrder: 10,
},
protectionDevice: null,
};
const target: DistributionBoardComponentSnapshot = {
component: {
...expected.component,
equipmentIdentifier: "-K21",
name: "KNX Jalousieaktor",
sortOrder: 30,
},
protectionDevice: null,
};
const repository =
new DistributionBoardComponentStructureProjectCommandRepository(
context.db
);
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command:
createDistributionBoardComponentInsertProjectCommand(
expected
),
});
const updated = repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command:
createDistributionBoardComponentUpdateProjectCommand(
expected,
target
),
});
assert.deepEqual(
context.db
.select()
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.id, expected.component.id))
.get(),
target.component
);
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(distributionBoardComponents)
.where(eq(distributionBoardComponents.id, expected.component.id))
.get(),
expected.component
);
} finally {
context.close();
}
});
it("updates group protection data atomically and rejects stale state", () => {
const context = createTestDatabase();
try {
const { list, section } = getListAndSection(context);
const expected: DistributionBoardComponentSnapshot = {
component: {
id: "component-update-protection",
circuitListId: list.id,
sectionId: section.id,
equipmentIdentifier: "-1Q1.0",
name: "Gruppen-FI",
role: "group_residual_current_protection",
placement: "group",
sortOrder: 10,
},
protectionDevice: {
componentId: "component-update-protection",
type: "FI",
ratedCurrentA: 40,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: "A",
ratedResidualCurrentMa: 30,
},
};
const target: DistributionBoardComponentSnapshot = {
component: {
...expected.component,
name: "Gruppen-FI 63 A",
},
protectionDevice: {
...expected.protectionDevice!,
ratedCurrentA: 63,
},
};
const repository =
new DistributionBoardComponentStructureProjectCommandRepository(
context.db
);
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command:
createDistributionBoardComponentInsertProjectCommand(
expected
),
});
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command:
createDistributionBoardComponentUpdateProjectCommand(
expected,
target
),
});
assert.deepEqual(
context.db
.select()
.from(distributionBoardComponentProtectionDevices)
.where(
eq(
distributionBoardComponentProtectionDevices.componentId,
expected.component.id
)
)
.get(),
target.protectionDevice
);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 2,
source: "user",
command:
createDistributionBoardComponentUpdateProjectCommand(
expected,
target
),
}),
/changed before update/
);
assert.equal(
context.db.select().from(projectRevisions).all().length,
2
);
assert.throws(
() =>
createDistributionBoardComponentUpdateProjectCommand(
target,
{
...target,
component: {
...target.component,
sectionId: "another-section",
},
}
),
/cannot change ownership or role/
);
} finally {
context.close();
}
});
it("rolls back a component update when history persistence fails", () => {
const context = createTestDatabase();
try {
const { list } = getListAndSection(context);
const expected: DistributionBoardComponentSnapshot = {
component: {
id: "component-update-rollback",
circuitListId: list.id,
sectionId: null,
equipmentIdentifier: "-K30",
name: "Aktor",
role: "auxiliary",
placement: "footer",
sortOrder: 10,
},
protectionDevice: null,
};
const target: DistributionBoardComponentSnapshot = {
component: {
...expected.component,
name: "Geänderter Aktor",
sortOrder: 20,
},
protectionDevice: null,
};
const repository =
new DistributionBoardComponentStructureProjectCommandRepository(
context.db
);
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command:
createDistributionBoardComponentInsertProjectCommand(
expected
),
});
context.sqlite.exec(`
CREATE TRIGGER fail_component_update_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced component update history failure');
END;
`);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command:
createDistributionBoardComponentUpdateProjectCommand(
expected,
target
),
}),
/forced component update history failure/
);
assert.deepEqual(
context.db
.select()
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.id, expected.component.id))
.get(),
expected.component
);
assert.equal(
context.db.select().from(projectRevisions).all().length,
1
);
} finally {
context.close();
}
});
it("rejects fixed roles, foreign sections, duplicate BMKs and stale revisions", () => {
const context = createTestDatabase();
try {
const { list } = getListAndSection(context);
assert.throws(
() =>
createDistributionBoardComponentInsertProjectCommand({
component: {
id: "fixed",
circuitListId: list.id,
sectionId: null,
equipmentIdentifier: "-Q1",
name: "Hauptschalter",
role: "main_switch",
placement: "header",
sortOrder: 10,
} as never,
protectionDevice: null,
}),
/Fixed distribution-board components/
);
const snapshot: DistributionBoardComponentSnapshot = {
component: {
id: "duplicate",
circuitListId: list.id,
sectionId: null,
equipmentIdentifier: " -q0 ",
name: "Doppelt",
role: "auxiliary",
placement: "footer",
sortOrder: 10,
},
protectionDevice: null,
};
const repository =
new DistributionBoardComponentStructureProjectCommandRepository(
context.db
);
const foreignBoard = new DistributionBoardFixtureRepository(
context.db
).createWithCircuitListAndDefaultSections(
"project-2",
"UV fremd"
);
const foreignSection = context.db
.select()
.from(circuitSections)
.where(
eq(circuitSections.circuitListId, foreignBoard.id)
)
.get();
assert.ok(foreignSection);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command:
createDistributionBoardComponentInsertProjectCommand({
component: {
id: "foreign-section",
circuitListId: list.id,
sectionId: foreignSection.id,
equipmentIdentifier: "-1Q2.0",
name: "Fremder Gruppen-FI",
role: "group_residual_current_protection",
placement: "group",
sortOrder: 10,
},
protectionDevice: {
componentId: "foreign-section",
type: "FI",
ratedCurrentA: 40,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: "A",
ratedResidualCurrentMa: 30,
},
}),
}),
/section does not belong/
);
const staleSnapshot: DistributionBoardComponentSnapshot = {
component: {
...snapshot.component,
id: "stale",
equipmentIdentifier: "-K-stale",
},
protectionDevice: null,
};
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command:
createDistributionBoardComponentInsertProjectCommand(
staleSnapshot
),
}),
ProjectRevisionConflictError
);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command:
createDistributionBoardComponentInsertProjectCommand(
snapshot
),
}),
/Duplicate equipmentIdentifier in circuit list\./
);
assert.equal(
context.db.select().from(projectRevisions).all().length,
0
);
} finally {
context.close();
}
});
it("rejects changed deletes and rolls back late history failures", () => {
const context = createTestDatabase();
try {
const { list } = getListAndSection(context);
const snapshot: DistributionBoardComponentSnapshot = {
component: {
id: "component-change",
circuitListId: list.id,
sectionId: null,
equipmentIdentifier: "-K2",
name: "Aktor",
role: "auxiliary",
placement: "footer",
sortOrder: 20,
},
protectionDevice: null,
};
const repository =
new DistributionBoardComponentStructureProjectCommandRepository(
context.db
);
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command:
createDistributionBoardComponentInsertProjectCommand(
snapshot
),
});
context.db
.update(distributionBoardComponents)
.set({ name: "Direkt geändert" })
.where(eq(distributionBoardComponents.id, snapshot.component.id))
.run();
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: new ProjectHistoryRepository(
context.db
).getNextCommand("project-1", "undo")?.changeSetId,
command: inserted.inverse,
}),
/changed before deletion/
);
} finally {
context.close();
}
const rollback = createTestDatabase();
try {
const { list } = getListAndSection(rollback);
rollback.sqlite.exec(`
CREATE TRIGGER fail_component_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced component history failure');
END;
`);
const repository =
new DistributionBoardComponentStructureProjectCommandRepository(
rollback.db
);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command:
createDistributionBoardComponentInsertProjectCommand({
component: {
id: "component-rollback",
circuitListId: list.id,
sectionId: null,
equipmentIdentifier: "-K3",
name: "Aktor",
role: "auxiliary",
placement: "footer",
sortOrder: 30,
},
protectionDevice: null,
}),
}),
/forced component history failure/
);
assert.equal(
rollback.db
.select()
.from(distributionBoardComponents)
.where(
eq(
distributionBoardComponents.id,
"component-rollback"
)
)
.get(),
undefined
);
} finally {
rollback.close();
}
});
});