/*
RFC-0947: unit tests for sichtpass.generate — composite hash, deduplication, missing data handling.
- Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only.
- RFC-0947: initial sichtpass-generate unit tests.
*/
import { test, expect, beforeEach, afterEach, vi } from "vitest";
import fs from "node:fs/promises";
import { mkdirSync } from "node:fs";
import path from "node:path";
import os from "node:os";
// Mock bordbuch-io and bordbuch-commit-helper
vi.mock("../bordbuch/bordbuch-io.ts", () => ({
readBordbuch: vi.fn(),
resolveBordbuchProjectionDir: vi.fn(),
}));
vi.mock("../bordbuch/bordbuch-commit-helper.ts", () => ({
appendAndCommitBordbuch: vi.fn(),
}));
// Mock registry-io
vi.mock("../sternsystem/registry-io.ts", () => ({
resolveCacheClonePath: vi.fn(),
resolveActiveWorkpieceDir: vi.fn(),
readSystemState: vi.fn(),
readSystemConfig: vi.fn(),
}));
// Mock nachweis-io
vi.mock("../nachweis/nachweis-io.ts", () => ({
resolveNachweisCachePath: vi.fn(),
}));
// Mock werkstatt lock/operation
vi.mock("../werkstatt/index.ts", () => ({
acquireLock: vi.fn(),
releaseLock: vi.fn(),
generateOperationId: vi.fn(),
}));
import { runSichtpassGenerate } from "./sichtpass-generate.ts";
import { readBordbuch } from "../bordbuch/bordbuch-io.ts";
import { appendAndCommitBordbuch } from "../bordbuch/bordbuch-commit-helper.ts";
import {
resolveCacheClonePath,
resolveActiveWorkpieceDir,
readSystemState,
readSystemConfig,
} from "../sternsystem/registry-io.ts";
import { resolveNachweisCachePath } from "../nachweis/nachweis-io.ts";
import { acquireLock, releaseLock, generateOperationId } from "../werkstatt/index.ts";
import type { BordbuchEntry } from "@warpgogol/werkstatt-engine/schemas";
import { createDefaultIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
const mockReadBordbuch = vi.mocked(readBordbuch);
const mockAppendAndCommit = vi.mocked(appendAndCommitBordbuch);
const mockResolveCacheClone = vi.mocked(resolveCacheClonePath);
const mockResolveWorkpiece = vi.mocked(resolveActiveWorkpieceDir);
const mockReadSystemState = vi.mocked(readSystemState);
const mockReadSystemConfig = vi.mocked(readSystemConfig);
const mockResolveNachweisCache = vi.mocked(resolveNachweisCachePath);
const mockAcquireLock = vi.mocked(acquireLock);
const mockReleaseLock = vi.mocked(releaseLock);
const mockGenerateOpId = vi.mocked(generateOperationId);
let tmpDir: string;
let cachePath: string;
const systemId = "test-system";
const workspaceRoot = "/fake/workspace";
function makeContext() {
return {
io: createDefaultIO().io,
workspaceRoot,
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
debug: vi.fn(),
},
site: { name: systemId },
} as any;
}
function makeInput(flags: Record = {}) {
return { flags } as any;
}
function makeBordbuchEntry(
kind: string,
metadata: Record,
id = "evt-001",
): BordbuchEntry {
return {
schemaVersion: "1.0.0",
id,
systemId,
occurredAt: "2025-01-01T00:00:00.000Z",
kind: kind as BordbuchEntry["kind"],
status: "done",
missionId: null,
releaseId: null,
actor: "agent",
summary: "test",
metadata,
previousHash: null,
hash: "sha256:abc",
} as BordbuchEntry;
}
beforeEach(() => {
tmpDir = path.join(
os.tmpdir(),
`sichtpass-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
cachePath = path.join(tmpDir, "cache");
mkdirSync(cachePath, { recursive: true });
vi.clearAllMocks();
mockResolveCacheClone.mockResolvedValue(cachePath);
mockResolveWorkpiece.mockResolvedValue(null);
mockResolveNachweisCache.mockResolvedValue(cachePath);
mockReadSystemState.mockResolvedValue({
schemaVersion: "1.0.0",
systemId,
currentMission: null,
lastRelease: null,
lastPropagated: {},
accessPin: null,
} as any);
mockReadSystemConfig.mockResolvedValue({
deployment: {
adapter: "cloudflare-workers",
channels: {
dev: { workerName: "w-dev", url: "https://dev.example.com" },
alt: { workerName: "w-alt", url: "https://alt.example.com" },
main: { workerName: "w-main", url: "https://main.example.com" },
},
},
} as any);
mockReadBordbuch.mockResolvedValue([]);
mockAppendAndCommit.mockResolvedValue({
entry: {} as BordbuchEntry,
commitResult: { commitSha: "abc", pushed: true, error: null },
} as any);
mockAcquireLock.mockResolvedValue(undefined as any);
mockReleaseLock.mockResolvedValue(undefined as any);
mockGenerateOpId.mockReturnValue("op-test");
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
test("computes composite hash and appends Bordbuch entry on first run", async () => {
const result = await runSichtpassGenerate(makeInput(), makeContext());
expect(result.exitCode).toBe(0);
const data = result.data as any;
expect(data).toHaveProperty("compositeHash");
expect(data.compositeHash).toMatch(/^sha256:[0-9a-f]{64}$/);
expect(mockAppendAndCommit).toHaveBeenCalledOnce();
});
test("deduplicates — skips Bordbuch append when composite hash is unchanged", async () => {
// First run to get the composite hash
const result1 = await runSichtpassGenerate(makeInput(), makeContext());
const compositeHash = (result1.data as any).compositeHash;
// Second run: bordbuch now has the last sichtpass __site__ entry with same hash
mockReadBordbuch.mockResolvedValue([
makeBordbuchEntry("sichtpass", {
slug: "__site__",
recordHash: compositeHash,
}),
]);
vi.clearAllMocks();
mockReadBordbuch.mockResolvedValue([
makeBordbuchEntry("sichtpass", {
slug: "__site__",
recordHash: compositeHash,
}),
]);
const result2 = await runSichtpassGenerate(makeInput(), makeContext());
expect(result2.exitCode).toBe(0);
expect(mockAppendAndCommit).not.toHaveBeenCalled();
expect(result2.summary).toContain("deduplicated=true");
});
test("appends Bordbuch entry when composite hash differs from last entry", async () => {
mockReadBordbuch.mockResolvedValue([
makeBordbuchEntry("sichtpass", {
slug: "__site__",
recordHash: "sha256:different-hash-1234567890abcdef",
}),
]);
const result = await runSichtpassGenerate(makeInput(), makeContext());
expect(result.exitCode).toBe(0);
expect(mockAppendAndCommit).toHaveBeenCalledOnce();
});
test("handles missing data sources gracefully", async () => {
// No files exist in cachePath — all data sources should return defaults
const result = await runSichtpassGenerate(makeInput(), makeContext());
expect(result.exitCode).toBe(0);
const data = result.data as any;
expect(data.contentHash).toBeNull();
expect(data.routeCount).toBe(0);
expect(data.behaviorRouteCount).toBe(0);
expect(data.nachweisCount).toBe(0);
expect(data.nachweisSlugs).toEqual([]);
expect(data.pseoModules).toEqual([]);
expect(data.coverageAtoms).toBe(0);
expect(data.coveragePages).toEqual([]);
});
test("reads nachweis manifest when present", async () => {
const manifestDir = path.join(cachePath, "public", "nachweise");
await fs.mkdir(manifestDir, { recursive: true });
await fs.writeFile(
path.join(manifestDir, "manifest.json"),
JSON.stringify({
records: [{ slug: "record-a" }, { slug: "record-b" }],
}),
);
const result = await runSichtpassGenerate(makeInput(), makeContext());
const data = result.data as any;
expect(data.nachweisCount).toBe(2);
expect(data.nachweisSlugs).toEqual(["record-a", "record-b"]);
});
test("reads coverage ledger when present", async () => {
const ledgerDir = path.join(cachePath, "provenance");
await fs.mkdir(ledgerDir, { recursive: true });
await fs.writeFile(
path.join(ledgerDir, "coverage-ledger.yaml"),
[
"version: 1",
"atoms:",
" - atomId: a1",
" sourceId: s1",
" version: '1.0'",
" atomHash: sha256:abc",
" batch: batch-1",
" pageId: page-a",
" acceptedAt: '2025-01-01'",
" - atomId: a2",
" sourceId: s2",
" version: '1.0'",
" atomHash: sha256:def",
" batch: batch-1",
" pageId: page-b",
" acceptedAt: '2025-01-01'",
" - atomId: a3",
" sourceId: s3",
" version: '1.0'",
" atomHash: sha256:ghi",
" batch: batch-1",
" pageId: page-a",
" acceptedAt: '2025-01-01'",
" supersededBy: a1",
].join("\n"),
);
const result = await runSichtpassGenerate(makeInput(), makeContext());
const data = result.data as any;
// a3 is superseded, so only 2 active atoms, 2 unique pages
expect(data.coverageAtoms).toBe(2);
expect(data.coveragePages).toEqual(["page-a", "page-b"]);
});
test("reads bordbuch status.generated.yaml pseo modules when present", async () => {
const bordbuchDir = path.join(cachePath, "bordbuch");
await fs.mkdir(bordbuchDir, { recursive: true });
await fs.writeFile(
path.join(bordbuchDir, "status.generated.yaml"),
[
"site: test-system",
"pseo:",
" modules:",
" - id: mod-a",
" entitlement: nachweis",
" masterLocale: de",
" publishedLocales: [de, en]",
" blueprints: [bp-1]",
].join("\n"),
);
const result = await runSichtpassGenerate(makeInput(), makeContext());
const data = result.data as any;
expect(data.pseoModules).toHaveLength(1);
expect(data.pseoModules[0]).toEqual({
id: "mod-a",
masterLocale: "de",
publishedLocales: ["de", "en"],
});
});
test("composite hash is deterministic for same input", async () => {
const result1 = await runSichtpassGenerate(makeInput(), makeContext());
const result2 = await runSichtpassGenerate(makeInput(), makeContext());
const hash1 = (result1.data as any).compositeHash;
const hash2 = (result2.data as any).compositeHash;
expect(hash1).toBe(hash2);
});
test("composite hash changes when nachweis count changes", async () => {
const result1 = await runSichtpassGenerate(makeInput(), makeContext());
const hash1 = (result1.data as any).compositeHash;
// Add nachweis manifest
const manifestDir = path.join(cachePath, "public", "nachweise");
await fs.mkdir(manifestDir, { recursive: true });
await fs.writeFile(
path.join(manifestDir, "manifest.json"),
JSON.stringify({ records: [{ slug: "record-a" }] }),
);
const result2 = await runSichtpassGenerate(makeInput(), makeContext());
const hash2 = (result2.data as any).compositeHash;
expect(hash1).not.toBe(hash2);
});
test("fail-open: Bordbuch read failure does not prevent append", async () => {
mockReadBordbuch.mockRejectedValue(new Error("bordbuch read failed"));
const result = await runSichtpassGenerate(makeInput(), makeContext());
expect(result.exitCode).toBe(0);
expect(mockAppendAndCommit).toHaveBeenCalledOnce();
});
test("fail-open: Bordbuch append failure is non-fatal", async () => {
mockAppendAndCommit.mockRejectedValue(new Error("bordbuch append failed"));
const result = await runSichtpassGenerate(makeInput(), makeContext());
expect(result.exitCode).toBe(0);
expect(result.summary).toContain("warning=");
});
test("acquires and releases system and bordbuch locks", async () => {
await runSichtpassGenerate(makeInput(), makeContext());
expect(mockAcquireLock).toHaveBeenCalledTimes(2);
expect(mockReleaseLock).toHaveBeenCalledTimes(2);
const lockCalls = mockAcquireLock.mock.calls.map((c) => c[1]);
expect(lockCalls).toContain(`system:${systemId}`);
expect(lockCalls).toContain(`bordbuch:${systemId}`);
});
test("uses --system flag when provided", async () => {
const customSystem = "custom-sys";
const result = await runSichtpassGenerate(makeInput({ system: customSystem }), makeContext());
expect(result.exitCode).toBe(0);
const data = result.data as any;
expect(data.systemId).toBe(customSystem);
});