Add portable project transfer API
This commit is contained in:
@@ -226,6 +226,16 @@ fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der global
|
||||
Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal.
|
||||
Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt,
|
||||
damit Änderungen weiterhin niemals still synchronisiert werden.
|
||||
`GET /api/projects/:projectId/export` verpackt denselben vollständigen
|
||||
Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument
|
||||
mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format,
|
||||
Snapshot-Relationen und Prüfsumme vor jedem Schreibzugriff. Der Modus `replace`
|
||||
läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft
|
||||
rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-,
|
||||
Struktur-, Geräte-, Raum-, Stromkreis- und Gerätezeilen-UUIDs vollständig neu
|
||||
zu und legt die Kopie mit Revision `0` in einer Transaktion an. Upgrade-only-
|
||||
Consumer-Verweise werden nicht in die Kopie übernommen; fachliche Verknüpfungen
|
||||
innerhalb des unterstützten Laufzeitmodells bleiben erhalten.
|
||||
`distribution-board.insert` versioniert die Anlage einer Verteilung als einen
|
||||
vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen.
|
||||
Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import crypto from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
createProjectTransferEnvelope,
|
||||
parseProjectTransferEnvelope,
|
||||
remapProjectState,
|
||||
} from "../../domain/models/project-transfer.model.js";
|
||||
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
|
||||
import type { ProjectTransferStore } from "../../domain/ports/project-transfer.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import { floors } from "../schema/floors.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { rooms } from "../schema/rooms.js";
|
||||
import {
|
||||
hashProjectStatePayload,
|
||||
readProjectStateSnapshot,
|
||||
} from "./project-state-snapshot.persistence.js";
|
||||
import { serializeProjectStateSnapshot } from "../../domain/models/project-state-snapshot.model.js";
|
||||
|
||||
export class ProjectTransferRepository implements ProjectTransferStore {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
exportProject(projectId: string) {
|
||||
const snapshot = readProjectStateSnapshot(this.database, projectId);
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
return createProjectTransferEnvelope(
|
||||
new Date().toISOString(),
|
||||
snapshot.payloadSha256,
|
||||
snapshot.state
|
||||
);
|
||||
}
|
||||
|
||||
prepareReplace(projectId: string, value: unknown) {
|
||||
const transfer = this.parseVerified(value);
|
||||
const current = readProjectStateSnapshot(this.database, projectId);
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
const targetState =
|
||||
transfer.projectState.project.id === projectId
|
||||
? transfer.projectState
|
||||
: remapProjectState(
|
||||
transfer.projectState,
|
||||
projectId,
|
||||
() => crypto.randomUUID()
|
||||
);
|
||||
return {
|
||||
command: createProjectStateRestoreCommand(
|
||||
current.payloadSha256,
|
||||
targetState
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
importDuplicate(value: unknown) {
|
||||
const transfer = this.parseVerified(value);
|
||||
const projectId = crypto.randomUUID();
|
||||
const state = remapProjectState(
|
||||
transfer.projectState,
|
||||
projectId,
|
||||
() => crypto.randomUUID(),
|
||||
`${transfer.projectState.project.name} (Kopie)`
|
||||
);
|
||||
this.database.transaction((tx) => insertProjectState(tx, state));
|
||||
return { projectId, name: state.project.name };
|
||||
}
|
||||
|
||||
private parseVerified(value: unknown) {
|
||||
const transfer = parseProjectTransferEnvelope(value);
|
||||
const payloadJson = serializeProjectStateSnapshot(
|
||||
transfer.projectState
|
||||
);
|
||||
if (
|
||||
hashProjectStatePayload(payloadJson) !== transfer.payloadSha256
|
||||
) {
|
||||
throw new Error("Project transfer checksum verification failed.");
|
||||
}
|
||||
return transfer;
|
||||
}
|
||||
}
|
||||
|
||||
function insertProjectState(
|
||||
database: AppDatabase,
|
||||
state: ReturnType<typeof remapProjectState>
|
||||
) {
|
||||
const existing = database
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, state.project.id))
|
||||
.get();
|
||||
if (existing) {
|
||||
throw new Error("Project transfer target already exists.");
|
||||
}
|
||||
database
|
||||
.insert(projects)
|
||||
.values({ ...state.project, currentRevision: 0 })
|
||||
.run();
|
||||
insertMany(database, floors, state.floors);
|
||||
insertMany(database, rooms, state.rooms);
|
||||
insertMany(database, projectDevices, state.projectDevices);
|
||||
insertMany(database, distributionBoards, state.distributionBoards);
|
||||
insertMany(database, circuitLists, state.circuitLists);
|
||||
insertMany(database, circuitSections, state.circuitSections);
|
||||
insertMany(
|
||||
database,
|
||||
circuits,
|
||||
state.circuits.map(({ deviceRows: _deviceRows, ...circuit }) => ({
|
||||
...circuit,
|
||||
isReserve: circuit.isReserve ? 1 : 0,
|
||||
}))
|
||||
);
|
||||
insertMany(
|
||||
database,
|
||||
circuitDeviceRows,
|
||||
state.circuits.flatMap((circuit) => circuit.deviceRows)
|
||||
);
|
||||
}
|
||||
|
||||
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
|
||||
database: AppDatabase,
|
||||
table: TTable,
|
||||
values: unknown[]
|
||||
) {
|
||||
if (values.length > 0) {
|
||||
database.insert(table).values(values as never).run();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
parseProjectStateSnapshot,
|
||||
projectStateSnapshotSchemaVersion,
|
||||
type ProjectStateSnapshot,
|
||||
} from "./project-state-snapshot.model.js";
|
||||
|
||||
export const projectTransferFormat = "leistungsbilanz.project" as const;
|
||||
export const projectTransferFormatVersion = 1 as const;
|
||||
|
||||
export interface ProjectTransferEnvelope {
|
||||
format: typeof projectTransferFormat;
|
||||
formatVersion: typeof projectTransferFormatVersion;
|
||||
exportedAtIso: string;
|
||||
payloadSha256: string;
|
||||
projectState: ProjectStateSnapshot;
|
||||
}
|
||||
|
||||
export function createProjectTransferEnvelope(
|
||||
exportedAtIso: string,
|
||||
payloadSha256: string,
|
||||
projectState: ProjectStateSnapshot
|
||||
): ProjectTransferEnvelope {
|
||||
return parseProjectTransferEnvelope({
|
||||
format: projectTransferFormat,
|
||||
formatVersion: projectTransferFormatVersion,
|
||||
exportedAtIso,
|
||||
payloadSha256,
|
||||
projectState,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseProjectTransferEnvelope(
|
||||
value: unknown
|
||||
): ProjectTransferEnvelope {
|
||||
if (!isPlainObject(value)) {
|
||||
throw new Error("Project transfer file must be an object.");
|
||||
}
|
||||
if (
|
||||
value.format !== projectTransferFormat ||
|
||||
value.formatVersion !== projectTransferFormatVersion
|
||||
) {
|
||||
throw new Error("Project transfer format is not supported.");
|
||||
}
|
||||
if (
|
||||
typeof value.exportedAtIso !== "string" ||
|
||||
!Number.isFinite(Date.parse(value.exportedAtIso))
|
||||
) {
|
||||
throw new Error("Project transfer timestamp is invalid.");
|
||||
}
|
||||
if (
|
||||
typeof value.payloadSha256 !== "string" ||
|
||||
!/^[a-f0-9]{64}$/.test(value.payloadSha256)
|
||||
) {
|
||||
throw new Error("Project transfer checksum is invalid.");
|
||||
}
|
||||
const projectState = parseProjectStateSnapshot(value.projectState);
|
||||
if (projectState.schemaVersion !== projectStateSnapshotSchemaVersion) {
|
||||
throw new Error("Project transfer snapshot version is not supported.");
|
||||
}
|
||||
if (Object.keys(value).length !== 5) {
|
||||
throw new Error("Project transfer file contains unknown fields.");
|
||||
}
|
||||
return {
|
||||
format: projectTransferFormat,
|
||||
formatVersion: projectTransferFormatVersion,
|
||||
exportedAtIso: value.exportedAtIso,
|
||||
payloadSha256: value.payloadSha256,
|
||||
projectState,
|
||||
};
|
||||
}
|
||||
|
||||
export function remapProjectState(
|
||||
source: ProjectStateSnapshot,
|
||||
targetProjectId: string,
|
||||
createId: () => string,
|
||||
name: string = source.project.name
|
||||
): ProjectStateSnapshot {
|
||||
const boardIds = idMap(source.distributionBoards, createId);
|
||||
const listIds = idMap(source.circuitLists, createId);
|
||||
const sectionIds = idMap(source.circuitSections, createId);
|
||||
const circuitIds = idMap(source.circuits, createId);
|
||||
const deviceIds = idMap(source.projectDevices, createId);
|
||||
const floorIds = idMap(source.floors, createId);
|
||||
const roomIds = idMap(source.rooms, createId);
|
||||
const rowIds = new Map(
|
||||
source.circuits.flatMap((circuit) =>
|
||||
circuit.deviceRows.map((row) => [row.id, createId()] as const)
|
||||
)
|
||||
);
|
||||
return parseProjectStateSnapshot({
|
||||
...source,
|
||||
project: { ...source.project, id: targetProjectId, name },
|
||||
distributionBoards: source.distributionBoards.map((board) => ({
|
||||
...board,
|
||||
id: requiredId(boardIds, board.id),
|
||||
projectId: targetProjectId,
|
||||
})),
|
||||
circuitLists: source.circuitLists.map((list) => ({
|
||||
...list,
|
||||
id: requiredId(listIds, list.id),
|
||||
projectId: targetProjectId,
|
||||
distributionBoardId: requiredId(boardIds, list.distributionBoardId),
|
||||
})),
|
||||
circuitSections: source.circuitSections.map((section) => ({
|
||||
...section,
|
||||
id: requiredId(sectionIds, section.id),
|
||||
circuitListId: requiredId(listIds, section.circuitListId),
|
||||
})),
|
||||
circuits: source.circuits.map((circuit) => ({
|
||||
...circuit,
|
||||
id: requiredId(circuitIds, circuit.id),
|
||||
circuitListId: requiredId(listIds, circuit.circuitListId),
|
||||
sectionId: requiredId(sectionIds, circuit.sectionId),
|
||||
deviceRows: circuit.deviceRows.map((row) => ({
|
||||
...row,
|
||||
id: requiredId(rowIds, row.id),
|
||||
circuitId: requiredId(circuitIds, circuit.id),
|
||||
linkedProjectDeviceId:
|
||||
row.linkedProjectDeviceId === null
|
||||
? null
|
||||
: requiredId(deviceIds, row.linkedProjectDeviceId),
|
||||
legacyConsumerId: null,
|
||||
roomId:
|
||||
row.roomId === null ? null : requiredId(roomIds, row.roomId),
|
||||
})),
|
||||
})),
|
||||
projectDevices: source.projectDevices.map((device) => ({
|
||||
...device,
|
||||
id: requiredId(deviceIds, device.id),
|
||||
projectId: targetProjectId,
|
||||
})),
|
||||
floors: source.floors.map((floor) => ({
|
||||
...floor,
|
||||
id: requiredId(floorIds, floor.id),
|
||||
projectId: targetProjectId,
|
||||
})),
|
||||
rooms: source.rooms.map((room) => ({
|
||||
...room,
|
||||
id: requiredId(roomIds, room.id),
|
||||
projectId: targetProjectId,
|
||||
floorId:
|
||||
room.floorId === null ? null : requiredId(floorIds, room.floorId),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function idMap(
|
||||
entries: ReadonlyArray<{ id: string }>,
|
||||
createId: () => string
|
||||
) {
|
||||
return new Map(entries.map((entry) => [entry.id, createId()]));
|
||||
}
|
||||
|
||||
function requiredId(ids: ReadonlyMap<string, string>, sourceId: string) {
|
||||
const targetId = ids.get(sourceId);
|
||||
if (!targetId) {
|
||||
throw new Error(`Project transfer reference "${sourceId}" is invalid.`);
|
||||
}
|
||||
return targetId;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ProjectStateRestoreCommand } from "../models/project-state-restore-command.model.js";
|
||||
import type { ProjectTransferEnvelope } from "../models/project-transfer.model.js";
|
||||
|
||||
export interface PreparedProjectTransferReplace {
|
||||
command: ProjectStateRestoreCommand;
|
||||
}
|
||||
|
||||
export interface ProjectTransferStore {
|
||||
exportProject(projectId: string): ProjectTransferEnvelope | null;
|
||||
prepareReplace(
|
||||
projectId: string,
|
||||
transfer: unknown
|
||||
): PreparedProjectTransferReplace | null;
|
||||
importDuplicate(transfer: unknown): { projectId: string; name: string };
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { db } from "../../db/client.js";
|
||||
import { ProjectTransferRepository } from "../../db/repositories/project-transfer.repository.js";
|
||||
|
||||
export const projectTransferStore = new ProjectTransferRepository(db);
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
|
||||
import { ProjectStateConflictError } from "../../domain/errors/project-state-conflict.error.js";
|
||||
import { importProjectTransferSchema } from "../../shared/validation/project-transfer.schemas.js";
|
||||
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||
import { projectTransferStore } from "../composition/project-transfer-store.js";
|
||||
|
||||
export function exportProjectTransfer(req: Request, res: Response) {
|
||||
const projectId = getProjectId(req, res);
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
const transfer = projectTransferStore.exportProject(projectId);
|
||||
if (!transfer) {
|
||||
return res.status(404).json({ error: "Project not found" });
|
||||
}
|
||||
const filename = safeFilename(transfer.projectState.project.name);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${filename}.leistungsbilanz.json"`
|
||||
);
|
||||
return res.json(transfer);
|
||||
}
|
||||
|
||||
export function importProjectTransfer(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const projectId = getProjectId(req, res);
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
const parsed = importProjectTransferSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
try {
|
||||
if (parsed.data.mode === "duplicate") {
|
||||
return res
|
||||
.status(201)
|
||||
.json(projectTransferStore.importDuplicate(parsed.data.transfer));
|
||||
}
|
||||
const prepared = projectTransferStore.prepareReplace(
|
||||
projectId,
|
||||
parsed.data.transfer
|
||||
);
|
||||
if (!prepared) {
|
||||
return res.status(404).json({ error: "Project not found" });
|
||||
}
|
||||
const result = projectCommandService.executeRestore({
|
||||
projectId,
|
||||
expectedRevision: parsed.data.expectedRevision,
|
||||
description: "Projektimport wiederherstellen",
|
||||
command: prepared.command,
|
||||
});
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof ProjectRevisionConflictError) {
|
||||
return res.status(409).json({
|
||||
error: error.message,
|
||||
code: "PROJECT_REVISION_CONFLICT",
|
||||
expectedRevision: error.expectedRevision,
|
||||
currentRevision: error.actualRevision,
|
||||
});
|
||||
}
|
||||
if (error instanceof ProjectStateConflictError) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: error.message, code: "PROJECT_STATE_CONFLICT" });
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.message.startsWith("Project transfer") ||
|
||||
error.message.startsWith("Snapshot"))
|
||||
) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
function getProjectId(req: Request, res: Response) {
|
||||
const { projectId } = req.params;
|
||||
if (typeof projectId !== "string" || !projectId.trim()) {
|
||||
res.status(400).json({ error: "Invalid projectId" });
|
||||
return null;
|
||||
}
|
||||
return projectId;
|
||||
}
|
||||
|
||||
function safeFilename(name: string) {
|
||||
return (
|
||||
name
|
||||
.normalize("NFKD")
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80) || "projekt"
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ import { errorMiddleware } from "./middleware/error.middleware.js";
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.json({ limit: "25mb" }));
|
||||
|
||||
app.get("/health", (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
|
||||
@@ -27,12 +27,18 @@ import {
|
||||
listProjectSnapshots,
|
||||
restoreProjectSnapshot,
|
||||
} from "../controllers/project-snapshot.controller.js";
|
||||
import {
|
||||
exportProjectTransfer,
|
||||
importProjectTransfer,
|
||||
} from "../controllers/project-transfer.controller.js";
|
||||
|
||||
export const projectRouter = Router();
|
||||
|
||||
projectRouter.get("/", listProjects);
|
||||
projectRouter.post("/", createProject);
|
||||
projectRouter.get("/:projectId", getProject);
|
||||
projectRouter.get("/:projectId/export", exportProjectTransfer);
|
||||
projectRouter.post("/:projectId/import", importProjectTransfer);
|
||||
projectRouter.get("/:projectId/history", getProjectHistory);
|
||||
projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
|
||||
projectRouter.post("/:projectId/commands", executeProjectCommand);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from "zod";
|
||||
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
|
||||
|
||||
export const importProjectTransferSchema = z.discriminatedUnion("mode", [
|
||||
z
|
||||
.object({
|
||||
mode: z.literal("replace"),
|
||||
expectedRevision: expectedProjectRevisionSchema,
|
||||
transfer: z.unknown(),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
mode: z.literal("duplicate"),
|
||||
transfer: z.unknown(),
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user