import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { issueRoutes } from "../routes/issues.js"; import { errorHandler } from "../middleware/index.js"; const mockIssueService = vi.hoisted(() => ({ getById: vi.fn(), update: vi.fn(), addComment: vi.fn(), findMentionedAgents: vi.fn(), })); const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), hasPermission: 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), })); const mockAgentService = vi.hoisted(() => ({ getById: vi.fn(), })); const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); vi.mock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => mockAgentService, 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: mockLogActivity, projectService: () => ({}), routineService: () => ({ syncRunStatusForIssue: vi.fn(async () => undefined), }), workProductService: () => ({}), })); function createApp() { const app = express(); app.use(express.json()); app.use((req, _res, next) => { (req as any).actor = { type: "board", userId: "local-board", companyIds: ["company-1"], source: "local_implicit", isInstanceAdmin: false, }; next(); }); app.use("/api", issueRoutes({} as any, {} as any)); app.use(errorHandler); return app; } function makeIssue(status: "todo" | "done") { return { id: "11111111-1111-4111-8111-111111111111", companyId: "company-1", status, assigneeAgentId: "22222222-2222-4222-8222-222222222222", assigneeUserId: null, createdByUserId: "local-board", identifier: "PAP-580", title: "Comment reopen default", }; } describe("issue comment reopen routes", () => { beforeEach(() => { vi.clearAllMocks(); mockIssueService.addComment.mockResolvedValue({ id: "comment-1", issueId: "11111111-1111-4111-8111-111111111111", companyId: "company-1", body: "hello", createdAt: new Date(), updatedAt: new Date(), authorAgentId: null, authorUserId: "local-board", }); mockIssueService.findMentionedAgents.mockResolvedValue([]); }); it("treats reopen=true as a no-op when the issue is already open", async () => { mockIssueService.getById.mockResolvedValue(makeIssue("todo")); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...makeIssue("todo"), ...patch, })); const res = await request(createApp()) .patch("/api/issues/11111111-1111-4111-8111-111111111111") .send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" }); expect(res.status).toBe(200); expect(mockIssueService.update).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111", { assigneeAgentId: "33333333-3333-4333-8333-333333333333", }); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ action: "issue.updated", details: expect.not.objectContaining({ reopened: true }), }), ); }); it("reopens closed issues via the PATCH comment path", async () => { mockIssueService.getById.mockResolvedValue(makeIssue("done")); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...makeIssue("done"), ...patch, })); const res = await request(createApp()) .patch("/api/issues/11111111-1111-4111-8111-111111111111") .send({ comment: "hello", reopen: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" }); expect(res.status).toBe(200); expect(mockIssueService.update).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111", { assigneeAgentId: "33333333-3333-4333-8333-333333333333", status: "todo", }); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ action: "issue.updated", details: expect.objectContaining({ reopened: true, reopenedFrom: "done", status: "todo", }), }), ); }); it("interrupts an active run before a combined comment update", async () => { const issue = { ...makeIssue("todo"), executionRunId: "run-1", }; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...issue, ...patch, })); mockHeartbeatService.getRun.mockResolvedValue({ id: "run-1", companyId: "company-1", agentId: "22222222-2222-4222-8222-222222222222", status: "running", }); mockHeartbeatService.cancelRun.mockResolvedValue({ id: "run-1", companyId: "company-1", agentId: "22222222-2222-4222-8222-222222222222", status: "cancelled", }); const res = await request(createApp()) .patch("/api/issues/11111111-1111-4111-8111-111111111111") .send({ comment: "hello", interrupt: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" }); expect(res.status).toBe(200); expect(mockHeartbeatService.getRun).toHaveBeenCalledWith("run-1"); expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("run-1"); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ action: "heartbeat.cancelled", details: expect.objectContaining({ source: "issue_comment_interrupt", issueId: "11111111-1111-4111-8111-111111111111", }), }), ); }); });