Files
aniplay-api/src/libs/tasks/processDelayedTasks.spec.ts

241 lines
7.1 KiB
TypeScript

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<typeof mock>;
let kvDeleteSpy: ReturnType<typeof mock>;
let kvPutSpy: ReturnType<typeof mock>;
let queueSendSpy: ReturnType<typeof mock>;
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();
});
});