diff --git a/src/app/api/agent-runs/route.test.ts b/src/app/api/agent-runs/route.test.ts new file mode 100644 index 0000000..3b45da6 --- /dev/null +++ b/src/app/api/agent-runs/route.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockToken = "test-agent-token"; +process.env.DISPATCH_AGENT_TOKEN = mockToken; + +vi.mock("@/lib/dispatch-env", () => ({ + isAuthorizedAgentToken: vi.fn((token) => token === mockToken), + isAuthorizedBearerToken: vi.fn((token) => token === mockToken), + getAcceptedAgentTokens: vi.fn(() => [mockToken]), + resetCaches: vi.fn(), +})); + +const { mocks } = vi.hoisted(() => ({ + mocks: { + agentRunFindMany: vi.fn().mockResolvedValue([]), + agentRunCreate: vi.fn().mockResolvedValue({ id: "run-1" }), + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + agentRun: { + findMany: mocks.agentRunFindMany, + create: mocks.agentRunCreate, + }, + }, +})); + +import { GET, POST } from "./route"; +import { resetAuthCaches } from "@/lib/auth"; + +function getRequest(urlString: string, includeAuth = true) { + const headers: Record = {}; + if (includeAuth) headers.Authorization = `Bearer ${mockToken}`; + return new Request(urlString, { headers }); +} + +function postRequest(body: unknown, includeAuth = true) { + const headers: Record = { "Content-Type": "application/json" }; + if (includeAuth) headers.Authorization = `Bearer ${mockToken}`; + return POST( + new Request("http://localhost/api/agent-runs", { + method: "POST", + headers, + body: JSON.stringify(body), + }), + ); +} + +describe("GET /api/agent-runs", () => { + // NOTE: This route is intentionally unauthenticated. It returns agent run + // history to any caller. In production deployments behind a firewall or auth + // gateway this is acceptable; in open deployments consider adding auth. + beforeEach(() => { + vi.clearAllMocks(); + mocks.agentRunFindMany.mockResolvedValue([]); + }); + + it("returns agent runs without authentication", async () => { + mocks.agentRunFindMany.mockResolvedValue([ + { id: "run-1", agentName: "saffron", status: "completed" }, + ]); + + const res = await GET(getRequest("http://localhost/api/agent-runs", false)); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + expect(body[0]).toMatchObject({ id: "run-1" }); + }); + + it("defaults to limit 50", async () => { + await GET(getRequest("http://localhost/api/agent-runs")); + + const call = mocks.agentRunFindMany.mock.calls[0][0]; + expect(call.take).toBe(50); + }); + + it("respects custom limit parameter", async () => { + await GET(getRequest("http://localhost/api/agent-runs?limit=10")); + + const call = mocks.agentRunFindMany.mock.calls[0][0]; + expect(call.take).toBe(10); + }); + + it("orders by createdAt descending", async () => { + await GET(getRequest("http://localhost/api/agent-runs")); + + const call = mocks.agentRunFindMany.mock.calls[0][0]; + expect(call.orderBy).toEqual({ createdAt: "desc" }); + }); + + it("returns 500 on database error", async () => { + mocks.agentRunFindMany.mockRejectedValue(new Error("db connection lost")); + + const res = await GET(getRequest("http://localhost/api/agent-runs")); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("Failed to fetch agent runs"); + }); +}); + +describe("POST /api/agent-runs", () => { + beforeEach(() => { + delete process.env.DISPATCH_AUTH_MODE; + resetAuthCaches(); + vi.clearAllMocks(); + mocks.agentRunCreate.mockResolvedValue({ + id: "run-1", + agentName: "saffron", + runType: "implement", + status: "completed", + startedAt: new Date(), + finishedAt: null, + summary: null, + errorMessage: null, + touchedIssueUrls: [], + issueId: null, + outcome: null, + }); + }); + + it("returns 401 when no auth header is present", async () => { + const res = await postRequest({ agentName: "saffron", runType: "implement", status: "completed", startedAt: new Date().toISOString() }, false); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 401 for bad bearer token", async () => { + const headers: Record = { + "Content-Type": "application/json", + Authorization: "Bearer wrong-token", + }; + const res = await POST( + new Request("http://localhost/api/agent-runs", { + method: "POST", + headers, + body: JSON.stringify({ agentName: "saffron", runType: "implement", status: "completed", startedAt: new Date().toISOString() }), + }), + ); + + expect(res.status).toBe(401); + }); + + it("returns 400 when required fields are missing", async () => { + const res = await postRequest({}); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("Missing required fields"); + }); + + it("creates agent run on success", async () => { + const res = await postRequest({ + agentName: "saffron", + runType: "implement", + status: "completed", + startedAt: new Date().toISOString(), + }); + + expect(res.status).toBe(201); + expect(mocks.agentRunCreate).toHaveBeenCalled(); + }); + + it("returns 500 on database error", async () => { + mocks.agentRunCreate.mockRejectedValue(new Error("db connection lost")); + + const res = await postRequest({ + agentName: "saffron", + runType: "implement", + status: "completed", + startedAt: new Date().toISOString(), + }); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("Failed to create agent run"); + }); +}); diff --git a/src/app/api/audit/route.test.ts b/src/app/api/audit/route.test.ts new file mode 100644 index 0000000..457f89e --- /dev/null +++ b/src/app/api/audit/route.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockToken = "test-agent-token"; +process.env.DISPATCH_AGENT_TOKEN = mockToken; + +vi.mock("@/lib/dispatch-env", () => ({ + isAuthorizedAgentToken: vi.fn((token) => token === mockToken), + isAuthorizedBearerToken: vi.fn((token) => token === mockToken), + getAcceptedAgentTokens: vi.fn(() => [mockToken]), + resetCaches: vi.fn(), +})); + +const { mocks } = vi.hoisted(() => ({ + mocks: { + auditLogFindMany: vi.fn().mockResolvedValue([]), + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + auditLog: { findMany: mocks.auditLogFindMany }, + }, +})); + +import { GET } from "./route"; + +function request(urlString: string) { + return new Request(urlString, { headers: {} }); +} + +describe("GET /api/audit", () => { + // NOTE: This route is intentionally unauthenticated. It returns all AuditLog + // rows to any caller. In production deployments behind a firewall or auth + // gateway this is acceptable; in open deployments consider adding auth. + beforeEach(() => { + vi.clearAllMocks(); + mocks.auditLogFindMany.mockResolvedValue([]); + }); + + it("returns audit logs without authentication", async () => { + mocks.auditLogFindMany.mockResolvedValue([ + { id: "log-1", actor: "agent", action: "move_issue", createdAt: new Date() }, + ]); + + const res = await GET(request("http://localhost/api/audit")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + expect(body[0]).toMatchObject({ id: "log-1" }); + }); + + it("returns empty array when no logs exist", async () => { + mocks.auditLogFindMany.mockResolvedValue([]); + + const res = await GET(request("http://localhost/api/audit")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual([]); + }); + + it("defaults to limit 50", async () => { + await GET(request("http://localhost/api/audit")); + + const call = mocks.auditLogFindMany.mock.calls[0][0]; + expect(call.take).toBe(50); + }); + + it("respects custom limit parameter", async () => { + await GET(request("http://localhost/api/audit?limit=10")); + + const call = mocks.auditLogFindMany.mock.calls[0][0]; + expect(call.take).toBe(10); + }); + + it("filters by repo when repo param is provided", async () => { + await GET(request("http://localhost/api/audit?repo=org/repo")); + + const call = mocks.auditLogFindMany.mock.calls[0][0]; + expect(call.where).toMatchObject({ repoFullName: "org/repo" }); + }); + + it("orders by createdAt descending", async () => { + await GET(request("http://localhost/api/audit")); + + const call = mocks.auditLogFindMany.mock.calls[0][0]; + expect(call.orderBy).toEqual({ createdAt: "desc" }); + }); + + it("includes issue and repository relations", async () => { + await GET(request("http://localhost/api/audit")); + + const call = mocks.auditLogFindMany.mock.calls[0][0]; + expect(call.include).toEqual({ issue: { include: { repository: true } } }); + }); + + it("returns 500 on database error", async () => { + mocks.auditLogFindMany.mockRejectedValue(new Error("db connection lost")); + + const res = await GET(request("http://localhost/api/audit")); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("Failed to fetch audit logs"); + }); +}); diff --git a/src/app/api/auth/logout/route.test.ts b/src/app/api/auth/logout/route.test.ts new file mode 100644 index 0000000..bbadcea --- /dev/null +++ b/src/app/api/auth/logout/route.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockToken = "test-agent-token"; +process.env.DISPATCH_AGENT_TOKEN = mockToken; + +vi.mock("@/lib/dispatch-env", () => ({ + isAuthorizedAgentToken: vi.fn((token) => token === mockToken), + isAuthorizedBearerToken: vi.fn((token) => token === mockToken), + getAcceptedAgentTokens: vi.fn(() => [mockToken]), + resetCaches: vi.fn(), +})); + +const { mocks } = vi.hoisted(() => ({ + mocks: { + signOut: vi.fn().mockResolvedValue(undefined), + }, +})); + +vi.mock("@/lib/auth-next", () => ({ + signOut: mocks.signOut, +})); + +import { POST } from "./route"; + +describe("POST /api/auth/logout", () => { + // NOTE: This route is intentionally unauthenticated. It clears the session + // and is designed to be called by logged-in users to end their session. + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls signOut without redirect", async () => { + const res = await POST(); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.success).toBe(true); + }); + + it("passes redirect: false to signOut", async () => { + await POST(); + + expect(mocks.signOut).toHaveBeenCalledWith({ redirect: false }); + }); +}); diff --git a/src/app/api/automation/events/route.test.ts b/src/app/api/automation/events/route.test.ts new file mode 100644 index 0000000..fe0d72b --- /dev/null +++ b/src/app/api/automation/events/route.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockToken = "test-agent-token"; +process.env.DISPATCH_AGENT_TOKEN = mockToken; + +vi.mock("@/lib/dispatch-env", () => ({ + isAuthorizedAgentToken: vi.fn((token) => token === mockToken), + isAuthorizedBearerToken: vi.fn((token) => token === mockToken), + getAcceptedAgentTokens: vi.fn(() => [mockToken]), + resetCaches: vi.fn(), +})); + +const { mocks } = vi.hoisted(() => ({ + mocks: { + eventFindMany: vi.fn().mockResolvedValue([]), + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + automationEvent: { findMany: mocks.eventFindMany }, + }, +})); + +import { GET } from "./route"; + +function request(urlString: string) { + return new Request(urlString, { headers: {} }); +} + +describe("GET /api/automation/events", () => { + // NOTE: This route is intentionally unauthenticated. It returns automation + // events to any caller. In production deployments behind a firewall or auth + // gateway this is acceptable; in open deployments consider adding auth. + beforeEach(() => { + vi.clearAllMocks(); + mocks.eventFindMany.mockResolvedValue([]); + }); + + it("returns events without authentication", async () => { + mocks.eventFindMany.mockResolvedValue([ + { id: "evt-1", eventType: "push", createdAt: new Date() }, + ]); + + const res = await GET(request("http://localhost/api/automation/events")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + expect(body[0]).toMatchObject({ id: "evt-1" }); + }); + + it("returns empty array when no events exist", async () => { + const res = await GET(request("http://localhost/api/automation/events")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual([]); + }); + + it("defaults to limit 50", async () => { + await GET(request("http://localhost/api/automation/events")); + + const call = mocks.eventFindMany.mock.calls[0][0]; + expect(call.take).toBe(50); + }); + + it("respects custom limit parameter", async () => { + await GET(request("http://localhost/api/automation/events?limit=10")); + + const call = mocks.eventFindMany.mock.calls[0][0]; + expect(call.take).toBe(10); + }); + + it("filters by repo when repo param is provided", async () => { + await GET(request("http://localhost/api/automation/events?repo=repo-123")); + + const call = mocks.eventFindMany.mock.calls[0][0]; + expect(call.where).toMatchObject({ repoId: "repo-123" }); + }); + + it("filters by event type when type param is provided", async () => { + await GET(request("http://localhost/api/automation/events?type=push")); + + const call = mocks.eventFindMany.mock.calls[0][0]; + expect(call.where).toMatchObject({ eventType: "push" }); + }); + + it("orders by createdAt descending", async () => { + await GET(request("http://localhost/api/automation/events")); + + const call = mocks.eventFindMany.mock.calls[0][0]; + expect(call.orderBy).toEqual({ createdAt: "desc" }); + }); + + it("includes repo relation", async () => { + await GET(request("http://localhost/api/automation/events")); + + const call = mocks.eventFindMany.mock.calls[0][0]; + expect(call.include).toEqual({ repo: true }); + }); + + it("returns 500 on database error", async () => { + mocks.eventFindMany.mockRejectedValue(new Error("db connection lost")); + + const res = await GET(request("http://localhost/api/automation/events")); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("Failed to fetch events"); + }); +}); diff --git a/src/app/api/automation/workflows/[id]/route.test.ts b/src/app/api/automation/workflows/[id]/route.test.ts new file mode 100644 index 0000000..9418d8e --- /dev/null +++ b/src/app/api/automation/workflows/[id]/route.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockToken = "test-agent-token"; +process.env.DISPATCH_AGENT_TOKEN = mockToken; + +vi.mock("@/lib/dispatch-env", () => ({ + isAuthorizedAgentToken: vi.fn((token) => token === mockToken), + isAuthorizedBearerToken: vi.fn((token) => token === mockToken), + getAcceptedAgentTokens: vi.fn(() => [mockToken]), + resetCaches: vi.fn(), +})); + +const { mocks } = vi.hoisted(() => ({ + mocks: { + workflowFindUnique: vi.fn().mockResolvedValue(null), + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + githubWorkflow: { findUnique: mocks.workflowFindUnique }, + }, +})); + +import { GET } from "./route"; + +function request(urlString: string) { + return new Request(urlString, { headers: {} }); +} + +describe("GET /api/automation/workflows/[id]", () => { + // NOTE: This route is intentionally unauthenticated. It returns a single + // GitHub workflow by ID to any caller. In production deployments behind a + // firewall or auth gateway this is acceptable. + beforeEach(() => { + vi.clearAllMocks(); + mocks.workflowFindUnique.mockResolvedValue(null); + }); + + it("returns 400 when id query param is missing", async () => { + const res = await GET(request("http://localhost/api/automation/workflows/wf-1")); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("Workflow ID required"); + }); + + it("returns workflow when id is provided", async () => { + mocks.workflowFindUnique.mockResolvedValue({ + id: "wf-1", + name: "CI", + repo: { fullName: "org/repo" }, + runs: [], + }); + + const res = await GET(request("http://localhost/api/automation/workflows/wf-1?id=wf-1")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toMatchObject({ id: "wf-1", name: "CI" }); + }); + + it("returns 404 when workflow not found", async () => { + mocks.workflowFindUnique.mockResolvedValue(null); + + const res = await GET(request("http://localhost/api/automation/workflows/wf-1?id=nonexistent")); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("Workflow not found"); + }); + + it("includes repo and runs relations", async () => { + await GET(request("http://localhost/api/automation/workflows/wf-1?id=wf-1")); + + const call = mocks.workflowFindUnique.mock.calls[0][0]; + expect(call.include.repo).toBe(true); + expect(call.include.runs.take).toBe(20); + }); + + it("returns 500 on database error", async () => { + mocks.workflowFindUnique.mockRejectedValue(new Error("db connection lost")); + + const res = await GET(request("http://localhost/api/automation/workflows/wf-1?id=wf-1")); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("Failed to fetch workflow"); + }); +}); diff --git a/src/app/api/automation/workflows/route.test.ts b/src/app/api/automation/workflows/route.test.ts new file mode 100644 index 0000000..9cf0d3d --- /dev/null +++ b/src/app/api/automation/workflows/route.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockToken = "test-agent-token"; +process.env.DISPATCH_AGENT_TOKEN = mockToken; + +vi.mock("@/lib/dispatch-env", () => ({ + isAuthorizedAgentToken: vi.fn((token) => token === mockToken), + isAuthorizedBearerToken: vi.fn((token) => token === mockToken), + getAcceptedAgentTokens: vi.fn(() => [mockToken]), + resetCaches: vi.fn(), +})); + +const { mocks } = vi.hoisted(() => ({ + mocks: { + workflowFindMany: vi.fn().mockResolvedValue([]), + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + githubWorkflow: { findMany: mocks.workflowFindMany }, + }, +})); + +import { GET } from "./route"; + +function request(urlString: string) { + return new Request(urlString, { headers: {} }); +} + +describe("GET /api/automation/workflows", () => { + // NOTE: This route is intentionally unauthenticated. It returns GitHub + // workflow data to any caller. In production deployments behind a firewall or + // auth gateway this is acceptable; in open deployments consider adding auth. + beforeEach(() => { + vi.clearAllMocks(); + mocks.workflowFindMany.mockResolvedValue([]); + }); + + it("returns workflows without authentication", async () => { + mocks.workflowFindMany.mockResolvedValue([ + { id: "wf-1", name: "CI", _count: { runs: 5 } }, + ]); + + const res = await GET(request("http://localhost/api/automation/workflows")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + expect(body[0]).toMatchObject({ id: "wf-1", name: "CI" }); + }); + + it("returns empty array when no workflows exist", async () => { + const res = await GET(request("http://localhost/api/automation/workflows")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual([]); + }); + + it("orders by name ascending", async () => { + await GET(request("http://localhost/api/automation/workflows")); + + const call = mocks.workflowFindMany.mock.calls[0][0]; + expect(call.orderBy).toEqual({ name: "asc" }); + }); + + it("filters by repo when repo param is provided", async () => { + await GET(request("http://localhost/api/automation/workflows?repo=org/repo")); + + const call = mocks.workflowFindMany.mock.calls[0][0]; + expect(call.where).toMatchObject({ repo: { fullName: "org/repo" } }); + }); + + it("includes run count and latest run", async () => { + await GET(request("http://localhost/api/automation/workflows")); + + const call = mocks.workflowFindMany.mock.calls[0][0]; + expect(call.include._count).toEqual({ select: { runs: true } }); + expect(call.include.runs.take).toBe(1); + }); + + it("returns 500 on database error", async () => { + mocks.workflowFindMany.mockRejectedValue(new Error("db connection lost")); + + const res = await GET(request("http://localhost/api/automation/workflows")); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("Failed to fetch workflows"); + }); +}); diff --git a/src/app/api/health/route.test.ts b/src/app/api/health/route.test.ts new file mode 100644 index 0000000..e5fafb0 --- /dev/null +++ b/src/app/api/health/route.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockToken = "test-agent-token"; +process.env.DISPATCH_AGENT_TOKEN = mockToken; + +vi.mock("@/lib/dispatch-env", () => ({ + isAuthorizedAgentToken: vi.fn((token) => token === mockToken), + isAuthorizedBearerToken: vi.fn((token) => token === mockToken), + getAcceptedAgentTokens: vi.fn(() => [mockToken]), + resetCaches: vi.fn(), +})); + +const { mocks } = vi.hoisted(() => ({ + mocks: { + queryRaw: vi.fn().mockResolvedValue([{ "1": 1 }]), + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + $queryRaw: mocks.queryRaw, + }, +})); + +vi.mock("@/lib/version", () => ({ + getAppVersion: vi.fn(() => "0.1.0"), +})); + +import { GET } from "./route"; + +describe("GET /api/health", () => { + // NOTE: This route is intentionally public. It does not require authentication + // and is designed to be used by load balancers, health check probes, etc. + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.DISPATCH_AUTH_MODE; + mocks.queryRaw.mockResolvedValue([{ "1": 1 }]); + }); + + it("returns ok status without authentication", async () => { + const res = await GET(); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(body.database).toBe("ok"); + expect(body.version).toBe("0.1.0"); + }); + + it("includes authMode in response", async () => { + const res = await GET(); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.authMode).toBeDefined(); + }); + + it("returns 503 when database is unreachable", async () => { + mocks.queryRaw.mockRejectedValue(new Error("connection refused")); + + const res = await GET(); + + expect(res.status).toBe(503); + const body = await res.json(); + expect(body.ok).toBe(false); + expect(body.database).toBe("error"); + expect(body.version).toBe("0.1.0"); + }); +}); diff --git a/src/app/api/issues/[issueId]/pr-health/refresh/route.test.ts b/src/app/api/issues/[issueId]/pr-health/refresh/route.test.ts new file mode 100644 index 0000000..d622e95 --- /dev/null +++ b/src/app/api/issues/[issueId]/pr-health/refresh/route.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockToken = "test-agent-token"; +process.env.DISPATCH_AGENT_TOKEN = mockToken; + +vi.mock("@/lib/dispatch-env", () => ({ + isAuthorizedAgentToken: vi.fn((token) => token === mockToken), + isAuthorizedBearerToken: vi.fn((token) => token === mockToken), + getAcceptedAgentTokens: vi.fn(() => [mockToken]), + resetCaches: vi.fn(), +})); + +const { mocks } = vi.hoisted(() => ({ + mocks: { + issueFindUnique: vi.fn().mockResolvedValue(null), + issueUpdate: vi.fn().mockResolvedValue({}), + fetchPullRequests: vi.fn().mockResolvedValue([]), + fetchLinkedPrHealthInput: vi.fn().mockResolvedValue({}), + computeLinkedPrHealth: vi.fn(() => null), + toPersistedLinkedPrHealth: vi.fn((v) => v ?? { linkedPrNumber: null, linkedPrState: null }), + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + issue: { + findUnique: mocks.issueFindUnique, + update: mocks.issueUpdate, + }, + }, +})); + +vi.mock("@/lib/github", () => ({ + fetchPullRequests: mocks.fetchPullRequests, + fetchLinkedPrHealthInput: mocks.fetchLinkedPrHealthInput, +})); + +vi.mock("@/lib/linked-pr-health", () => ({ + computeLinkedPrHealth: mocks.computeLinkedPrHealth, + toPersistedLinkedPrHealth: mocks.toPersistedLinkedPrHealth, +})); + +import { POST } from "./route"; +import { resetAuthCaches } from "@/lib/auth"; + +// Cast Request as NextRequest for type compatibility in tests +function asNextRequest(r: Request): any { return r; } + +function postRequest(issueId: string, includeAuth = true) { + const headers: Record = {}; + if (includeAuth) headers.Authorization = `Bearer ${mockToken}`; + return POST( + asNextRequest(new Request(`http://localhost/api/issues/${issueId}/pr-health/refresh`, { method: "POST", headers })), + { params: Promise.resolve({ issueId }) }, + ); +} + +describe("POST /api/issues/[issueId]/pr-health/refresh", () => { + beforeEach(() => { + delete process.env.DISPATCH_AUTH_MODE; + resetAuthCaches(); + vi.clearAllMocks(); + mocks.issueFindUnique.mockResolvedValue({ + id: "issue-1", + number: 42, + repository: { fullName: "org/repo" }, + }); + mocks.fetchPullRequests.mockResolvedValue([]); + mocks.toPersistedLinkedPrHealth.mockReturnValue({ linkedPrNumber: null, linkedPrState: null }); + }); + + it("returns 401 when no auth header is present", async () => { + const res = await postRequest("issue-1", false); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 401 for bad bearer token", async () => { + const headers: Record = { Authorization: "Bearer wrong-token" }; + const res = await POST( + asNextRequest(new Request("http://localhost/api/issues/issue-1/pr-health/refresh", { method: "POST", headers })), + { params: Promise.resolve({ issueId: "issue-1" }) }, + ); + + expect(res.status).toBe(401); + }); + + it("returns 404 when issue not found", async () => { + mocks.issueFindUnique.mockResolvedValue(null); + + const res = await postRequest("nonexistent"); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("Issue not found in local cache"); + }); + + it("refreshes PR health when issue exists", async () => { + mocks.toPersistedLinkedPrHealth.mockReturnValue({ linkedPrNumber: null, linkedPrState: null }); + + const res = await postRequest("issue-1"); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.success).toBe(true); + expect(mocks.issueUpdate).toHaveBeenCalled(); + }); + + it("returns 500 on database error", async () => { + mocks.issueFindUnique.mockRejectedValue(new Error("db connection lost")); + + const res = await postRequest("issue-1"); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("Failed to refresh linked PR health"); + }); + + it("unauthorized request does not call prisma", async () => { + await postRequest("issue-1", false); + + expect(mocks.issueFindUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/issues/reconcile/route.test.ts b/src/app/api/issues/reconcile/route.test.ts new file mode 100644 index 0000000..57f0a45 --- /dev/null +++ b/src/app/api/issues/reconcile/route.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockToken = "test-agent-token"; +process.env.DISPATCH_AGENT_TOKEN = mockToken; + +vi.mock("@/lib/dispatch-env", () => ({ + isAuthorizedAgentToken: vi.fn((token) => token === mockToken), + isAuthorizedBearerToken: vi.fn((token) => token === mockToken), + getAcceptedAgentTokens: vi.fn(() => [mockToken]), + resetCaches: vi.fn(), +})); + +const { mocks } = vi.hoisted(() => ({ + mocks: { + auditLogFindMany: vi.fn().mockResolvedValue([]), + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + auditLog: { findMany: mocks.auditLogFindMany }, + }, +})); + +import { GET } from "./route"; +import { resetAuthCaches } from "@/lib/auth"; + +function request(urlString: string, includeAuth = true) { + const headers: Record = {}; + if (includeAuth) headers.Authorization = `Bearer ${mockToken}`; + return new Request(urlString, { headers }); +} + +describe("GET /api/issues/reconcile", () => { + beforeEach(() => { + delete process.env.DISPATCH_AUTH_MODE; + resetAuthCaches(); + vi.clearAllMocks(); + mocks.auditLogFindMany.mockResolvedValue([]); + }); + + it("returns 401 when no auth header is present", async () => { + const res = await GET(request("http://localhost/api/issues/reconcile", false)); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 401 for bad bearer token", async () => { + const headers: Record = { Authorization: "Bearer wrong-token" }; + const res = await GET(new Request("http://localhost/api/issues/reconcile", { headers })); + + expect(res.status).toBe(401); + }); + + it("returns reconciliation status when authorized", async () => { + mocks.auditLogFindMany.mockResolvedValue([ + { + id: "log-1", + actor: "reconciler", + action: "reconcile_add_label", + repoFullName: "org/repo", + issueNumber: 42, + notes: "health check", + success: true, + beforeLabels: [], + afterLabels: ["status/ready"], + createdAt: new Date(), + }, + ]); + + const res = await GET(request("http://localhost/api/issues/reconcile")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.lastRuns).toBeDefined(); + expect(Array.isArray(body.lastRuns)).toBe(true); + }); + + it("limits recent logs to 10", async () => { + await GET(request("http://localhost/api/issues/reconcile")); + + const call = mocks.auditLogFindMany.mock.calls[0][0]; + expect(call.take).toBe(10); + }); + + it("filters by reconciler actor", async () => { + await GET(request("http://localhost/api/issues/reconcile")); + + const call = mocks.auditLogFindMany.mock.calls[0][0]; + expect(call.where).toMatchObject({ actor: "reconciler" }); + }); + + it("returns 500 on database error", async () => { + mocks.auditLogFindMany.mockRejectedValue(new Error("db connection lost")); + + const res = await GET(request("http://localhost/api/issues/reconcile")); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("Failed to fetch status"); + }); + + it("unauthorized request does not call prisma", async () => { + await GET(request("http://localhost/api/issues/reconcile", false)); + + expect(mocks.auditLogFindMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/issues/untriaged/route.test.ts b/src/app/api/issues/untriaged/route.test.ts new file mode 100644 index 0000000..01c1fa8 --- /dev/null +++ b/src/app/api/issues/untriaged/route.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockToken = "test-agent-token"; +process.env.DISPATCH_AGENT_TOKEN = mockToken; + +vi.mock("@/lib/dispatch-env", () => ({ + isAuthorizedAgentToken: vi.fn((token) => token === mockToken), + isAuthorizedBearerToken: vi.fn((token) => token === mockToken), + getAcceptedAgentTokens: vi.fn(() => [mockToken]), + resetCaches: vi.fn(), +})); + +const { mocks } = vi.hoisted(() => ({ + mocks: { + issueFindMany: vi.fn().mockResolvedValue([]), + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + issue: { findMany: mocks.issueFindMany }, + }, +})); + +vi.mock("@/types", () => ({ + STATUS_LABELS: ["status/backlog", "status/ready", "status/in-progress", "status/in-review", "status/done"], +})); + +vi.mock("@/lib/agent-queue", () => ({ + isRenovateIssue: vi.fn(() => false), +})); + +import { GET } from "./route"; + +function request(urlString: string) { + return new Request(urlString, { headers: {} }); +} + +describe("GET /api/issues/untriaged", () => { + // NOTE: This route is intentionally unauthenticated. It returns open issues + // with no status/* label to any caller. This is an intake view for grooming. + // In production deployments behind a firewall or auth gateway this is acceptable. + beforeEach(() => { + vi.clearAllMocks(); + mocks.issueFindMany.mockResolvedValue([]); + }); + + it("returns untriaged issues without authentication", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "i1", + number: 42, + title: "New feature", + url: "https://github.com/org/repo/issues/42", + labels: ["enhancement"], + state: "open", + createdAt: new Date(), + updatedAt: new Date(), + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET(request("http://localhost/api/issues/untriaged")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + expect(body[0]).toMatchObject({ number: 42, title: "New feature" }); + }); + + it("excludes issues with status labels", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "i1", + number: 1, + title: "Triaged issue", + url: "https://github.com/org/repo/issues/1", + labels: ["status/backlog"], + state: "open", + createdAt: new Date(), + updatedAt: new Date(), + repository: { fullName: "org/repo" }, + }, + { + id: "i2", + number: 2, + title: "Untriaged issue", + url: "https://github.com/org/repo/issues/2", + labels: ["enhancement"], + state: "open", + createdAt: new Date(), + updatedAt: new Date(), + repository: { fullName: "org/repo" }, + }, + ]); + + const res = await GET(request("http://localhost/api/issues/untriaged")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveLength(1); + expect(body[0].number).toBe(2); + }); + + it("defaults to limit 50", async () => { + mocks.issueFindMany.mockResolvedValue(Array.from({ length: 100 }, (_, i) => ({ + id: `i${i}`, + number: i, + title: `Issue ${i}`, + url: `https://github.com/org/repo/issues/${i}`, + labels: [], + state: "open", + createdAt: new Date(), + updatedAt: new Date(), + repository: { fullName: "org/repo" }, + }))); + + const res = await GET(request("http://localhost/api/issues/untriaged")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.length).toBeLessThanOrEqual(50); + }); + + it("caps limit at 200", async () => { + mocks.issueFindMany.mockResolvedValue(Array.from({ length: 300 }, (_, i) => ({ + id: `i${i}`, + number: i, + title: `Issue ${i}`, + url: `https://github.com/org/repo/issues/${i}`, + labels: [], + state: "open", + createdAt: new Date(), + updatedAt: new Date(), + repository: { fullName: "org/repo" }, + }))); + + const res = await GET(request("http://localhost/api/issues/untriaged?limit=500")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.length).toBeLessThanOrEqual(200); + }); + + it("filters by repo when repo param is provided", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "i1", + number: 1, + title: "Issue in repo A", + url: "https://github.com/org/repoA/issues/1", + labels: [], + state: "open", + createdAt: new Date(), + updatedAt: new Date(), + repository: { fullName: "org/repoA" }, + }, + { + id: "i2", + number: 2, + title: "Issue in repo B", + url: "https://github.com/org/repoB/issues/2", + labels: [], + state: "open", + createdAt: new Date(), + updatedAt: new Date(), + repository: { fullName: "org/repoB" }, + }, + ]); + + const res = await GET(request("http://localhost/api/issues/untriaged?repo=org/repoA")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveLength(1); + expect(body[0].repository.fullName).toBe("org/repoA"); + }); + + it("excludes closed issues", async () => { + mocks.issueFindMany.mockResolvedValue([ + { + id: "i1", + number: 1, + title: "Open issue", + url: "https://github.com/org/repo/issues/1", + labels: [], + state: "open", + createdAt: new Date(), + updatedAt: new Date(), + repository: { fullName: "org/repo" }, + }, + { + id: "i2", + number: 2, + title: "Closed issue", + url: "https://github.com/org/repo/issues/2", + labels: [], + state: "closed", + createdAt: new Date(), + updatedAt: new Date(), + repository: { fullName: "org/repo" }, + }, + ]); + + await GET(request("http://localhost/api/issues/untriaged")); + + // The route only fetches open issues via Prisma where clause + const call = mocks.issueFindMany.mock.calls[0][0]; + expect(call.where.state).toBe("open"); + }); + + it("returns 500 on database error", async () => { + mocks.issueFindMany.mockRejectedValue(new Error("db connection lost")); + + const res = await GET(request("http://localhost/api/issues/untriaged")); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("Failed to fetch untriaged issues"); + }); +}); diff --git a/src/app/api/pr-fix-queue/enqueue/route.test.ts b/src/app/api/pr-fix-queue/enqueue/route.test.ts new file mode 100644 index 0000000..f13e028 --- /dev/null +++ b/src/app/api/pr-fix-queue/enqueue/route.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockToken = "test-agent-token"; +process.env.DISPATCH_AGENT_TOKEN = mockToken; + +vi.mock("@/lib/dispatch-env", () => ({ + isAuthorizedAgentToken: vi.fn((token) => token === mockToken), + isAuthorizedBearerToken: vi.fn((token) => token === mockToken), + getAcceptedAgentTokens: vi.fn(() => [mockToken]), + resetCaches: vi.fn(), +})); + +const { mocks } = vi.hoisted(() => ({ + mocks: { + prFixQueueClient: vi.fn(), + parseEnqueuePrFixInput: vi.fn(), + enqueuePrFixItem: vi.fn().mockResolvedValue({ id: "fix-1" }), + auditLogCreate: vi.fn().mockResolvedValue({ id: "log-1" }), + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + auditLog: { create: mocks.auditLogCreate }, + }, + asPrFixQueueClient: mocks.prFixQueueClient, +})); + +vi.mock("@/lib/pr-fix-queue", () => ({ + parseEnqueuePrFixInput: mocks.parseEnqueuePrFixInput, + enqueuePrFixItem: mocks.enqueuePrFixItem, +})); + +import { POST } from "./route"; +import { resetAuthCaches } from "@/lib/auth"; + +function postRequest(body: unknown, includeAuth = true) { + const headers: Record = { "Content-Type": "application/json" }; + if (includeAuth) headers.Authorization = `Bearer ${mockToken}`; + return POST( + new Request("http://localhost/api/pr-fix-queue/enqueue", { + method: "POST", + headers, + body: JSON.stringify(body), + }), + ); +} + +describe("POST /api/pr-fix-queue/enqueue", () => { + beforeEach(() => { + delete process.env.DISPATCH_AUTH_MODE; + resetAuthCaches(); + vi.clearAllMocks(); + mocks.prFixQueueClient.mockReturnValue({}); + mocks.parseEnqueuePrFixInput.mockReturnValue({ repo: "org/repo", pr: 42 }); + mocks.enqueuePrFixItem.mockResolvedValue({ id: "fix-1", lane: "NORMAL" }); + mocks.auditLogCreate.mockResolvedValue({ id: "log-1" }); + }); + + it("returns 401 when no auth header is present", async () => { + const res = await postRequest({ repo: "org/repo", pr: 42 }, false); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 401 for bad bearer token", async () => { + const headers: Record = { + "Content-Type": "application/json", + Authorization: "Bearer wrong-token", + }; + const res = await POST( + new Request("http://localhost/api/pr-fix-queue/enqueue", { + method: "POST", + headers, + body: JSON.stringify({ repo: "org/repo", pr: 42 }), + }), + ); + + expect(res.status).toBe(401); + }); + + it("returns 400 on malformed JSON", async () => { + const res = await POST( + new Request("http://localhost/api/pr-fix-queue/enqueue", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${mockToken}`, + }, + body: "not-json", + }), + ); + + expect(res.status).toBe(400); + }); + + it("delegates validation errors from parseEnqueuePrFixInput", async () => { + mocks.parseEnqueuePrFixInput.mockReturnValue({ error: "repo is required" }); + + const res = await postRequest({}); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("repo is required"); + }); + + it("enqueues item and creates audit log on success", async () => { + mocks.enqueuePrFixItem.mockResolvedValue({ id: "fix-1", lane: "NORMAL", reason: "test" }); + + const res = await postRequest({ repo: "org/repo", pr: 42, reason: "test" }); + + expect(res.status).toBe(200); + expect(mocks.enqueuePrFixItem).toHaveBeenCalled(); + expect(mocks.auditLogCreate).toHaveBeenCalled(); + }); + + it("returns 500 on database error", async () => { + mocks.enqueuePrFixItem.mockRejectedValue(new Error("db connection lost")); + + const res = await postRequest({ repo: "org/repo", pr: 42, reason: "test" }); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("Failed to enqueue PR fix item"); + }); + + it("unauthorized request does not call prisma", async () => { + await postRequest({ repo: "org/repo", pr: 42 }, false); + + expect(mocks.enqueuePrFixItem).not.toHaveBeenCalled(); + expect(mocks.auditLogCreate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/pr-fix-queue/mark/route.test.ts b/src/app/api/pr-fix-queue/mark/route.test.ts new file mode 100644 index 0000000..f76e9fa --- /dev/null +++ b/src/app/api/pr-fix-queue/mark/route.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockToken = "test-agent-token"; +process.env.DISPATCH_AGENT_TOKEN = mockToken; + +vi.mock("@/lib/dispatch-env", () => ({ + isAuthorizedAgentToken: vi.fn((token) => token === mockToken), + isAuthorizedBearerToken: vi.fn((token) => token === mockToken), + getAcceptedAgentTokens: vi.fn(() => [mockToken]), + resetCaches: vi.fn(), +})); + +const { mocks } = vi.hoisted(() => ({ + mocks: { + prFixQueueClient: vi.fn(), + parseMarkPrFixInput: vi.fn(), + markPrFixItem: vi.fn().mockResolvedValue({ id: "fix-1" }), + auditLogCreate: vi.fn().mockResolvedValue({ id: "log-1" }), + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + auditLog: { create: mocks.auditLogCreate }, + }, + asPrFixQueueClient: mocks.prFixQueueClient, +})); + +vi.mock("@/lib/pr-fix-queue", () => ({ + parseMarkPrFixInput: mocks.parseMarkPrFixInput, + markPrFixItem: mocks.markPrFixItem, +})); + +import { POST } from "./route"; +import { resetAuthCaches } from "@/lib/auth"; + +function postRequest(body: unknown, includeAuth = true) { + const headers: Record = { "Content-Type": "application/json" }; + if (includeAuth) headers.Authorization = `Bearer ${mockToken}`; + return POST( + new Request("http://localhost/api/pr-fix-queue/mark", { + method: "POST", + headers, + body: JSON.stringify(body), + }), + ); +} + +describe("POST /api/pr-fix-queue/mark", () => { + beforeEach(() => { + delete process.env.DISPATCH_AUTH_MODE; + resetAuthCaches(); + vi.clearAllMocks(); + mocks.prFixQueueClient.mockReturnValue({}); + mocks.parseMarkPrFixInput.mockReturnValue({ repo: "org/repo", pr: 42, status: "DONE" }); + mocks.markPrFixItem.mockResolvedValue({ id: "fix-1", status: "DONE" }); + mocks.auditLogCreate.mockResolvedValue({ id: "log-1" }); + }); + + it("returns 401 when no auth header is present", async () => { + const res = await postRequest({ repo: "org/repo", pr: 42, status: "DONE" }, false); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 401 for bad bearer token", async () => { + const headers: Record = { + "Content-Type": "application/json", + Authorization: "Bearer wrong-token", + }; + const res = await POST( + new Request("http://localhost/api/pr-fix-queue/mark", { + method: "POST", + headers, + body: JSON.stringify({ repo: "org/repo", pr: 42, status: "DONE" }), + }), + ); + + expect(res.status).toBe(401); + }); + + it("returns 400 on malformed JSON", async () => { + const res = await POST( + new Request("http://localhost/api/pr-fix-queue/mark", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${mockToken}`, + }, + body: "not-json", + }), + ); + + expect(res.status).toBe(400); + }); + + it("delegates validation errors from parseMarkPrFixInput", async () => { + mocks.parseMarkPrFixInput.mockReturnValue({ error: "repo is required" }); + + const res = await postRequest({}); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("repo is required"); + }); + + it("marks item and creates audit log on success", async () => { + mocks.markPrFixItem.mockResolvedValue({ id: "fix-1", status: "DONE" }); + + const res = await postRequest({ repo: "org/repo", pr: 42, status: "DONE" }); + + expect(res.status).toBe(200); + expect(mocks.markPrFixItem).toHaveBeenCalled(); + expect(mocks.auditLogCreate).toHaveBeenCalled(); + }); + + it("returns 404 when item not found", async () => { + mocks.markPrFixItem.mockResolvedValue(null); + + const res = await postRequest({ repo: "org/repo", pr: 42, status: "DONE" }); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("PR fix queue item not found"); + }); + + it("returns 500 on database error", async () => { + mocks.markPrFixItem.mockRejectedValue(new Error("db connection lost")); + + const res = await postRequest({ repo: "org/repo", pr: 42, status: "DONE" }); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("Failed to mark PR fix queue item"); + }); + + it("unauthorized request does not call prisma", async () => { + await postRequest({ repo: "org/repo", pr: 42, status: "DONE" }, false); + + expect(mocks.markPrFixItem).not.toHaveBeenCalled(); + expect(mocks.auditLogCreate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/pr-fix-queue/queued/route.test.ts b/src/app/api/pr-fix-queue/queued/route.test.ts new file mode 100644 index 0000000..ae7570b --- /dev/null +++ b/src/app/api/pr-fix-queue/queued/route.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockToken = "test-agent-token"; +process.env.DISPATCH_AGENT_TOKEN = mockToken; + +vi.mock("@/lib/dispatch-env", () => ({ + isAuthorizedAgentToken: vi.fn((token) => token === mockToken), + isAuthorizedBearerToken: vi.fn((token) => token === mockToken), + getAcceptedAgentTokens: vi.fn(() => [mockToken]), + resetCaches: vi.fn(), +})); + +const { mocks } = vi.hoisted(() => ({ + mocks: { + prFixQueueClient: vi.fn(), + listQueuedPrFixItems: vi.fn().mockResolvedValue([]), + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: {}, + asPrFixQueueClient: mocks.prFixQueueClient, +})); + +vi.mock("@/lib/pr-fix-queue", () => ({ + listQueuedPrFixItems: mocks.listQueuedPrFixItems, +})); + +import { GET } from "./route"; +import { resetAuthCaches } from "@/lib/auth"; + +function request(urlString: string, includeAuth = true) { + const headers: Record = {}; + if (includeAuth) headers.Authorization = `Bearer ${mockToken}`; + return new Request(urlString, { headers }); +} + +describe("GET /api/pr-fix-queue/queued", () => { + beforeEach(() => { + delete process.env.DISPATCH_AUTH_MODE; + resetAuthCaches(); + vi.clearAllMocks(); + mocks.listQueuedPrFixItems.mockResolvedValue([]); + mocks.prFixQueueClient.mockReturnValue({}); + }); + + it("returns 401 when no auth header is present", async () => { + const res = await GET(request("http://localhost/api/pr-fix-queue/queued", false)); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 401 for bad bearer token", async () => { + const headers: Record = { Authorization: "Bearer wrong-token" }; + const res = await GET(new Request("http://localhost/api/pr-fix-queue/queued", { headers })); + + expect(res.status).toBe(401); + }); + + it("returns queued items when authorized", async () => { + mocks.listQueuedPrFixItems.mockResolvedValue([ + { id: "fix-1", repo: "org/repo", pr: 42, status: "QUEUED" }, + ]); + + const res = await GET(request("http://localhost/api/pr-fix-queue/queued")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + expect(body[0]).toMatchObject({ id: "fix-1" }); + }); + + it("passes lane filter to listQueuedPrFixItems", async () => { + await GET(request("http://localhost/api/pr-fix-queue/queued?lane=normal")); + + const call = mocks.listQueuedPrFixItems.mock.calls[0][1]; + expect(call.lane).toBe("normal"); + }); + + it("passes includeBlocked filter", async () => { + await GET(request("http://localhost/api/pr-fix-queue/queued?include_blocked=true")); + + const call = mocks.listQueuedPrFixItems.mock.calls[0][1]; + expect(call.includeBlocked).toBe(true); + }); + + it("returns 400 for invalid lane", async () => { + const res = await GET(request("http://localhost/api/pr-fix-queue/queued?lane=invalid-lane")); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("Invalid lane"); + }); + + it("returns 500 on database error", async () => { + mocks.listQueuedPrFixItems.mockRejectedValue(new Error("db connection lost")); + + const res = await GET(request("http://localhost/api/pr-fix-queue/queued")); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("Failed to list PR fix queue"); + }); + + it("unauthorized request does not call prisma", async () => { + await GET(request("http://localhost/api/pr-fix-queue/queued", false)); + + expect(mocks.listQueuedPrFixItems).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/pr-followup/sync/route.test.ts b/src/app/api/pr-followup/sync/route.test.ts new file mode 100644 index 0000000..14deec0 --- /dev/null +++ b/src/app/api/pr-followup/sync/route.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockToken = "test-agent-token"; +process.env.DISPATCH_AGENT_TOKEN = mockToken; + +vi.mock("@/lib/dispatch-env", () => ({ + isAuthorizedAgentToken: vi.fn((token) => token === mockToken), + isAuthorizedBearerToken: vi.fn((token) => token === mockToken), + getAcceptedAgentTokens: vi.fn(() => [mockToken]), + resetCaches: vi.fn(), +})); + +const { mocks } = vi.hoisted(() => ({ + mocks: { + prFixQueueClient: vi.fn(), + processPrFollowupEvents: vi.fn().mockResolvedValue({ enqueued: 0, skipped: 0 }), + ingestMergeConflict: vi.fn().mockResolvedValue(null), + clearResolvedConflictItems: vi.fn().mockResolvedValue(undefined), + getTrackedRepos: vi.fn().mockResolvedValue([]), + isAllowedBotAuthor: vi.fn(() => false), + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: {}, + asPrFixQueueClient: mocks.prFixQueueClient, +})); + +vi.mock("@/lib/pr-followup-ingestion", () => ({ + processPrFollowupEvents: mocks.processPrFollowupEvents, + isAllowedBotAuthor: mocks.isAllowedBotAuthor, + ingestMergeConflict: mocks.ingestMergeConflict, + clearResolvedConflictItems: mocks.clearResolvedConflictItems, +})); + +vi.mock("@/lib/config", () => ({ + getTrackedRepos: mocks.getTrackedRepos, +})); + +import { POST } from "./route"; +import { resetAuthCaches } from "@/lib/auth"; + +// Cast Request as NextRequest for type compatibility in tests +function asNextRequest(r: Request): any { return r; } + +function postRequest(includeAuth = true) { + const headers: Record = {}; + if (includeAuth) headers.Authorization = `Bearer ${mockToken}`; + return POST(asNextRequest(new Request("http://localhost/api/pr-followup/sync", { method: "POST", headers }))); +} + +describe("POST /api/pr-followup/sync", () => { + beforeEach(() => { + delete process.env.DISPATCH_AUTH_MODE; + resetAuthCaches(); + vi.clearAllMocks(); + mocks.prFixQueueClient.mockReturnValue({}); + mocks.getTrackedRepos.mockResolvedValue([]); + }); + + it("returns 401 when no auth header is present", async () => { + const res = await postRequest(false); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 401 for bad bearer token", async () => { + const headers: Record = { Authorization: "Bearer wrong-token" }; + const res = await POST(asNextRequest(new Request("http://localhost/api/pr-followup/sync", { method: "POST", headers }))); + + expect(res.status).toBe(401); + }); + + it("returns 500 when GITHUB_TOKEN is not configured", async () => { + delete process.env.GITHUB_TOKEN; + + const res = await postRequest(true); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("GITHUB_TOKEN not configured"); + }); + + it("returns success when no tracked repos", async () => { + process.env.GITHUB_TOKEN = "gh_fake_token"; + mocks.getTrackedRepos.mockResolvedValue([]); + + const res = await postRequest(true); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.message).toBe("No tracked repos configured"); + }); + + it("unauthorized request does not call getTrackedRepos", async () => { + await postRequest(false); + + expect(mocks.getTrackedRepos).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/pr-followup/webhook/route.test.ts b/src/app/api/pr-followup/webhook/route.test.ts new file mode 100644 index 0000000..6981724 --- /dev/null +++ b/src/app/api/pr-followup/webhook/route.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockToken = "test-agent-token"; +process.env.DISPATCH_AGENT_TOKEN = mockToken; + +vi.mock("@/lib/dispatch-env", () => ({ + isAuthorizedAgentToken: vi.fn((token) => token === mockToken), + isAuthorizedBearerToken: vi.fn((token) => token === mockToken), + getAcceptedAgentTokens: vi.fn(() => [mockToken]), + resetCaches: vi.fn(), +})); + +const { mocks } = vi.hoisted(() => ({ + mocks: { + prFixQueueClient: vi.fn(), + processPrFollowupEvents: vi.fn().mockResolvedValue({ enqueued: 1, skipped: 0 }), + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: {}, + asPrFixQueueClient: mocks.prFixQueueClient, +})); + +vi.mock("@/lib/pr-followup-ingestion", () => ({ + processPrFollowupEvents: mocks.processPrFollowupEvents, +})); + +import { POST } from "./route"; +import { resetAuthCaches } from "@/lib/auth"; + +function postRequest(body: unknown, headers: Record = {}) { + return POST( + new Request("http://localhost/api/pr-followup/webhook", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: JSON.stringify(body), + }), + ); +} + +describe("POST /api/pr-followup/webhook", () => { + beforeEach(() => { + delete process.env.DISPATCH_AUTH_MODE; + delete process.env.WEBHOOK_SECRET; + delete process.env.WEBHOOK_GATEWAY_MODE; + resetAuthCaches(); + vi.clearAllMocks(); + mocks.prFixQueueClient.mockReturnValue({}); + }); + + it("returns 401 when no auth header is present", async () => { + const res = await postRequest({}, { "x-github-event": "pull_request_review" }); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 400 when x-github-event header is missing", async () => { + const res = await postRequest({}, { Authorization: `Bearer ${mockToken}` }); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("Missing x-github-event header"); + }); + + it("returns 400 for invalid JSON body", async () => { + const res = await POST( + new Request("http://localhost/api/pr-followup/webhook", { + method: "POST", + headers: { + Authorization: `Bearer ${mockToken}`, + "x-github-event": "pull_request_review", + }, + body: "not-json", + }), + ); + + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid payload type", async () => { + const res = await postRequest("string-body", { + Authorization: `Bearer ${mockToken}`, + "x-github-event": "pull_request_review", + }); + + expect(res.status).toBe(400); + }); + + it("returns 200 for unhandled event type", async () => { + const res = await postRequest({ action: "opened" }, { + Authorization: `Bearer ${mockToken}`, + "x-github-event": "unknown_event", + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.message).toContain("Unhandled event type"); + }); + + it("processes pull_request_review events", async () => { + const prBody = { + review: { id: 1, body: "Looks good", state: "APPROVED" }, + pull_request: { + number: 42, + html_url: "https://github.com/org/repo/pull/42", + title: "Fix bug", + user: { login: "bot-user" }, + head: { ref: "fix/issue-1" }, + base: { repo: { full_name: "org/repo" } }, + }, + }; + + const res = await postRequest(prBody, { + Authorization: `Bearer ${mockToken}`, + "x-github-event": "pull_request_review", + }); + + expect(res.status).toBe(200); + expect(mocks.processPrFollowupEvents).toHaveBeenCalled(); + }); + + it("processes pull_request events", async () => { + const prBody = { + pull_request: { + id: 1, + number: 42, + html_url: "https://github.com/org/repo/pull/42", + title: "Fix bug", + user: { login: "bot-user" }, + head: { ref: "fix/issue-1" }, + base: { repo: { full_name: "org/repo" } }, + mergeable_state: "clean", + }, + }; + + const res = await postRequest(prBody, { + Authorization: `Bearer ${mockToken}`, + "x-github-event": "pull_request", + }); + + expect(res.status).toBe(200); + expect(mocks.processPrFollowupEvents).toHaveBeenCalled(); + }); + + it("returns events count in response", async () => { + mocks.processPrFollowupEvents.mockResolvedValue({ enqueued: 1, skipped: 0 }); + + const prBody = { + review: { id: 1, body: "Fix this", state: "CHANGES_REQUESTED" }, + pull_request: { + number: 42, + html_url: "https://github.com/org/repo/pull/42", + title: "Fix bug", + user: { login: "bot-user" }, + head: { ref: "fix/issue-1" }, + base: { repo: { full_name: "org/repo" } }, + }, + }; + + const res = await postRequest(prBody, { + Authorization: `Bearer ${mockToken}`, + "x-github-event": "pull_request_review", + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.eventsReceived).toBe(1); + expect(body.enqueued).toBe(1); + }); + + it("returns 500 on processing error", async () => { + mocks.processPrFollowupEvents.mockRejectedValue(new Error("db connection lost")); + + const prBody = { + review: { id: 1, body: "Fix this", state: "CHANGES_REQUESTED" }, + pull_request: { + number: 42, + html_url: "https://github.com/org/repo/pull/42", + title: "Fix bug", + user: { login: "bot-user" }, + head: { ref: "fix/issue-1" }, + base: { repo: { full_name: "org/repo" } }, + }, + }; + + const res = await postRequest(prBody, { + Authorization: `Bearer ${mockToken}`, + "x-github-event": "pull_request_review", + }); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("Webhook processing failed"); + }); +}); diff --git a/src/app/api/repos/route.test.ts b/src/app/api/repos/route.test.ts new file mode 100644 index 0000000..b3ee24c --- /dev/null +++ b/src/app/api/repos/route.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockToken = "test-agent-token"; +process.env.DISPATCH_AGENT_TOKEN = mockToken; + +vi.mock("@/lib/dispatch-env", () => ({ + isAuthorizedAgentToken: vi.fn((token) => token === mockToken), + isAuthorizedBearerToken: vi.fn((token) => token === mockToken), + getAcceptedAgentTokens: vi.fn(() => [mockToken]), + resetCaches: vi.fn(), +})); + +const { mocks } = vi.hoisted(() => ({ + mocks: { + repositoryFindMany: vi.fn().mockResolvedValue([]), + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + repository: { findMany: mocks.repositoryFindMany }, + }, +})); + +import { GET } from "./route"; + +describe("GET /api/repos", () => { + // NOTE: This route is intentionally unauthenticated. It returns all tracked + // repositories to any caller. In production deployments behind a firewall or + // auth gateway this is acceptable; in open deployments consider adding auth. + beforeEach(() => { + vi.clearAllMocks(); + mocks.repositoryFindMany.mockResolvedValue([]); + }); + + it("returns repos without authentication", async () => { + mocks.repositoryFindMany.mockResolvedValue([ + { id: "r1", fullName: "org/repo1", enabled: true }, + { id: "r2", fullName: "org/repo2", enabled: true }, + ]); + + const res = await GET(); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + expect(body).toHaveLength(2); + expect(body[0]).toMatchObject({ fullName: "org/repo1" }); + }); + + it("returns empty array when no repos exist", async () => { + mocks.repositoryFindMany.mockResolvedValue([]); + + const res = await GET(); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual([]); + }); + + it("orders by fullName ascending", async () => { + await GET(); + + const call = mocks.repositoryFindMany.mock.calls[0][0]; + expect(call.orderBy).toEqual({ fullName: "asc" }); + }); + + it("returns 500 on database error", async () => { + mocks.repositoryFindMany.mockRejectedValue(new Error("db connection lost")); + + const res = await GET(); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("Failed to fetch repositories"); + }); +});