301 lines
9.9 KiB
TypeScript
301 lines
9.9 KiB
TypeScript
import { describe, expect, test, vi } from "vitest";
|
|
import {
|
|
buildAiBrowserTaskJob,
|
|
buildAiChatCompletionJob,
|
|
buildAiEmbeddingJob,
|
|
buildBrowserSessionCleanupJob,
|
|
buildBrowserSessionRunJob,
|
|
buildBrowserUsageFlushJob,
|
|
buildCleanupJob,
|
|
buildExportJob,
|
|
buildFilePreviewJob,
|
|
buildFileScanJob,
|
|
buildImportJob,
|
|
buildNotificationJob,
|
|
buildPluginInvokeJob,
|
|
buildRetryJobOptions,
|
|
buildTransactionalMailJob,
|
|
buildWebhookDeliveryJob,
|
|
enqueueJob,
|
|
queueNames,
|
|
type ProducerJobDescriptor,
|
|
type QueueName,
|
|
type TypedQueue,
|
|
} from "../src/index.js";
|
|
import * as producerExports from "../src/producers.js";
|
|
|
|
type RetryMetrics = Pick<
|
|
ReturnType<typeof buildRetryJobOptions>,
|
|
"attempts" | "backoff" | "removeOnComplete" | "removeOnFail"
|
|
>;
|
|
|
|
const descriptorBuilders = {
|
|
buildAiBrowserTaskJob: () => buildAiBrowserTaskJob({ tenantId: "tenant_1", task: { action: "research" } }),
|
|
buildAiChatCompletionJob: () =>
|
|
buildAiChatCompletionJob({ tenantId: "tenant_1", messages: [{ role: "user", content: "Hello" }] }),
|
|
buildAiEmbeddingJob: () =>
|
|
buildAiEmbeddingJob({ tenantId: "tenant_1", messages: [{ role: "user", content: "Embed this" }] }),
|
|
buildBrowserSessionCleanupJob: () => buildBrowserSessionCleanupJob({ tenantId: "tenant_1", sessionId: "session_1" }),
|
|
buildBrowserSessionRunJob: () =>
|
|
buildBrowserSessionRunJob({ tenantId: "tenant_1", sessionId: "session_1", task: { action: "capture" } }),
|
|
buildBrowserUsageFlushJob: () => buildBrowserUsageFlushJob({ tenantId: "tenant_1", sessionId: "session_1" }),
|
|
buildCleanupJob: () => buildCleanupJob({ task: "expired-sessions" }),
|
|
buildExportJob: () =>
|
|
buildExportJob({
|
|
exportJobId: "export_job_metrics_1",
|
|
tenantId: "tenant_1",
|
|
requestedByUserId: "user_1",
|
|
exportType: "members",
|
|
format: "csv",
|
|
}),
|
|
buildFilePreviewJob: () =>
|
|
buildFilePreviewJob({ fileId: "file_1", tenantId: "tenant_1", bucket: "uploads", storageKey: "tenants/tenant_1/files/file_1/original", mimeType: "image/heic" }),
|
|
buildFileScanJob: () => buildFileScanJob({ bucket: "uploads", key: "tenant_1/upload_1/file.pdf" }),
|
|
buildImportJob: () =>
|
|
buildImportJob({
|
|
importJobId: "import_job_metrics_1",
|
|
tenantId: "tenant_1",
|
|
requestedByUserId: "user_1",
|
|
importType: "members",
|
|
format: "csv",
|
|
sourceBucket: "uploads",
|
|
sourceKey: "tenant_1/imports/members.csv",
|
|
}),
|
|
buildNotificationJob: () => buildNotificationJob({ tenantId: "tenant_1", userId: "user_1", channel: "in-app", messageKey: "message.ready" }),
|
|
buildPluginInvokeJob: () => buildPluginInvokeJob({ tenantId: "tenant_1", pluginId: "plugin_1", version: "1.0.0", hookName: "onEvent" }),
|
|
buildTransactionalMailJob: () => buildTransactionalMailJob({ to: ["user@example.com"], templateId: "welcome" }),
|
|
buildWebhookDeliveryJob: () => buildWebhookDeliveryJob({ url: "https://example.com/webhook", eventName: "entity.created" }),
|
|
} satisfies Record<string, () => ProducerJobDescriptor>;
|
|
|
|
function producerHelperExportNames(): string[] {
|
|
return Object.keys(producerExports)
|
|
.filter((name) => /^build[A-Z].*Job$/.test(name))
|
|
.sort();
|
|
}
|
|
|
|
function retryMetricsFor(descriptor: ProducerJobDescriptor): RetryMetrics {
|
|
const { attempts, backoff, removeOnComplete, removeOnFail } = buildRetryJobOptions(descriptor);
|
|
|
|
return { attempts, backoff, removeOnComplete, removeOnFail };
|
|
}
|
|
|
|
function sortedRetryMetricsSnapshot(): Record<string, RetryMetrics> {
|
|
return Object.fromEntries(
|
|
Object.values(descriptorBuilders)
|
|
.map((buildDescriptor) => {
|
|
const descriptor = buildDescriptor();
|
|
|
|
return [`${descriptor.queueName}:${descriptor.jobName}`, retryMetricsFor(descriptor)] as const;
|
|
})
|
|
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
|
|
);
|
|
}
|
|
|
|
function createMockQueue<Name extends QueueName>(): TypedQueue<Name> {
|
|
return { add: vi.fn().mockResolvedValue({ id: "queued-job" }) } as unknown as TypedQueue<Name>;
|
|
}
|
|
|
|
async function enqueueOptionsFor(descriptor: ProducerJobDescriptor, options?: Parameters<typeof enqueueJob>[2]) {
|
|
const queue = createMockQueue<typeof descriptor.queueName>();
|
|
|
|
await enqueueJob(queue, descriptor, options);
|
|
|
|
return vi.mocked(queue.add).mock.calls[0]?.[2];
|
|
}
|
|
|
|
describe("queue retry metrics snapshot", () => {
|
|
test("covers every exported producer descriptor helper", () => {
|
|
expect(Object.keys(descriptorBuilders).sort()).toEqual(producerHelperExportNames());
|
|
});
|
|
|
|
test("locks retry defaults for every queue producer descriptor", () => {
|
|
expect(sortedRetryMetricsSnapshot()).toMatchInlineSnapshot(`
|
|
{
|
|
"${queueNames.aiChatbot}:browser-task": {
|
|
"attempts": 2,
|
|
"backoff": {
|
|
"delay": 5000,
|
|
"type": "exponential",
|
|
},
|
|
"removeOnComplete": true,
|
|
"removeOnFail": 1000,
|
|
},
|
|
"${queueNames.aiChatbot}:chat-completion": {
|
|
"attempts": 3,
|
|
"backoff": {
|
|
"delay": 2000,
|
|
"type": "exponential",
|
|
},
|
|
"removeOnComplete": true,
|
|
"removeOnFail": 1000,
|
|
},
|
|
"${queueNames.aiChatbot}:create-embedding": {
|
|
"attempts": 3,
|
|
"backoff": {
|
|
"delay": 2000,
|
|
"type": "exponential",
|
|
},
|
|
"removeOnComplete": true,
|
|
"removeOnFail": 1000,
|
|
},
|
|
"${queueNames.browserAutomation}:browser-session-cleanup": {
|
|
"attempts": 3,
|
|
"backoff": {
|
|
"delay": 10000,
|
|
"type": "fixed",
|
|
},
|
|
"removeOnComplete": true,
|
|
"removeOnFail": 1000,
|
|
},
|
|
"${queueNames.browserAutomation}:browser-session-run": {
|
|
"attempts": 2,
|
|
"backoff": {
|
|
"delay": 5000,
|
|
"type": "exponential",
|
|
},
|
|
"removeOnComplete": true,
|
|
"removeOnFail": 1000,
|
|
},
|
|
"${queueNames.browserAutomation}:usage-flush": {
|
|
"attempts": 3,
|
|
"backoff": {
|
|
"delay": 10000,
|
|
"type": "fixed",
|
|
},
|
|
"removeOnComplete": true,
|
|
"removeOnFail": 1000,
|
|
},
|
|
"${queueNames.email}:deliver-webhook": {
|
|
"attempts": 5,
|
|
"backoff": {
|
|
"delay": 2000,
|
|
"type": "exponential",
|
|
},
|
|
"removeOnComplete": true,
|
|
"removeOnFail": 1000,
|
|
},
|
|
"${queueNames.email}:send-transactional-email": {
|
|
"attempts": 3,
|
|
"backoff": {
|
|
"delay": 1000,
|
|
"type": "exponential",
|
|
},
|
|
"removeOnComplete": true,
|
|
"removeOnFail": 1000,
|
|
},
|
|
"${queueNames.exports}:run-export": {
|
|
"attempts": 2,
|
|
"backoff": {
|
|
"delay": 10000,
|
|
"type": "exponential",
|
|
},
|
|
"removeOnComplete": true,
|
|
"removeOnFail": 1000,
|
|
},
|
|
"${queueNames.filePreview}:generate-file-preview": {
|
|
"attempts": 3,
|
|
"backoff": {
|
|
"delay": 3000,
|
|
"type": "exponential",
|
|
},
|
|
"removeOnComplete": true,
|
|
"removeOnFail": 1000,
|
|
},
|
|
"${queueNames.fileScan}:scan-file": {
|
|
"attempts": 3,
|
|
"backoff": {
|
|
"delay": 2000,
|
|
"type": "exponential",
|
|
},
|
|
"removeOnComplete": true,
|
|
"removeOnFail": 1000,
|
|
},
|
|
"${queueNames.imports}:run-import": {
|
|
"attempts": 2,
|
|
"backoff": {
|
|
"delay": 10000,
|
|
"type": "exponential",
|
|
},
|
|
"removeOnComplete": true,
|
|
"removeOnFail": 1000,
|
|
},
|
|
"${queueNames.maintenance}:run-cleanup": {
|
|
"attempts": 2,
|
|
"backoff": {
|
|
"delay": 5000,
|
|
"type": "fixed",
|
|
},
|
|
"removeOnComplete": true,
|
|
"removeOnFail": 1000,
|
|
},
|
|
"${queueNames.notifications}:send-notification": {
|
|
"attempts": 3,
|
|
"backoff": {
|
|
"delay": 1000,
|
|
"type": "exponential",
|
|
},
|
|
"removeOnComplete": true,
|
|
"removeOnFail": 1000,
|
|
},
|
|
"${queueNames.pluginInvoke}:invoke-plugin": {
|
|
"attempts": 3,
|
|
"backoff": {
|
|
"delay": 2000,
|
|
"type": "exponential",
|
|
},
|
|
"removeOnComplete": true,
|
|
"removeOnFail": 1000,
|
|
},
|
|
}
|
|
`);
|
|
});
|
|
});
|
|
|
|
describe("queue idempotency diagnostics", () => {
|
|
test("uses an explicit jobId before an idempotencyKey", async () => {
|
|
const options = await enqueueOptionsFor(buildTransactionalMailJob({ to: ["user@example.com"], templateId: "welcome" }), {
|
|
idempotencyKey: "idempotency-key",
|
|
jobId: "explicit-job-id",
|
|
});
|
|
|
|
expect(options?.jobId).toBe("explicit-job-id");
|
|
expect(options).not.toHaveProperty("idempotencyKey");
|
|
});
|
|
|
|
test("uses an explicit idempotencyKey before payload metadata", async () => {
|
|
const options = await enqueueOptionsFor(
|
|
buildNotificationJob({
|
|
tenantId: "tenant_1",
|
|
userId: "user_1",
|
|
channel: "in-app",
|
|
messageKey: "message.ready",
|
|
metadata: { idempotencyKey: "metadata-key" },
|
|
}),
|
|
{ idempotencyKey: "explicit-idempotency-key" }
|
|
);
|
|
|
|
expect(options?.jobId).toBe("explicit-idempotency-key");
|
|
expect(options).not.toHaveProperty("idempotencyKey");
|
|
});
|
|
|
|
test("uses metadata idempotencyKey when no explicit key is present", async () => {
|
|
const options = await enqueueOptionsFor(
|
|
buildCleanupJob({ task: "temporary-files", metadata: { idempotencyKey: "metadata-key" } })
|
|
);
|
|
|
|
expect(options?.jobId).toBe("metadata-key");
|
|
});
|
|
|
|
test("does not derive jobId values from body payloads", async () => {
|
|
const options = await enqueueOptionsFor(
|
|
buildWebhookDeliveryJob({
|
|
url: "https://example.com/webhook",
|
|
eventName: "entity.created",
|
|
body: { idempotencyKey: "unsafe-body-key", jobId: "unsafe-body-job-id" },
|
|
})
|
|
);
|
|
|
|
expect(options).not.toHaveProperty("jobId");
|
|
});
|
|
});
|