Merge pull request #3492 from bluewave-labs/feat/service-tests

feat/service tests
This commit is contained in:
Alexander Holliday
2026-04-10 12:43:54 -07:00
committed by GitHub
60 changed files with 12712 additions and 650 deletions
-86
View File
@@ -69,7 +69,6 @@
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"c8": "10.1.3",
"eslint": "^9.17.0",
"eslint-plugin-mocha": "^10.5.0",
"esm": "3.2.25",
@@ -1358,16 +1357,6 @@
"integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==",
"license": "Apache-2.0"
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@colors/colors": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
@@ -6053,40 +6042,6 @@
"node": ">= 0.8"
}
},
"node_modules/c8": {
"version": "10.1.3",
"resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
"integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==",
"dev": true,
"license": "ISC",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.1",
"@istanbuljs/schema": "^0.1.3",
"find-up": "^5.0.0",
"foreground-child": "^3.1.1",
"istanbul-lib-coverage": "^3.2.0",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.1.6",
"test-exclude": "^7.0.1",
"v8-to-istanbul": "^9.0.0",
"yargs": "^17.7.2",
"yargs-parser": "^21.1.1"
},
"bin": {
"c8": "bin/c8.js"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"monocart-coverage-reports": "^2"
},
"peerDependenciesMeta": {
"monocart-coverage-reports": {
"optional": true
}
}
},
"node_modules/cacheable-lookup": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
@@ -14666,47 +14621,6 @@
"node": ">=6"
}
},
"node_modules/test-exclude": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
"integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^10.4.1",
"minimatch": "^9.0.4"
},
"engines": {
"node": ">=18"
}
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/test-exclude/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/text-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
+2 -2
View File
@@ -5,7 +5,8 @@
"main": "index.js",
"type": "module",
"scripts": {
"test": "NODE_OPTIONS=--experimental-vm-modules c8 jest --runInBand",
"test": "NODE_OPTIONS=--experimental-vm-modules jest --coverage",
"test:services": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/service/**/*.ts' test/unit/services test/unit/providers/network test/unit/providers/notifications",
"dev": "nodemon --exec tsx src/index.js",
"start": "node --watch ./dist/index.js",
"build": "tsc && tsc-alias && cp -r src/templates dist/templates",
@@ -84,7 +85,6 @@
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"c8": "10.1.3",
"eslint": "^9.17.0",
"eslint-plugin-mocha": "^10.5.0",
"esm": "3.2.25",
-1
View File
@@ -364,7 +364,6 @@ export const initializeServices = async ({
});
const monitorService = new MonitorService({
jobQueue: superSimpleQueue,
emailService,
logger,
games,
monitorsRepository,
+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;
@@ -95,17 +95,14 @@ export class IncidentService implements IIncidentService {
}
}
if (decision.shouldResolveIncident) {
if (!activeIncident) {
return null;
}
activeIncident.status = false;
activeIncident.endTime = Date.now().toString();
activeIncident.resolutionType = "automatic";
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
if (!decision.shouldResolveIncident || !activeIncident) {
return null;
}
return null;
activeIncident.status = false;
activeIncident.endTime = Date.now().toString();
activeIncident.resolutionType = "automatic";
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
};
private buildThresholdBreachMessage(monitor: Monitor, monitorStatusResponse?: MonitorStatusResponse): string {
+10 -31
View File
@@ -23,7 +23,6 @@ import demoMonitorsData from "@/utils/demoMonitors.json" with { type: "json" };
import { AppError } from "@/utils/AppError.js";
import type { ImportedMonitor } from "@/validation/monitorValidation.js";
import { ISuperSimpleQueue } from "../infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
import { IEmailService } from "../infrastructure/emailService.js";
import { ILogger } from "@/utils/logger.js";
const SERVICE_NAME = "MonitorService";
@@ -91,7 +90,6 @@ export class MonitorService implements IMonitorService {
static SERVICE_NAME = SERVICE_NAME;
private jobQueue: ISuperSimpleQueue;
private emailService: IEmailService;
private logger: ILogger;
private games: GamesMap;
private monitorsRepository: IMonitorsRepository;
@@ -103,7 +101,6 @@ export class MonitorService implements IMonitorService {
constructor({
jobQueue,
emailService,
logger,
games,
monitorsRepository,
@@ -114,7 +111,6 @@ export class MonitorService implements IMonitorService {
incidentsRepository,
}: {
jobQueue: ISuperSimpleQueue;
emailService: IEmailService;
logger: ILogger;
games: GamesMap;
monitorsRepository: IMonitorsRepository;
@@ -125,7 +121,6 @@ export class MonitorService implements IMonitorService {
incidentsRepository: IIncidentsRepository;
}) {
this.jobQueue = jobQueue;
this.emailService = emailService;
this.logger = logger;
this.games = games;
this.monitorsRepository = monitorsRepository;
@@ -176,7 +171,7 @@ export class MonitorService implements IMonitorService {
createMonitors = async (monitors: Array<Monitor>): Promise<Monitor[] | null> => {
const createdMonitors = await this.monitorsRepository.createMonitors(monitors);
if (!monitors || monitors.length === 0) {
if (!createdMonitors || createdMonitors.length === 0) {
throw new AppError({ message: "Failed to create monitors", status: 500, service: SERVICE_NAME, method: "createMonitors" });
}
@@ -185,8 +180,7 @@ export class MonitorService implements IMonitorService {
};
addDemoMonitors = async ({ userId, teamId }: { userId: string; teamId: string }): Promise<Monitor[]> => {
const demoData = demoMonitorsData;
const monitors = demoData.map((monitor) => ({
const monitors = demoMonitorsData.map((monitor) => ({
userId,
teamId,
name: monitor.name,
@@ -214,7 +208,7 @@ export class MonitorService implements IMonitorService {
if (!monitor) {
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
}
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
type: monitor.type,
@@ -263,7 +257,7 @@ export class MonitorService implements IMonitorService {
throw new AppError({ message: `${monitor.type} monitors are not supported for hardware details`, status: 400 });
}
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
type: monitor.type,
@@ -305,7 +299,7 @@ export class MonitorService implements IMonitorService {
throw new AppError({ message: `${monitor.type} monitors are not supported for pagespeed details`, status: 400 });
}
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
type: monitor.type,
@@ -346,7 +340,7 @@ export class MonitorService implements IMonitorService {
return { groupedGeoChecks: [] };
}
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey);
const groupedGeoChecks = await this.geoChecksRepository.findGroupedByMonitorIdAndDateRange(
monitor.id,
@@ -360,8 +354,7 @@ export class MonitorService implements IMonitorService {
};
getMonitorById = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
return monitor;
return await this.monitorsRepository.findById(monitorId, teamId);
};
getMonitorsByTeamId = async ({
@@ -373,11 +366,7 @@ export class MonitorService implements IMonitorService {
type?: MonitorType | MonitorType[];
filter?: string;
}): Promise<Monitor[] | null> => {
const monitors = await this.monitorsRepository.findByTeamId(teamId, {
type,
filter,
});
return monitors;
return await this.monitorsRepository.findByTeamId(teamId, { type, filter });
};
getMonitorsWithChecksByTeamId = async ({
@@ -432,8 +421,7 @@ export class MonitorService implements IMonitorService {
};
getGroupsByTeamId = async ({ teamId }: { teamId: string }): Promise<string[]> => {
const groups = await this.monitorsRepository.findGroupsByTeamId(teamId);
return groups;
return await this.monitorsRepository.findGroupsByTeamId(teamId);
};
editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: Partial<Monitor> }) => {
@@ -574,15 +562,6 @@ export class MonitorService implements IMonitorService {
const createdMonitors = await this.createMonitors(cleanedMonitors);
if (!createdMonitors || createdMonitors.length === 0) {
throw new AppError({
message: "Failed to import any monitors. Please check the file format and try again.",
service: SERVICE_NAME,
method: "importMonitorsFromJSON",
status: 400,
});
}
return { imported: createdMonitors.length, errors };
return { imported: createdMonitors!.length, errors };
};
}
+8 -24
View File
@@ -110,10 +110,7 @@ export class UserService implements IUserService {
}
issueToken = (payload: Partial<User>, appSettings: EnvConfig) => {
const tokenTTL = appSettings?.jwtTTL ?? "2h";
const tokenSecret = appSettings?.jwtSecret;
const payloadData = payload;
return this.jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL });
return this.jwt.sign(payload, appSettings.jwtSecret, { expiresIn: appSettings.jwtTTL });
};
registerUser = async (user: Partial<User>, inviteToken: string, file: Express.Multer.File | null) => {
@@ -253,14 +250,10 @@ export class UserService implements IUserService {
currentUserEmail: string
) => {
// Change Password check
if (updates?.password && updates?.newPassword) {
// Get user's email
// Add user email to body for DB operation
if (updates.password && updates.newPassword) {
updates.email = currentUserEmail;
// Get user
const user = await this.usersRepository.findByEmail(currentUserEmail);
// Compare passwords
const match = await bcrypt.compare(updates?.password, user.password);
const match = await bcrypt.compare(updates.password, user.password);
// If not a match, throw a 403
// 403 instead of 401 to avoid triggering axios interceptor
if (!match) {
@@ -286,7 +279,7 @@ export class UserService implements IUserService {
const recoveryToken = await this.recoveryTokensRepository.create(email);
const name = user.firstName;
const settings = this.settingsService.getSettings();
const url = `${settings.clientHost!}/set-new-password/${recoveryToken.token}`;
const url = `${settings.clientHost}/set-new-password/${recoveryToken.token}`;
const html = await this.emailService.buildEmail("passwordResetTemplate", {
name,
@@ -336,17 +329,10 @@ export class UserService implements IUserService {
throw new AppError({ message: "Demo user cannot be deleted", service: SERVICE_NAME, method: "deleteUser", status: 400 });
}
// 1. Find all the monitors associated with the team ID if superadmin
const res = await this.monitorsRepository.findByTeamId(teamId, {});
if (roles.includes("superadmin")) {
// 2. Remove all jobs, delete checks and alerts
if (res && res.length > 0) {
await Promise.all(
res.map(async (monitor) => {
await this.jobQueue.deleteJob(monitor);
})
);
const monitors = await this.monitorsRepository.findByTeamId(teamId, {});
if (monitors) {
await Promise.all(monitors.map((monitor) => this.jobQueue.deleteJob(monitor)));
}
}
// 6. Delete the user by id
@@ -410,9 +396,7 @@ export class UserService implements IUserService {
if (!roles.includes("superadmin") && !roles.includes("admin")) {
throw new AppError({ message: "Insufficient permissions", service: SERVICE_NAME, status: 403 });
}
const user = await this.usersRepository.findById(userId);
return user;
return await this.usersRepository.findById(userId);
};
editUserById = async (userId: string, patch: Partial<User>) => {
@@ -51,7 +51,6 @@ export interface ISuperSimpleQueue {
getMetrics(): Promise<QueueMetrics>;
getJobs(): Promise<QueueJobSummary[]>;
flushQueues(): Promise<{ success: boolean }>;
obliterate(): Promise<void>;
}
export class SuperSimpleQueue implements ISuperSimpleQueue {
@@ -306,8 +305,8 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
if (failCount > 0) {
acc.jobsWithFailures.push({
monitorId: job.id,
monitorUrl: job?.data?.url || null,
monitorType: job?.data?.type || null,
monitorUrl: job.data?.url || null,
monitorType: job.data?.type || null,
failedAt: job.lastFailedAt ?? null,
failCount,
failReason: job.lastFailReason ?? null,
@@ -339,9 +338,9 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
return jobs.map((job) => {
return {
monitorId: job.id,
monitorUrl: job?.data?.url || null,
monitorType: job?.data?.type || null,
monitorInterval: job?.data?.interval || null,
monitorUrl: job.data?.url || null,
monitorType: job.data?.type || null,
monitorInterval: job.data?.interval || null,
active: job.active,
lockedAt: job.lockedAt ?? null,
runCount: job.runCount ?? 0,
@@ -363,12 +362,4 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
success: Boolean(stopRes && flushRes && initRes),
};
};
obliterate = async () => {
this.logger.warn({
message: "obliterate method not implemented",
service: SERVICE_NAME,
method: "obliterate",
});
};
}
@@ -9,7 +9,6 @@ const SERVICE_NAME = "BufferService";
export interface IBufferService {
addToBuffer(check: Check): void;
addGeoCheckToBuffer(geoCheck: GeoCheck): void;
removeCheckFromBuffer(check: Check): boolean;
scheduleNextFlush(): void;
flushBuffer(): Promise<void>;
flushGeoBuffer(): Promise<void>;
@@ -72,44 +71,6 @@ export class BufferService implements IBufferService {
}
}
removeCheckFromBuffer(checkToRemove: Check) {
try {
if (!checkToRemove) {
return false;
}
const index = this.buffer.findIndex((check) => {
if (checkToRemove.id && check.id) {
return check.id.toString() === checkToRemove.id.toString();
}
return (
check.metadata.monitorId?.toString() === checkToRemove.metadata.monitorId &&
check.metadata.teamId?.toString() === checkToRemove.metadata.teamId &&
check.metadata.type === checkToRemove.metadata.type &&
check.status === checkToRemove.status &&
check.statusCode === checkToRemove.statusCode &&
check.responseTime === checkToRemove.responseTime &&
check.message === checkToRemove.message
);
});
if (index !== -1) {
this.buffer.splice(index, 1);
return true;
}
return false;
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: this.SERVICE_NAME,
method: "removeCheckFromBuffer",
stack: error instanceof Error ? error.stack : undefined,
});
return false;
}
}
scheduleNextFlush() {
if (this.bufferTimer) {
clearTimeout(this.bufferTimer);
@@ -35,7 +35,6 @@ export class EmailService implements IEmailService {
private logger: ILogger;
private transporter: ReturnType<typeof import("nodemailer").createTransport> | null = null;
private templateLookup: Record<string, ((context: Record<string, unknown>) => string) | undefined>;
private loadTemplate: (templateName: string) => ((context: Record<string, unknown>) => string) | undefined;
constructor(
settingsService: ISettingsService,
@@ -54,7 +53,6 @@ export class EmailService implements IEmailService {
this.nodemailer = nodemailer;
this.logger = logger;
this.templateLookup = {};
this.loadTemplate = () => undefined;
this.init();
}
@@ -63,7 +61,7 @@ export class EmailService implements IEmailService {
}
init = () => {
this.loadTemplate = (templateName) => {
const loadTemplate = (templateName: string) => {
try {
const templatePath = this.path.join(__dirname, `../../templates/${templateName}.mjml`);
const templateContent = this.fs.readFileSync(templatePath, "utf8");
@@ -79,12 +77,12 @@ export class EmailService implements IEmailService {
};
this.templateLookup = {
welcomeEmailTemplate: this.loadTemplate("welcomeEmail"),
employeeActivationTemplate: this.loadTemplate("employeeActivation"),
noIncidentsThisWeekTemplate: this.loadTemplate("noIncidentsThisWeek"),
passwordResetTemplate: this.loadTemplate("passwordReset"),
testEmailTemplate: this.loadTemplate("testEmailTemplate"),
unifiedNotificationTemplate: this.loadTemplate("unifiedNotification"),
welcomeEmailTemplate: loadTemplate("welcomeEmail"),
employeeActivationTemplate: loadTemplate("employeeActivation"),
noIncidentsThisWeekTemplate: loadTemplate("noIncidentsThisWeek"),
passwordResetTemplate: loadTemplate("passwordReset"),
testEmailTemplate: loadTemplate("testEmailTemplate"),
unifiedNotificationTemplate: loadTemplate("unifiedNotification"),
};
};
@@ -167,7 +165,7 @@ export class EmailService implements IEmailService {
subject: subject,
html: html,
});
return info?.messageId;
return info.messageId;
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
@@ -90,7 +90,8 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
if (partialIdMatch && !exactIdMatch) matchTypes.push("partial ID");
if (matchTypes.length > 1) {
const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${exactIdMatch ? "exact ID" : exactNameMatch ? "exact name" : "partial ID"} match.`;
const matchUsed = exactIdMatch ? "exact ID" : "exact name";
const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${matchUsed} match.`;
this.logger.warn({
message,
@@ -138,11 +139,24 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
};
}
if (!response) {
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code: NETWORK_ERROR,
message: "No response from Docker container inspect",
responseTime,
payload: null,
};
}
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: response?.State?.Status === "running",
status: response.State?.Status === "running",
code: 200,
message: "Docker container status fetched successfully",
responseTime,
@@ -60,7 +60,7 @@ export class GrpcProvider implements IStatusProvider<GrpcStatusPayload> {
throw new AppError({ message: "Monitor port is required", service: SERVICE_NAME, method: "handle" });
}
const host = url?.replace(/^https?:\/\//, "").split(/[/?#:]/)[0];
const host = url.replace(/^https?:\/\//, "").split(/[/?#:]/)[0];
const target = `${host}:${port}`;
const currentFilePath = fileURLToPath(import.meta.url);
@@ -118,7 +118,7 @@ export class NetworkService implements INetworkService {
},
});
if (response?.data?.status !== "success") return false;
if (response.data?.status !== "success") return false;
return true;
} catch (err: unknown) {
const originalMessage = err instanceof Error ? err.message : String(err);
@@ -63,7 +63,7 @@ export class MatrixProvider implements INotificationProvider {
message: `Matrix notification failed : ${err.message}`,
service: SERVICE_NAME,
method: "sendMessage",
stack: err?.stack,
stack: err.stack,
});
return false;
}
@@ -147,13 +147,7 @@ export class StatusService implements IStatusService {
}
// Calculate uptime percentage
let uptimePercentage;
if (stats.totalChecks > 0) {
uptimePercentage = stats.totalUpChecks / stats.totalChecks;
} else {
uptimePercentage = status === true ? 100 : 0;
}
stats.uptimePercentage = uptimePercentage;
stats.uptimePercentage = stats.totalUpChecks / stats.totalChecks;
// latest check
stats.lastCheckTimestamp = new Date().getTime();
@@ -268,7 +262,7 @@ export class StatusService implements IStatusService {
let thresholdBreaches: { cpu: boolean; memory: boolean; disk: boolean; temp: boolean } | undefined;
if (monitor.type === "hardware" && statusResponse.payload) {
const payload = statusResponse.payload as HardwareStatusPayload;
const metrics = payload?.data;
const metrics = payload.data;
if (metrics) {
// Evaluate threshold breaches
@@ -278,9 +272,11 @@ export class StatusService implements IStatusService {
const memoryUsage = metrics.memory?.usage_percent ?? -1;
const memoryBreach = memoryUsage !== -1 && memoryUsage > monitor.memoryAlertThreshold / 100;
const diskBreach =
metrics.disk?.some((d: CheckDiskInfo) => typeof d?.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100) ??
false;
const diskBreach = metrics.disk
? metrics.disk.some(
(d: CheckDiskInfo) => d != null && typeof d.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100
)
: false;
const temps = metrics.cpu?.temperature ?? [];
const tempBreach = temps.some((temp: number) => temp > monitor.tempAlertThreshold);
@@ -366,27 +362,4 @@ export class StatusService implements IStatusService {
});
}
};
insertCheck = async (check: Check) => {
try {
if (typeof check === "undefined") {
this.logger.warn({
message: "Failed to build check",
service: SERVICE_NAME,
method: "insertCheck",
});
return false;
}
this.buffer.addToBuffer(check);
return true;
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "insertCheck",
details: { msg: `Error inserting check for monitor: ${check?.metadata.monitorId}` },
stack: error instanceof Error ? error.stack : undefined,
});
}
};
}
@@ -53,9 +53,6 @@ export class SettingsService implements ISettingsService {
}
getSettings() {
if (!this.settings) {
throw new Error("Settings have not been loaded");
}
return this.settings;
}
@@ -0,0 +1,51 @@
import type { Notification } from "../../src/types/index.ts";
import type { NotificationMessage } from "../../src/types/notificationMessage.ts";
export const makeNotification = (overrides?: Partial<Notification>): Notification =>
({
address: "https://hooks.example.com/webhook",
accessToken: "token-abc",
homeserverUrl: "https://matrix.example.com",
roomId: "!room:example.com",
...overrides,
}) as Notification;
export const makeMessage = (overrides?: Partial<NotificationMessage>): NotificationMessage => ({
type: "monitor_down",
severity: "critical",
monitor: { id: "mon-1", name: "Test Monitor", url: "https://example.com", type: "http", status: "down" },
content: {
title: "Monitor Down: Test Monitor",
summary: 'Monitor "Test Monitor" is currently down.',
details: ["URL: https://example.com", "Status: Down"],
timestamp: new Date("2025-01-01T00:00:00Z"),
},
clientHost: "https://app.example.com",
metadata: { teamId: "team-1", notificationReason: "status_change" },
...overrides,
});
export const makeMessageWithThresholds = (): NotificationMessage =>
makeMessage({
type: "threshold_breach",
severity: "warning",
monitor: { id: "mon-1", name: "Infra Server", url: "https://infra.example.com", type: "hardware", status: "up" },
content: {
title: "Threshold Exceeded: Infra Server",
summary: "Thresholds exceeded.",
details: ["URL: https://infra.example.com"],
thresholds: [
{ metric: "cpu", currentValue: 90, threshold: 80, unit: "%", formattedValue: "90.0%" },
{ metric: "memory", currentValue: 85, threshold: 70, unit: "%", formattedValue: "85.0%" },
],
timestamp: new Date("2025-01-01T00:00:00Z"),
},
});
export const makeMessageWithIncident = (): NotificationMessage =>
makeMessage({
content: {
...makeMessage().content,
incident: { id: "inc-1", url: "https://app.example.com/incidents/inc-1", createdAt: new Date("2025-01-01T00:00:00Z") },
},
});
@@ -0,0 +1,40 @@
import { describe, expect, it } from "@jest/globals";
import type { INotificationProvider } from "../../src/service/infrastructure/notificationProviders/INotificationProvider.ts";
import type { Notification } from "../../src/types/index.ts";
import type { NotificationMessage } from "../../src/types/notificationMessage.ts";
const makeMessage = (): NotificationMessage => ({
type: "monitor_down",
severity: "critical",
monitor: { id: "mon-1", name: "Test", url: "https://example.com", type: "http", status: "down" },
content: {
title: "Monitor Down: Test",
summary: "Test is down",
details: ["URL: https://example.com"],
timestamp: new Date("2025-01-01T00:00:00Z"),
},
clientHost: "https://app.example.com",
metadata: { teamId: "team-1", notificationReason: "status_change" },
});
export const testNotificationProviderContract = (
name: string,
opts: {
create: () => INotificationProvider;
makeNotification: () => Partial<Notification>;
}
) => {
describe(`INotificationProvider contract: ${name}`, () => {
it("sendTestAlert returns a boolean", async () => {
const provider = opts.create();
const result = await provider.sendTestAlert(opts.makeNotification());
expect(typeof result).toBe("boolean");
});
it("sendMessage returns a boolean", async () => {
const provider = opts.create();
const result = await provider.sendMessage(opts.makeNotification() as Notification, makeMessage());
expect(typeof result).toBe("boolean");
});
});
};
@@ -0,0 +1,54 @@
import { describe, expect, it } from "@jest/globals";
import type { IStatusProvider } from "../../src/service/infrastructure/network/IStatusProvider.ts";
import type { Monitor } from "../../src/types/index.ts";
/**
* Shared contract tests for all IStatusProvider implementations.
* Tests the interface guarantees that every provider must satisfy.
*/
export const testStatusProviderContract = (
name: string,
opts: {
create: () => IStatusProvider<unknown>;
supportedType: string;
unsupportedType: string;
makeMonitor: () => Monitor;
}
) => {
describe(`IStatusProvider contract: ${name}`, () => {
it("has a readonly type property matching the supported type", () => {
const provider = opts.create();
expect(provider.type).toBe(opts.supportedType);
});
it("supports() returns true for its own type", () => {
const provider = opts.create();
expect(provider.supports(opts.supportedType as any)).toBe(true);
});
it("supports() returns false for a different type", () => {
const provider = opts.create();
expect(provider.supports(opts.unsupportedType as any)).toBe(false);
});
it("handle() returns an object with required MonitorStatusResponse fields", async () => {
const provider = opts.create();
try {
const result = await provider.handle(opts.makeMonitor());
expect(result).toEqual(
expect.objectContaining({
monitorId: expect.anything(),
teamId: expect.anything(),
type: expect.anything(),
status: expect.any(Boolean),
code: expect.any(Number),
message: expect.any(String),
})
);
} catch (err: any) {
// Some providers throw AppError on failure — that's also valid behavior
expect(err).toBeDefined();
}
});
});
};
-225
View File
@@ -1,225 +0,0 @@
import { jest } from "@jest/globals";
import { MonitorService } from "../src/service/business/monitorService.ts";
import type {
IChecksRepository,
IGeoChecksRepository,
IIncidentsRepository,
IMonitorStatsRepository,
IMonitorsRepository,
IStatusPagesRepository,
} from "../src/repositories/index.ts";
const createMonitorsRepositoryMock = () =>
({
findMonitorCountByTeamIdAndType: jest.fn(),
findByTeamId: jest.fn(),
findById: jest.fn(),
findMonitorsSummaryByTeamId: jest.fn(),
}) as unknown as IMonitorsRepository;
const createChecksRepositoryMock = () =>
({
findByDateRangeAndMonitorId: jest.fn(),
}) as unknown as IChecksRepository;
const createMonitorStatsRepositoryMock = () =>
({
findByMonitorId: jest.fn(),
deleteByMonitorId: jest.fn(),
}) as unknown as IMonitorStatsRepository;
const createStatusPagesRepositoryMock = () =>
({
removeMonitorFromStatusPages: jest.fn(),
}) as unknown as IStatusPagesRepository;
const createGeoChecksRepositoryMock = () => ({}) as unknown as IGeoChecksRepository;
const createIncidentsRepositoryMock = () => ({}) as unknown as IIncidentsRepository;
const createService = ({
monitorsRepository = createMonitorsRepositoryMock(),
checksRepository = createChecksRepositoryMock(),
monitorStatsRepository = createMonitorStatsRepositoryMock(),
statusPagesRepository = createStatusPagesRepositoryMock(),
geoChecksRepository = createGeoChecksRepositoryMock(),
incidentsRepository = createIncidentsRepositoryMock(),
}: {
monitorsRepository?: IMonitorsRepository;
checksRepository?: IChecksRepository;
monitorStatsRepository?: IMonitorStatsRepository;
statusPagesRepository?: IStatusPagesRepository;
geoChecksRepository?: IGeoChecksRepository;
incidentsRepository?: IIncidentsRepository;
} = {}) => {
return new MonitorService({
jobQueue: {
addJob: jest.fn(),
updateJob: jest.fn(),
resumeJob: jest.fn(),
pauseJob: jest.fn(),
deleteJob: jest.fn(),
} as any,
emailService: { buildEmail: jest.fn(), sendEmail: jest.fn() } as any,
logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() } as any,
games: [],
monitorsRepository,
checksRepository,
geoChecksRepository,
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
});
};
describe("MonitorService", () => {
describe("getMonitorsWithChecksByTeamId", () => {
it("returns monitors enriched with normalized recentChecks", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(2);
(monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({
totalMonitors: 2,
upMonitors: 2,
downMonitors: 0,
pausedMonitors: 0,
});
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([
{
id: "m1",
name: "Monitor 1",
type: "http",
interval: 60000,
recentChecks: [
{ responseTime: 10, status: true, message: "OK" },
{ responseTime: 20, status: true, message: "OK" },
],
},
{
id: "m2",
name: "Monitor 2",
type: "http",
interval: 60000,
recentChecks: [{ responseTime: 50, status: true, message: "OK" }],
},
]);
const service = createService({ monitorsRepository });
const result = await service.getMonitorsWithChecksByTeamId({ teamId: "team" });
expect(result).toMatchObject({ count: 2 });
expect(result.monitors).toHaveLength(2);
expect(result.monitors[0]).toHaveProperty("recentChecks");
expect(result.monitors[0].recentChecks.length).toBeGreaterThan(0);
expect(result.monitors[0].recentChecks[0]).toEqual(
expect.objectContaining({
responseTime: expect.any(Number),
status: expect.any(Boolean),
message: expect.any(String),
})
);
});
});
describe("getMonitorsByTeamId", () => {
it("returns monitors array from repository", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([
{ id: "m1", name: "Monitor 1" },
{ id: "m2", name: "Monitor 2" },
]);
const service = createService({ monitorsRepository });
const result = await service.getMonitorsByTeamId({ teamId: "team" } as any);
expect(result).toHaveLength(2);
expect(result![0]).toHaveProperty("id", "m1");
});
});
describe("getMonitorsWithChecksByTeamId summary", () => {
it("includes summary and monitors with recentChecks", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1);
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([{ id: "m1", type: "http", recentChecks: [] }]);
(monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({
totalMonitors: 1,
upMonitors: 1,
downMonitors: 0,
pausedMonitors: 0,
});
const service = createService({ monitorsRepository });
const result = await service.getMonitorsWithChecksByTeamId({ teamId: "team" });
expect(result).toEqual({
summary: { totalMonitors: 1, upMonitors: 1, downMonitors: 0, pausedMonitors: 0 },
count: 1,
monitors: [{ id: "m1", type: "http", recentChecks: [] }],
});
});
});
describe("getUptimeDetailsById", () => {
it("returns monitorData and monitorStats with expected shape", async () => {
const TEAM_ID = "team";
const monitor = {
id: "monitor-1",
teamId: TEAM_ID,
name: "HTTP monitor",
interval: 60000,
type: "http",
url: "https://example.com",
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
const checksRepository = createChecksRepositoryMock();
(checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({
monitorType: "http",
groupedChecks: [{ _id: "2024-01-01", avgResponseTime: 100, totalChecks: 2 }],
groupedUpChecks: [{ _id: "2024-01-01", totalChecks: 2, avgResponseTime: 90 }],
groupedDownChecks: [{ _id: "2024-01-01", totalChecks: 0, avgResponseTime: 0 }],
uptimePercentage: 0.99,
avgResponseTime: 95,
});
const monitorStatsRepository = createMonitorStatsRepositoryMock();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue({
id: "stats-1",
monitorId: monitor.id,
avgResponseTime: 90,
totalChecks: 10,
totalUpChecks: 9,
totalDownChecks: 1,
uptimePercentage: 0.9,
lastCheckTimestamp: 123456789,
lastResponseTime: 80,
timeOfLastFailure: 123456700,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const service = createService({ monitorsRepository, checksRepository, monitorStatsRepository });
const result = await service.getUptimeDetailsById({ teamId: TEAM_ID, monitorId: "monitor-1", dateRange: "recent" });
expect(result).toHaveProperty("monitorData");
expect(result.monitorData.monitor).toMatchObject({ id: monitor.id, name: monitor.name });
expect(result.monitorData.groupedChecks[0]).toEqual(
expect.objectContaining({
_id: expect.any(String),
avgResponseTime: expect.any(Number),
totalChecks: expect.any(Number),
})
);
expect(result.monitorStats).toEqual(
expect.objectContaining({
monitorId: monitor.id,
avgResponseTime: 90,
totalChecks: 10,
totalUpChecks: 9,
totalDownChecks: 1,
})
);
});
});
});
-153
View File
@@ -1,153 +0,0 @@
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";
const createHelper = (overrides?: Record<string, unknown>) => {
const maintenanceWindowsRepository = {
findByMonitorId: jest.fn().mockResolvedValue([]),
};
const monitorsRepository = {
updateById: jest.fn().mockResolvedValue({}),
findAllMonitorIds: jest.fn().mockResolvedValue(["m1"]),
deleteByTeamIdsNotIn: jest.fn().mockResolvedValue(0),
};
const teamsRepository = {
findAllTeamIds: jest.fn().mockResolvedValue(["team"]),
};
const monitorStatsRepository = {
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
};
const checksRepository = {
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
};
const incidentsRepository = {
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
};
const geoChecksRepository = {
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
};
const statusServiceMock = {
updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1", status: "up" }, statusChanged: false, prevStatus: "up", code: 200 }),
};
const settingsServiceMock = {
getDBSettings: jest.fn().mockResolvedValue({ checkTTL: 30 }),
};
const geoChecksServiceMock = {
buildGeoCheck: jest.fn().mockResolvedValue(null),
};
const defaults = {
logger: createMockLogger(),
networkService: { requestStatus: jest.fn() },
statusService: statusServiceMock,
notificationsService: { handleNotifications: jest.fn().mockResolvedValue(undefined) },
checkService: { buildCheck: jest.fn().mockReturnValue({}), deleteOlderThan: jest.fn().mockResolvedValue(0) },
settingsService: settingsServiceMock,
buffer: { addToBuffer: jest.fn(), addGeoCheckToBuffer: jest.fn() },
incidentService: { handleIncident: jest.fn().mockResolvedValue(undefined) },
maintenanceWindowsRepository,
monitorsRepository,
teamsRepository,
monitorStatsRepository,
checksRepository,
incidentsRepository,
geoChecksService: geoChecksServiceMock,
geoChecksRepository,
...overrides,
};
const helper = new SuperSimpleQueueHelper(
defaults.logger as any,
defaults.networkService as any,
defaults.statusService as any,
defaults.notificationsService as any,
defaults.checkService as any,
defaults.settingsService as any,
defaults.buffer as any,
defaults.incidentService as any,
defaults.maintenanceWindowsRepository as any,
defaults.monitorsRepository as any,
defaults.teamsRepository as any,
defaults.monitorStatsRepository as any,
defaults.checksRepository as any,
defaults.incidentsRepository as any,
defaults.geoChecksService as any,
defaults.geoChecksRepository as any
);
return { helper, maintenanceWindowsRepository, defaults };
};
describe("SuperSimpleQueueHelper", () => {
describe("getHeartbeatJob", () => {
it("skips execution when monitor is in maintenance window", async () => {
const { helper } = createHelper();
const spy = jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true);
const job = helper.getHeartbeatJob();
await job({ id: "m1", teamId: "team", interval: 60000 } as Monitor);
expect(helper["networkService"].requestStatus).not.toHaveBeenCalled();
expect(helper["logger"].debug).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("Monitor m1 is in maintenance window") })
);
spy.mockRestore();
});
it("processes monitor status and notifications when active", async () => {
const networkResponse = { monitor: { id: "m1" }, status: true, code: 200, message: "OK" };
const statusServiceMock = {
updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1", status: "up" }, statusChanged: false, prevStatus: "up", code: 200 }),
};
const { helper } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
statusService: statusServiceMock,
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
const monitor = { id: "m1", teamId: "team" } as Monitor;
await job(monitor);
expect(helper["networkService"].requestStatus).toHaveBeenCalledWith(monitor);
});
it("throws when monitor id is missing", async () => {
const { helper } = createHelper();
const job = helper.getHeartbeatJob();
await expect(job({} as Monitor)).rejects.toThrow("No monitor id");
expect(helper["logger"].warn).toHaveBeenCalled();
});
});
describe("isInMaintenanceWindow", () => {
it("returns true when an active window spans now", async () => {
const now = new Date();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now.getTime() - 1000).toISOString(),
end: new Date(now.getTime() + 1000).toISOString(),
repeat: 0,
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
});
it("returns true when repeat interval advances window into current time", async () => {
const now = Date.now();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now - 7200000).toISOString(),
end: new Date(now - 6600000).toISOString(),
repeat: 3600000,
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
});
it("returns false when no active windows exist", async () => {
const { helper } = createHelper();
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
});
});
});
@@ -0,0 +1,228 @@
import { describe, expect, it, jest } from "@jest/globals";
import { AdvancedMatcher } from "../../../../src/service/infrastructure/network/AdvancedMatcher.ts";
import type { Monitor } from "../../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createMockJmespath = () => ({
search: jest.fn((data: any, expr: string) => {
// Simple dot-path evaluation for tests
return expr.split(".").reduce((acc: any, key: string) => acc?.[key], data);
}),
});
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
useAdvancedMatching: false,
jsonPath: undefined,
matchMethod: undefined,
expectedValue: undefined,
...overrides,
}) as Monitor;
// ── Tests ────────────────────────────────────────────────────────────────────
describe("AdvancedMatcher", () => {
describe("validate", () => {
it("returns ok when advanced matching is disabled", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate({ data: "test" }, makeMonitor({ useAdvancedMatching: false }));
expect(result).toEqual({ ok: true, message: "Success" });
});
// ── jsonPath extraction ──────────────────────────────────────────
describe("jsonPath extraction", () => {
it("extracts value using jsonPath", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate({ status: "healthy" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "status" }));
expect(result).toEqual({ ok: true, message: "Success", extracted: "healthy" });
});
it("returns error when jsonPath evaluation throws", () => {
const jmespath = {
search: jest.fn().mockImplementation(() => {
throw new Error("bad expression");
}),
};
const matcher = new AdvancedMatcher(jmespath as any);
const result = matcher.validate({ data: "test" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "invalid[" }));
expect(result).toEqual({ ok: false, message: "Error evaluating JSON path" });
});
});
// ── expectedValue matching ───────────────────────────────────────
describe("expectedValue with matchMethod", () => {
it("matches with 'equal' method", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate(
{ status: "ok" },
makeMonitor({ useAdvancedMatching: true, jsonPath: "status", matchMethod: "equal", expectedValue: "ok" })
);
expect(result.ok).toBe(true);
});
it("fails with 'equal' method on mismatch", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate(
{ status: "error" },
makeMonitor({ useAdvancedMatching: true, jsonPath: "status", matchMethod: "equal", expectedValue: "ok" })
);
expect(result.ok).toBe(false);
expect(result.message).toBe("Expected value did not match");
});
it("matches with 'include' method", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate(
{ msg: "server is healthy" },
makeMonitor({ useAdvancedMatching: true, jsonPath: "msg", matchMethod: "include", expectedValue: "healthy" })
);
expect(result.ok).toBe(true);
});
it("fails with 'include' method when substring not found", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate(
{ msg: "server is down" },
makeMonitor({ useAdvancedMatching: true, jsonPath: "msg", matchMethod: "include", expectedValue: "healthy" })
);
expect(result.ok).toBe(false);
});
it("matches with 'regex' method", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate(
{ code: "200" },
makeMonitor({ useAdvancedMatching: true, jsonPath: "code", matchMethod: "regex", expectedValue: "^2\\d{2}$" })
);
expect(result.ok).toBe(true);
});
it("fails with 'regex' method on mismatch", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate(
{ code: "500" },
makeMonitor({ useAdvancedMatching: true, jsonPath: "code", matchMethod: "regex", expectedValue: "^2\\d{2}$" })
);
expect(result.ok).toBe(false);
});
it("defaults to 'equal' comparison when matchMethod is undefined", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate({ val: "exact" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "val", expectedValue: "exact" }));
expect(result.ok).toBe(true);
});
it("defaults to 'equal' comparison for unknown matchMethod", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate(
{ val: "exact" },
makeMonitor({ useAdvancedMatching: true, jsonPath: "val", matchMethod: "unknown" as any, expectedValue: "exact" })
);
expect(result.ok).toBe(true);
});
it("compares entire payload when jsonPath is not set", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate(
"hello",
makeMonitor({ useAdvancedMatching: true, jsonPath: undefined, matchMethod: "equal", expectedValue: "hello" })
);
expect(result.ok).toBe(true);
expect(result.extracted).toBe("hello");
});
it("includes extracted value in result", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate(
{ nested: { value: "42" } },
makeMonitor({ useAdvancedMatching: true, jsonPath: "nested.value", matchMethod: "equal", expectedValue: "42" })
);
expect(result.extracted).toBe("42");
});
});
// ── falsy value check (no expectedValue) ────────────────────────
describe("falsy value check (no expectedValue)", () => {
it("returns ok for truthy extracted value", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate({ status: "up" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "status" }));
expect(result.ok).toBe(true);
expect(result.extracted).toBe("up");
});
it("returns not ok for false", () => {
const jmespath = { search: jest.fn().mockReturnValue(false) };
const matcher = new AdvancedMatcher(jmespath as any);
const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "missing" }));
expect(result.ok).toBe(false);
expect(result.message).toBe("Extracted value is falsy");
});
it("returns not ok for string 'false'", () => {
const jmespath = { search: jest.fn().mockReturnValue("false") };
const matcher = new AdvancedMatcher(jmespath as any);
const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" }));
expect(result.ok).toBe(false);
});
it("returns not ok for undefined", () => {
const jmespath = { search: jest.fn().mockReturnValue(undefined) };
const matcher = new AdvancedMatcher(jmespath as any);
const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" }));
expect(result.ok).toBe(false);
});
it("returns not ok for null", () => {
const jmespath = { search: jest.fn().mockReturnValue(null) };
const matcher = new AdvancedMatcher(jmespath as any);
const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" }));
expect(result.ok).toBe(false);
});
it("returns ok for zero (not in falsy list)", () => {
const jmespath = { search: jest.fn().mockReturnValue(0) };
const matcher = new AdvancedMatcher(jmespath as any);
const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" }));
expect(result.ok).toBe(true);
});
it("returns ok for empty string (not in falsy list)", () => {
const jmespath = { search: jest.fn().mockReturnValue("") };
const matcher = new AdvancedMatcher(jmespath as any);
const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" }));
expect(result.ok).toBe(true);
});
});
});
});
@@ -0,0 +1,343 @@
import { describe, expect, it, jest } from "@jest/globals";
import { DockerProvider } from "../../../../src/service/infrastructure/network/DockerProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
import type { Monitor } from "../../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "docker",
url: "my-container",
...overrides,
}) as Monitor;
const makeContainer = (overrides?: Record<string, any>) => ({
Id: "abc123def456abc123def456abc123def456abc123def456abc123def456abcd",
Names: ["/my-container"],
State: "running",
...overrides,
});
const createMockDocker = (containers: any[] = [makeContainer()], inspectResult?: any) => {
const inspect = jest.fn().mockResolvedValue(inspectResult ?? { State: { Status: "running" } });
const getContainer = jest.fn().mockReturnValue({ inspect });
const instance = {
listContainers: jest.fn().mockResolvedValue(containers),
getContainer,
};
const DockerLib = jest.fn().mockReturnValue(instance) as any;
return { DockerLib, instance, inspect, getContainer };
};
const createProvider = (opts?: { containers?: any[]; inspectResult?: any }) => {
const logger = createMockLogger();
const { DockerLib, instance, inspect, getContainer } = createMockDocker(opts?.containers, opts?.inspectResult);
const provider = new DockerProvider(logger as any, DockerLib);
return { provider, logger, docker: instance, inspect, getContainer };
};
// ── Contract ─────────────────────────────────────────────────────────────────
testStatusProviderContract("DockerProvider", {
create: () => createProvider().provider,
supportedType: "docker",
unsupportedType: "http",
makeMonitor: () => makeMonitor(),
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("DockerProvider", () => {
// ── Container matching ───────────────────────────────────────────────
describe("container matching", () => {
it("matches by exact container name", async () => {
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ url: "my-container" }));
expect(result.status).toBe(true);
expect(result.code).toBe(200);
});
it("matches by exact full ID (64 chars)", async () => {
const fullId = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd";
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ url: fullId }));
expect(result.status).toBe(true);
});
it("matches by partial ID prefix", async () => {
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ url: "abc123" }));
expect(result.status).toBe(true);
});
it("strips leading slashes from input", async () => {
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ url: "///my-container" }));
expect(result.status).toBe(true);
});
it("matches case-insensitively", async () => {
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ url: "MY-CONTAINER" }));
expect(result.status).toBe(true);
});
it("returns 404 when no container matches", async () => {
const { provider, logger } = createProvider();
const result = await provider.handle(makeMonitor({ url: "nonexistent" }));
expect(result.status).toBe(false);
expect(result.code).toBe(404);
expect(result.message).toBe("Docker container not found");
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("No container found"),
})
);
});
});
// ── Ambiguity detection ──────────────────────────────────────────────
describe("ambiguity detection", () => {
it("returns error when input matches both exact name and partial ID", async () => {
// Container whose name matches the input AND whose ID starts with the input
const container = makeContainer({
Id: "my-containerabc123def456abc123def456abc123def456abc123def456abcd",
Names: ["/my-container"],
});
const { provider, logger } = createProvider({ containers: [container] });
const result = await provider.handle(makeMonitor({ url: "my-container" }));
expect(result.status).toBe(false);
expect(result.code).toBe(NETWORK_ERROR);
expect(result.message).toContain("Ambiguous");
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("Ambiguous"),
})
);
});
it("reports 'exact ID' in ambiguity message when exact ID and name both match", async () => {
const fullId = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd";
const container = makeContainer({
Id: fullId,
Names: ["/" + fullId],
});
const { provider } = createProvider({ containers: [container] });
const result = await provider.handle(makeMonitor({ url: fullId }));
expect(result.message).toContain("Using exact ID");
});
it("reports 'exact name' in ambiguity message when name and partial ID match", async () => {
const container = makeContainer({
Id: "my-containerabc123def456abc123def456abc123def456abc123def456abcd",
Names: ["/my-container"],
});
const { provider } = createProvider({ containers: [container] });
const result = await provider.handle(makeMonitor({ url: "my-container" }));
expect(result.message).toContain("Using exact name");
});
});
// ── Container inspection ─────────────────────────────────────────────
describe("container inspection", () => {
it("returns running status for running container", async () => {
const { provider } = createProvider({ inspectResult: { State: { Status: "running" } } });
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(true);
expect(result.message).toBe("Docker container status fetched successfully");
});
it("returns not-running status for stopped container", async () => {
const { provider } = createProvider({ inspectResult: { State: { Status: "exited" } } });
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(false);
});
it("returns NETWORK_ERROR when inspect response is null", async () => {
const logger = createMockLogger();
const inspect = jest.fn().mockResolvedValue(null);
const getContainer = jest.fn().mockReturnValue({ inspect });
const instance = { listContainers: jest.fn().mockResolvedValue([makeContainer()]), getContainer };
const DockerLib = jest.fn().mockReturnValue(instance) as any;
const provider = new DockerProvider(logger as any, DockerLib);
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(false);
expect(result.code).toBe(NETWORK_ERROR);
expect(result.message).toBe("No response from Docker container inspect");
});
it("returns false status when inspect response has no State", async () => {
const { provider } = createProvider({ inspectResult: {} });
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(false);
});
it("returns false status when inspect response has State but no Status", async () => {
const { provider } = createProvider({ inspectResult: { State: {} } });
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(false);
});
it("handles inspect error with Docker-specific error", async () => {
const { provider } = createProvider();
// Make inspect throw a Docker error
const dockerErr = Object.assign(new Error("container not found"), {
statusCode: 404,
reason: "no such container",
json: { message: "Container not found" },
});
provider["docker"] = {
listContainers: jest.fn().mockResolvedValue([makeContainer()]),
getContainer: jest.fn().mockReturnValue({
inspect: jest.fn().mockRejectedValue(dockerErr),
}),
} as any;
// Need to re-assign; simpler to use the createProvider mock
const { provider: p2 } = createProvider();
// Override getContainer to return failing inspect
p2["docker"].getContainer = jest.fn().mockReturnValue({
inspect: jest.fn().mockRejectedValue(dockerErr),
});
const result = await p2.handle(makeMonitor());
expect(result.status).toBe(false);
expect(result.code).toBe(404);
expect(result.message).toBe("Container not found");
});
it("handles inspect error with Docker error using reason fallback", async () => {
const { provider } = createProvider();
const dockerErr = Object.assign(new Error("error"), {
statusCode: 500,
reason: "internal error",
json: {},
});
provider["docker"].getContainer = jest.fn().mockReturnValue({
inspect: jest.fn().mockRejectedValue(dockerErr),
});
const result = await provider.handle(makeMonitor());
expect(result.message).toBe("internal error");
});
it("handles inspect error with Docker error using Error message fallback", async () => {
const { provider } = createProvider();
const dockerErr = Object.assign(new Error("base error"), {
statusCode: 503,
reason: undefined,
json: undefined,
});
provider["docker"].getContainer = jest.fn().mockReturnValue({
inspect: jest.fn().mockRejectedValue(dockerErr),
});
const result = await provider.handle(makeMonitor());
expect(result.message).toBe("base error");
});
it("handles inspect error with NETWORK_ERROR when no statusCode", async () => {
const { provider } = createProvider();
const dockerErr = Object.assign(new Error("unknown"), {
statusCode: undefined,
});
provider["docker"].getContainer = jest.fn().mockReturnValue({
inspect: jest.fn().mockRejectedValue(dockerErr),
});
const result = await provider.handle(makeMonitor());
expect(result.code).toBe(NETWORK_ERROR);
});
it("handles inspect error with standard Error (not Docker error)", async () => {
const { provider } = createProvider();
provider["docker"].getContainer = jest.fn().mockReturnValue({
inspect: jest.fn().mockRejectedValue(new Error("ECONNREFUSED")),
});
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(false);
expect(result.message).toBe("ECONNREFUSED");
});
it("handles inspect error with non-Error non-Docker thrown value", async () => {
const { provider } = createProvider();
provider["docker"].getContainer = jest.fn().mockReturnValue({
inspect: jest.fn().mockRejectedValue("string error"),
});
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(false);
expect(result.message).toBe("Failed to fetch Docker container information");
});
});
// ── Outer catch ──────────────────────────────────────────────────────
describe("outer error handling", () => {
it("throws AppError when containerInput is missing", async () => {
const { provider } = createProvider();
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("Container name or ID is required for Docker monitor");
});
it("throws AppError when listContainers throws", async () => {
const { provider } = createProvider();
provider["docker"].listContainers = jest.fn().mockRejectedValue(new Error("Docker daemon unavailable"));
await expect(provider.handle(makeMonitor())).rejects.toThrow("Docker daemon unavailable");
});
it("throws AppError with default message for non-Error thrown values", async () => {
const { provider } = createProvider();
provider["docker"].listContainers = jest.fn().mockRejectedValue(null);
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing Docker request");
});
});
});
@@ -0,0 +1,191 @@
import { describe, expect, it, jest } from "@jest/globals";
import { GameProvider } from "../../../../src/service/infrastructure/network/GameProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
import type { Monitor } from "../../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "game",
url: "play.example.com",
port: 25565,
gameId: "minecraft",
...overrides,
}) as Monitor;
const createMockGameDig = (state?: Record<string, any> | null) => ({
query: jest.fn().mockResolvedValue(
state ?? {
name: "My Server",
map: "world",
players: [],
ping: 45,
}
),
});
const createProvider = (gameDig?: any) => {
const logger = createMockLogger();
const dig = gameDig ?? createMockGameDig();
const provider = new GameProvider(logger as any, dig as any);
return { provider, logger, gameDig: dig };
};
// ── Contract ─────────────────────────────────────────────────────────────────
testStatusProviderContract("GameProvider", {
create: () => createProvider().provider,
supportedType: "game",
unsupportedType: "http",
makeMonitor: () => makeMonitor(),
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("GameProvider", () => {
it("returns success with server state", async () => {
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
monitorId: "mon-1",
teamId: "team-1",
type: "game",
status: true,
code: 200,
message: "Success",
responseTime: 45,
})
);
});
it("queries with correct host, port, and gameId", async () => {
const { provider, gameDig } = createProvider();
await provider.handle(makeMonitor({ url: "https://play.example.com/path", port: 27015, gameId: "csgo" }));
expect(gameDig.query).toHaveBeenCalledWith({
type: "csgo",
host: "play.example.com",
port: 27015,
});
});
it("strips protocol and path from url for host", async () => {
const { provider, gameDig } = createProvider();
await provider.handle(makeMonitor({ url: "http://game.server.com:8080/info" }));
expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ host: "game.server.com" }));
});
it("defaults gameId to 'unknown' when not set", async () => {
const { provider, gameDig } = createProvider();
await provider.handle(makeMonitor({ gameId: undefined }));
expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ type: "unknown" }));
});
it("defaults port to 0 when not set", async () => {
const { provider, gameDig } = createProvider();
await provider.handle(makeMonitor({ port: undefined }));
expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ port: 0 }));
});
it("defaults responseTime to 0 when ping is undefined", async () => {
const { provider } = createProvider(createMockGameDig({ ping: undefined }));
const result = await provider.handle(makeMonitor());
expect(result.responseTime).toBe(0);
});
it("returns failure when query resolves to undefined (caught error)", async () => {
const gameDig = { query: jest.fn().mockRejectedValue(new Error("Server offline")) };
// GameProvider catches the error internally via .catch()
const { provider, logger } = createProvider(gameDig);
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
status: false,
code: NETWORK_ERROR,
message: "No response from game server",
responseTime: 0,
})
);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: "Server offline",
method: "handle",
})
);
});
it("logs non-Error thrown values from query", async () => {
const gameDig = { query: jest.fn().mockRejectedValue("string error") };
const { provider, logger } = createProvider(gameDig);
await provider.handle(makeMonitor());
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "string error" }));
});
it("uses empty string as host when url is undefined", async () => {
const { provider, gameDig } = createProvider();
await provider.handle(makeMonitor({ url: undefined }));
expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ host: "" }));
});
it("throws AppError with default message when error stringifies to empty", async () => {
const gameDig = {
query: jest.fn().mockReturnValue({
catch: () => {
throw new Error("");
},
}),
};
const { provider } = createProvider(gameDig);
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing game server check");
});
it("throws AppError with default message for non-Error that stringifies to empty", async () => {
const gameDig = {
query: jest.fn().mockReturnValue({
catch: () => {
throw "";
},
}),
};
const { provider } = createProvider(gameDig);
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing game server check");
});
it("throws AppError with Error message in outer catch", async () => {
const gameDig = {
query: jest.fn().mockReturnValue({
catch: () => {
throw new Error("unexpected");
},
}),
};
const { provider } = createProvider(gameDig);
await expect(provider.handle(makeMonitor())).rejects.toThrow("unexpected");
});
});
@@ -0,0 +1,378 @@
import { describe, expect, it, jest } from "@jest/globals";
import { GrpcProvider } from "../../../../src/service/infrastructure/network/GrpcProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import type { Monitor } from "../../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "grpc",
url: "grpc.example.com",
port: 50051,
grpcServiceName: "my.Service",
ignoreTlsErrors: false,
...overrides,
}) as Monitor;
const createMockClient = (behavior: "serving" | "not-serving" | "error" | "grpc-error" = "serving") => {
const close = jest.fn();
const Check = jest.fn((_req: any, _opts: any, cb: Function) => {
if (behavior === "serving") {
cb(null, { status: "SERVING" });
} else if (behavior === "not-serving") {
cb(null, { status: "NOT_SERVING" });
} else if (behavior === "error") {
cb({ code: 14, details: "Connection refused", message: "unavailable" });
} else if (behavior === "grpc-error") {
cb({ code: 2, message: "Unknown error" });
}
});
return { Check, close };
};
const createMockGrpc = (client?: ReturnType<typeof createMockClient>) => {
const c = client ?? createMockClient();
const Health = jest.fn().mockReturnValue(c);
return {
grpc: {
loadPackageDefinition: jest.fn().mockReturnValue({
grpc: { health: { v1: { Health } } },
}),
credentials: {
createInsecure: jest.fn().mockReturnValue("insecure-creds"),
createSsl: jest.fn().mockReturnValue("ssl-creds"),
},
},
protoLoader: {
loadSync: jest.fn().mockReturnValue({}),
},
Health,
client: c,
};
};
const createProvider = (opts?: { behavior?: "serving" | "not-serving" | "error" | "grpc-error" }) => {
const client = createMockClient(opts?.behavior ?? "serving");
const mocks = createMockGrpc(client);
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
return { provider, ...mocks, client };
};
// ── Contract ─────────────────────────────────────────────────────────────────
testStatusProviderContract("GrpcProvider", {
create: () => createProvider().provider,
supportedType: "grpc",
unsupportedType: "http",
makeMonitor: () => makeMonitor(),
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("GrpcProvider", () => {
// ── Success paths ────────────────────────────────────────────────────
describe("success responses", () => {
it("returns healthy status for SERVING response", async () => {
const { provider } = createProvider({ behavior: "serving" });
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
monitorId: "mon-1",
teamId: "team-1",
type: "grpc",
status: true,
code: 200,
message: expect.stringContaining("SERVING"),
})
);
expect(result.payload).toEqual(
expect.objectContaining({
grpcStatusCode: 0,
grpcStatusName: "OK",
serviceName: "my.Service",
servingStatus: "SERVING",
})
);
});
it("returns unhealthy status for NOT_SERVING response", async () => {
const { provider } = createProvider({ behavior: "not-serving" });
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(false);
expect(result.code).toBe(5000);
expect(result.message).toContain("NOT_SERVING");
});
it("defaults to UNKNOWN when response has no status", async () => {
const client = { Check: jest.fn((_r: any, _o: any, cb: Function) => cb(null, {})), close: jest.fn() };
const mocks = createMockGrpc(client as any);
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
const result = await provider.handle(makeMonitor());
expect(result.payload).toEqual(expect.objectContaining({ servingStatus: "UNKNOWN" }));
});
it("defaults to UNKNOWN when response is undefined", async () => {
const client = { Check: jest.fn((_r: any, _o: any, cb: Function) => cb(null, undefined)), close: jest.fn() };
const mocks = createMockGrpc(client as any);
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
const result = await provider.handle(makeMonitor());
expect(result.payload).toEqual(expect.objectContaining({ servingStatus: "UNKNOWN" }));
});
});
// ── gRPC errors ──────────────────────────────────────────────────────
describe("gRPC errors", () => {
it("returns failure with gRPC error details", async () => {
const { provider } = createProvider({ behavior: "error" });
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(false);
expect(result.code).toBe(14);
expect(result.message).toBe("Connection refused");
expect(result.payload).toEqual(
expect.objectContaining({
grpcStatusCode: 14,
grpcStatusName: "UNAVAILABLE",
servingStatus: "UNKNOWN",
})
);
});
it("falls back to message when details is missing", async () => {
const client = {
Check: jest.fn((_r: any, _o: any, cb: Function) => cb({ code: 2, message: "Unknown error" })),
close: jest.fn(),
};
const mocks = createMockGrpc(client as any);
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
const result = await provider.handle(makeMonitor());
expect(result.message).toBe("Unknown error");
});
it("uses default message and code when error has no code/details/message", async () => {
const client = {
Check: jest.fn((_r: any, _o: any, cb: Function) => cb({})),
close: jest.fn(),
};
const mocks = createMockGrpc(client as any);
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
const result = await provider.handle(makeMonitor());
expect(result.code).toBe(5000);
expect(result.message).toBe("gRPC error");
expect(result.payload).toEqual(expect.objectContaining({ grpcStatusCode: -1, grpcStatusName: "UNKNOWN" }));
});
it("defaults grpcCode to 5000 when error has no grpcCode", async () => {
const client = {
Check: jest.fn((_r: any, _o: any, cb: Function) => {
const err = new Error("test") as any;
// no grpcPayload or grpcCode
cb(err);
}),
close: jest.fn(),
};
const mocks = createMockGrpc(client as any);
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
const result = await provider.handle(makeMonitor());
expect(result.code).toBe(5000);
});
it("includes grpcPayload in error result", async () => {
const { provider } = createProvider({ behavior: "error" });
const result = await provider.handle(makeMonitor());
expect(result.payload).toEqual(
expect.objectContaining({
grpcStatusCode: 14,
grpcStatusName: "UNAVAILABLE",
})
);
});
it("defaults code, message, and payload when error lacks gRPC properties", async () => {
// client.Check throws synchronously — timeRequest catches a plain object with no gRPC props
const client = {
Check: jest.fn(() => {
throw { unexpected: true };
}),
close: jest.fn(),
};
const mocks = createMockGrpc(client as any);
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
const result = await provider.handle(makeMonitor());
expect(result.code).toBe(5000);
expect(result.message).toBe("gRPC health check failed");
expect(result.payload).toBeNull();
});
});
// ── Credentials ──────────────────────────────────────────────────────
describe("credentials", () => {
it("uses insecure credentials by default", async () => {
const { provider, grpc } = createProvider();
await provider.handle(makeMonitor({ ignoreTlsErrors: false }));
expect(grpc.credentials.createInsecure).toHaveBeenCalled();
});
it("uses SSL credentials with checkServerIdentity override when ignoreTlsErrors is true", async () => {
const { provider, grpc } = createProvider();
await provider.handle(makeMonitor({ ignoreTlsErrors: true }));
expect(grpc.credentials.createSsl).toHaveBeenCalledWith(null, null, null, {
checkServerIdentity: expect.any(Function),
});
// Verify checkServerIdentity returns undefined
const opts = (grpc.credentials.createSsl as jest.Mock).mock.calls[0][3];
expect(opts.checkServerIdentity()).toBeUndefined();
});
});
// ── URL parsing ──────────────────────────────────────────────────────
describe("URL and target construction", () => {
it("strips protocol from URL", async () => {
const { provider, Health } = createProvider();
await provider.handle(makeMonitor({ url: "https://grpc.example.com", port: 443 }));
expect(Health).toHaveBeenCalledWith("grpc.example.com:443", expect.anything());
});
it("uses grpcServiceName in Check request", async () => {
const { provider, client } = createProvider();
await provider.handle(makeMonitor({ grpcServiceName: "health.v1" }));
expect(client.Check).toHaveBeenCalledWith(
{ service: "health.v1" },
expect.objectContaining({ deadline: expect.any(Date) }),
expect.any(Function)
);
});
it("defaults grpcServiceName to empty string when not set", async () => {
const { provider, client } = createProvider();
await provider.handle(makeMonitor({ grpcServiceName: undefined }));
expect(client.Check).toHaveBeenCalledWith({ service: "" }, expect.anything(), expect.any(Function));
});
});
// ── Validation ───────────────────────────────────────────────────────
describe("validation", () => {
it("throws AppError when url is missing", async () => {
const { provider } = createProvider();
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("Monitor host is required");
});
it("throws AppError when port is missing", async () => {
const { provider } = createProvider();
await expect(provider.handle(makeMonitor({ port: undefined }))).rejects.toThrow("Monitor port is required");
});
});
// ── Outer catch ──────────────────────────────────────────────────────
describe("outer error handling", () => {
it("throws AppError when protoLoader.loadSync throws", async () => {
const mocks = createMockGrpc();
mocks.protoLoader.loadSync = jest.fn().mockImplementation(() => {
throw new Error("Proto file not found");
});
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
await expect(provider.handle(makeMonitor())).rejects.toThrow("Proto file not found");
});
it("throws AppError with default message when Error has empty message", async () => {
const mocks = createMockGrpc();
mocks.protoLoader.loadSync = jest.fn().mockImplementation(() => {
throw new Error("");
});
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing gRPC health check");
});
it("throws AppError with stringified message for non-Error thrown values", async () => {
const mocks = createMockGrpc();
mocks.protoLoader.loadSync = jest.fn().mockImplementation(() => {
throw "proto load failed";
});
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
await expect(provider.handle(makeMonitor())).rejects.toThrow("proto load failed");
});
});
// ── getGrpcStatusName ────────────────────────────────────────────────
describe("gRPC status name mapping", () => {
const statusCodes: [number, string][] = [
[0, "OK"],
[1, "CANCELLED"],
[2, "UNKNOWN"],
[3, "INVALID_ARGUMENT"],
[4, "DEADLINE_EXCEEDED"],
[5, "NOT_FOUND"],
[6, "ALREADY_EXISTS"],
[7, "PERMISSION_DENIED"],
[8, "RESOURCE_EXHAUSTED"],
[9, "FAILED_PRECONDITION"],
[10, "ABORTED"],
[11, "OUT_OF_RANGE"],
[12, "UNIMPLEMENTED"],
[13, "INTERNAL"],
[14, "UNAVAILABLE"],
[15, "DATA_LOSS"],
[16, "UNAUTHENTICATED"],
[99, "UNKNOWN"],
];
it.each(statusCodes)("maps gRPC code %i to %s", async (code, expectedName) => {
const client = {
Check: jest.fn((_r: any, _o: any, cb: Function) => cb({ code, details: "test" })),
close: jest.fn(),
};
const mocks = createMockGrpc(client as any);
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
const result = await provider.handle(makeMonitor());
expect(result.payload).toEqual(expect.objectContaining({ grpcStatusName: expectedName }));
});
});
});
@@ -0,0 +1,91 @@
import { describe, expect, it, jest } from "@jest/globals";
import { HardwareProvider } from "../../../../src/service/infrastructure/network/HardwareProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import type { HttpProvider } from "../../../../src/service/infrastructure/network/HttpProvider.ts";
import type { Monitor } from "../../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "hardware",
url: "https://capture.example.com",
...overrides,
}) as Monitor;
const createMockHttpProvider = () =>
({
handle: jest.fn().mockResolvedValue({
monitorId: "mon-1",
teamId: "team-1",
type: "hardware",
status: true,
code: 200,
message: "OK",
responseTime: 50,
payload: { data: { cpu: { usage_percent: 0.5 } } },
}),
}) as unknown as jest.Mocked<HttpProvider>;
const createProvider = () => {
const httpProvider = createMockHttpProvider();
const provider = new HardwareProvider(httpProvider);
return { provider, httpProvider };
};
// ── Contract ─────────────────────────────────────────────────────────────────
testStatusProviderContract("HardwareProvider", {
create: () => createProvider().provider,
supportedType: "hardware",
unsupportedType: "http",
makeMonitor: () => makeMonitor(),
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("HardwareProvider", () => {
it("delegates to httpProvider.handle", async () => {
const { provider, httpProvider } = createProvider();
const monitor = makeMonitor();
await provider.handle(monitor);
expect(httpProvider.handle).toHaveBeenCalledWith(monitor);
});
it("returns the response from httpProvider", async () => {
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
monitorId: "mon-1",
status: true,
code: 200,
})
);
});
it("throws AppError when url is missing", async () => {
const { provider } = createProvider();
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for Hardware monitor");
});
it("throws AppError when httpProvider.handle throws", async () => {
const { provider, httpProvider } = createProvider();
(httpProvider.handle as jest.Mock).mockRejectedValue(new Error("connection failed"));
await expect(provider.handle(makeMonitor())).rejects.toThrow("connection failed");
});
it("throws AppError with default message for non-Error thrown values", async () => {
const { provider, httpProvider } = createProvider();
(httpProvider.handle as jest.Mock).mockRejectedValue("string error");
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing Hardware request");
});
});
@@ -0,0 +1,348 @@
import { describe, expect, it, jest } from "@jest/globals";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
import type { Monitor } from "../../../../src/types/index.ts";
// ── Mocks ────────────────────────────────────────────────────────────────────
jest.unstable_mockModule("cacheable-lookup", () => ({
default: jest.fn().mockImplementation(() => ({})),
}));
const mockGot = jest.fn();
// got instance returned by got.extend()
mockGot.mockImplementation(() => Promise.resolve());
(mockGot as any).extend = jest.fn().mockReturnValue(mockGot);
jest.unstable_mockModule("got", () => ({
type: { Got: {} },
HTTPError: class HTTPError extends Error {
response: any;
timings: any;
constructor(msg: string, response?: any, timings?: any) {
super(msg);
this.name = "HTTPError";
this.response = response;
this.timings = timings;
}
},
RequestError: class RequestError extends Error {
response: any;
timings: any;
constructor(msg: string, response?: any, timings?: any) {
super(msg);
this.name = "RequestError";
this.response = response;
this.timings = timings;
}
},
}));
const { HttpProvider } = await import("../../../../src/service/infrastructure/network/HttpProvider.ts");
const gotModule = await import("got");
const { HTTPError, RequestError } = gotModule;
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "http",
url: "https://example.com",
secret: undefined,
jsonPath: undefined,
ignoreTlsErrors: false,
useAdvancedMatching: false,
matchMethod: undefined,
expectedValue: undefined,
...overrides,
}) as Monitor;
const createMockMatcher = (result?: { ok: boolean; message: string; extracted?: unknown }) => ({
validate: jest.fn().mockReturnValue(result ?? { ok: true, message: "Success" }),
});
const makeGotResponse = (overrides?: Record<string, any>) => ({
ok: true,
statusCode: 200,
statusMessage: "OK",
headers: { "content-type": "text/html" },
body: "<html></html>",
timings: { phases: { total: 100 } },
...overrides,
});
const createProvider = (matcher?: any) => {
const advancedMatcher = matcher ?? createMockMatcher();
const provider = new HttpProvider(mockGot as any, advancedMatcher);
return { provider, advancedMatcher };
};
// ── Contract ─────────────────────────────────────────────────────────────────
testStatusProviderContract("HttpProvider", {
create: () => {
mockGot.mockResolvedValue(makeGotResponse());
return createProvider().provider;
},
supportedType: "http",
unsupportedType: "ping",
makeMonitor: () => makeMonitor(),
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("HttpProvider", () => {
// ── Success paths ────────────────────────────────────────────────────
describe("success responses", () => {
it("returns success for a standard HTML response", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
monitorId: "mon-1",
teamId: "team-1",
type: "http",
status: true,
code: 200,
message: "OK",
responseTime: 100,
})
);
});
it("parses JSON body when content-type is application/json", async () => {
mockGot.mockResolvedValue(
makeGotResponse({
headers: { "content-type": "application/json" },
body: '{"status":"ok"}',
})
);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.payload).toEqual({ status: "ok" });
});
it("returns raw body when JSON parsing fails", async () => {
mockGot.mockResolvedValue(
makeGotResponse({
headers: { "content-type": "application/json" },
body: "not-json",
})
);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.payload).toBe("not-json");
});
it("returns raw body for non-JSON content", async () => {
mockGot.mockResolvedValue(makeGotResponse({ body: "<html>test</html>" }));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.payload).toBe("<html>test</html>");
});
it("passes Authorization header when secret is set", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor({ secret: "my-token" }));
expect(mockGot).toHaveBeenCalledWith(
"https://example.com",
expect.objectContaining({
headers: { Authorization: "Bearer my-token" },
})
);
});
it("passes undefined headers when no secret", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor({ secret: undefined }));
expect(mockGot).toHaveBeenCalledWith(
"https://example.com",
expect.objectContaining({
headers: undefined,
})
);
});
it("defaults responseTime to 0 when timings.phases.total is undefined", async () => {
mockGot.mockResolvedValue(makeGotResponse({ timings: { phases: { total: undefined } } }));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.responseTime).toBe(0);
});
it("defaults statusMessage to 'OK' when undefined", async () => {
mockGot.mockResolvedValue(makeGotResponse({ statusMessage: undefined }));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.message).toBe("OK");
});
it("uses empty string for content-type when header is missing", async () => {
mockGot.mockResolvedValue(makeGotResponse({ headers: {} }));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
// Should treat as non-JSON
expect(result.payload).toBe("<html></html>");
});
});
// ── jsonPath + non-JSON response ─────────────────────────────────────
describe("jsonPath validation", () => {
it("returns failure when jsonPath is set but response is not JSON", async () => {
mockGot.mockResolvedValue(makeGotResponse({ headers: { "content-type": "text/html" } }));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ jsonPath: "status" }));
expect(result.status).toBe(false);
expect(result.message).toBe("Response is not JSON");
});
it("defaults responseTime to 0 in non-JSON jsonPath response when total is undefined", async () => {
mockGot.mockResolvedValue(
makeGotResponse({
headers: { "content-type": "text/html" },
timings: { phases: { total: undefined } },
})
);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ jsonPath: "status" }));
expect(result.responseTime).toBe(0);
});
});
// ── AdvancedMatcher integration ──────────────────────────────────────
describe("advanced matching", () => {
it("uses matcher result for status and message", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const matcher = createMockMatcher({ ok: false, message: "Mismatch", extracted: "value" });
const { provider } = createProvider(matcher);
const result = await provider.handle(makeMonitor({ useAdvancedMatching: true }));
expect(result.status).toBe(false);
expect(result.message).toBe("Mismatch");
expect(result.extracted).toBe("value");
});
it("sets status to false when response.ok is false even if matcher passes", async () => {
mockGot.mockResolvedValue(makeGotResponse({ ok: false, statusCode: 301 }));
const matcher = createMockMatcher({ ok: true, message: "Success" });
const { provider } = createProvider(matcher);
const result = await provider.handle(makeMonitor());
// status = response.ok && matchResult.ok
expect(result.status).toBe(false);
});
});
// ── Error handling ───────────────────────────────────────────────────
describe("error handling", () => {
it("handles HTTPError with response and timings", async () => {
const err = new HTTPError("Not Found");
(err as any).response = { statusCode: 404 };
(err as any).timings = { phases: { total: 50 } };
mockGot.mockRejectedValue(err);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
status: false,
code: 404,
message: "Not Found",
responseTime: 50,
})
);
});
it("handles RequestError with response and timings", async () => {
const err = new RequestError("ECONNREFUSED");
(err as any).response = { statusCode: undefined };
(err as any).timings = { phases: { total: undefined } };
mockGot.mockRejectedValue(err);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.code).toBe(NETWORK_ERROR);
expect(result.responseTime).toBe(0);
});
it("handles HTTPError without response (defaults to NETWORK_ERROR)", async () => {
const err = new HTTPError("Timeout");
(err as any).response = undefined;
(err as any).timings = undefined;
mockGot.mockRejectedValue(err);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.code).toBe(NETWORK_ERROR);
expect(result.responseTime).toBe(0);
});
it("handles generic Error (non-HTTPError/RequestError)", async () => {
mockGot.mockRejectedValue(new Error("DNS lookup failed"));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
status: false,
code: NETWORK_ERROR,
message: "DNS lookup failed",
responseTime: 0,
})
);
});
it("handles non-Error thrown values", async () => {
mockGot.mockRejectedValue("string error");
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.message).toBe("string error");
expect(result.code).toBe(NETWORK_ERROR);
});
it("throws when url is missing", async () => {
const { provider } = createProvider();
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for HTTP monitor");
});
});
});
@@ -0,0 +1,165 @@
import { describe, expect, it, jest } from "@jest/globals";
import { PageSpeedProvider } from "../../../../src/service/infrastructure/network/PageSpeedProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import type { HttpProvider } from "../../../../src/service/infrastructure/network/HttpProvider.ts";
import type { ISettingsService } from "../../../../src/service/system/settingsService.ts";
import type { Monitor } from "../../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "pagespeed",
url: "https://example.com",
...overrides,
}) as Monitor;
const createMockHttpProvider = () =>
({
handle: jest.fn().mockResolvedValue({
monitorId: "mon-1",
teamId: "team-1",
type: "pagespeed",
status: true,
code: 200,
message: "OK",
responseTime: 2000,
payload: { lighthouseResult: {} },
}),
}) as unknown as jest.Mocked<HttpProvider>;
const createMockSettingsService = (apiKey?: string) =>
({
getDBSettings: jest.fn().mockResolvedValue({ pagespeedApiKey: apiKey }),
}) as unknown as jest.Mocked<ISettingsService>;
const createProvider = (opts?: { apiKey?: string }) => {
const logger = createMockLogger();
const httpProvider = createMockHttpProvider();
const settingsService = createMockSettingsService(opts?.apiKey);
const provider = new PageSpeedProvider(httpProvider, settingsService, logger as any);
return { provider, httpProvider, settingsService, logger };
};
// ── Contract ─────────────────────────────────────────────────────────────────
testStatusProviderContract("PageSpeedProvider", {
create: () => createProvider({ apiKey: "test-key" }).provider,
supportedType: "pagespeed",
unsupportedType: "http",
makeMonitor: () => makeMonitor(),
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("PageSpeedProvider", () => {
it("delegates to httpProvider with PageSpeed API URL", async () => {
const { provider, httpProvider } = createProvider({ apiKey: "my-key" });
await provider.handle(makeMonitor({ url: "https://example.com" }));
expect(httpProvider.handle).toHaveBeenCalledWith(
expect.objectContaining({
url: expect.stringContaining("pagespeedonline.googleapis.com"),
})
);
});
it("includes API key in URL when available", async () => {
const { provider, httpProvider } = createProvider({ apiKey: "my-key" });
await provider.handle(makeMonitor());
const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url;
expect(calledUrl).toContain("key=my-key");
});
it("omits API key and logs warning when not configured", async () => {
const { provider, httpProvider, logger } = createProvider({ apiKey: undefined });
await provider.handle(makeMonitor());
const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url;
expect(calledUrl).not.toContain("key=");
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("API key not found"),
service: "PageSpeedProvider",
})
);
});
it("encodes the monitor URL in the PageSpeed API URL", async () => {
const { provider, httpProvider } = createProvider({ apiKey: "key" });
await provider.handle(makeMonitor({ url: "https://example.com/path?q=1" }));
const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url;
expect(calledUrl).toContain(encodeURIComponent("https://example.com/path?q=1"));
});
it("includes all PageSpeed categories in the URL", async () => {
const { provider, httpProvider } = createProvider({ apiKey: "key" });
await provider.handle(makeMonitor());
const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url;
expect(calledUrl).toContain("category=seo");
expect(calledUrl).toContain("category=accessibility");
expect(calledUrl).toContain("category=best-practices");
expect(calledUrl).toContain("category=performance");
});
it("passes all monitor properties through to httpProvider", async () => {
const { provider, httpProvider } = createProvider({ apiKey: "key" });
const monitor = makeMonitor({ id: "mon-5", teamId: "team-9" });
await provider.handle(monitor);
expect(httpProvider.handle).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-5", teamId: "team-9" }));
});
it("throws AppError when url is missing", async () => {
const { provider } = createProvider();
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for PageSpeed monitor");
});
it("throws AppError when httpProvider.handle throws", async () => {
const { provider, httpProvider } = createProvider({ apiKey: "key" });
(httpProvider.handle as jest.Mock).mockRejectedValue(new Error("timeout"));
await expect(provider.handle(makeMonitor())).rejects.toThrow("timeout");
});
it("throws AppError with default message for non-Error thrown values", async () => {
const { provider, httpProvider } = createProvider({ apiKey: "key" });
(httpProvider.handle as jest.Mock).mockRejectedValue("string error");
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing PageSpeed request");
});
it("handles null apiKey from DB settings", async () => {
const { provider, logger } = createProvider({ apiKey: null as any });
await provider.handle(makeMonitor());
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("API key not found") }));
});
it("handles null dbSettings response", async () => {
const logger = createMockLogger();
const httpProvider = createMockHttpProvider();
const settingsService = {
getDBSettings: jest.fn().mockResolvedValue(null),
} as unknown as jest.Mocked<ISettingsService>;
const provider = new PageSpeedProvider(httpProvider, settingsService, logger as any);
await provider.handle(makeMonitor());
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("API key not found") }));
});
});
@@ -0,0 +1,139 @@
import { describe, expect, it, jest } from "@jest/globals";
import { PingProvider } from "../../../../src/service/infrastructure/network/PingProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import type { Monitor } from "../../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "ping",
url: "example.com",
...overrides,
}) as Monitor;
const createMockPing = (result?: Partial<{ alive: boolean; time: number | string }>) => ({
promise: {
probe: jest.fn().mockResolvedValue({
alive: true,
time: 25,
...result,
}),
},
});
// ── Contract ─────────────────────────────────────────────────────────────────
testStatusProviderContract("PingProvider", {
create: () => new PingProvider(createMockPing() as any),
supportedType: "ping",
unsupportedType: "http",
makeMonitor: () => makeMonitor(),
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("PingProvider", () => {
it("returns success when host is alive", async () => {
const provider = new PingProvider(createMockPing({ alive: true, time: 30 }) as any);
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
monitorId: "mon-1",
teamId: "team-1",
type: "ping",
status: true,
code: 200,
message: "Success",
responseTime: 30,
})
);
});
it("returns failure when host is not alive", async () => {
const provider = new PingProvider(createMockPing({ alive: false, time: 0 }) as any);
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
status: false,
code: 5000,
message: "Ping failed",
})
);
});
it("parses string time values", async () => {
const provider = new PingProvider(createMockPing({ alive: true, time: "42.5" }) as any);
const result = await provider.handle(makeMonitor());
expect(result.responseTime).toBe(42.5);
});
it("defaults responseTime to 0 for non-numeric time", async () => {
const provider = new PingProvider(createMockPing({ alive: true, time: "unknown" }) as any);
const result = await provider.handle(makeMonitor());
expect(result.responseTime).toBe(0);
});
it("defaults status to false when alive is undefined", async () => {
const provider = new PingProvider(createMockPing({ alive: undefined as any }) as any);
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(false);
});
it("sanitizes URL by stripping protocol and path", async () => {
const mockPing = createMockPing();
const provider = new PingProvider(mockPing as any);
await provider.handle(makeMonitor({ url: "https://example.com/path?query=1" }));
expect(mockPing.promise.probe).toHaveBeenCalledWith("example.com");
});
it("sanitizes URL by stripping port", async () => {
const mockPing = createMockPing();
const provider = new PingProvider(mockPing as any);
await provider.handle(makeMonitor({ url: "example.com:8080" }));
expect(mockPing.promise.probe).toHaveBeenCalledWith("example.com");
});
it("throws AppError when url is missing", async () => {
const provider = new PingProvider(createMockPing() as any);
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for ping monitor");
});
it("throws AppError when ping.probe rejects", async () => {
const mockPing = { promise: { probe: jest.fn().mockRejectedValue(new Error("Host unreachable")) } };
const provider = new PingProvider(mockPing as any);
await expect(provider.handle(makeMonitor())).rejects.toThrow("Host unreachable");
});
it("throws AppError when probe returns no response", async () => {
const mockPing = { promise: { probe: jest.fn().mockResolvedValue(null) } };
const provider = new PingProvider(mockPing as any);
await expect(provider.handle(makeMonitor())).rejects.toThrow("No response from ping");
});
it("throws AppError with stringified message for non-Error throws", async () => {
const mockPing = { promise: { probe: jest.fn().mockRejectedValue(42) } };
const provider = new PingProvider(mockPing as any);
await expect(provider.handle(makeMonitor())).rejects.toThrow("42");
});
});
@@ -0,0 +1,270 @@
import { describe, expect, it, jest } from "@jest/globals";
import { PortProvider } from "../../../../src/service/infrastructure/network/PortProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
import type { Monitor } from "../../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "port",
url: "example.com",
port: 443,
...overrides,
}) as Monitor;
const createMockNet = (behavior: "connect" | "timeout" | "error" = "connect") => {
const socket = {
setTimeout: jest.fn(),
on: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
};
const createConnection = jest.fn((opts: any, onConnect: () => void) => {
if (behavior === "connect") {
// Simulate immediate connection
process.nextTick(onConnect);
} else if (behavior === "timeout") {
process.nextTick(() => {
const timeoutCb = socket.on.mock.calls.find((c: any) => c[0] === "timeout");
// Simulate via setTimeout handler
const setTimeoutCb = socket.setTimeout.mock.calls[0];
if (setTimeoutCb) {
// The timeout event handler is registered via socket.on("timeout", ...)
}
});
} else if (behavior === "error") {
process.nextTick(() => {
const errorCb = socket.on.mock.calls.find((c: any) => c[0] === "error");
if (errorCb) {
errorCb[1](new Error("ECONNREFUSED"));
}
});
}
return socket;
});
return { createConnection, __socket: socket } as any;
};
const createSuccessNet = () => {
const socket = {
setTimeout: jest.fn(),
on: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
};
return {
createConnection: jest.fn((_opts: any, onConnect: () => void) => {
process.nextTick(onConnect);
return socket;
}),
} as any;
};
const createErrorNet = (error: Error = new Error("ECONNREFUSED")) => {
const socket = {
setTimeout: jest.fn(),
on: jest.fn((event: string, cb: Function) => {
if (event === "error") {
process.nextTick(() => cb(error));
}
}),
end: jest.fn(),
destroy: jest.fn(),
};
return {
createConnection: jest.fn(() => socket),
} as any;
};
const createTimeoutNet = () => {
const socket = {
setTimeout: jest.fn(),
on: jest.fn((event: string, cb: Function) => {
if (event === "timeout") {
process.nextTick(() => cb());
}
}),
end: jest.fn(),
destroy: jest.fn(),
};
return {
createConnection: jest.fn(() => socket),
} as any;
};
// ── Contract ─────────────────────────────────────────────────────────────────
testStatusProviderContract("PortProvider", {
create: () => new PortProvider(createSuccessNet()),
supportedType: "port",
unsupportedType: "http",
makeMonitor: () => makeMonitor(),
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("PortProvider", () => {
it("returns success when port connection succeeds", async () => {
const provider = new PortProvider(createSuccessNet());
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
monitorId: "mon-1",
teamId: "team-1",
type: "port",
status: true,
code: 200,
message: "Port check successful",
payload: { success: true },
})
);
expect(result.responseTime).toBeGreaterThanOrEqual(0);
});
it("returns failure when connection errors", async () => {
const provider = new PortProvider(createErrorNet());
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
status: false,
code: NETWORK_ERROR,
message: "ECONNREFUSED",
payload: { success: false },
})
);
});
it("returns failure with generic message for non-Error connection error", async () => {
const net = createErrorNet();
// Override the on handler to throw a non-Error
const socket = {
setTimeout: jest.fn(),
on: jest.fn((event: string, cb: Function) => {
if (event === "error") {
process.nextTick(() => cb("string error"));
}
}),
end: jest.fn(),
destroy: jest.fn(),
};
net.createConnection = jest.fn(() => socket);
const provider = new PortProvider(net);
const result = await provider.handle(makeMonitor());
expect(result.message).toBe("Port check failed");
});
it("returns failure when connection times out", async () => {
const provider = new PortProvider(createTimeoutNet());
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
status: false,
code: NETWORK_ERROR,
message: "Connection timeout",
})
);
});
it("throws AppError when url is missing", async () => {
const provider = new PortProvider(createSuccessNet());
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL and port are required for port monitoring");
});
it("throws AppError when port is missing", async () => {
const provider = new PortProvider(createSuccessNet());
await expect(provider.handle(makeMonitor({ port: undefined }))).rejects.toThrow("URL and port are required for port monitoring");
});
it("returns failure with generic message when timeRequest catches non-Error", async () => {
const socket = {
setTimeout: jest.fn(),
on: jest.fn((event: string, cb: Function) => {
if (event === "error") {
process.nextTick(() => cb({ notAnError: true }));
}
}),
end: jest.fn(),
destroy: jest.fn(),
};
const net = { createConnection: jest.fn(() => socket) } as any;
const provider = new PortProvider(net);
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(false);
expect(result.message).toBe("Port check failed");
});
it("throws AppError wrapping the original Error message", async () => {
const provider = new PortProvider(createSuccessNet());
try {
await provider.handle(makeMonitor({ url: "" }));
expect.unreachable("should have thrown");
} catch (err: any) {
expect(err.service).toBe("PortProvider");
expect(err.method).toBe("handle");
}
});
it("throws AppError when an unexpected error occurs in outer try", async () => {
const provider = new PortProvider(createSuccessNet());
const monitor = makeMonitor();
let calls = 0;
Object.defineProperty(monitor, "url", {
get() {
calls++;
if (calls === 1) throw new Error("getter exploded");
return "example.com";
},
});
await expect(provider.handle(monitor)).rejects.toThrow("getter exploded");
});
it("throws AppError with fallback message when Error has empty message", async () => {
const provider = new PortProvider(createSuccessNet());
const monitor = makeMonitor();
let calls = 0;
Object.defineProperty(monitor, "url", {
get() {
calls++;
if (calls === 1) throw new Error("");
return "example.com";
},
});
await expect(provider.handle(monitor)).rejects.toThrow("Error performing port check");
});
it("throws AppError with stringified message for non-Error thrown values", async () => {
const provider = new PortProvider(createSuccessNet());
const monitor = makeMonitor();
let calls = 0;
Object.defineProperty(monitor, "url", {
get() {
calls++;
if (calls === 1) throw 42;
return "example.com";
},
});
await expect(provider.handle(monitor)).rejects.toThrow("42");
});
});
@@ -0,0 +1,44 @@
import { describe, expect, it } from "@jest/globals";
import { timeRequest, NETWORK_ERROR, PING_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
describe("network utils", () => {
describe("timeRequest", () => {
it("returns response and responseTime on success", async () => {
const result = await timeRequest(async () => "hello");
expect(result.response).toBe("hello");
expect(result.responseTime).toBeGreaterThanOrEqual(0);
expect(result.error).toBeNull();
});
it("returns error and responseTime on failure", async () => {
const err = new Error("boom");
const result = await timeRequest(async () => {
throw err;
});
expect(result.response).toBeNull();
expect(result.responseTime).toBeGreaterThanOrEqual(0);
expect(result.error).toBe(err);
});
it("measures elapsed time", async () => {
const result = await timeRequest(async () => {
await new Promise((r) => setTimeout(r, 50));
return "done";
});
expect(result.responseTime).toBeGreaterThanOrEqual(40);
});
});
describe("constants", () => {
it("NETWORK_ERROR is 5000", () => {
expect(NETWORK_ERROR).toBe(5000);
});
it("PING_ERROR is 5001", () => {
expect(PING_ERROR).toBe(5001);
});
});
});
@@ -0,0 +1,194 @@
import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals";
import { WebSocketProvider } from "../../../../src/service/infrastructure/network/WebSocketProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
import type { Monitor } from "../../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "websocket",
url: "wss://example.com/ws",
ignoreTlsErrors: false,
...overrides,
}) as Monitor;
const createMockWS = (behavior: "open" | "error" = "open") => {
return jest.fn().mockImplementation((_url: string, _opts: any) => {
const handlers: Record<string, Function> = {};
const ws = {
on: jest.fn((event: string, cb: Function) => {
handlers[event] = cb;
if (behavior === "open" && event === "open") {
process.nextTick(() => cb());
}
if (behavior === "error" && event === "error") {
process.nextTick(() => cb(new Error("Connection failed")));
}
}),
close: jest.fn(),
};
return ws;
}) as any;
};
// ── Contract ─────────────────────────────────────────────────────────────────
testStatusProviderContract("WebSocketProvider", {
create: () => new WebSocketProvider(createMockWS("open")),
supportedType: "websocket",
unsupportedType: "http",
makeMonitor: () => makeMonitor(),
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("WebSocketProvider", () => {
it("returns success when connection opens", async () => {
const provider = new WebSocketProvider(createMockWS("open"));
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
monitorId: "mon-1",
teamId: "team-1",
type: "websocket",
status: true,
code: 200,
message: "WebSocket check successful",
payload: { connected: true },
})
);
});
it("returns failure when connection errors", async () => {
const provider = new WebSocketProvider(createMockWS("error"));
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
status: false,
code: NETWORK_ERROR,
message: "Connection failed",
payload: { connected: false },
})
);
});
it("returns failure with generic message for non-Error connection error", async () => {
const WS = jest.fn().mockImplementation(() => ({
on: jest.fn((event: string, cb: Function) => {
if (event === "error") {
process.nextTick(() => cb("string error"));
}
}),
close: jest.fn(),
})) as any;
const provider = new WebSocketProvider(WS);
const result = await provider.handle(makeMonitor());
expect(result.message).toBe("WebSocket check failed");
});
it("passes rejectUnauthorized: false when ignoreTlsErrors is true", async () => {
const WS = jest.fn().mockImplementation((_url: string, opts: any) => {
expect(opts.rejectUnauthorized).toBe(false);
return {
on: jest.fn((event: string, cb: Function) => {
if (event === "open") process.nextTick(() => cb());
}),
close: jest.fn(),
};
}) as any;
const provider = new WebSocketProvider(WS);
await provider.handle(makeMonitor({ ignoreTlsErrors: true }));
expect(WS).toHaveBeenCalled();
});
it("throws AppError when url is missing", async () => {
const provider = new WebSocketProvider(createMockWS("open"));
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for WebSocket monitoring");
});
it("throws AppError when an unexpected error occurs in outer try", async () => {
const provider = new WebSocketProvider(createMockWS("open"));
const monitor = makeMonitor();
let calls = 0;
Object.defineProperty(monitor, "url", {
get() {
calls++;
if (calls === 1) throw new Error("getter failed");
return "wss://example.com";
},
});
await expect(provider.handle(monitor)).rejects.toThrow("getter failed");
});
it("throws AppError with fallback message when Error has empty message", async () => {
const provider = new WebSocketProvider(createMockWS("open"));
const monitor = makeMonitor();
let calls = 0;
Object.defineProperty(monitor, "url", {
get() {
calls++;
if (calls === 1) throw new Error("");
return "wss://example.com";
},
});
await expect(provider.handle(monitor)).rejects.toThrow("Error performing WebSocket check");
});
it("throws AppError with stringified message for non-Error thrown values", async () => {
const provider = new WebSocketProvider(createMockWS("open"));
const monitor = makeMonitor();
let calls = 0;
Object.defineProperty(monitor, "url", {
get() {
calls++;
if (calls === 1) throw 99;
return "wss://example.com";
},
});
await expect(provider.handle(monitor)).rejects.toThrow("99");
});
describe("connection timeout", () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it("returns failure on connection timeout", async () => {
// WS that never fires open or error
const WS = jest.fn().mockImplementation(() => ({
on: jest.fn(),
close: jest.fn(),
})) as any;
const provider = new WebSocketProvider(WS);
const promise = provider.handle(makeMonitor());
await jest.advanceTimersByTimeAsync(10000);
const result = await promise;
expect(result).toEqual(
expect.objectContaining({
status: false,
code: NETWORK_ERROR,
message: "WebSocket connection timeout",
})
);
});
});
});
@@ -0,0 +1,132 @@
import { describe, expect, it, jest } from "@jest/globals";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
const mockGotPost = jest.fn().mockResolvedValue({});
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
const { DiscordProvider } = await import("../../../../src/service/infrastructure/notificationProviders/discord.ts");
const createProvider = () => {
const logger = createMockLogger();
const provider = new DiscordProvider(logger as any);
return { provider, logger };
};
testNotificationProviderContract("DiscordProvider", {
create: () => {
mockGotPost.mockResolvedValue({});
return createProvider().provider;
},
makeNotification: () => makeNotification(),
});
describe("DiscordProvider", () => {
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
describe("sendTestAlert", () => {
it("sends test message and returns true", async () => {
const { provider } = createProvider();
const result = await provider.sendTestAlert(makeNotification());
expect(result).toBe(true);
expect(mockGotPost).toHaveBeenCalledWith(
"https://hooks.example.com/webhook",
expect.objectContaining({ json: expect.objectContaining({ content: expect.any(String) }) })
);
});
it("returns false when address is missing", async () => {
const { provider } = createProvider();
expect(await provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("network"));
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert" }));
});
it("handles undefined thrown values", async () => {
mockGotPost.mockRejectedValue(undefined);
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
});
});
describe("sendMessage", () => {
it("sends embed and returns true", async () => {
const { provider } = createProvider();
const result = await provider.sendMessage(makeNotification() as any, makeMessage());
expect(result).toBe(true);
expect(mockGotPost).toHaveBeenCalledWith(
"https://hooks.example.com/webhook",
expect.objectContaining({ json: expect.objectContaining({ embeds: expect.any(Array) }) })
);
});
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 and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" }));
});
it("handles undefined thrown values in sendMessage", async () => {
mockGotPost.mockRejectedValue(undefined);
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
});
it("includes threshold breaches in embed fields", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
const payload = mockGotPost.mock.calls[0][1].json;
const fields = payload.embeds[0].fields;
expect(fields.some((f: any) => f.name === "Threshold Breaches")).toBe(true);
});
it("includes details in embed fields", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessage());
const fields = mockGotPost.mock.calls[0][1].json.embeds[0].fields;
expect(fields.some((f: any) => f.name === "Details")).toBe(true);
});
it("maps all severity levels to colors", async () => {
const { provider } = createProvider();
for (const severity of ["critical", "warning", "success", "info"] as const) {
mockGotPost.mockClear();
await provider.sendMessage(makeNotification() as any, makeMessage({ severity }));
const color = mockGotPost.mock.calls[0][1].json.embeds[0].color;
expect(color).toBeGreaterThan(0);
}
});
it("uses info color for unknown severity", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessage({ severity: "unknown" as any }));
const color = mockGotPost.mock.calls[0][1].json.embeds[0].color;
expect(color).toBe(0x3b82f6);
});
it("omits threshold fields when not present", async () => {
const { provider } = createProvider();
const msg = makeMessage();
msg.content.thresholds = undefined;
msg.content.details = undefined;
await provider.sendMessage(makeNotification() as any, msg);
const fields = mockGotPost.mock.calls[0][1].json.embeds[0].fields;
expect(fields.some((f: any) => f.name === "Threshold Breaches")).toBe(false);
expect(fields.some((f: any) => f.name === "Details")).toBe(false);
});
});
});
@@ -0,0 +1,149 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { EmailProvider } from "../../../../src/service/infrastructure/notificationProviders/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/infrastructure/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 returns falsy", async () => {
const { provider, emailService, logger } = createProvider();
(emailService.sendEmail as jest.Mock).mockResolvedValue(false);
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" }));
});
it("returns false when sendEmail returns falsy", async () => {
const { provider, emailService, logger } = createProvider();
(emailService.sendEmail as jest.Mock).mockResolvedValue(undefined);
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" }));
});
});
});
@@ -0,0 +1,144 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
const mockGotPut = jest.fn().mockResolvedValue({});
jest.unstable_mockModule("got", () => ({ default: { put: mockGotPut } }));
jest.unstable_mockModule("crypto", () => ({ randomUUID: () => "test-uuid-1234" }));
const { MatrixProvider } = await import("../../../../src/service/infrastructure/notificationProviders/matrix.ts");
const createProvider = () => {
const logger = createMockLogger();
return { provider: new MatrixProvider(logger as any), logger };
};
testNotificationProviderContract("MatrixProvider", {
create: () => {
mockGotPut.mockResolvedValue({});
return createProvider().provider;
},
makeNotification: () => makeNotification(),
});
describe("MatrixProvider", () => {
beforeEach(() => mockGotPut.mockReset().mockResolvedValue({}));
describe("sendTestAlert", () => {
it("sends to Matrix API and returns true", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
expect(mockGotPut).toHaveBeenCalledWith(
expect.stringContaining("matrix.example.com/_matrix/client/v3/rooms/!room:example.com/send/m.room.message/test-uuid-1234"),
expect.objectContaining({
headers: expect.objectContaining({ Authorization: "Bearer token-abc" }),
})
);
});
it("returns false when homeserverUrl is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification({ homeserverUrl: undefined }))).toBe(false);
});
it("returns false when accessToken is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification({ accessToken: undefined }))).toBe(false);
});
it("returns false when roomId is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification({ roomId: undefined }))).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPut.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalled();
});
it("handles non-Error thrown values", async () => {
mockGotPut.mockRejectedValue({ message: "custom obj" });
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
});
});
describe("sendMessage", () => {
it("sends formatted HTML and plain text", async () => {
const { provider } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
const body = mockGotPut.mock.calls[0][1].json;
expect(body.msgtype).toBe("m.text");
expect(body.format).toBe("org.matrix.custom.html");
expect(body.body).toContain("Monitor Down");
expect(body.formatted_body).toContain("<h2");
});
it("returns false when homeserverUrl is missing", async () => {
expect(await createProvider().provider.sendMessage(makeNotification({ homeserverUrl: undefined }) as any, makeMessage())).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPut.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalled();
});
it("includes threshold breaches", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
const body = mockGotPut.mock.calls[0][1].json;
expect(body.body).toContain("CPU");
expect(body.formatted_body).toContain("CPU");
});
it("includes incident link", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithIncident());
const body = mockGotPut.mock.calls[0][1].json;
expect(body.body).toContain("View Incident");
expect(body.formatted_body).toContain("View Incident");
});
it("omits optional sections when not present", async () => {
const { provider } = createProvider();
const msg = makeMessage();
msg.content.thresholds = undefined;
msg.content.details = undefined;
msg.content.incident = undefined;
await provider.sendMessage(makeNotification() as any, msg);
const body = mockGotPut.mock.calls[0][1].json;
expect(body.body).not.toContain("Threshold");
expect(body.body).not.toContain("Additional");
expect(body.body).not.toContain("View Incident");
});
it("escapes HTML special characters", async () => {
const { provider } = createProvider();
const msg = makeMessage();
msg.monitor.name = '<script>alert("xss")</script>';
await provider.sendMessage(makeNotification() as any, msg);
const html = mockGotPut.mock.calls[0][1].json.formatted_body;
expect(html).not.toContain("<script>");
expect(html).toContain("&lt;script&gt;");
});
it("maps all severity levels to colors", async () => {
const { provider } = createProvider();
for (const severity of ["critical", "warning", "success", "info"] as const) {
mockGotPut.mockClear();
await provider.sendMessage(makeNotification() as any, makeMessage({ severity }));
const html = mockGotPut.mock.calls[0][1].json.formatted_body;
expect(html).toContain("color:");
}
});
it("uses default color for unknown severity", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessage({ severity: "unknown" as any }));
const html = mockGotPut.mock.calls[0][1].json.formatted_body;
expect(html).toContain("#808080");
});
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it, jest } from "@jest/globals";
import { buildTestEmail, getTestMessage } from "../../../../src/service/infrastructure/notificationProviders/utils.ts";
import type { IEmailService } from "../../../../src/service/infrastructure/emailService.ts";
describe("notification utils", () => {
describe("getTestMessage", () => {
it("returns a non-empty string", () => {
expect(getTestMessage()).toBe("This is a test notification from Checkmate");
});
});
describe("buildTestEmail", () => {
it("calls emailService.buildEmail with test template and context", async () => {
const emailService = {
buildEmail: jest.fn().mockResolvedValue("<html>test</html>"),
} as unknown as IEmailService;
const result = await buildTestEmail(emailService);
expect(emailService.buildEmail).toHaveBeenCalledWith("testEmailTemplate", { testName: "Monitoring System" });
expect(result).toBe("<html>test</html>");
});
it("returns undefined when buildEmail returns undefined", async () => {
const emailService = {
buildEmail: jest.fn().mockResolvedValue(undefined),
} as unknown as IEmailService;
const result = await buildTestEmail(emailService);
expect(result).toBeUndefined();
});
});
});
@@ -0,0 +1,132 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { makeNotification, makeMessage, makeMessageWithThresholds } from "../../../helpers/notificationMessage.ts";
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
const mockGotPost = jest.fn().mockResolvedValue({});
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
const { PagerDutyProvider } = await import("../../../../src/service/infrastructure/notificationProviders/pagerduty.ts");
const createProvider = () => {
const logger = createMockLogger();
return { provider: new PagerDutyProvider(logger as any), logger };
};
testNotificationProviderContract("PagerDutyProvider", {
create: () => {
mockGotPost.mockResolvedValue({});
return createProvider().provider;
},
makeNotification: () => makeNotification(),
});
describe("PagerDutyProvider", () => {
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
describe("sendTestAlert", () => {
it("sends to PagerDuty API and returns true", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
expect(mockGotPost).toHaveBeenCalledWith(
"https://events.pagerduty.com/v2/enqueue",
expect.objectContaining({
json: expect.objectContaining({ routing_key: "https://hooks.example.com/webhook", event_action: "trigger" }),
})
);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalled();
});
it("handles non-Error thrown values", async () => {
mockGotPost.mockRejectedValue(null);
const { provider } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
});
});
describe("sendMessage", () => {
it("sends payload and returns true", async () => {
const { provider } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
const payload = mockGotPost.mock.calls[0][1].json;
expect(payload.routing_key).toBe("https://hooks.example.com/webhook");
expect(payload.event_action).toBe("trigger");
expect(payload.dedup_key).toBe("checkmate-mon-1");
});
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 and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalled();
});
it("handles non-Error thrown values in sendMessage", async () => {
mockGotPost.mockRejectedValue(null);
const { provider } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
});
it("uses 'resolve' event_action for monitor_up", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "monitor_up" }));
expect(mockGotPost.mock.calls[0][1].json.event_action).toBe("resolve");
});
it("uses 'resolve' event_action for threshold_resolved", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "threshold_resolved" }));
expect(mockGotPost.mock.calls[0][1].json.event_action).toBe("resolve");
});
it("includes threshold info in summary and custom_details", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
const payload = mockGotPost.mock.calls[0][1].json;
expect(payload.payload.summary).toContain("CPU");
expect(payload.payload.custom_details.threshold_breaches).toBeDefined();
});
it("includes details in custom_details", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessage());
expect(mockGotPost.mock.calls[0][1].json.payload.custom_details.details).toBeDefined();
});
it("maps all severity levels", async () => {
const { provider } = createProvider();
for (const severity of ["critical", "warning", "info", "success"] as const) {
mockGotPost.mockClear();
await provider.sendMessage(makeNotification() as any, makeMessage({ severity }));
expect(mockGotPost.mock.calls[0][1].json.payload.severity).toBeTruthy();
}
});
it("uses 'error' for unknown severity", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessage({ severity: "unknown" as any }));
expect(mockGotPost.mock.calls[0][1].json.payload.severity).toBe("error");
});
it("omits threshold and details from custom_details when not present", async () => {
const { provider } = createProvider();
const msg = makeMessage();
msg.content.thresholds = undefined;
msg.content.details = undefined;
await provider.sendMessage(makeNotification() as any, msg);
const cd = mockGotPost.mock.calls[0][1].json.payload.custom_details;
expect(cd.threshold_breaches).toBeUndefined();
expect(cd.details).toBeUndefined();
});
});
});
@@ -0,0 +1,127 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
const mockGotPost = jest.fn().mockResolvedValue({});
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost }, HTTPError: class extends Error {} }));
const { SlackProvider } = await import("../../../../src/service/infrastructure/notificationProviders/slack.ts");
const createProvider = () => {
const logger = createMockLogger();
return { provider: new SlackProvider(logger as any), logger };
};
testNotificationProviderContract("SlackProvider", {
create: () => {
mockGotPost.mockResolvedValue({});
return createProvider().provider;
},
makeNotification: () => makeNotification(),
});
describe("SlackProvider", () => {
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
describe("sendTestAlert", () => {
it("returns true on success", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
});
it("returns false when address is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalled();
});
it("handles undefined thrown values", async () => {
mockGotPost.mockRejectedValue(undefined);
const { provider } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
});
});
describe("sendMessage", () => {
it("sends Block Kit payload and returns true", async () => {
const { provider } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
const payload = mockGotPost.mock.calls[0][1].json;
expect(payload.blocks).toBeDefined();
expect(payload.attachments).toBeDefined();
});
it("returns false when address is missing", async () => {
expect(await createProvider().provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalled();
});
it("handles undefined thrown values in sendMessage", async () => {
mockGotPost.mockRejectedValue(undefined);
const { provider } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
});
it("includes threshold section when thresholds present", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
const blocks = mockGotPost.mock.calls[0][1].json.blocks;
const text = JSON.stringify(blocks);
expect(text).toContain("Threshold Breaches");
});
it("includes incident button when incident present", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithIncident());
const blocks = mockGotPost.mock.calls[0][1].json.blocks;
expect(blocks.some((b: any) => b.type === "actions")).toBe(true);
});
it("includes details section when details are present", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessage());
const blocks = mockGotPost.mock.calls[0][1].json.blocks;
const text = JSON.stringify(blocks);
expect(text).toContain("Additional Information");
});
it("omits threshold, details, and incident sections when not present", async () => {
const { provider } = createProvider();
const msg = makeMessage();
msg.content.thresholds = undefined;
msg.content.details = undefined;
msg.content.incident = undefined;
await provider.sendMessage(makeNotification() as any, msg);
const blocks = mockGotPost.mock.calls[0][1].json.blocks;
const text = JSON.stringify(blocks);
expect(text).not.toContain("Additional Information");
expect(blocks.some((b: any) => b.type === "actions")).toBe(false);
});
it("maps all severity levels to colors", async () => {
const { provider } = createProvider();
for (const severity of ["critical", "warning", "success", "info"] as const) {
mockGotPost.mockClear();
await provider.sendMessage(makeNotification() as any, makeMessage({ severity }));
expect(mockGotPost.mock.calls[0][1].json.attachments[0].color).toBeTruthy();
}
});
it("uses default color for unknown severity", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessage({ severity: "unknown" as any }));
expect(mockGotPost.mock.calls[0][1].json.attachments[0].color).toBe("#808080");
});
});
});
@@ -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",
@@ -248,5 +248,48 @@ describe("TeamsProvider", () => {
})
);
});
it("handles non-Error thrown values in sendMessage", async () => {
mockPost.mockRejectedValue(null);
const result = await provider.sendMessage(createNotification(), createMessage());
expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
});
it("includes details section in Adaptive Card when details are present", async () => {
const notification = createNotification();
const message = createMessage({
content: {
title: "Test",
summary: "Summary",
details: ["URL: https://example.com", "Status: Down"],
timestamp: new Date(),
},
});
await provider.sendMessage(notification, message);
const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
const card = options.json.attachments[0].content;
const text = JSON.stringify(card.body);
expect(text).toContain("Additional Information");
expect(text).toContain("URL: https://example.com");
});
it("uses default color for unknown severity", async () => {
const notification = createNotification();
const message = createMessage({ severity: "unknown" as any });
await provider.sendMessage(notification, message);
const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
const card = options.json.attachments[0].content;
expect(card.body[0].color).toBe("default");
});
});
describe("sendTestAlert error handling", () => {
it("handles non-Error thrown values", async () => {
mockPost.mockRejectedValue(null);
const result = await provider.sendTestAlert(createNotification());
expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
});
});
});
@@ -0,0 +1,115 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
const mockGotPost = jest.fn().mockResolvedValue({});
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
const { TelegramProvider } = await import("../../../../src/service/infrastructure/notificationProviders/telegram.ts");
const createProvider = () => {
const logger = createMockLogger();
return { provider: new TelegramProvider(logger as any), logger };
};
testNotificationProviderContract("TelegramProvider", {
create: () => {
mockGotPost.mockResolvedValue({});
return createProvider().provider;
},
makeNotification: () => makeNotification(),
});
describe("TelegramProvider", () => {
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
describe("sendTestAlert", () => {
it("sends to Telegram API and returns true", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
expect(mockGotPost).toHaveBeenCalledWith(
expect.stringContaining("api.telegram.org/bottoken-abc/sendMessage"),
expect.objectContaining({ json: expect.objectContaining({ chat_id: "https://hooks.example.com/webhook" }) })
);
});
it("returns false when address is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
});
it("returns false when accessToken is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification({ accessToken: undefined }))).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert", details: { error: "fail" } }));
});
it("handles non-Error thrown values in sendTestAlert", async () => {
mockGotPost.mockRejectedValue("string error");
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined, details: { error: "unknown error" } }));
});
});
describe("sendMessage", () => {
it("sends HTML-formatted text and returns true", async () => {
const { provider } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
const json = mockGotPost.mock.calls[0][1].json;
expect(json.parse_mode).toBe("HTML");
expect(json.text).toContain("<b>Monitor Down");
});
it("returns false when address is missing", async () => {
expect(await createProvider().provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
});
it("returns false when accessToken is missing", async () => {
expect(await createProvider().provider.sendMessage(makeNotification({ accessToken: undefined }) as any, makeMessage())).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" }));
});
it("handles non-Error thrown values in sendMessage", async () => {
mockGotPost.mockRejectedValue(42);
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ details: { error: "unknown error" } }));
});
it("includes thresholds in text", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
expect(mockGotPost.mock.calls[0][1].json.text).toContain("CPU");
});
it("includes incident link in text", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithIncident());
expect(mockGotPost.mock.calls[0][1].json.text).toContain("View Incident");
});
it("omits optional sections when not present", async () => {
const { provider } = createProvider();
const msg = makeMessage();
msg.content.thresholds = undefined;
msg.content.details = undefined;
msg.content.incident = undefined;
await provider.sendMessage(makeNotification() as any, msg);
const text = mockGotPost.mock.calls[0][1].json.text;
expect(text).not.toContain("Threshold");
expect(text).not.toContain("Additional");
expect(text).not.toContain("View Incident");
});
});
});
@@ -0,0 +1,102 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
const mockGotPost = jest.fn().mockResolvedValue({});
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
const { WebhookProvider } = await import("../../../../src/service/infrastructure/notificationProviders/webhook.ts");
const createProvider = () => {
const logger = createMockLogger();
return { provider: new WebhookProvider(logger as any), logger };
};
testNotificationProviderContract("WebhookProvider", {
create: () => {
mockGotPost.mockResolvedValue({});
return createProvider().provider;
},
makeNotification: () => makeNotification(),
});
describe("WebhookProvider", () => {
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
describe("sendTestAlert", () => {
it("returns true on success", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
});
it("returns false when address is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalled();
});
it("handles non-Error thrown values", async () => {
mockGotPost.mockRejectedValue(null);
const { provider } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
});
});
describe("sendMessage", () => {
it("sends payload with text and structured data", async () => {
const { provider } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
const payload = mockGotPost.mock.calls[0][1].json;
expect(payload.text).toContain("Monitor Down");
expect(payload.severity).toBe("critical");
expect(payload.monitor.id).toBe("mon-1");
});
it("returns false when address is missing", async () => {
expect(await createProvider().provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalled();
});
it("handles non-Error thrown values in sendMessage", async () => {
mockGotPost.mockRejectedValue(null);
const { provider } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
});
it("includes threshold breaches in text", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
expect(mockGotPost.mock.calls[0][1].json.text).toContain("CPU");
});
it("includes incident link in text", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithIncident());
expect(mockGotPost.mock.calls[0][1].json.text).toContain("View Incident");
});
it("omits threshold and incident sections when not present", async () => {
const { provider } = createProvider();
const msg = makeMessage();
msg.content.thresholds = undefined;
msg.content.details = undefined;
msg.content.incident = undefined;
await provider.sendMessage(makeNotification() as any, msg);
const text = mockGotPost.mock.calls[0][1].json.text;
expect(text).not.toContain("Threshold");
expect(text).not.toContain("Additional Information");
expect(text).not.toContain("View Incident");
});
});
});
@@ -0,0 +1,454 @@
import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals";
import { BufferService } from "../../../src/service/infrastructure/bufferService.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { ICheckService } from "../../../src/service/business/checkService.ts";
import type { IGeoChecksService } from "../../../src/service/business/geoChecksService.ts";
import type { ISettingsService } from "../../../src/service/system/settingsService.ts";
import type { Check, GeoCheck } from "../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createMockCheckService = () =>
({
createChecks: jest.fn().mockResolvedValue([]),
}) as unknown as jest.Mocked<ICheckService>;
const createMockGeoChecksService = () =>
({
createGeoChecks: jest.fn().mockResolvedValue([]),
}) as unknown as jest.Mocked<IGeoChecksService>;
const createMockSettingsService = (nodeEnv: string = "development") =>
({
getSettings: jest.fn().mockReturnValue({ nodeEnv }),
}) as unknown as jest.Mocked<ISettingsService>;
const makeCheck = (overrides?: Partial<Check>): Check =>
({
id: "check-1",
metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" },
status: true,
statusCode: 200,
responseTime: 100,
message: "OK",
...overrides,
}) as Check;
const makeGeoCheck = (overrides?: Partial<GeoCheck>): GeoCheck =>
({
id: "geo-1",
monitorId: "mon-1",
...overrides,
}) as GeoCheck;
const createService = (nodeEnv: string = "development") => {
const logger = createMockLogger();
const checkService = createMockCheckService();
const geoChecksService = createMockGeoChecksService();
const settingsService = createMockSettingsService(nodeEnv);
const service = new BufferService(logger as any, checkService, geoChecksService, settingsService);
return { service, logger, checkService, geoChecksService };
};
// ── Tests ────────────────────────────────────────────────────────────────────
describe("BufferService", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
// ── Static / instance properties ─────────────────────────────────────
describe("serviceName", () => {
it("returns BufferService from static property", () => {
expect(BufferService.SERVICE_NAME).toBe("BufferService");
});
it("returns BufferService from instance getter", () => {
const { service } = createService();
expect(service.serviceName).toBe("BufferService");
});
});
// ── constructor ──────────────────────────────────────────────────────
describe("constructor", () => {
it("logs initialization with development timeout", () => {
const { logger } = createService("development");
expect(logger.info).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("0.01s"),
service: "BufferService",
method: "constructor",
})
);
});
it("uses 60s timeout in non-development environment", () => {
const { logger } = createService("production");
expect(logger.info).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("60s"),
})
);
});
it("schedules a flush on construction", () => {
createService();
expect(jest.getTimerCount()).toBeGreaterThanOrEqual(1);
});
});
// ── addToBuffer ──────────────────────────────────────────────────────
describe("addToBuffer", () => {
it("adds a check to the buffer", async () => {
const { service, checkService } = createService();
const check = makeCheck();
service.addToBuffer(check);
await service.flushBuffer();
expect(checkService.createChecks).toHaveBeenCalledWith([check]);
});
it("adds multiple checks to the buffer", async () => {
const { service, checkService } = createService();
const check1 = makeCheck({ id: "c1" });
const check2 = makeCheck({ id: "c2" });
service.addToBuffer(check1);
service.addToBuffer(check2);
await service.flushBuffer();
expect(checkService.createChecks).toHaveBeenCalledWith([check1, check2]);
});
it("logs error if push throws", () => {
const { service, logger } = createService();
// Force buffer.push to throw by making buffer non-extensible
Object.defineProperty(service, "buffer", { value: Object.freeze([]) });
service.addToBuffer(makeCheck());
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
service: "BufferService",
method: "addToBuffer",
})
);
});
it("logs 'Unknown error' for non-Error thrown values", () => {
const { service, logger } = createService();
Object.defineProperty(service, "buffer", {
value: {
push: () => {
throw "string error";
},
},
});
service.addToBuffer(makeCheck());
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Unknown error",
stack: undefined,
})
);
});
});
// ── addGeoCheckToBuffer ──────────────────────────────────────────────
describe("addGeoCheckToBuffer", () => {
it("adds a geo check to the buffer", async () => {
const { service, geoChecksService } = createService();
const geoCheck = makeGeoCheck();
service.addGeoCheckToBuffer(geoCheck);
await service.flushGeoBuffer();
expect(geoChecksService.createGeoChecks).toHaveBeenCalledWith([geoCheck]);
});
it("logs error if push throws", () => {
const { service, logger } = createService();
Object.defineProperty(service, "geoBuffer", { value: Object.freeze([]) });
service.addGeoCheckToBuffer(makeGeoCheck());
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
service: "BufferService",
method: "addGeoCheckToBuffer",
})
);
});
it("logs 'Unknown error' for non-Error thrown values", () => {
const { service, logger } = createService();
Object.defineProperty(service, "geoBuffer", {
value: {
push: () => {
throw 42;
},
},
});
service.addGeoCheckToBuffer(makeGeoCheck());
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Unknown error",
stack: undefined,
})
);
});
});
// ── scheduleNextFlush ────────────────────────────────────────────────
describe("scheduleNextFlush", () => {
it("clears existing timer and sets a new one", () => {
const { service } = createService();
// Constructor already scheduled one flush
const initialTimerCount = jest.getTimerCount();
service.scheduleNextFlush();
// Should still have timers (cleared old, set new)
expect(jest.getTimerCount()).toBe(initialTimerCount);
});
it("flushes buffer and geo buffer when timer fires", async () => {
const { service, checkService, geoChecksService } = createService();
service.addToBuffer(makeCheck());
service.addGeoCheckToBuffer(makeGeoCheck());
await jest.advanceTimersByTimeAsync(10);
expect(checkService.createChecks).toHaveBeenCalled();
expect(geoChecksService.createGeoChecks).toHaveBeenCalled();
});
it("reschedules after flush completes", async () => {
const { service, checkService } = createService();
(checkService.createChecks as jest.Mock).mockResolvedValue([]);
service.addToBuffer(makeCheck());
await jest.advanceTimersByTimeAsync(10);
// Add another check and advance again to confirm rescheduling
service.addToBuffer(makeCheck({ id: "c2" }));
await jest.advanceTimersByTimeAsync(10);
expect(checkService.createChecks).toHaveBeenCalledTimes(2);
});
it("reschedules even when flush throws", async () => {
const { service, checkService, logger } = createService();
(checkService.createChecks as jest.Mock).mockRejectedValueOnce(new Error("DB down"));
service.addToBuffer(makeCheck());
await jest.advanceTimersByTimeAsync(10);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "DB down",
method: "flushBuffer",
})
);
// Should still reschedule — add another check and flush
(checkService.createChecks as jest.Mock).mockResolvedValue([]);
service.addToBuffer(makeCheck({ id: "c2" }));
await jest.advanceTimersByTimeAsync(10);
expect(checkService.createChecks).toHaveBeenCalledTimes(2);
});
it("logs error and reschedules when flush throws past its own catch", async () => {
const { service, logger } = createService();
// Override flushBuffer to throw past its own try/catch
service.flushBuffer = jest.fn<() => Promise<void>>().mockRejectedValueOnce(new Error("unexpected"));
await jest.advanceTimersByTimeAsync(10);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "unexpected",
method: "scheduleNextFlush",
})
);
});
it("logs 'Unknown error' when flush throws non-Error past its own catch", async () => {
const { service, logger } = createService();
service.flushBuffer = jest.fn<() => Promise<void>>().mockRejectedValueOnce("string error");
await jest.advanceTimersByTimeAsync(10);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Unknown error",
method: "scheduleNextFlush",
stack: undefined,
})
);
});
});
// ── flushBuffer ──────────────────────────────────────────────────────
describe("flushBuffer", () => {
it("does nothing when buffer is empty", async () => {
const { service, checkService } = createService();
await service.flushBuffer();
expect(checkService.createChecks).not.toHaveBeenCalled();
});
it("flushes checks to checksService and clears buffer", async () => {
const { service, checkService } = createService();
const check = makeCheck();
service.addToBuffer(check);
await service.flushBuffer();
expect(checkService.createChecks).toHaveBeenCalledWith([check]);
// Buffer should be empty now
await service.flushBuffer();
expect(checkService.createChecks).toHaveBeenCalledTimes(1);
});
it("logs debug message before flushing", async () => {
const { service, logger } = createService();
service.addToBuffer(makeCheck());
await service.flushBuffer();
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({
message: "Flushing 1 checks to database",
service: "BufferService",
method: "flushBuffer",
})
);
});
it("clears buffer even on error to prevent infinite retries", async () => {
const { service, checkService, logger } = createService();
(checkService.createChecks as jest.Mock).mockRejectedValue(new Error("DB write failed"));
service.addToBuffer(makeCheck());
await service.flushBuffer();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "DB write failed",
method: "flushBuffer",
})
);
// Buffer should be cleared
await service.flushBuffer();
expect(checkService.createChecks).toHaveBeenCalledTimes(1);
});
it("logs 'Unknown error' for non-Error thrown values", async () => {
const { service, checkService, logger } = createService();
(checkService.createChecks as jest.Mock).mockRejectedValue(null);
service.addToBuffer(makeCheck());
await service.flushBuffer();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Unknown error",
method: "flushBuffer",
stack: undefined,
})
);
});
});
// ── flushGeoBuffer ───────────────────────────────────────────────────
describe("flushGeoBuffer", () => {
it("does nothing when geo buffer is empty", async () => {
const { service, geoChecksService } = createService();
await service.flushGeoBuffer();
expect(geoChecksService.createGeoChecks).not.toHaveBeenCalled();
});
it("flushes geo checks and clears buffer", async () => {
const { service, geoChecksService } = createService();
const geoCheck = makeGeoCheck();
service.addGeoCheckToBuffer(geoCheck);
await service.flushGeoBuffer();
expect(geoChecksService.createGeoChecks).toHaveBeenCalledWith([geoCheck]);
// Buffer should be empty now
await service.flushGeoBuffer();
expect(geoChecksService.createGeoChecks).toHaveBeenCalledTimes(1);
});
it("logs debug message before flushing", async () => {
const { service, logger } = createService();
service.addGeoCheckToBuffer(makeGeoCheck());
await service.flushGeoBuffer();
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({
message: "Flushing 1 geo checks to database",
service: "BufferService",
method: "flushGeoBuffer",
})
);
});
it("clears geo buffer even on error", async () => {
const { service, geoChecksService, logger } = createService();
(geoChecksService.createGeoChecks as jest.Mock).mockRejectedValue(new Error("DB error"));
service.addGeoCheckToBuffer(makeGeoCheck());
await service.flushGeoBuffer();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "DB error",
method: "flushGeoBuffer",
})
);
// Buffer should be cleared
await service.flushGeoBuffer();
expect(geoChecksService.createGeoChecks).toHaveBeenCalledTimes(1);
});
it("logs 'Unknown error' for non-Error thrown values", async () => {
const { service, geoChecksService, logger } = createService();
(geoChecksService.createGeoChecks as jest.Mock).mockRejectedValue(undefined);
service.addGeoCheckToBuffer(makeGeoCheck());
await service.flushGeoBuffer();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Unknown error",
method: "flushGeoBuffer",
stack: undefined,
})
);
});
});
});
@@ -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);
});
});
});
@@ -0,0 +1,151 @@
import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals";
import { DiagnosticService } from "../../../src/service/business/diagnosticService.ts";
// ── Tests ────────────────────────────────────────────────────────────────────
describe("DiagnosticService", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
describe("serviceName", () => {
it("returns diagnosticService from static property", () => {
expect(DiagnosticService.SERVICE_NAME).toBe("diagnosticService");
});
it("returns diagnosticService from instance getter", () => {
const service = new DiagnosticService();
expect(service.serviceName).toBe("diagnosticService");
});
});
// ── constructor ─────────────────────────────────────────────────────────
describe("constructor", () => {
it("sets up PerformanceObserver that clears marks on callback", () => {
let capturedCallback: ((list: PerformanceObserverEntryList) => void) | null = null;
const originalObserver = globalThis.PerformanceObserver;
const mockObserve = jest.fn();
globalThis.PerformanceObserver = jest.fn().mockImplementation((cb: any) => {
capturedCallback = cb;
return { observe: mockObserve };
}) as any;
const clearMarksSpy = jest.spyOn(performance, "clearMarks");
const service = new DiagnosticService();
expect(mockObserve).toHaveBeenCalledWith({ entryTypes: ["measure"] });
expect(capturedCallback).not.toBeNull();
// Invoke the callback manually
const mockEntryList = { getEntries: jest.fn().mockReturnValue([]) } as unknown as PerformanceObserverEntryList;
capturedCallback!(mockEntryList);
expect(mockEntryList.getEntries).toHaveBeenCalled();
expect(clearMarksSpy).toHaveBeenCalled();
clearMarksSpy.mockRestore();
globalThis.PerformanceObserver = originalObserver;
expect(service).toBeDefined();
});
});
// ── getCPUUsage ─────────────────────────────────────────────────────────
describe("getCPUUsage", () => {
it("returns cpu usage metrics after timing period", async () => {
const service = new DiagnosticService();
const promise = service.getCPUUsage();
jest.advanceTimersByTime(1000);
const result = await promise;
expect(result).toHaveProperty("userUsageMs");
expect(result).toHaveProperty("systemUsageMs");
expect(result).toHaveProperty("usagePercentage");
expect(typeof result.userUsageMs).toBe("number");
expect(typeof result.systemUsageMs).toBe("number");
expect(typeof result.usagePercentage).toBe("number");
});
});
// ── getSystemStats ──────────────────────────────────────────────────────
describe("getSystemStats", () => {
it("returns all diagnostic sections", async () => {
const service = new DiagnosticService();
const promise = service.getSystemStats();
// getCPUUsage has 1s timeout, then event loop delay has 0ms timeout
jest.advanceTimersByTime(1000);
// Need to flush the microtask queue and advance again for the nested setTimeout(0)
await jest.advanceTimersByTimeAsync(10);
const result = await promise;
expect(result).toHaveProperty("osStats");
expect(result.osStats).toHaveProperty("freeMemoryBytes");
expect(result.osStats).toHaveProperty("totalMemoryBytes");
expect(result).toHaveProperty("memoryUsage");
expect(result.memoryUsage).toHaveProperty("rss");
expect(result.memoryUsage).toHaveProperty("heapTotal");
expect(result.memoryUsage).toHaveProperty("heapUsed");
expect(result.memoryUsage).toHaveProperty("external");
expect(result.memoryUsage).toHaveProperty("arrayBuffers");
expect(result).toHaveProperty("cpuUsage");
expect(result.cpuUsage).toHaveProperty("userUsageMs");
expect(result).toHaveProperty("v8HeapStats");
expect(result.v8HeapStats).toHaveProperty("totalHeapSizeBytes");
expect(result.v8HeapStats).toHaveProperty("usedHeapSizeBytes");
expect(result.v8HeapStats).toHaveProperty("heapSizeLimitBytes");
expect(typeof result.eventLoopDelayMs).toBe("number");
expect(result.eventLoopDelayMs).toBeGreaterThanOrEqual(0);
expect(typeof result.uptimeMs).toBe("number");
expect(result.uptimeMs).toBeGreaterThan(0);
});
it("reads eventLoopDelay from real performance entries", async () => {
jest.useRealTimers();
const service = new DiagnosticService();
// Call getSystemStats with real timers — slow but guarantees real perf entries
const result = await service.getSystemStats();
expect(typeof result.eventLoopDelayMs).toBe("number");
expect(result.eventLoopDelayMs).toBeGreaterThanOrEqual(0);
jest.useFakeTimers();
});
it.each([
{ label: "empty entries array", entries: [] },
{ label: "undefined entry in array", entries: [undefined] },
])("defaults eventLoopDelayMs to 0 when $label", async ({ entries }) => {
const service = new DiagnosticService();
const originalGetEntries = performance.getEntriesByName;
const originalMeasure = performance.measure;
const originalMark = performance.mark;
performance.mark = jest.fn() as any;
performance.measure = jest.fn() as any;
performance.getEntriesByName = jest.fn().mockReturnValue(entries) as any;
const promise = service.getSystemStats();
jest.advanceTimersByTime(1000);
await jest.advanceTimersByTimeAsync(10);
const result = await promise;
expect(result.eventLoopDelayMs).toBe(0);
expect(performance.getEntriesByName).toHaveBeenCalledWith("eventLoopDelay");
performance.getEntriesByName = originalGetEntries;
performance.measure = originalMeasure;
performance.mark = originalMark;
});
});
});
@@ -0,0 +1,400 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { EmailService } from "../../../src/service/infrastructure/emailService.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { ISettingsService } from "../../../src/service/system/settingsService.ts";
import type { EmailTransportConfig } from "../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeTransportConfig = (overrides?: Partial<EmailTransportConfig>): EmailTransportConfig =>
({
systemEmailHost: "smtp.example.com",
systemEmailPort: 587,
systemEmailSecure: false,
systemEmailPool: false,
systemEmailUser: "user@example.com",
systemEmailAddress: "noreply@example.com",
systemEmailPassword: "password123",
systemEmailConnectionHost: "mail.example.com",
systemEmailTLSServername: "smtp.example.com",
systemEmailIgnoreTLS: false,
systemEmailRequireTLS: true,
systemEmailRejectUnauthorized: true,
...overrides,
}) as EmailTransportConfig;
const createMockSettingsService = () =>
({
getDBSettings: jest.fn().mockResolvedValue(makeTransportConfig()),
}) as unknown as jest.Mocked<ISettingsService>;
const createMockFs = (templateContent: string = "<mjml><mj-body></mj-body></mjml>") =>
({
readFileSync: jest.fn().mockReturnValue(templateContent),
}) as any;
const createMockPath = () =>
({
join: jest.fn((...args: string[]) => args.join("/")),
dirname: jest.fn().mockReturnValue("/mock/dir"),
}) as any;
const createMockCompile = (result: string = "<mjml>compiled</mjml>") => {
const compiledFn = jest.fn().mockReturnValue(result);
const compile = jest.fn().mockReturnValue(compiledFn) as any;
compile.__compiledFn = compiledFn;
return compile;
};
const createMockMjml = (html: string = "<html>rendered</html>") => jest.fn().mockReturnValue({ html }) as any;
const createMockTransporter = () => ({
verify: jest.fn().mockResolvedValue(true),
sendMail: jest.fn().mockResolvedValue({ messageId: "msg-123" }),
});
const createMockNodemailer = (transporter?: ReturnType<typeof createMockTransporter>) => {
const t = transporter ?? createMockTransporter();
return {
createTransport: jest.fn().mockReturnValue(t),
__transporter: t,
} as any;
};
const createService = (overrides?: { settingsService?: any; fs?: any; path?: any; compile?: any; mjml?: any; nodemailer?: any }) => {
const logger = createMockLogger();
const settingsService = overrides?.settingsService ?? createMockSettingsService();
const mockFs = overrides?.fs ?? createMockFs();
const mockPath = overrides?.path ?? createMockPath();
const compile = overrides?.compile ?? createMockCompile();
const mjml = overrides?.mjml ?? createMockMjml();
const mockNodemailer = overrides?.nodemailer ?? createMockNodemailer();
const service = new EmailService(settingsService, mockFs, mockPath, compile, mjml, mockNodemailer, logger as any);
return { service, logger, settingsService, mockFs, mockPath, compile, mjml, mockNodemailer };
};
// ── Tests ────────────────────────────────────────────────────────────────────
describe("EmailService", () => {
describe("SERVICE_NAME", () => {
it("returns EmailService from static property", () => {
expect(EmailService.SERVICE_NAME).toBe("EmailService");
});
it("returns EmailService from instance getter", () => {
const { service } = createService();
expect(service.serviceName).toBe("EmailService");
});
});
// ── init / loadTemplate ──────────────────────────────────────────────
describe("init", () => {
it("loads all expected templates on construction", () => {
const { mockFs } = createService();
// 6 templates loaded
expect(mockFs.readFileSync).toHaveBeenCalledTimes(6);
});
it("compiles each loaded template", () => {
const { compile } = createService();
expect(compile).toHaveBeenCalledTimes(6);
});
it("logs error when a template file is not found", () => {
const failFs = {
readFileSync: jest.fn().mockImplementation(() => {
throw new Error("ENOENT: no such file");
}),
};
const { logger } = createService({ fs: failFs });
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "ENOENT: no such file",
service: "EmailService",
method: "loadTemplate",
})
);
});
it("logs 'Unknown error' for non-Error thrown values in loadTemplate", () => {
const failFs = {
readFileSync: jest.fn().mockImplementation(() => {
throw "string error";
}),
};
const { logger } = createService({ fs: failFs });
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Unknown error",
stack: undefined,
})
);
});
it("can be called again to reinitialize templates", () => {
const { service, mockFs } = createService();
const initialCalls = mockFs.readFileSync.mock.calls.length;
service.init();
expect(mockFs.readFileSync).toHaveBeenCalledTimes(initialCalls + 6);
});
});
// ── buildEmail ───────────────────────────────────────────────────────
describe("buildEmail", () => {
it("compiles template with context and returns rendered HTML", async () => {
const { service } = createService();
const result = await service.buildEmail("welcomeEmailTemplate", { name: "Test" });
expect(result).toBe("<html>rendered</html>");
});
it("passes context to the compiled template function", async () => {
const compile = createMockCompile();
const { service } = createService({ compile });
const context = { name: "Alex", url: "https://example.com" };
await service.buildEmail("welcomeEmailTemplate", context);
expect(compile.__compiledFn).toHaveBeenCalledWith(context);
});
it("returns undefined and logs error when template is not found", async () => {
const { service, logger } = createService();
const result = await service.buildEmail("nonExistentTemplate", {});
expect(result).toBeUndefined();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Template nonExistentTemplate not found",
service: "EmailService",
method: "buildEmail",
})
);
});
it("returns undefined and logs error when template function returns falsy", async () => {
const compile = jest.fn().mockReturnValue(jest.fn().mockReturnValue(undefined)) as any;
const { service, logger } = createService({ compile });
const result = await service.buildEmail("welcomeEmailTemplate", {});
expect(result).toBeUndefined();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Template welcomeEmailTemplate not found",
})
);
});
it("returns undefined and logs error when mjml throws", async () => {
const mjml = jest.fn().mockImplementation(() => {
throw new Error("MJML parse error");
}) as any;
const { service, logger } = createService({ mjml });
const result = await service.buildEmail("welcomeEmailTemplate", {});
expect(result).toBeUndefined();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "MJML parse error",
method: "buildEmail",
})
);
});
it("logs 'Unknown error' for non-Error thrown values in buildEmail", async () => {
const mjml = jest.fn().mockImplementation(() => {
throw null;
}) as any;
const { service, logger } = createService({ mjml });
const result = await service.buildEmail("welcomeEmailTemplate", {});
expect(result).toBeUndefined();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Unknown error",
method: "buildEmail",
stack: undefined,
})
);
});
});
// ── sendEmail ────────────────────────────────────────────────────────
describe("sendEmail", () => {
it("sends email with provided transport config and returns messageId", async () => {
const transporter = createMockTransporter();
const nodemailer = createMockNodemailer(transporter);
const { service } = createService({ nodemailer });
const config = makeTransportConfig();
const result = await service.sendEmail("to@example.com", "Subject", "<p>body</p>", config);
expect(result).toBe("msg-123");
expect(transporter.verify).toHaveBeenCalled();
expect(transporter.sendMail).toHaveBeenCalledWith({
to: "to@example.com",
from: "noreply@example.com",
subject: "Subject",
html: "<p>body</p>",
});
});
it("falls back to DB settings when no transport config is provided", async () => {
const settingsService = createMockSettingsService();
const transporter = createMockTransporter();
const nodemailer = createMockNodemailer(transporter);
const { service } = createService({ settingsService, nodemailer });
await service.sendEmail("to@example.com", "Subject", "<p>body</p>");
expect(settingsService.getDBSettings).toHaveBeenCalled();
});
it("creates transporter with correct email config", async () => {
const transporter = createMockTransporter();
const nodemailer = createMockNodemailer(transporter);
const { service } = createService({ nodemailer });
const config = makeTransportConfig({
systemEmailHost: "smtp.test.com",
systemEmailPort: 465,
systemEmailSecure: true,
systemEmailPool: true,
systemEmailUser: "testuser",
systemEmailPassword: "testpass",
systemEmailConnectionHost: "conn.test.com",
systemEmailRejectUnauthorized: false,
systemEmailIgnoreTLS: true,
systemEmailRequireTLS: false,
systemEmailTLSServername: "tls.test.com",
});
await service.sendEmail("to@example.com", "Subject", "<p>body</p>", config);
expect(nodemailer.createTransport).toHaveBeenCalledWith({
host: "smtp.test.com",
port: 465,
secure: true,
auth: { user: "testuser", pass: "testpass" },
name: "conn.test.com",
connectionTimeout: 5000,
pool: true,
tls: {
rejectUnauthorized: false,
ignoreTLS: true,
requireTLS: false,
servername: "tls.test.com",
},
});
});
it("uses systemEmailAddress as auth user when systemEmailUser is falsy", async () => {
const transporter = createMockTransporter();
const nodemailer = createMockNodemailer(transporter);
const { service } = createService({ nodemailer });
const config = makeTransportConfig({ systemEmailUser: "", systemEmailAddress: "fallback@example.com" });
await service.sendEmail("to@example.com", "Subject", "<p>body</p>", config);
expect(nodemailer.createTransport).toHaveBeenCalledWith(
expect.objectContaining({
auth: expect.objectContaining({ user: "fallback@example.com" }),
})
);
});
it("uses 'localhost' as name when systemEmailConnectionHost is falsy", async () => {
const transporter = createMockTransporter();
const nodemailer = createMockNodemailer(transporter);
const { service } = createService({ nodemailer });
const config = makeTransportConfig({ systemEmailConnectionHost: "" });
await service.sendEmail("to@example.com", "Subject", "<p>body</p>", config);
expect(nodemailer.createTransport).toHaveBeenCalledWith(expect.objectContaining({ name: "localhost" }));
});
it("returns false when transporter verification fails", async () => {
const transporter = createMockTransporter();
(transporter.verify as jest.Mock).mockRejectedValue(new Error("SMTP auth failed"));
const nodemailer = createMockNodemailer(transporter);
const { service, logger } = createService({ nodemailer });
const result = await service.sendEmail("to@example.com", "Subject", "<p>body</p>", makeTransportConfig());
expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: "Email transporter verification failed",
service: "EmailService",
method: "verifyTransporter",
})
);
});
it("logs stack as undefined when verification fails with non-Error", async () => {
const transporter = createMockTransporter();
(transporter.verify as jest.Mock).mockRejectedValue("connection refused");
const nodemailer = createMockNodemailer(transporter);
const { service, logger } = createService({ nodemailer });
const result = await service.sendEmail("to@example.com", "Subject", "<p>body</p>", makeTransportConfig());
expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
});
it("returns undefined and logs error when sendMail throws", async () => {
const transporter = createMockTransporter();
(transporter.sendMail as jest.Mock).mockRejectedValue(new Error("Recipient rejected"));
const nodemailer = createMockNodemailer(transporter);
const { service, logger } = createService({ nodemailer });
const result = await service.sendEmail("to@example.com", "Subject", "<p>body</p>", makeTransportConfig());
expect(result).toBeUndefined();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Recipient rejected",
service: "EmailService",
method: "sendEmail",
})
);
});
it("logs 'Unknown error' for non-Error thrown values in sendMail", async () => {
const transporter = createMockTransporter();
(transporter.sendMail as jest.Mock).mockRejectedValue(42);
const nodemailer = createMockNodemailer(transporter);
const { service, logger } = createService({ nodemailer });
const result = await service.sendEmail("to@example.com", "Subject", "<p>body</p>", makeTransportConfig());
expect(result).toBeUndefined();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Unknown error",
method: "sendEmail",
stack: undefined,
})
);
});
});
});
@@ -0,0 +1,269 @@
import { describe, expect, it, jest } from "@jest/globals";
import { GeoChecksService } from "../../../src/service/business/geoChecksService.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { Monitor } from "../../../src/types/index.ts";
import type { GeoCheckResult } from "../../../src/types/geoCheck.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "http",
url: "https://example.com",
geoCheckEnabled: true,
geoCheckLocations: ["NA", "EU"],
...overrides,
}) as Monitor;
const makeGeoResult = (overrides?: Partial<GeoCheckResult>): GeoCheckResult => ({
continent: "NA",
country: "US",
city: "New York",
status: "up",
responseTime: 50,
...overrides,
});
const createService = (overrides?: Record<string, unknown>) => {
const logger = createMockLogger();
const geoChecksRepository = {
createGeoChecks: jest.fn().mockResolvedValue([]),
findByMonitorId: jest.fn().mockResolvedValue({ geoChecksCount: 0, geoChecks: [] }),
};
const globalPingService = {
createMeasurement: jest.fn().mockResolvedValue("measurement-123"),
pollForResults: jest.fn().mockResolvedValue([makeGeoResult()]),
};
const monitorsRepository = {
findById: jest.fn().mockResolvedValue(makeMonitor()),
};
const defaults = { logger, geoChecksRepository, globalPingService, monitorsRepository, ...overrides };
const service = new GeoChecksService(defaults as any);
return { service, ...defaults };
};
// ── Tests ────────────────────────────────────────────────────────────────────
describe("GeoChecksService", () => {
describe("serviceName", () => {
it("returns GeoChecksService from static property", () => {
expect(GeoChecksService.SERVICE_NAME).toBe("GeoChecksService");
});
it("returns GeoChecksService from instance getter", () => {
const { service } = createService();
expect(service.serviceName).toBe("GeoChecksService");
});
});
// ── buildGeoCheck ───────────────────────────────────────────────────────
describe("buildGeoCheck", () => {
it("returns a geo check document on success", async () => {
const { service } = createService();
const result = await service.buildGeoCheck(makeMonitor());
expect(result).not.toBeNull();
expect(result!.metadata).toEqual({ monitorId: "mon-1", teamId: "team-1", type: "http" });
expect(result!.results).toHaveLength(1);
expect(result!.results[0].continent).toBe("NA");
expect(result!.id).toBeDefined();
expect(result!.expiry).toBeDefined();
});
it("returns null and warns when monitor has no URL", async () => {
const { service, logger } = createService();
const result = await service.buildGeoCheck(makeMonitor({ url: "" }));
expect(result).toBeNull();
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Monitor missing URL for geo check" }));
});
it("returns null and warns when geoCheckLocations is empty", async () => {
const { service, logger } = createService();
const result = await service.buildGeoCheck(makeMonitor({ geoCheckLocations: [] }));
expect(result).toBeNull();
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Monitor missing geo check locations" }));
});
it("returns null and warns when geoCheckLocations is undefined", async () => {
const { service, logger } = createService();
const result = await service.buildGeoCheck(makeMonitor({ geoCheckLocations: undefined as any }));
expect(result).toBeNull();
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Monitor missing geo check locations" }));
});
it("returns null when createMeasurement returns null (API unavailable)", async () => {
const { service, logger } = createService({
globalPingService: {
createMeasurement: jest.fn().mockResolvedValue(null),
pollForResults: jest.fn(),
},
});
const result = await service.buildGeoCheck(makeMonitor());
expect(result).toBeNull();
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Skipping geo check due to API unavailability" }));
});
it("returns null when pollForResults returns empty array", async () => {
const { service, logger } = createService({
globalPingService: {
createMeasurement: jest.fn().mockResolvedValue("measurement-123"),
pollForResults: jest.fn().mockResolvedValue([]),
},
});
const result = await service.buildGeoCheck(makeMonitor());
expect(result).toBeNull();
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "No successful geo check results" }));
});
it("returns null and logs error when an exception is thrown", async () => {
const { service, logger } = createService({
globalPingService: {
createMeasurement: jest.fn().mockRejectedValue(new Error("API error")),
pollForResults: jest.fn(),
},
});
const result = await service.buildGeoCheck(makeMonitor());
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Error executing geo check",
details: expect.objectContaining({ error: "API error" }),
})
);
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { service, logger } = createService({
globalPingService: {
createMeasurement: jest.fn().mockRejectedValue("string error"),
pollForResults: jest.fn(),
},
});
const result = await service.buildGeoCheck(makeMonitor());
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ details: expect.objectContaining({ error: "Unknown error" }) }));
});
});
// ── createGeoChecks ─────────────────────────────────────────────────────
describe("createGeoChecks", () => {
it("delegates to repository", async () => {
const { service, geoChecksRepository } = createService();
const geoChecks = [{ id: "gc-1" }] as any;
await service.createGeoChecks(geoChecks);
expect(geoChecksRepository.createGeoChecks).toHaveBeenCalledWith(geoChecks);
});
});
// ── getGeoChecksByMonitor ────────────────────────────────────────────────
describe("getGeoChecksByMonitor", () => {
it("returns geo checks from repository", async () => {
const expected = { geoChecksCount: 1, geoChecks: [{ id: "gc-1" }] };
const { service, geoChecksRepository } = createService();
(geoChecksRepository.findByMonitorId as jest.Mock).mockResolvedValue(expected);
const result = await service.getGeoChecksByMonitor({
monitorId: "mon-1",
teamId: "team-1",
sortOrder: "desc",
dateRange: "day",
continent: "NA" as any,
});
expect(result).toEqual(expected);
expect(geoChecksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "desc", "day", 0, 5, ["NA"]);
});
it("passes array of continents through", async () => {
const { service, geoChecksRepository } = createService();
await service.getGeoChecksByMonitor({
monitorId: "mon-1",
teamId: "team-1",
sortOrder: "desc",
dateRange: "day",
continent: ["NA", "EU"] as any,
});
expect(geoChecksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "desc", "day", 0, 5, ["NA", "EU"]);
});
it("passes undefined continents when not provided", async () => {
const { service, geoChecksRepository } = createService();
await service.getGeoChecksByMonitor({
monitorId: "mon-1",
teamId: "team-1",
sortOrder: "desc",
dateRange: "day",
continent: undefined as any,
});
expect(geoChecksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "desc", "day", 0, 5, undefined);
});
it("uses provided page and rowsPerPage", async () => {
const { service, geoChecksRepository } = createService();
await service.getGeoChecksByMonitor({
monitorId: "mon-1",
teamId: "team-1",
sortOrder: "asc",
dateRange: "week",
page: 3,
rowsPerPage: 20,
continent: "EU" as any,
});
expect(geoChecksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "asc", "week", 3, 20, ["EU"]);
});
it("throws when monitorId is missing", async () => {
const { service } = createService();
await expect(
service.getGeoChecksByMonitor({ monitorId: "", teamId: "team-1", sortOrder: "desc", dateRange: "day", continent: "NA" as any })
).rejects.toThrow("No monitor ID in request");
});
it("throws when teamId is missing", async () => {
const { service } = createService();
await expect(
service.getGeoChecksByMonitor({ monitorId: "mon-1", teamId: "", sortOrder: "desc", dateRange: "day", continent: "NA" as any })
).rejects.toThrow("No team ID in request");
});
it("throws 404 when monitor is not found", async () => {
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(null);
await expect(
service.getGeoChecksByMonitor({ monitorId: "mon-1", teamId: "team-1", sortOrder: "desc", dateRange: "day", continent: "NA" as any })
).rejects.toThrow("Monitor with ID mon-1 not found.");
});
});
});
@@ -0,0 +1,499 @@
import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { GeoContinent } from "../../../src/types/geoCheck.ts";
// ── got mock ─────────────────────────────────────────────────────────────────
const mockGotPost = jest.fn();
const mockGotGet = jest.fn();
jest.unstable_mockModule("got", () => ({
default: {
post: mockGotPost,
get: mockGotGet,
},
}));
// Dynamic import AFTER mock registration
const { GlobalPingService } = await import("../../../src/service/infrastructure/globalPingService.ts");
// ── Helpers ──────────────────────────────────────────────────────────────────
const createService = () => {
const logger = createMockLogger();
const service = new GlobalPingService(logger as any);
return { service, logger };
};
const makeProbeResult = (overrides?: Record<string, any>) => ({
probe: {
continent: "NA" as GeoContinent,
region: "Northern America",
country: "US",
state: "CA",
city: "San Francisco",
longitude: -122.4,
latitude: 37.77,
},
result: {
status: "finished",
statusCode: 200,
statusCodeName: "OK",
timings: {
total: 150,
dns: 10,
tcp: 20,
tls: 30,
firstByte: 50,
download: 40,
},
},
...overrides,
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("GlobalPingService", () => {
beforeEach(() => {
jest.useFakeTimers();
mockGotPost.mockReset();
mockGotGet.mockReset();
});
afterEach(() => {
jest.useRealTimers();
});
// ── Static / instance properties ─────────────────────────────────────
describe("serviceName", () => {
it("returns GlobalPingService from static property", () => {
expect(GlobalPingService.SERVICE_NAME).toBe("GlobalPingService");
});
it("returns GlobalPingService from instance getter", () => {
const { service } = createService();
expect(service.serviceName).toBe("GlobalPingService");
});
});
// ── createMeasurement ────────────────────────────────────────────────
describe("createMeasurement", () => {
it("creates a measurement and returns the id", async () => {
const { service, logger } = createService();
mockGotPost.mockResolvedValue({ body: { id: "meas-123" } });
const result = await service.createMeasurement("http", "https://example.com", ["NA", "EU"] as GeoContinent[]);
expect(result).toBe("meas-123");
expect(mockGotPost).toHaveBeenCalledWith(
"https://api.globalping.io/v1/measurements",
expect.objectContaining({
json: {
type: "http",
target: "example.com",
locations: [{ continent: "NA" }, { continent: "EU" }],
limit: 2,
},
responseType: "json",
timeout: { request: 10000 },
})
);
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("meas-123"),
method: "createMeasurement",
})
);
});
it("strips http:// protocol from target URL", async () => {
const { service } = createService();
mockGotPost.mockResolvedValue({ body: { id: "meas-1" } });
await service.createMeasurement("http", "http://example.com", ["NA"] as GeoContinent[]);
expect(mockGotPost).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
json: expect.objectContaining({ target: "example.com" }),
})
);
});
it("strips https:// protocol from target URL", async () => {
const { service } = createService();
mockGotPost.mockResolvedValue({ body: { id: "meas-1" } });
await service.createMeasurement("http", "https://example.com/path", ["EU"] as GeoContinent[]);
expect(mockGotPost).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
json: expect.objectContaining({ target: "example.com/path" }),
})
);
});
it("returns null and logs error for unsupported monitor type", async () => {
const { service, logger } = createService();
const result = await service.createMeasurement("hardware" as any, "https://example.com", ["NA"] as GeoContinent[]);
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "GlobalPing API unavailable, skipping geo check",
method: "createMeasurement",
})
);
});
it("returns null and logs error when API call fails", async () => {
const { service, logger } = createService();
mockGotPost.mockRejectedValue(new Error("Network error"));
const result = await service.createMeasurement("http", "https://example.com", ["NA"] as GeoContinent[]);
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "GlobalPing API unavailable, skipping geo check",
method: "createMeasurement",
})
);
});
it("logs stack as undefined for non-Error thrown values", async () => {
const { service, logger } = createService();
mockGotPost.mockRejectedValue("string error");
await service.createMeasurement("http", "https://example.com", ["NA"] as GeoContinent[]);
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
});
it("supports ping monitor type", async () => {
const { service } = createService();
mockGotPost.mockResolvedValue({ body: { id: "meas-ping" } });
const result = await service.createMeasurement("ping", "example.com", ["NA"] as GeoContinent[]);
expect(result).toBe("meas-ping");
});
});
// ── pollForResults ───────────────────────────────────────────────────
describe("pollForResults", () => {
it("returns transformed results when measurement is finished", async () => {
const { service, logger } = createService();
mockGotGet.mockResolvedValue({
body: {
status: "finished",
results: [makeProbeResult()],
},
});
const results = await service.pollForResults("meas-123");
expect(results).toHaveLength(1);
expect(results[0]).toEqual(
expect.objectContaining({
location: expect.objectContaining({
continent: "NA",
city: "San Francisco",
}),
status: true,
statusCode: 200,
timings: expect.objectContaining({
total: 150,
dns: 10,
}),
})
);
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("meas-123"),
method: "pollForResults",
})
);
});
it("defaults results to empty array when finished with no results", async () => {
const { service } = createService();
mockGotGet.mockResolvedValue({
body: { status: "finished", results: undefined },
});
const results = await service.pollForResults("meas-123");
expect(results).toEqual([]);
});
it("returns empty array when measurement has failed", async () => {
const { service, logger } = createService();
mockGotGet.mockResolvedValue({
body: { status: "failed" },
});
const results = await service.pollForResults("meas-123");
expect(results).toEqual([]);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("failed"),
method: "pollForResults",
})
);
});
it("polls again when status is in-progress, then returns on finished", async () => {
const { service } = createService();
mockGotGet.mockResolvedValueOnce({ body: { status: "in-progress" } }).mockResolvedValueOnce({
body: {
status: "finished",
results: [makeProbeResult()],
},
});
const promise = service.pollForResults("meas-123");
// Advance past the sleep(2000)
await jest.advanceTimersByTimeAsync(2000);
const results = await promise;
expect(mockGotGet).toHaveBeenCalledTimes(2);
expect(results).toHaveLength(1);
});
it("returns empty array and logs error when API call throws", async () => {
const { service, logger } = createService();
mockGotGet.mockRejectedValue(new Error("Connection refused"));
const results = await service.pollForResults("meas-123");
expect(results).toEqual([]);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Error polling GlobalPing API",
method: "pollForResults",
})
);
});
it("logs stack as undefined for non-Error thrown values in poll", async () => {
const { service, logger } = createService();
mockGotGet.mockRejectedValue(42);
await service.pollForResults("meas-123");
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
});
it("returns empty array and logs warning on timeout", async () => {
const { service, logger } = createService();
// Always return in-progress so we hit the timeout
mockGotGet.mockImplementation(async () => ({ body: { status: "in-progress" } }));
const promise = service.pollForResults("meas-123", 100);
// Advance time well past the timeout
await jest.advanceTimersByTimeAsync(35000);
const results = await promise;
expect(results).toEqual([]);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("timeout"),
method: "pollForResults",
details: expect.objectContaining({ measurementId: "meas-123", timeoutMs: 100 }),
})
);
});
});
// ── transformResults (via pollForResults) ────────────────────────────
describe("transformResults", () => {
it("skips probes with non-finished status", async () => {
const { service } = createService();
mockGotGet.mockResolvedValue({
body: {
status: "finished",
results: [makeProbeResult({ result: { status: "failed" } }), makeProbeResult({ result: { status: "timeout" } })],
},
});
const results = await service.pollForResults("meas-123");
expect(results).toEqual([]);
});
it("transforms HTTP results with statusCode and timings", async () => {
const { service } = createService();
mockGotGet.mockResolvedValue({
body: {
status: "finished",
results: [makeProbeResult()],
},
});
const results = await service.pollForResults("meas-123");
expect(results[0].status).toBe(true);
expect(results[0].statusCode).toBe(200);
expect(results[0].timings.total).toBe(150);
});
it("marks HTTP result as failed for non-2xx status codes", async () => {
const { service } = createService();
mockGotGet.mockResolvedValue({
body: {
status: "finished",
results: [
makeProbeResult({
result: {
status: "finished",
statusCode: 500,
timings: { total: 100, dns: 5, tcp: 10, tls: 15, firstByte: 30, download: 40 },
},
}),
],
},
});
const results = await service.pollForResults("meas-123");
expect(results[0].status).toBe(false);
expect(results[0].statusCode).toBe(500);
});
it("transforms ping results with stats (no loss)", async () => {
const { service } = createService();
mockGotGet.mockResolvedValue({
body: {
status: "finished",
results: [
makeProbeResult({
result: {
status: "finished",
stats: { min: 10, max: 30, avg: 20, total: 3, loss: 0, rcv: 3, drop: 0 },
},
}),
],
},
});
const results = await service.pollForResults("meas-123");
expect(results[0].status).toBe(true);
expect(results[0].statusCode).toBe(200);
expect(results[0].timings.total).toBe(20);
expect(results[0].timings.dns).toBe(0);
});
it("transforms ping results with loss as failed", async () => {
const { service } = createService();
mockGotGet.mockResolvedValue({
body: {
status: "finished",
results: [
makeProbeResult({
result: {
status: "finished",
stats: { min: 10, max: 30, avg: 20, total: 3, loss: 2, rcv: 1, drop: 2 },
},
}),
],
},
});
const results = await service.pollForResults("meas-123");
expect(results[0].status).toBe(false);
expect(results[0].statusCode).toBe(5000);
});
it("uses empty string for null state in location", async () => {
const { service } = createService();
mockGotGet.mockResolvedValue({
body: {
status: "finished",
results: [
makeProbeResult({
probe: {
continent: "EU",
region: "Western Europe",
country: "DE",
state: null,
city: "Berlin",
longitude: 13.4,
latitude: 52.5,
},
}),
],
},
});
const results = await service.pollForResults("meas-123");
expect(results[0].location.state).toBe("");
});
it("skips probes with no statusCode/timings and no stats", async () => {
const { service } = createService();
mockGotGet.mockResolvedValue({
body: {
status: "finished",
results: [
makeProbeResult({
result: { status: "finished" },
}),
],
},
});
const results = await service.pollForResults("meas-123");
expect(results).toEqual([]);
});
it("transforms multiple probes from different locations", async () => {
const { service } = createService();
mockGotGet.mockResolvedValue({
body: {
status: "finished",
results: [
makeProbeResult(),
makeProbeResult({
probe: {
continent: "EU",
region: "Western Europe",
country: "GB",
state: null,
city: "London",
longitude: -0.12,
latitude: 51.5,
},
result: {
status: "finished",
statusCode: 200,
timings: { total: 80, dns: 5, tcp: 10, tls: 15, firstByte: 30, download: 20 },
},
}),
],
},
});
const results = await service.pollForResults("meas-123");
expect(results).toHaveLength(2);
expect(results[0].location.continent).toBe("NA");
expect(results[1].location.continent).toBe("EU");
});
});
});
@@ -0,0 +1,472 @@
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";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createIncidentsRepo = () =>
({
findActiveByMonitorId: jest.fn(),
findActiveByIncidentId: jest.fn(),
create: jest.fn(),
updateById: jest.fn(),
findById: jest.fn(),
findByTeamId: jest.fn(),
countByTeamId: jest.fn(),
findSummaryByTeamId: jest.fn(),
}) as unknown as jest.Mocked<IIncidentsRepository>;
const createMonitorsRepo = () =>
({
findById: jest.fn(),
}) as unknown as jest.Mocked<IMonitorsRepository>;
const createUsersRepo = () =>
({
findById: jest.fn(),
}) as unknown as jest.Mocked<IUsersRepository>;
const createMessageBuilder = () =>
({
extractThresholdBreaches: jest.fn(),
}) as unknown as jest.Mocked<INotificationMessageBuilder>;
const createService = (overrides?: {
logger?: ReturnType<typeof createMockLogger>;
incidentsRepository?: ReturnType<typeof createIncidentsRepo>;
monitorsRepository?: ReturnType<typeof createMonitorsRepo>;
usersRepository?: ReturnType<typeof createUsersRepo>;
notificationMessageBuilder?: ReturnType<typeof createMessageBuilder>;
}) => {
const logger = overrides?.logger ?? createMockLogger();
const incidentsRepository = overrides?.incidentsRepository ?? createIncidentsRepo();
const monitorsRepository = overrides?.monitorsRepository ?? createMonitorsRepo();
const usersRepository = overrides?.usersRepository ?? createUsersRepo();
const notificationMessageBuilder = overrides?.notificationMessageBuilder ?? createMessageBuilder();
const service = new IncidentService(logger as any, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder);
return { service, logger, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder };
};
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
name: "Test Monitor",
type: "http",
url: "https://example.com",
status: "down",
...overrides,
}) as Monitor;
const makeIncident = (overrides?: Partial<Incident>): Incident =>
({
id: "inc-1",
monitorId: "mon-1",
teamId: "team-1",
startTime: "1700000000000",
status: true,
statusCode: 500,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
}) as Incident;
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: false,
incidentReason: null,
notificationReason: null,
...overrides,
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("IncidentService", () => {
describe("serviceName", () => {
it("returns incidentService", () => {
const { service } = createService();
expect(service.serviceName).toBe("incidentService");
});
});
// ── handleIncident ───────────────────────────────────────────────────────
describe("handleIncident", () => {
it("returns null when neither create nor resolve is requested", async () => {
const { service } = createService();
const result = await service.handleIncident(makeMonitor(), 200, makeDecision());
expect(result).toBeNull();
});
it("returns existing active incident when shouldCreateIncident and one already exists", async () => {
const existing = makeIncident();
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(existing);
const result = await service.handleIncident(makeMonitor(), 500, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }));
expect(result).toBe(existing);
expect(incidentsRepository.create).not.toHaveBeenCalled();
});
it("creates a new incident when shouldCreateIncident and no active incident exists", async () => {
const created = makeIncident();
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
const result = await service.handleIncident(makeMonitor(), 500, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }));
expect(result).toBe(created);
expect(incidentsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
monitorId: "mon-1",
teamId: "team-1",
status: true,
statusCode: 500,
})
);
});
it("uses status code 9999 and builds message for threshold_breach incidents", async () => {
const created = makeIncident();
const { service, incidentsRepository, notificationMessageBuilder } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
(notificationMessageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([
{ metric: "cpu", formattedValue: "95%", threshold: 80, unit: "%" },
]);
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
await service.handleIncident(makeMonitor(), 200, decision, { monitorId: "mon-1" } as any);
expect(incidentsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: 9999,
message: "CPU: 95% (threshold: 80%)",
})
);
});
it("uses fallback message when monitorStatusResponse is undefined for threshold_breach", async () => {
const created = makeIncident();
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
await service.handleIncident(makeMonitor(), 200, decision, undefined);
expect(incidentsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ message: "Threshold breach detected" }));
});
it("uses fallback message when extractThresholdBreaches returns empty array", async () => {
const created = makeIncident();
const { service, incidentsRepository, notificationMessageBuilder } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
(notificationMessageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([]);
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
await service.handleIncident(makeMonitor(), 200, decision, { monitorId: "mon-1" } as any);
expect(incidentsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ message: "Threshold breach detected" }));
});
it("joins multiple threshold breaches with commas", async () => {
const created = makeIncident();
const { service, incidentsRepository, notificationMessageBuilder } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
(notificationMessageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([
{ metric: "cpu", formattedValue: "95%", threshold: 80, unit: "%" },
{ metric: "memory", formattedValue: "90%", threshold: 80, unit: "%" },
]);
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
await service.handleIncident(makeMonitor(), 200, decision, { monitorId: "mon-1" } as any);
expect(incidentsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ message: "CPU: 95% (threshold: 80%), MEMORY: 90% (threshold: 80%)" })
);
});
it("resolves active incident when shouldResolveIncident", async () => {
const active = makeIncident();
const resolved = makeIncident({ status: false, endTime: "123", resolutionType: "automatic" });
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(active);
(incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved);
const result = await service.handleIncident(makeMonitor(), 200, makeDecision({ shouldResolveIncident: true }));
expect(result).toBe(resolved);
expect(incidentsRepository.updateById).toHaveBeenCalledWith(
"inc-1",
"team-1",
expect.objectContaining({ status: false, resolutionType: "automatic" })
);
});
it("returns null when shouldResolveIncident but no active incident exists", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
const result = await service.handleIncident(makeMonitor(), 200, makeDecision({ shouldResolveIncident: true }));
expect(result).toBeNull();
});
});
// ── resolveIncident ──────────────────────────────────────────────────────
describe("resolveIncident", () => {
it("resolves incident with manual resolution", async () => {
const active = makeIncident();
const resolved = makeIncident({ status: false });
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(active);
(incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved);
const result = await service.resolveIncident("inc-1", "user-1", "team-1", "Fixed it", "user@test.com");
expect(result).toBe(resolved);
expect(incidentsRepository.updateById).toHaveBeenCalledWith(
"inc-1",
"team-1",
expect.objectContaining({
resolutionType: "manual",
status: false,
resolvedBy: "user-1",
resolvedByEmail: "user@test.com",
comment: "Fixed it",
})
);
});
it("sets resolvedByEmail and comment to null when not provided", async () => {
const active = makeIncident();
const resolved = makeIncident({ status: false });
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(active);
(incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved);
await service.resolveIncident("inc-1", "user-1", "team-1");
expect(incidentsRepository.updateById).toHaveBeenCalledWith(
"inc-1",
"team-1",
expect.objectContaining({
resolvedByEmail: null,
comment: null,
})
);
});
it("throws when incidentId is missing", async () => {
const { service } = createService();
await expect(service.resolveIncident("", "user-1", "team-1")).rejects.toThrow("No incident ID in request");
});
it("throws when userId is missing", async () => {
const { service } = createService();
await expect(service.resolveIncident("inc-1", "", "team-1")).rejects.toThrow("No user ID in request");
});
it("throws when teamId is missing", async () => {
const { service } = createService();
await expect(service.resolveIncident("inc-1", "user-1", "")).rejects.toThrow("No team ID in request");
});
it("throws when incident is not found", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(null);
await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toThrow("Incident not found");
});
it("throws when incident is already resolved", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(makeIncident({ status: false }));
await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toThrow("Incident is already resolved");
});
it("logs error and rethrows on unexpected failure", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockRejectedValue(new Error("db error"));
await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toThrow("db error");
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({ service: "incidentService", method: "resolveIncident", message: "db error" })
);
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockRejectedValue("string error");
await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toBe("string error");
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
// ── getIncidentsByTeam ───────────────────────────────────────────────────
describe("getIncidentsByTeam", () => {
it("returns incidents and count", async () => {
const incidents = [makeIncident()];
const { service, incidentsRepository } = createService();
(incidentsRepository.findByTeamId as jest.Mock).mockResolvedValue(incidents);
(incidentsRepository.countByTeamId as jest.Mock).mockResolvedValue(1);
const result = await service.getIncidentsByTeam("team-1", "desc", "day", 0, 20, undefined, undefined, undefined);
expect(result).toEqual({ incidents, count: 1 });
});
it("defaults page to 0 and rowsPerPage to 20 when nullish", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findByTeamId as jest.Mock).mockResolvedValue([]);
(incidentsRepository.countByTeamId as jest.Mock).mockResolvedValue(0);
await service.getIncidentsByTeam("team-1", "desc", "day", null as any, null as any, undefined, undefined, undefined);
expect(incidentsRepository.findByTeamId).toHaveBeenCalledWith("team-1", expect.anything(), 0, 20, "desc", undefined, undefined, undefined);
});
it("passes filter parameters through to repository", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findByTeamId as jest.Mock).mockResolvedValue([]);
(incidentsRepository.countByTeamId as jest.Mock).mockResolvedValue(0);
await service.getIncidentsByTeam("team-1", "asc", "week", 2, 10, true, "mon-1", "manual");
expect(incidentsRepository.findByTeamId).toHaveBeenCalledWith("team-1", expect.any(Date), 2, 10, "asc", true, "mon-1", "manual");
expect(incidentsRepository.countByTeamId).toHaveBeenCalledWith("team-1", expect.any(Date), true, "mon-1", "manual");
});
it("throws when teamId is missing", async () => {
const { service } = createService();
await expect(service.getIncidentsByTeam("", "desc", "day", 0, 20, undefined, undefined, undefined)).rejects.toThrow("No team ID in request");
});
it("logs error and rethrows on unexpected failure", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findByTeamId as jest.Mock).mockRejectedValue(new Error("db error"));
await expect(service.getIncidentsByTeam("team-1", "desc", "day", 0, 20, undefined, undefined, undefined)).rejects.toThrow("db error");
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ service: "incidentService", method: "getIncidentsByTeam" }));
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findByTeamId as jest.Mock).mockRejectedValue(42);
await expect(service.getIncidentsByTeam("team-1", "desc", "day", 0, 20, undefined, undefined, undefined)).rejects.toBe(42);
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
// ── getIncidentSummary ───────────────────────────────────────────────────
describe("getIncidentSummary", () => {
it("returns summary from repository", async () => {
const summary = { recentIncidents: [], totalIncidents: 0 };
const { service, incidentsRepository } = createService();
(incidentsRepository.findSummaryByTeamId as jest.Mock).mockResolvedValue(summary);
const result = await service.getIncidentSummary("team-1", 5);
expect(result).toBe(summary);
expect(incidentsRepository.findSummaryByTeamId).toHaveBeenCalledWith("team-1", 5);
});
it("defaults limit to 10 when not provided", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findSummaryByTeamId as jest.Mock).mockResolvedValue({});
await service.getIncidentSummary("team-1");
expect(incidentsRepository.findSummaryByTeamId).toHaveBeenCalledWith("team-1", 10);
});
it("throws when teamId is missing", async () => {
const { service } = createService();
await expect(service.getIncidentSummary("")).rejects.toThrow("No team ID in request");
});
it("logs error and rethrows on unexpected failure", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findSummaryByTeamId as jest.Mock).mockRejectedValue(new Error("db error"));
await expect(service.getIncidentSummary("team-1")).rejects.toThrow("db error");
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ service: "incidentService", method: "getIncidentSummary" }));
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findSummaryByTeamId as jest.Mock).mockRejectedValue("boom");
await expect(service.getIncidentSummary("team-1")).rejects.toBe("boom");
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
// ── getIncidentById ──────────────────────────────────────────────────────
describe("getIncidentById", () => {
it("returns incident, monitor, and user when resolvedBy exists", async () => {
const incident = makeIncident({ resolvedBy: "user-1" });
const monitor = makeMonitor();
const user = { id: "user-1", email: "user@test.com" };
const { service, incidentsRepository, monitorsRepository, usersRepository } = createService();
(incidentsRepository.findById as jest.Mock).mockResolvedValue(incident);
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(usersRepository.findById as jest.Mock).mockResolvedValue(user);
const result = await service.getIncidentById("inc-1", "team-1");
expect(result).toEqual({ incident, monitor, user });
expect(usersRepository.findById).toHaveBeenCalledWith("user-1");
});
it("returns user as null when resolvedBy is not set", async () => {
const incident = makeIncident({ resolvedBy: undefined });
const monitor = makeMonitor();
const { service, incidentsRepository, monitorsRepository, usersRepository } = createService();
(incidentsRepository.findById as jest.Mock).mockResolvedValue(incident);
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
const result = await service.getIncidentById("inc-1", "team-1");
expect(result.user).toBeNull();
expect(usersRepository.findById).not.toHaveBeenCalled();
});
it("logs error and rethrows on unexpected failure", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findById as jest.Mock).mockRejectedValue(new Error("not found"));
await expect(service.getIncidentById("inc-1", "team-1")).rejects.toThrow("not found");
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({ service: "incidentService", method: "getIncidentById", details: { incidentId: "inc-1" } })
);
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findById as jest.Mock).mockRejectedValue(null);
await expect(service.getIncidentById("inc-1", "team-1")).rejects.toBeNull();
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
});
@@ -0,0 +1,166 @@
import { describe, expect, it, jest } from "@jest/globals";
import { InviteService } from "../../../src/service/business/inviteService.ts";
import type { Invite, UserRole } from "../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeInvite = (overrides?: Partial<Invite>): Invite => ({
id: "inv-1",
email: "invited@example.com",
teamId: "team-1",
role: ["user"] as UserRole[],
token: "invite-token-123",
expiry: "2026-12-31T00:00:00Z",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
});
const createService = (overrides?: Record<string, unknown>) => {
const invitesRepository = {
create: jest.fn().mockResolvedValue(makeInvite()),
findByToken: jest.fn().mockResolvedValue(makeInvite()),
};
const settingsService = {
getSettings: jest.fn().mockReturnValue({ clientHost: "http://localhost:5173" }),
};
const emailService = {
buildEmail: jest.fn().mockResolvedValue("<html>Invite</html>"),
sendEmail: jest.fn().mockResolvedValue("msg-id-123"),
};
const defaults = { invitesRepository, settingsService, emailService, ...overrides };
const service = new InviteService(defaults as any);
return { service, ...defaults };
};
// ── Tests ────────────────────────────────────────────────────────────────────
describe("InviteService", () => {
describe("serviceName", () => {
it("returns inviteService from static property", () => {
expect(InviteService.SERVICE_NAME).toBe("inviteService");
});
it("returns inviteService from instance getter", () => {
const { service } = createService();
expect(service.serviceName).toBe("inviteService");
});
});
// ── getInviteToken ──────────────────────────────────────────────────────
describe("getInviteToken", () => {
it("creates an invite with teamId assigned", async () => {
const { service, invitesRepository } = createService();
const invite: Partial<Invite> = { email: "new@example.com", role: ["user"] };
const result = await service.getInviteToken({ invite, teamId: "team-1", userRoles: ["superadmin"] });
expect(invitesRepository.create).toHaveBeenCalledWith(expect.objectContaining({ email: "new@example.com", teamId: "team-1" }));
expect(result).toEqual(makeInvite());
});
it("allows creation when role is undefined (defaults to empty)", async () => {
const { service } = createService();
const result = await service.getInviteToken({ invite: { email: "new@example.com" }, teamId: "team-1", userRoles: ["user"] });
expect(result).toEqual(makeInvite());
});
it("throws 403 when actor cannot manage the target role", async () => {
const { service } = createService();
await expect(service.getInviteToken({ invite: { role: ["superadmin"] }, teamId: "team-1", userRoles: ["admin"] })).rejects.toThrow(
"You do not have permission to create this invite"
);
});
});
// ── sendInviteEmail ─────────────────────────────────────────────────────
describe("sendInviteEmail", () => {
it("creates invite, builds email, and sends it", async () => {
const { service, invitesRepository, emailService, settingsService } = createService();
await service.sendInviteEmail({
invite: { email: "new@example.com", role: ["user"] },
firstName: "Test",
userRoles: ["superadmin"],
});
expect(invitesRepository.create).toHaveBeenCalledWith(expect.objectContaining({ email: "new@example.com" }));
expect(settingsService.getSettings).toHaveBeenCalled();
expect(emailService.buildEmail).toHaveBeenCalledWith("employeeActivationTemplate", {
name: "Test",
link: "http://localhost:5173/register/invite-token-123",
});
expect(emailService.sendEmail).toHaveBeenCalledWith("new@example.com", "Welcome to Uptime Monitor", "<html>Invite</html>");
});
it("throws 400 when invite email is missing", async () => {
const { service } = createService();
await expect(service.sendInviteEmail({ invite: {}, firstName: "Test", userRoles: ["superadmin"] })).rejects.toThrow(
"Invite email is required to send an invite"
);
});
it("throws 403 when actor cannot manage the target role", async () => {
const { service } = createService();
await expect(
service.sendInviteEmail({ invite: { email: "new@example.com", role: ["superadmin"] }, firstName: "Test", userRoles: ["admin"] })
).rejects.toThrow("You do not have permission to create this invite");
});
it("allows sending when role is undefined (defaults to empty)", async () => {
const { service, emailService } = createService();
await service.sendInviteEmail({ invite: { email: "new@example.com" }, firstName: "Test", userRoles: ["user"] });
expect(emailService.sendEmail).toHaveBeenCalled();
});
it("throws 500 when buildEmail returns falsy", async () => {
const { service } = createService({
emailService: {
buildEmail: jest.fn().mockResolvedValue(null),
sendEmail: jest.fn(),
},
});
await expect(
service.sendInviteEmail({ invite: { email: "new@example.com", role: ["user"] }, firstName: "Test", userRoles: ["superadmin"] })
).rejects.toThrow("Failed to build invite e-mail");
});
it("throws 500 when sendEmail returns falsy", async () => {
const { service } = createService({
emailService: {
buildEmail: jest.fn().mockResolvedValue("<html>ok</html>"),
sendEmail: jest.fn().mockResolvedValue(false),
},
});
await expect(
service.sendInviteEmail({ invite: { email: "new@example.com", role: ["user"] }, firstName: "Test", userRoles: ["superadmin"] })
).rejects.toThrow("Failed to send invite e-mail");
});
});
// ── verifyInviteToken ───────────────────────────────────────────────────
describe("verifyInviteToken", () => {
it("delegates to repository", async () => {
const { service, invitesRepository } = createService();
const result = await service.verifyInviteToken({ inviteToken: "invite-token-123" });
expect(invitesRepository.findByToken).toHaveBeenCalledWith("invite-token-123");
expect(result).toEqual(makeInvite());
});
});
});
@@ -0,0 +1,252 @@
import { describe, expect, it, jest } from "@jest/globals";
import { MaintenanceWindowService } from "../../../src/service/business/maintenanceWindowService.ts";
import type { IMaintenanceWindowsRepository } from "../../../src/repositories/maintenance-windows/IMaintenanceWindowsRepository.ts";
import type { IMonitorsRepository } from "../../../src/repositories/monitors/IMonitorsRepository.ts";
import type { MaintenanceWindow } from "../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createMaintenanceWindowsRepo = () =>
({
create: jest.fn().mockResolvedValue(makeWindow()),
findById: jest.fn().mockResolvedValue(makeWindow()),
findByMonitorId: jest.fn().mockResolvedValue([makeWindow()]),
findByTeamId: jest.fn().mockResolvedValue([makeWindow()]),
updateById: jest.fn().mockResolvedValue(makeWindow()),
deleteById: jest.fn().mockResolvedValue(makeWindow()),
countByTeamId: jest.fn().mockResolvedValue(1),
}) as unknown as jest.Mocked<IMaintenanceWindowsRepository>;
const createMonitorsRepo = () =>
({
findByIds: jest.fn().mockResolvedValue([
{ id: "mon-1", teamId: "team-1" },
{ id: "mon-2", teamId: "team-1" },
]),
}) as unknown as jest.Mocked<IMonitorsRepository>;
const createService = (overrides?: {
monitorsRepository?: ReturnType<typeof createMonitorsRepo>;
maintenanceWindowsRepository?: ReturnType<typeof createMaintenanceWindowsRepo>;
}) => {
const monitorsRepository = overrides?.monitorsRepository ?? createMonitorsRepo();
const maintenanceWindowsRepository = overrides?.maintenanceWindowsRepository ?? createMaintenanceWindowsRepo();
const service = new MaintenanceWindowService({ monitorsRepository, maintenanceWindowsRepository });
return { service, monitorsRepository, maintenanceWindowsRepository };
};
const makeWindow = (overrides?: Partial<MaintenanceWindow>): MaintenanceWindow => ({
id: "mw-1",
monitorId: "mon-1",
teamId: "team-1",
active: true,
name: "Scheduled Maintenance",
duration: 60,
durationUnit: "minutes",
repeat: 0,
start: "2026-04-10T02:00:00Z",
end: "2026-04-10T03:00:00Z",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
});
const defaultCreateParams = {
teamId: "team-1",
monitorIDs: ["mon-1", "mon-2"],
name: "Scheduled Maintenance",
active: true,
duration: 60,
durationUnit: "minutes" as const,
repeat: 0,
start: "2026-04-10T02:00:00Z",
end: "2026-04-10T03:00:00Z",
};
// ── Tests ────────────────────────────────────────────────────────────────────
describe("MaintenanceWindowService", () => {
describe("serviceName", () => {
it("returns maintenanceWindowService from static property", () => {
expect(MaintenanceWindowService.SERVICE_NAME).toBe("maintenanceWindowService");
});
it("returns maintenanceWindowService from instance getter", () => {
const { service } = createService();
expect(service.serviceName).toBe("maintenanceWindowService");
});
});
// ── createMaintenanceWindow ──────────────────────────────────────────────
describe("createMaintenanceWindow", () => {
it("creates a maintenance window for each monitor", async () => {
const { service, maintenanceWindowsRepository } = createService();
await service.createMaintenanceWindow(defaultCreateParams);
expect(maintenanceWindowsRepository.create).toHaveBeenCalledTimes(2);
expect(maintenanceWindowsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
teamId: "team-1",
monitorId: "mon-1",
name: "Scheduled Maintenance",
active: true,
duration: 60,
durationUnit: "minutes",
repeat: 0,
start: "2026-04-10T02:00:00Z",
end: "2026-04-10T03:00:00Z",
})
);
expect(maintenanceWindowsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ monitorId: "mon-2" }));
});
it("verifies monitor ownership via findByIds", async () => {
const { service, monitorsRepository } = createService();
await service.createMaintenanceWindow(defaultCreateParams);
expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"]);
});
it("throws 403 when a monitor belongs to a different team", async () => {
const monitorsRepository = createMonitorsRepo();
(monitorsRepository.findByIds as jest.Mock).mockResolvedValue([
{ id: "mon-1", teamId: "team-1" },
{ id: "mon-2", teamId: "team-other" },
]);
const { service, maintenanceWindowsRepository } = createService({ monitorsRepository });
await expect(service.createMaintenanceWindow(defaultCreateParams)).rejects.toThrow(
"Unauthorized to create maintenance window for one or more monitors"
);
expect(maintenanceWindowsRepository.create).not.toHaveBeenCalled();
});
it("does not throw when all monitors belong to the team", async () => {
const { service } = createService();
await expect(service.createMaintenanceWindow(defaultCreateParams)).resolves.toBeUndefined();
});
it("creates a single window when monitorIDs has one entry", async () => {
const { service, maintenanceWindowsRepository } = createService();
await service.createMaintenanceWindow({ ...defaultCreateParams, monitorIDs: ["mon-1"] });
expect(maintenanceWindowsRepository.create).toHaveBeenCalledTimes(1);
});
});
// ── getMaintenanceWindowById ─────────────────────────────────────────────
describe("getMaintenanceWindowById", () => {
it("delegates to repository with id and teamId", async () => {
const expected = makeWindow();
const { service, maintenanceWindowsRepository } = createService();
(maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(expected);
const result = await service.getMaintenanceWindowById({ id: "mw-1", teamId: "team-1" });
expect(result).toBe(expected);
expect(maintenanceWindowsRepository.findById).toHaveBeenCalledWith("mw-1", "team-1");
});
});
// ── getMaintenanceWindowsByTeamId ────────────────────────────────────────
describe("getMaintenanceWindowsByTeamId", () => {
it("returns windows and count", async () => {
const windows = [makeWindow()];
const { service, maintenanceWindowsRepository } = createService();
(maintenanceWindowsRepository.findByTeamId as jest.Mock).mockResolvedValue(windows);
(maintenanceWindowsRepository.countByTeamId as jest.Mock).mockResolvedValue(1);
const result = await service.getMaintenanceWindowsByTeamId({ teamId: "team-1" });
expect(result).toEqual({ maintenanceWindows: windows, maintenanceWindowCount: 1 });
});
it("defaults page to 0 and rowsPerPage to 10 when not provided", async () => {
const { service, maintenanceWindowsRepository } = createService();
await service.getMaintenanceWindowsByTeamId({ teamId: "team-1" });
expect(maintenanceWindowsRepository.findByTeamId).toHaveBeenCalledWith("team-1", 0, 10, undefined, undefined, undefined);
});
it("passes pagination and filter parameters through", async () => {
const { service, maintenanceWindowsRepository } = createService();
await service.getMaintenanceWindowsByTeamId({
teamId: "team-1",
active: true,
page: 2,
rowsPerPage: 5,
field: "name",
order: "asc",
});
expect(maintenanceWindowsRepository.findByTeamId).toHaveBeenCalledWith("team-1", 2, 5, "name", "asc", true);
expect(maintenanceWindowsRepository.countByTeamId).toHaveBeenCalledWith("team-1", true);
});
});
// ── getMaintenanceWindowsByMonitorId ─────────────────────────────────────
describe("getMaintenanceWindowsByMonitorId", () => {
it("delegates to repository with monitorId and teamId", async () => {
const windows = [makeWindow()];
const { service, maintenanceWindowsRepository } = createService();
(maintenanceWindowsRepository.findByMonitorId as jest.Mock).mockResolvedValue(windows);
const result = await service.getMaintenanceWindowsByMonitorId({ monitorId: "mon-1", teamId: "team-1" });
expect(result).toBe(windows);
expect(maintenanceWindowsRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "team-1");
});
});
// ── deleteMaintenanceWindow ──────────────────────────────────────────────
describe("deleteMaintenanceWindow", () => {
it("delegates to repository and returns deleted window", async () => {
const deleted = makeWindow();
const { service, maintenanceWindowsRepository } = createService();
(maintenanceWindowsRepository.deleteById as jest.Mock).mockResolvedValue(deleted);
const result = await service.deleteMaintenanceWindow({ id: "mw-1", teamId: "team-1" });
expect(result).toBe(deleted);
expect(maintenanceWindowsRepository.deleteById).toHaveBeenCalledWith("mw-1", "team-1");
});
});
// ── editMaintenanceWindow ────────────────────────────────────────────────
describe("editMaintenanceWindow", () => {
it("delegates to repository with id, teamId, and body", async () => {
const updated = makeWindow({ name: "Updated Name" });
const { service, maintenanceWindowsRepository } = createService();
(maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(updated);
const result = await service.editMaintenanceWindow({ id: "mw-1", teamId: "team-1", body: { name: "Updated Name" } });
expect(result).toBe(updated);
expect(maintenanceWindowsRepository.updateById).toHaveBeenCalledWith("mw-1", "team-1", { name: "Updated Name" });
});
it("passes partial body fields through", async () => {
const { service, maintenanceWindowsRepository } = createService();
await service.editMaintenanceWindow({
id: "mw-1",
teamId: "team-1",
body: { active: false, repeat: 3600000 },
});
expect(maintenanceWindowsRepository.updateById).toHaveBeenCalledWith("mw-1", "team-1", { active: false, repeat: 3600000 });
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,482 @@
import { describe, expect, it, jest } from "@jest/globals";
import { NetworkService } from "../../../src/service/infrastructure/networkService.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import { NETWORK_ERROR } from "../../../src/service/infrastructure/network/utils.ts";
import type { IStatusProvider } from "../../../src/service/infrastructure/network/IStatusProvider.ts";
import type { Monitor, MonitorType } from "../../../src/types/index.ts";
import type { AxiosStatic } from "axios";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createMockAxios = () =>
({
post: jest.fn(),
}) as unknown as jest.Mocked<AxiosStatic>;
const createMockProvider = (type: string, result?: unknown) => {
const provider: jest.Mocked<IStatusProvider<unknown>> = {
type,
supports: jest.fn((t: MonitorType) => t === type) as any,
handle: jest.fn().mockResolvedValue(
result ?? {
monitorId: "mon-1",
teamId: "team-1",
type,
status: true,
code: 200,
message: "OK",
}
),
};
return provider;
};
const createService = (providers: IStatusProvider<unknown>[] = []) => {
const logger = createMockLogger();
const axios = createMockAxios();
const service = new NetworkService(axios, logger as any, providers);
return { service, logger, axios };
};
// ── Tests ────────────────────────────────────────────────────────────────────
describe("NetworkService", () => {
// ── Static / instance properties ─────────────────────────────────────────
describe("serviceName", () => {
it("returns NetworkService from static property", () => {
expect(NetworkService.SERVICE_NAME).toBe("NetworkService");
});
it("returns NetworkService from instance getter", () => {
const { service } = createService();
expect(service.serviceName).toBe("NetworkService");
});
});
// ── requestStatus ────────────────────────────────────────────────────────
describe("requestStatus", () => {
it("delegates to the matching provider", async () => {
const provider = createMockProvider("http");
const { service } = createService([provider]);
const monitor = { type: "http", _id: "mon-1" } as unknown as Monitor & { type: "http" };
const result = await service.requestStatus(monitor);
expect(provider.supports).toHaveBeenCalledWith("http");
expect(provider.handle).toHaveBeenCalledWith(monitor);
expect(result).toEqual(
expect.objectContaining({
monitorId: "mon-1",
status: true,
code: 200,
})
);
});
it("selects the correct provider when multiple are registered", async () => {
const httpProvider = createMockProvider("http");
const pingProvider = createMockProvider("ping");
const { service } = createService([httpProvider, pingProvider]);
const monitor = { type: "ping", _id: "mon-2" } as unknown as Monitor & { type: "ping" };
await service.requestStatus(monitor);
expect(httpProvider.handle).not.toHaveBeenCalled();
expect(pingProvider.handle).toHaveBeenCalledWith(monitor);
});
it("returns unsupported-type response when no provider matches", async () => {
const { service } = createService([]);
const monitor = { type: "unknown_type" as any, _id: "mon-1" } as unknown as Monitor & { type: any };
const result = await service.requestStatus(monitor);
expect(result).toEqual({
monitorId: "unknown",
teamId: "unknown",
type: "unknown",
status: false,
code: NETWORK_ERROR,
message: "Unsupported type: unknown_type",
});
});
});
// ── requestWebhook ───────────────────────────────────────────────────────
describe("requestWebhook", () => {
it("sends a POST and returns success response", async () => {
const { service, axios } = createService();
(axios.post as jest.Mock).mockResolvedValue({ status: 200, data: { ok: true } });
const result = await service.requestWebhook("slack", "https://hooks.example.com", { text: "hello" });
expect(axios.post).toHaveBeenCalledWith("https://hooks.example.com", { text: "hello" }, { headers: { "Content-Type": "application/json" } });
expect(result).toEqual({
type: "webhook",
status: true,
code: 200,
message: "Successfully sent slack notification",
payload: { ok: true },
});
});
it("returns failure with response data on axios error with response", async () => {
const { service, axios, logger } = createService();
const axiosError = Object.assign(new Error("Request failed"), {
response: { status: 403, data: { error: "forbidden" } },
});
(axios.post as jest.Mock).mockRejectedValue(axiosError);
const result = await service.requestWebhook("discord", "https://hooks.example.com", {});
expect(result).toEqual({
type: "webhook",
status: false,
code: 403,
message: "Failed to send discord notification",
payload: { error: "forbidden" },
});
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: "Request failed",
service: "NetworkService",
method: "requestWebhook",
})
);
});
it("returns failure with NETWORK_ERROR when axios error has no response status", async () => {
const { service, axios } = createService();
const axiosError = Object.assign(new Error("timeout"), {
response: { status: undefined, data: undefined },
});
(axios.post as jest.Mock).mockRejectedValue(axiosError);
const result = await service.requestWebhook("slack", "https://hooks.example.com", {});
expect(result.code).toBe(NETWORK_ERROR);
});
it("returns failure with NETWORK_ERROR on non-axios error (no response property)", async () => {
const { service, axios } = createService();
(axios.post as jest.Mock).mockRejectedValue(new Error("DNS failure"));
const result = await service.requestWebhook("slack", "https://hooks.example.com", {});
expect(result).toEqual({
type: "webhook",
status: false,
code: NETWORK_ERROR,
message: "Failed to send slack notification",
});
});
it("handles non-Error thrown values", async () => {
const { service, axios, logger } = createService();
(axios.post as jest.Mock).mockRejectedValue("string error");
const result = await service.requestWebhook("slack", "https://hooks.example.com", {});
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "string error" }));
expect(result).toEqual({
type: "webhook",
status: false,
code: NETWORK_ERROR,
message: "Failed to send slack notification",
});
});
it("returns NETWORK_ERROR when error has response key but response is undefined", async () => {
const { service, axios } = createService();
const axiosError = Object.assign(new Error("fail"), { response: undefined });
(axios.post as jest.Mock).mockRejectedValue(axiosError);
const result = await service.requestWebhook("slack", "https://hooks.example.com", {});
expect(result.code).toBe(NETWORK_ERROR);
expect(result.payload).toBeUndefined();
});
});
// ── requestPagerDuty ─────────────────────────────────────────────────────
describe("requestPagerDuty", () => {
const args = {
message: "Server down",
routingKey: "routing-key-123",
monitorUrl: "https://monitor.example.com",
};
it("sends correct payload and returns true on success", async () => {
const { service, axios } = createService();
(axios.post as jest.Mock).mockResolvedValue({ data: { status: "success" } });
const result = await service.requestPagerDuty(args);
expect(result).toBe(true);
expect(axios.post).toHaveBeenCalledWith(
"https://events.pagerduty.com/v2/enqueue",
expect.objectContaining({
routing_key: "routing-key-123",
event_action: "trigger",
payload: expect.objectContaining({
summary: "Server down",
severity: "critical",
source: "https://monitor.example.com",
}),
})
);
});
it("returns false when response status is not success", async () => {
const { service, axios } = createService();
(axios.post as jest.Mock).mockResolvedValue({ data: { status: "invalid event" } });
const result = await service.requestPagerDuty(args);
expect(result).toBe(false);
});
it("returns false when response data is undefined", async () => {
const { service, axios } = createService();
(axios.post as jest.Mock).mockResolvedValue({ data: undefined });
const result = await service.requestPagerDuty(args);
expect(result).toBe(false);
});
it("throws AppError on axios failure with response data", async () => {
const { service, axios } = createService();
const axiosError = Object.assign(new Error("Request failed"), {
response: { data: { message: "invalid routing key" } },
});
(axios.post as jest.Mock).mockRejectedValue(axiosError);
await expect(service.requestPagerDuty(args)).rejects.toThrow("Request failed");
});
it("throws AppError on network error without response", async () => {
const { service, axios } = createService();
(axios.post as jest.Mock).mockRejectedValue(new Error("ECONNREFUSED"));
await expect(service.requestPagerDuty(args)).rejects.toThrow("ECONNREFUSED");
});
it("throws AppError with default message for non-Error thrown value", async () => {
const { service, axios } = createService();
(axios.post as jest.Mock).mockRejectedValue(null);
await expect(service.requestPagerDuty(args)).rejects.toThrow("null");
});
it("uses fallback message when Error has empty string message", async () => {
const { service, axios } = createService();
(axios.post as jest.Mock).mockRejectedValue(new Error(""));
try {
await service.requestPagerDuty(args);
expect.unreachable("should have thrown");
} catch (err: any) {
expect(err.message).toBe("Error sending PagerDuty notification");
}
});
it("includes responseData when non-Error object has response property", async () => {
const { service, axios } = createService();
const errorObj = { response: { data: { detail: "bad key" } } };
(axios.post as jest.Mock).mockRejectedValue(errorObj);
try {
await service.requestPagerDuty(args);
expect.unreachable("should have thrown");
} catch (err: any) {
expect(err.details).toEqual({ responseData: { detail: "bad key" } });
}
});
it("sets responseData to undefined when error is a primitive (non-object)", async () => {
const { service, axios } = createService();
(axios.post as jest.Mock).mockRejectedValue(42);
try {
await service.requestPagerDuty(args);
expect.unreachable("should have thrown");
} catch (err: any) {
expect(err.details).toEqual({ responseData: undefined });
}
});
it("sets responseData to undefined when object has response key set to undefined", async () => {
const { service, axios } = createService();
(axios.post as jest.Mock).mockRejectedValue({ response: undefined });
try {
await service.requestPagerDuty(args);
expect.unreachable("should have thrown");
} catch (err: any) {
expect(err.details).toEqual({ responseData: undefined });
}
});
it("includes responseData in AppError details when available", async () => {
const { service, axios } = createService();
const axiosError = Object.assign(new Error("Bad request"), {
response: { data: { errors: ["invalid key"] } },
});
(axios.post as jest.Mock).mockRejectedValue(axiosError);
try {
await service.requestPagerDuty(args);
expect.unreachable("should have thrown");
} catch (err: any) {
expect(err.details).toEqual({
responseData: { errors: ["invalid key"] },
});
}
});
it("sets responseData to undefined in AppError when error has no response", async () => {
const { service, axios } = createService();
(axios.post as jest.Mock).mockRejectedValue(new Error("timeout"));
try {
await service.requestPagerDuty(args);
expect.unreachable("should have thrown");
} catch (err: any) {
expect(err.details).toEqual({ responseData: undefined });
}
});
});
// ── requestMatrix ────────────────────────────────────────────────────────
describe("requestMatrix", () => {
const args = {
homeserverUrl: "https://matrix.example.com",
accessToken: "token-abc",
roomId: "!room:example.com",
message: "<b>Alert</b>",
};
it("sends correct payload and returns success response", async () => {
const { service, axios } = createService();
(axios.post as jest.Mock).mockResolvedValue({ status: 200, data: { event_id: "$abc" } });
const result = await service.requestMatrix(args);
expect(result).toEqual({
status: true,
code: 200,
message: "Successfully sent Matrix notification",
});
expect(axios.post).toHaveBeenCalledWith(
"https://matrix.example.com/_matrix/client/v3/rooms/!room:example.com/send/m.room.message?access_token=token-abc",
{
msgtype: "m.text",
body: "<b>Alert</b>",
format: "org.matrix.custom.html",
formatted_body: "<b>Alert</b>",
},
{ headers: { "Content-Type": "application/json" } }
);
});
it("returns failure with response data on axios error with response (Error instance)", async () => {
const { service, axios, logger } = createService();
const axiosError = Object.assign(new Error("Forbidden"), {
response: { status: 403, data: { errcode: "M_FORBIDDEN" } },
});
(axios.post as jest.Mock).mockRejectedValue(axiosError);
const result = await service.requestMatrix(args);
expect(result).toEqual({
status: false,
code: 403,
message: "Failed to send Matrix notification",
payload: { errcode: "M_FORBIDDEN" },
});
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: "Forbidden",
service: "NetworkService",
method: "requestMatrix",
})
);
});
it("returns NETWORK_ERROR when axios error response has no status", async () => {
const { service, axios } = createService();
const axiosError = Object.assign(new Error("timeout"), {
response: { status: 0, data: undefined },
});
(axios.post as jest.Mock).mockRejectedValue(axiosError);
const result = await service.requestMatrix(args);
expect(result.code).toBe(NETWORK_ERROR);
});
it("returns failure with NETWORK_ERROR on Error without response property", async () => {
const { service, axios, logger } = createService();
(axios.post as jest.Mock).mockRejectedValue(new Error("DNS failure"));
const result = await service.requestMatrix(args);
expect(result).toEqual({
status: false,
code: NETWORK_ERROR,
message: "Failed to send Matrix notification",
});
// First warn call is from the Error instanceof branch
expect(logger.warn).toHaveBeenCalledTimes(2);
});
it("returns failure with NETWORK_ERROR on non-Error thrown value", async () => {
const { service, axios, logger } = createService();
(axios.post as jest.Mock).mockRejectedValue("string error");
const result = await service.requestMatrix(args);
expect(result).toEqual({
status: false,
code: NETWORK_ERROR,
message: "Failed to send Matrix notification",
});
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: "string error",
service: "NetworkService",
method: "requestMatrix",
})
);
});
it("returns NETWORK_ERROR when Error has response key but response is undefined", async () => {
const { service, axios } = createService();
const axiosError = Object.assign(new Error("fail"), { response: undefined });
(axios.post as jest.Mock).mockRejectedValue(axiosError);
const result = await service.requestMatrix(args);
expect(result.code).toBe(NETWORK_ERROR);
});
it("returns NETWORK_ERROR when Error response has undefined status", async () => {
const { service, axios } = createService();
const axiosError = Object.assign(new Error("fail"), {
response: { status: undefined, data: { error: "unknown" } },
});
(axios.post as jest.Mock).mockRejectedValue(axiosError);
const result = await service.requestMatrix(args);
expect(result.code).toBe(NETWORK_ERROR);
expect(result.payload).toEqual({ error: "unknown" });
});
});
});
@@ -0,0 +1,761 @@
import { describe, expect, it, beforeEach } from "@jest/globals";
import { NotificationMessageBuilder } from "../../../src/service/infrastructure/notificationMessageBuilder.ts";
import type { Monitor, MonitorStatusResponse, HardwareStatusPayload } from "../../../src/types/index.ts";
import type { MonitorActionDecision } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
name: "Test Monitor",
url: "https://example.com",
type: "http",
status: "down",
teamId: "team-1",
cpuAlertThreshold: undefined,
memoryAlertThreshold: undefined,
diskAlertThreshold: undefined,
tempAlertThreshold: undefined,
...overrides,
}) as Monitor;
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: true,
incidentReason: null,
notificationReason: "status_change",
...overrides,
});
const makeStatusResponse = (overrides?: Partial<MonitorStatusResponse>): MonitorStatusResponse =>
({
monitorId: "mon-1",
teamId: "team-1",
type: "http",
status: false,
code: 500,
message: "Internal Server Error",
...overrides,
}) as MonitorStatusResponse;
const makeHardwarePayload = (overrides?: Partial<HardwareStatusPayload["data"]>): HardwareStatusPayload =>
({
data: {
cpu: { usage_percent: 0.5, temperature: [45] },
memory: { usage_percent: 0.6 },
disk: [{ usage_percent: 0.7 }],
...overrides,
},
}) as HardwareStatusPayload;
// ── Tests ────────────────────────────────────────────────────────────────────
describe("NotificationMessageBuilder", () => {
let builder: NotificationMessageBuilder;
beforeEach(() => {
builder = new NotificationMessageBuilder();
});
describe("SERVICE_NAME", () => {
it("returns NotificationMessageBuilder", () => {
expect(NotificationMessageBuilder.SERVICE_NAME).toBe("NotificationMessageBuilder");
});
});
// ── buildMessage ─────────────────────────────────────────────────────
describe("buildMessage", () => {
it("builds a monitor_down message", () => {
const monitor = makeMonitor({ status: "down" });
const decision = makeDecision();
const response = makeStatusResponse();
const msg = builder.buildMessage(monitor, response, decision, "https://app.example.com");
expect(msg.type).toBe("monitor_down");
expect(msg.severity).toBe("critical");
expect(msg.monitor).toEqual({
id: "mon-1",
name: "Test Monitor",
url: "https://example.com",
type: "http",
status: "down",
});
expect(msg.content.title).toBe("Monitor Down: Test Monitor");
expect(msg.clientHost).toBe("https://app.example.com");
expect(msg.metadata).toEqual({
teamId: "team-1",
notificationReason: "status_change",
});
});
it("builds a monitor_up message", () => {
const monitor = makeMonitor({ status: "up", type: "http" });
const decision = makeDecision({ notificationReason: "status_change" });
const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "https://app.example.com");
expect(msg.type).toBe("monitor_up");
expect(msg.severity).toBe("success");
expect(msg.content.title).toBe("Monitor Recovered: Test Monitor");
});
it("builds a threshold_breach message", () => {
const monitor = makeMonitor({ status: "up", type: "hardware", cpuAlertThreshold: 80 });
const decision = makeDecision({ notificationReason: "threshold_breach" });
const response = makeStatusResponse({
type: "hardware",
payload: makeHardwarePayload({ cpu: { usage_percent: 0.9, temperature: [50] } as any }),
} as any);
const msg = builder.buildMessage(monitor, response, decision, "https://app.example.com");
expect(msg.type).toBe("threshold_breach");
expect(msg.severity).toBe("warning");
expect(msg.content.title).toBe("Threshold Exceeded: Test Monitor");
expect(msg.content.thresholds).toBeDefined();
expect(msg.content.thresholds!.length).toBeGreaterThan(0);
});
it("builds a threshold_resolved message for hardware monitor recovering", () => {
const monitor = makeMonitor({ status: "up", type: "hardware" });
const decision = makeDecision({ notificationReason: "status_change" });
const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "https://app.example.com");
expect(msg.type).toBe("threshold_resolved");
expect(msg.severity).toBe("success");
expect(msg.content.title).toBe("Thresholds Resolved: Test Monitor");
});
it("uses notificationReason from decision, falling back to status_change", () => {
const monitor = makeMonitor({ status: "down" });
const decision = makeDecision({ notificationReason: null });
const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "https://app.example.com");
expect(msg.metadata.notificationReason).toBe("status_change");
});
});
// ── determineNotificationType (via buildMessage) ─────────────────────
describe("determineNotificationType", () => {
it("returns monitor_down when status is down, even if notificationReason is threshold_breach", () => {
const monitor = makeMonitor({ status: "down" });
const decision = makeDecision({ notificationReason: "threshold_breach" });
const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "");
expect(msg.type).toBe("monitor_down");
});
it("returns threshold_breach when reason is threshold_breach and status is not down", () => {
const monitor = makeMonitor({ status: "up", type: "hardware" });
const decision = makeDecision({ notificationReason: "threshold_breach" });
const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "");
expect(msg.type).toBe("threshold_breach");
});
it("returns threshold_resolved for hardware monitor with status_change and up", () => {
const monitor = makeMonitor({ status: "up", type: "hardware" });
const decision = makeDecision({ notificationReason: "status_change" });
const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "");
expect(msg.type).toBe("threshold_resolved");
});
it("returns monitor_up for non-hardware monitor with status up", () => {
const monitor = makeMonitor({ status: "up", type: "http" });
const decision = makeDecision({ notificationReason: "status_change" });
const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "");
expect(msg.type).toBe("monitor_up");
});
it("returns monitor_up as default for unrecognized status", () => {
const monitor = makeMonitor({ status: "unknown" as any });
const decision = makeDecision({ notificationReason: null });
const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "");
expect(msg.type).toBe("monitor_up");
});
});
// ── determineSeverity (via buildMessage) ─────────────────────────────
describe("determineSeverity", () => {
it("returns critical for monitor_down", () => {
const msg = builder.buildMessage(makeMonitor({ status: "down" }), makeStatusResponse(), makeDecision(), "");
expect(msg.severity).toBe("critical");
});
it("returns warning for threshold_breach", () => {
const msg = builder.buildMessage(
makeMonitor({ status: "up", type: "hardware" }),
makeStatusResponse(),
makeDecision({ notificationReason: "threshold_breach" }),
""
);
expect(msg.severity).toBe("warning");
});
it("returns success for monitor_up", () => {
const msg = builder.buildMessage(
makeMonitor({ status: "up", type: "http" }),
makeStatusResponse(),
makeDecision({ notificationReason: "status_change" }),
""
);
expect(msg.severity).toBe("success");
});
it("returns success for threshold_resolved", () => {
const msg = builder.buildMessage(
makeMonitor({ status: "up", type: "hardware" }),
makeStatusResponse(),
makeDecision({ notificationReason: "status_change" }),
""
);
expect(msg.severity).toBe("success");
});
});
// ── buildContent variants ────────────────────────────────────────────
describe("buildContent", () => {
describe("monitor_down", () => {
it("includes response code and error message when present", () => {
const monitor = makeMonitor({ status: "down" });
const response = makeStatusResponse({ code: 503, message: "Service Unavailable" });
const msg = builder.buildMessage(monitor, response, makeDecision(), "");
expect(msg.content.details).toContain("Response Code: 503");
expect(msg.content.details).toContain("Error: Service Unavailable");
});
it("omits response code when falsy", () => {
const monitor = makeMonitor({ status: "down" });
const response = makeStatusResponse({ code: 0, message: "" });
const msg = builder.buildMessage(monitor, response, makeDecision(), "");
expect(msg.content.details).not.toContainEqual(expect.stringContaining("Response Code:"));
expect(msg.content.details).not.toContainEqual(expect.stringContaining("Error:"));
});
it("includes URL, Status, and Type in details", () => {
const monitor = makeMonitor({ status: "down" });
const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), "");
expect(msg.content.details).toContain("URL: https://example.com");
expect(msg.content.details).toContain("Status: Down");
expect(msg.content.details).toContain("Type: http");
});
it("sets summary text", () => {
const monitor = makeMonitor({ status: "down" });
const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), "");
expect(msg.content.summary).toBe('Monitor "Test Monitor" is currently down and unreachable.');
});
it("sets timestamp", () => {
const monitor = makeMonitor({ status: "down" });
const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), "");
expect(msg.content.timestamp).toBeInstanceOf(Date);
});
});
describe("monitor_up", () => {
it("includes recovery details", () => {
const monitor = makeMonitor({ status: "up", type: "http" });
const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), "");
expect(msg.content.title).toBe("Monitor Recovered: Test Monitor");
expect(msg.content.summary).toBe('Monitor "Test Monitor" is back up and operational.');
expect(msg.content.details).toContain("Status: Up");
});
});
describe("threshold_breach", () => {
it("includes threshold breaches in content", () => {
const monitor = makeMonitor({ status: "up", type: "hardware", cpuAlertThreshold: 80 });
const response = makeStatusResponse({
payload: makeHardwarePayload({ cpu: { usage_percent: 0.9, temperature: [50] } as any }),
} as any);
const msg = builder.buildMessage(monitor, response, makeDecision({ notificationReason: "threshold_breach" }), "");
expect(msg.content.thresholds).toEqual(
expect.arrayContaining([
expect.objectContaining({
metric: "cpu",
currentValue: 90,
threshold: 80,
}),
])
);
});
});
describe("threshold_resolved", () => {
it("includes resolved details", () => {
const monitor = makeMonitor({ status: "up", type: "hardware" });
const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), "");
expect(msg.content.title).toBe("Thresholds Resolved: Test Monitor");
expect(msg.content.summary).toBe('Monitor "Test Monitor" thresholds have returned to normal.');
expect(msg.content.details).toContain("Status: Up");
});
});
describe("default content", () => {
it("builds default content for unhandled notification type", () => {
// Force default by creating a scenario where type falls through
// We can test buildContent default by subclassing or testing indirectly
// The default case in buildContent handles "test" type among others
// Since determineNotificationType never returns "test", we test via
// a monitor with unrecognized status that maps to monitor_up
// Instead, let's verify the default case exists by checking that
// non-standard types would get default content
const monitor = makeMonitor({ status: "up", type: "http" });
const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), "");
// monitor_up is handled, so content should be monitor_up specific
expect(msg.content.title).toBe("Monitor Recovered: Test Monitor");
});
});
});
// ── determineSeverity edge cases (via private method) ────────────────
describe("determineSeverity edge cases", () => {
it("returns info for test type", () => {
const result = (builder as any).determineSeverity("test");
expect(result).toBe("info");
});
it("returns info for unknown type (default)", () => {
const result = (builder as any).determineSeverity("unknown_type");
expect(result).toBe("info");
});
});
// ── buildContent edge cases (via private method) ─────────────────────
describe("buildContent edge cases", () => {
it("returns default content for unhandled notification type", () => {
const monitor = makeMonitor({ status: "up", type: "http" });
const result = (builder as any).buildContent("test", monitor, makeStatusResponse());
expect(result.title).toBe("Monitor: Test Monitor");
expect(result.summary).toBe('Status update for monitor "Test Monitor".');
expect(result.details).toContain("URL: https://example.com");
expect(result.details).toContain("Status: up");
expect(result.details).toContain("Type: http");
});
});
// ── extractThresholdBreaches ─────────────────────────────────────────
describe("extractThresholdBreaches", () => {
it("returns empty array for non-hardware monitor", () => {
const monitor = makeMonitor({ type: "http" });
const response = makeStatusResponse();
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches).toEqual([]);
});
it("returns empty array when payload is missing", () => {
const monitor = makeMonitor({ type: "hardware" });
const response = makeStatusResponse({ payload: undefined } as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches).toEqual([]);
});
it("returns empty array when hardware data is missing", () => {
const monitor = makeMonitor({ type: "hardware" });
const response = makeStatusResponse({ payload: { data: undefined } } as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches).toEqual([]);
});
// ── CPU ──────────────────────────────────────────────────────────
describe("cpu threshold", () => {
it("detects CPU breach when usage exceeds threshold", () => {
const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: 80 });
const response = makeStatusResponse({
payload: makeHardwarePayload({ cpu: { usage_percent: 0.9, temperature: [50] } as any }),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches).toContainEqual(
expect.objectContaining({
metric: "cpu",
currentValue: 90,
threshold: 80,
unit: "%",
formattedValue: "90.0%",
})
);
});
it("does not report CPU breach when usage is below threshold", () => {
const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: 80 });
const response = makeStatusResponse({
payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [50] } as any }),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined();
});
it("skips CPU check when cpuAlertThreshold is undefined", () => {
const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: undefined });
const response = makeStatusResponse({
payload: makeHardwarePayload({ cpu: { usage_percent: 0.99 } as any }),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined();
});
it("skips CPU check when cpuAlertThreshold is null", () => {
const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: null as any });
const response = makeStatusResponse({
payload: makeHardwarePayload({ cpu: { usage_percent: 0.99 } as any }),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined();
});
it("skips CPU check when cpu usage_percent is undefined", () => {
const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: 80 });
const response = makeStatusResponse({
payload: makeHardwarePayload({ cpu: { usage_percent: undefined } as any }),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined();
});
it("skips CPU check when cpu object is undefined", () => {
const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: 80 });
const response = makeStatusResponse({
payload: makeHardwarePayload({ cpu: undefined as any }),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined();
});
});
// ── Memory ───────────────────────────────────────────────────────
describe("memory threshold", () => {
it("detects memory breach when usage exceeds threshold", () => {
const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: 70 });
const response = makeStatusResponse({
payload: makeHardwarePayload({ memory: { usage_percent: 0.85 } as any }),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches).toContainEqual(
expect.objectContaining({
metric: "memory",
currentValue: 85,
threshold: 70,
unit: "%",
formattedValue: "85.0%",
})
);
});
it("does not report memory breach when usage is below threshold", () => {
const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: 90 });
const response = makeStatusResponse({
payload: makeHardwarePayload({ memory: { usage_percent: 0.5 } as any }),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches.find((b) => b.metric === "memory")).toBeUndefined();
});
it("skips memory check when memoryAlertThreshold is undefined", () => {
const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: undefined });
const response = makeStatusResponse({
payload: makeHardwarePayload({ memory: { usage_percent: 0.99 } as any }),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches.find((b) => b.metric === "memory")).toBeUndefined();
});
it("skips memory check when memoryAlertThreshold is null", () => {
const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: null as any });
const response = makeStatusResponse({
payload: makeHardwarePayload({ memory: { usage_percent: 0.99 } as any }),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches.find((b) => b.metric === "memory")).toBeUndefined();
});
it("skips memory check when memory usage_percent is undefined", () => {
const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: 70 });
const response = makeStatusResponse({
payload: makeHardwarePayload({ memory: { usage_percent: undefined } as any }),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches.find((b) => b.metric === "memory")).toBeUndefined();
});
it("skips memory check when memory object is undefined", () => {
const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: 70 });
const response = makeStatusResponse({
payload: makeHardwarePayload({ memory: undefined as any }),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches.find((b) => b.metric === "memory")).toBeUndefined();
});
});
// ── Disk ─────────────────────────────────────────────────────────
describe("disk threshold", () => {
it("detects disk breach using highest usage across multiple disks", () => {
const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: 80 });
const response = makeStatusResponse({
payload: makeHardwarePayload({
disk: [{ usage_percent: 0.5 }, { usage_percent: 0.95 }, { usage_percent: 0.7 }] as any,
}),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches).toContainEqual(
expect.objectContaining({
metric: "disk",
currentValue: 95,
threshold: 80,
formattedValue: "95.0%",
})
);
});
it("does not report disk breach when all disks are below threshold", () => {
const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: 90 });
const response = makeStatusResponse({
payload: makeHardwarePayload({
disk: [{ usage_percent: 0.5 }, { usage_percent: 0.6 }] as any,
}),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches.find((b) => b.metric === "disk")).toBeUndefined();
});
it("skips disk check when diskAlertThreshold is undefined", () => {
const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: undefined });
const response = makeStatusResponse({
payload: makeHardwarePayload({ disk: [{ usage_percent: 0.99 }] as any }),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches.find((b) => b.metric === "disk")).toBeUndefined();
});
it("skips disk check when diskAlertThreshold is null", () => {
const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: null as any });
const response = makeStatusResponse({
payload: makeHardwarePayload({ disk: [{ usage_percent: 0.99 }] as any }),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches.find((b) => b.metric === "disk")).toBeUndefined();
});
it("skips disk check when disk is not an array", () => {
const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: 80 });
const response = makeStatusResponse({
payload: makeHardwarePayload({ disk: "not-an-array" as any }),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches.find((b) => b.metric === "disk")).toBeUndefined();
});
it("skips disks with undefined usage_percent", () => {
const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: 50 });
const response = makeStatusResponse({
payload: makeHardwarePayload({
disk: [{ usage_percent: undefined }, { usage_percent: 0.3 }] as any,
}),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches.find((b) => b.metric === "disk")).toBeUndefined();
});
});
// ── Temperature ──────────────────────────────────────────────────
describe("temperature threshold", () => {
it("detects temperature breach from array of temperatures", () => {
const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 70 });
const response = makeStatusResponse({
payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [65, 75, 68] } as any }),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches).toContainEqual(
expect.objectContaining({
metric: "temp",
currentValue: 75,
threshold: 70,
unit: "°C",
formattedValue: "75.0°C",
})
);
});
it("handles single temperature value (non-array)", () => {
const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 70 });
const response = makeStatusResponse({
payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: 80 } as any }),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches).toContainEqual(
expect.objectContaining({
metric: "temp",
currentValue: 80,
threshold: 70,
})
);
});
it("does not report temperature breach when below threshold", () => {
const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 80 });
const response = makeStatusResponse({
payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [60, 65] } as any }),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches.find((b) => b.metric === "temp")).toBeUndefined();
});
it("skips temperature check when tempAlertThreshold is undefined", () => {
const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: undefined });
const response = makeStatusResponse({
payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [99] } as any }),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches.find((b) => b.metric === "temp")).toBeUndefined();
});
it("skips temperature check when tempAlertThreshold is null", () => {
const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: null as any });
const response = makeStatusResponse({
payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [99] } as any }),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches.find((b) => b.metric === "temp")).toBeUndefined();
});
it("skips temperature check when cpu.temperature is falsy", () => {
const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 70 });
const response = makeStatusResponse({
payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: null } as any }),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches.find((b) => b.metric === "temp")).toBeUndefined();
});
it("skips temperature check when cpu object is undefined", () => {
const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 70 });
const response = makeStatusResponse({
payload: makeHardwarePayload({ cpu: undefined as any }),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
expect(breaches.find((b) => b.metric === "temp")).toBeUndefined();
});
});
// ── Combined ─────────────────────────────────────────────────────
describe("combined thresholds", () => {
it("detects multiple breaches simultaneously", () => {
const monitor = makeMonitor({
type: "hardware",
cpuAlertThreshold: 80,
memoryAlertThreshold: 70,
diskAlertThreshold: 85,
tempAlertThreshold: 65,
});
const response = makeStatusResponse({
payload: makeHardwarePayload({
cpu: { usage_percent: 0.9, temperature: [70] } as any,
memory: { usage_percent: 0.85 } as any,
disk: [{ usage_percent: 0.95 }] as any,
}),
} as any);
const breaches = builder.extractThresholdBreaches(monitor, response);
const metrics = breaches.map((b) => b.metric);
expect(metrics).toContain("cpu");
expect(metrics).toContain("memory");
expect(metrics).toContain("disk");
expect(metrics).toContain("temp");
});
});
});
});
@@ -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");
});
});
});
@@ -0,0 +1,184 @@
import { describe, expect, it, jest } from "@jest/globals";
import { SettingsService } from "../../../src/service/system/settingsService.ts";
import type { ISettingsRepository } from "../../../src/repositories/settings/ISettingsRepository.ts";
import type { ValidatedEnv } from "../../../src/validation/envValidation.ts";
import type { Settings } from "../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeEnv = (overrides?: Partial<ValidatedEnv>): ValidatedEnv =>
({
JWT_SECRET: "test-secret",
TOKEN_TTL: "99d",
NODE_ENV: "development",
LOG_LEVEL: "debug",
CLIENT_HOST: "http://localhost:5173",
DB_CONNECTION_STRING: "mongodb://localhost:27017/test_db",
DB_TYPE: "mongodb",
...overrides,
}) as ValidatedEnv;
const makeSettings = (overrides?: Partial<Settings>): Settings =>
({
id: "settings-1",
checkTTL: 30,
language: "en",
systemEmailSecure: false,
systemEmailPool: false,
systemEmailIgnoreTLS: false,
systemEmailRequireTLS: false,
systemEmailRejectUnauthorized: false,
showURL: true,
singleton: true,
version: 1,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
}) as Settings;
const createSettingsRepo = () =>
({
create: jest.fn().mockResolvedValue(makeSettings()),
findSingleton: jest.fn().mockResolvedValue(makeSettings()),
update: jest.fn().mockResolvedValue(makeSettings()),
deleteLegacy: jest.fn().mockResolvedValue(true),
}) as unknown as jest.Mocked<ISettingsRepository>;
const createService = (envOverrides?: Partial<ValidatedEnv>) => {
const env = makeEnv(envOverrides);
const service = new SettingsService(env);
const settingsRepository = createSettingsRepo();
service.setRepository(settingsRepository);
return { service, settingsRepository, env };
};
// ── Tests ────────────────────────────────────────────────────────────────────
describe("SettingsService", () => {
describe("serviceName", () => {
it("returns SettingsService from static property", () => {
expect(SettingsService.SERVICE_NAME).toBe("SettingsService");
});
it("returns SettingsService from instance getter", () => {
const { service } = createService();
expect(service.serviceName).toBe("SettingsService");
});
});
// ── loadSettings ────────────────────────────────────────────────────────
describe("loadSettings", () => {
it("returns env config mapped from constructor", () => {
const { service } = createService();
const settings = service.loadSettings();
expect(settings).toEqual({
jwtSecret: "test-secret",
jwtTTL: "99d",
nodeEnv: "development",
logLevel: "debug",
clientHost: "http://localhost:5173",
dbConnectionString: "mongodb://localhost:27017/test_db",
dbType: "mongodb",
});
});
it("reflects custom env values", () => {
const { service } = createService({ NODE_ENV: "production", LOG_LEVEL: "error" });
const settings = service.loadSettings();
expect(settings.nodeEnv).toBe("production");
expect(settings.logLevel).toBe("error");
});
});
// ── getSettings ─────────────────────────────────────────────────────────
describe("getSettings", () => {
it("returns the same config as loadSettings", () => {
const { service } = createService();
expect(service.getSettings()).toEqual(service.loadSettings());
});
});
// ── setRepository ───────────────────────────────────────────────────────
describe("setRepository", () => {
it("allows DB methods to work after being called", async () => {
const env = makeEnv();
const service = new SettingsService(env);
const repo = createSettingsRepo();
service.setRepository(repo);
const settings = await service.getDBSettings();
expect(settings).toEqual(makeSettings());
});
});
// ── getDBSettings ───────────────────────────────────────────────────────
describe("getDBSettings", () => {
it("deletes legacy settings and returns singleton", async () => {
const { service, settingsRepository } = createService();
const expected = makeSettings();
(settingsRepository.findSingleton as jest.Mock).mockResolvedValue(expected);
const result = await service.getDBSettings();
expect(settingsRepository.deleteLegacy).toHaveBeenCalled();
expect(settingsRepository.findSingleton).toHaveBeenCalled();
expect(result).toBe(expected);
});
it("creates default settings when singleton is null, then returns them", async () => {
const { service, settingsRepository } = createService();
const created = makeSettings();
(settingsRepository.findSingleton as jest.Mock).mockResolvedValueOnce(null).mockResolvedValueOnce(created);
const result = await service.getDBSettings();
expect(settingsRepository.create).toHaveBeenCalledWith({});
expect(settingsRepository.findSingleton).toHaveBeenCalledTimes(2);
expect(result).toBe(created);
});
it("throws when settings are still null after creation attempt", async () => {
const { service, settingsRepository } = createService();
(settingsRepository.findSingleton as jest.Mock).mockResolvedValue(null);
await expect(service.getDBSettings()).rejects.toThrow("Settings not found");
});
it("throws when repository is not set", async () => {
const env = makeEnv();
const service = new SettingsService(env);
await expect(service.getDBSettings()).rejects.toThrow("Settings repository not initialized");
});
});
// ── updateDbSettings ────────────────────────────────────────────────────
describe("updateDbSettings", () => {
it("delegates to repository and returns updated settings", async () => {
const updated = makeSettings({ checkTTL: 60 });
const { service, settingsRepository } = createService();
(settingsRepository.update as jest.Mock).mockResolvedValue(updated);
const result = await service.updateDbSettings({ checkTTL: 60 });
expect(result).toBe(updated);
expect(settingsRepository.update).toHaveBeenCalledWith({ checkTTL: 60 });
});
it("throws when repository is not set", async () => {
const env = makeEnv();
const service = new SettingsService(env);
await expect(service.updateDbSettings({ checkTTL: 60 })).rejects.toThrow("Settings repository not initialized");
});
});
});
@@ -0,0 +1,107 @@
import { describe, expect, it, jest } from "@jest/globals";
import { StatusPageService } from "../../../src/service/business/statusPageService.ts";
import type { IStatusPagesRepository } from "../../../src/repositories/status-pages/IStatusPagesRepository.ts";
import type { StatusPage } from "../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeStatusPage = (overrides?: Partial<StatusPage>): StatusPage =>
({
id: "sp-1",
teamId: "team-1",
userId: "user-1",
url: "my-status-page",
companyName: "Test Co",
monitors: ["mon-1"],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
}) as StatusPage;
const createRepo = () =>
({
create: jest.fn().mockResolvedValue(makeStatusPage()),
findByUrl: jest.fn().mockResolvedValue(makeStatusPage()),
findByTeamId: jest.fn().mockResolvedValue([makeStatusPage()]),
updateById: jest.fn().mockResolvedValue(makeStatusPage()),
deleteById: jest.fn().mockResolvedValue(makeStatusPage()),
removeMonitorFromStatusPages: jest.fn().mockResolvedValue(1),
}) as unknown as jest.Mocked<IStatusPagesRepository>;
const createService = () => {
const repo = createRepo();
const service = new StatusPageService(repo);
return { service, repo };
};
// ── Tests ────────────────────────────────────────────────────────────────────
describe("StatusPageService", () => {
describe("createStatusPage", () => {
it("delegates to repository with all parameters", async () => {
const { service, repo } = createService();
const data = { companyName: "New Co" };
const file = { originalname: "logo.png" } as Express.Multer.File;
const result = await service.createStatusPage("user-1", "team-1", file, data);
expect(repo.create).toHaveBeenCalledWith("user-1", "team-1", file, data);
expect(result).toEqual(makeStatusPage());
});
it("passes undefined image when not provided", async () => {
const { service, repo } = createService();
await service.createStatusPage("user-1", "team-1", undefined, {});
expect(repo.create).toHaveBeenCalledWith("user-1", "team-1", undefined, {});
});
});
describe("getStatusPageByUrl", () => {
it("delegates to repository", async () => {
const { service, repo } = createService();
const result = await service.getStatusPageByUrl("my-status-page");
expect(repo.findByUrl).toHaveBeenCalledWith("my-status-page");
expect(result).toEqual(makeStatusPage());
});
});
describe("getStatusPagesByTeamId", () => {
it("delegates to repository", async () => {
const { service, repo } = createService();
const result = await service.getStatusPagesByTeamId("team-1");
expect(repo.findByTeamId).toHaveBeenCalledWith("team-1");
expect(result).toEqual([makeStatusPage()]);
});
});
describe("updateStatusPage", () => {
it("delegates to repository with all parameters", async () => {
const { service, repo } = createService();
const updated = makeStatusPage({ companyName: "Updated Co" });
(repo.updateById as jest.Mock).mockResolvedValue(updated);
const file = { originalname: "new-logo.png" } as Express.Multer.File;
const result = await service.updateStatusPage("sp-1", "team-1", file, { companyName: "Updated Co" });
expect(repo.updateById).toHaveBeenCalledWith("sp-1", "team-1", file, { companyName: "Updated Co" });
expect(result).toBe(updated);
});
});
describe("deleteStatusPage", () => {
it("delegates to repository and returns deleted page", async () => {
const { service, repo } = createService();
const result = await service.deleteStatusPage("sp-1", "team-1");
expect(repo.deleteById).toHaveBeenCalledWith("sp-1", "team-1");
expect(result).toEqual(makeStatusPage());
});
});
});
@@ -0,0 +1,813 @@
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";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createBuffer = (): jest.Mocked<Pick<IBufferService, "addToBuffer">> => ({
addToBuffer: jest.fn(),
});
const createMonitorsRepo = () =>
({
findById: jest.fn(),
updateById: jest.fn(),
}) as unknown as jest.Mocked<IMonitorsRepository>;
const createMonitorStatsRepo = () =>
({
findByMonitorId: jest.fn(),
create: jest.fn(),
updateByMonitorId: jest.fn(),
}) as unknown as jest.Mocked<IMonitorStatsRepository>;
const createChecksRepo = () => ({}) as unknown as jest.Mocked<IChecksRepository>;
const createService = (overrides?: {
logger?: ReturnType<typeof createMockLogger>;
buffer?: ReturnType<typeof createBuffer>;
monitorsRepository?: ReturnType<typeof createMonitorsRepo>;
monitorStatsRepository?: ReturnType<typeof createMonitorStatsRepo>;
checksRepository?: ReturnType<typeof createChecksRepo>;
}) => {
const logger = overrides?.logger ?? createMockLogger();
const buffer = overrides?.buffer ?? createBuffer();
const monitorsRepository = overrides?.monitorsRepository ?? createMonitorsRepo();
const monitorStatsRepository = overrides?.monitorStatsRepository ?? createMonitorStatsRepo();
const checksRepository = overrides?.checksRepository ?? createChecksRepo();
const service = new StatusService(logger as any, buffer as any, monitorsRepository, monitorStatsRepository, checksRepository);
return { service, logger, buffer, monitorsRepository, monitorStatsRepository, checksRepository };
};
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
userId: "user-1",
teamId: "team-1",
name: "Test Monitor",
type: "http",
url: "https://example.com",
isActive: true,
interval: 60000,
status: "up",
statusWindow: [],
statusWindowSize: 5,
statusWindowThreshold: 80,
recentChecks: [],
cpuAlertThreshold: 80,
memoryAlertThreshold: 80,
diskAlertThreshold: 80,
tempAlertThreshold: 80,
cpuAlertCounter: 5,
memoryAlertCounter: 5,
diskAlertCounter: 5,
tempAlertCounter: 5,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
}) as Monitor;
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;
const makeCheck = (overrides?: Partial<Check>): Check =>
({
id: "check-1",
metadata: { monitorId: "mon-1", teamId: "team-1" },
status: true,
responseTime: 100,
statusCode: 200,
message: "OK",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
}) as Check;
const makeExistingStats = (overrides?: Record<string, unknown>) => ({
id: "stats-1",
monitorId: "mon-1",
avgResponseTime: 100,
maxResponseTime: 200,
totalChecks: 10,
totalUpChecks: 9,
totalDownChecks: 1,
uptimePercentage: 0.9,
lastResponseTime: 90,
lastCheckTimestamp: 1000,
timeOfLastFailure: 500,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("StatusService", () => {
describe("serviceName", () => {
it("returns StatusService", () => {
const { service } = createService();
expect(service.serviceName).toBe("StatusService");
});
});
// ── updateRunningStats ───────────────────────────────────────────────────
describe("updateRunningStats", () => {
it("creates new stats when none exist", async () => {
const { service, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockRejectedValue(new Error("not found"));
(monitorStatsRepository.create as jest.Mock).mockResolvedValue({});
const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 50 }));
expect(result).toBe(true);
expect(monitorStatsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
monitorId: "mon-1",
totalChecks: 1,
totalUpChecks: 1,
avgResponseTime: 50,
lastResponseTime: 50,
})
);
});
it("updates existing stats for a successful check", async () => {
const { service, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats());
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 110 }));
expect(result).toBe(true);
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith(
"mon-1",
expect.objectContaining({
totalChecks: 11,
totalUpChecks: 10,
totalDownChecks: 1,
})
);
});
it("increments totalDownChecks and resets timeOfLastFailure on failure", async () => {
const { service, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats());
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ status: false, responseTime: 100 }));
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith(
"mon-1",
expect.objectContaining({
totalDownChecks: 2,
timeOfLastFailure: 0,
})
);
});
it("sets timeOfLastFailure when status is up and it was previously 0", async () => {
const { service, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ timeOfLastFailure: 0 }));
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ status: true }));
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith(
"mon-1",
expect.objectContaining({
timeOfLastFailure: expect.any(Number),
})
);
const call = (monitorStatsRepository.updateByMonitorId as jest.Mock).mock.calls[0] as [string, Record<string, unknown>];
expect(call[1].timeOfLastFailure).toBeGreaterThan(0);
});
it("updates maxResponseTime when new response is higher", async () => {
const { service, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ maxResponseTime: 200 }));
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 300 }));
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ maxResponseTime: 300 }));
});
it("does not update maxResponseTime when new response is lower", async () => {
const { service, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ maxResponseTime: 200 }));
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 50 }));
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ maxResponseTime: 200 }));
});
it("initializes avgResponseTime when previous avg was 0", async () => {
const { service, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ avgResponseTime: 0 }));
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 75 }));
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ avgResponseTime: 75 }));
});
it("computes running average when avgResponseTime is nonzero", async () => {
const { service, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ avgResponseTime: 100, totalChecks: 10 }));
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 210 }));
const call = (monitorStatsRepository.updateByMonitorId as jest.Mock).mock.calls[0] as [string, Record<string, unknown>];
// (100 * 10 + 210) / 11 = 110
expect(call[1].avgResponseTime).toBe(110);
});
it("leaves avgResponseTime unchanged when responseTime is undefined", async () => {
const { service, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ avgResponseTime: 100 }));
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: undefined }));
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ avgResponseTime: 100 }));
});
it("handles responseTime of 0 (falsy but defined)", async () => {
const { service, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ avgResponseTime: 100, maxResponseTime: 200 }));
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 0 }));
const call = (monitorStatsRepository.updateByMonitorId as jest.Mock).mock.calls[0] as [string, Record<string, unknown>];
expect(call[1].lastResponseTime).toBe(0);
// responseTime 0 is falsy, so maxResponseTime stays unchanged
expect(call[1].maxResponseTime).toBe(200);
});
it("returns false and logs error when an exception is thrown", async () => {
const { service, logger, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats());
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockRejectedValue(new Error("db write failed"));
const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse());
expect(result).toBe(false);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
service: "StatusService",
method: "updateRunningStats",
message: "db write failed",
})
);
});
it("logs error with 'Unknown error' for non-Error exceptions", async () => {
const { service, logger, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats());
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockRejectedValue("string error");
const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse());
expect(result).toBe(false);
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
it("creates new stats when findByMonitorId resolves to null", async () => {
const { service, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(null);
(monitorStatsRepository.create as jest.Mock).mockResolvedValue({});
const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 50, status: true }));
expect(result).toBe(true);
expect(monitorStatsRepository.create).toHaveBeenCalled();
});
});
// ── updateMonitorStatus ──────────────────────────────────────────────────
describe("updateMonitorStatus", () => {
it("returns early with no status change when statusWindow is not full", async () => {
const monitor = makeMonitor({ statusWindow: [], statusWindowSize: 5, status: "up" });
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck());
expect(result.statusChanged).toBe(false);
expect(result.prevStatus).toBe("up");
});
it("pushes to statusWindow and trims to statusWindowSize", async () => {
const monitor = makeMonitor({ statusWindow: [true, true, true, true, true], statusWindowSize: 5 });
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck());
// Window should have shifted: [true, true, true, true, false]
expect(monitor.statusWindow).toHaveLength(5);
expect(monitor.statusWindow[4]).toBe(false);
});
it("pushes check snapshot to recentChecks and trims to 25", async () => {
const existingChecks = Array.from({ length: 25 }, (_, i) => ({ id: `old-${i}` }));
const monitor = makeMonitor({ recentChecks: existingChecks as any, statusWindow: [], statusWindowSize: 5 });
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
await service.updateMonitorStatus(makeStatusResponse(), makeCheck({ id: "new-check" }));
expect(monitor.recentChecks).toHaveLength(25);
expect(monitor.recentChecks[24]).toEqual(expect.objectContaining({ id: "new-check" }));
});
it("marks status as down when failure threshold is met", async () => {
// 5/5 failures = 100% >= 80% threshold
const monitor = makeMonitor({
statusWindow: [false, false, false, false],
statusWindowSize: 5,
statusWindowThreshold: 80,
status: "up",
});
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }));
expect(result.statusChanged).toBe(true);
expect(result.monitor.status).toBe("down");
});
it("recovers to up when failure rate drops below threshold", async () => {
// 1/5 failures = 20% < 80% threshold, and monitor was down
const monitor = makeMonitor({
statusWindow: [true, true, true, false],
statusWindowSize: 5,
statusWindowThreshold: 80,
status: "down",
});
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true }));
expect(result.statusChanged).toBe(true);
expect(result.monitor.status).toBe("up");
});
it("does not change status when already up and below threshold", async () => {
const monitor = makeMonitor({
statusWindow: [true, true, true, true],
statusWindowSize: 5,
statusWindowThreshold: 80,
status: "up",
});
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck());
expect(result.statusChanged).toBe(false);
expect(result.monitor.status).toBe("up");
});
it("does not change status when already down and still above threshold", async () => {
const monitor = makeMonitor({
statusWindow: [false, false, false, false],
statusWindowSize: 5,
statusWindowThreshold: 80,
status: "down",
});
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }));
expect(result.statusChanged).toBe(false);
expect(result.monitor.status).toBe("down");
});
it("initializes statusWindow when undefined", async () => {
const monitor = makeMonitor({ statusWindow: undefined as any, statusWindowSize: 5 });
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
await service.updateMonitorStatus(makeStatusResponse(), makeCheck());
expect(monitor.statusWindow).toEqual([true]);
});
it("initializes recentChecks when undefined", async () => {
const monitor = makeMonitor({ recentChecks: undefined as any, statusWindowSize: 5 });
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
await service.updateMonitorStatus(makeStatusResponse(), makeCheck());
expect(monitor.recentChecks).toHaveLength(1);
});
it("throws AppError when repository throws", async () => {
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockRejectedValue(new Error("db error"));
await expect(service.updateMonitorStatus(makeStatusResponse(), makeCheck())).rejects.toThrow("Failed to update monitor");
});
it("throws AppError with 'Unknown error' for non-Error exceptions", async () => {
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockRejectedValue("string error");
await expect(service.updateMonitorStatus(makeStatusResponse(), makeCheck())).rejects.toThrow("Unknown error");
});
it("returns code and timestamp in result", async () => {
const monitor = makeMonitor({ statusWindow: [true, true, true, true], statusWindowSize: 5 });
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ code: 201 }), makeCheck());
expect(result.code).toBe(201);
expect(result.timestamp).toBeGreaterThan(0);
});
// ── Hardware threshold breach tests ──────────────────────────────────
describe("hardware threshold breaches", () => {
const makeHardwareMonitor = (overrides?: Partial<Monitor>) =>
makeMonitor({
type: "hardware",
statusWindow: [true, true, true, true],
statusWindowSize: 5,
status: "up",
cpuAlertThreshold: 80,
memoryAlertThreshold: 80,
diskAlertThreshold: 80,
tempAlertThreshold: 80,
cpuAlertCounter: 5,
memoryAlertCounter: 5,
diskAlertCounter: 5,
tempAlertCounter: 5,
...overrides,
});
const makeHardwareResponse = (payload: HardwareStatusPayload, overrides?: Partial<MonitorStatusResponse<HardwareStatusPayload>>) =>
makeStatusResponse({
type: "hardware",
status: true,
payload,
...overrides,
} as any) as MonitorStatusResponse<HardwareStatusPayload>;
it("detects CPU threshold breach and decrements counter", async () => {
const monitor = makeHardwareMonitor({ cpuAlertCounter: 1 });
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.5 }, disk: [], host: {} } } as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.cpu).toBe(true);
expect(result.thresholdBreaches?.memory).toBe(false);
expect(monitor.cpuAlertCounter).toBe(0);
expect(result.statusChanged).toBe(true);
expect(result.monitor.status).toBe("breached");
});
it("detects memory threshold breach", async () => {
const monitor = makeHardwareMonitor({ memoryAlertCounter: 1 });
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.5 }, memory: { usage_percent: 0.9 }, disk: [], host: {} } } as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.memory).toBe(true);
expect(monitor.memoryAlertCounter).toBe(0);
expect(result.monitor.status).toBe("breached");
});
it("detects disk threshold breach", async () => {
const monitor = makeHardwareMonitor({ diskAlertCounter: 1 });
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.95 }], host: {} },
} as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.disk).toBe(true);
expect(monitor.diskAlertCounter).toBe(0);
expect(result.monitor.status).toBe("breached");
});
it("detects temperature threshold breach", async () => {
const monitor = makeHardwareMonitor({ tempAlertCounter: 1 });
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
data: { cpu: { usage_percent: 0.1, temperature: [90] }, memory: { usage_percent: 0.1 }, disk: [], host: {} },
} as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.temp).toBe(true);
expect(monitor.tempAlertCounter).toBe(0);
expect(result.monitor.status).toBe("breached");
});
it("resets counters to 5 when thresholds are not breached", async () => {
const monitor = makeHardwareMonitor({
cpuAlertCounter: 2,
memoryAlertCounter: 2,
diskAlertCounter: 2,
tempAlertCounter: 2,
});
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
data: { cpu: { usage_percent: 0.1, temperature: [30] }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.1 }], host: {} },
} as any);
await service.updateMonitorStatus(response, makeCheck());
expect(monitor.cpuAlertCounter).toBe(5);
expect(monitor.memoryAlertCounter).toBe(5);
expect(monitor.diskAlertCounter).toBe(5);
expect(monitor.tempAlertCounter).toBe(5);
});
it("stays breached without statusChanged when already breached and counter still at 0", async () => {
const monitor = makeHardwareMonitor({
status: "breached",
cpuAlertCounter: 1,
});
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} } } as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.monitor.status).toBe("breached");
expect(result.statusChanged).toBe(false);
});
it("recovers from breached to up when all thresholds return to normal", async () => {
const monitor = makeHardwareMonitor({ status: "breached" });
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
data: { cpu: { usage_percent: 0.1, temperature: [30] }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.1 }], host: {} },
} as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.statusChanged).toBe(true);
expect(result.monitor.status).toBe("up");
});
it("does not override down status with breached", async () => {
// Monitor is down due to failure threshold, hardware breaches should not override
const monitor = makeHardwareMonitor({
statusWindow: [false, false, false, false],
statusWindowSize: 5,
statusWindowThreshold: 80,
status: "up",
cpuAlertCounter: 1,
});
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
status: false,
data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} },
} as any);
const result = await service.updateMonitorStatus(response, makeCheck({ status: false }));
expect(result.monitor.status).toBe("down");
});
it("handles missing cpu usage_percent (returns -1)", async () => {
const monitor = makeHardwareMonitor();
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ data: { cpu: {}, memory: { usage_percent: 0.1 }, disk: [], host: {} } } as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.cpu).toBe(false);
});
it("handles missing memory usage_percent", async () => {
const monitor = makeHardwareMonitor();
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.1 }, memory: {}, disk: [], host: {} } } as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.memory).toBe(false);
});
it("handles empty temperature array", async () => {
const monitor = makeHardwareMonitor();
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
data: { cpu: { usage_percent: 0.1, temperature: [] }, memory: { usage_percent: 0.1 }, disk: [], host: {} },
} as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.temp).toBe(false);
});
it("handles undefined temperature", async () => {
const monitor = makeHardwareMonitor();
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [], host: {} },
} as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.temp).toBe(false);
});
it("skips threshold evaluation when payload is undefined", async () => {
const monitor = makeHardwareMonitor();
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse(undefined as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches).toBeUndefined();
});
it("skips threshold evaluation when payload.data is undefined", async () => {
const monitor = makeHardwareMonitor();
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({} as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches).toBeUndefined();
});
it("does not set thresholdBreaches for non-hardware monitors", async () => {
const monitor = makeMonitor({
type: "http",
statusWindow: [true, true, true, true],
statusWindowSize: 5,
});
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse(), makeCheck());
expect(result.thresholdBreaches).toBeUndefined();
});
it("handles null entry in disk array", async () => {
const monitor = makeHardwareMonitor();
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [null as any], host: {} },
} as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.disk).toBe(false);
});
it("handles disk with undefined usage_percent entries", async () => {
const monitor = makeHardwareMonitor();
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [{ device: "/dev/sda" }], host: {} },
} as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.disk).toBe(false);
});
it("handles nullish cpu in metrics", async () => {
const monitor = makeHardwareMonitor();
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
data: { cpu: undefined, memory: { usage_percent: 0.1 }, disk: [], host: {} },
} as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.cpu).toBe(false);
expect(result.thresholdBreaches?.temp).toBe(false);
});
it("handles nullish memory in metrics", async () => {
const monitor = makeHardwareMonitor();
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
data: { cpu: { usage_percent: 0.1 }, memory: undefined, disk: [], host: {} },
} as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.memory).toBe(false);
});
it("handles nullish disk in metrics", async () => {
const monitor = makeHardwareMonitor();
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: undefined, host: {} },
} as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.disk).toBe(false);
});
it("counters do not go below 0", async () => {
const monitor = makeHardwareMonitor({
cpuAlertCounter: 0,
memoryAlertCounter: 0,
diskAlertCounter: 0,
tempAlertCounter: 0,
});
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
data: { cpu: { usage_percent: 0.9, temperature: [90] }, memory: { usage_percent: 0.9 }, disk: [{ usage_percent: 0.95 }], host: {} },
} as any);
await service.updateMonitorStatus(response, makeCheck());
expect(monitor.cpuAlertCounter).toBe(0);
expect(monitor.memoryAlertCounter).toBe(0);
expect(monitor.diskAlertCounter).toBe(0);
expect(monitor.tempAlertCounter).toBe(0);
});
});
});
});
@@ -0,0 +1,554 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { Monitor } from "../../../src/types/monitor.ts";
// ── Mock Scheduler ───────────────────────────────────────────────────────────
const createScheduler = () => {
const listeners: Record<string, Function[]> = {};
return {
on: jest.fn((event: string, cb: Function) => {
listeners[event] = listeners[event] || [];
listeners[event].push(cb);
}),
emit: (event: string, ...args: unknown[]) => {
(listeners[event] || []).forEach((cb) => cb(...args));
},
start: jest.fn(),
stop: jest.fn().mockResolvedValue(true),
addTemplate: jest.fn(),
addJob: jest.fn(),
removeJob: jest.fn(),
getJob: jest.fn().mockResolvedValue(null),
getJobs: jest.fn().mockResolvedValue([]),
pauseJob: jest.fn().mockResolvedValue(true),
resumeJob: jest.fn().mockResolvedValue(true),
updateJob: jest.fn(),
flushJobs: jest.fn().mockResolvedValue(true),
};
};
const mockSchedulerInstance = createScheduler();
const MockScheduler = jest.fn().mockReturnValue(mockSchedulerInstance);
jest.unstable_mockModule("super-simple-scheduler", () => ({
default: MockScheduler,
}));
const { SuperSimpleQueue } = await import("../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts");
const createQueueHelper = () => ({
getHeartbeatJob: jest.fn().mockReturnValue(() => Promise.resolve()),
getHeartbeatGeoJob: jest.fn().mockReturnValue(() => Promise.resolve()),
getCleanupOrphanedJob: jest.fn().mockReturnValue(() => Promise.resolve()),
getCleanupRetentionJob: jest.fn().mockReturnValue(() => Promise.resolve()),
});
const createMonitorsRepo = () => ({
findAll: jest.fn().mockResolvedValue([]),
});
const createQueue = (overrides?: Record<string, unknown>) => {
const logger = createMockLogger();
const helper = createQueueHelper();
const monitorsRepository = createMonitorsRepo();
const scheduler = createScheduler();
const defaults = { logger, helper, monitorsRepository, scheduler, ...overrides };
const queue = new SuperSimpleQueue(defaults.logger as any, defaults.helper as any, defaults.monitorsRepository as any, defaults.scheduler as any);
return { queue, ...defaults };
};
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "http",
interval: 60000,
isActive: true,
geoCheckEnabled: false,
geoCheckInterval: 300000,
geoCheckLocations: [],
...overrides,
}) as Monitor;
// ── Tests ────────────────────────────────────────────────────────────────────
describe("SuperSimpleQueue", () => {
describe("serviceName", () => {
it("returns JobQueue", () => {
const { queue } = createQueue();
expect(queue.serviceName).toBe("JobQueue");
});
});
// ── init ─────────────────────────────────────────────────────────────────
describe("init", () => {
it("registers listeners, starts scheduler, adds templates and system jobs", async () => {
const { queue, scheduler, helper } = createQueue();
const result = await queue.init();
expect(result).toBe(true);
expect(scheduler.start).toHaveBeenCalled();
expect(scheduler.addTemplate).toHaveBeenCalledWith("monitor-job", expect.any(Function));
expect(scheduler.addTemplate).toHaveBeenCalledWith("geo-check-job", expect.any(Function));
expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-orphaned", expect.any(Function));
expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-retention-job", expect.any(Function));
expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-orphaned" }));
expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-retention" }));
});
it("schedules existing monitors with staggered offsets", async () => {
jest.useFakeTimers();
const monitors = [makeMonitor({ id: "m1" }), makeMonitor({ id: "m2" })];
const { queue, scheduler, monitorsRepository } = createQueue();
(monitorsRepository.findAll as jest.Mock).mockResolvedValue(monitors);
await queue.init();
jest.runAllTimers();
// Each monitor gets addJob called (once for the monitor itself via addJob method)
// The init schedules via setTimeout → addJob, plus the two system jobs
expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "m1", template: "monitor-job" }));
expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "m2", template: "monitor-job" }));
jest.useRealTimers();
});
it("returns true when findAll returns null", async () => {
const { queue, monitorsRepository, scheduler } = createQueue();
(monitorsRepository.findAll as jest.Mock).mockResolvedValue(null);
const result = await queue.init();
expect(result).toBe(true);
// System jobs should not be added when monitors is null (early return)
expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-orphaned" }));
});
it("returns false and logs error on failure", async () => {
const { queue, logger, monitorsRepository } = createQueue();
(monitorsRepository.findAll as jest.Mock).mockRejectedValue(new Error("db down"));
const result = await queue.init();
expect(result).toBe(false);
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("db down") }));
});
it("logs error with String(error) for non-Error exceptions", async () => {
const { queue, logger, monitorsRepository } = createQueue();
(monitorsRepository.findAll as jest.Mock).mockRejectedValue("string error");
const result = await queue.init();
expect(result).toBe(false);
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("string error") }));
});
});
// ── registerListeners ────────────────────────────────────────────────────
describe("registerListeners (via init)", () => {
it("logs on scheduler:start", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.init();
scheduler.emit("scheduler:start");
expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler started" }));
});
it("logs on scheduler:stop", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.init();
scheduler.emit("scheduler:stop");
expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler stopped" }));
});
it("logs on scheduler:error with Error", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.init();
scheduler.emit("scheduler:error", new Error("boom"));
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler error: boom" }));
});
it("logs on scheduler:error with non-Error", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.init();
scheduler.emit("scheduler:error", "string error");
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler error: string error", stack: undefined }));
});
it("logs on job:abort", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.init();
scheduler.emit("job:abort", { id: "m1" }, "timeout");
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 aborted: timeout" }));
});
it("logs on job:attempt", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.init();
scheduler.emit("job:attempt", { id: "m1" }, 2);
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 attempt 2" }));
});
it("logs on job:complete", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.init();
scheduler.emit("job:complete", { id: "m1" });
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 completed successfully" }));
});
it("logs on job:exhausted with Error", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.init();
scheduler.emit("job:exhausted", { id: "m1" }, new Error("gave up"));
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 exhausted all retries: gave up" }));
});
it("logs on job:exhausted with non-Error", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.init();
scheduler.emit("job:exhausted", { id: "m1" }, "gave up");
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 exhausted all retries: gave up", stack: undefined }));
});
it("logs on job:fail with Error", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.init();
scheduler.emit("job:fail", { id: "m1" }, new Error("oops"), 1);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 failed on attempt 1: oops" }));
});
it("logs on job:fail with non-Error", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.init();
scheduler.emit("job:fail", { id: "m1" }, "oops", 1);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 failed on attempt 1: oops", stack: undefined }));
});
it("logs on job:start", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.init();
scheduler.emit("job:start", { id: "m1" });
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 started" }));
});
});
// ── addJob ───────────────────────────────────────────────────────────────
describe("addJob", () => {
it("adds monitor job to scheduler", async () => {
const { queue, scheduler } = createQueue();
await queue.addJob("mon-1", makeMonitor());
expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1", template: "monitor-job", repeat: 60000, active: true }));
});
it("adds geo check job when geoCheckEnabled and type supports it", async () => {
const { queue, scheduler } = createQueue();
await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 }));
expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1-geo", template: "geo-check-job", repeat: 300000 }));
});
it("skips geo job when type does not support geo checks", async () => {
const { queue, scheduler } = createQueue();
await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: true, type: "hardware" }));
expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1-geo" }));
});
it("skips geo job when geoCheckEnabled is false", async () => {
const { queue, scheduler } = createQueue();
await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: false, type: "http" }));
expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1-geo" }));
});
});
// ── deleteJob ────────────────────────────────────────────────────────────
describe("deleteJob", () => {
it("removes monitor job", async () => {
const { queue, scheduler } = createQueue();
await queue.deleteJob(makeMonitor());
expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1");
});
it("removes geo job if it exists", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
await queue.deleteJob(makeMonitor());
expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo");
});
it("does not remove geo job if it does not exist", async () => {
const { queue, scheduler } = createQueue();
await queue.deleteJob(makeMonitor());
expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo");
});
});
// ── pauseJob ─────────────────────────────────────────────────────────────
describe("pauseJob", () => {
it("pauses monitor and removes geo job if it exists", async () => {
const { queue, scheduler, logger } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
await queue.pauseJob(makeMonitor());
expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1");
expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo");
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Paused monitor mon-1" }));
});
it("does not remove geo job if it does not exist", async () => {
const { queue, scheduler } = createQueue();
await queue.pauseJob(makeMonitor());
expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo");
});
it("throws when scheduler.pauseJob returns false", async () => {
const { queue, scheduler } = createQueue();
(scheduler.pauseJob as jest.Mock).mockResolvedValue(false);
await expect(queue.pauseJob(makeMonitor())).rejects.toThrow("Failed to pause monitor");
});
});
// ── resumeJob ────────────────────────────────────────────────────────────
describe("resumeJob", () => {
it("resumes monitor and geo job", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.resumeJob(makeMonitor());
expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1");
expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1-geo");
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Resumed monitor mon-1" }));
});
it("throws when scheduler.resumeJob returns false", async () => {
const { queue, scheduler } = createQueue();
(scheduler.resumeJob as jest.Mock).mockResolvedValue(false);
await expect(queue.resumeJob(makeMonitor())).rejects.toThrow("Failed to resume monitor");
});
});
// ── updateJob ────────────────────────────────────────────────────────────
describe("updateJob", () => {
it("updates monitor job and syncs geo job", async () => {
const { queue, scheduler } = createQueue();
const monitor = makeMonitor({ interval: 120000 });
await queue.updateJob(monitor);
expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", expect.objectContaining({ repeat: 120000, data: monitor }));
});
});
// ── syncGeoJob (via updateJob) ───────────────────────────────────────────
describe("syncGeoJob (via updateJob)", () => {
it("removes existing geo job when geoCheckEnabled is false", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
await queue.updateJob(makeMonitor({ geoCheckEnabled: false, type: "http" }));
expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo");
});
it("removes existing geo job when type does not support geo checks", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
await queue.updateJob(makeMonitor({ geoCheckEnabled: true, type: "hardware" }));
expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo");
});
it("does not remove when geo disabled and no existing geo job", async () => {
const { queue, scheduler } = createQueue();
await queue.updateJob(makeMonitor({ geoCheckEnabled: false }));
expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo");
});
it("updates existing geo job when geoCheckEnabled", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
const monitor = makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 });
await queue.updateJob(monitor);
expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1-geo", expect.objectContaining({ repeat: 300000 }));
});
it("creates new geo job when enabled but no existing job", async () => {
const { queue, scheduler } = createQueue();
const monitor = makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 });
await queue.updateJob(monitor);
expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1-geo", template: "geo-check-job" }));
});
});
// ── shutdown ──────────────────────────────────────────────────────────────
describe("shutdown", () => {
it("stops the scheduler", async () => {
const { queue, scheduler } = createQueue();
await queue.shutdown();
expect(scheduler.stop).toHaveBeenCalled();
});
});
// ── getMetrics ───────────────────────────────────────────────────────────
describe("getMetrics", () => {
it("returns empty metrics when no jobs", async () => {
const { queue } = createQueue();
const metrics = await queue.getMetrics();
expect(metrics).toEqual({ jobs: 0, activeJobs: 0, failingJobs: 0, jobsWithFailures: [], totalRuns: 0, totalFailures: 0 });
});
it("aggregates metrics from jobs", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJobs as jest.Mock).mockResolvedValue([
{ id: "m1", runCount: 10, failCount: 2, lastFailedAt: 200, lastRunAt: 100, lockedAt: null, data: { url: "http://a.com", type: "http" } },
{ id: "m2", runCount: 5, failCount: 0, lastFailedAt: 0, lastRunAt: 50, lockedAt: 999, data: { url: "http://b.com", type: "ping" } },
]);
const metrics = await queue.getMetrics();
expect(metrics.jobs).toBe(2);
expect(metrics.totalRuns).toBe(15);
expect(metrics.totalFailures).toBe(2);
expect(metrics.activeJobs).toBe(1);
expect(metrics.failingJobs).toBe(1);
expect(metrics.jobsWithFailures).toHaveLength(1);
expect(metrics.jobsWithFailures[0]).toEqual(expect.objectContaining({ monitorId: "m1", failCount: 2, monitorUrl: "http://a.com" }));
});
it("handles jobs with undefined fields", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJobs as jest.Mock).mockResolvedValue([{ id: "m1", data: null }]);
const metrics = await queue.getMetrics();
expect(metrics.jobs).toBe(1);
expect(metrics.totalRuns).toBe(0);
});
it("handles failing job with null data", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJobs as jest.Mock).mockResolvedValue([
{ id: "m1", runCount: 1, failCount: 1, lastFailedAt: 100, lastRunAt: 50, data: null, lastFailReason: "timeout" },
]);
const metrics = await queue.getMetrics();
expect(metrics.jobsWithFailures).toHaveLength(1);
expect(metrics.jobsWithFailures[0].monitorUrl).toBeNull();
expect(metrics.jobsWithFailures[0].monitorType).toBeNull();
expect(metrics.jobsWithFailures[0].failReason).toBe("timeout");
});
it("handles failing job with undefined lastFailedAt and lastFailReason", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJobs as jest.Mock).mockResolvedValue([{ id: "m1", runCount: 1, failCount: 1, data: { url: "http://a.com", type: "http" } }]);
const metrics = await queue.getMetrics();
expect(metrics.jobsWithFailures[0].failedAt).toBeNull();
expect(metrics.jobsWithFailures[0].failReason).toBeNull();
});
it("does not count as failing when lastFailedAt < lastRunAt", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJobs as jest.Mock).mockResolvedValue([
{ id: "m1", runCount: 10, failCount: 1, lastFailedAt: 50, lastRunAt: 100, data: { url: "a" } },
]);
const metrics = await queue.getMetrics();
expect(metrics.failingJobs).toBe(0);
expect(metrics.jobsWithFailures).toHaveLength(1);
});
});
// ── getJobs ──────────────────────────────────────────────────────────────
describe("getJobs", () => {
it("maps scheduler jobs to summaries", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJobs as jest.Mock).mockResolvedValue([
{
id: "m1",
active: true,
lockedAt: null,
runCount: 5,
failCount: 1,
lastFailReason: "timeout",
lastRunAt: 1000,
lastFinishedAt: 1100,
lastFailedAt: 900,
data: { url: "http://a.com", type: "http", interval: 60000 },
},
]);
const jobs = await queue.getJobs();
expect(jobs).toHaveLength(1);
expect(jobs[0]).toEqual({
monitorId: "m1",
monitorUrl: "http://a.com",
monitorType: "http",
monitorInterval: 60000,
active: true,
lockedAt: null,
runCount: 5,
failCount: 1,
failReason: "timeout",
lastRunAt: 1000,
lastFinishedAt: 1100,
lastRunTook: 100,
lastFailedAt: 900,
});
});
it("returns null lastRunTook when job is locked", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJobs as jest.Mock).mockResolvedValue([
{ id: "m1", active: true, lockedAt: 999, runCount: 1, data: { url: "a", type: "http", interval: 60000 } },
]);
const jobs = await queue.getJobs();
expect(jobs[0].lastRunTook).toBeNull();
});
it("handles jobs with missing data", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJobs as jest.Mock).mockResolvedValue([{ id: "m1", active: true, data: null }]);
const jobs = await queue.getJobs();
expect(jobs[0].monitorUrl).toBeNull();
expect(jobs[0].monitorType).toBeNull();
expect(jobs[0].monitorInterval).toBeNull();
});
});
// ── flushQueues ──────────────────────────────────────────────────────────
describe("flushQueues", () => {
it("stops, flushes, and reinitializes", async () => {
const { queue, scheduler } = createQueue();
const result = await queue.flushQueues();
expect(scheduler.stop).toHaveBeenCalled();
expect(scheduler.flushJobs).toHaveBeenCalled();
expect(result.success).toBe(true);
});
it("returns false when any step fails", async () => {
const { queue, scheduler } = createQueue();
(scheduler.flushJobs as jest.Mock).mockResolvedValue(false);
const result = await queue.flushQueues();
expect(result.success).toBe(false);
});
});
// ── static create ────────────────────────────────────────────────────────
describe("static create", () => {
it("creates a SuperSimpleQueue instance with a Scheduler and calls init", async () => {
const logger = createMockLogger();
const helper = createQueueHelper();
const monitorsRepository = createMonitorsRepo();
const instance = await SuperSimpleQueue.create(logger as any, helper as any, monitorsRepository as any);
expect(MockScheduler).toHaveBeenCalled();
expect(instance).toBeInstanceOf(SuperSimpleQueue);
// init was called, which calls scheduler.start
expect(mockSchedulerInstance.start).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,718 @@
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";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createHelper = (overrides?: Record<string, unknown>) => {
const maintenanceWindowsRepository = {
findByMonitorId: jest.fn().mockResolvedValue([]),
};
const monitorsRepository = {
updateById: jest.fn().mockResolvedValue({}),
findAllMonitorIds: jest.fn().mockResolvedValue(["m1"]),
deleteByTeamIdsNotIn: jest.fn().mockResolvedValue(0),
};
const teamsRepository = {
findAllTeamIds: jest.fn().mockResolvedValue(["team"]),
};
const monitorStatsRepository = {
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
};
const checksRepository = {
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
};
const incidentsRepository = {
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
};
const geoChecksRepository = {
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
};
const statusServiceMock = {
updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1", status: "up" }, statusChanged: false, prevStatus: "up", code: 200 }),
};
const settingsServiceMock = {
getDBSettings: jest.fn().mockResolvedValue({ checkTTL: 30 }),
};
const geoChecksServiceMock = {
buildGeoCheck: jest.fn().mockResolvedValue(null),
};
const defaults = {
logger: createMockLogger(),
networkService: { requestStatus: jest.fn() },
statusService: statusServiceMock,
notificationsService: { handleNotifications: jest.fn().mockResolvedValue(undefined) },
checkService: { buildCheck: jest.fn().mockReturnValue({ id: "check-1" }), deleteOlderThan: jest.fn().mockResolvedValue(0) },
settingsService: settingsServiceMock,
buffer: { addToBuffer: jest.fn(), addGeoCheckToBuffer: jest.fn() },
incidentService: { handleIncident: jest.fn().mockResolvedValue(undefined) },
maintenanceWindowsRepository,
monitorsRepository,
teamsRepository,
monitorStatsRepository,
checksRepository,
incidentsRepository,
geoChecksService: geoChecksServiceMock,
geoChecksRepository,
...overrides,
};
const helper = new SuperSimpleQueueHelper(
defaults.logger as any,
defaults.networkService as any,
defaults.statusService as any,
defaults.notificationsService as any,
defaults.checkService as any,
defaults.settingsService as any,
defaults.buffer as any,
defaults.incidentService as any,
defaults.maintenanceWindowsRepository as any,
defaults.monitorsRepository as any,
defaults.teamsRepository as any,
defaults.monitorStatsRepository as any,
defaults.checksRepository as any,
defaults.incidentsRepository as any,
defaults.geoChecksService as any,
defaults.geoChecksRepository as any
);
return { helper, defaults, maintenanceWindowsRepository };
};
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "m1",
teamId: "team",
type: "http",
interval: 60000,
status: "up",
geoCheckEnabled: false,
geoCheckLocations: [],
...overrides,
}) as Monitor;
// ── Tests ────────────────────────────────────────────────────────────────────
describe("SuperSimpleQueueHelper", () => {
describe("serviceName", () => {
it("returns JobQueueHelper", () => {
const { helper } = createHelper();
expect(helper.serviceName).toBe("JobQueueHelper");
});
});
// ── getHeartbeatJob ──────────────────────────────────────────────────────
describe("getHeartbeatJob", () => {
it("throws when monitor id is missing", async () => {
const { helper } = createHelper();
const job = helper.getHeartbeatJob();
await expect(job({} as Monitor)).rejects.toThrow("No monitor id");
});
it("skips execution and sets maintenance status when in maintenance window", async () => {
const { helper, defaults } = createHelper();
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true);
const job = helper.getHeartbeatJob();
await job(makeMonitor({ status: "up" }));
expect(defaults.networkService.requestStatus).not.toHaveBeenCalled();
expect(defaults.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { status: "maintenance" });
});
it("skips monitor status update when already in maintenance", async () => {
const { helper, defaults } = createHelper();
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true);
const job = helper.getHeartbeatJob();
await job(makeMonitor({ status: "maintenance" }));
expect(defaults.monitorsRepository.updateById).not.toHaveBeenCalled();
});
it("throws when network response is null", async () => {
const { helper } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(null) },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await expect(job(makeMonitor())).rejects.toThrow("No network response");
});
it("returns early and warns when buildCheck returns null", async () => {
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
checkService: { buildCheck: jest.fn().mockReturnValue(null), deleteOlderThan: jest.fn() },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No check could be built") }));
expect(defaults.statusService.updateMonitorStatus).not.toHaveBeenCalled();
});
it("processes full pipeline: network → check → status → incident", async () => {
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
expect(defaults.buffer.addToBuffer).toHaveBeenCalled();
expect(defaults.statusService.updateMonitorStatus).toHaveBeenCalled();
expect(defaults.incidentService.handleIncident).toHaveBeenCalled();
});
it("sends notifications when decision says shouldSendNotification", async () => {
const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" };
const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
expect(defaults.notificationsService.handleNotifications).toHaveBeenCalledWith(
statusResult.monitor,
networkResponse,
expect.objectContaining({ shouldSendNotification: true, shouldCreateIncident: true })
);
});
it("logs error when notifications fail (fire-and-forget)", async () => {
const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" };
const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 };
const notificationsService = { handleNotifications: jest.fn().mockRejectedValue(new Error("smtp down")) };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) },
notificationsService,
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
// Wait for the fire-and-forget catch to execute
await new Promise((r) => setTimeout(r, 10));
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("smtp down") }));
});
it("logs error when notifications fail with non-Error (fire-and-forget)", async () => {
const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" };
const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 };
const notificationsService = { handleNotifications: jest.fn().mockRejectedValue("string error") };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) },
notificationsService,
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
await new Promise((r) => setTimeout(r, 10));
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Unknown error") }));
});
it("logs warning when incident handling fails (fire-and-forget)", async () => {
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
const incidentService = { handleIncident: jest.fn().mockRejectedValue(new Error("db error")) };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
incidentService,
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
await new Promise((r) => setTimeout(r, 10));
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("db error") }));
});
it("logs warning when incident handling fails with non-Error", async () => {
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
const incidentService = { handleIncident: jest.fn().mockRejectedValue(42) };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
incidentService,
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
await new Promise((r) => setTimeout(r, 10));
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Unknown error") }));
});
it("logs and rethrows on unexpected error with non-Error", async () => {
const { helper } = createHelper({
networkService: { requestStatus: jest.fn().mockRejectedValue("unexpected") },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await expect(job(makeMonitor())).rejects.toBe("unexpected");
});
});
// ── getHeartbeatGeoJob ───────────────────────────────────────────────────
describe("getHeartbeatGeoJob", () => {
it("throws nothing when monitor id is missing (logs error)", async () => {
const { helper, defaults } = createHelper();
const job = helper.getHeartbeatGeoJob();
await job({} as Monitor);
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "No monitor id" }));
});
it("returns early when geoCheckEnabled is false", async () => {
const { helper, defaults } = createHelper();
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: false }));
expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled();
});
it("returns early when monitor type does not support geo checks", async () => {
const { helper, defaults } = createHelper();
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "hardware", geoCheckLocations: ["us-east"] }));
expect(defaults.logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("does not support geo checks") })
);
expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled();
});
it("returns early when geoCheckLocations is empty", async () => {
const { helper, defaults } = createHelper();
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: [] }));
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") }));
});
it("returns early when geoCheckLocations is undefined", async () => {
const { helper, defaults } = createHelper();
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: undefined as any }));
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") }));
});
it("skips when in maintenance window", async () => {
const { helper, defaults } = createHelper();
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true);
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("maintenance window") }));
expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled();
});
it("warns when buildGeoCheck returns null", async () => {
const { helper, defaults } = createHelper();
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check could be built") }));
});
it("adds geo check to buffer on success", async () => {
const geoCheck = { id: "gc-1", monitorId: "m1" };
const { helper, defaults } = createHelper({
geoChecksService: { buildGeoCheck: jest.fn().mockResolvedValue(geoCheck) },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
expect(defaults.buffer.addGeoCheckToBuffer).toHaveBeenCalledWith(geoCheck);
expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Geo check job executed") }));
});
it("logs error on unexpected failure without rethrowing", async () => {
const { helper, defaults } = createHelper({
geoChecksService: { buildGeoCheck: jest.fn().mockRejectedValue(new Error("api timeout")) },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "api timeout" }));
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { helper, defaults } = createHelper({
geoChecksService: { buildGeoCheck: jest.fn().mockRejectedValue("string error") },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
// ── getCleanupOrphanedJob ────────────────────────────────────────────────
describe("getCleanupOrphanedJob", () => {
it("cleans up orphaned data across all repositories", async () => {
const { helper, defaults } = createHelper();
(defaults.monitorsRepository.deleteByTeamIdsNotIn as jest.Mock).mockResolvedValue(2);
(defaults.monitorStatsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(3);
(defaults.checksRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(4);
(defaults.incidentsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(1);
(defaults.geoChecksRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(5);
const job = helper.getCleanupOrphanedJob();
await job();
expect(defaults.teamsRepository.findAllTeamIds).toHaveBeenCalled();
expect(defaults.monitorsRepository.deleteByTeamIdsNotIn).toHaveBeenCalledWith(["team"]);
expect(defaults.monitorsRepository.findAllMonitorIds).toHaveBeenCalled();
expect(defaults.monitorStatsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
expect(defaults.checksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
expect(defaults.incidentsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
expect(defaults.geoChecksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
// Logs info for each deleted count > 0
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("2 orphaned monitors") }));
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("3 orphaned monitor stats") }));
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("4 orphaned checks") }));
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("1 orphaned incidents") }));
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("5 orphaned geo checks") }));
});
it("skips info logs when no orphaned data is found", async () => {
const { helper, defaults } = createHelper();
const job = helper.getCleanupOrphanedJob();
await job();
// Only start and completion logs, no deletion logs
const infoMessages = (defaults.logger.info as jest.Mock).mock.calls.map((c: any) => c[0].message);
expect(infoMessages).toContain("Starting cleanup of orphaned data");
expect(infoMessages).toContain("Cleanup of orphaned data completed");
expect(infoMessages).not.toContain(expect.stringContaining("orphaned monitors"));
});
it("logs warning and rethrows on error", async () => {
const { helper, defaults } = createHelper();
(defaults.teamsRepository.findAllTeamIds as jest.Mock).mockRejectedValue(new Error("db down"));
const job = helper.getCleanupOrphanedJob();
await expect(job()).rejects.toThrow("db down");
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "db down" }));
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { helper, defaults } = createHelper();
(defaults.teamsRepository.findAllTeamIds as jest.Mock).mockRejectedValue(null);
const job = helper.getCleanupOrphanedJob();
await expect(job()).rejects.toBeNull();
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
// ── getCleanupRetentionJob ───────────────────────────────────────────────
describe("getCleanupRetentionJob", () => {
it("deletes checks older than TTL cutoff", async () => {
const { helper, defaults } = createHelper();
const job = helper.getCleanupRetentionJob();
await job();
expect(defaults.checkService.deleteOlderThan).toHaveBeenCalledWith(expect.any(Date));
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Deleted") }));
});
it("skips cleanup when checkTTL is sentinel value (366)", async () => {
const { helper, defaults } = createHelper({
settingsService: { getDBSettings: jest.fn().mockResolvedValue({ checkTTL: 366 }) },
});
const job = helper.getCleanupRetentionJob();
await job();
expect(defaults.checkService.deleteOlderThan).not.toHaveBeenCalled();
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("unlimited") }));
});
it("logs error on failure without rethrowing", async () => {
const { helper, defaults } = createHelper({
settingsService: { getDBSettings: jest.fn().mockRejectedValue(new Error("db error")) },
});
const job = helper.getCleanupRetentionJob();
await job(); // Should not throw
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "db error" }));
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { helper, defaults } = createHelper({
settingsService: { getDBSettings: jest.fn().mockRejectedValue("boom") },
});
const job = helper.getCleanupRetentionJob();
await job();
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
// ── isInMaintenanceWindow ────────────────────────────────────────────────
describe("isInMaintenanceWindow", () => {
it("returns true when an active window spans now", async () => {
const now = new Date();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now.getTime() - 1000).toISOString(),
end: new Date(now.getTime() + 1000).toISOString(),
repeat: 0,
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
});
it("returns true when repeat interval advances window into current time", async () => {
const now = Date.now();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now - 7200000).toISOString(),
end: new Date(now - 6600000).toISOString(),
repeat: 3600000,
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
});
it("returns false when no active windows exist", async () => {
const { helper } = createHelper();
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
});
it("returns false when window is inactive", async () => {
const now = new Date();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: false,
start: new Date(now.getTime() - 1000).toISOString(),
end: new Date(now.getTime() + 1000).toISOString(),
repeat: 0,
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
});
it("returns false when active window is in the past without repeat", async () => {
const now = Date.now();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now - 5000).toISOString(),
end: new Date(now - 1000).toISOString(),
repeat: 0,
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
});
it("returns false when repeat advances past current time without match", async () => {
const now = Date.now();
const { helper, maintenanceWindowsRepository } = createHelper();
// Window was 2 hours ago, lasts 10 minutes, repeats every hour
// First repeat: -1h to -50min (past), second repeat: now to +10min... actually this would match
// Use a repeat that skips over now
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now - 3600000 * 3 - 300000).toISOString(), // 3h5min ago
end: new Date(now - 3600000 * 3).toISOString(), // 3h ago, 5min window
repeat: 3600000, // 1 hour repeat
},
]);
// Advances: -2h5m to -2h, -1h5m to -1h, -5m to 0 — that last one might match if now is within 5min
// Let's use a different setup that clearly doesn't match
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now - 1800000 - 60000).toISOString(), // 31 min ago
end: new Date(now - 1800000).toISOString(), // 30 min ago, 1 min window
repeat: 3600000, // 1 hour repeat — next window would be in 29 min
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
});
it("preserves previous true result via accumulator", async () => {
const now = new Date();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now.getTime() - 1000).toISOString(),
end: new Date(now.getTime() + 1000).toISOString(),
repeat: 0,
},
{
active: false,
start: new Date(now.getTime() - 1000).toISOString(),
end: new Date(now.getTime() + 1000).toISOString(),
repeat: 0,
},
]);
// Second window is inactive but first already matched — accumulator should preserve true
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
});
});
// ── evaluateMonitorAction (tested indirectly via getHeartbeatJob) ─────────
describe("evaluateMonitorAction", () => {
const runJobAndGetDecision = async (statusResult: Record<string, unknown>) => {
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
const incidentService = { handleIncident: jest.fn().mockResolvedValue(undefined) };
const notificationsService = { handleNotifications: jest.fn().mockResolvedValue(undefined) };
const { helper } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) },
incidentService,
notificationsService,
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
return { incidentService, notificationsService };
};
it("does nothing when statusChanged is false", async () => {
const { incidentService, notificationsService } = await runJobAndGetDecision({
monitor: { id: "m1", status: "up" },
statusChanged: false,
prevStatus: "up",
code: 200,
});
expect(incidentService.handleIncident).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ shouldCreateIncident: false, shouldResolveIncident: false, shouldSendNotification: false }),
expect.anything()
);
});
it("creates incident and notifies when monitor goes down", async () => {
const { incidentService } = await runJobAndGetDecision({
monitor: { id: "m1", status: "down" },
statusChanged: true,
prevStatus: "up",
code: 500,
});
expect(incidentService.handleIncident).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
shouldCreateIncident: true,
shouldSendNotification: true,
incidentReason: "status_down",
notificationReason: "status_change",
}),
expect.anything()
);
});
it("creates incident and notifies when monitor is breached", async () => {
const { incidentService } = await runJobAndGetDecision({
monitor: { id: "m1", status: "breached" },
statusChanged: true,
prevStatus: "up",
code: 200,
});
expect(incidentService.handleIncident).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
shouldCreateIncident: true,
incidentReason: "threshold_breach",
notificationReason: "threshold_breach",
}),
expect.anything()
);
});
it("resolves incident when monitor recovers from down", async () => {
const { incidentService } = await runJobAndGetDecision({
monitor: { id: "m1", status: "up" },
statusChanged: true,
prevStatus: "down",
code: 200,
});
expect(incidentService.handleIncident).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ shouldResolveIncident: true, shouldSendNotification: true }),
expect.anything()
);
});
it("resolves incident when monitor recovers from breached", async () => {
const { incidentService } = await runJobAndGetDecision({
monitor: { id: "m1", status: "up" },
statusChanged: true,
prevStatus: "breached",
code: 200,
});
expect(incidentService.handleIncident).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ shouldResolveIncident: true }),
expect.anything()
);
});
it("does not create or resolve for unhandled status transitions", async () => {
const { incidentService } = await runJobAndGetDecision({
monitor: { id: "m1", status: "paused" },
statusChanged: true,
prevStatus: "up",
code: 200,
});
expect(incidentService.handleIncident).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ shouldCreateIncident: false, shouldResolveIncident: false, shouldSendNotification: false }),
expect.anything()
);
});
});
});
@@ -0,0 +1,629 @@
import { describe, expect, it, jest } from "@jest/globals";
import { UserService } from "../../../src/service/business/userService.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { User, UserRole } from "../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeUser = (overrides?: Partial<User>): User => ({
id: "user-1",
firstName: "Test",
lastName: "User",
email: "test@example.com",
password: "$2a$10$hashedpassword",
isActive: true,
isVerified: true,
role: ["user"] as UserRole[],
teamId: "team-1",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
});
const makeAppSettings = () => ({
jwtSecret: "test-secret",
jwtTTL: "99d" as const,
nodeEnv: "development",
logLevel: "debug",
clientHost: "http://localhost:5173",
dbConnectionString: "mongodb://localhost:27017/test_db",
dbType: "mongodb" as const,
});
const createService = (overrides?: Record<string, unknown>) => {
const logger = createMockLogger();
const usersRepository = {
create: jest.fn().mockResolvedValue(makeUser()),
findByEmail: jest.fn().mockResolvedValue(makeUser()),
findById: jest.fn().mockResolvedValue(makeUser()),
findAll: jest.fn().mockResolvedValue([makeUser()]),
updateById: jest.fn().mockResolvedValue(makeUser()),
deleteById: jest.fn().mockResolvedValue(makeUser()),
findSuperAdmin: jest.fn().mockResolvedValue(true),
};
const invitesRepository = {
findByTokenAndDelete: jest.fn().mockResolvedValue({ role: ["user"], teamId: "team-1", email: "invited@example.com" }),
};
const recoveryTokensRepository = {
create: jest.fn().mockResolvedValue({ token: "recovery-token-123", email: "test@example.com" }),
findByToken: jest.fn().mockResolvedValue({ token: "recovery-token-123", email: "test@example.com" }),
deleteManyByEmail: jest.fn().mockResolvedValue(1),
};
const settingsRepository = {
update: jest.fn().mockResolvedValue({}),
};
const teamsRepository = {
create: jest.fn().mockResolvedValue({ id: "team-new" }),
};
const monitorsRepository = {
findByTeamId: jest.fn().mockResolvedValue([{ id: "mon-1" }, { id: "mon-2" }]),
};
const emailService = {
buildEmail: jest.fn().mockResolvedValue("<html>Welcome</html>"),
sendEmail: jest.fn().mockResolvedValue("msg-id-123"),
};
const settingsService = {
getSettings: jest.fn().mockReturnValue(makeAppSettings()),
};
const jobQueue = {
deleteJob: jest.fn().mockResolvedValue(undefined),
};
const jwtMock = {
sign: jest.fn().mockReturnValue("jwt-token-123"),
};
const cryptoMock = {
randomBytes: jest.fn().mockReturnValue({ toString: () => "random-hex-secret" }),
};
const defaults = {
logger,
usersRepository,
invitesRepository,
recoveryTokensRepository,
settingsRepository,
teamsRepository,
monitorsRepository,
emailService,
settingsService,
jobQueue,
jwt: jwtMock,
crypto: cryptoMock,
...overrides,
};
const service = new UserService(defaults as any);
return { service, ...defaults };
};
// ── Tests ────────────────────────────────────────────────────────────────────
describe("UserService", () => {
describe("serviceName", () => {
it("returns userService", () => {
const { service } = createService();
expect(service.serviceName).toBe("userService");
});
});
// ── issueToken ──────────────────────────────────────────────────────────
describe("issueToken", () => {
it("signs a JWT with the payload and settings", () => {
const { service, jwt: jwtMock } = createService();
const user = makeUser();
const settings = makeAppSettings();
const token = service.issueToken(user, settings);
expect(token).toBe("jwt-token-123");
expect(jwtMock.sign).toHaveBeenCalledWith(user, "test-secret", { expiresIn: "99d" });
});
});
// ── registerUser ────────────────────────────────────────────────────────
describe("registerUser", () => {
it("registers an invited user when superadmin exists", async () => {
const { service, usersRepository, invitesRepository } = createService();
const result = await service.registerUser({ password: "password123" }, "invite-token", null);
expect(invitesRepository.findByTokenAndDelete).toHaveBeenCalledWith("invite-token");
expect(usersRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ role: ["user"], teamId: "team-1", email: "invited@example.com" }),
null
);
expect(result.token).toBe("jwt-token-123");
expect(result.user).toBeDefined();
});
it("creates first user as superadmin with new team and JWT secret", async () => {
const {
service,
usersRepository,
teamsRepository,
settingsRepository,
crypto: cryptoMock,
} = createService({
usersRepository: {
create: jest.fn().mockResolvedValue(makeUser({ role: ["superadmin"], teamId: "team-new" })),
findByEmail: jest.fn(),
findById: jest.fn(),
findAll: jest.fn(),
updateById: jest.fn(),
deleteById: jest.fn(),
findSuperAdmin: jest.fn().mockResolvedValue(false),
},
});
const result = await service.registerUser({ email: "first@example.com", password: "password123" }, "", null);
expect(cryptoMock.randomBytes).toHaveBeenCalledWith(64);
expect(settingsRepository.update).toHaveBeenCalledWith({ jwtSecret: "random-hex-secret" });
expect(teamsRepository.create).toHaveBeenCalledWith("first@example.com");
expect(result.user).toBeDefined();
});
it("throws when first user has no email", async () => {
const { service } = createService({
usersRepository: {
create: jest.fn(),
findByEmail: jest.fn(),
findById: jest.fn(),
findAll: jest.fn(),
updateById: jest.fn(),
deleteById: jest.fn(),
findSuperAdmin: jest.fn().mockResolvedValue(false),
},
});
await expect(service.registerUser({ password: "pass" }, "", null)).rejects.toThrow("Email is required for first user");
});
it("sends welcome email after registration", async () => {
const { service, emailService } = createService();
await service.registerUser({ password: "pass" }, "invite-token", null);
expect(emailService.buildEmail).toHaveBeenCalledWith("welcomeEmailTemplate", expect.objectContaining({ name: "Test" }));
expect(emailService.sendEmail).toHaveBeenCalledWith("test@example.com", "Welcome to Uptime Monitor", "<html>Welcome</html>");
});
it("logs warning when welcome email build returns null", async () => {
const { service, logger } = createService({
emailService: {
buildEmail: jest.fn().mockResolvedValue(null),
sendEmail: jest.fn(),
},
});
const result = await service.registerUser({ password: "pass" }, "invite-token", null);
expect(result.user).toBeDefined();
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "registerUser" }));
});
it("uses default role when invite has no role", async () => {
const { service, usersRepository } = createService({
invitesRepository: {
findByTokenAndDelete: jest.fn().mockResolvedValue({ role: undefined, teamId: "team-1", email: "invited@example.com" }),
},
});
await service.registerUser({ password: "pass" }, "invite-token", null);
expect(usersRepository.create).toHaveBeenCalledWith(expect.objectContaining({ role: ["user"] }), null);
});
it("registers user without password", async () => {
const { service, usersRepository } = createService();
await service.registerUser({}, "invite-token", null);
const call = (usersRepository.create as jest.Mock).mock.calls[0] as any[];
// Password should not be hashed since it was never provided
expect(call[0].password).toBeUndefined();
});
it("logs warning when sendEmail rejects (fire-and-forget)", async () => {
const { service, logger } = createService({
emailService: {
buildEmail: jest.fn().mockResolvedValue("<html>ok</html>"),
sendEmail: jest.fn().mockRejectedValue(new Error("SMTP down")),
},
});
await service.registerUser({ password: "pass" }, "invite-token", null);
await new Promise((r) => setTimeout(r, 10));
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "SMTP down" }));
});
it("logs 'Unknown error' when sendEmail rejects with non-Error", async () => {
const { service, logger } = createService({
emailService: {
buildEmail: jest.fn().mockResolvedValue("<html>ok</html>"),
sendEmail: jest.fn().mockRejectedValue("string error"),
},
});
await service.registerUser({ password: "pass" }, "invite-token", null);
await new Promise((r) => setTimeout(r, 10));
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error" }));
});
it("logs 'Unknown error' when buildEmail throws non-Error", async () => {
const { service, logger } = createService({
emailService: {
buildEmail: jest.fn().mockRejectedValue(42),
sendEmail: jest.fn(),
},
});
const result = await service.registerUser({ password: "pass" }, "invite-token", null);
expect(result.user).toBeDefined();
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error" }));
});
});
// ── createUser ──────────────────────────────────────────────────────────
describe("createUser", () => {
it("creates a user with hashed password and assigned team", async () => {
const { service, usersRepository } = createService();
const result = await service.createUser({ password: "pass", role: ["user"] }, "team-1", ["superadmin"], null);
expect(usersRepository.create).toHaveBeenCalledWith(expect.objectContaining({ teamId: "team-1" }), null);
expect(result.password).toBe("");
});
it("creates user without password when not provided", async () => {
const { service, usersRepository } = createService();
await service.createUser({ role: ["user"] }, "team-1", ["superadmin"], null);
const call = (usersRepository.create as jest.Mock).mock.calls[0] as any[];
expect(call[0].password).toBeUndefined();
});
it("defaults to empty roles when userData.role is undefined", async () => {
const { service } = createService();
// No role provided, targetRoles defaults to [] via ?? [], loop doesn't execute, no permission error
const result = await service.createUser({ password: "pass" }, "team-1", ["user"], null);
expect(result).toBeDefined();
});
it("throws when actor cannot manage the target role", async () => {
const { service } = createService();
await expect(service.createUser({ role: ["superadmin"] }, "team-1", ["admin"], null)).rejects.toThrow(
"You do not have permission to assign this role"
);
});
});
// ── loginUser ───────────────────────────────────────────────────────────
describe("loginUser", () => {
it("returns user and token on correct password", async () => {
const { service, usersRepository } = createService();
const bcrypt = await import("bcryptjs");
const hashed = bcrypt.hashSync("correct-password", 10);
(usersRepository.findByEmail as jest.Mock).mockResolvedValue(makeUser({ password: hashed }));
const result = await service.loginUser("test@example.com", "correct-password");
expect(result.token).toBe("jwt-token-123");
expect(result.user.password).toBe("");
});
it("throws on incorrect password", async () => {
const { service, usersRepository } = createService();
const bcrypt = await import("bcryptjs");
const hashed = bcrypt.hashSync("correct-password", 10);
(usersRepository.findByEmail as jest.Mock).mockResolvedValue(makeUser({ password: hashed }));
await expect(service.loginUser("test@example.com", "wrong-password")).rejects.toThrow("Incorrect password");
});
});
// ── editUser ────────────────────────────────────────────────────────────
describe("editUser", () => {
it("updates user without password change", async () => {
const { service, usersRepository } = createService();
await service.editUser({ firstName: "Updated" }, null, "user-1", "test@example.com");
expect(usersRepository.updateById).toHaveBeenCalledWith("user-1", { firstName: "Updated" }, null);
});
it("skips password flow when only password is provided without newPassword", async () => {
const { service, usersRepository } = createService();
await service.editUser({ password: "old-pass" }, null, "user-1", "test@example.com");
expect(usersRepository.findByEmail).not.toHaveBeenCalled();
expect(usersRepository.updateById).toHaveBeenCalledWith("user-1", expect.objectContaining({ password: "old-pass" }), null);
});
it("skips password flow when only newPassword is provided without password", async () => {
const { service, usersRepository } = createService();
await service.editUser({ newPassword: "new-pass" } as any, null, "user-1", "test@example.com");
expect(usersRepository.findByEmail).not.toHaveBeenCalled();
});
it("changes password when old and new password provided", async () => {
const { service, usersRepository } = createService();
const bcrypt = await import("bcryptjs");
const hashed = bcrypt.hashSync("old-password", 10);
(usersRepository.findByEmail as jest.Mock).mockResolvedValue(makeUser({ password: hashed }));
await service.editUser({ password: "old-password", newPassword: "new-password" }, null, "user-1", "test@example.com");
expect(usersRepository.updateById).toHaveBeenCalledWith("user-1", expect.objectContaining({ email: "test@example.com" }), null);
const call = (usersRepository.updateById as jest.Mock).mock.calls[0] as any[];
expect(call[1].newPassword).toBeUndefined();
expect(call[1].password).not.toBe("old-password");
});
it("throws when current password is incorrect", async () => {
const { service, usersRepository } = createService();
const bcrypt = await import("bcryptjs");
const hashed = bcrypt.hashSync("actual-password", 10);
(usersRepository.findByEmail as jest.Mock).mockResolvedValue(makeUser({ password: hashed }));
await expect(service.editUser({ password: "wrong-password", newPassword: "new-pass" }, null, "user-1", "test@example.com")).rejects.toThrow(
"Incorrect current password"
);
});
});
// ── checkSuperadminExists ───────────────────────────────────────────────
describe("checkSuperadminExists", () => {
it("delegates to repository", async () => {
const { service, usersRepository } = createService();
const result = await service.checkSuperadminExists();
expect(result).toBe(true);
expect(usersRepository.findSuperAdmin).toHaveBeenCalled();
});
});
// ── requestRecovery ─────────────────────────────────────────────────────
describe("requestRecovery", () => {
it("creates recovery token and sends email", async () => {
const { service, recoveryTokensRepository, emailService } = createService();
const result = await service.requestRecovery("test@example.com");
expect(recoveryTokensRepository.deleteManyByEmail).toHaveBeenCalledWith("test@example.com");
expect(recoveryTokensRepository.create).toHaveBeenCalledWith("test@example.com");
expect(emailService.buildEmail).toHaveBeenCalledWith(
"passwordResetTemplate",
expect.objectContaining({
name: "Test",
email: "test@example.com",
url: "http://localhost:5173/set-new-password/recovery-token-123",
})
);
expect(result).toBe("msg-id-123");
});
it("throws when email HTML fails to build", async () => {
const { service } = createService({
emailService: {
buildEmail: jest.fn().mockResolvedValue(null),
sendEmail: jest.fn(),
},
});
await expect(service.requestRecovery("test@example.com")).rejects.toThrow("Failed to build password reset email HTML");
});
});
// ── validateRecovery ────────────────────────────────────────────────────
describe("validateRecovery", () => {
it("delegates to repository findByToken", async () => {
const { service, recoveryTokensRepository } = createService();
await service.validateRecovery("recovery-token-123");
expect(recoveryTokensRepository.findByToken).toHaveBeenCalledWith("recovery-token-123");
});
});
// ── resetPassword ───────────────────────────────────────────────────────
describe("resetPassword", () => {
it("resets password and returns user with token", async () => {
const { service, usersRepository, recoveryTokensRepository } = createService();
// Ensure old password doesn't match new one (bcrypt.compare returns false)
(usersRepository.findByEmail as jest.Mock).mockResolvedValue(makeUser({ password: "$2a$10$differenthash" }));
const result = await service.resetPassword("new-password", "recovery-token-123");
expect(recoveryTokensRepository.findByToken).toHaveBeenCalledWith("recovery-token-123");
expect(usersRepository.updateById).toHaveBeenCalledWith("user-1", expect.objectContaining({ password: expect.any(String) }), null);
expect(recoveryTokensRepository.deleteManyByEmail).toHaveBeenCalledWith("test@example.com");
expect(result.token).toBe("jwt-token-123");
expect(result.user.password).toBe("");
});
it("throws when new password matches old password", async () => {
const { service, usersRepository } = createService();
const bcrypt = await import("bcryptjs");
const hashed = bcrypt.hashSync("same-password", 10);
(usersRepository.findByEmail as jest.Mock).mockResolvedValue(makeUser({ password: hashed }));
await expect(service.resetPassword("same-password", "recovery-token-123")).rejects.toThrow("New password cannot be same as old password");
});
});
// ── deleteUser ──────────────────────────────────────────────────────────
describe("deleteUser", () => {
it("deletes user and cleans up monitor jobs for superadmin", async () => {
const { service, usersRepository, jobQueue, monitorsRepository } = createService();
await service.deleteUser({ userId: "user-1", teamId: "team-1", roles: ["superadmin"] });
expect(monitorsRepository.findByTeamId).toHaveBeenCalledWith("team-1", {});
expect(jobQueue.deleteJob).toHaveBeenCalledTimes(2);
expect(usersRepository.deleteById).toHaveBeenCalledWith("user-1");
});
it("handles null monitors for superadmin gracefully", async () => {
const { service, usersRepository, jobQueue } = createService({
monitorsRepository: { findByTeamId: jest.fn().mockResolvedValue(null) },
});
await service.deleteUser({ userId: "user-1", teamId: "team-1", roles: ["superadmin"] });
expect(jobQueue.deleteJob).not.toHaveBeenCalled();
expect(usersRepository.deleteById).toHaveBeenCalledWith("user-1");
});
it("skips monitor cleanup for non-superadmin", async () => {
const { service, usersRepository, monitorsRepository } = createService();
await service.deleteUser({ userId: "user-1", teamId: "team-1", roles: ["user"] });
expect(monitorsRepository.findByTeamId).not.toHaveBeenCalled();
expect(usersRepository.deleteById).toHaveBeenCalledWith("user-1");
});
it("throws when deleting a demo user", async () => {
const { service } = createService();
await expect(service.deleteUser({ userId: "user-1", teamId: "team-1", roles: ["demo"] })).rejects.toThrow("Demo user cannot be deleted");
});
});
// ── deleteUserById ──────────────────────────────────────────────────────
describe("deleteUserById", () => {
it("deletes target user when actor has permission", async () => {
const { service, usersRepository, logger } = createService();
await service.deleteUserById({ actorId: "admin-1", actorTeamId: "team-1", actorRoles: ["superadmin"], targetUserId: "user-1" });
expect(usersRepository.deleteById).toHaveBeenCalledWith("user-1");
expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "User user-1 deleted by admin-1" }));
});
it("throws when actor tries to delete themselves", async () => {
const { service } = createService();
await expect(
service.deleteUserById({ actorId: "user-1", actorTeamId: "team-1", actorRoles: ["superadmin"], targetUserId: "user-1" })
).rejects.toThrow("Cannot delete your own account from here");
});
it("throws when target is on a different team", async () => {
const { service, usersRepository } = createService();
(usersRepository.findById as jest.Mock).mockResolvedValue(makeUser({ teamId: "team-other" }));
await expect(
service.deleteUserById({ actorId: "admin-1", actorTeamId: "team-1", actorRoles: ["superadmin"], targetUserId: "user-1" })
).rejects.toThrow("User is not on your team");
});
it("throws when target is a demo user", async () => {
const { service, usersRepository } = createService();
(usersRepository.findById as jest.Mock).mockResolvedValue(makeUser({ role: ["demo"] }));
await expect(
service.deleteUserById({ actorId: "admin-1", actorTeamId: "team-1", actorRoles: ["superadmin"], targetUserId: "user-1" })
).rejects.toThrow("Demo user cannot be deleted");
});
it("throws when actor lacks permission to manage target role", async () => {
const { service, usersRepository } = createService();
(usersRepository.findById as jest.Mock).mockResolvedValue(makeUser({ role: ["superadmin"] }));
await expect(
service.deleteUserById({ actorId: "admin-1", actorTeamId: "team-1", actorRoles: ["admin"], targetUserId: "user-1" })
).rejects.toThrow("You do not have permission to remove this user");
});
});
// ── getAllUsers ──────────────────────────────────────────────────────────
describe("getAllUsers", () => {
it("delegates to repository", async () => {
const { service, usersRepository } = createService();
const result = await service.getAllUsers();
expect(result).toEqual([makeUser()]);
expect(usersRepository.findAll).toHaveBeenCalled();
});
});
// ── getUserById ─────────────────────────────────────────────────────────
describe("getUserById", () => {
it("returns user for admin", async () => {
const { service, usersRepository } = createService();
const result = await service.getUserById(["admin"], "user-1");
expect(result).toEqual(makeUser());
expect(usersRepository.findById).toHaveBeenCalledWith("user-1");
});
it("returns user for superadmin", async () => {
const { service } = createService();
const result = await service.getUserById(["superadmin"], "user-1");
expect(result).toEqual(makeUser());
});
it("throws for non-admin roles", async () => {
const { service } = createService();
await expect(service.getUserById(["user"], "user-1")).rejects.toThrow("Insufficient permissions");
});
});
// ── editUserById ────────────────────────────────────────────────────────
describe("editUserById", () => {
it("delegates to repository", async () => {
const { service, usersRepository } = createService();
await service.editUserById("user-1", { firstName: "New" });
expect(usersRepository.updateById).toHaveBeenCalledWith("user-1", { firstName: "New" }, null);
});
});
// ── setPasswordByUserId ─────────────────────────────────────────────────
describe("setPasswordByUserId", () => {
it("hashes and sets password", async () => {
const { service, usersRepository } = createService();
const result = await service.setPasswordByUserId("user-1", "new-password");
expect(usersRepository.updateById).toHaveBeenCalledWith("user-1", expect.objectContaining({ password: expect.any(String) }), null);
// Ensure the stored password is hashed, not plaintext
const call = (usersRepository.updateById as jest.Mock).mock.calls[0] as any[];
expect(call[1].password).not.toBe("new-password");
expect(result).toEqual(makeUser());
});
});
});