import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mockFeedbackService = vi.hoisted(() => ({ getFeedbackTraceById: vi.fn(), getFeedbackTraceBundle: vi.fn(), listIssueVotesForUser: vi.fn(), listFeedbackTraces: vi.fn(), saveIssueVote: vi.fn(), })); const mockIssueService = vi.hoisted(() => ({ getById: vi.fn(), getByIdentifier: vi.fn(), update: vi.fn(), addComment: vi.fn(), findMentionedAgents: vi.fn(), })); const mockFeedbackExportService = vi.hoisted(() => ({ flushPendingFeedbackTraces: vi.fn(async () => ({ attempted: 1, sent: 1, failed: 0 })), })); const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), hasPermission: vi.fn(), })); const mockAgentService = vi.hoisted(() => ({ getById: 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 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), })); const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); const mockIssueThreadInteractionService = vi.hoisted(() => ({ expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), })); const mockEnvironmentService = vi.hoisted(() => ({ getById: vi.fn(async () => null), })); const mockExecutionWorkspaceService = vi.hoisted(() => ({})); const mockIssueReferenceService = vi.hoisted(() => ({ deleteDocumentSource: vi.fn(async () => undefined), diffIssueReferenceSummary: vi.fn(() => ({ addedReferencedIssues: [], removedReferencedIssues: [], currentReferencedIssues: [], })), emptySummary: vi.fn(() => ({ outbound: [], inbound: [] })), listIssueReferenceSummary: vi.fn(async () => ({ outbound: [], inbound: [] })), syncComment: vi.fn(async () => undefined), syncDocument: vi.fn(async () => undefined), syncIssue: vi.fn(async () => undefined), })); function registerModuleMocks() { vi.doMock("@paperclipai/shared/telemetry", () => ({ trackAgentTaskCompleted: vi.fn(), trackErrorHandlerCrash: vi.fn(), })); vi.doMock("../telemetry.js", () => ({ getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), })); vi.doMock("../services/index.js", () => ({ companyService: () => ({ getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })), }), accessService: () => mockAccessService, agentService: () => mockAgentService, documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), documentService: () => ({}), executionWorkspaceService: () => mockExecutionWorkspaceService, goalService: () => ({}), heartbeatService: () => mockHeartbeatService, issueApprovalService: () => ({}), issueReferenceService: () => mockIssueReferenceService, issueRecoveryActionService: () => ({ getActiveForIssue: vi.fn(async () => null), listActiveForIssues: vi.fn(async () => new Map()), }), issueService: () => mockIssueService, issueThreadInteractionService: () => mockIssueThreadInteractionService, logActivity: mockLogActivity, projectService: () => ({}), routineService: () => mockRoutineService, workProductService: () => ({}), })); vi.doMock("../services/environments.js", () => ({ environmentService: () => mockEnvironmentService, })); vi.doMock("../services/execution-workspaces.js", () => ({ executionWorkspaceService: () => mockExecutionWorkspaceService, })); vi.doMock("../services/feedback.js", () => ({ feedbackService: () => mockFeedbackService, })); vi.doMock("../services/instance-settings.js", () => ({ instanceSettingsService: () => mockInstanceSettingsService, })); } async function createApp(actor: Record) { const [{ issueRoutes }, { errorHandler }] = await Promise.all([ import("../routes/issues.js"), import("../middleware/index.js"), ]); 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, { feedbackExportService: mockFeedbackExportService })); app.use(errorHandler); return app; } describe("issue feedback trace routes", () => { beforeEach(() => { vi.resetModules(); vi.doUnmock("@paperclipai/shared/telemetry"); vi.doUnmock("../telemetry.js"); vi.doUnmock("../services/index.js"); vi.doUnmock("../services/environments.js"); vi.doUnmock("../services/execution-workspaces.js"); vi.doUnmock("../services/feedback.js"); vi.doUnmock("../services/instance-settings.js"); vi.doUnmock("../routes/issues.js"); vi.doUnmock("../middleware/index.js"); registerModuleMocks(); vi.clearAllMocks(); mockFeedbackExportService.flushPendingFeedbackTraces.mockResolvedValue({ attempted: 1, sent: 1, failed: 0, }); 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); mockLogActivity.mockResolvedValue(undefined); }); it("flushes a newly shared feedback trace immediately after saving the vote", async () => { const targetId = "11111111-1111-4111-8111-111111111111"; mockIssueService.getById.mockResolvedValue({ id: "issue-1", companyId: "company-1", identifier: "PAP-1", }); mockFeedbackService.saveIssueVote.mockResolvedValue({ vote: { targetType: "issue_comment", targetId, vote: "up", reason: null, }, traceId: "trace-1", consentEnabledNow: false, persistedSharingPreference: null, sharingEnabled: true, }); const app = await createApp({ type: "board", userId: "user-1", source: "session", isInstanceAdmin: true, companyIds: ["company-1"], }); const res = await request(app) .post("/api/issues/issue-1/feedback-votes") .send({ targetType: "issue_comment", targetId, vote: "up", allowSharing: true, }); expect([200, 201]).toContain(res.status); expect(mockFeedbackExportService.flushPendingFeedbackTraces).toHaveBeenCalledWith({ companyId: "company-1", traceId: "trace-1", limit: 1, }); }); it("rejects non-board callers before fetching a feedback trace", async () => { const app = await createApp({ type: "agent", agentId: "agent-1", companyId: "company-1", source: "agent_key", runId: "run-1", }); const res = await request(app).get("/api/feedback-traces/trace-1"); expect(res.status).toBe(403); expect(mockFeedbackService.getFeedbackTraceById).not.toHaveBeenCalled(); }); it("returns 404 when a board user lacks access to the trace company", async () => { mockFeedbackService.getFeedbackTraceById.mockResolvedValue({ id: "trace-1", companyId: "company-2", }); const app = await createApp({ type: "board", userId: "user-1", source: "session", isInstanceAdmin: false, companyIds: ["company-1"], }); const res = await request(app).get("/api/feedback-traces/trace-1"); expect(res.status).toBe(404); }); it("returns 404 for bundle fetches when a board user lacks access to the trace company", async () => { mockFeedbackService.getFeedbackTraceBundle.mockResolvedValue({ id: "trace-1", companyId: "company-2", issueId: "issue-1", files: [], }); const app = await createApp({ type: "board", userId: "user-1", source: "session", isInstanceAdmin: false, companyIds: ["company-1"], }); const res = await request(app).get("/api/feedback-traces/trace-1/bundle"); expect(res.status).toBe(404); }); });