leistungsbilanz-ts/tests/project-device-row-sync-project-command.repository.test.ts
Julian Appel c6cdfc42d5 Keep manual quantity consistent when synchronizing device rows
Synchronizing a project device pushes its quantity onto every linked
device row, but manualQuantity is not part of the sync snapshot and was
left untouched. A device whose quantity is lower than the row's left the
row at manualQuantity > quantity, violating the snapshot invariant.

The violation surfaced far from its cause: the invariant is only checked
when a full state snapshot is read, and the automatic snapshot runs every
25 revisions. A project could therefore accumulate the broken row
silently and then reject every subsequent command, because the failing
validation rolls back the whole transaction including the revision bump.

Recompute manualQuantity as the synchronized quantity minus the total of
the linked external objects, matching the invariant that
assertCircuitDeviceRowQuantity enforces elsewhere, and reject a
synchronized quantity that falls below that total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 18:05:50 +02:00

690 lines
19 KiB
TypeScript

import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { asc, eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.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 { externalImportBatches } from "../src/db/schema/external-import-batches.js";
import { externalModelObjects } from "../src/db/schema/external-model-objects.js";
import { externalModelSources } from "../src/db/schema/external-model-sources.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js";
import {
createProjectDeviceRowSyncProjectCommand,
type ProjectDeviceSyncRowSnapshot,
} from "../src/domain/models/project-device-row-sync-project-command.model.js";
interface TestFixture {
context: DatabaseContext;
}
function createTestDatabase(): TestFixture {
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: "Other project" },
])
.run();
const boards = new DistributionBoardFixtureRepository(context.db);
const board = boards.createWithCircuitListAndDefaultSections(
"project-1",
"UV-01"
);
const foreignBoard = boards.createWithCircuitListAndDefaultSections(
"project-2",
"UV-02"
);
const section = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, board.id))
.get();
const foreignSection = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, foreignBoard.id))
.get();
assert.ok(section);
assert.ok(foreignSection);
context.db
.insert(projectDevices)
.values([
{
id: "project-device-1",
projectId: "project-1",
name: "Luminaire",
displayName: "Office lighting",
phaseType: "single_phase",
quantity: 4,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
cosPhi: 0.95,
remark: "DALI",
},
{
id: "project-device-foreign",
projectId: "project-2",
name: "Foreign device",
displayName: "Foreign device",
phaseType: "single_phase",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
])
.run();
context.db
.insert(circuits)
.values([
{
id: "circuit-1",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F1",
sortOrder: 10,
},
{
id: "circuit-foreign",
circuitListId: foreignBoard.id,
sectionId: foreignSection.id,
equipmentIdentifier: "-1F1",
sortOrder: 10,
},
])
.run();
context.db
.insert(circuitDeviceRows)
.values([
{
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: "project-device-1",
sortOrder: 10,
name: "Old luminaire",
displayName: "Local row 1",
phaseType: "single_phase",
category: "lighting",
quantity: 1,
powerPerUnit: 0.03,
simultaneityFactor: 1,
overriddenFields: '["displayName","quantity"]',
},
{
id: "row-2",
circuitId: "circuit-1",
linkedProjectDeviceId: "project-device-1",
sortOrder: 20,
name: "Old luminaire",
displayName: "Local row 2",
phaseType: "single_phase",
category: "lighting",
quantity: 2,
powerPerUnit: 0.03,
simultaneityFactor: 1,
overriddenFields: '["displayName"]',
},
{
id: "row-foreign",
circuitId: "circuit-foreign",
linkedProjectDeviceId: "project-device-foreign",
sortOrder: 10,
name: "Foreign device",
displayName: "Foreign device",
phaseType: "single_phase",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
])
.run();
return { context };
}
function getRow(context: DatabaseContext, rowId: string) {
const row = context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, rowId))
.get();
assert.ok(row);
return row;
}
function linkExternalObject(
context: DatabaseContext,
rowId: string,
effectiveQuantity: number
) {
context.db
.insert(externalModelSources)
.values({
id: "source-1",
projectId: "project-1",
name: "Revit",
sourceType: "revit_csv",
})
.run();
context.db
.insert(externalImportBatches)
.values({
id: "batch-1",
projectId: "project-1",
sourceId: "source-1",
importKind: "initial",
importedAtIso: "2026-08-02T16:00:00.000Z",
fileName: "revit.csv",
sha256: "a".repeat(64),
appliedProjectRevision: 0,
configurationVersion: 1,
configurationSnapshot: externalCsvTestConfiguration,
originalBytes: Buffer.from("test"),
document: { delimiter: ";", encoding: "utf-8", headers: [], rows: [] },
})
.run();
context.db
.insert(externalModelObjects)
.values({
id: "object-1",
projectId: "project-1",
sourceId: "source-1",
ifcGuid: "ifc-1",
lastSeenImportBatchId: "batch-1",
lastAcceptedImportBatchId: "batch-1",
acceptedSourceValues: {
rowNumber: 2,
roomNumber: "101",
roomName: "Büro",
familyAndType: "Leuchte: Standard",
selectionMarker: "Leuchte",
circuitIdentifier: "-1F1",
power: "30",
quantity: String(effectiveQuantity),
additionalSourceValues: {},
},
planningValues: {
displayName: "Leuchte",
internalDeviceType: "luminaire",
category: "single_phase",
connectionKind: "fixed",
effectiveQuantity,
powerPerUnitW: 30,
simultaneityFactor: 1,
cosPhi: null,
costGroup: null,
remark: null,
},
overriddenFields: [],
externalRoomMappingId: null,
distributionBoardId: null,
linkedProjectDeviceId: null,
circuitDeviceRowId: rowId,
presenceStatus: "present",
})
.run();
}
function snapshot(
context: DatabaseContext,
rowId: string
): ProjectDeviceSyncRowSnapshot {
const row = getRow(context, rowId);
return {
linkedProjectDeviceId: row.linkedProjectDeviceId,
name: row.name,
displayName: row.displayName,
phaseType: row.phaseType,
connectionKind: row.connectionKind,
costGroup: row.costGroup,
category: row.category,
quantity: row.quantity,
powerPerUnit: row.powerPerUnit,
simultaneityFactor: row.simultaneityFactor,
cosPhi: row.cosPhi,
remark: row.remark,
overriddenFields: row.overriddenFields,
};
}
function syncCommand(context: DatabaseContext) {
return createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"synchronize",
["row-1", "row-2"].map((rowId) => {
const expected = snapshot(context, rowId);
return {
rowId,
expected,
target: {
...expected,
name: "Luminaire",
displayName: "Office lighting",
quantity: 4,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
cosPhi: 0.95,
remark: "DALI",
overriddenFields: null,
},
};
})
);
}
function listOwnRows(context: DatabaseContext) {
return context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, "circuit-1"))
.orderBy(asc(circuitDeviceRows.id))
.all();
}
describe("project-device row sync project-command repository", () => {
it("synchronizes multiple rows and supports persisted undo and redo", () => {
const fixture = createTestDatabase();
try {
const store = new ProjectDeviceRowSyncProjectCommandRepository(
fixture.context.db
);
const command = syncCommand(fixture.context);
const synchronized = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.deepEqual(
listOwnRows(fixture.context).map((row) => [
row.displayName,
row.quantity,
row.overriddenFields,
row.sortOrder,
]),
[
["Office lighting", 4, null, 10],
["Office lighting", 4, null, 20],
]
);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId:
synchronized.revision.changeSetId,
command: synchronized.inverse,
});
assert.deepEqual(
listOwnRows(fixture.context).map((row) => [
row.displayName,
row.quantity,
row.overriddenFields,
]),
[
["Local row 1", 1, '["displayName","quantity"]'],
["Local row 2", 2, '["displayName"]'],
]
);
store.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId:
synchronized.revision.changeSetId,
command,
});
assert.deepEqual(
new ProjectHistoryRepository(fixture.context.db).getState(
"project-1"
),
{
projectId: "project-1",
currentRevision: 3,
undoDepth: 1,
redoDepth: 0,
undoChangeSetId: synchronized.revision.changeSetId,
redoChangeSetId: null,
}
);
} finally {
fixture.context.close();
}
});
it("disconnects and reconnects rows without changing local values", () => {
const fixture = createTestDatabase();
try {
const store = new ProjectDeviceRowSyncProjectCommandRepository(
fixture.context.db
);
const before = listOwnRows(fixture.context);
const command = createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"disconnect",
["row-1", "row-2"].map((rowId) => {
const expected = snapshot(fixture.context, rowId);
return {
rowId,
expected,
target: {
...expected,
linkedProjectDeviceId: null,
},
};
})
);
const disconnected = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.deepEqual(
listOwnRows(fixture.context).map((row) => [
row.linkedProjectDeviceId,
row.displayName,
row.quantity,
]),
before.map((row) => [null, row.displayName, row.quantity])
);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId:
disconnected.revision.changeSetId,
command: disconnected.inverse,
});
assert.deepEqual(
listOwnRows(fixture.context).map(
(row) => row.linkedProjectDeviceId
),
["project-device-1", "project-device-1"]
);
} finally {
fixture.context.close();
}
});
it("rejects stale snapshots and cross-project device or row access", () => {
const fixture = createTestDatabase();
try {
const store = new ProjectDeviceRowSyncProjectCommandRepository(
fixture.context.db
);
const staleCommand = syncCommand(fixture.context);
fixture.context.db
.update(circuitDeviceRows)
.set({ displayName: "Changed elsewhere" })
.where(eq(circuitDeviceRows.id, "row-1"))
.run();
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: staleCommand,
}),
/changed before sync execution/
);
const expected = snapshot(fixture.context, "row-foreign");
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceRowSyncProjectCommand(
"project-device-foreign",
"disconnect",
[
{
rowId: "row-foreign",
expected,
target: {
...expected,
linkedProjectDeviceId: null,
},
},
]
),
}),
/does not belong to project/
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"disconnect",
[
{
rowId: "row-foreign",
expected: {
...expected,
linkedProjectDeviceId: "project-device-1",
},
target: {
...expected,
linkedProjectDeviceId: null,
},
},
]
),
}),
/rows do not belong to project/
);
assert.equal(
fixture.context.db.select().from(projectRevisions).all()
.length,
0
);
} finally {
fixture.context.close();
}
});
it("rolls back every row and revision after a late history failure", () => {
const fixture = createTestDatabase();
try {
fixture.context.sqlite.exec(`
CREATE TRIGGER fail_sync_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced sync history failure');
END;
`);
const store = new ProjectDeviceRowSyncProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: syncCommand(fixture.context),
}),
/forced sync history failure/
);
assert.deepEqual(
listOwnRows(fixture.context).map((row) => [
row.displayName,
row.quantity,
]),
[
["Local row 1", 1],
["Local row 2", 2],
]
);
assert.equal(
fixture.context.db.select().from(projectRevisions).all()
.length,
0
);
} finally {
fixture.context.close();
}
});
it("keeps manualQuantity from exceeding quantity when a synced quantity shrinks", () => {
const fixture = createTestDatabase();
try {
fixture.context.db
.update(circuitDeviceRows)
.set({ quantity: 5, manualQuantity: 5 })
.where(eq(circuitDeviceRows.id, "row-1"))
.run();
const store = new ProjectDeviceRowSyncProjectCommandRepository(
fixture.context.db
);
const expected = snapshot(fixture.context, "row-1");
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"synchronize",
[
{
rowId: "row-1",
expected,
target: { ...expected, quantity: 2 },
},
]
),
});
const row = getRow(fixture.context, "row-1");
assert.equal(row.quantity, 2);
assert.equal(row.manualQuantity, 2);
} finally {
fixture.context.close();
}
});
it("subtracts linked external objects when a synced quantity shrinks", () => {
const fixture = createTestDatabase();
try {
fixture.context.db
.update(circuitDeviceRows)
.set({ quantity: 5, manualQuantity: 2 })
.where(eq(circuitDeviceRows.id, "row-1"))
.run();
linkExternalObject(fixture.context, "row-1", 3);
const store = new ProjectDeviceRowSyncProjectCommandRepository(
fixture.context.db
);
const expected = snapshot(fixture.context, "row-1");
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"synchronize",
[{ rowId: "row-1", expected, target: { ...expected, quantity: 4 } }]
),
});
const row = getRow(fixture.context, "row-1");
assert.equal(row.quantity, 4);
assert.equal(row.manualQuantity, 1);
} finally {
fixture.context.close();
}
});
it("rejects a synced quantity below the linked external total", () => {
const fixture = createTestDatabase();
try {
fixture.context.db
.update(circuitDeviceRows)
.set({ quantity: 5, manualQuantity: 2 })
.where(eq(circuitDeviceRows.id, "row-1"))
.run();
linkExternalObject(fixture.context, "row-1", 3);
const store = new ProjectDeviceRowSyncProjectCommandRepository(
fixture.context.db
);
const expected = snapshot(fixture.context, "row-1");
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"synchronize",
[{ rowId: "row-1", expected, target: { ...expected, quantity: 2 } }]
),
}),
/below the total quantity of linked external objects/
);
const row = getRow(fixture.context, "row-1");
assert.equal(row.quantity, 5);
assert.equal(row.manualQuantity, 2);
assert.equal(
fixture.context.db.select().from(projectRevisions).all().length,
0
);
} finally {
fixture.context.close();
}
});
it("rolls back synchronized rows for a stale project revision", () => {
const fixture = createTestDatabase();
try {
const store = new ProjectDeviceRowSyncProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: syncCommand(fixture.context),
}),
/at revision 0, expected 1/
);
assert.deepEqual(
listOwnRows(fixture.context).map((row) => [
row.displayName,
row.quantity,
]),
[
["Local row 1", 1],
["Local row 2", 2],
]
);
} finally {
fixture.context.close();
}
});
});