mirror of
https://github.com/bluewave-labs/Checkmate.git
synced 2026-05-13 13:08:41 -05:00
Merge pull request #3492 from bluewave-labs/feat/service-tests
feat/service tests
This commit is contained in:
Generated
-86
@@ -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
@@ -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",
|
||||
|
||||
@@ -364,7 +364,6 @@ export const initializeServices = async ({
|
||||
});
|
||||
const monitorService = new MonitorService({
|
||||
jobQueue: superSimpleQueue,
|
||||
emailService,
|
||||
logger,
|
||||
games,
|
||||
monitorsRepository,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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("<script>");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
+47
-4
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user