add notificationsService tests

This commit is contained in:
Alex Holliday committed 2026-04-08 11:32:08 -07:00
1 parent 7329306ccf
commit ba5368f942
9 files changed
+806 -31

No files matched your search

+1
View File
@@ -6,6 +6,7 @@
"type": "module",
"scripts": {
"test": "NODE_OPTIONS=--experimental-vm-modules jest --coverage",
"test:services": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/service/business/*.ts' --collectCoverageFrom='src/service/infrastructure/statusService.ts' --collectCoverageFrom='src/service/infrastructure/notificationsService.ts' --collectCoverageFrom='src/service/infrastructure/SuperSimpleQueue/*.ts' test/unit/services",
"dev": "nodemon --exec tsx src/index.js",
"start": "node --watch ./dist/index.js",
"build": "tsc && tsc-alias && cp -r src/templates dist/templates",
+10 -10
View File
@@ -107,16 +107,16 @@ export class CheckService implements ICheckService {
numericUnit: audit.numericUnit,
};
};
check.accessibility = (categories?.accessibility?.score || 0) * 100;
check.bestPractices = (categories?.["best-practices"]?.score || 0) * 100;
check.seo = (categories?.seo?.score || 0) * 100;
check.performance = (categories?.performance?.score || 0) * 100;
check.accessibility = (categories.accessibility?.score || 0) * 100;
check.bestPractices = (categories["best-practices"]?.score || 0) * 100;
check.seo = (categories.seo?.score || 0) * 100;
check.performance = (categories.performance?.score || 0) * 100;
check.audits = {
cls: mapAudit(audits?.["cumulative-layout-shift"]),
si: mapAudit(audits?.["speed-index"]),
fcp: mapAudit(audits?.["first-contentful-paint"]),
lcp: mapAudit(audits?.["largest-contentful-paint"]),
tbt: mapAudit(audits?.["total-blocking-time"]),
cls: mapAudit(audits["cumulative-layout-shift"]),
si: mapAudit(audits["speed-index"]),
fcp: mapAudit(audits["first-contentful-paint"]),
lcp: mapAudit(audits["largest-contentful-paint"]),
tbt: mapAudit(audits["total-blocking-time"]),
};
}
@@ -124,7 +124,7 @@ export class CheckService implements ICheckService {
const hardwarePayload = payload as HardwareStatusPayload | undefined;
const { cpu, memory, disk, host, net } = hardwarePayload?.data ?? {};
const errorsSource = Array.isArray(hardwarePayload?.errors)
? hardwarePayload?.errors
? hardwarePayload.errors
: (hardwarePayload?.errors as { errors?: CheckErrorInfo[] } | undefined)?.errors;
check.cpu = cpu;
check.memory = memory;
@@ -1,7 +1,7 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import type { Notification } from "../../src/types/notification.ts";
import type { NotificationMessage } from "../../src/types/notificationMessage.ts";
import { createMockLogger } from "../helpers/createMockLogger.ts";
import type { Notification } from "../../../src/types/notification.ts";
import type { NotificationMessage } from "../../../src/types/notificationMessage.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
const mockPost = jest.fn();
jest.unstable_mockModule("got", () => ({
@@ -9,7 +9,7 @@ jest.unstable_mockModule("got", () => ({
HTTPError: class HTTPError extends Error {},
}));
const { TeamsProvider } = await import("../../src/service/infrastructure/notificationProviders/teams.ts");
const { TeamsProvider } = await import("../../../src/service/infrastructure/notificationProviders/teams.ts");
const createNotification = (overrides?: Partial<Notification>): Notification => ({
id: "notif-1",
@@ -0,0 +1,427 @@
import { describe, expect, it, jest } from "@jest/globals";
import { CheckService } from "../../../src/service/business/checkService.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { IChecksRepository, IMonitorsRepository } from "../../../src/repositories/index.ts";
import type { MonitorStatusResponse, HardwareStatusPayload, PageSpeedStatusPayload } from "../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createMonitorsRepo = () =>
({
findById: jest.fn().mockResolvedValue({}),
}) as unknown as jest.Mocked<IMonitorsRepository>;
const createChecksRepo = () =>
({
createChecks: jest.fn().mockImplementation((checks: unknown) => Promise.resolve(checks)),
findByMonitorId: jest.fn().mockResolvedValue({ checks: [], count: 0 }),
findByTeamId: jest.fn().mockResolvedValue({ checks: [], count: 0 }),
findSummaryByTeamId: jest.fn().mockResolvedValue({}),
deleteByMonitorId: jest.fn().mockResolvedValue(5),
deleteByTeamId: jest.fn().mockResolvedValue(10),
deleteOlderThan: jest.fn().mockResolvedValue(3),
}) as unknown as jest.Mocked<IChecksRepository>;
const createService = () => {
const logger = createMockLogger();
const monitorsRepository = createMonitorsRepo();
const checksRepository = createChecksRepo();
const service = new CheckService(monitorsRepository, logger as any, checksRepository);
return { service, logger, monitorsRepository, checksRepository };
};
const makeStatusResponse = (overrides?: Partial<MonitorStatusResponse>): MonitorStatusResponse =>
({
monitorId: "mon-1",
teamId: "team-1",
type: "http",
status: true,
code: 200,
message: "OK",
responseTime: 100,
...overrides,
}) as MonitorStatusResponse;
// ── Tests ────────────────────────────────────────────────────────────────────
describe("CheckService", () => {
describe("serviceName", () => {
it("returns checkService", () => {
const { service } = createService();
expect(service.serviceName).toBe("checkService");
});
});
// ── buildCheck ───────────────────────────────────────────────────────────
describe("buildCheck", () => {
it("builds a basic HTTP check with correct fields", () => {
const { service } = createService();
const check = service.buildCheck(makeStatusResponse());
expect(check).toBeDefined();
expect(check!.metadata).toEqual({ monitorId: "mon-1", teamId: "team-1", type: "http" });
expect(check!.status).toBe(true);
expect(check!.statusCode).toBe(200);
expect(check!.responseTime).toBe(100);
expect(check!.message).toBe("OK");
expect(check!.id).toBeDefined();
expect(check!.createdAt).toBeDefined();
});
it("defaults responseTime to 0 when falsy", () => {
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ responseTime: undefined }));
expect(check!.responseTime).toBe(0);
});
it("includes timings when provided", () => {
const timings = { start: 0, socket: 10, lookup: 20 };
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ timings } as any));
expect(check!.timings).toBe(timings);
});
// ── PageSpeed ────────────────────────────────────────────────────────
describe("pagespeed type", () => {
it("extracts category scores and audits from lighthouse result", () => {
const payload: PageSpeedStatusPayload = {
lighthouseResult: {
categories: {
accessibility: { score: 0.9 },
"best-practices": { score: 0.8 },
seo: { score: 0.95 },
performance: { score: 0.7 },
},
audits: {
"cumulative-layout-shift": { id: "cls", title: "CLS", score: 0.1, displayValue: "0.1", numericValue: 0.1, numericUnit: "unitless" },
"speed-index": { id: "si", title: "SI", score: 0.5, displayValue: "3.0s", numericValue: 3000, numericUnit: "millisecond" },
"first-contentful-paint": { id: "fcp", title: "FCP", score: 0.8 },
"largest-contentful-paint": { id: "lcp", title: "LCP", score: 0.6 },
"total-blocking-time": { id: "tbt", title: "TBT", score: 0.9 },
},
},
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.accessibility).toBe(90);
expect(check!.bestPractices).toBe(80);
expect(check!.seo).toBe(95);
expect(check!.performance).toBe(70);
expect(check!.audits!.cls).toEqual(expect.objectContaining({ id: "cls", score: 0.1, numericValue: 0.1 }));
expect(check!.audits!.si).toEqual(expect.objectContaining({ id: "si", numericValue: 3000 }));
expect(check!.audits!.fcp).toEqual(expect.objectContaining({ id: "fcp" }));
});
it("returns undefined when pagespeed payload is missing", () => {
const { service, logger } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload: undefined } as any));
expect(check).toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build check" }));
});
it("defaults categories and audits to empty when lighthouseResult is missing", () => {
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload: {} } as any));
expect(check!.accessibility).toBe(0);
expect(check!.bestPractices).toBe(0);
expect(check!.seo).toBe(0);
expect(check!.performance).toBe(0);
expect(check!.audits!.cls).toBeUndefined();
});
it("handles audit with non-number score (string)", () => {
const payload = {
lighthouseResult: {
categories: {},
audits: {
"cumulative-layout-shift": { id: "cls", title: "CLS", score: "informative" },
},
},
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.audits!.cls!.score).toBe("informative");
});
it("handles audit with null score", () => {
const payload = {
lighthouseResult: {
categories: {},
audits: {
"cumulative-layout-shift": { id: "cls", title: "CLS", score: null },
},
},
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.audits!.cls!.score).toBeNull();
});
it("handles audit with non-number numericValue", () => {
const payload = {
lighthouseResult: {
categories: {},
audits: {
"speed-index": { id: "si", title: "SI", score: 0.5, numericValue: "n/a" },
},
},
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.audits!.si!.numericValue).toBeUndefined();
});
it("returns undefined for non-object audit", () => {
const payload = {
lighthouseResult: {
categories: {},
audits: {
"cumulative-layout-shift": null,
"speed-index": "not an object",
},
},
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.audits!.cls).toBeUndefined();
expect(check!.audits!.si).toBeUndefined();
});
});
// ── Hardware ─────────────────────────────────────────────────────────
describe("hardware type", () => {
it("extracts cpu, memory, disk, host, net, and capture from payload", () => {
const payload: HardwareStatusPayload = {
data: {
cpu: { usage_percent: 0.5 },
memory: { usage_percent: 0.6 },
disk: [{ device: "/dev/sda", usage_percent: 0.7 }],
host: { os: "linux" },
net: [{ name: "eth0" }],
},
capture: { screenshot: "base64data" },
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any));
expect(check!.cpu).toEqual({ usage_percent: 0.5 });
expect(check!.memory).toEqual({ usage_percent: 0.6 });
expect(check!.disk).toHaveLength(1);
expect(check!.host).toEqual({ os: "linux" });
expect(check!.net).toHaveLength(1);
expect(check!.capture).toEqual({ screenshot: "base64data" });
});
it("extracts errors from array format", () => {
const payload = {
data: {},
errors: [{ message: "timeout" }],
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any));
expect(check!.errors).toEqual([{ message: "timeout" }]);
});
it("extracts errors from nested object format", () => {
const payload = {
data: {},
errors: { errors: [{ message: "nested error" }] },
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any));
expect(check!.errors).toEqual([{ message: "nested error" }]);
});
it("handles undefined payload gracefully", () => {
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload: undefined } as any));
expect(check).toBeDefined();
expect(check!.cpu).toBeUndefined();
expect(check!.memory).toBeUndefined();
expect(check!.disk).toBeUndefined();
expect(check!.errors).toBeUndefined();
});
});
});
// ── createChecks ─────────────────────────────────────────────────────────
describe("createChecks", () => {
it("delegates to repository", async () => {
const { service, checksRepository } = createService();
const checks = [{ id: "c1" }] as any;
await service.createChecks(checks);
expect(checksRepository.createChecks).toHaveBeenCalledWith(checks);
});
});
// ── getChecksByMonitor ───────────────────────────────────────────────────
describe("getChecksByMonitor", () => {
it("returns checks from repository", async () => {
const expected = { checks: [{ id: "c1" }], count: 1 };
const { service, checksRepository } = createService();
(checksRepository.findByMonitorId as jest.Mock).mockResolvedValue(expected);
const result = await service.getChecksByMonitor({
monitorId: "mon-1",
teamId: "team-1",
sortOrder: "desc",
dateRange: "day",
page: 0,
rowsPerPage: 10,
});
expect(result).toBe(expected);
});
it("defaults page to 0 and rowsPerPage to 5 when nullish", async () => {
const { service, checksRepository } = createService();
await service.getChecksByMonitor({
monitorId: "mon-1",
teamId: "team-1",
sortOrder: "desc",
dateRange: "day",
page: null as any,
rowsPerPage: null as any,
});
expect(checksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "desc", "day", undefined, 0, 5, undefined);
});
it("passes filter and status parameters", async () => {
const { service, checksRepository } = createService();
await service.getChecksByMonitor({
monitorId: "mon-1",
teamId: "team-1",
sortOrder: "asc",
dateRange: "week",
page: 2,
rowsPerPage: 20,
filter: "error",
status: false,
});
expect(checksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "asc", "week", "error", 2, 20, false);
});
it("throws when monitorId is missing", async () => {
const { service } = createService();
await expect(
service.getChecksByMonitor({ monitorId: "", teamId: "team-1", sortOrder: "desc", dateRange: "day", page: 0, rowsPerPage: 10 })
).rejects.toThrow("No monitor ID in request");
});
it("throws when teamId is missing", async () => {
const { service } = createService();
await expect(
service.getChecksByMonitor({ monitorId: "mon-1", teamId: "", sortOrder: "desc", dateRange: "day", page: 0, rowsPerPage: 10 })
).rejects.toThrow("No team ID in request");
});
it("verifies monitor belongs to team via repository", async () => {
const { service, monitorsRepository } = createService();
await service.getChecksByMonitor({
monitorId: "mon-1",
teamId: "team-1",
sortOrder: "desc",
dateRange: "day",
page: 0,
rowsPerPage: 10,
});
expect(monitorsRepository.findById).toHaveBeenCalledWith("mon-1", "team-1");
});
});
// ── getChecksByTeam ──────────────────────────────────────────────────────
describe("getChecksByTeam", () => {
it("returns checks from repository", async () => {
const expected = { checks: [], count: 0 };
const { service, checksRepository } = createService();
(checksRepository.findByTeamId as jest.Mock).mockResolvedValue(expected);
const result = await service.getChecksByTeam({ teamId: "team-1", sortOrder: "desc", dateRange: "day", page: 0, rowsPerPage: 10 });
expect(result).toBe(expected);
});
it("defaults page to 0 and rowsPerPage to 5 when nullish", async () => {
const { service, checksRepository } = createService();
await service.getChecksByTeam({ teamId: "team-1", sortOrder: "desc", dateRange: "day", page: null as any, rowsPerPage: null as any });
expect(checksRepository.findByTeamId).toHaveBeenCalledWith("desc", "day", undefined, 0, 5, "team-1");
});
});
// ── getChecksSummaryByTeamId ──────────────────────────────────────────────
describe("getChecksSummaryByTeamId", () => {
it("delegates to repository", async () => {
const summary = { total: 5, up: 4, down: 1 };
const { service, checksRepository } = createService();
(checksRepository.findSummaryByTeamId as jest.Mock).mockResolvedValue(summary);
const result = await service.getChecksSummaryByTeamId({ teamId: "team-1", dateRange: "day" });
expect(result).toBe(summary);
expect(checksRepository.findSummaryByTeamId).toHaveBeenCalledWith("team-1", "day");
});
});
// ── deleteChecks ─────────────────────────────────────────────────────────
describe("deleteChecks", () => {
it("verifies monitor ownership and deletes", async () => {
const { service, monitorsRepository, checksRepository } = createService();
const result = await service.deleteChecks({ monitorId: "mon-1", teamId: "team-1" });
expect(monitorsRepository.findById).toHaveBeenCalledWith("mon-1", "team-1");
expect(checksRepository.deleteByMonitorId).toHaveBeenCalledWith("mon-1");
expect(result).toBe(5);
});
});
describe("deleteChecksByTeamId", () => {
it("delegates to repository", async () => {
const { service, checksRepository } = createService();
const result = await service.deleteChecksByTeamId({ teamId: "team-1" });
expect(checksRepository.deleteByTeamId).toHaveBeenCalledWith("team-1");
expect(result).toBe(10);
});
});
describe("deleteOlderThan", () => {
it("delegates to repository", async () => {
const date = new Date();
const { service, checksRepository } = createService();
const result = await service.deleteOlderThan(date);
expect(checksRepository.deleteOlderThan).toHaveBeenCalledWith(date);
expect(result).toBe(3);
});
});
});
@@ -1,11 +1,11 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { IncidentService } from "../../src/service/business/incidentService.ts";
import { createMockLogger } from "../helpers/createMockLogger.ts";
import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from "../../src/repositories/index.ts";
import type { INotificationMessageBuilder } from "../../src/service/infrastructure/notificationMessageBuilder.ts";
import type { Monitor } from "../../src/types/monitor.ts";
import type { Incident } from "../../src/types/index.ts";
import type { MonitorActionDecision } from "../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
import { IncidentService } from "../../../src/service/business/incidentService.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from "../../../src/repositories/index.ts";
import type { INotificationMessageBuilder } from "../../../src/service/infrastructure/notificationMessageBuilder.ts";
import type { Monitor } from "../../../src/types/monitor.ts";
import type { Incident } from "../../../src/types/index.ts";
import type { MonitorActionDecision } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -1,5 +1,5 @@
import { jest } from "@jest/globals";
import { MonitorService } from "../../src/service/business/monitorService.ts";
import { MonitorService } from "../../../src/service/business/monitorService.ts";
import type {
IChecksRepository,
IGeoChecksRepository,
@@ -7,7 +7,7 @@ import type {
IMonitorStatsRepository,
IMonitorsRepository,
IStatusPagesRepository,
} from "../../src/repositories/index.ts";
} from "../../../src/repositories/index.ts";
const createMonitorsRepositoryMock = () =>
({
@@ -0,0 +1,347 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { NotificationsService } from "../../../src/service/infrastructure/notificationsService.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { Notification, Monitor, MonitorStatusResponse } from "../../../src/types/index.ts";
import type { MonitorActionDecision } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createProvider = () => ({
sendMessage: jest.fn<() => Promise<boolean>>().mockResolvedValue(true),
sendTestAlert: jest.fn<() => Promise<boolean>>().mockResolvedValue(true),
});
const createNotificationsRepo = () => ({
create: jest.fn(),
findById: jest.fn(),
findNotificationsByIds: jest.fn(),
findByTeamId: jest.fn(),
updateById: jest.fn(),
deleteById: jest.fn(),
});
const createMonitorsRepo = () => ({
removeNotificationFromMonitors: jest.fn(),
});
const createSettingsService = (clientHost = "https://app.example.com") => ({
getSettings: jest.fn().mockReturnValue({ clientHost }),
});
const createMessageBuilder = () => ({
buildMessage: jest.fn().mockReturnValue({ type: "monitor_down", content: { title: "Down" } }),
extractThresholdBreaches: jest.fn(),
});
const createService = (overrides?: Record<string, unknown>) => {
const logger = createMockLogger();
const notificationsRepository = createNotificationsRepo();
const monitorsRepository = createMonitorsRepo();
const webhookProvider = createProvider();
const emailProvider = createProvider();
const slackProvider = createProvider();
const discordProvider = createProvider();
const pagerDutyProvider = createProvider();
const matrixProvider = createProvider();
const teamsProvider = createProvider();
const telegramProvider = createProvider();
const settingsService = createSettingsService();
const notificationMessageBuilder = createMessageBuilder();
const defaults = {
logger,
notificationsRepository,
monitorsRepository,
webhookProvider,
emailProvider,
slackProvider,
discordProvider,
pagerDutyProvider,
matrixProvider,
teamsProvider,
telegramProvider,
settingsService,
notificationMessageBuilder,
...overrides,
};
const service = new NotificationsService(
defaults.notificationsRepository as any,
defaults.monitorsRepository as any,
defaults.webhookProvider as any,
defaults.emailProvider as any,
defaults.slackProvider as any,
defaults.discordProvider as any,
defaults.pagerDutyProvider as any,
defaults.matrixProvider as any,
defaults.teamsProvider as any,
defaults.telegramProvider as any,
defaults.settingsService as any,
defaults.logger as any,
defaults.notificationMessageBuilder as any
);
return { service, ...defaults };
};
const makeNotification = (overrides?: Partial<Notification>): Notification =>
({
id: "notif-1",
userId: "user-1",
teamId: "team-1",
type: "email",
notificationName: "Email Alert",
address: "test@example.com",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
}) as Notification;
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
name: "Test Monitor",
type: "http",
notifications: ["notif-1"],
...overrides,
}) as Monitor;
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: true,
incidentReason: null,
notificationReason: "status_change",
...overrides,
});
const makeStatusResponse = () => ({ monitorId: "mon-1", status: false, code: 500 }) as unknown as MonitorStatusResponse;
// ── Tests ────────────────────────────────────────────────────────────────────
describe("NotificationsService", () => {
// ── handleNotifications ───────────────────────────────────────────────────
describe("handleNotifications", () => {
it("returns false when shouldSendNotification is false", async () => {
const { service } = createService();
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision({ shouldSendNotification: false }));
expect(result).toBe(false);
});
it("sends notifications to all configured providers and returns true", async () => {
const { service, notificationsRepository, emailProvider } = createService();
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type: "email" })]);
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
expect(result).toBe(true);
expect(emailProvider.sendMessage).toHaveBeenCalledTimes(1);
});
it("routes to correct provider for each notification type", async () => {
const types = ["webhook", "slack", "matrix", "pager_duty", "discord", "email", "teams", "telegram"] as const;
for (const type of types) {
const deps = createService();
(deps.notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type })]);
await deps.service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
const providerMap: Record<string, ReturnType<typeof createProvider>> = {
webhook: deps.webhookProvider,
slack: deps.slackProvider,
matrix: deps.matrixProvider,
pager_duty: deps.pagerDutyProvider,
discord: deps.discordProvider,
email: deps.emailProvider,
teams: deps.teamsProvider,
telegram: deps.telegramProvider,
};
expect(providerMap[type].sendMessage).toHaveBeenCalledTimes(1);
}
});
it("returns false and logs warning for unknown notification type", async () => {
const { service, notificationsRepository, logger } = createService();
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type: "carrier_pigeon" as any })]);
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("Unknown notification type: carrier_pigeon") })
);
});
it("returns false and logs warning when notificationMessage is undefined", async () => {
const notificationMessageBuilder = createMessageBuilder();
notificationMessageBuilder.buildMessage.mockReturnValue(undefined);
const { service, notificationsRepository, logger } = createService({ notificationMessageBuilder });
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification()]);
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Notification message not provided" }));
});
it("handles monitors with no notification IDs", async () => {
const { service, notificationsRepository } = createService();
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([]);
const result = await service.handleNotifications(makeMonitor({ notifications: undefined as any }), makeStatusResponse(), makeDecision());
expect(result).toBe(true);
expect(notificationsRepository.findNotificationsByIds).toHaveBeenCalledWith([]);
});
it("returns false and logs when some notifications fail", async () => {
const { service, notificationsRepository, emailProvider, slackProvider, logger } = createService();
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([
makeNotification({ id: "n1", type: "email" }),
makeNotification({ id: "n2", type: "slack" }),
]);
emailProvider.sendMessage.mockResolvedValue(true);
slackProvider.sendMessage.mockResolvedValue(false);
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("1 success, 1 failure") }));
});
it("uses fallback clientHost when settings.clientHost is empty", async () => {
const settingsService = createSettingsService("");
const { service, notificationsRepository, notificationMessageBuilder } = createService({ settingsService });
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification()]);
await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
expect(notificationMessageBuilder.buildMessage).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
"Host not defined"
);
});
});
// ── sendTestNotification ─────────────────────────────────────────────────
describe("sendTestNotification", () => {
it.each([["email"], ["slack"], ["discord"], ["pager_duty"], ["matrix"], ["webhook"], ["teams"], ["telegram"]] as const)(
"routes %s to the correct provider",
async (type) => {
const deps = createService();
const notification = makeNotification({ type: type as any });
const result = await deps.service.sendTestNotification(notification);
expect(result).toBe(true);
const providerMap: Record<string, ReturnType<typeof createProvider>> = {
webhook: deps.webhookProvider,
slack: deps.slackProvider,
matrix: deps.matrixProvider,
pager_duty: deps.pagerDutyProvider,
discord: deps.discordProvider,
email: deps.emailProvider,
teams: deps.teamsProvider,
telegram: deps.telegramProvider,
};
expect(providerMap[type].sendTestAlert).toHaveBeenCalledWith(notification);
}
);
it("returns false for unknown notification type", async () => {
const { service } = createService();
const result = await service.sendTestNotification(makeNotification({ type: "unknown" as any }));
expect(result).toBe(false);
});
});
// ── testAllNotifications ─────────────────────────────────────────────────
describe("testAllNotifications", () => {
it("returns true when all test alerts succeed", async () => {
const { service, notificationsRepository } = createService();
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([
makeNotification({ type: "email" }),
makeNotification({ type: "slack" }),
]);
const result = await service.testAllNotifications(["notif-1", "notif-2"]);
expect(result).toBe(true);
});
it("returns false when any test alert fails", async () => {
const { service, notificationsRepository, emailProvider } = createService();
emailProvider.sendTestAlert.mockResolvedValue(false);
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type: "email" })]);
const result = await service.testAllNotifications(["notif-1"]);
expect(result).toBe(false);
});
});
// ── CRUD operations ──────────────────────────────────────────────────────
describe("createNotification", () => {
it("sets userId and teamId and delegates to repository", async () => {
const created = makeNotification();
const { service, notificationsRepository } = createService();
(notificationsRepository.create as jest.Mock).mockResolvedValue(created);
const result = await service.createNotification({ type: "email", address: "a@b.com" }, "user-1", "team-1");
expect(result).toBe(created);
expect(notificationsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ userId: "user-1", teamId: "team-1", type: "email" }));
});
});
describe("findById", () => {
it("delegates to repository", async () => {
const notification = makeNotification();
const { service, notificationsRepository } = createService();
(notificationsRepository.findById as jest.Mock).mockResolvedValue(notification);
const result = await service.findById("notif-1", "team-1");
expect(result).toBe(notification);
});
});
describe("findNotificationsByTeamId", () => {
it("delegates to repository", async () => {
const notifications = [makeNotification()];
const { service, notificationsRepository } = createService();
(notificationsRepository.findByTeamId as jest.Mock).mockResolvedValue(notifications);
const result = await service.findNotificationsByTeamId("team-1");
expect(result).toBe(notifications);
});
});
describe("updateById", () => {
it("delegates to repository", async () => {
const updated = makeNotification({ address: "new@example.com" });
const { service, notificationsRepository } = createService();
(notificationsRepository.updateById as jest.Mock).mockResolvedValue(updated);
const result = await service.updateById("notif-1", "team-1", { address: "new@example.com" });
expect(result).toBe(updated);
});
});
describe("deleteById", () => {
it("deletes notification and removes from monitors", async () => {
const deleted = makeNotification();
const { service, notificationsRepository, monitorsRepository } = createService();
(notificationsRepository.deleteById as jest.Mock).mockResolvedValue(deleted);
const result = await service.deleteById("notif-1", "team-1");
expect(result).toBe(deleted);
expect(monitorsRepository.removeNotificationFromMonitors).toHaveBeenCalledWith("notif-1");
});
});
});
@@ -1,9 +1,9 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { StatusService } from "../../src/service/infrastructure/statusService.ts";
import { createMockLogger } from "../helpers/createMockLogger.ts";
import type { Monitor, MonitorStatusResponse, Check, HardwareStatusPayload } from "../../src/types/index.ts";
import type { IMonitorsRepository, IMonitorStatsRepository, IChecksRepository } from "../../src/repositories/index.ts";
import type { IBufferService } from "../../src/service/infrastructure/bufferService.ts";
import { StatusService } from "../../../src/service/infrastructure/statusService.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { Monitor, MonitorStatusResponse, Check, HardwareStatusPayload } from "../../../src/types/index.ts";
import type { IMonitorsRepository, IMonitorStatsRepository, IChecksRepository } from "../../../src/repositories/index.ts";
import type { IBufferService } from "../../../src/service/infrastructure/bufferService.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -1,7 +1,7 @@
import { describe, expect, it, jest } from "@jest/globals";
import { SuperSimpleQueueHelper } from "../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
import type { Monitor } from "../../src/types/monitor.ts";
import { createMockLogger } from "../helpers/createMockLogger.ts";
import { SuperSimpleQueueHelper } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
import type { Monitor } from "../../../src/types/monitor.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
const createHelper = (overrides?: Record<string, unknown>) => {
const maintenanceWindowsRepository = {