import { beforeEach, describe, expect, it, mock } from "bun:test"; import { processDelayedTasks } from "./processDelayedTasks"; describe("processDelayedTasks", () => { let mockEnv: Cloudflare.Env; let mockCtx: ExecutionContext; let kvGetSpy: ReturnType; let kvDeleteSpy: ReturnType; let kvPutSpy: ReturnType; let queueSendSpy: ReturnType; beforeEach(() => { kvGetSpy = mock(() => Promise.resolve(null)); kvDeleteSpy = mock(() => Promise.resolve()); kvPutSpy = mock(() => Promise.resolve()); queueSendSpy = mock(() => Promise.resolve()); mockEnv = { DELAYED_TASKS: { get: kvGetSpy, delete: kvDeleteSpy, put: kvPutSpy, list: mock(() => Promise.resolve({ keys: [], list_complete: true })), getWithMetadata: mock(() => Promise.resolve({ value: null, metadata: null }), ), } as any, NEW_EPISODE: { send: queueSendSpy, } as any, ANILIST_UPDATES: { send: mock(() => Promise.resolve()), } as any, } as any; mockCtx = { waitUntil: mock(() => {}), passThroughOnException: mock(() => {}), } as any; }); it("handles empty KV namespace", async () => { await processDelayedTasks(mockEnv, mockCtx); expect(kvDeleteSpy).not.toHaveBeenCalled(); expect(queueSendSpy).not.toHaveBeenCalled(); }); it("queues tasks within 12 hours of scheduled time", async () => { const now = Math.floor(Date.now() / 1000); const scheduledTime = now + 6 * 3600; // 6 hours from now const taskMetadata = { queueName: "NEW_EPISODE", body: { aniListId: 123, episodeNumber: 1 }, headers: { "Content-Type": "application/json" }, scheduledEpochTime: scheduledTime, taskId: "task-1", createdAt: now - 18 * 3600, retryCount: 0, }; mockEnv.DELAYED_TASKS.list = mock(() => Promise.resolve({ keys: [{ name: `delayed-task:${scheduledTime}:task-1` }], list_complete: true, }), ); kvGetSpy.mockReturnValue(Promise.resolve(JSON.stringify(taskMetadata))); await processDelayedTasks(mockEnv, mockCtx); expect(queueSendSpy).toHaveBeenCalledTimes(1); expect(kvDeleteSpy).toHaveBeenCalledTimes(1); expect(kvDeleteSpy).toHaveBeenCalledWith( `delayed-task:${scheduledTime}:task-1`, ); }); it("does not queue tasks beyond 12 hours", async () => { const now = Math.floor(Date.now() / 1000); const scheduledTime = now + 24 * 3600; // 24 hours from now const taskMetadata = { queueName: "NEW_EPISODE", body: { aniListId: 456, episodeNumber: 2 }, headers: { "Content-Type": "application/json" }, scheduledEpochTime: scheduledTime, taskId: "task-2", createdAt: now, retryCount: 0, }; mockEnv.DELAYED_TASKS.list = mock(() => Promise.resolve({ keys: [{ name: `delayed-task:${scheduledTime}:task-2` }], list_complete: true, }), ); kvGetSpy.mockReturnValue(Promise.resolve(JSON.stringify(taskMetadata))); await processDelayedTasks(mockEnv, mockCtx); expect(queueSendSpy).not.toHaveBeenCalled(); expect(kvDeleteSpy).not.toHaveBeenCalled(); }); it("increments retry count on queue failure", async () => { const now = Math.floor(Date.now() / 1000); const scheduledTime = now + 1 * 3600; // 1 hour from now const taskMetadata = { queueName: "NEW_EPISODE", body: { aniListId: 789, episodeNumber: 3 }, headers: { "Content-Type": "application/json" }, scheduledEpochTime: scheduledTime, taskId: "task-3", createdAt: now - 23 * 3600, retryCount: 0, }; mockEnv.DELAYED_TASKS.list = mock(() => Promise.resolve({ keys: [{ name: `delayed-task:${scheduledTime}:task-3` }], list_complete: true, }), ); kvGetSpy.mockReturnValue(Promise.resolve(JSON.stringify(taskMetadata))); queueSendSpy.mockRejectedValue(new Error("Queue error")); await processDelayedTasks(mockEnv, mockCtx); expect(kvPutSpy).toHaveBeenCalledTimes(1); const updatedMetadata = JSON.parse(kvPutSpy.mock.calls[0][1]); expect(updatedMetadata.retryCount).toBe(1); expect(kvDeleteSpy).not.toHaveBeenCalled(); }); it("logs alert after 3 failed attempts", async () => { const consoleErrorSpy = mock(() => {}); const originalConsoleError = console.error; console.error = consoleErrorSpy as any; const now = Math.floor(Date.now() / 1000); const scheduledTime = now + 1 * 3600; const taskMetadata = { queueName: "NEW_EPISODE", body: { aniListId: 999, episodeNumber: 4 }, headers: { "Content-Type": "application/json" }, scheduledEpochTime: scheduledTime, taskId: "task-4", createdAt: now - 23 * 3600, retryCount: 2, // Will become 3 after this failure }; mockEnv.DELAYED_TASKS.list = mock(() => Promise.resolve({ keys: [{ name: `delayed-task:${scheduledTime}:task-4` }], list_complete: true, }), ); kvGetSpy.mockReturnValue(Promise.resolve(JSON.stringify(taskMetadata))); queueSendSpy.mockRejectedValue(new Error("Queue error")); await processDelayedTasks(mockEnv, mockCtx); // Check that alert was logged const alertCalls = consoleErrorSpy.mock.calls.filter((call: any) => call[0]?.includes("🚨 ALERT"), ); expect(alertCalls.length).toBeGreaterThan(0); console.error = originalConsoleError; }); it("handles multiple tasks in single cron run", async () => { const now = Math.floor(Date.now() / 1000); const task1Metadata = { queueName: "NEW_EPISODE", body: { aniListId: 100, episodeNumber: 1 }, headers: { "Content-Type": "application/json" }, scheduledEpochTime: now + 2 * 3600, taskId: "task-1", createdAt: now - 20 * 3600, retryCount: 0, }; const task2Metadata = { queueName: "NEW_EPISODE", body: { aniListId: 200, episodeNumber: 2 }, headers: { "Content-Type": "application/json" }, scheduledEpochTime: now + 5 * 3600, taskId: "task-2", createdAt: now - 19 * 3600, retryCount: 0, }; mockEnv.DELAYED_TASKS.list = mock(() => Promise.resolve({ keys: [ { name: `delayed-task:${task1Metadata.scheduledEpochTime}:task-1` }, { name: `delayed-task:${task2Metadata.scheduledEpochTime}:task-2` }, ], list_complete: true, }), ); kvGetSpy .mockReturnValueOnce(Promise.resolve(JSON.stringify(task1Metadata))) .mockReturnValueOnce(Promise.resolve(JSON.stringify(task2Metadata))); await processDelayedTasks(mockEnv, mockCtx); expect(queueSendSpy).toHaveBeenCalledTimes(2); expect(kvDeleteSpy).toHaveBeenCalledTimes(2); }); it("skips tasks with null values in KV", async () => { mockEnv.DELAYED_TASKS.list = mock(() => Promise.resolve({ keys: [{ name: "delayed-task:123:invalid" }], list_complete: true, }), ); kvGetSpy.mockReturnValue(Promise.resolve(null)); await processDelayedTasks(mockEnv, mockCtx); expect(queueSendSpy).not.toHaveBeenCalled(); expect(kvDeleteSpy).not.toHaveBeenCalled(); }); });