import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mockIssueService = vi.hoisted(() => ({ getById: vi.fn(), })); const mockTreeControlService = vi.hoisted(() => ({ preview: vi.fn(), createHold: vi.fn(), cancelIssueStatusesForHold: vi.fn(), restoreIssueStatusesForHold: vi.fn(), getHold: vi.fn(), releaseHold: vi.fn(), cancelUnclaimedWakeupsForTree: vi.fn(), })); const mockLogActivity = vi.hoisted(() => vi.fn()); const mockHeartbeatService = vi.hoisted(() => ({ cancelRun: vi.fn(), wakeup: vi.fn(), })); vi.mock("../services/index.js", () => ({ heartbeatService: () => mockHeartbeatService, issueService: () => mockIssueService, issueTreeControlService: () => mockTreeControlService, logActivity: mockLogActivity, })); async function createApp(actor: Record) { const [{ errorHandler }, { issueTreeControlRoutes }] = await Promise.all([ import("../middleware/index.js"), import("../routes/issue-tree-control.js"), ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { (req as any).actor = actor; next(); }); app.use("/api", issueTreeControlRoutes({} as any)); app.use(errorHandler); return app; } describe("issue tree control routes", () => { beforeEach(() => { vi.clearAllMocks(); mockIssueService.getById.mockResolvedValue({ id: "11111111-1111-4111-8111-111111111111", companyId: "company-2", }); mockTreeControlService.cancelUnclaimedWakeupsForTree.mockResolvedValue([]); mockTreeControlService.cancelIssueStatusesForHold.mockResolvedValue({ updatedIssueIds: [], updatedIssues: [] }); mockTreeControlService.restoreIssueStatusesForHold.mockResolvedValue({ updatedIssueIds: [], updatedIssues: [], releasedCancelHoldIds: [], restoreHold: null, }); mockHeartbeatService.cancelRun.mockResolvedValue(null); mockHeartbeatService.wakeup.mockResolvedValue(null); }); it("rejects cross-company preview requests before calling the preview service", async () => { const app = await createApp({ type: "board", userId: "user-1", companyIds: ["company-1"], source: "session", isInstanceAdmin: false, }); const res = await request(app) .post("/api/issues/11111111-1111-4111-8111-111111111111/tree-control/preview") .send({ mode: "pause" }); expect(res.status).toBe(403); expect(mockTreeControlService.preview).not.toHaveBeenCalled(); expect(mockLogActivity).not.toHaveBeenCalled(); }); it("requires board access for hold creation", async () => { const app = await createApp({ type: "agent", agentId: "22222222-2222-4222-8222-222222222222", companyId: "company-2", runId: null, source: "api_key", }); const res = await request(app) .post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds") .send({ mode: "pause" }); expect(res.status).toBe(403); expect(mockIssueService.getById).not.toHaveBeenCalled(); expect(mockTreeControlService.createHold).not.toHaveBeenCalled(); }); it("cancels active descendant runs when creating a pause hold", async () => { const app = await createApp({ type: "board", userId: "user-1", companyIds: ["company-2"], source: "session", isInstanceAdmin: false, }); mockTreeControlService.createHold.mockResolvedValue({ hold: { id: "33333333-3333-4333-8333-333333333333", mode: "pause", reason: "pause subtree", }, preview: { mode: "pause", totals: { affectedIssues: 1 }, warnings: [], activeRuns: [ { id: "44444444-4444-4444-8444-444444444444", issueId: "11111111-1111-4111-8111-111111111111", }, ], }, }); const res = await request(app) .post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds") .send({ mode: "pause", reason: "pause subtree" }); expect(res.status).toBe(201); expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("44444444-4444-4444-8444-444444444444"); expect(mockTreeControlService.cancelUnclaimedWakeupsForTree).toHaveBeenCalledWith( "company-2", "11111111-1111-4111-8111-111111111111", "Cancelled because an active subtree pause hold was created", ); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ action: "issue.tree_hold_run_interrupted", entityId: "44444444-4444-4444-8444-444444444444", }), ); }); it("marks affected issues cancelled when creating a cancel hold", async () => { const app = await createApp({ type: "board", userId: "user-1", companyIds: ["company-2"], source: "session", isInstanceAdmin: false, }); mockTreeControlService.createHold.mockResolvedValue({ hold: { id: "33333333-3333-4333-8333-333333333333", mode: "cancel", reason: "cancel subtree", }, preview: { mode: "cancel", totals: { affectedIssues: 2 }, warnings: [], activeRuns: [], }, }); mockTreeControlService.cancelIssueStatusesForHold.mockResolvedValue({ updatedIssueIds: [ "11111111-1111-4111-8111-111111111111", "55555555-5555-4555-8555-555555555555", ], updatedIssues: [], }); const res = await request(app) .post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds") .send({ mode: "cancel", reason: "cancel subtree" }); expect(res.status).toBe(201); expect(mockTreeControlService.cancelIssueStatusesForHold).toHaveBeenCalledWith( "company-2", "11111111-1111-4111-8111-111111111111", "33333333-3333-4333-8333-333333333333", ); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ action: "issue.tree_cancel_status_updated", details: expect.objectContaining({ cancelledIssueCount: 2 }), }), ); }); it("still marks affected issues cancelled when run interruption fails", async () => { const app = await createApp({ type: "board", userId: "user-1", companyIds: ["company-2"], source: "session", isInstanceAdmin: false, }); mockTreeControlService.createHold.mockResolvedValue({ hold: { id: "33333333-3333-4333-8333-333333333333", mode: "cancel", reason: "cancel subtree", }, preview: { mode: "cancel", totals: { affectedIssues: 1 }, warnings: [], activeRuns: [ { id: "44444444-4444-4444-8444-444444444444", issueId: "11111111-1111-4111-8111-111111111111", }, ], }, }); mockTreeControlService.cancelIssueStatusesForHold.mockResolvedValue({ updatedIssueIds: ["11111111-1111-4111-8111-111111111111"], updatedIssues: [], }); mockHeartbeatService.cancelRun.mockRejectedValue(new Error("adapter process did not exit")); const res = await request(app) .post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds") .send({ mode: "cancel", reason: "cancel subtree" }); expect(res.status).toBe(201); expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("44444444-4444-4444-8444-444444444444"); expect(mockTreeControlService.cancelIssueStatusesForHold).toHaveBeenCalledWith( "company-2", "11111111-1111-4111-8111-111111111111", "33333333-3333-4333-8333-333333333333", ); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ action: "issue.tree_hold_run_interrupt_failed", entityId: "44444444-4444-4444-8444-444444444444", details: expect.objectContaining({ error: "adapter process did not exit", }), }), ); }); it("restores affected issues and can request explicit wakeups", async () => { const app = await createApp({ type: "board", userId: "user-1", companyIds: ["company-2"], source: "session", isInstanceAdmin: false, }); mockTreeControlService.createHold.mockResolvedValue({ hold: { id: "66666666-6666-4666-8666-666666666666", mode: "restore", reason: "restore subtree", }, preview: { mode: "restore", totals: { affectedIssues: 1 }, warnings: [], activeRuns: [], }, }); mockTreeControlService.restoreIssueStatusesForHold.mockResolvedValue({ updatedIssueIds: ["55555555-5555-4555-8555-555555555555"], updatedIssues: [ { id: "55555555-5555-4555-8555-555555555555", status: "todo", assigneeAgentId: "22222222-2222-4222-8222-222222222222", }, ], releasedCancelHoldIds: ["33333333-3333-4333-8333-333333333333"], restoreHold: { id: "66666666-6666-4666-8666-666666666666", mode: "restore", status: "released", }, }); mockHeartbeatService.wakeup.mockResolvedValue({ id: "77777777-7777-4777-8777-777777777777", }); const res = await request(app) .post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds") .send({ mode: "restore", reason: "restore subtree", metadata: { wakeAgents: true } }); expect(res.status).toBe(200); expect(mockTreeControlService.restoreIssueStatusesForHold).toHaveBeenCalledWith( "company-2", "11111111-1111-4111-8111-111111111111", "66666666-6666-4666-8666-666666666666", expect.objectContaining({ reason: "restore subtree" }), ); expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( "22222222-2222-4222-8222-222222222222", expect.objectContaining({ reason: "issue_tree_restored", payload: expect.objectContaining({ issueId: "55555555-5555-4555-8555-555555555555" }), }), ); expect(res.body.hold.status).toBe("released"); }); it("releases a restore hold if the restore application fails", async () => { const app = await createApp({ type: "board", userId: "user-1", companyIds: ["company-2"], source: "session", isInstanceAdmin: false, }); mockTreeControlService.createHold.mockResolvedValue({ hold: { id: "66666666-6666-4666-8666-666666666666", mode: "restore", reason: "restore subtree", }, preview: { mode: "restore", totals: { affectedIssues: 1 }, warnings: [], activeRuns: [], }, }); mockTreeControlService.restoreIssueStatusesForHold.mockRejectedValue(new Error("restore failed")); mockTreeControlService.releaseHold.mockResolvedValue({ id: "66666666-6666-4666-8666-666666666666", mode: "restore", status: "released", }); const res = await request(app) .post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds") .send({ mode: "restore", reason: "restore subtree" }); expect(res.status).toBe(500); expect(mockTreeControlService.releaseHold).toHaveBeenCalledWith( "company-2", "11111111-1111-4111-8111-111111111111", "66666666-6666-4666-8666-666666666666", expect.objectContaining({ reason: "Restore operation failed before subtree status updates completed", metadata: { cleanup: "restore_failed_before_apply" }, }), ); }); it("returns resume operations as released holds and avoids cancellation side effects", async () => { const app = await createApp({ type: "board", userId: "user-1", companyIds: ["company-2"], source: "session", isInstanceAdmin: false, }); mockTreeControlService.createHold.mockResolvedValue({ hold: { id: "77777777-7777-4777-8777-777777777777", mode: "resume", status: "released", reason: "resume subtree", }, preview: { mode: "resume", totals: { affectedIssues: 1, }, warnings: [], activeRuns: [], }, resumedPauseHoldIds: ["33333333-3333-4333-8333-333333333333"], }); const res = await request(app) .post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds") .send({ mode: "resume", reason: "resume subtree" }); expect(res.status).toBe(200); expect(res.body.hold.mode).toBe("resume"); expect(res.body.hold.status).toBe("released"); expect(res.body.resumedPauseHoldIds).toEqual(["33333333-3333-4333-8333-333333333333"]); expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); expect(mockTreeControlService.cancelUnclaimedWakeupsForTree).not.toHaveBeenCalled(); expect(mockTreeControlService.cancelIssueStatusesForHold).not.toHaveBeenCalled(); expect(mockTreeControlService.restoreIssueStatusesForHold).not.toHaveBeenCalled(); }); });