import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { errorHandler } from "../middleware/index.js"; import { issueRoutes } from "../routes/issues.js"; import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts"; const mockIssueService = vi.hoisted(() => ({ getById: vi.fn(), assertCheckoutOwner: vi.fn(), update: vi.fn(), addComment: vi.fn(), findMentionedAgents: vi.fn(), getRelationSummaries: vi.fn(), listWakeableBlockedDependents: vi.fn(), getWakeableParentAfterChildCompletion: vi.fn(), })); const mockHeartbeatService = vi.hoisted(() => ({ wakeup: vi.fn(async () => undefined), reportRunActivity: vi.fn(async () => undefined), getRun: vi.fn(async () => null), getActiveRunForAgent: vi.fn(async () => null), cancelRun: vi.fn(async () => null), })); vi.mock("../services/index.js", () => ({ accessService: () => ({ canUser: vi.fn(async () => false), hasPermission: vi.fn(async () => false), }), agentService: () => ({ getById: vi.fn(async () => null), }), documentService: () => ({}), executionWorkspaceService: () => ({}), feedbackService: () => ({ listIssueVotesForUser: vi.fn(async () => []), saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), }), goalService: () => ({}), heartbeatService: () => mockHeartbeatService, instanceSettingsService: () => ({ get: vi.fn(async () => ({ id: "instance-settings-1", general: { censorUsernameInLogs: false, feedbackDataSharingPreference: "prompt", }, })), listCompanyIds: vi.fn(async () => ["company-1"]), }), issueApprovalService: () => ({}), issueService: () => mockIssueService, logActivity: vi.fn(async () => undefined), projectService: () => ({}), routineService: () => ({ syncRunStatusForIssue: vi.fn(async () => undefined), }), workProductService: () => ({}), })); function createApp( actor: Record = { type: "board", userId: "local-board", companyIds: ["company-1"], source: "local_implicit", isInstanceAdmin: false, }, ) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { (req as any).actor = actor; next(); }); app.use("/api", issueRoutes({} as any, {} as any)); app.use(errorHandler); return app; } describe("issue execution policy routes", () => { beforeEach(() => { vi.clearAllMocks(); mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); mockIssueService.findMentionedAgents.mockResolvedValue([]); mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); }); it("does not auto-start execution review when reviewers are added to an already in_review issue", async () => { const policy = normalizeIssueExecutionPolicy({ stages: [ { id: "11111111-1111-4111-8111-111111111111", type: "review", participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }], }, ], })!; const issue = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "in_review", assigneeAgentId: null, assigneeUserId: "local-board", createdByUserId: "local-board", identifier: "PAP-999", title: "Execution policy edit", executionPolicy: null, executionState: null, }; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...issue, ...patch, updatedAt: new Date(), })); const res = await request(createApp()) .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") .send({ executionPolicy: policy }); expect(res.status).toBe(200); expect(mockIssueService.update).toHaveBeenCalledWith( "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", expect.objectContaining({ executionPolicy: policy, actorAgentId: null, actorUserId: "local-board", }), ); const updatePatch = mockIssueService.update.mock.calls[0]?.[1] as Record; expect(updatePatch.status).toBeUndefined(); expect(updatePatch.assigneeAgentId).toBeUndefined(); expect(updatePatch.assigneeUserId).toBeUndefined(); expect(updatePatch.executionState).toBeUndefined(); expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); it("rejects agent stage advances from non-participants", async () => { const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; const approverAgentId = "44444444-4444-4444-8444-444444444444"; const executorAgentId = "22222222-2222-4222-8222-222222222222"; const policy = normalizeIssueExecutionPolicy({ stages: [ { id: "11111111-1111-4111-8111-111111111111", type: "review", participants: [{ type: "agent", agentId: reviewerAgentId }], }, { id: "55555555-5555-4555-8555-555555555555", type: "approval", participants: [{ type: "agent", agentId: approverAgentId }], }, ], })!; const issue = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "in_review", assigneeAgentId: reviewerAgentId, assigneeUserId: null, createdByUserId: "local-board", identifier: "PAP-1000", title: "Execution policy guard", executionPolicy: policy, executionState: { status: "pending", currentStageId: "11111111-1111-4111-8111-111111111111", currentStageIndex: 0, currentStageType: "review", currentParticipant: { type: "agent", agentId: reviewerAgentId }, returnAssignee: { type: "agent", agentId: executorAgentId }, completedStageIds: [], lastDecisionId: null, lastDecisionOutcome: null, }, }; mockIssueService.getById.mockResolvedValue(issue); const res = await request( createApp({ type: "agent", agentId: approverAgentId, companyId: "company-1", source: "api_key", runId: "run-1", }), ) .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") .send({ status: "done", comment: "Skipping review." }); expect(res.status).toBe(403); expect(res.body.error).toContain("active review participant"); expect(mockIssueService.update).not.toHaveBeenCalled(); }); });