test(FN-1415): add API tests for agent runs and fix validation behavior

- Add comprehensive test suite for agent runs API endpoints (routes, wrappers)
- Fix run-audit API wrapper tests to match correct validation behavior
- Add test coverage for wake context validation and on-demand run creation
- Update project memory with API test learnings
This commit is contained in:
gsxdsm
2026-04-10 08:27:56 -07:00
parent 97afba2f7e
commit 05bd2f6623
5 changed files with 538 additions and 0 deletions

View File

@@ -789,5 +789,337 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
expect(response.body.auditByDomain.filesystem.length).toBe(1);
expect(response.body.counts.auditEvents).toBe(3);
});
it("returns 400 for blank runId (URL-encoded space)", async () => {
const response = await request(app, "GET", "/api/agents/agent-001/runs/%20/audit");
expect(response.status).toBe(400);
expect(response.body.error).toContain("runId is required");
});
it("returns 400 for whitespace-only runId", async () => {
const response = await request(app, "GET", "/api/agents/agent-001/runs/%09%09/audit");
expect(response.status).toBe(400);
expect(response.body.error).toContain("runId is required");
});
it("returns 404 with exact 'Run not found' message for unknown runId", async () => {
mockGetRunDetail.mockResolvedValue(null);
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-nonexistent/audit");
expect(response.status).toBe(404);
expect(response.body.error).toContain("Run not found");
});
it("handles legacy/partial contextSnapshot (no taskId) without falling back to agent.taskId", async () => {
// Create a run with no contextSnapshot (legacy/partial context)
const mockRun = createMockRun({
id: "run-legacy",
contextSnapshot: undefined, // No contextSnapshot at all
});
mockGetRunDetail.mockResolvedValue(mockRun);
mockGetRunAuditEvents.mockReturnValue([
{
id: "audit-legacy-1",
timestamp: "2026-01-01T00:01:00.000Z",
agentId: "agent-001",
runId: "run-legacy",
domain: "database",
mutationType: "task:update",
target: "FN-001",
taskId: undefined, // No taskId in event either
},
]);
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-legacy/audit");
expect(response.status).toBe(200);
expect(response.body.runId).toBe("run-legacy");
expect(Array.isArray(response.body.events)).toBe(true);
expect(response.body.events.length).toBe(1);
// Verify the event has undefined taskId, not a fallback value
expect(response.body.events[0].taskId).toBeUndefined();
expect(response.body.filters.taskId).toBeUndefined();
});
it("handles empty contextSnapshot object (no taskId field) without falling back to agent.taskId", async () => {
// Create a run with empty contextSnapshot object
const mockRun = createMockRun({
id: "run-empty-context",
contextSnapshot: {}, // Empty object, no taskId
});
mockGetRunDetail.mockResolvedValue(mockRun);
mockGetRunAuditEvents.mockReturnValue([]);
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-empty-context/audit");
expect(response.status).toBe(200);
expect(response.body.runId).toBe("run-empty-context");
expect(response.body.events).toEqual([]);
expect(response.body.filters.taskId).toBeUndefined();
});
it("asserts deterministic ordering for timeline with duplicate timestamps using sortKey", async () => {
const mockRun = createMockRun({
id: "run-dup-ts",
contextSnapshot: { taskId: "FN-001" },
});
mockGetRunDetail.mockResolvedValue(mockRun);
// Create events with IDENTICAL millisecond timestamps but different IDs
// This tests the sortKey tie-breaking logic
const sameTimestamp = "2026-01-01T00:05:00.000Z";
const mockAuditEvents = [
{
id: "audit-d-1",
timestamp: sameTimestamp,
agentId: "agent-001",
runId: "run-dup-ts",
domain: "database",
mutationType: "task:update",
target: "FN-001",
},
{
id: "audit-d-2",
timestamp: sameTimestamp,
agentId: "agent-001",
runId: "run-dup-ts",
domain: "git",
mutationType: "git:commit",
target: "branch-name",
},
{
id: "audit-d-3",
timestamp: sameTimestamp,
agentId: "agent-001",
runId: "run-dup-ts",
domain: "filesystem",
mutationType: "file:write",
target: "src/file.ts",
},
];
mockGetRunAuditEvents.mockReturnValue(mockAuditEvents);
// Run multiple times to verify deterministic ordering
const orderings: string[][] = [];
for (let i = 0; i < 5; i++) {
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-dup-ts/timeline");
expect(response.status).toBe(200);
const ids = response.body.timeline.map((entry: { audit?: { id: string }; log?: unknown }) =>
entry.audit?.id ?? "log"
);
orderings.push(ids);
}
// All orderings should be identical (deterministic)
for (const ordering of orderings) {
expect(ordering).toEqual(orderings[0]);
}
// Verify sortKey-based tie-breaking: audit events with same timestamp should sort by ID
const timeline = orderings[0];
const auditIds = timeline.filter((id: string) => id.startsWith("audit-d-"));
expect(auditIds.length).toBe(3);
// The IDs should be in ascending order based on the sortKey (A_timestamp_id format)
expect(auditIds).toEqual(["audit-d-1", "audit-d-2", "audit-d-3"]);
});
it("traces filesystem/file-change audit records in correlated timeline response", async () => {
const mockRun = createMockRun({
id: "run-fs-trace",
contextSnapshot: { taskId: "FN-001" },
});
mockGetRunDetail.mockResolvedValue(mockRun);
// Filesystem event with metadata for traceability
const mockAuditEvents = [
{
id: "audit-fs-1",
timestamp: "2026-01-01T00:03:00.000Z",
agentId: "agent-001",
runId: "run-fs-trace",
domain: "filesystem",
mutationType: "file:write",
target: "src/task-impl.ts",
metadata: {
filesChanged: 2,
paths: ["src/task-impl.ts", "src/task-impl.test.ts"],
operation: "create",
},
},
{
id: "audit-fs-2",
timestamp: "2026-01-01T00:03:01.000Z",
agentId: "agent-001",
runId: "run-fs-trace",
domain: "filesystem",
mutationType: "file:modify",
target: "src/task-impl.ts",
metadata: {
filesChanged: 1,
paths: ["src/task-impl.ts"],
operation: "modify",
},
},
];
mockGetRunAuditEvents.mockReturnValue(mockAuditEvents);
store.getAgentLogsByTimeRange = vi.fn().mockResolvedValue([]);
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-fs-trace/timeline");
expect(response.status).toBe(200);
// Verify filesystem domain grouping
expect(response.body.auditByDomain.filesystem.length).toBe(2);
// Verify traceability: each filesystem event should have runId and domain in response
const fsEntries = response.body.timeline.filter(
(entry: { type: string; audit?: { domain: string; runId: string } }) =>
entry.type === "audit" && entry.audit?.domain === "filesystem"
);
expect(fsEntries.length).toBe(2);
// Verify metadata is preserved in the response
const fsEntry1 = fsEntries.find(
(entry: { audit?: { id: string } }) => entry.audit?.id === "audit-fs-1"
);
expect(fsEntry1).toBeDefined();
expect(fsEntry1.audit.metadata).toEqual({
filesChanged: 2,
paths: ["src/task-impl.ts", "src/task-impl.test.ts"],
operation: "create",
});
// Verify runId correlation is present
expect(fsEntry1.audit.domain).toBe("filesystem");
// Verify timeline ordering (filesystem events should be in correct order)
expect(fsEntries[0].audit.id).toBe("audit-fs-1");
expect(fsEntries[1].audit.id).toBe("audit-fs-2");
});
it("normalizes filesystem events with file-change mutation type", async () => {
const mockRun = createMockRun({ id: "run-file-change" });
mockGetRunDetail.mockResolvedValue(mockRun);
const mockAuditEvents = [
{
id: "audit-filechange-1",
timestamp: "2026-01-01T00:04:00.000Z",
agentId: "agent-001",
runId: "run-file-change",
domain: "filesystem",
mutationType: "file:change",
target: "src/main.ts",
taskId: "FN-001",
metadata: {
filesChanged: 5,
paths: ["src/a.ts", "src/b.ts", "src/c.ts", "src/d.ts", "src/e.ts"],
operation: "batch-modify",
},
},
];
mockGetRunAuditEvents.mockReturnValue(mockAuditEvents);
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-file-change/audit");
expect(response.status).toBe(200);
expect(response.body.events.length).toBe(1);
expect(response.body.events[0].domain).toBe("filesystem");
expect(response.body.events[0].mutationType).toBe("file:change");
expect(response.body.events[0].metadata).toEqual({
filesChanged: 5,
paths: ["src/a.ts", "src/b.ts", "src/c.ts", "src/d.ts", "src/e.ts"],
operation: "batch-modify",
});
// Summary should include filesystem domain prefix
expect(response.body.events[0].summary).toContain("FS");
expect(response.body.events[0].summary).toContain("change");
});
});
// ── Timeline endpoint additional tests ──────────────────────────────────
describe("GET /api/agents/:id/runs/:runId/timeline (extended)", () => {
it("returns 400 for blank runId (URL-encoded space)", async () => {
const response = await request(app, "GET", "/api/agents/agent-001/runs/%20/timeline");
expect(response.status).toBe(400);
expect(response.body.error).toContain("runId is required");
});
it("returns 404 with exact 'Run not found' message for unknown runId", async () => {
mockGetRunDetail.mockResolvedValue(null);
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-nonexistent/timeline");
expect(response.status).toBe(404);
expect(response.body.error).toContain("Run not found");
});
it("handles legacy/partial contextSnapshot (no taskId) without falling back to agent.taskId", async () => {
// Create a run with no contextSnapshot - timeline should still work
const mockRun = createMockRun({
id: "run-legacy-tl",
contextSnapshot: undefined,
});
mockGetRunDetail.mockResolvedValue(mockRun);
mockGetRunAuditEvents.mockReturnValue([]);
store.getAgentLogsByTimeRange = vi.fn().mockResolvedValue([]);
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-legacy-tl/timeline");
expect(response.status).toBe(200);
expect(response.body.run.id).toBe("run-legacy-tl");
// taskId should be undefined (no fallback to current agent state)
expect(response.body.run.taskId).toBeUndefined();
expect(response.body.timeline).toEqual([]);
});
it("verifies timeline sortKey tie-breaking with mixed audit and log entries at same timestamp", async () => {
const sameTimestamp = "2026-01-01T00:06:00.000Z";
const mockRun = createMockRun({
id: "run-mixed-ts",
startedAt: sameTimestamp,
contextSnapshot: { taskId: "FN-001" },
});
mockGetRunDetail.mockResolvedValue(mockRun);
// Audit event at timestamp
const mockAuditEvents = [
{
id: "audit-mixed-a",
timestamp: sameTimestamp,
agentId: "agent-001",
runId: "run-mixed-ts",
domain: "database",
mutationType: "task:update",
target: "FN-001",
},
];
mockGetRunAuditEvents.mockReturnValue(mockAuditEvents);
// Log entry at EXACT same timestamp (using timestamp as unique key)
store.getAgentLogsByTimeRange = vi.fn().mockResolvedValue([
{ timestamp: sameTimestamp, type: "info", message: "Log at exact same time" },
]);
const response = await request(app, "GET", "/api/agents/agent-001/runs/run-mixed-ts/timeline");
expect(response.status).toBe(200);
expect(response.body.timeline.length).toBe(2);
// Verify deterministic ordering: audit (A prefix) should come before log (L prefix)
// SortKey format: "A_timestamp_id" vs "L_timestamp_timestamp"
// A < L alphabetically, so audit comes first at same timestamp
expect(response.body.timeline[0].type).toBe("audit");
expect(response.body.timeline[1].type).toBe("log");
// Run multiple times to verify determinism
for (let i = 0; i < 3; i++) {
const response2 = await request(app, "GET", "/api/agents/agent-001/runs/run-mixed-ts/timeline");
expect(response2.body.timeline[0].type).toBe("audit");
expect(response2.body.timeline[1].type).toBe("log");
}
});
});
});