import { afterEach, describe, expect, test, vi } from "vitest"; import { buildRetryJobOptions, buildTransactionalMailJob, queueNames, type TypedQueue } from "@toolctrl/queue"; import { queueTransactionalEmail } from "./TransactionalEmailQueue.server"; const runtimeQueue = { add: vi.fn(async () => ({ id: "job_from_env" })), close: vi.fn(async () => undefined), }; const originalRedisEnv = { BULLMQ_REDIS_URL: process.env.BULLMQ_REDIS_URL, REDIS_URL: process.env.REDIS_URL, }; function restoreRedisEnv() { for (const [key, value] of Object.entries(originalRedisEnv)) { if (value === undefined) { delete process.env[key]; } else { process.env[key] = value; } } } vi.mock("@toolctrl/queue/runtime", () => ({ createQueue: vi.fn(() => runtimeQueue), })); afterEach(() => { vi.clearAllMocks(); restoreRedisEnv(); }); describe("queueTransactionalEmail", () => { test("fails closed without Redis configuration", async () => { await expect( queueTransactionalEmail( { to: "user@example.com", subject: "Reset password", body: "

Reset

", alias: "password-reset", }, { env: {}, } ) ).rejects.toThrow("Redis connection required to enqueue transactional email."); }); test("enqueues transactional email payloads", async () => { const add = vi.fn(async () => ({ id: "job_1" })); const queue = { add } as unknown as TypedQueue; const result = await queueTransactionalEmail( { to: "user@example.com", subject: "Reset password", body: "

Reset

", alias: "password-reset", tenantId: "tenant_1", userId: "user_1", data: { action_url: "https://example.com/reset" }, }, { queue, } ); expect(result).toEqual({ enqueued: true, jobId: "job_1" }); expect(add).toHaveBeenCalledOnce(); const descriptor = buildTransactionalMailJob({ to: ["user@example.com"], subject: "Reset password", html: "

Reset

", templateData: { action_url: "https://example.com/reset" }, tenantId: "tenant_1", userId: "user_1", metadata: { alias: "password-reset" }, }); expect(add).toHaveBeenCalledWith( "send-transactional-email", descriptor.payload, expect.objectContaining({ ...buildRetryJobOptions(descriptor), // priority is resolved from tenantId; without a registered plan // lookup it falls back to NORMAL (5). priority: 5, }) ); }); test("uses process Redis configuration by default", async () => { process.env.BULLMQ_REDIS_URL = "redis://localhost:6379/2"; const result = await queueTransactionalEmail({ to: "user@example.com", subject: "Reset password", body: "

Reset

", alias: "password-reset", }); expect(result).toEqual({ enqueued: true, jobId: "job_from_env" }); expect(runtimeQueue.add).toHaveBeenCalledOnce(); expect(runtimeQueue.close).toHaveBeenCalledOnce(); }); test("propagates enqueue failures without synchronous mail fallback", async () => { const enqueueError = new Error("queue unavailable"); const add = vi.fn(async () => { throw enqueueError; }); const queue = { add } as unknown as TypedQueue; await expect( queueTransactionalEmail( { to: "user@example.com", subject: "Welcome", body: "

Welcome

", alias: "registration-welcome", data: { firstName: "Ada" }, }, { queue, } ) ).rejects.toThrow(enqueueError); expect(add).toHaveBeenCalledOnce(); const descriptor = buildTransactionalMailJob({ to: ["user@example.com"], subject: "Welcome", html: "

Welcome

", templateData: { firstName: "Ada" }, metadata: { alias: "registration-welcome" }, }); expect(add).toHaveBeenCalledWith( "send-transactional-email", expect.objectContaining({ kind: "mail.transactional", templateData: { firstName: "Ada" }, metadata: { alias: "registration-welcome" }, }), buildRetryJobOptions(descriptor) ); }); });