import { afterEach, describe, expect, test, vi } from "vitest"; import { buildRetryJobOptions, buildTransactionalMailJob, queueNames, type TypedQueue } from "@hivegrid/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("@hivegrid/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

", text: "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", text: "Reset", 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("uses only an opaque verification idempotency key in BullMQ job identity and metadata", async () => { const add = vi.fn(async () => ({ id: "job_opaque" })); const queue = { add } as unknown as TypedQueue; const email = "ada@example.com"; const verificationToken = "raw-verification-capability"; const creationRequestKey = "d7077797-054b-497a-b1ce-8e4f36257a96"; const opaqueKey = "registration-verification:registration_1:184a82a6628ec5c7d863148d76f5598a"; await queueTransactionalEmail( { to: email, subject: "Verify your email", body: `Verify`, alias: "email-verification", idempotencyKey: opaqueKey, }, { queue } ); const [, payload, options] = add.mock.calls[0] ?? []; const serializedIdentity = JSON.stringify({ jobId: options?.jobId, metadata: payload?.metadata, }); expect(serializedIdentity).toContain(opaqueKey); expect(serializedIdentity).not.toContain(email); expect(serializedIdentity).not.toContain(verificationToken); expect(serializedIdentity).not.toContain(creationRequestKey); }); 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) ); }); });