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: "

Hi

", text: "Hi", }; it("reports success with the provider message id on a clean send", async () => { sendMail.mockResolvedValueOnce({ messageId: "" }); const result = await provider.sendEmail(params); expect(result).toEqual({ status: "sent", providerMessageId: "" }); }); 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: "" }); 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" } }), ); }); });