import { describe, expect, it, vi } from "vitest"; import { finalizeRoutineFailureNotification, finalizeRoutineNotification, handleRoutineSelfHealing, MAX_ROUTINE_ATTEMPTS, } from "./executor-routine.js"; import type { ExecutorDeps } from "./executor-types.js"; describe("finalizeRoutineNotification", () => { it("suppresses notification and unread badge when on_change detects no delta", async () => { const notifySend = vi.fn().mockResolvedValue(true); const mockDeps = { notifications: { send: notifySend }, prisma: { run: { findFirst: vi.fn().mockResolvedValue({ bot: { notifyOnFinish: true }, thread: { groupId: null }, }), }, }, } as unknown as ExecutorDeps; const result = await finalizeRoutineNotification({ deps: mockDeps, run: { id: "run-1", spaceId: "space-1", botId: "bot-1", threadId: "thread-1", userId: "user-1", routineId: "routine-1", }, bot: { id: "bot-1", name: "Monitor Bot" }, routine: { id: "routine-1", name: "Health Check", notify: true }, notifyMode: "on_change", outputText: "All systems operational", hasChanged: false, }); expect(result.markUnread).toBe(false); expect(result.isQuiet).toBe(true); expect(notifySend).not.toHaveBeenCalled(); }); it("dispatches notification and sets unread when on_change detects delta", async () => { const notifySend = vi.fn().mockResolvedValue(true); const mockDeps = { notifications: { send: notifySend }, prisma: { run: { findFirst: vi.fn().mockResolvedValue({ bot: { notifyOnFinish: true }, thread: { groupId: null }, }), }, }, } as unknown as ExecutorDeps; const result = await finalizeRoutineNotification({ deps: mockDeps, run: { id: "run-1", spaceId: "space-1", botId: "bot-1", threadId: "thread-1", userId: "user-1", routineId: "routine-1", }, bot: { id: "bot-1", name: "Monitor Bot" }, routine: { id: "routine-1", name: "Health Check", notify: true }, notifyMode: "on_change", outputText: "New alert: CPU high", hasChanged: true, deltaSummary: "CPU high", }); expect(result.markUnread).toBe(true); expect(result.isQuiet).toBe(false); expect(notifySend).toHaveBeenCalledTimes(1); expect(notifySend).toHaveBeenCalledWith( expect.objectContaining({ kind: "completion", title: "Monitor Bot", body: "CPU high", }), expect.anything(), ); }); it("suppresses notifications when routine.notify is false", async () => { const notifySend = vi.fn().mockResolvedValue(true); const mockDeps = { notifications: { send: notifySend }, prisma: { run: { findFirst: vi.fn().mockResolvedValue({ bot: { notifyOnFinish: true }, thread: { groupId: null }, }), }, }, } as unknown as ExecutorDeps; const result = await finalizeRoutineNotification({ deps: mockDeps, run: { id: "run-1", spaceId: "space-1", botId: "bot-1", threadId: "thread-1", userId: "user-1", routineId: "routine-1", }, bot: { id: "bot-1", name: "Monitor Bot" }, routine: { id: "routine-1", name: "Health Check", notify: false }, notifyMode: "always", outputText: "Changed output", hasChanged: true, }); expect(result.markUnread).toBe(false); expect(result.isQuiet).toBe(true); expect(notifySend).not.toHaveBeenCalled(); }); }); describe("finalizeRoutineFailureNotification", () => { it("dispatches notification on failure when notifyMode permits", async () => { const notifySend = vi.fn().mockResolvedValue(true); const mockDeps = { notifications: { send: notifySend }, prisma: { run: { findFirst: vi.fn().mockResolvedValue({ bot: { notifyOnFinish: true }, thread: { groupId: null }, }), }, }, } as unknown as ExecutorDeps; const notified = await finalizeRoutineFailureNotification( mockDeps, { spaceId: "space-1", botId: "bot-1", threadId: "thread-1", userId: "user-1", routineId: "routine-1", }, { id: "bot-1", name: "Monitor Bot" }, { id: "routine-1", name: "Health Check", notify: true }, "on_failure", "Network connection refused", ); expect(notified).toBe(true); expect(notifySend).toHaveBeenCalledTimes(1); }); it("suppresses failure notification when notifyMode is never", async () => { const notifySend = vi.fn().mockResolvedValue(true); const mockDeps = { notifications: { send: notifySend }, } as unknown as ExecutorDeps; const notified = await finalizeRoutineFailureNotification( mockDeps, { spaceId: "space-1", botId: "bot-1", threadId: "thread-1", userId: "user-1", routineId: "routine-1", }, { id: "bot-1", name: "Monitor Bot" }, { id: "routine-1", name: "Health Check", notify: true }, "never", "Network connection refused", ); expect(notified).toBe(false); expect(notifySend).not.toHaveBeenCalled(); }); }); describe("handleRoutineSelfHealing", () => { it("requeues transient error with backoff when under MAX_ROUTINE_ATTEMPTS", async () => { const updateRun = vi.fn().mockResolvedValue({}); const enqueueJob = vi.fn().mockResolvedValue({}); const mockDeps = { prisma: { run: { update: updateRun } }, jobs: { enqueue: enqueueJob }, } as unknown as ExecutorDeps; const recovered = await handleRoutineSelfHealing( mockDeps, "run-123", 1, new Error("ECONNRESET: socket hang up"), ); expect(recovered).toBe(true); expect(updateRun).toHaveBeenCalledWith({ where: { id: "run-123" }, data: expect.objectContaining({ status: "queued", leaseOwner: null, leaseExpiresAt: null, }), }); expect(enqueueJob).toHaveBeenCalledWith( expect.objectContaining({ name: "run.continue", payload: { runId: "run-123" }, availableAt: expect.any(Date), }), ); }); it("fails fast on terminal errors without retrying", async () => { const updateRun = vi.fn(); const enqueueJob = vi.fn(); const mockDeps = { prisma: { run: { update: updateRun } }, jobs: { enqueue: enqueueJob }, } as unknown as ExecutorDeps; const recovered = await handleRoutineSelfHealing(mockDeps, "run-123", 1, { status: 401, message: "Unauthorized API key", }); expect(recovered).toBe(false); expect(updateRun).not.toHaveBeenCalled(); expect(enqueueJob).not.toHaveBeenCalled(); }); it("does not retry when attemptNumber reaches MAX_ROUTINE_ATTEMPTS", async () => { const updateRun = vi.fn(); const enqueueJob = vi.fn(); const mockDeps = { prisma: { run: { update: updateRun } }, jobs: { enqueue: enqueueJob }, } as unknown as ExecutorDeps; const recovered = await handleRoutineSelfHealing( mockDeps, "run-123", MAX_ROUTINE_ATTEMPTS, new Error("fetch failed"), ); expect(recovered).toBe(false); expect(updateRun).not.toHaveBeenCalled(); expect(enqueueJob).not.toHaveBeenCalled(); }); });