import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; 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 mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(async () => false), hasPermission: vi.fn(async () => false), })); 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 mockFeedbackService = vi.hoisted(() => ({ listIssueVotesForUser: vi.fn(async () => []), saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), })); const mockInstanceSettingsService = vi.hoisted(() => ({ get: vi.fn(async () => ({ id: "instance-settings-1", general: { censorUsernameInLogs: false, feedbackDataSharingPreference: "prompt", }, })), listCompanyIds: vi.fn(async () => ["company-1"]), })); const mockRoutineService = vi.hoisted(() => ({ syncRunStatusForIssue: vi.fn(async () => undefined), })); function registerModuleMocks() { vi.doMock("../services/access.js", () => ({ accessService: () => mockAccessService, })); vi.doMock("../services/activity-log.js", () => ({ logActivity: mockLogActivity, })); vi.doMock("../services/feedback.js", () => ({ feedbackService: () => mockFeedbackService, })); vi.doMock("../services/heartbeat.js", () => ({ heartbeatService: () => mockHeartbeatService, })); vi.doMock("../services/instance-settings.js", () => ({ instanceSettingsService: () => mockInstanceSettingsService, })); vi.doMock("../services/issues.js", () => ({ issueService: () => mockIssueService, })); vi.doMock("../services/routines.js", () => ({ routineService: () => mockRoutineService, })); vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => ({ getById: vi.fn(async () => null), }), documentService: () => ({}), executionWorkspaceService: () => ({}), feedbackService: () => mockFeedbackService, goalService: () => ({}), heartbeatService: () => mockHeartbeatService, instanceSettingsService: () => mockInstanceSettingsService, issueApprovalService: () => ({}), issueReferenceService: () => ({ deleteDocumentSource: async () => undefined, diffIssueReferenceSummary: () => ({ addedReferencedIssues: [], removedReferencedIssues: [], currentReferencedIssues: [], }), emptySummary: () => ({ outbound: [], inbound: [] }), listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), syncComment: async () => undefined, syncDocument: async () => undefined, syncIssue: async () => undefined, }), issueService: () => mockIssueService, logActivity: mockLogActivity, projectService: () => ({}), routineService: () => mockRoutineService, workProductService: () => ({}), })); } async function createApp() { const [{ issueRoutes }, { errorHandler }] = await Promise.all([ vi.importActual("../routes/issues.js"), vi.importActual("../middleware/index.js"), ]); 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() { return { id: "11111111-1111-4111-8111-111111111111", companyId: "company-1", status: "todo", assigneeAgentId: "22222222-2222-4222-8222-222222222222", assigneeUserId: null, createdByUserId: "local-board", identifier: "PAP-580", title: "Activity event issue", executionPolicy: null, executionState: null, }; } describe("issue activity event routes", () => { beforeEach(() => { vi.resetModules(); vi.doUnmock("../services/access.js"); vi.doUnmock("../services/activity-log.js"); vi.doUnmock("../services/feedback.js"); vi.doUnmock("../services/heartbeat.js"); vi.doUnmock("../services/index.js"); vi.doUnmock("../services/instance-settings.js"); vi.doUnmock("../services/issues.js"); vi.doUnmock("../services/routines.js"); vi.doUnmock("../routes/issues.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); vi.clearAllMocks(); mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); mockIssueService.findMentionedAgents.mockResolvedValue([]); mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); mockAccessService.canUser.mockResolvedValue(false); mockAccessService.hasPermission.mockResolvedValue(false); mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]); mockFeedbackService.saveIssueVote.mockResolvedValue({ vote: null, consentEnabledNow: false, sharingEnabled: false, }); mockHeartbeatService.wakeup.mockResolvedValue(undefined); mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined); mockHeartbeatService.getRun.mockResolvedValue(null); mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null); mockHeartbeatService.cancelRun.mockResolvedValue(null); mockInstanceSettingsService.get.mockResolvedValue({ id: "instance-settings-1", general: { censorUsernameInLogs: false, feedbackDataSharingPreference: "prompt", }, }); mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]); mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined); }); it("logs blocker activity with added and removed issue summaries", async () => { const issue = makeIssue(); mockIssueService.getById.mockResolvedValue(issue); const previousRelations = { blockedBy: [ { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", identifier: "PAP-10", title: "Old blocker", status: "todo", priority: "medium", assigneeAgentId: null, assigneeUserId: null, }, ], blocks: [], }; const nextRelations = { blockedBy: [ { id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", identifier: "PAP-11", title: "New blocker", status: "todo", priority: "medium", assigneeAgentId: null, assigneeUserId: null, }, ], blocks: [], }; let relationLookupCount = 0; mockIssueService.getRelationSummaries.mockImplementation(async () => { relationLookupCount += 1; return relationLookupCount === 1 ? previousRelations : nextRelations; }); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...issue, ...patch, updatedAt: new Date(), })); const res = await request(await createApp()) .patch("/api/issues/11111111-1111-4111-8111-111111111111") .send({ blockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"] }); expect(res.status).toBe(200); await vi.waitFor(() => { expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ action: "issue.blockers_updated", details: expect.objectContaining({ addedBlockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"], removedBlockedByIssueIds: ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"], addedBlockedByIssues: [ { id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", identifier: "PAP-11", title: "New blocker", }, ], removedBlockedByIssues: [ { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", identifier: "PAP-10", title: "Old blocker", }, ], }), }), ); }); }, 15_000); it("logs explicit reviewer and approver activity when execution policy participants change", async () => { const existingPolicy = normalizeIssueExecutionPolicy({ stages: [ { id: "11111111-1111-4111-8111-111111111111", type: "review", participants: [{ type: "agent", agentId: "11111111-2222-4333-8444-555555555555" }], }, { id: "22222222-2222-4222-8222-222222222222", type: "approval", participants: [{ type: "agent", agentId: "66666666-7777-4888-8999-aaaaaaaaaaaa" }], }, ], })!; const nextPolicy = normalizeIssueExecutionPolicy({ stages: [ { id: "11111111-1111-4111-8111-111111111111", type: "review", participants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff" }], }, { id: "22222222-2222-4222-8222-222222222222", type: "approval", participants: [{ type: "user", userId: "local-board" }], }, ], })!; const issue = { ...makeIssue(), executionPolicy: existingPolicy, }; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...issue, ...patch, executionPolicy: patch.executionPolicy, updatedAt: new Date(), })); const res = await request(await createApp()) .patch("/api/issues/11111111-1111-4111-8111-111111111111") .send({ executionPolicy: nextPolicy }); expect(res.status).toBe(200); await vi.waitFor(() => { expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ action: "issue.reviewers_updated", details: expect.objectContaining({ participants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }], addedParticipants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }], removedParticipants: [{ type: "agent", agentId: "11111111-2222-4333-8444-555555555555", userId: null }], }), }), ); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ action: "issue.approvers_updated", details: expect.objectContaining({ participants: [{ type: "user", agentId: null, userId: "local-board" }], addedParticipants: [{ type: "user", agentId: null, userId: "local-board" }], removedParticipants: [{ type: "agent", agentId: "66666666-7777-4888-8999-aaaaaaaaaaaa", userId: null }], }), }), ); }); }); });