89 lines
3.9 KiB
TypeScript
89 lines
3.9 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const sendMail = vi.fn();
|
|
vi.mock("nodemailer", () => ({
|
|
default: { createTransport: vi.fn(() => ({ sendMail })) },
|
|
}));
|
|
|
|
const { BrevoEmailProvider } = await import("../brevo-email-provider");
|
|
|
|
/**
|
|
* Exercises BrevoEmailProvider's retry classification (see isRetryable in
|
|
* brevo-email-provider.ts) with a mocked nodemailer transporter — no real
|
|
* SMTP connection is ever made in this test file, matching the Phase 7
|
|
* brief's "no real API calls in automated tests" requirement.
|
|
*/
|
|
describe("BrevoEmailProvider", () => {
|
|
const provider = new BrevoEmailProvider({
|
|
host: "smtp-relay.brevo.com",
|
|
port: 587,
|
|
username: "test-user",
|
|
password: "test-pass",
|
|
fromAddress: "noreply@example.com",
|
|
});
|
|
|
|
beforeEach(() => {
|
|
sendMail.mockReset();
|
|
});
|
|
|
|
const params = {
|
|
to: { email: "customer@example.com" },
|
|
sender: { name: "Blake Heritage Barbershop" },
|
|
subject: "Hi",
|
|
html: "<p>Hi</p>",
|
|
text: "Hi",
|
|
};
|
|
|
|
it("reports success with the provider message id on a clean send", async () => {
|
|
sendMail.mockResolvedValueOnce({ messageId: "<abc123@smtp-relay.brevo.com>" });
|
|
const result = await provider.sendEmail(params);
|
|
expect(result).toEqual({ status: "sent", providerMessageId: "<abc123@smtp-relay.brevo.com>" });
|
|
});
|
|
|
|
it("classifies a connection timeout as retryable", async () => {
|
|
sendMail.mockRejectedValueOnce({ code: "ETIMEDOUT", message: "Connection timed out" });
|
|
const result = await provider.sendEmail(params);
|
|
expect(result).toEqual({ status: "failed", retryable: true, reason: "Connection timed out" });
|
|
});
|
|
|
|
it("classifies a 4xx SMTP response (rate limit / temporary) as retryable", async () => {
|
|
sendMail.mockRejectedValueOnce({ responseCode: 421, message: "Too many connections" });
|
|
const result = await provider.sendEmail(params);
|
|
expect(result).toEqual({ status: "failed", retryable: true, reason: "Too many connections" });
|
|
});
|
|
|
|
it("classifies a 5xx SMTP response (invalid recipient / rejected) as permanent, not retryable", async () => {
|
|
sendMail.mockRejectedValueOnce({ responseCode: 550, message: "Mailbox unavailable" });
|
|
const result = await provider.sendEmail(params);
|
|
expect(result).toEqual({ status: "failed", retryable: false, reason: "Mailbox unavailable" });
|
|
});
|
|
|
|
it("classifies an auth failure (535, permanent per SMTP) as not retryable", async () => {
|
|
sendMail.mockRejectedValueOnce({ responseCode: 535, message: "Authentication failed" });
|
|
const result = await provider.sendEmail(params);
|
|
expect(result.status).toBe("failed");
|
|
expect((result as { retryable: boolean }).retryable).toBe(false);
|
|
});
|
|
|
|
it("defaults an unrecognized error to retryable, bounded later by the background processor's max-attempt cap", async () => {
|
|
sendMail.mockRejectedValueOnce({ message: "Something unexpected" });
|
|
const result = await provider.sendEmail(params);
|
|
expect(result).toEqual({ status: "failed", retryable: true, reason: "Something unexpected" });
|
|
});
|
|
|
|
it("never includes the SMTP username/password in a failure result", async () => {
|
|
sendMail.mockRejectedValueOnce({ responseCode: 535, message: "Authentication failed" });
|
|
const result = await provider.sendEmail(params);
|
|
expect(JSON.stringify(result)).not.toContain("test-pass");
|
|
expect(JSON.stringify(result)).not.toContain("test-user");
|
|
});
|
|
|
|
it("sends with the per-message sender name and the provider's own configured address, ignoring any sender.email", async () => {
|
|
sendMail.mockResolvedValueOnce({ messageId: "<abc123@smtp-relay.brevo.com>" });
|
|
await provider.sendEmail({ ...params, sender: { name: "Riverside Barbershop", email: "someone-else@other-domain.com" } });
|
|
expect(sendMail).toHaveBeenCalledWith(
|
|
expect.objectContaining({ from: { address: "noreply@example.com", name: "Riverside Barbershop" } }),
|
|
);
|
|
});
|
|
});
|