Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
245 changes: 245 additions & 0 deletions apps/gittensory-miner-ui/src/ledgers.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import {
emptyLedgersSummary,
fetchLedgers,
LEDGERS_API_PATH,
type LedgersResult,
type LedgersSummary,
} from "./lib/ledgers";
import { LedgersPage, LedgersView } from "./routes/ledgers";
import { handleLedgersRequest, type LedgersApiDeps } from "../vite-ledgers-api";

const fixtureSummary: LedgersSummary = {
claims: { total: 3, byStatus: { active: 2, released: 1, expired: 0 } },
events: {
total: 2,
byType: { attempt_started: 1, attempt_succeeded: 1 },
recent: [
{ eventType: "attempt_succeeded", repoFullName: "acme/widgets", createdAt: "2026-07-10T06:05:00.000Z" },
{ eventType: "attempt_started", repoFullName: "acme/widgets", createdAt: "2026-07-10T06:00:00.000Z" },
],
},
governor: { total: 2, byEventType: { rate_limit_deferred: 1, budget_deferred: 1 } },
};

// Raw store rows carrying excluded raw columns (the free-text claim `note`, and event/governor payloads) the
// summary must NEVER republish. The API structurally omits these fields entirely, so whatever they contain —
// including any secret — cannot surface; the sentinels below are deliberately NON-secret-shaped so the repo's own
// secret scanner never trips on this fixture, while still proving the raw fields are dropped.
const rawClaimRows = [
{
repoFullName: "private-org/watched-repo",
issueNumber: 12,
status: "active",
claimedAt: "t1",
note: "LEAK_CANARY_CLAIM_A",
},
{
repoFullName: "private-org/watched-repo",
issueNumber: 13,
status: "released",
claimedAt: "t2",
note: "LEAK_CANARY_CLAIM_B",
},
{ repoFullName: "private-org/other", issueNumber: 7, status: "active", claimedAt: "t3", note: null },
];
const rawEventRows = [
{
type: "attempt_started",
repoFullName: "private-org/watched-repo",
createdAt: "t1",
payload: { detail: "LEAK_CANARY_EVENT_A" },
},
{
type: "attempt_succeeded",
repoFullName: "private-org/watched-repo",
createdAt: "t2",
payload_json: '{"detail":"LEAK_CANARY_EVENT_B"}',
},
];
const rawGovernorRows = [
{
eventType: "rate_limit_deferred",
repoFullName: "private-org/watched-repo",
ts: "t1",
payload: { detail: "LEAK_CANARY_GOV_A" },
},
{
eventType: "budget_deferred",
repoFullName: "private-org/watched-repo",
ts: "t2",
payload_json: '{"detail":"LEAK_CANARY_GOV_B"}',
},
];

describe("emptyLedgersSummary (#4855)", () => {
it("summarizes empty ledgers to zeros", () => {
expect(emptyLedgersSummary()).toEqual({
claims: { total: 0, byStatus: { active: 0, released: 0, expired: 0 } },
events: { total: 0, byType: {}, recent: [] },
governor: { total: 0, byEventType: {} },
});
});
});

describe("LedgersView (#4855)", () => {
it("renders claim status counts, the governor type table, and the recent-events feed", () => {
render(<LedgersView result={{ ok: true, summary: fixtureSummary }} />);
expect(screen.getByText("Active", { selector: "dt" }).nextSibling?.textContent).toBe("2");
expect(screen.getByText("Released", { selector: "dt" }).nextSibling?.textContent).toBe("1");
expect(screen.getByText("rate_limit_deferred")).toBeTruthy();
expect(screen.getByText("attempt_succeeded")).toBeTruthy();
expect(screen.getAllByText("acme/widgets").length).toBeGreaterThan(0);
});

it("renders the fresh-install empty state when every ledger is empty", () => {
render(<LedgersView result={{ ok: true, summary: emptyLedgersSummary() }} />);
expect(screen.getByText(/No ledger activity yet/i)).toBeTruthy();
expect(screen.queryByRole("table")).toBeNull();
});

it("renders an error message when the local API is unreachable", () => {
render(<LedgersView result={{ ok: false, error: "connection refused" }} />);
expect(screen.getByRole("alert").textContent).toContain("connection refused");
});

it("renders the loading state before the first result arrives", () => {
render(<LedgersView result={null} />);
expect(screen.getByText(/Loading local ledgers/i)).toBeTruthy();
});
});

