Isolate circuit section transactions

This commit is contained in:
2026-07-23 19:02:38 +02:00
parent 91baa6c76c
commit 9be94028b3
9 changed files with 377 additions and 214 deletions
@@ -0,0 +1,214 @@
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 { CircuitSectionTransactionRepository } from "../src/db/repositories/circuit-section-transaction.repository.js";
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.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 { projects } from "../src/db/schema/projects.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" }).run();
const board = new DistributionBoardRepository(
context.db
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
const [section] = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, board.id))
.limit(1)
.all();
context.db
.insert(circuits)
.values([
{
id: "circuit-1",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F1",
displayName: "Stromkreis 1",
sortOrder: 10,
isReserve: 0,
},
{
id: "circuit-2",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F2",
displayName: "Stromkreis 2",
sortOrder: 20,
isReserve: 1,
},
{
id: "circuit-3",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F3",
displayName: "Stromkreis 3",
sortOrder: 30,
isReserve: 1,
},
])
.run();
context.db
.insert(circuitDeviceRows)
.values({
id: "row-1",
circuitId: "circuit-1",
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
})
.run();
return context;
}
function listCircuits(context: DatabaseContext) {
return context.db
.select()
.from(circuits)
.orderBy(asc(circuits.id))
.all();
}
describe("circuit-section transaction repository", () => {
it("commits identifier swaps without violating the circuit-list uniqueness constraint", () => {
const context = createTestDatabase();
try {
const persisted = listCircuits(context);
const repository = new CircuitSectionTransactionRepository(context.db);
repository.updateEquipmentIdentifiers(
persisted[0].circuitListId,
[
{ id: "circuit-1", equipmentIdentifier: "-1F2" },
{ id: "circuit-2", equipmentIdentifier: "-1F1" },
],
persisted[0].sectionId
);
assert.deepEqual(
listCircuits(context).map((circuit) => circuit.equipmentIdentifier),
["-1F2", "-1F1", "-1F3"]
);
assert.equal(
context.db.select().from(circuitDeviceRows).all()[0].circuitId,
"circuit-1"
);
} finally {
context.close();
}
});
it("rolls back temporary and final identifiers when a later update fails", () => {
const context = createTestDatabase();
try {
const persisted = listCircuits(context);
context.sqlite.exec(`
CREATE TRIGGER fail_second_final_identifier
BEFORE UPDATE OF equipment_identifier ON circuits
WHEN OLD.id = 'circuit-2' AND NEW.equipment_identifier = '-1F1'
BEGIN
SELECT RAISE(ABORT, 'forced identifier failure');
END;
`);
const repository = new CircuitSectionTransactionRepository(context.db);
assert.throws(
() =>
repository.updateEquipmentIdentifiers(
persisted[0].circuitListId,
[
{ id: "circuit-1", equipmentIdentifier: "-1F2" },
{ id: "circuit-2", equipmentIdentifier: "-1F1" },
],
persisted[0].sectionId
),
/forced identifier failure/
);
assert.deepEqual(
listCircuits(context).map((circuit) => circuit.equipmentIdentifier),
["-1F1", "-1F2", "-1F3"]
);
} finally {
context.close();
}
});
it("commits a complete section reorder without changing identifiers", () => {
const context = createTestDatabase();
try {
const persisted = listCircuits(context);
const repository = new CircuitSectionTransactionRepository(context.db);
repository.updateSortOrders(persisted[0].sectionId, [
"circuit-3",
"circuit-1",
"circuit-2",
]);
assert.deepEqual(
listCircuits(context).map((circuit) => [
circuit.id,
circuit.sortOrder,
circuit.equipmentIdentifier,
]),
[
["circuit-1", 20, "-1F1"],
["circuit-2", 30, "-1F2"],
["circuit-3", 10, "-1F3"],
]
);
} finally {
context.close();
}
});
it("rolls back earlier sort orders when a later update fails", () => {
const context = createTestDatabase();
try {
const persisted = listCircuits(context);
context.sqlite.exec(`
CREATE TRIGGER fail_last_sort_order
BEFORE UPDATE OF sort_order ON circuits
WHEN OLD.id = 'circuit-2' AND NEW.sort_order = 30
BEGIN
SELECT RAISE(ABORT, 'forced sort failure');
END;
`);
const repository = new CircuitSectionTransactionRepository(context.db);
assert.throws(
() =>
repository.updateSortOrders(persisted[0].sectionId, [
"circuit-3",
"circuit-1",
"circuit-2",
]),
/forced sort failure/
);
assert.deepEqual(
listCircuits(context).map((circuit) => circuit.sortOrder),
[10, 20, 30]
);
} finally {
context.close();
}
});
});
+15 -123
View File
@@ -1,9 +1,6 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { CircuitWriteService } from "../src/domain/services/circuit-write.service.js";
import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.repository.js";
import { CircuitRepository } from "../src/db/repositories/circuit.repository.js";
import { db } from "../src/db/client.js";
import { createCircuitSchema } from "../src/shared/validation/circuit.schemas.js";
describe("circuit write service rules", () => {
@@ -320,7 +317,9 @@ describe("circuit write service rules", () => {
async listByCircuitList() {
return [{ id: "x1", sectionId: "s2", equipmentIdentifier: "-3F1" }] as never[];
},
async updateEquipmentIdentifiersSafely(_listId: string, updates: Array<{ id: string; equipmentIdentifier: string }>) {
} as never,
circuitSectionTransactionStore: {
updateEquipmentIdentifiers(_listId: string, updates: Array<{ id: string; equipmentIdentifier: string }>) {
safeUpdatePayload = updates;
},
} as never,
@@ -353,7 +352,9 @@ describe("circuit write service rules", () => {
async listByCircuitList() {
return [{ id: "o1", sectionId: "s2", equipmentIdentifier: "-1F2" }] as never[];
},
async updateEquipmentIdentifiersSafely(_listId: string, updates: Array<{ id: string; equipmentIdentifier: string }>) {
} as never,
circuitSectionTransactionStore: {
updateEquipmentIdentifiers(_listId: string, updates: Array<{ id: string; equipmentIdentifier: string }>) {
safeUpdatePayload = updates;
},
} as never,
@@ -386,7 +387,9 @@ describe("circuit write service rules", () => {
async listByCircuitList() {
return [] as never[];
},
async updateEquipmentIdentifiersSafely() {
} as never,
circuitSectionTransactionStore: {
updateEquipmentIdentifiers() {
safeCalled += 1;
},
} as never,
@@ -692,7 +695,9 @@ describe("circuit write service rules", () => {
{ id: "c3", sectionId: "s1", equipmentIdentifier: "-2F5", sortOrder: 30, isReserve: 1 },
] as never[];
},
updateSortOrdersSafely(sectionId: string, circuitIds: string[]) {
} as never,
circuitSectionTransactionStore: {
updateSortOrders(sectionId: string, circuitIds: string[]) {
safeReorder = { sectionId, circuitIds };
},
} as never,
@@ -720,7 +725,9 @@ describe("circuit write service rules", () => {
{ id: "c2", sectionId: "s1", equipmentIdentifier: "-1F2", sortOrder: 20, isReserve: 0 },
] as never[];
},
async updateEquipmentIdentifiersSafely() {
} as never,
circuitSectionTransactionStore: {
updateEquipmentIdentifiers() {
safeCalled = true;
},
} as never,
@@ -734,121 +741,6 @@ describe("circuit write service rules", () => {
assert.equal(safeCalled, true);
});
it("safe identifier bulk update uses synchronous transaction callback", async () => {
const repository = new CircuitRepository();
const originalTransaction = (db as unknown as { transaction: unknown }).transaction;
let callbackReturnedPromise = false;
(db as unknown as { transaction: (cb: (tx: unknown) => unknown) => void }).transaction = (cb) => {
const fakeTx = {
select() {
return {
from() {
return {
where() {
return {
all() {
return [{ id: "c1" }, { id: "c2" }];
},
};
},
};
},
};
},
update() {
return {
set() {
return {
where() {
return {
run() {
return;
},
};
},
};
},
};
},
};
const result = cb(fakeTx);
callbackReturnedPromise = Boolean(result && typeof (result as Promise<unknown>).then === "function");
if (callbackReturnedPromise) {
throw new Error("Transaction function cannot return a promise");
}
};
try {
await repository.updateEquipmentIdentifiersSafely(
"l1",
[
{ id: "c1", equipmentIdentifier: "-1F1" },
{ id: "c2", equipmentIdentifier: "-1F2" },
],
"s1"
);
assert.equal(callbackReturnedPromise, false);
} finally {
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
}
});
it("safe circuit reorder uses one synchronous transaction callback", () => {
const repository = new CircuitRepository();
const originalTransaction = (db as unknown as { transaction: unknown }).transaction;
let callbackReturnedPromise = false;
const sortOrders: number[] = [];
(db as unknown as { transaction: (cb: (tx: unknown) => unknown) => void }).transaction = (cb) => {
const fakeTx = {
select() {
return {
from() {
return {
where() {
return {
all() {
return [{ id: "c1" }, { id: "c2" }, { id: "c3" }];
},
};
},
};
},
};
},
update() {
return {
set(values: { sortOrder: number }) {
sortOrders.push(values.sortOrder);
return {
where() {
return {
run() {
return { changes: 1 };
},
};
},
};
},
};
},
};
const callbackResult = cb(fakeTx);
callbackReturnedPromise = Boolean(
callbackResult && typeof (callbackResult as Promise<unknown>).then === "function"
);
};
try {
repository.updateSortOrdersSafely("s1", ["c3", "c1", "c2"]);
assert.equal(callbackReturnedPromise, false);
assert.deepEqual(sortOrders, [10, 20, 30]);
} finally {
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
}
});
it("tracks local edits on linked device rows as overridden fields", async () => {
let updatePayload: Record<string, unknown> = {};
const current = {