Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/emails/services/TransactionalEmailQueue.server.test.ts
T

180 lines
5.5 KiB
TypeScript

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: "<p>Reset</p>",
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<typeof queueNames.email>;
const result = await queueTransactionalEmail(
{
to: "user@example.com",
subject: "Reset password",
body: "<p>Reset</p>",
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: "<p>Reset</p>",
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: "<p>Reset</p>",
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<typeof queueNames.email>;
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: `<a href="https://example.test/verify/${verificationToken}">Verify</a>`,
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<typeof queueNames.email>;
await expect(
queueTransactionalEmail(
{
to: "user@example.com",
subject: "Welcome",
body: "<p>Welcome</p>",
alias: "registration-welcome",
data: { firstName: "Ada" },
},
{
queue,
}
)
).rejects.toThrow(enqueueError);
expect(add).toHaveBeenCalledOnce();
const descriptor = buildTransactionalMailJob({
to: ["user@example.com"],
subject: "Welcome",
html: "<p>Welcome</p>",
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)
);
});
});