/*
RFC-0583: unit tests for bordbuch.repair — orphan-mission-close detection, insertion, hash recompute, dry-run, idempotency, unrepairable rejection.
- Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only.
- RFC-0583: initial bordbuch.repair unit tests.
*/
import { test, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs/promises";
import { existsSync, mkdirSync } from "node:fs";
import path from "node:path";
import os from "node:os";
import { execSync } from "node:child_process";
import type { KernelCommandInput, KernelRuntimeContext } from "@warpgogol/werkstatt-engine/kernel";
import type { BordbuchEntry } from "@warpgogol/werkstatt-engine/schemas";
import { computeEntryHash } from "./bordbuch-io.ts";
import { runBordbuchRepair } from "./bordbuch-repair.ts";
import { createDefaultIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
let tmpDir: string;
const systemId = "test-system";
function mockContext(workspaceRoot: string): KernelRuntimeContext {
return {
io: createDefaultIO().io,
workspaceRoot,
logger: {
info: () => {},
success: () => {},
error: () => {},
warn: () => {},
event: () => {},
getEvents: () => [],
},
} as unknown as KernelRuntimeContext;
}
function makeInput(flags: Record): KernelCommandInput {
return {
commandName: "bordbuch.repair",
flags,
} as unknown as KernelCommandInput;
}
function makeEntry(
overrides: Partial &
Pick,
prevHash: string | null,
): BordbuchEntry {
const base: Omit = {
schemaVersion: "1.0.0",
id: overrides.id,
systemId,
occurredAt: overrides.occurredAt,
kind: overrides.kind,
status: "done",
missionId: overrides.missionId,
releaseId: null,
actor: "agent",
summary: overrides.summary,
previousHash: prevHash,
metadata: overrides.metadata,
};
const hash = computeEntryHash(base);
return { ...base, hash };
}
function buildValidBordbuchWithOrphan(): BordbuchEntry[] {
const e1 = makeEntry(
{
id: "event-000001",
kind: "mission-open",
missionId: "m001",
occurredAt: "2026-07-28T10:00:00.000Z",
summary: "Mission m001 opened",
},
null,
);
const e2 = makeEntry(
{
id: "event-000002",
kind: "mission-close",
missionId: "m001",
occurredAt: "2026-07-28T11:00:00.000Z",
summary: "Mission m001 closed",
},
e1.hash,
);
const e3 = makeEntry(
{
id: "event-000003",
kind: "mission-close",
missionId: "m002",
occurredAt: "2026-07-28T12:00:00.000Z",
summary: "Mission m002 closed (orphan)",
},
e2.hash,
);
return [e1, e2, e3];
}
function buildValidBordbuch(): BordbuchEntry[] {
const e1 = makeEntry(
{
id: "event-000001",
kind: "mission-open",
missionId: "m001",
occurredAt: "2026-07-28T10:00:00.000Z",
summary: "Mission m001 opened",
},
null,
);
const e2 = makeEntry(
{
id: "event-000002",
kind: "mission-close",
missionId: "m001",
occurredAt: "2026-07-28T11:00:00.000Z",
summary: "Mission m001 closed",
},
e1.hash,
);
return [e1, e2];
}
async function writeBordbuch(entries: BordbuchEntry[]): Promise {
const cacheDir = path.join(tmpDir, "..", "systems-cache", systemId);
const bordbuchDir = path.join(cacheDir, "bordbuch");
if (!existsSync(bordbuchDir)) mkdirSync(bordbuchDir, { recursive: true });
const ndjson = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
await fs.writeFile(path.join(bordbuchDir, "events.ndjson"), ndjson, "utf8");
}
async function readBordbuchFile(): Promise {
const filePath = path.join(tmpDir, "..", "systems-cache", systemId, "bordbuch", "events.ndjson");
const raw = await fs.readFile(filePath, "utf8");
const lines = raw.split("\n").filter((l) => l.trim().length > 0);
return lines.map((l) => JSON.parse(l) as BordbuchEntry);
}
let testRoot: string;
beforeEach(async () => {
testRoot = await fs.mkdtemp(path.join(os.tmpdir(), "bordbuch-repair-test-"));
tmpDir = path.join(testRoot, "workspace");
await fs.mkdir(tmpDir, { recursive: true });
});
afterEach(async () => {
await fs.rm(testRoot, { recursive: true, force: true });
});
test("repairs orphan-mission-close by inserting mission-open and recomputing hashes", async () => {
const entries = buildValidBordbuchWithOrphan();
await writeBordbuch(entries);
const result = await runBordbuchRepair(makeInput({ system: systemId }), mockContext(tmpDir));
expect(result.data!.insertedEvents).toBe(1);
expect(result.data!.recomputedHashes).toBe(4);
expect(result.data!.dryRun).toBe(false);
const repaired = await readBordbuchFile();
expect(repaired).toHaveLength(4);
expect(repaired[0].kind).toBe("mission-open");
expect(repaired[0].missionId).toBe("m001");
expect(repaired[0].id).toBe("event-000001");
expect(repaired[1].kind).toBe("mission-close");
expect(repaired[1].missionId).toBe("m001");
expect(repaired[1].id).toBe("event-000002");
expect(repaired[2].kind).toBe("mission-open");
expect(repaired[2].missionId).toBe("m002");
expect(repaired[2].summary).toBe("Mission opened (auto-repaired)");
expect(repaired[2].id).toBe("event-000003");
expect(repaired[3].kind).toBe("mission-close");
expect(repaired[3].missionId).toBe("m002");
expect(repaired[3].id).toBe("event-000004");
for (let i = 0; i < repaired.length; i++) {
expect(repaired[i].id).toBe(`event-${String(i + 1).padStart(6, "0")}`);
if (i === 0) {
expect(repaired[i].previousHash).toBeNull();
} else {
expect(repaired[i].previousHash).toBe(repaired[i - 1].hash);
}
}
});
test("dry-run shows planned repairs without writing", async () => {
const entries = buildValidBordbuchWithOrphan();
await writeBordbuch(entries);
const originalContent = await fs.readFile(
path.join(tmpDir, "..", "systems-cache", systemId, "bordbuch", "events.ndjson"),
"utf8",
);
const result = await runBordbuchRepair(
makeInput({ system: systemId, "dry-run": true }),
mockContext(tmpDir),
);
expect(result.data!.dryRun).toBe(true);
expect(result.data!.insertedEvents).toBe(0);
expect(result.data!.orphans).toBeDefined();
expect(result.data!.orphans).toHaveLength(1);
expect(result.data!.orphans![0].missionId).toBe("m002");
expect(result.data!.orphans![0].metadataSource).toBe("auto-derived");
const contentAfter = await fs.readFile(
path.join(tmpDir, "..", "systems-cache", systemId, "bordbuch", "events.ndjson"),
"utf8",
);
expect(contentAfter).toBe(originalContent);
});
test("is idempotent — no-op on already-valid bordbuch", async () => {
const entries = buildValidBordbuch();
await writeBordbuch(entries);
const result = await runBordbuchRepair(makeInput({ system: systemId }), mockContext(tmpDir));
expect(result.data!.insertedEvents).toBe(0);
expect(result.data!.recomputedHashes).toBe(0);
expect(result.summary).toContain("no repairs needed");
});
test("rejects unrepairable violations", async () => {
const entries = buildValidBordbuch();
const tampered = [...entries];
tampered[1] = { ...tampered[1], summary: "tampered summary", hash: "sha256:fake" };
await writeBordbuch(tampered);
await expect(
runBordbuchRepair(makeInput({ system: systemId }), mockContext(tmpDir)),
).rejects.toThrow(/unrepairable violations found/);
});
test("--metadata overrides auto-derived metadata", async () => {
const entries = buildValidBordbuchWithOrphan();
await writeBordbuch(entries);
const customMetadata = JSON.stringify({
occurredAt: "2026-07-28T09:30:00.000Z",
summary: "Custom repaired open",
actor: "operator",
});
const result = await runBordbuchRepair(
makeInput({ system: systemId, metadata: customMetadata }),
mockContext(tmpDir),
);
expect(result.data!.insertedEvents).toBe(1);
const repaired = await readBordbuchFile();
const openEntry = repaired.find((e) => e.kind === "mission-open" && e.missionId === "m002");
expect(openEntry).toBeDefined();
expect(openEntry!.summary).toBe("Custom repaired open");
expect(openEntry!.actor).toBe("operator");
expect(openEntry!.occurredAt).toBe("2026-07-28T09:30:00.000Z");
});
test("--mission filters to only the specified mission; remaining orphans cause failure", async () => {
const e1 = makeEntry(
{
id: "event-000001",
kind: "mission-close",
missionId: "m001",
occurredAt: "2026-07-28T10:00:00.000Z",
summary: "Orphan close m001",
},
null,
);
const e2 = makeEntry(
{
id: "event-000002",
kind: "mission-close",
missionId: "m002",
occurredAt: "2026-07-28T11:00:00.000Z",
summary: "Orphan close m002",
},
e1.hash,
);
await writeBordbuch([e1, e2]);
await expect(
runBordbuchRepair(makeInput({ system: systemId, mission: "m001" }), mockContext(tmpDir)),
).rejects.toThrow(/repaired bordbuch still invalid.*m002/);
});
test("auto-commits repaired bordbuch when cache clone is a git repo", async () => {
const entries = buildValidBordbuchWithOrphan();
await writeBordbuch(entries);
// Initialize a git repo in the cache clone directory
const cacheDir = path.join(tmpDir, "..", "systems-cache", systemId);
execSync("git init", { cwd: cacheDir });
execSync("git config user.email test@test.com", { cwd: cacheDir });
execSync("git config user.name Test", { cwd: cacheDir });
execSync("git add -A", { cwd: cacheDir });
execSync('git commit -m "initial"', { cwd: cacheDir });
execSync("git branch -M main", { cwd: cacheDir });
// Set up a bare repo as origin to allow push
const bareRepoDir = path.join(tmpDir, "..", "systems-git");
const bareRepo = path.join(bareRepoDir, systemId);
mkdirSync(bareRepo, { recursive: true });
execSync("git init --bare", { cwd: bareRepo });
execSync(`git remote add origin ${bareRepo}`, { cwd: cacheDir });
execSync("git push -u origin main", { cwd: cacheDir });
const result = await runBordbuchRepair(makeInput({ system: systemId }), mockContext(tmpDir));
expect(result.data!.insertedEvents).toBe(1);
// Verify the repair was committed
const logOutput = execSync("git log --oneline -1", { cwd: cacheDir }).toString().trim();
expect(logOutput).toContain("bordbuch.repair");
// Verify working tree is clean (no unstaged changes)
const status = execSync("git status --porcelain", { cwd: cacheDir }).toString().trim();
expect(status).toBe("");
});
test("throws on auto-commit failure to prevent dirty cache clone", async () => {
const entries = buildValidBordbuchWithOrphan();
await writeBordbuch(entries);
// Initialize a git repo with a pre-commit hook that always fails
const cacheDir = path.join(tmpDir, "..", "systems-cache", systemId);
execSync("git init", { cwd: cacheDir });
execSync("git config user.email test@test.com", { cwd: cacheDir });
execSync("git config user.name Test", { cwd: cacheDir });
execSync("git add -A", { cwd: cacheDir });
execSync('git commit -m "initial"', { cwd: cacheDir });
execSync("git branch -M main", { cwd: cacheDir });
// Install a pre-commit hook that rejects all commits
const hookPath = path.join(cacheDir, ".git", "hooks", "pre-commit");
await fs.writeFile(hookPath, "#!/bin/sh\necho 'reject' >&2\nexit 1\n");
await fs.chmod(hookPath, 0o755);
await expect(
runBordbuchRepair(makeInput({ system: systemId }), mockContext(tmpDir)),
).rejects.toThrow(/commit failed/);
});
test("repairs unmatched-mission-open by appending mission-close", async () => {
// Build a bordbuch with a mission-open but no matching close
const e1 = makeEntry(
{
id: "event-000001",
kind: "mission-open",
missionId: "m003",
occurredAt: "2026-07-28T10:00:00.000Z",
summary: "Mission m003 opened",
},
null,
);
await writeBordbuch([e1]);
const result = await runBordbuchRepair(makeInput({ system: systemId }), mockContext(tmpDir));
expect(result.data!.insertedEvents).toBe(1);
const repaired = await readBordbuchFile();
// Should now have 2 entries: the original open + appended close
expect(repaired.length).toBe(2);
const closeEntry = repaired.find((e) => e.kind === "mission-close" && e.missionId === "m003");
expect(closeEntry).toBeDefined();
expect(closeEntry!.metadata).toEqual({ autoRepaired: true });
// Verify hash chain is valid
expect(repaired[1].previousHash).toBe(repaired[0].hash);
expect(repaired[1].id).toBe("event-000002");
});