diff --git a/server/package-lock.json b/server/package-lock.json index 067a46b64..1b9061d0c 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -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", diff --git a/server/package.json b/server/package.json index 4d1ab4c98..c4de0e1d4 100755 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 8ed72862a..eae4ccd7e 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -364,7 +364,6 @@ export const initializeServices = async ({ }); const monitorService = new MonitorService({ jobQueue: superSimpleQueue, - emailService, logger, games, monitorsRepository, diff --git a/server/src/service/business/checkService.ts b/server/src/service/business/checkService.ts index 374090709..dd2a66202 100644 --- a/server/src/service/business/checkService.ts +++ b/server/src/service/business/checkService.ts @@ -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; diff --git a/server/src/service/business/incidentService.ts b/server/src/service/business/incidentService.ts index 4790f9aac..2070a8937 100644 --- a/server/src/service/business/incidentService.ts +++ b/server/src/service/business/incidentService.ts @@ -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 { diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 71c9d9d90..4ab5c9476 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -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): Promise => { 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 => { - 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 => { - 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 => { - 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 => { - 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 }) => { @@ -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 }; }; } diff --git a/server/src/service/business/userService.ts b/server/src/service/business/userService.ts index 48ee5cfbf..7acca5742 100644 --- a/server/src/service/business/userService.ts +++ b/server/src/service/business/userService.ts @@ -110,10 +110,7 @@ export class UserService implements IUserService { } issueToken = (payload: Partial, 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, 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) => { diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts index 542e8e38e..7260c82c7 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts @@ -51,7 +51,6 @@ export interface ISuperSimpleQueue { getMetrics(): Promise; getJobs(): Promise; flushQueues(): Promise<{ success: boolean }>; - obliterate(): Promise; } 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", - }); - }; } diff --git a/server/src/service/infrastructure/bufferService.ts b/server/src/service/infrastructure/bufferService.ts index 229c5e7d4..07be446c6 100755 --- a/server/src/service/infrastructure/bufferService.ts +++ b/server/src/service/infrastructure/bufferService.ts @@ -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; flushGeoBuffer(): Promise; @@ -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); diff --git a/server/src/service/infrastructure/emailService.ts b/server/src/service/infrastructure/emailService.ts index f536f3f18..6cf8caf4f 100755 --- a/server/src/service/infrastructure/emailService.ts +++ b/server/src/service/infrastructure/emailService.ts @@ -35,7 +35,6 @@ export class EmailService implements IEmailService { private logger: ILogger; private transporter: ReturnType | null = null; private templateLookup: Record) => string) | undefined>; - private loadTemplate: (templateName: string) => ((context: Record) => 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", diff --git a/server/src/service/infrastructure/network/DockerProvider.ts b/server/src/service/infrastructure/network/DockerProvider.ts index 8e84b5dc6..1d4b98818 100644 --- a/server/src/service/infrastructure/network/DockerProvider.ts +++ b/server/src/service/infrastructure/network/DockerProvider.ts @@ -90,7 +90,8 @@ export class DockerProvider implements IStatusProvider { 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 { }; } + 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, diff --git a/server/src/service/infrastructure/network/GrpcProvider.ts b/server/src/service/infrastructure/network/GrpcProvider.ts index 66684ba0c..14a063bca 100644 --- a/server/src/service/infrastructure/network/GrpcProvider.ts +++ b/server/src/service/infrastructure/network/GrpcProvider.ts @@ -60,7 +60,7 @@ export class GrpcProvider implements IStatusProvider { 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); diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/infrastructure/networkService.ts index 9111ef7b2..ca85d12ac 100644 --- a/server/src/service/infrastructure/networkService.ts +++ b/server/src/service/infrastructure/networkService.ts @@ -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); diff --git a/server/src/service/infrastructure/notificationProviders/matrix.ts b/server/src/service/infrastructure/notificationProviders/matrix.ts index 413ecd47e..9d92e1f3c 100644 --- a/server/src/service/infrastructure/notificationProviders/matrix.ts +++ b/server/src/service/infrastructure/notificationProviders/matrix.ts @@ -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; } diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index ef725b80a..9826d6603 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -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, - }); - } - }; } diff --git a/server/src/service/system/settingsService.ts b/server/src/service/system/settingsService.ts index f2daff550..6638bc493 100755 --- a/server/src/service/system/settingsService.ts +++ b/server/src/service/system/settingsService.ts @@ -53,9 +53,6 @@ export class SettingsService implements ISettingsService { } getSettings() { - if (!this.settings) { - throw new Error("Settings have not been loaded"); - } return this.settings; } diff --git a/server/test/helpers/notificationMessage.ts b/server/test/helpers/notificationMessage.ts new file mode 100644 index 000000000..c25644c81 --- /dev/null +++ b/server/test/helpers/notificationMessage.ts @@ -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 => + ({ + 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 => ({ + 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") }, + }, + }); diff --git a/server/test/helpers/notificationProviderContract.ts b/server/test/helpers/notificationProviderContract.ts new file mode 100644 index 000000000..f95048f24 --- /dev/null +++ b/server/test/helpers/notificationProviderContract.ts @@ -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; + } +) => { + 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"); + }); + }); +}; diff --git a/server/test/helpers/statusProviderContract.ts b/server/test/helpers/statusProviderContract.ts new file mode 100644 index 000000000..6f7c89466 --- /dev/null +++ b/server/test/helpers/statusProviderContract.ts @@ -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; + 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(); + } + }); + }); +}; diff --git a/server/test/monitorService.test.ts b/server/test/monitorService.test.ts deleted file mode 100644 index 02e62c75b..000000000 --- a/server/test/monitorService.test.ts +++ /dev/null @@ -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, - }) - ); - }); - }); -}); diff --git a/server/test/superSimpleQueueHelper.test.ts b/server/test/superSimpleQueueHelper.test.ts deleted file mode 100644 index 25db19dbc..000000000 --- a/server/test/superSimpleQueueHelper.test.ts +++ /dev/null @@ -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) => { - 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); - }); - }); -}); diff --git a/server/test/unit/providers/network/advancedMatcher.test.ts b/server/test/unit/providers/network/advancedMatcher.test.ts new file mode 100644 index 000000000..e0b2c773c --- /dev/null +++ b/server/test/unit/providers/network/advancedMatcher.test.ts @@ -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 => + ({ + 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); + }); + }); + }); +}); diff --git a/server/test/unit/providers/network/dockerProvider.test.ts b/server/test/unit/providers/network/dockerProvider.test.ts new file mode 100644 index 000000000..0f3dd68f6 --- /dev/null +++ b/server/test/unit/providers/network/dockerProvider.test.ts @@ -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 => + ({ + id: "mon-1", + teamId: "team-1", + type: "docker", + url: "my-container", + ...overrides, + }) as Monitor; + +const makeContainer = (overrides?: Record) => ({ + 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"); + }); + }); +}); diff --git a/server/test/unit/providers/network/gameProvider.test.ts b/server/test/unit/providers/network/gameProvider.test.ts new file mode 100644 index 000000000..d34e99621 --- /dev/null +++ b/server/test/unit/providers/network/gameProvider.test.ts @@ -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 => + ({ + id: "mon-1", + teamId: "team-1", + type: "game", + url: "play.example.com", + port: 25565, + gameId: "minecraft", + ...overrides, + }) as Monitor; + +const createMockGameDig = (state?: Record | 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"); + }); +}); diff --git a/server/test/unit/providers/network/grpcProvider.test.ts b/server/test/unit/providers/network/grpcProvider.test.ts new file mode 100644 index 000000000..efa2e7992 --- /dev/null +++ b/server/test/unit/providers/network/grpcProvider.test.ts @@ -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 => + ({ + 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) => { + 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 })); + }); + }); +}); diff --git a/server/test/unit/providers/network/hardwareProvider.test.ts b/server/test/unit/providers/network/hardwareProvider.test.ts new file mode 100644 index 000000000..2f51454a0 --- /dev/null +++ b/server/test/unit/providers/network/hardwareProvider.test.ts @@ -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 => + ({ + 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; + +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"); + }); +}); diff --git a/server/test/unit/providers/network/httpProvider.test.ts b/server/test/unit/providers/network/httpProvider.test.ts new file mode 100644 index 000000000..1b814d7b2 --- /dev/null +++ b/server/test/unit/providers/network/httpProvider.test.ts @@ -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 => + ({ + 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) => ({ + ok: true, + statusCode: 200, + statusMessage: "OK", + headers: { "content-type": "text/html" }, + body: "", + 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: "test" })); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload).toBe("test"); + }); + + 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(""); + }); + }); + + // ── 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"); + }); + }); +}); diff --git a/server/test/unit/providers/network/pageSpeedProvider.test.ts b/server/test/unit/providers/network/pageSpeedProvider.test.ts new file mode 100644 index 000000000..c461b0ad2 --- /dev/null +++ b/server/test/unit/providers/network/pageSpeedProvider.test.ts @@ -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 => + ({ + 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; + +const createMockSettingsService = (apiKey?: string) => + ({ + getDBSettings: jest.fn().mockResolvedValue({ pagespeedApiKey: apiKey }), + }) as unknown as jest.Mocked; + +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; + 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") })); + }); +}); diff --git a/server/test/unit/providers/network/pingProvider.test.ts b/server/test/unit/providers/network/pingProvider.test.ts new file mode 100644 index 000000000..2f38eef00 --- /dev/null +++ b/server/test/unit/providers/network/pingProvider.test.ts @@ -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 => + ({ + 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"); + }); +}); diff --git a/server/test/unit/providers/network/portProvider.test.ts b/server/test/unit/providers/network/portProvider.test.ts new file mode 100644 index 000000000..cbf64af16 --- /dev/null +++ b/server/test/unit/providers/network/portProvider.test.ts @@ -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 => + ({ + 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"); + }); +}); diff --git a/server/test/unit/providers/network/utils.test.ts b/server/test/unit/providers/network/utils.test.ts new file mode 100644 index 000000000..56d8e7f9c --- /dev/null +++ b/server/test/unit/providers/network/utils.test.ts @@ -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); + }); + }); +}); diff --git a/server/test/unit/providers/network/webSocketProvider.test.ts b/server/test/unit/providers/network/webSocketProvider.test.ts new file mode 100644 index 000000000..73e4416a1 --- /dev/null +++ b/server/test/unit/providers/network/webSocketProvider.test.ts @@ -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 => + ({ + 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 = {}; + 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", + }) + ); + }); + }); +}); diff --git a/server/test/unit/providers/notifications/discordProvider.test.ts b/server/test/unit/providers/notifications/discordProvider.test.ts new file mode 100644 index 000000000..28c30ed22 --- /dev/null +++ b/server/test/unit/providers/notifications/discordProvider.test.ts @@ -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); + }); + }); +}); diff --git a/server/test/unit/providers/notifications/emailProvider.test.ts b/server/test/unit/providers/notifications/emailProvider.test.ts new file mode 100644 index 000000000..e0dc8b676 --- /dev/null +++ b/server/test/unit/providers/notifications/emailProvider.test.ts @@ -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("email"), + sendEmail: jest.fn().mockResolvedValue("msg-123"), + }) as unknown as jest.Mocked; + +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", "email"); + }); + + 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 = { 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" })); + }); + }); +}); diff --git a/server/test/unit/providers/notifications/matrixProvider.test.ts b/server/test/unit/providers/notifications/matrixProvider.test.ts new file mode 100644 index 000000000..f6ad36692 --- /dev/null +++ b/server/test/unit/providers/notifications/matrixProvider.test.ts @@ -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(" { + 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 = ''; + await provider.sendMessage(makeNotification() as any, msg); + const html = mockGotPut.mock.calls[0][1].json.formatted_body; + expect(html).not.toContain("