Add portable project transfer API

This commit is contained in:
2026-07-28 18:34:57 +02:00
parent 5fa2a701dc
commit da8115fe1d
10 changed files with 552 additions and 1 deletions
+99
View File
@@ -10,6 +10,8 @@ import {
} from "../src/db/database-context.js";
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js";
import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js";
import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
@@ -317,3 +319,100 @@ describe("project snapshot repository", () => {
}
});
});
describe("portable project transfer", () => {
it("exports and atomically duplicates a complete project with new ids", () => {
const context = createTestDatabase();
try {
const repository = new ProjectTransferRepository(context.db);
const transfer = repository.exportProject("project-1");
assert.ok(transfer);
const duplicate = repository.importDuplicate(transfer);
assert.notEqual(duplicate.projectId, "project-1");
assert.equal(duplicate.name, "Snapshot-Projekt (Kopie)");
const source = readProjectStateSnapshot(context.db, "project-1");
const copied = readProjectStateSnapshot(
context.db,
duplicate.projectId
);
assert.ok(source);
assert.ok(copied);
assert.equal(copied.currentRevision, 0);
assert.equal(copied.state.circuits.length, source.state.circuits.length);
assert.notEqual(
copied.state.circuits[0].id,
source.state.circuits[0].id
);
assert.equal(
copied.state.circuits[0].deviceRows[0].linkedProjectDeviceId,
copied.state.projectDevices[0].id
);
assert.equal(
copied.state.circuits[0].deviceRows[0].roomId,
copied.state.rooms[0].id
);
assert.equal(
copied.state.circuits[0].deviceRows[0].legacyConsumerId,
null
);
} finally {
context.close();
}
});
it("prepares same-project replacement and rejects damaged payloads", () => {
const context = createTestDatabase();
try {
const repository = new ProjectTransferRepository(context.db);
const transfer = repository.exportProject("project-1");
assert.ok(transfer);
const prepared = repository.prepareReplace("project-1", transfer);
assert.ok(prepared);
assert.equal(
prepared.command.payload.targetState.project.id,
"project-1"
);
assert.throws(
() =>
repository.importDuplicate({
...transfer,
payloadSha256: "0".repeat(64),
}),
/checksum verification failed/
);
assert.equal(
context.db.select().from(projects).all().length,
1
);
} finally {
context.close();
}
});
it("rolls back a duplicate when a late project-state insert fails", () => {
const context = createTestDatabase();
try {
const repository = new ProjectTransferRepository(context.db);
const transfer = repository.exportProject("project-1");
assert.ok(transfer);
context.sqlite.exec(`
CREATE TRIGGER fail_imported_device_row
BEFORE INSERT ON circuit_device_rows
BEGIN
SELECT RAISE(ABORT, 'forced project transfer failure');
END;
`);
assert.throws(
() => repository.importDuplicate(transfer),
/forced project transfer failure/
);
assert.equal(
context.db.select().from(projects).all().length,
1
);
} finally {
context.close();
}
});
});