241 lines
7.1 KiB
TypeScript
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();
|
|
});
|
|
});
|