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>
690 lines
19 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|