// Task 166 — log-ingest helper tests.
//
// Verifies:
//   - formatWriteEventLine produces the exact success/error shapes from the task spec
//   - emitWriteEvent POSTs to http://127.0.0.1:${PLATFORM_PORT}/api/admin/log-ingest
//     with the right body (tag/level/line)
//   - emitWriteEvent falls back to stderr (and the structured line) when the
//     loopback POST fails — and never throws
//   - withWriteEvent emits exactly once on both success and error exit paths
//   - classifyReason maps known thrown messages to the enumerated slugs
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { classifyReason, emitWriteEvent, formatWriteEventLine, withWriteEvent, } from "../../lib/log-ingest.js";
describe("formatWriteEventLine", () => {
    it("renders the success shape from the task spec", () => {
        const line = formatWriteEventLine({
            tool: "memory-write",
            result: "ok",
            accountId: "acct-1",
            session: "conv-7",
            labels: ["LocalBusiness"],
            name: "Smalleys",
            elementId: "4:abc:1",
            durationMs: 42,
        });
        expect(line).toBe("memory-write account=acct-1 session=conv-7 labels=LocalBusiness name=Smalleys result=ok elementId=4:abc:1 ms=42");
    });
    it("renders the failure shape from the task spec", () => {
        const line = formatWriteEventLine({
            tool: "memory-write",
            result: "error",
            reason: "invalid-target-node-id",
            detail: "targetNodeId 'self' does not resolve",
            accountId: "acct-1",
            session: "conv-7",
            labels: ["LocalBusiness"],
            name: "Smalleys",
            durationMs: 12,
        });
        expect(line).toBe("memory-write account=acct-1 session=conv-7 labels=LocalBusiness name=Smalleys result=error reason=invalid-target-node-id detail=targetNodeId 'self' does not resolve ms=12");
    });
    it("substitutes — for unknown account/session/labels/name", () => {
        const line = formatWriteEventLine({
            tool: "graph-prune-denylist-add",
            result: "ok",
            durationMs: 3,
        });
        expect(line).toBe("graph-prune-denylist-add account=— session=— labels=— name=— result=ok elementId=— ms=3");
    });
    it("truncates detail strings longer than 120 chars with an ellipsis", () => {
        const long = "x".repeat(300);
        const line = formatWriteEventLine({
            tool: "memory-write",
            result: "error",
            reason: "neo4j-driver-error",
            detail: long,
            durationMs: 1,
        });
        // 119 'x' characters + '…' = 120
        expect(line).toContain(`detail=${"x".repeat(119)}…`);
    });
});
describe("emitWriteEvent (loopback POST)", () => {
    let fetchSpy;
    let stderrSpy;
    let originalFetch;
    beforeEach(() => {
        process.env.PLATFORM_PORT = "12345";
        process.env.SESSION_NODE_ID = "env-conv-99";
        originalFetch = globalThis.fetch;
        fetchSpy = vi.fn();
        globalThis.fetch = fetchSpy;
        stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
    });
    afterEach(() => {
        globalThis.fetch = originalFetch;
        stderrSpy.mockRestore();
        delete process.env.PLATFORM_PORT;
        delete process.env.SESSION_NODE_ID;
    });
    it("POSTs to the loopback URL with tag=mcp:memory and the formatted line", async () => {
        fetchSpy.mockResolvedValue(new Response('{"ok":true}', { status: 200 }));
        await emitWriteEvent({
            tool: "memory-write",
            result: "ok",
            accountId: "acct-1",
            labels: ["LocalBusiness"],
            name: "Smalleys",
            elementId: "eid",
            durationMs: 5,
        });
        expect(fetchSpy).toHaveBeenCalledTimes(1);
        const [url, init] = fetchSpy.mock.calls[0];
        expect(url).toBe("http://127.0.0.1:12345/api/admin/log-ingest");
        expect(init.method).toBe("POST");
        const body = JSON.parse(init.body);
        expect(body.tag).toBe("mcp:memory");
        expect(body.level).toBe("info");
        expect(body.line).toContain("memory-write account=acct-1 session=env-conv-99 labels=LocalBusiness name=Smalleys result=ok elementId=eid ms=5");
        expect(stderrSpy).not.toHaveBeenCalled();
    });
    it("sets level=error for failure events", async () => {
        fetchSpy.mockResolvedValue(new Response('{"ok":true}', { status: 200 }));
        await emitWriteEvent({
            tool: "memory-write",
            result: "error",
            reason: "invalid-input",
            detail: "scope is required",
            durationMs: 1,
        });
        const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
        expect(body.level).toBe("error");
    });
    it("falls back to stderr (line + log-ingest-failed) when fetch throws — never re-throws", async () => {
        fetchSpy.mockRejectedValue(new Error("ECONNREFUSED 127.0.0.1:12345"));
        await expect(emitWriteEvent({
            tool: "memory-write",
            result: "ok",
            accountId: "acct-1",
            durationMs: 1,
        })).resolves.toBeUndefined();
        const writes = stderrSpy.mock.calls.map((c) => c[0]).join("");
        expect(writes).toContain("[mcp:memory] log-ingest-failed status=0 err=ECONNREFUSED");
        expect(writes).toContain("[mcp:memory] memory-write account=acct-1");
    });
    it("falls back to stderr when the loopback returns non-2xx", async () => {
        fetchSpy.mockResolvedValue(new Response("nope", { status: 500 }));
        await emitWriteEvent({
            tool: "memory-write",
            result: "ok",
            durationMs: 1,
        });
        const writes = stderrSpy.mock.calls.map((c) => c[0]).join("");
        expect(writes).toContain("log-ingest-failed status=500");
        expect(writes).toContain("[mcp:memory] memory-write");
    });
    it("writes directly to stderr when PLATFORM_PORT is unset (no fetch call)", async () => {
        delete process.env.PLATFORM_PORT;
        await emitWriteEvent({
            tool: "memory-write",
            result: "ok",
            accountId: "acct-1",
            durationMs: 1,
        });
        expect(fetchSpy).not.toHaveBeenCalled();
        const writes = stderrSpy.mock.calls.map((c) => c[0]).join("");
        expect(writes).toContain("[mcp:memory] memory-write account=acct-1");
    });
});
describe("withWriteEvent (one emit per exit path)", () => {
    let fetchSpy;
    let originalFetch;
    beforeEach(() => {
        process.env.PLATFORM_PORT = "12345";
        originalFetch = globalThis.fetch;
        fetchSpy = vi.fn().mockResolvedValue(new Response('{"ok":true}', { status: 200 }));
        globalThis.fetch = fetchSpy;
    });
    afterEach(() => {
        globalThis.fetch = originalFetch;
        delete process.env.PLATFORM_PORT;
    });
    it("emits result=ok exactly once on the happy path and returns the body result", async () => {
        const out = await withWriteEvent({ tool: "memory-write", accountId: "acct-1", labels: ["LocalBusiness"] }, async () => ({ nodeId: "eid-1", labels: ["LocalBusiness"], created: true }), (r) => ({ elementId: r.nodeId, labels: r.labels }));
        expect(out.nodeId).toBe("eid-1");
        expect(fetchSpy).toHaveBeenCalledTimes(1);
        const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
        expect(body.level).toBe("info");
        expect(body.line).toContain("result=ok elementId=eid-1");
    });
    it("emits result=error exactly once on the failure path and re-throws the original error", async () => {
        const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
        try {
            await expect(withWriteEvent({ tool: "memory-write", accountId: "acct-1", labels: ["LocalBusiness"] }, async () => {
                throw new Error("scope is required");
            })).rejects.toThrow(/scope is required/);
            expect(fetchSpy).toHaveBeenCalledTimes(1);
            const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
            expect(body.level).toBe("error");
            expect(body.line).toContain("result=error reason=invalid-input");
            expect(body.line).toContain("detail=scope is required");
        }
        finally {
            stderrSpy.mockRestore();
        }
    });
});
describe("classifyReason (slug mapping is deterministic)", () => {
    it.each([
        ["scope is required", "invalid-input"],
        ["No properties to update — provide at least one property.", "invalid-input"],
        ["All provided properties are restricted and cannot be updated.", "invalid-input"],
        ["Node not found or not owned by this account (ID: 4:abc:1).", "invalid-input"],
        [
            "memory-write: producedByTaskId Task.accountId='a' does not match write accountId='b' (account isolation violation)",
            "account-isolation",
        ],
        [
            "memory-write: producedByTaskId 'x' did not resolve to a :Task node",
            "producedby-task-not-found",
        ],
        [
            "Write blocked (missing-AdminUser): no AdminUser node for this account",
            "gate-blocked",
        ],
        [
            "Update blocked (missing-LocalBusiness): operator has no LocalBusiness yet",
            "gate-blocked",
        ],
        [
            "Node already exists (elementId: 4:abc:1) — property 'name' = 'Smalleys' is unique.",
            "conflicting-element-id",
        ],
        [
            "Unknown label 'Sandwich' not declared in schema.cypher",
            "schema-unknown-label",
        ],
        [
            "property 'snake_case_name' is not Schema.org-conformant",
            "schema-property-naming",
        ],
        [
            "writeNodeWithEdges: targetNodeId '4:xyz:9' did not resolve to a real node",
            "invalid-target-node-id",
        ],
    ])("maps %j → %s", (msg, expected) => {
        expect(classifyReason(new Error(msg))).toBe(expected);
    });
    it("maps Neo4j ConstraintValidationFailed (by code) → constraint-violation", () => {
        const err = Object.assign(new Error("Node(219) already exists"), {
            code: "Neo.ClientError.Schema.ConstraintValidationFailed",
        });
        expect(classifyReason(err)).toBe("constraint-violation");
    });
    it("maps an unknown driver error → neo4j-driver-error (no synthesized unknown slug)", () => {
        expect(classifyReason(new Error("session expired"))).toBe("neo4j-driver-error");
    });
    it("maps a non-Error throw → neo4j-driver-error", () => {
        expect(classifyReason("plain string")).toBe("neo4j-driver-error");
    });
});
//# sourceMappingURL=log-ingest.test.js.map