describe("LedgersPage (#4855)", () => {
it("loads the summary through the injected loader and renders it", async () => {
const loadLedgers = async (): Promise<LedgersResult> => ({ ok: true, summary: fixtureSummary });
render(<LedgersPage loadLedgers={loadLedgers} />);
expect(screen.getByRole("heading", { name: "Ledgers" })).toBeTruthy();
await waitFor(() => expect(screen.getByText("Active", { selector: "dt" }).nextSibling?.textContent).toBe("2"));
});
});

describe("fetchLedgers (#4855)", () => {
const jsonResponse = (status: number, payload: unknown) =>
({ ok: status >= 200 && status < 300, status, json: async () => payload }) as unknown as Response;

it("returns a typed summary from a well-formed payload, requesting the local API path", async () => {
let requested: string | undefined;
const result = await fetchLedgers(async (input) => {
requested = String(input);
return jsonResponse(200, { summary: fixtureSummary });
});
expect(requested).toBe(LEDGERS_API_PATH);
expect(result).toEqual({ ok: true, summary: fixtureSummary });
});

it("surfaces non-2xx, malformed payloads, and thrown fetches as typed errors", async () => {
expect(await fetchLedgers(async () => jsonResponse(500, {}))).toEqual({
ok: false,
error: "local ledgers API responded 500",
});
expect(await fetchLedgers(async () => jsonResponse(200, { summary: { claims: { total: 1 } } }))).toMatchObject({
ok: false,
});
expect(
await fetchLedgers(async () => {
throw new Error("connection refused");
}),
).toEqual({ ok: false, error: "connection refused" });
});
});

