import path from "node:path"; import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { eq } from "drizzle-orm"; import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { createDatabaseContext, type AppDatabase, type DatabaseContext, } from "../src/db/database-context.js"; import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; import { appendProjectRevision } from "../src/db/repositories/project-revision.persistence.js"; import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js"; import { projectChangeSets } from "../src/db/schema/project-change-sets.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projectSnapshots } from "../src/db/schema/project-snapshots.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(); return context; } function appendTestRevision( database: AppDatabase, expectedRevision: number ) { return database.transaction((tx) => appendProjectRevision(tx, { projectId: "project-1", expectedRevision, source: "user", description: "Stromkreis bearbeiten", actorId: "test-user", forward: { schemaVersion: 1, type: "circuit.update", payload: { circuitId: "circuit-1", displayName: "Neu" }, }, inverse: { schemaVersion: 1, type: "circuit.update", payload: { circuitId: "circuit-1", displayName: "Alt" }, }, }) ); } function getCurrentRevision(database: AppDatabase, projectId: string) { return ( database .select({ currentRevision: projects.currentRevision }) .from(projects) .where(eq(projects.id, projectId)) .get()?.currentRevision ?? null ); } describe("project revision persistence", () => { it("appends immutable revision metadata and payloads with a monotonic number", () => { const context = createTestDatabase(); try { assert.equal(getCurrentRevision(context.db, "project-1"), 0); const appended = appendTestRevision(context.db, 0); assert.equal(appended.projectId, "project-1"); assert.equal(appended.revisionNumber, 1); assert.equal(getCurrentRevision(context.db, "project-1"), 1); const revision = context.db .select() .from(projectRevisions) .where(eq(projectRevisions.id, appended.revisionId)) .get(); const changeSet = context.db .select() .from(projectChangeSets) .where(eq(projectChangeSets.id, appended.changeSetId)) .get(); assert.deepEqual(revision, { id: appended.revisionId, projectId: "project-1", revisionNumber: 1, createdAtIso: appended.createdAtIso, actorId: "test-user", source: "user", description: "Stromkreis bearbeiten", }); assert.deepEqual(changeSet, { id: appended.changeSetId, projectRevisionId: appended.revisionId, commandType: "circuit.update", payloadSchemaVersion: 1, forwardPayloadJson: JSON.stringify({ schemaVersion: 1, type: "circuit.update", payload: { circuitId: "circuit-1", displayName: "Neu", }, }), inversePayloadJson: JSON.stringify({ schemaVersion: 1, type: "circuit.update", payload: { circuitId: "circuit-1", displayName: "Alt", }, }), }); const next = appendTestRevision(context.db, 1); assert.equal(next.revisionNumber, 2); assert.equal(getCurrentRevision(context.db, "project-1"), 2); } finally { context.close(); } }); it("rejects a stale expected revision without writing history", () => { const context = createTestDatabase(); try { appendTestRevision(context.db, 0); assert.throws( () => appendTestRevision(context.db, 0), (error) => error instanceof ProjectRevisionConflictError && error.expectedRevision === 0 && error.actualRevision === 1 ); assert.equal(getCurrentRevision(context.db, "project-1"), 1); assert.equal(context.db.select().from(projectRevisions).all().length, 1); assert.equal(context.db.select().from(projectChangeSets).all().length, 1); } finally { context.close(); } }); it("rolls back the project counter and revision when change-set storage fails", () => { const context = createTestDatabase(); try { context.sqlite.exec(` CREATE TRIGGER fail_project_change_set_insert BEFORE INSERT ON project_change_sets BEGIN SELECT RAISE(ABORT, 'forced change-set failure'); END; `); assert.throws( () => appendTestRevision(context.db, 0), /forced change-set failure/ ); assert.equal(getCurrentRevision(context.db, "project-1"), 0); assert.equal(context.db.select().from(projectRevisions).all().length, 0); assert.equal(context.db.select().from(projectChangeSets).all().length, 0); } finally { context.close(); } }); it("captures and retains automatic snapshots without deleting named snapshots", () => { const context = createTestDatabase(); try { const snapshots = new ProjectSnapshotRepository(context.db); const named = snapshots.createNamed({ projectId: "project-1", expectedRevision: 0, name: "Automatisch · Revision 25", }); assert.ok(named); for ( let expectedRevision = 0; expectedRevision < 325; expectedRevision += 1 ) { appendTestRevision(context.db, expectedRevision); } const stored = context.db .select({ id: projectSnapshots.id, kind: projectSnapshots.kind, name: projectSnapshots.name, sourceRevision: projectSnapshots.sourceRevision, createdByActorId: projectSnapshots.createdByActorId, }) .from(projectSnapshots) .all(); const automatic = stored .filter((snapshot) => snapshot.kind === "automatic") .sort( (left, right) => left.sourceRevision - right.sourceRevision ); assert.equal(automatic.length, 12); assert.deepEqual( automatic.map((snapshot) => snapshot.sourceRevision), [50, 75, 100, 125, 150, 175, 200, 225, 250, 275, 300, 325] ); assert.equal( automatic.every( (snapshot) => snapshot.createdByActorId === "test-user" ), true ); assert.equal( automatic.some( (snapshot) => snapshot.name === "Automatisch · Revision 25" ), false ); assert.deepEqual( stored.filter((snapshot) => snapshot.kind === "named"), [ { id: named.id, kind: "named", name: "Automatisch · Revision 25", sourceRevision: 0, createdByActorId: null, }, ] ); } finally { context.close(); } }); it("rolls back revision 25 when automatic snapshot persistence fails", () => { const context = createTestDatabase(); try { for ( let expectedRevision = 0; expectedRevision < 24; expectedRevision += 1 ) { appendTestRevision(context.db, expectedRevision); } context.sqlite.exec(` CREATE TRIGGER fail_automatic_project_snapshot BEFORE INSERT ON project_snapshots WHEN NEW.kind = 'automatic' BEGIN SELECT RAISE(ABORT, 'forced automatic snapshot failure'); END; `); assert.throws( () => appendTestRevision(context.db, 24), /forced automatic snapshot failure/ ); assert.equal(getCurrentRevision(context.db, "project-1"), 24); assert.equal( context.db.select().from(projectRevisions).all().length, 24 ); assert.equal( context.db.select().from(projectChangeSets).all().length, 24 ); assert.equal( context.db.select().from(projectSnapshots).all().length, 0 ); } finally { context.close(); } }); });