sendEmail caught transport failures internally and resolved to false or undefined, so every caller's .catch() was dead code and send failures were silently suppressed. Password recovery in particular reported "email sent successfully" while the user received nothing. sendEmail now throws an AppError carrying the underlying cause, and returns Promise<string> instead of Promise<string | false | undefined>. Call sites updated for the new contract: - UserService.registerUser: the existing .catch() now actually fires - UserService.requestRecovery: the failure reaches the client instead of a misleading success response - InviteService and the test-email endpoint: keep their user-facing messages and attach the SMTP cause - EmailProvider: catches and returns false, because NotificationsService fans providers out through Promise.all and a rejection there would abort the other channels' alerts Closes #3757
152 lines
8.0 KiB
TypeScript
152 lines
8.0 KiB
TypeScript
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
|
import { EmailProvider } from "../../../../src/domain/notifications/providers/email.ts";
|
|
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
|
import { makeNotification, makeMessage } from "../../../helpers/notificationMessage.ts";
|
|
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
|
|
import type { IEmailService } from "../../../../src/service/emailService.ts";
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
const createMockEmailService = () =>
|
|
({
|
|
buildEmail: jest.fn().mockResolvedValue("<html>email</html>"),
|
|
sendEmail: jest.fn().mockResolvedValue("msg-123"),
|
|
}) as unknown as jest.Mocked<IEmailService>;
|
|
|
|
const createProvider = () => {
|
|
const logger = createMockLogger();
|
|
const emailService = createMockEmailService();
|
|
const provider = new EmailProvider(emailService, logger as any);
|
|
return { provider, logger, emailService };
|
|
};
|
|
|
|
testNotificationProviderContract("EmailProvider", {
|
|
create: () => createProvider().provider,
|
|
makeNotification: () => makeNotification(),
|
|
});
|
|
|
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
describe("EmailProvider", () => {
|
|
describe("sendTestAlert", () => {
|
|
it("builds test email and sends it", async () => {
|
|
const { provider, emailService } = createProvider();
|
|
const result = await provider.sendTestAlert(makeNotification());
|
|
expect(result).toBe(true);
|
|
expect(emailService.buildEmail).toHaveBeenCalledWith("testEmailTemplate", { testName: "Monitoring System" });
|
|
expect(emailService.sendEmail).toHaveBeenCalledWith("https://hooks.example.com/webhook", "Test notification", "<html>email</html>");
|
|
});
|
|
|
|
it("returns false when address is missing", async () => {
|
|
const { provider, logger } = createProvider();
|
|
expect(await provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
|
|
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Missing address" }));
|
|
});
|
|
|
|
it("returns false when buildEmail returns undefined", async () => {
|
|
const { provider, emailService, logger } = createProvider();
|
|
(emailService.buildEmail as jest.Mock).mockResolvedValue(undefined);
|
|
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
|
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build test email content" }));
|
|
});
|
|
|
|
it("returns false when sendEmail rejects", async () => {
|
|
const { provider, emailService, logger } = createProvider();
|
|
(emailService.sendEmail as jest.Mock).mockRejectedValue(new Error("SMTP auth failed"));
|
|
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
|
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Email test alert failed" }));
|
|
});
|
|
});
|
|
|
|
describe("sendMessage", () => {
|
|
it("builds email from message and sends it", async () => {
|
|
const { provider, emailService } = createProvider();
|
|
const result = await provider.sendMessage(makeNotification() as any, makeMessage());
|
|
expect(result).toBe(true);
|
|
expect(emailService.buildEmail).toHaveBeenCalledWith(
|
|
"unifiedNotificationTemplate",
|
|
expect.objectContaining({ title: "Monitor Down: Test Monitor" })
|
|
);
|
|
expect(emailService.sendEmail).toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns false when address is missing", async () => {
|
|
const { provider } = createProvider();
|
|
expect(await provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
|
|
});
|
|
|
|
it("returns false when buildEmail returns undefined", async () => {
|
|
const { provider, emailService, logger } = createProvider();
|
|
(emailService.buildEmail as jest.Mock).mockResolvedValue(undefined);
|
|
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
|
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build email content" }));
|
|
});
|
|
|
|
// NotificationsService fans providers out through Promise.all, so a failed email must
|
|
// resolve to false rather than reject and take the other channels down with it.
|
|
it("returns false when sendEmail rejects", async () => {
|
|
const { provider, emailService, logger } = createProvider();
|
|
(emailService.sendEmail as jest.Mock).mockRejectedValue(new Error("SMTP auth failed"));
|
|
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
|
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Email notification failed" }));
|
|
});
|
|
|
|
it("builds correct subject for monitor_down", async () => {
|
|
const { provider, emailService } = createProvider();
|
|
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "monitor_down" }));
|
|
expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Monitor Test Monitor is down", expect.anything());
|
|
});
|
|
|
|
it("builds correct subject for monitor_up", async () => {
|
|
const { provider, emailService } = createProvider();
|
|
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "monitor_up" }));
|
|
expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Monitor Test Monitor is back up", expect.anything());
|
|
});
|
|
|
|
it("builds correct subject for threshold_breach", async () => {
|
|
const { provider, emailService } = createProvider();
|
|
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "threshold_breach" }));
|
|
expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Monitor Test Monitor threshold exceeded", expect.anything());
|
|
});
|
|
|
|
it("builds correct subject for threshold_resolved", async () => {
|
|
const { provider, emailService } = createProvider();
|
|
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "threshold_resolved" }));
|
|
expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Monitor Test Monitor thresholds resolved", expect.anything());
|
|
});
|
|
|
|
it("builds default subject for unknown type", async () => {
|
|
const { provider, emailService } = createProvider();
|
|
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "test" }));
|
|
expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Alert: Test Monitor", expect.anything());
|
|
});
|
|
|
|
it("passes incident url in context when present", async () => {
|
|
const { provider, emailService } = createProvider();
|
|
const msg = makeMessage();
|
|
msg.content.incident = { id: "inc-1", url: "https://app.example.com/incidents/inc-1", createdAt: new Date() };
|
|
await provider.sendMessage(makeNotification() as any, msg);
|
|
expect(emailService.buildEmail).toHaveBeenCalledWith(
|
|
"unifiedNotificationTemplate",
|
|
expect.objectContaining({ incidentUrl: "https://app.example.com/incidents/inc-1" })
|
|
);
|
|
});
|
|
|
|
it("maps all severity levels to colors", async () => {
|
|
const { provider, emailService } = createProvider();
|
|
const severityColors: Record<string, string> = { critical: "red", warning: "#f59e0b", info: "#3b82f6", success: "green" };
|
|
for (const [severity, color] of Object.entries(severityColors)) {
|
|
(emailService.buildEmail as jest.Mock).mockClear();
|
|
await provider.sendMessage(makeNotification() as any, makeMessage({ severity: severity as any }));
|
|
expect(emailService.buildEmail).toHaveBeenCalledWith("unifiedNotificationTemplate", expect.objectContaining({ headerColor: color }));
|
|
}
|
|
});
|
|
|
|
it("uses default color for unknown severity", async () => {
|
|
const { provider, emailService } = createProvider();
|
|
await provider.sendMessage(makeNotification() as any, makeMessage({ severity: "unknown" as any }));
|
|
expect(emailService.buildEmail).toHaveBeenCalledWith("unifiedNotificationTemplate", expect.objectContaining({ headerColor: "#3b82f6" }));
|
|
});
|
|
});
|
|
});
|