describe("handleLedgersRequest (#4855)", () => {
function deps(overrides: Partial<LedgersApiDeps> = {}): LedgersApiDeps {
return {
loadClaimLedgerModule: async () => ({
resolveClaimLedgerDbPath: () => "/home/miner/.config/gittensory-miner/claim-ledger.sqlite3",
listClaims: () => rawClaimRows,
}),
loadEventLedgerModule: async () => ({
resolveEventLedgerDbPath: () => "/home/miner/.config/gittensory-miner/event-ledger.sqlite3",
readEvents: () => rawEventRows,
}),
loadGovernorLedgerModule: async () => ({
resolveGovernorLedgerDbPath: () => "/home/miner/.config/gittensory-miner/governor-ledger.sqlite3",
readGovernorEvents: () => rawGovernorRows,
}),
fileExists: () => true,
...overrides,
};
}

it("aggregates the three ledgers to counts and a safe recent-events feed", async () => {
const handled = await handleLedgersRequest("GET", "/api/ledgers", deps());
expect(handled?.status).toBe(200);
const body = JSON.parse(handled?.body ?? "{}") as { summary: LedgersSummary };
expect(body.summary.claims).toEqual({ total: 3, byStatus: { active: 2, released: 1, expired: 0 } });
expect(body.summary.governor).toEqual({ total: 2, byEventType: { rate_limit_deferred: 1, budget_deferred: 1 } });
expect(body.summary.events.total).toBe(2);
expect(body.summary.events.byType).toEqual({ attempt_started: 1, attempt_succeeded: 1 });
expect(body.summary.events.recent[0]).toEqual({
eventType: "attempt_succeeded",
repoFullName: "private-org/watched-repo",
createdAt: "t2",
});
});

it("INVARIANT (canary): never republishes the claim note or any raw event/governor payload", async () => {
const handled = await handleLedgersRequest("GET", "/api/ledgers", deps());
const body = handled?.body ?? "";
// Repo names are fine (already shown locally by the CLI's own dashboards), but every excluded raw column —
// the free-text note and the event/governor payloads (whatever they hold) — must be structurally absent.
for (const forbidden of [
"LEAK_CANARY_CLAIM_A",
"LEAK_CANARY_CLAIM_B",
"LEAK_CANARY_EVENT_A",
"LEAK_CANARY_EVENT_B",
"LEAK_CANARY_GOV_A",
"LEAK_CANARY_GOV_B",
"payload",
"note",
"detail",
]) {
expect(body).not.toContain(forbidden);
}
});

it("serves an empty summary on a fresh install WITHOUT initializing any store", async () => {
let touched = false;
const handled = await handleLedgersRequest(
"GET",
"/api/ledgers",
deps({
fileExists: () => false,
loadClaimLedgerModule: async () => ({
resolveClaimLedgerDbPath: () => "/nowhere/claim-ledger.sqlite3",
listClaims: () => {
touched = true;
return rawClaimRows;
},
}),
}),
);
expect(handled).toEqual({ status: 200, body: JSON.stringify({ summary: emptyLedgersSummary() }) });
expect(touched).toBe(false);
});

it("falls through (null) for other paths and non-GET methods", async () => {
expect(await handleLedgersRequest("GET", "/api/portfolio-queue", deps())).toBeNull();
expect(await handleLedgersRequest("POST", "/api/ledgers", deps())).toBeNull();
});

it("surfaces a store read failure as a 500 with a safe message", async () => {
const handled = await handleLedgersRequest(
"GET",
"/api/ledgers",
deps({
loadGovernorLedgerModule: async () => {
throw new Error("sqlite locked");
},
}),
);
expect(handled).toEqual({ status: 500, body: JSON.stringify({ error: "sqlite locked" }) });
});
});
76 changes: 76 additions & 0 deletions apps/gittensory-miner-ui/src/lib/ledgers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Read-only client for the local ledgers API (#4855). The middleware aggregates the claim / event / governor
// ledgers server-side into status/type counts plus a small feed of SAFE columns — it never republishes raw
// payloads, the free-text claim note, or any secret-shaped value (the same invariant the read-only MCP tools
// enforce). This client just fetches that summary and validates its shape; a failure surfaces as a typed error
// result the view renders, never a crash.

export const LEDGERS_API_PATH = "/api/ledgers";

export const CLAIM_STATUSES = ["active", "released", "expired"] as const;
export type ClaimStatus = (typeof CLAIM_STATUSES)[number];
export type ClaimStatusCounts = Record<ClaimStatus, number>;

export type ClaimsSummary = { total: number; byStatus: ClaimStatusCounts };
export type EventFeedEntry = { eventType: string; repoFullName: string | null; createdAt: string | null };
export type EventsSummary = { total: number; byType: Record<string, number>; recent: EventFeedEntry[] };
export type GovernorSummary = { total: number; byEventType: Record<string, number> };
export type LedgersSummary = { claims: ClaimsSummary; events: EventsSummary; governor: GovernorSummary };

export type LedgersResult = { ok: true; summary: LedgersSummary } | { ok: false; error: string };

export const emptyLedgersSummary = (): LedgersSummary => ({
claims: { total: 0, byStatus: { active: 0, released: 0, expired: 0 } },
events: { total: 0, byType: {}, recent: [] },
governor: { total: 0, byEventType: {} },
});

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

function isCountMap(value: unknown): value is Record<string, number> {
return isRecord(value) && Object.values(value).every((count) => typeof count === "number");
}

function isClaimStatusCounts(value: unknown): value is ClaimStatusCounts {
return isRecord(value) && CLAIM_STATUSES.every((status) => typeof value[status] === "number");
}

function isEventFeedEntry(value: unknown): value is EventFeedEntry {
if (!isRecord(value)) return false;
const okName = value.repoFullName === null || typeof value.repoFullName === "string";
const okAt = value.createdAt === null || typeof value.createdAt === "string";
return typeof value.eventType === "string" && okName && okAt;
}

function isLedgersSummary(value: unknown): value is LedgersSummary {
if (!isRecord(value)) return false;
const { claims, events, governor } = value as Record<string, unknown>;
if (!isRecord(claims) || typeof claims.total !== "number" || !isClaimStatusCounts(claims.byStatus)) return false;
if (
!isRecord(events) ||
typeof events.total !== "number" ||
!isCountMap(events.byType) ||
!Array.isArray(events.recent) ||
!events.recent.every(isEventFeedEntry)
) {
return false;
}
if (!isRecord(governor) || typeof governor.total !== "number" || !isCountMap(governor.byEventType)) return false;
return true;
}

/** Fetch the local ledgers summary; failures surface as a typed error result the view renders, never a crash. */
export async function fetchLedgers(fetchImpl: typeof fetch = fetch): Promise<LedgersResult> {
try {
const response = await fetchImpl(LEDGERS_API_PATH);
if (!response.ok) return { ok: false, error: `local ledgers API responded ${response.status}` };
const payload: unknown = await response.json();
const summary = (payload as { summary?: unknown }).summary;
if (!isLedgersSummary(summary))
return { ok: false, error: "local ledgers API returned an unexpected payload shape" };
return { ok: true, summary };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "failed to reach the local ledgers API" };
}
}
Loading
Loading