Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc7688bc13 | ||
|
|
ff114b3ab2 | ||
|
|
233966b73d | ||
|
|
45d4779b45 | ||
|
|
d608b5ea24 | ||
|
|
1ad750e2ef | ||
|
|
4d1af5e2b4 | ||
|
|
b515d688f4 | ||
|
|
ff92b307bb | ||
|
|
e4ffe0e267 | ||
|
|
2b121355c8 | ||
|
|
e7db938baa | ||
|
|
1b746d0f0a | ||
|
|
6886fc75de | ||
|
|
5c5f84a5b2 | ||
|
|
a12154523f | ||
|
|
6c8adf8594 | ||
|
|
a0f73e4e8d | ||
|
|
63efef65c7 | ||
|
|
dca09daf35 | ||
|
|
db833b441a | ||
|
|
bb55f3b3eb | ||
|
|
88e0660762 | ||
|
|
a8c172c77f | ||
|
|
b355da4aed | ||
|
|
77a4bf9c36 | ||
|
|
6a0f4372c6 |
No files matched your search
+3
-1
@@ -6,7 +6,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "NODE_OPTIONS=--experimental-vm-modules jest --coverage",
|
||||
"test:services": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/service/business/*.ts' --collectCoverageFrom='src/service/infrastructure/statusService.ts' --collectCoverageFrom='src/service/infrastructure/notificationsService.ts' --collectCoverageFrom='src/service/infrastructure/SuperSimpleQueue/*.ts' test/unit/services",
|
||||
"test:unit": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/**/*.ts' test/unit",
|
||||
"test:unit:services": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/service/**/*.ts' test/unit/services test/unit/providers/network test/unit/providers/notifications",
|
||||
"test:integration": "NODE_OPTIONS=--experimental-vm-modules jest --no-coverage test/integration",
|
||||
"dev": "nodemon --exec tsx src/index.js",
|
||||
"start": "node --watch ./dist/index.js",
|
||||
"build": "tsc && tsc-alias && cp -r src/templates dist/templates",
|
||||
|
||||
@@ -364,7 +364,6 @@ export const initializeServices = async ({
|
||||
});
|
||||
const monitorService = new MonitorService({
|
||||
jobQueue: superSimpleQueue,
|
||||
emailService,
|
||||
logger,
|
||||
games,
|
||||
monitorsRepository,
|
||||
|
||||
@@ -23,7 +23,6 @@ import demoMonitorsData from "@/utils/demoMonitors.json" with { type: "json" };
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import type { ImportedMonitor } from "@/validation/monitorValidation.js";
|
||||
import { ISuperSimpleQueue } from "../infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
|
||||
import { IEmailService } from "../infrastructure/emailService.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
|
||||
const SERVICE_NAME = "MonitorService";
|
||||
@@ -91,7 +90,6 @@ export class MonitorService implements IMonitorService {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
private jobQueue: ISuperSimpleQueue;
|
||||
private emailService: IEmailService;
|
||||
private logger: ILogger;
|
||||
private games: GamesMap;
|
||||
private monitorsRepository: IMonitorsRepository;
|
||||
@@ -103,7 +101,6 @@ export class MonitorService implements IMonitorService {
|
||||
|
||||
constructor({
|
||||
jobQueue,
|
||||
emailService,
|
||||
logger,
|
||||
games,
|
||||
monitorsRepository,
|
||||
@@ -114,7 +111,6 @@ export class MonitorService implements IMonitorService {
|
||||
incidentsRepository,
|
||||
}: {
|
||||
jobQueue: ISuperSimpleQueue;
|
||||
emailService: IEmailService;
|
||||
logger: ILogger;
|
||||
games: GamesMap;
|
||||
monitorsRepository: IMonitorsRepository;
|
||||
@@ -125,7 +121,6 @@ export class MonitorService implements IMonitorService {
|
||||
incidentsRepository: IIncidentsRepository;
|
||||
}) {
|
||||
this.jobQueue = jobQueue;
|
||||
this.emailService = emailService;
|
||||
this.logger = logger;
|
||||
this.games = games;
|
||||
this.monitorsRepository = monitorsRepository;
|
||||
@@ -176,7 +171,7 @@ export class MonitorService implements IMonitorService {
|
||||
|
||||
createMonitors = async (monitors: Array<Monitor>): Promise<Monitor[] | null> => {
|
||||
const createdMonitors = await this.monitorsRepository.createMonitors(monitors);
|
||||
if (!monitors || monitors.length === 0) {
|
||||
if (!createdMonitors || createdMonitors.length === 0) {
|
||||
throw new AppError({ message: "Failed to create monitors", status: 500, service: SERVICE_NAME, method: "createMonitors" });
|
||||
}
|
||||
|
||||
@@ -185,8 +180,7 @@ export class MonitorService implements IMonitorService {
|
||||
};
|
||||
|
||||
addDemoMonitors = async ({ userId, teamId }: { userId: string; teamId: string }): Promise<Monitor[]> => {
|
||||
const demoData = demoMonitorsData;
|
||||
const monitors = demoData.map((monitor) => ({
|
||||
const monitors = demoMonitorsData.map((monitor) => ({
|
||||
userId,
|
||||
teamId,
|
||||
name: monitor.name,
|
||||
@@ -214,7 +208,7 @@ export class MonitorService implements IMonitorService {
|
||||
if (!monitor) {
|
||||
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
|
||||
}
|
||||
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
|
||||
const rangeKey = dateRange as DateRangeKey;
|
||||
const { start, end } = this.getDateRange(rangeKey);
|
||||
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
|
||||
type: monitor.type,
|
||||
@@ -263,7 +257,7 @@ export class MonitorService implements IMonitorService {
|
||||
throw new AppError({ message: `${monitor.type} monitors are not supported for hardware details`, status: 400 });
|
||||
}
|
||||
|
||||
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
|
||||
const rangeKey = dateRange as DateRangeKey;
|
||||
const { start, end } = this.getDateRange(rangeKey);
|
||||
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
|
||||
type: monitor.type,
|
||||
@@ -305,7 +299,7 @@ export class MonitorService implements IMonitorService {
|
||||
throw new AppError({ message: `${monitor.type} monitors are not supported for pagespeed details`, status: 400 });
|
||||
}
|
||||
|
||||
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
|
||||
const rangeKey = dateRange as DateRangeKey;
|
||||
const { start, end } = this.getDateRange(rangeKey);
|
||||
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
|
||||
type: monitor.type,
|
||||
@@ -346,7 +340,7 @@ export class MonitorService implements IMonitorService {
|
||||
return { groupedGeoChecks: [] };
|
||||
}
|
||||
|
||||
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
|
||||
const rangeKey = dateRange as DateRangeKey;
|
||||
const { start, end } = this.getDateRange(rangeKey);
|
||||
const groupedGeoChecks = await this.geoChecksRepository.findGroupedByMonitorIdAndDateRange(
|
||||
monitor.id,
|
||||
@@ -360,8 +354,7 @@ export class MonitorService implements IMonitorService {
|
||||
};
|
||||
|
||||
getMonitorById = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => {
|
||||
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
|
||||
return monitor;
|
||||
return await this.monitorsRepository.findById(monitorId, teamId);
|
||||
};
|
||||
|
||||
getMonitorsByTeamId = async ({
|
||||
@@ -373,11 +366,7 @@ export class MonitorService implements IMonitorService {
|
||||
type?: MonitorType | MonitorType[];
|
||||
filter?: string;
|
||||
}): Promise<Monitor[] | null> => {
|
||||
const monitors = await this.monitorsRepository.findByTeamId(teamId, {
|
||||
type,
|
||||
filter,
|
||||
});
|
||||
return monitors;
|
||||
return await this.monitorsRepository.findByTeamId(teamId, { type, filter });
|
||||
};
|
||||
|
||||
getMonitorsWithChecksByTeamId = async ({
|
||||
@@ -432,8 +421,7 @@ export class MonitorService implements IMonitorService {
|
||||
};
|
||||
|
||||
getGroupsByTeamId = async ({ teamId }: { teamId: string }): Promise<string[]> => {
|
||||
const groups = await this.monitorsRepository.findGroupsByTeamId(teamId);
|
||||
return groups;
|
||||
return await this.monitorsRepository.findGroupsByTeamId(teamId);
|
||||
};
|
||||
|
||||
editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: Partial<Monitor> }) => {
|
||||
@@ -574,15 +562,6 @@ export class MonitorService implements IMonitorService {
|
||||
|
||||
const createdMonitors = await this.createMonitors(cleanedMonitors);
|
||||
|
||||
if (!createdMonitors || createdMonitors.length === 0) {
|
||||
throw new AppError({
|
||||
message: "Failed to import any monitors. Please check the file format and try again.",
|
||||
service: SERVICE_NAME,
|
||||
method: "importMonitorsFromJSON",
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return { imported: createdMonitors.length, errors };
|
||||
return { imported: createdMonitors!.length, errors };
|
||||
};
|
||||
}
|
||||
@@ -110,10 +110,7 @@ export class UserService implements IUserService {
|
||||
}
|
||||
|
||||
issueToken = (payload: Partial<User>, appSettings: EnvConfig) => {
|
||||
const tokenTTL = appSettings?.jwtTTL ?? "2h";
|
||||
const tokenSecret = appSettings?.jwtSecret;
|
||||
const payloadData = payload;
|
||||
return this.jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL });
|
||||
return this.jwt.sign(payload, appSettings.jwtSecret, { expiresIn: appSettings.jwtTTL });
|
||||
};
|
||||
|
||||
registerUser = async (user: Partial<User>, inviteToken: string, file: Express.Multer.File | null) => {
|
||||
@@ -253,14 +250,10 @@ export class UserService implements IUserService {
|
||||
currentUserEmail: string
|
||||
) => {
|
||||
// Change Password check
|
||||
if (updates?.password && updates?.newPassword) {
|
||||
// Get user's email
|
||||
// Add user email to body for DB operation
|
||||
if (updates.password && updates.newPassword) {
|
||||
updates.email = currentUserEmail;
|
||||
// Get user
|
||||
const user = await this.usersRepository.findByEmail(currentUserEmail);
|
||||
// Compare passwords
|
||||
const match = await bcrypt.compare(updates?.password, user.password);
|
||||
const match = await bcrypt.compare(updates.password, user.password);
|
||||
// If not a match, throw a 403
|
||||
// 403 instead of 401 to avoid triggering axios interceptor
|
||||
if (!match) {
|
||||
@@ -286,7 +279,7 @@ export class UserService implements IUserService {
|
||||
const recoveryToken = await this.recoveryTokensRepository.create(email);
|
||||
const name = user.firstName;
|
||||
const settings = this.settingsService.getSettings();
|
||||
const url = `${settings.clientHost!}/set-new-password/${recoveryToken.token}`;
|
||||
const url = `${settings.clientHost}/set-new-password/${recoveryToken.token}`;
|
||||
|
||||
const html = await this.emailService.buildEmail("passwordResetTemplate", {
|
||||
name,
|
||||
@@ -336,17 +329,10 @@ export class UserService implements IUserService {
|
||||
throw new AppError({ message: "Demo user cannot be deleted", service: SERVICE_NAME, method: "deleteUser", status: 400 });
|
||||
}
|
||||
|
||||
// 1. Find all the monitors associated with the team ID if superadmin
|
||||
const res = await this.monitorsRepository.findByTeamId(teamId, {});
|
||||
|
||||
if (roles.includes("superadmin")) {
|
||||
// 2. Remove all jobs, delete checks and alerts
|
||||
if (res && res.length > 0) {
|
||||
await Promise.all(
|
||||
res.map(async (monitor) => {
|
||||
await this.jobQueue.deleteJob(monitor);
|
||||
})
|
||||
);
|
||||
const monitors = await this.monitorsRepository.findByTeamId(teamId, {});
|
||||
if (monitors) {
|
||||
await Promise.all(monitors.map((monitor) => this.jobQueue.deleteJob(monitor)));
|
||||
}
|
||||
}
|
||||
// 6. Delete the user by id
|
||||
@@ -410,9 +396,7 @@ export class UserService implements IUserService {
|
||||
if (!roles.includes("superadmin") && !roles.includes("admin")) {
|
||||
throw new AppError({ message: "Insufficient permissions", service: SERVICE_NAME, status: 403 });
|
||||
}
|
||||
const user = await this.usersRepository.findById(userId);
|
||||
|
||||
return user;
|
||||
return await this.usersRepository.findById(userId);
|
||||
};
|
||||
|
||||
editUserById = async (userId: string, patch: Partial<User>) => {
|
||||
|
||||
@@ -51,7 +51,6 @@ export interface ISuperSimpleQueue {
|
||||
getMetrics(): Promise<QueueMetrics>;
|
||||
getJobs(): Promise<QueueJobSummary[]>;
|
||||
flushQueues(): Promise<{ success: boolean }>;
|
||||
obliterate(): Promise<void>;
|
||||
}
|
||||
|
||||
export class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
@@ -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",
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -168,15 +168,17 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
|
||||
});
|
||||
}
|
||||
|
||||
// Step 7. Handle incidents (best effort, don't wait)
|
||||
this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status).catch((error: unknown) => {
|
||||
// Step 7. Handle incidents
|
||||
try {
|
||||
await this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status);
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn({
|
||||
message: `Error handling incident for job ${monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getMonitorJob",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn({
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
@@ -377,7 +379,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return acc;
|
||||
}
|
||||
return acc;
|
||||
}, false);
|
||||
|
||||
@@ -9,7 +9,6 @@ const SERVICE_NAME = "BufferService";
|
||||
export interface IBufferService {
|
||||
addToBuffer(check: Check): void;
|
||||
addGeoCheckToBuffer(geoCheck: GeoCheck): void;
|
||||
removeCheckFromBuffer(check: Check): boolean;
|
||||
scheduleNextFlush(): void;
|
||||
flushBuffer(): Promise<void>;
|
||||
flushGeoBuffer(): Promise<void>;
|
||||
@@ -72,44 +71,6 @@ export class BufferService implements IBufferService {
|
||||
}
|
||||
}
|
||||
|
||||
removeCheckFromBuffer(checkToRemove: Check) {
|
||||
try {
|
||||
if (!checkToRemove) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const index = this.buffer.findIndex((check) => {
|
||||
if (checkToRemove.id && check.id) {
|
||||
return check.id.toString() === checkToRemove.id.toString();
|
||||
}
|
||||
return (
|
||||
check.metadata.monitorId?.toString() === checkToRemove.metadata.monitorId &&
|
||||
check.metadata.teamId?.toString() === checkToRemove.metadata.teamId &&
|
||||
check.metadata.type === checkToRemove.metadata.type &&
|
||||
check.status === checkToRemove.status &&
|
||||
check.statusCode === checkToRemove.statusCode &&
|
||||
check.responseTime === checkToRemove.responseTime &&
|
||||
check.message === checkToRemove.message
|
||||
);
|
||||
});
|
||||
|
||||
if (index !== -1) {
|
||||
this.buffer.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error: unknown) {
|
||||
this.logger.error({
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
service: this.SERVICE_NAME,
|
||||
method: "removeCheckFromBuffer",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
scheduleNextFlush() {
|
||||
if (this.bufferTimer) {
|
||||
clearTimeout(this.bufferTimer);
|
||||
|
||||
@@ -35,7 +35,6 @@ export class EmailService implements IEmailService {
|
||||
private logger: ILogger;
|
||||
private transporter: ReturnType<typeof import("nodemailer").createTransport> | null = null;
|
||||
private templateLookup: Record<string, ((context: Record<string, unknown>) => string) | undefined>;
|
||||
private loadTemplate: (templateName: string) => ((context: Record<string, unknown>) => string) | undefined;
|
||||
|
||||
constructor(
|
||||
settingsService: ISettingsService,
|
||||
@@ -54,7 +53,6 @@ export class EmailService implements IEmailService {
|
||||
this.nodemailer = nodemailer;
|
||||
this.logger = logger;
|
||||
this.templateLookup = {};
|
||||
this.loadTemplate = () => undefined;
|
||||
this.init();
|
||||
}
|
||||
|
||||
@@ -63,7 +61,7 @@ export class EmailService implements IEmailService {
|
||||
}
|
||||
|
||||
init = () => {
|
||||
this.loadTemplate = (templateName) => {
|
||||
const loadTemplate = (templateName: string) => {
|
||||
try {
|
||||
const templatePath = this.path.join(__dirname, `../../templates/${templateName}.mjml`);
|
||||
const templateContent = this.fs.readFileSync(templatePath, "utf8");
|
||||
@@ -79,12 +77,12 @@ export class EmailService implements IEmailService {
|
||||
};
|
||||
|
||||
this.templateLookup = {
|
||||
welcomeEmailTemplate: this.loadTemplate("welcomeEmail"),
|
||||
employeeActivationTemplate: this.loadTemplate("employeeActivation"),
|
||||
noIncidentsThisWeekTemplate: this.loadTemplate("noIncidentsThisWeek"),
|
||||
passwordResetTemplate: this.loadTemplate("passwordReset"),
|
||||
testEmailTemplate: this.loadTemplate("testEmailTemplate"),
|
||||
unifiedNotificationTemplate: this.loadTemplate("unifiedNotification"),
|
||||
welcomeEmailTemplate: loadTemplate("welcomeEmail"),
|
||||
employeeActivationTemplate: loadTemplate("employeeActivation"),
|
||||
noIncidentsThisWeekTemplate: loadTemplate("noIncidentsThisWeek"),
|
||||
passwordResetTemplate: loadTemplate("passwordReset"),
|
||||
testEmailTemplate: loadTemplate("testEmailTemplate"),
|
||||
unifiedNotificationTemplate: loadTemplate("unifiedNotification"),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -167,7 +165,7 @@ export class EmailService implements IEmailService {
|
||||
subject: subject,
|
||||
html: html,
|
||||
});
|
||||
return info?.messageId;
|
||||
return info.messageId;
|
||||
} catch (error: unknown) {
|
||||
this.logger.error({
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
|
||||
@@ -90,7 +90,8 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
|
||||
if (partialIdMatch && !exactIdMatch) matchTypes.push("partial ID");
|
||||
|
||||
if (matchTypes.length > 1) {
|
||||
const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${exactIdMatch ? "exact ID" : exactNameMatch ? "exact name" : "partial ID"} match.`;
|
||||
const matchUsed = exactIdMatch ? "exact ID" : "exact name";
|
||||
const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${matchUsed} match.`;
|
||||
|
||||
this.logger.warn({
|
||||
message,
|
||||
@@ -138,11 +139,24 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: "No response from Docker container inspect",
|
||||
responseTime,
|
||||
payload: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
status: response?.State?.Status === "running",
|
||||
status: response.State?.Status === "running",
|
||||
code: 200,
|
||||
message: "Docker container status fetched successfully",
|
||||
responseTime,
|
||||
|
||||
@@ -60,7 +60,7 @@ export class GrpcProvider implements IStatusProvider<GrpcStatusPayload> {
|
||||
throw new AppError({ message: "Monitor port is required", service: SERVICE_NAME, method: "handle" });
|
||||
}
|
||||
|
||||
const host = url?.replace(/^https?:\/\//, "").split(/[/?#:]/)[0];
|
||||
const host = url.replace(/^https?:\/\//, "").split(/[/?#:]/)[0];
|
||||
const target = `${host}:${port}`;
|
||||
|
||||
const currentFilePath = fileURLToPath(import.meta.url);
|
||||
|
||||
@@ -118,7 +118,7 @@ export class NetworkService implements INetworkService {
|
||||
},
|
||||
});
|
||||
|
||||
if (response?.data?.status !== "success") return false;
|
||||
if (response.data?.status !== "success") return false;
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
const originalMessage = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { AlertMatrixPayload, Notification } from "@/types/index.js";
|
||||
import type { NotificationMessage } from "@/types/notificationMessage.js";
|
||||
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export class MatrixProvider implements INotificationProvider {
|
||||
private logger: ILogger;
|
||||
@@ -18,63 +19,51 @@ export class MatrixProvider implements INotificationProvider {
|
||||
if (!homeserverUrl || !accessToken || !roomId) {
|
||||
return false;
|
||||
}
|
||||
const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message?access_token=${accessToken}`;
|
||||
|
||||
const body = {
|
||||
msgtype: "m.text",
|
||||
body: getTestMessage(),
|
||||
};
|
||||
try {
|
||||
await got.post(url, {
|
||||
json: body,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.warn({
|
||||
message: "Matrix test alert failed",
|
||||
service: SERVICE_NAME,
|
||||
method: "sendTestAlert",
|
||||
stack: err?.stack,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return await this.sendMessageWithBody(notification, body);
|
||||
};
|
||||
|
||||
sendMessage = async (notification: Notification, message: NotificationMessage): Promise<boolean> => {
|
||||
const { homeserverUrl, accessToken, roomId } = notification;
|
||||
|
||||
if (!homeserverUrl || !accessToken || !roomId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { plainText, htmlText } = this.buildMatrixMessage(message);
|
||||
|
||||
const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message?access_token=${accessToken}`;
|
||||
const body = {
|
||||
msgtype: "m.text",
|
||||
body: plainText,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: htmlText,
|
||||
};
|
||||
return this.sendMessageWithBody(notification, body);
|
||||
};
|
||||
|
||||
private sendMessageWithBody = async (notification: Partial<Notification>, body: unknown): Promise<boolean> => {
|
||||
const { homeserverUrl, accessToken, roomId } = notification;
|
||||
const txnId = randomUUID();
|
||||
const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message/${txnId}`;
|
||||
|
||||
try {
|
||||
await got.post(url, {
|
||||
await got.put(url, {
|
||||
json: body,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.warn({
|
||||
message: "Matrix notification failed",
|
||||
message: `Matrix notification failed : ${err.message}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "sendMessage",
|
||||
stack: err?.stack,
|
||||
stack: err.stack,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ export class StatusService implements IStatusService {
|
||||
uptimePercentage: 0,
|
||||
lastResponseTime: 0,
|
||||
lastCheckTimestamp: 0,
|
||||
timeOfLastFailure: 0,
|
||||
};
|
||||
} else {
|
||||
// Use existing stats (omit id, monitorId, createdAt, updatedAt)
|
||||
@@ -362,27 +363,4 @@ export class StatusService implements IStatusService {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
insertCheck = async (check: Check) => {
|
||||
try {
|
||||
if (typeof check === "undefined") {
|
||||
this.logger.warn({
|
||||
message: "Failed to build check",
|
||||
service: SERVICE_NAME,
|
||||
method: "insertCheck",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
this.buffer.addToBuffer(check);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
this.logger.error({
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
service: SERVICE_NAME,
|
||||
method: "insertCheck",
|
||||
details: { msg: `Error inserting check for monitor: ${check?.metadata.monitorId}` },
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -53,9 +53,6 @@ export class SettingsService implements ISettingsService {
|
||||
}
|
||||
|
||||
getSettings() {
|
||||
if (!this.settings) {
|
||||
throw new Error("Settings have not been loaded");
|
||||
}
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { IIncidentsRepository } from "../../src/repositories/incidents/IIncidentsRepository.ts";
|
||||
import type { Incident, IncidentSummary } from "../../src/types/index.ts";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export class InMemoryIncidentsRepository implements IIncidentsRepository {
|
||||
private incidents: Incident[] = [];
|
||||
|
||||
async create(incident: Partial<Incident>): Promise<Incident> {
|
||||
const now = new Date().toISOString();
|
||||
const full: Incident = {
|
||||
id: randomUUID(),
|
||||
monitorId: incident.monitorId ?? "",
|
||||
teamId: incident.teamId ?? "",
|
||||
startTime: incident.startTime ?? now,
|
||||
endTime: incident.endTime ?? null,
|
||||
status: incident.status ?? true,
|
||||
message: incident.message ?? null,
|
||||
statusCode: incident.statusCode ?? null,
|
||||
resolutionType: incident.resolutionType ?? null,
|
||||
resolvedBy: incident.resolvedBy ?? null,
|
||||
resolvedByEmail: incident.resolvedByEmail ?? null,
|
||||
comment: incident.comment ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
this.incidents.push(full);
|
||||
return { ...full };
|
||||
}
|
||||
|
||||
async findById(incidentId: string, teamId: string): Promise<Incident> {
|
||||
const incident = this.incidents.find((i) => i.id === incidentId && i.teamId === teamId);
|
||||
if (!incident) {
|
||||
throw new Error(`Incident ${incidentId} not found`);
|
||||
}
|
||||
return { ...incident };
|
||||
}
|
||||
|
||||
async findActiveByIncidentId(incidentId: string, teamId: string): Promise<Incident | null> {
|
||||
const incident = this.incidents.find((i) => i.id === incidentId && i.teamId === teamId && i.status === true);
|
||||
return incident ? { ...incident } : null;
|
||||
}
|
||||
|
||||
async findActiveByMonitorId(monitorId: string, teamId: string): Promise<Incident | null> {
|
||||
const incident = this.incidents.find((i) => i.monitorId === monitorId && i.teamId === teamId && i.status === true);
|
||||
return incident ? { ...incident } : null;
|
||||
}
|
||||
|
||||
async findByTeamId(
|
||||
teamId: string,
|
||||
startDate: Date | undefined,
|
||||
page: number,
|
||||
rowsPerPage: number,
|
||||
sortOrder?: string,
|
||||
status?: boolean,
|
||||
monitorId?: string,
|
||||
resolutionType?: string
|
||||
): Promise<Incident[]> {
|
||||
let results = this.incidents.filter((i) => i.teamId === teamId);
|
||||
if (startDate) {
|
||||
results = results.filter((i) => new Date(i.startTime) >= startDate);
|
||||
}
|
||||
if (status !== undefined) {
|
||||
results = results.filter((i) => i.status === status);
|
||||
}
|
||||
if (monitorId) {
|
||||
results = results.filter((i) => i.monitorId === monitorId);
|
||||
}
|
||||
if (resolutionType) {
|
||||
results = results.filter((i) => i.resolutionType === resolutionType);
|
||||
}
|
||||
results.sort((a, b) => {
|
||||
const cmp = new Date(a.startTime).getTime() - new Date(b.startTime).getTime();
|
||||
return sortOrder === "desc" ? -cmp : cmp;
|
||||
});
|
||||
return results.slice(page * rowsPerPage, (page + 1) * rowsPerPage).map((i) => ({ ...i }));
|
||||
}
|
||||
|
||||
async findSummaryByTeamId(_teamId: string, _limit?: number): Promise<IncidentSummary> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
async countByTeamId(teamId: string, startDate: Date | undefined, status?: boolean, monitorId?: string, resolutionType?: string): Promise<number> {
|
||||
let results = this.incidents.filter((i) => i.teamId === teamId);
|
||||
if (startDate) {
|
||||
results = results.filter((i) => new Date(i.startTime) >= startDate);
|
||||
}
|
||||
if (status !== undefined) {
|
||||
results = results.filter((i) => i.status === status);
|
||||
}
|
||||
if (monitorId) {
|
||||
results = results.filter((i) => i.monitorId === monitorId);
|
||||
}
|
||||
if (resolutionType) {
|
||||
results = results.filter((i) => i.resolutionType === resolutionType);
|
||||
}
|
||||
return results.length;
|
||||
}
|
||||
|
||||
async updateById(incidentId: string, teamId: string, updateData: Partial<Incident>): Promise<Incident> {
|
||||
const index = this.incidents.findIndex((i) => i.id === incidentId && i.teamId === teamId);
|
||||
if (index === -1) {
|
||||
throw new Error(`Incident ${incidentId} not found`);
|
||||
}
|
||||
const updated: Incident = {
|
||||
...this.incidents[index],
|
||||
...updateData,
|
||||
id: this.incidents[index].id,
|
||||
monitorId: this.incidents[index].monitorId,
|
||||
teamId: this.incidents[index].teamId,
|
||||
createdAt: this.incidents[index].createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
this.incidents[index] = updated;
|
||||
return { ...updated };
|
||||
}
|
||||
|
||||
async deleteByMonitorId(monitorId: string, teamId: string): Promise<number> {
|
||||
const before = this.incidents.length;
|
||||
this.incidents = this.incidents.filter((i) => !(i.monitorId === monitorId && i.teamId === teamId));
|
||||
return before - this.incidents.length;
|
||||
}
|
||||
|
||||
async deleteByMonitorIdsNotIn(monitorIds: string[]): Promise<number> {
|
||||
const before = this.incidents.length;
|
||||
this.incidents = this.incidents.filter((i) => monitorIds.includes(i.monitorId));
|
||||
return before - this.incidents.length;
|
||||
}
|
||||
|
||||
// Test helpers — not part of the interface
|
||||
|
||||
getAll(): Incident[] {
|
||||
return this.incidents.map((i) => ({ ...i }));
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.incidents = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "../../src/repositories/monitors/IMonitorsRepository.ts";
|
||||
import type { Monitor, MonitorsSummary } from "../../src/types/index.ts";
|
||||
|
||||
export class InMemoryMonitorsRepository implements IMonitorsRepository {
|
||||
private monitors: Monitor[] = [];
|
||||
|
||||
async create(monitor: Monitor, _teamId: string, _userId: string): Promise<Monitor> {
|
||||
this.monitors.push({ ...monitor });
|
||||
return { ...monitor };
|
||||
}
|
||||
|
||||
async createMonitors(monitors: Monitor[]): Promise<Monitor[]> {
|
||||
const created = monitors.map((m) => ({ ...m }));
|
||||
this.monitors.push(...created);
|
||||
return created;
|
||||
}
|
||||
|
||||
async findById(monitorId: string, teamId: string): Promise<Monitor> {
|
||||
const monitor = this.monitors.find((m) => m.id === monitorId && m.teamId === teamId);
|
||||
if (!monitor) {
|
||||
throw new Error(`Monitor ${monitorId} not found`);
|
||||
}
|
||||
return { ...monitor };
|
||||
}
|
||||
|
||||
async findAll(): Promise<Monitor[]> {
|
||||
return this.monitors.map((m) => ({ ...m }));
|
||||
}
|
||||
|
||||
async findByTeamId(_teamId: string, _config: TeamQueryConfig): Promise<Monitor[]> {
|
||||
return this.monitors.filter((m) => m.teamId === _teamId).map((m) => ({ ...m }));
|
||||
}
|
||||
|
||||
async findByIds(monitorIds: string[]): Promise<Monitor[]> {
|
||||
return this.monitors.filter((m) => monitorIds.includes(m.id)).map((m) => ({ ...m }));
|
||||
}
|
||||
|
||||
async findByIdsWithChecks(monitorIds: string[], _checksCount?: number): Promise<Monitor[]> {
|
||||
return this.findByIds(monitorIds);
|
||||
}
|
||||
|
||||
async updateById(monitorId: string, teamId: string, updates: Partial<Monitor>): Promise<Monitor> {
|
||||
const index = this.monitors.findIndex((m) => m.id === monitorId && m.teamId === teamId);
|
||||
if (index === -1) {
|
||||
throw new Error(`Monitor ${monitorId} not found`);
|
||||
}
|
||||
const updated = { ...this.monitors[index], ...updates, id: this.monitors[index].id, teamId: this.monitors[index].teamId };
|
||||
this.monitors[index] = updated;
|
||||
return { ...updated };
|
||||
}
|
||||
|
||||
async togglePauseById(monitorId: string, teamId: string): Promise<Monitor> {
|
||||
const monitor = await this.findById(monitorId, teamId);
|
||||
const newStatus = monitor.status === "paused" ? "up" : "paused";
|
||||
return this.updateById(monitorId, teamId, { status: newStatus });
|
||||
}
|
||||
|
||||
async deleteById(monitorId: string, teamId: string): Promise<Monitor> {
|
||||
const index = this.monitors.findIndex((m) => m.id === monitorId && m.teamId === teamId);
|
||||
if (index === -1) {
|
||||
throw new Error(`Monitor ${monitorId} not found`);
|
||||
}
|
||||
const [deleted] = this.monitors.splice(index, 1);
|
||||
return { ...deleted };
|
||||
}
|
||||
|
||||
async deleteByTeamId(teamId: string): Promise<{ monitors: Monitor[]; deletedCount: number }> {
|
||||
const toDelete = this.monitors.filter((m) => m.teamId === teamId);
|
||||
this.monitors = this.monitors.filter((m) => m.teamId !== teamId);
|
||||
return { monitors: toDelete, deletedCount: toDelete.length };
|
||||
}
|
||||
|
||||
async findMonitorCountByTeamIdAndType(teamId: string, _config: TeamQueryConfig): Promise<number> {
|
||||
return this.monitors.filter((m) => m.teamId === teamId).length;
|
||||
}
|
||||
|
||||
async findMonitorsSummaryByTeamId(_teamId: string, _config?: SummaryConfig): Promise<MonitorsSummary> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
async findGroupsByTeamId(_teamId: string): Promise<string[]> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
async removeNotificationFromMonitors(_notificationId: string): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
async updateNotifications(_teamId: string, _monitorIds: string[], _notificationIds: string[], _action: "add" | "remove" | "set"): Promise<number> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
async deleteByTeamIdsNotIn(teamIds: string[]): Promise<number> {
|
||||
const before = this.monitors.length;
|
||||
this.monitors = this.monitors.filter((m) => teamIds.includes(m.teamId));
|
||||
return before - this.monitors.length;
|
||||
}
|
||||
|
||||
async findAllMonitorIds(): Promise<string[]> {
|
||||
return this.monitors.map((m) => m.id);
|
||||
}
|
||||
|
||||
// Test helpers
|
||||
|
||||
seed(monitor: Monitor): void {
|
||||
this.monitors.push({ ...monitor });
|
||||
}
|
||||
|
||||
getAll(): Monitor[] {
|
||||
return this.monitors.map((m) => ({ ...m }));
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.monitors = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { jest } from "@jest/globals";
|
||||
import { SuperSimpleQueueHelper } from "../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
|
||||
import { StatusService } from "../../src/service/infrastructure/statusService.ts";
|
||||
import { IncidentService } from "../../src/service/business/incidentService.ts";
|
||||
import { InMemoryMonitorsRepository } from "./InMemoryMonitorsRepository.ts";
|
||||
import { InMemoryIncidentsRepository } from "./InMemoryIncidentsRepository.ts";
|
||||
import { createMockLogger } from "./createMockLogger.ts";
|
||||
import type { Monitor } from "../../src/types/monitor.ts";
|
||||
import type { MonitorStatusResponse } from "../../src/types/network.ts";
|
||||
import type { Check } from "../../src/types/check.ts";
|
||||
import type { MaintenanceWindow } from "../../src/types/maintenanceWindow.ts";
|
||||
|
||||
let checkCounter = 0;
|
||||
|
||||
export const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
name: "Test Monitor",
|
||||
type: "http",
|
||||
url: "https://example.com",
|
||||
status: "up",
|
||||
// Pre-fill window so threshold evaluation runs immediately.
|
||||
// Without this, the warm-up path sets monitor.status directly
|
||||
// from each check, bypassing the threshold logic.
|
||||
statusWindow: [true, true, true, true, true],
|
||||
statusWindowSize: 5,
|
||||
statusWindowThreshold: 60,
|
||||
recentChecks: [],
|
||||
notifications: [],
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
export const makeStatusResponse = (status: boolean, code: number): MonitorStatusResponse => ({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "http",
|
||||
status,
|
||||
code,
|
||||
message: status ? "OK" : "Service Unavailable",
|
||||
responseTime: status ? 150 : 0,
|
||||
});
|
||||
|
||||
export const makeCheck = (status: boolean, code: number): Check => {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: `check-${++checkCounter}`,
|
||||
metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" },
|
||||
status,
|
||||
statusCode: code,
|
||||
responseTime: status ? 150 : 0,
|
||||
message: status ? "OK" : "Service Unavailable",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
};
|
||||
|
||||
const createStubMonitorStatsRepo = () => ({
|
||||
findByMonitorId: jest.fn().mockRejectedValue(new Error("no stats")),
|
||||
create: jest.fn().mockResolvedValue({}),
|
||||
updateByMonitorId: jest.fn().mockResolvedValue({}),
|
||||
deleteByMonitorId: jest.fn(),
|
||||
deleteByMonitorIds: jest.fn(),
|
||||
deleteByMonitorIdsNotIn: jest.fn(),
|
||||
});
|
||||
|
||||
const createStubChecksRepo = () => ({
|
||||
create: jest.fn(),
|
||||
createChecks: jest.fn(),
|
||||
findByMonitorId: jest.fn(),
|
||||
findByTeamId: jest.fn(),
|
||||
findLatestByMonitorIds: jest.fn(),
|
||||
findByDateRangeAndMonitorId: jest.fn(),
|
||||
findSummaryByTeamId: jest.fn(),
|
||||
deleteByMonitorId: jest.fn(),
|
||||
deleteByTeamId: jest.fn(),
|
||||
deleteByMonitorIdsNotIn: jest.fn(),
|
||||
deleteOlderThan: jest.fn(),
|
||||
});
|
||||
|
||||
export interface HeartbeatTestHarness {
|
||||
monitorsRepo: InMemoryMonitorsRepository;
|
||||
incidentsRepo: InMemoryIncidentsRepository;
|
||||
statusService: StatusService;
|
||||
incidentService: IncidentService;
|
||||
notificationsService: { handleNotifications: jest.Mock };
|
||||
networkService: { requestStatus: jest.Mock };
|
||||
bufferStub: { addToBuffer: jest.Mock; addGeoCheckToBuffer: jest.Mock; scheduleNextFlush: jest.Mock };
|
||||
maintenanceWindowsRepo: { findByMonitorId: jest.Mock };
|
||||
messageBuilder: { extractThresholdBreaches: jest.Mock };
|
||||
heartbeatJob: (monitor: Monitor) => Promise<void>;
|
||||
setNextResponse: (status: boolean, code: number) => void;
|
||||
setNextResponseFull: (response: MonitorStatusResponse) => void;
|
||||
}
|
||||
|
||||
export function createHeartbeatTestHarness(): HeartbeatTestHarness {
|
||||
checkCounter = 0;
|
||||
|
||||
const monitorsRepo = new InMemoryMonitorsRepository();
|
||||
const incidentsRepo = new InMemoryIncidentsRepository();
|
||||
const logger = createMockLogger() as any;
|
||||
const bufferStub = { addToBuffer: jest.fn(), addGeoCheckToBuffer: jest.fn(), scheduleNextFlush: jest.fn() };
|
||||
|
||||
const statusService = new StatusService(
|
||||
logger,
|
||||
bufferStub as any,
|
||||
monitorsRepo as any,
|
||||
createStubMonitorStatsRepo() as any,
|
||||
createStubChecksRepo() as any
|
||||
);
|
||||
|
||||
const messageBuilder = { extractThresholdBreaches: jest.fn().mockReturnValue([]) };
|
||||
const incidentService = new IncidentService(logger, incidentsRepo, monitorsRepo as any, { findById: jest.fn() } as any, messageBuilder as any);
|
||||
|
||||
const notificationsService = { handleNotifications: jest.fn().mockResolvedValue(true) };
|
||||
|
||||
let nextResponse: MonitorStatusResponse | null = null;
|
||||
let nextStatus = true;
|
||||
let nextCode = 200;
|
||||
const networkService = {
|
||||
requestStatus: jest.fn().mockImplementation(() => {
|
||||
if (nextResponse) {
|
||||
return Promise.resolve(nextResponse);
|
||||
}
|
||||
return Promise.resolve(makeStatusResponse(nextStatus, nextCode));
|
||||
}),
|
||||
};
|
||||
const checkService = {
|
||||
buildCheck: jest.fn().mockImplementation((response: MonitorStatusResponse) => {
|
||||
return makeCheck(response.status, response.code);
|
||||
}),
|
||||
};
|
||||
|
||||
const setNextResponse = (status: boolean, code: number) => {
|
||||
nextResponse = null;
|
||||
nextStatus = status;
|
||||
nextCode = code;
|
||||
};
|
||||
|
||||
const setNextResponseFull = (response: MonitorStatusResponse) => {
|
||||
nextResponse = response;
|
||||
};
|
||||
|
||||
const maintenanceWindowsRepo = { findByMonitorId: jest.fn().mockResolvedValue([]) };
|
||||
|
||||
const helper = new SuperSimpleQueueHelper(
|
||||
logger,
|
||||
networkService as any,
|
||||
statusService as any,
|
||||
notificationsService as any,
|
||||
checkService as any,
|
||||
{ getDBSettings: jest.fn() } as any,
|
||||
bufferStub as any,
|
||||
incidentService as any,
|
||||
maintenanceWindowsRepo as any,
|
||||
monitorsRepo as any,
|
||||
{ findAllTeamIds: jest.fn() } as any,
|
||||
createStubMonitorStatsRepo() as any,
|
||||
createStubChecksRepo() as any,
|
||||
incidentsRepo as any,
|
||||
{ buildGeoCheck: jest.fn() } as any,
|
||||
{ deleteByMonitorIdsNotIn: jest.fn() } as any
|
||||
);
|
||||
|
||||
return {
|
||||
monitorsRepo,
|
||||
incidentsRepo,
|
||||
statusService,
|
||||
incidentService,
|
||||
notificationsService,
|
||||
networkService,
|
||||
bufferStub,
|
||||
maintenanceWindowsRepo,
|
||||
messageBuilder,
|
||||
heartbeatJob: helper.getHeartbeatJob(),
|
||||
setNextResponse,
|
||||
setNextResponseFull,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Notification } from "../../src/types/index.ts";
|
||||
import type { NotificationMessage } from "../../src/types/notificationMessage.ts";
|
||||
|
||||
export const makeNotification = (overrides?: Partial<Notification>): Notification =>
|
||||
({
|
||||
address: "https://hooks.example.com/webhook",
|
||||
accessToken: "token-abc",
|
||||
homeserverUrl: "https://matrix.example.com",
|
||||
roomId: "!room:example.com",
|
||||
...overrides,
|
||||
}) as Notification;
|
||||
|
||||
export const makeMessage = (overrides?: Partial<NotificationMessage>): NotificationMessage => ({
|
||||
type: "monitor_down",
|
||||
severity: "critical",
|
||||
monitor: { id: "mon-1", name: "Test Monitor", url: "https://example.com", type: "http", status: "down" },
|
||||
content: {
|
||||
title: "Monitor Down: Test Monitor",
|
||||
summary: 'Monitor "Test Monitor" is currently down.',
|
||||
details: ["URL: https://example.com", "Status: Down"],
|
||||
timestamp: new Date("2025-01-01T00:00:00Z"),
|
||||
},
|
||||
clientHost: "https://app.example.com",
|
||||
metadata: { teamId: "team-1", notificationReason: "status_change" },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const makeMessageWithThresholds = (): NotificationMessage =>
|
||||
makeMessage({
|
||||
type: "threshold_breach",
|
||||
severity: "warning",
|
||||
monitor: { id: "mon-1", name: "Infra Server", url: "https://infra.example.com", type: "hardware", status: "up" },
|
||||
content: {
|
||||
title: "Threshold Exceeded: Infra Server",
|
||||
summary: "Thresholds exceeded.",
|
||||
details: ["URL: https://infra.example.com"],
|
||||
thresholds: [
|
||||
{ metric: "cpu", currentValue: 90, threshold: 80, unit: "%", formattedValue: "90.0%" },
|
||||
{ metric: "memory", currentValue: 85, threshold: 70, unit: "%", formattedValue: "85.0%" },
|
||||
],
|
||||
timestamp: new Date("2025-01-01T00:00:00Z"),
|
||||
},
|
||||
});
|
||||
|
||||
export const makeMessageWithIncident = (): NotificationMessage =>
|
||||
makeMessage({
|
||||
content: {
|
||||
...makeMessage().content,
|
||||
incident: { id: "inc-1", url: "https://app.example.com/incidents/inc-1", createdAt: new Date("2025-01-01T00:00:00Z") },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import type { INotificationProvider } from "../../src/service/infrastructure/notificationProviders/INotificationProvider.ts";
|
||||
import type { Notification } from "../../src/types/index.ts";
|
||||
import type { NotificationMessage } from "../../src/types/notificationMessage.ts";
|
||||
|
||||
const makeMessage = (): NotificationMessage => ({
|
||||
type: "monitor_down",
|
||||
severity: "critical",
|
||||
monitor: { id: "mon-1", name: "Test", url: "https://example.com", type: "http", status: "down" },
|
||||
content: {
|
||||
title: "Monitor Down: Test",
|
||||
summary: "Test is down",
|
||||
details: ["URL: https://example.com"],
|
||||
timestamp: new Date("2025-01-01T00:00:00Z"),
|
||||
},
|
||||
clientHost: "https://app.example.com",
|
||||
metadata: { teamId: "team-1", notificationReason: "status_change" },
|
||||
});
|
||||
|
||||
export const testNotificationProviderContract = (
|
||||
name: string,
|
||||
opts: {
|
||||
create: () => INotificationProvider;
|
||||
makeNotification: () => Partial<Notification>;
|
||||
}
|
||||
) => {
|
||||
describe(`INotificationProvider contract: ${name}`, () => {
|
||||
it("sendTestAlert returns a boolean", async () => {
|
||||
const provider = opts.create();
|
||||
const result = await provider.sendTestAlert(opts.makeNotification());
|
||||
expect(typeof result).toBe("boolean");
|
||||
});
|
||||
|
||||
it("sendMessage returns a boolean", async () => {
|
||||
const provider = opts.create();
|
||||
const result = await provider.sendMessage(opts.makeNotification() as Notification, makeMessage());
|
||||
expect(typeof result).toBe("boolean");
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import type { IStatusProvider } from "../../src/service/infrastructure/network/IStatusProvider.ts";
|
||||
import type { Monitor } from "../../src/types/index.ts";
|
||||
|
||||
/**
|
||||
* Shared contract tests for all IStatusProvider implementations.
|
||||
* Tests the interface guarantees that every provider must satisfy.
|
||||
*/
|
||||
export const testStatusProviderContract = (
|
||||
name: string,
|
||||
opts: {
|
||||
create: () => IStatusProvider<unknown>;
|
||||
supportedType: string;
|
||||
unsupportedType: string;
|
||||
makeMonitor: () => Monitor;
|
||||
}
|
||||
) => {
|
||||
describe(`IStatusProvider contract: ${name}`, () => {
|
||||
it("has a readonly type property matching the supported type", () => {
|
||||
const provider = opts.create();
|
||||
expect(provider.type).toBe(opts.supportedType);
|
||||
});
|
||||
|
||||
it("supports() returns true for its own type", () => {
|
||||
const provider = opts.create();
|
||||
expect(provider.supports(opts.supportedType as any)).toBe(true);
|
||||
});
|
||||
|
||||
it("supports() returns false for a different type", () => {
|
||||
const provider = opts.create();
|
||||
expect(provider.supports(opts.unsupportedType as any)).toBe(false);
|
||||
});
|
||||
|
||||
it("handle() returns an object with required MonitorStatusResponse fields", async () => {
|
||||
const provider = opts.create();
|
||||
try {
|
||||
const result = await provider.handle(opts.makeMonitor());
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
monitorId: expect.anything(),
|
||||
teamId: expect.anything(),
|
||||
type: expect.anything(),
|
||||
status: expect.any(Boolean),
|
||||
code: expect.any(Number),
|
||||
message: expect.any(String),
|
||||
})
|
||||
);
|
||||
} catch (err: any) {
|
||||
// Some providers throw AppError on failure — that's also valid behavior
|
||||
expect(err).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, beforeEach } from "@jest/globals";
|
||||
import { createHeartbeatTestHarness, makeMonitor, type HeartbeatTestHarness } from "../helpers/heartbeatTestHarness.ts";
|
||||
|
||||
describe("Heartbeat job: down detection", () => {
|
||||
let h: HeartbeatTestHarness;
|
||||
|
||||
beforeEach(() => {
|
||||
h = createHeartbeatTestHarness();
|
||||
});
|
||||
|
||||
it("transitions monitor to down and creates incident after threshold failures", async () => {
|
||||
// Monitor starts with full passing window: [true, true, true, true, true]
|
||||
const monitor = makeMonitor();
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// 2 failures: window = [true, true, true, false, false] = 40% < 60%
|
||||
h.setNextResponse(false, 503);
|
||||
await h.heartbeatJob(monitor);
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
let storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(storedMonitor.status).toBe("up");
|
||||
expect(h.incidentsRepo.getAll()).toHaveLength(0);
|
||||
|
||||
// 3rd failure: window = [true, true, false, false, false] = 60% >= 60%
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(storedMonitor.status).toBe("down");
|
||||
|
||||
// Incident should have been created
|
||||
const incidents = h.incidentsRepo.getAll();
|
||||
expect(incidents).toHaveLength(1);
|
||||
expect(incidents[0].monitorId).toBe("mon-1");
|
||||
expect(incidents[0].status).toBe(true);
|
||||
expect(incidents[0].statusCode).toBe(503);
|
||||
|
||||
// Notification should have been triggered
|
||||
expect(h.notificationsService.handleNotifications).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not create duplicate incidents on continued failures", async () => {
|
||||
const monitor = makeMonitor();
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
h.setNextResponse(false, 500);
|
||||
|
||||
// 3 failures trigger down, 4 more continue failing
|
||||
for (let i = 0; i < 7; i++) {
|
||||
await h.heartbeatJob(monitor);
|
||||
}
|
||||
|
||||
// Only one incident despite 7 heartbeats
|
||||
expect(h.incidentsRepo.getAll()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("stays up when failures are below threshold", async () => {
|
||||
const monitor = makeMonitor();
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// 2 failures: window = [true, true, true, false, false] = 40% < 60%
|
||||
h.setNextResponse(false, 500);
|
||||
await h.heartbeatJob(monitor);
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
// 3 passes: window = [false, false, true, true, true] = 40% < 60%
|
||||
h.setNextResponse(true, 200);
|
||||
await h.heartbeatJob(monitor);
|
||||
await h.heartbeatJob(monitor);
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(storedMonitor.status).toBe("up");
|
||||
expect(h.incidentsRepo.getAll()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, beforeEach } from "@jest/globals";
|
||||
import { createHeartbeatTestHarness, makeMonitor, type HeartbeatTestHarness } from "../helpers/heartbeatTestHarness.ts";
|
||||
import type { MaintenanceWindow } from "../../src/types/maintenanceWindow.ts";
|
||||
|
||||
const makeMaintenanceWindow = (overrides?: Partial<MaintenanceWindow>): MaintenanceWindow => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 60 * 60 * 1000); // 1 hour ago
|
||||
const end = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour from now
|
||||
return {
|
||||
id: "mw-1",
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
active: true,
|
||||
name: "Test Maintenance",
|
||||
duration: 2,
|
||||
durationUnit: "hours",
|
||||
repeat: 0,
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
describe("Heartbeat job: maintenance windows", () => {
|
||||
let h: HeartbeatTestHarness;
|
||||
|
||||
beforeEach(() => {
|
||||
h = createHeartbeatTestHarness();
|
||||
});
|
||||
|
||||
it("skips checks and sets monitor to maintenance when window is active", async () => {
|
||||
const monitor = makeMonitor();
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// Configure an active maintenance window covering the current time
|
||||
h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([makeMaintenanceWindow()]);
|
||||
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
// Monitor status should be set to maintenance
|
||||
const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(storedMonitor.status).toBe("maintenance");
|
||||
|
||||
// No network request should have been made
|
||||
expect(h.networkService.requestStatus).not.toHaveBeenCalled();
|
||||
|
||||
// No check should have been buffered
|
||||
expect(h.bufferStub.addToBuffer).not.toHaveBeenCalled();
|
||||
|
||||
// No incident should have been created
|
||||
expect(h.incidentsRepo.getAll()).toHaveLength(0);
|
||||
|
||||
// No notification should have been sent
|
||||
expect(h.notificationsService.handleNotifications).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not update status if monitor is already in maintenance", async () => {
|
||||
const monitor = makeMonitor({ status: "maintenance" });
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([makeMaintenanceWindow()]);
|
||||
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
// findById reads the stored monitor; updateById should not have been called
|
||||
// since status was already "maintenance"
|
||||
const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(storedMonitor.status).toBe("maintenance");
|
||||
expect(h.networkService.requestStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resumes normal checks when maintenance window expires", async () => {
|
||||
const monitor = makeMonitor();
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// First heartbeat: inside maintenance window
|
||||
h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([makeMaintenanceWindow()]);
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
let storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(storedMonitor.status).toBe("maintenance");
|
||||
|
||||
// Second heartbeat: maintenance window expired (no windows returned)
|
||||
h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([]);
|
||||
h.setNextResponse(true, 200);
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
// Network request should have been made this time
|
||||
expect(h.networkService.requestStatus).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Check should have been buffered
|
||||
expect(h.bufferStub.addToBuffer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips checks when window is inactive (active: false)", async () => {
|
||||
const monitor = makeMonitor();
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// Inactive maintenance window — should be ignored
|
||||
h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([makeMaintenanceWindow({ active: false })]);
|
||||
|
||||
h.setNextResponse(true, 200);
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
// Normal check should proceed since window is inactive
|
||||
expect(h.networkService.requestStatus).toHaveBeenCalledTimes(1);
|
||||
expect(h.bufferStub.addToBuffer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips checks when window is in the past (expired)", async () => {
|
||||
const monitor = makeMonitor();
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// Window ended an hour ago
|
||||
const pastEnd = new Date(Date.now() - 60 * 60 * 1000);
|
||||
const pastStart = new Date(pastEnd.getTime() - 2 * 60 * 60 * 1000);
|
||||
h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([
|
||||
makeMaintenanceWindow({ start: pastStart.toISOString(), end: pastEnd.toISOString() }),
|
||||
]);
|
||||
|
||||
h.setNextResponse(true, 200);
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
// Window is in the past — normal check should proceed
|
||||
expect(h.networkService.requestStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, beforeEach } from "@jest/globals";
|
||||
import { createHeartbeatTestHarness, makeMonitor, type HeartbeatTestHarness } from "../helpers/heartbeatTestHarness.ts";
|
||||
|
||||
describe("Heartbeat job: notification failure isolation", () => {
|
||||
let h: HeartbeatTestHarness;
|
||||
|
||||
beforeEach(() => {
|
||||
h = createHeartbeatTestHarness();
|
||||
});
|
||||
|
||||
it("creates incident even when notification dispatch throws", async () => {
|
||||
const monitor = makeMonitor();
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// Make notifications throw
|
||||
h.notificationsService.handleNotifications.mockRejectedValue(new Error("SMTP connection refused"));
|
||||
|
||||
// Drive monitor down
|
||||
h.setNextResponse(false, 503);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await h.heartbeatJob(monitor);
|
||||
}
|
||||
|
||||
// Incident should still have been created
|
||||
const incidents = h.incidentsRepo.getAll();
|
||||
expect(incidents).toHaveLength(1);
|
||||
expect(incidents[0].status).toBe(true);
|
||||
expect(incidents[0].statusCode).toBe(503);
|
||||
|
||||
// Monitor should still be marked down
|
||||
const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(storedMonitor.status).toBe("down");
|
||||
});
|
||||
|
||||
it("auto-resolves incident even when recovery notification throws", async () => {
|
||||
const monitor = makeMonitor();
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// Drive monitor down (notifications succeed here)
|
||||
h.setNextResponse(false, 503);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await h.heartbeatJob(monitor);
|
||||
}
|
||||
expect(h.incidentsRepo.getAll()[0].status).toBe(true);
|
||||
|
||||
// Now make notifications throw for the recovery
|
||||
h.notificationsService.handleNotifications.mockRejectedValue(new Error("Slack API timeout"));
|
||||
|
||||
// Recover
|
||||
h.setNextResponse(true, 200);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await h.heartbeatJob(monitor);
|
||||
}
|
||||
|
||||
// Incident should still be resolved despite notification failure
|
||||
const incidents = h.incidentsRepo.getAll();
|
||||
expect(incidents).toHaveLength(1);
|
||||
expect(incidents[0].status).toBe(false);
|
||||
expect(incidents[0].resolutionType).toBe("automatic");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, beforeEach } from "@jest/globals";
|
||||
import { createHeartbeatTestHarness, makeMonitor, type HeartbeatTestHarness } from "../helpers/heartbeatTestHarness.ts";
|
||||
|
||||
describe("Heartbeat job: recovery", () => {
|
||||
let h: HeartbeatTestHarness;
|
||||
|
||||
beforeEach(() => {
|
||||
h = createHeartbeatTestHarness();
|
||||
});
|
||||
|
||||
it("recovers monitor and auto-resolves incident when checks pass", async () => {
|
||||
const monitor = makeMonitor();
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// 3 failures to trigger down (60% threshold)
|
||||
h.setNextResponse(false, 503);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await h.heartbeatJob(monitor);
|
||||
}
|
||||
expect(h.incidentsRepo.getAll()).toHaveLength(1);
|
||||
expect(h.incidentsRepo.getAll()[0].status).toBe(true);
|
||||
|
||||
// Send passing checks to push failure rate below threshold
|
||||
// Window after 3 fails: [false, false, false, false, false] (prior trues shifted out)
|
||||
// Need enough passes to drop below 60%
|
||||
h.setNextResponse(true, 200);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await h.heartbeatJob(monitor);
|
||||
}
|
||||
|
||||
// Monitor should be back up
|
||||
const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(storedMonitor.status).toBe("up");
|
||||
|
||||
// Incident should be auto-resolved
|
||||
const incidents = h.incidentsRepo.getAll();
|
||||
expect(incidents).toHaveLength(1);
|
||||
expect(incidents[0].status).toBe(false);
|
||||
expect(incidents[0].resolutionType).toBe("automatic");
|
||||
expect(incidents[0].endTime).not.toBeNull();
|
||||
});
|
||||
|
||||
it("creates a new incident after recovery if monitor goes down again", async () => {
|
||||
const monitor = makeMonitor();
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// First outage: 3 failures to trigger (window shifts from all-true)
|
||||
h.setNextResponse(false, 503);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await h.heartbeatJob(monitor);
|
||||
}
|
||||
|
||||
// Recovery: enough passes to drop below threshold
|
||||
h.setNextResponse(true, 200);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await h.heartbeatJob(monitor);
|
||||
}
|
||||
|
||||
// Second outage: 3 more failures to trigger again
|
||||
h.setNextResponse(false, 502);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await h.heartbeatJob(monitor);
|
||||
}
|
||||
|
||||
const incidents = h.incidentsRepo.getAll();
|
||||
expect(incidents).toHaveLength(2);
|
||||
|
||||
// First incident resolved
|
||||
expect(incidents[0].status).toBe(false);
|
||||
expect(incidents[0].resolutionType).toBe("automatic");
|
||||
expect(incidents[0].statusCode).toBe(503);
|
||||
|
||||
// Second incident active
|
||||
expect(incidents[1].status).toBe(true);
|
||||
expect(incidents[1].statusCode).toBe(502);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it, beforeEach } from "@jest/globals";
|
||||
import { createHeartbeatTestHarness, makeMonitor, type HeartbeatTestHarness } from "../helpers/heartbeatTestHarness.ts";
|
||||
import type { MonitorStatusResponse } from "../../src/types/network.ts";
|
||||
import type { HardwareStatusPayload } from "../../src/types/network.ts";
|
||||
|
||||
const makeHardwareResponse = (cpuUsage: number): MonitorStatusResponse<HardwareStatusPayload> => ({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "hardware",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "OK",
|
||||
responseTime: 50,
|
||||
payload: {
|
||||
data: {
|
||||
cpu: { usage_percent: cpuUsage, temperature: [] },
|
||||
memory: { usage_percent: 0.3 },
|
||||
disk: [{ usage_percent: 0.2 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe("Heartbeat job: hardware threshold breach", () => {
|
||||
let h: HeartbeatTestHarness;
|
||||
|
||||
beforeEach(() => {
|
||||
h = createHeartbeatTestHarness();
|
||||
});
|
||||
|
||||
it("transitions to breached and creates incident after alert counter hits zero", async () => {
|
||||
// Hardware monitor with CPU threshold at 80% (stored as 80, compared as 80/100 = 0.8)
|
||||
// Alert counters start at 5, decrement each breach, trigger at 0
|
||||
const monitor = makeMonitor({
|
||||
type: "hardware",
|
||||
status: "up",
|
||||
cpuAlertThreshold: 80,
|
||||
cpuAlertCounter: 5,
|
||||
memoryAlertThreshold: 100,
|
||||
memoryAlertCounter: 5,
|
||||
diskAlertThreshold: 100,
|
||||
diskAlertCounter: 5,
|
||||
tempAlertThreshold: 100,
|
||||
tempAlertCounter: 5,
|
||||
selectedDisks: [],
|
||||
});
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// CPU at 90% (0.9) exceeds threshold of 80% (0.8)
|
||||
h.setNextResponseFull(makeHardwareResponse(0.9));
|
||||
|
||||
// Configure message builder for when incident is created
|
||||
h.messageBuilder.extractThresholdBreaches.mockReturnValue([{ metric: "cpu", formattedValue: "90%", threshold: 80, unit: "%" }]);
|
||||
|
||||
// 4 heartbeats: counter goes 5→4→3→2→1, status still "up"
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await h.heartbeatJob(monitor);
|
||||
}
|
||||
|
||||
let storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(storedMonitor.status).toBe("up");
|
||||
expect(storedMonitor.cpuAlertCounter).toBe(1);
|
||||
expect(h.incidentsRepo.getAll()).toHaveLength(0);
|
||||
|
||||
// 5th heartbeat: counter hits 0, status transitions to "breached"
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(storedMonitor.status).toBe("breached");
|
||||
expect(storedMonitor.cpuAlertCounter).toBe(0);
|
||||
|
||||
// Incident created with statusCode 9999 and threshold message
|
||||
const incidents = h.incidentsRepo.getAll();
|
||||
expect(incidents).toHaveLength(1);
|
||||
expect(incidents[0].statusCode).toBe(9999);
|
||||
expect(incidents[0].message).toBe("CPU: 90% (threshold: 80%)");
|
||||
expect(incidents[0].status).toBe(true);
|
||||
|
||||
// Notification should have been triggered
|
||||
expect(h.notificationsService.handleNotifications).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recovers from breached to up when metrics return to normal", async () => {
|
||||
// Start with a monitor already in breached state with counter at 0
|
||||
const monitor = makeMonitor({
|
||||
type: "hardware",
|
||||
status: "breached",
|
||||
cpuAlertThreshold: 80,
|
||||
cpuAlertCounter: 0,
|
||||
memoryAlertThreshold: 100,
|
||||
memoryAlertCounter: 5,
|
||||
diskAlertThreshold: 100,
|
||||
diskAlertCounter: 5,
|
||||
tempAlertThreshold: 100,
|
||||
tempAlertCounter: 5,
|
||||
selectedDisks: [],
|
||||
});
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// Also seed an active incident for this monitor
|
||||
await h.incidentsRepo.create({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
status: true,
|
||||
statusCode: 9999,
|
||||
message: "CPU: 90% (threshold: 80%)",
|
||||
});
|
||||
|
||||
// CPU at 50% (0.5) — below threshold of 80% (0.8)
|
||||
h.setNextResponseFull(makeHardwareResponse(0.5));
|
||||
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
// Monitor should recover to "up"
|
||||
const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(storedMonitor.status).toBe("up");
|
||||
// Counter resets to 5 when not breaching
|
||||
expect(storedMonitor.cpuAlertCounter).toBe(5);
|
||||
|
||||
// Incident should be auto-resolved
|
||||
const incidents = h.incidentsRepo.getAll();
|
||||
expect(incidents).toHaveLength(1);
|
||||
expect(incidents[0].status).toBe(false);
|
||||
expect(incidents[0].resolutionType).toBe("automatic");
|
||||
});
|
||||
|
||||
it("does not create duplicate incidents while remaining breached", async () => {
|
||||
const monitor = makeMonitor({
|
||||
type: "hardware",
|
||||
status: "up",
|
||||
cpuAlertThreshold: 80,
|
||||
cpuAlertCounter: 5,
|
||||
memoryAlertThreshold: 100,
|
||||
memoryAlertCounter: 5,
|
||||
diskAlertThreshold: 100,
|
||||
diskAlertCounter: 5,
|
||||
tempAlertThreshold: 100,
|
||||
tempAlertCounter: 5,
|
||||
selectedDisks: [],
|
||||
});
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
h.setNextResponseFull(makeHardwareResponse(0.9));
|
||||
h.messageBuilder.extractThresholdBreaches.mockReturnValue([{ metric: "cpu", formattedValue: "90%", threshold: 80, unit: "%" }]);
|
||||
|
||||
// 5 heartbeats to trigger breach, then 5 more while still breaching
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await h.heartbeatJob(monitor);
|
||||
}
|
||||
|
||||
// Only one incident despite 10 heartbeats
|
||||
expect(h.incidentsRepo.getAll()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { IncidentService } from "../../src/service/business/incidentService.ts";
|
||||
import { InMemoryIncidentsRepository } from "../helpers/InMemoryIncidentsRepository.ts";
|
||||
import { createMockLogger } from "../helpers/createMockLogger.ts";
|
||||
import type { IMonitorsRepository, IUsersRepository } from "../../src/repositories/index.ts";
|
||||
import type { INotificationMessageBuilder } from "../../src/service/infrastructure/notificationMessageBuilder.ts";
|
||||
import type { Monitor } from "../../src/types/monitor.ts";
|
||||
import type { MonitorActionDecision } from "../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
name: "Test Monitor",
|
||||
type: "http",
|
||||
url: "https://example.com",
|
||||
status: "down",
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
|
||||
shouldCreateIncident: false,
|
||||
shouldResolveIncident: false,
|
||||
shouldSendNotification: false,
|
||||
incidentReason: null,
|
||||
notificationReason: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ── Test suite ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Incident lifecycle (integration)", () => {
|
||||
let repo: InMemoryIncidentsRepository;
|
||||
let service: IncidentService;
|
||||
let monitorsRepo: jest.Mocked<IMonitorsRepository>;
|
||||
let usersRepo: jest.Mocked<IUsersRepository>;
|
||||
let messageBuilder: jest.Mocked<INotificationMessageBuilder>;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = new InMemoryIncidentsRepository();
|
||||
monitorsRepo = { findById: jest.fn() } as unknown as jest.Mocked<IMonitorsRepository>;
|
||||
usersRepo = { findById: jest.fn() } as unknown as jest.Mocked<IUsersRepository>;
|
||||
messageBuilder = { extractThresholdBreaches: jest.fn() } as unknown as jest.Mocked<INotificationMessageBuilder>;
|
||||
service = new IncidentService(createMockLogger() as any, repo, monitorsRepo, usersRepo, messageBuilder);
|
||||
});
|
||||
|
||||
// ── Creation ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("creates an incident when monitor goes down", async () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
|
||||
|
||||
const incident = await service.handleIncident(monitor, 503, decision);
|
||||
|
||||
expect(incident).not.toBeNull();
|
||||
expect(incident!.monitorId).toBe("mon-1");
|
||||
expect(incident!.teamId).toBe("team-1");
|
||||
expect(incident!.status).toBe(true);
|
||||
expect(incident!.statusCode).toBe(503);
|
||||
expect(incident!.endTime).toBeNull();
|
||||
expect(incident!.resolutionType).toBeNull();
|
||||
|
||||
const stored = repo.getAll();
|
||||
expect(stored).toHaveLength(1);
|
||||
expect(stored[0].id).toBe(incident!.id);
|
||||
});
|
||||
|
||||
// ── Idempotency ──────────────────────────────────────────────────────────
|
||||
|
||||
it("does not create a duplicate incident for the same monitor", async () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
|
||||
|
||||
const first = await service.handleIncident(monitor, 503, decision);
|
||||
const second = await service.handleIncident(monitor, 503, decision);
|
||||
|
||||
expect(second!.id).toBe(first!.id);
|
||||
expect(repo.getAll()).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ── Auto-resolve ─────────────────────────────────────────────────────────
|
||||
|
||||
it("auto-resolves an incident when monitor recovers", async () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
const createDecision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
|
||||
const created = await service.handleIncident(monitor, 503, createDecision);
|
||||
|
||||
const resolveDecision = makeDecision({ shouldResolveIncident: true });
|
||||
const resolved = await service.handleIncident(monitor, 200, resolveDecision);
|
||||
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.id).toBe(created!.id);
|
||||
expect(resolved!.status).toBe(false);
|
||||
expect(resolved!.endTime).toBeDefined();
|
||||
expect(resolved!.resolutionType).toBe("automatic");
|
||||
});
|
||||
|
||||
// ── Resolve with nothing active ──────────────────────────────────────────
|
||||
|
||||
it("returns null when resolving with no active incident", async () => {
|
||||
const monitor = makeMonitor({ status: "up" });
|
||||
const decision = makeDecision({ shouldResolveIncident: true });
|
||||
|
||||
const result = await service.handleIncident(monitor, 200, decision);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
// ── Manual resolution ────────────────────────────────────────────────────
|
||||
|
||||
it("manually resolves an active incident with comment", async () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
|
||||
const created = await service.handleIncident(monitor, 500, decision);
|
||||
|
||||
const resolved = await service.resolveIncident(created!.id, "user-1", "team-1", "Root cause identified", "user@test.com");
|
||||
|
||||
expect(resolved.status).toBe(false);
|
||||
expect(resolved.resolutionType).toBe("manual");
|
||||
expect(resolved.resolvedBy).toBe("user-1");
|
||||
expect(resolved.resolvedByEmail).toBe("user@test.com");
|
||||
expect(resolved.comment).toBe("Root cause identified");
|
||||
expect(resolved.endTime).toBeDefined();
|
||||
});
|
||||
|
||||
it("throws when manually resolving an already-resolved incident", async () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
|
||||
const created = await service.handleIncident(monitor, 500, decision);
|
||||
|
||||
await service.resolveIncident(created!.id, "user-1", "team-1");
|
||||
|
||||
await expect(service.resolveIncident(created!.id, "user-1", "team-1")).rejects.toThrow("Incident not found");
|
||||
});
|
||||
|
||||
// ── Threshold breach ─────────────────────────────────────────────────────
|
||||
|
||||
it("creates a threshold breach incident with statusCode 9999 and descriptive message", async () => {
|
||||
const monitor = makeMonitor({ status: "breached", type: "hardware" });
|
||||
(messageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([
|
||||
{ metric: "cpu", formattedValue: "92%", threshold: 80, unit: "%" },
|
||||
{ metric: "memory", formattedValue: "88%", threshold: 85, unit: "%" },
|
||||
]);
|
||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
|
||||
|
||||
const incident = await service.handleIncident(monitor, 200, decision, { monitorId: "mon-1" } as any);
|
||||
|
||||
expect(incident!.statusCode).toBe(9999);
|
||||
expect(incident!.message).toBe("CPU: 92% (threshold: 80%), MEMORY: 88% (threshold: 85%)");
|
||||
});
|
||||
|
||||
// ── No action ────────────────────────────────────────────────────────────
|
||||
|
||||
it("returns null when no action is needed", async () => {
|
||||
const monitor = makeMonitor({ status: "up" });
|
||||
const decision = makeDecision();
|
||||
|
||||
const result = await service.handleIncident(monitor, 200, decision);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(repo.getAll()).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── Full lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
it("handles a complete lifecycle: create -> manual resolve -> new incident -> auto-resolve", async () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
|
||||
// First outage
|
||||
const first = await service.handleIncident(monitor, 503, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }));
|
||||
expect(first!.status).toBe(true);
|
||||
|
||||
// Manually resolved
|
||||
await service.resolveIncident(first!.id, "user-1", "team-1", "Restarted server");
|
||||
|
||||
// Second outage — new incident since previous was resolved
|
||||
const second = await service.handleIncident(monitor, 502, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }));
|
||||
expect(second!.id).not.toBe(first!.id);
|
||||
expect(second!.statusCode).toBe(502);
|
||||
|
||||
// Auto-resolve
|
||||
const resolved = await service.handleIncident(monitor, 200, makeDecision({ shouldResolveIncident: true }));
|
||||
expect(resolved!.id).toBe(second!.id);
|
||||
expect(resolved!.status).toBe(false);
|
||||
expect(resolved!.resolutionType).toBe("automatic");
|
||||
|
||||
// Both incidents stored, both resolved
|
||||
const all = repo.getAll();
|
||||
expect(all).toHaveLength(2);
|
||||
expect(all.every((i) => i.status === false)).toBe(true);
|
||||
});
|
||||
|
||||
// ── Cross-monitor isolation ──────────────────────────────────────────────
|
||||
|
||||
it("incidents for different monitors do not interfere", async () => {
|
||||
const monitorA = makeMonitor({ id: "mon-a", status: "down" });
|
||||
const monitorB = makeMonitor({ id: "mon-b", status: "down" });
|
||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
|
||||
|
||||
const incidentA = await service.handleIncident(monitorA, 500, decision);
|
||||
const incidentB = await service.handleIncident(monitorB, 502, decision);
|
||||
|
||||
expect(incidentA!.id).not.toBe(incidentB!.id);
|
||||
expect(repo.getAll()).toHaveLength(2);
|
||||
|
||||
// Resolving monitor A does not affect monitor B
|
||||
const resolved = await service.handleIncident(monitorA, 200, makeDecision({ shouldResolveIncident: true }));
|
||||
expect(resolved!.id).toBe(incidentA!.id);
|
||||
|
||||
const bStillActive = await repo.findActiveByMonitorId("mon-b", "team-1");
|
||||
expect(bStillActive).not.toBeNull();
|
||||
expect(bStillActive!.status).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { AdvancedMatcher } from "../../../../src/service/infrastructure/network/AdvancedMatcher.ts";
|
||||
import type { Monitor } from "../../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const createMockJmespath = () => ({
|
||||
search: jest.fn((data: any, expr: string) => {
|
||||
// Simple dot-path evaluation for tests
|
||||
return expr.split(".").reduce((acc: any, key: string) => acc?.[key], data);
|
||||
}),
|
||||
});
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
useAdvancedMatching: false,
|
||||
jsonPath: undefined,
|
||||
matchMethod: undefined,
|
||||
expectedValue: undefined,
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AdvancedMatcher", () => {
|
||||
describe("validate", () => {
|
||||
it("returns ok when advanced matching is disabled", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate({ data: "test" }, makeMonitor({ useAdvancedMatching: false }));
|
||||
|
||||
expect(result).toEqual({ ok: true, message: "Success" });
|
||||
});
|
||||
|
||||
// ── jsonPath extraction ──────────────────────────────────────────
|
||||
|
||||
describe("jsonPath extraction", () => {
|
||||
it("extracts value using jsonPath", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate({ status: "healthy" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "status" }));
|
||||
|
||||
expect(result).toEqual({ ok: true, message: "Success", extracted: "healthy" });
|
||||
});
|
||||
|
||||
it("returns error when jsonPath evaluation throws", () => {
|
||||
const jmespath = {
|
||||
search: jest.fn().mockImplementation(() => {
|
||||
throw new Error("bad expression");
|
||||
}),
|
||||
};
|
||||
const matcher = new AdvancedMatcher(jmespath as any);
|
||||
|
||||
const result = matcher.validate({ data: "test" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "invalid[" }));
|
||||
|
||||
expect(result).toEqual({ ok: false, message: "Error evaluating JSON path" });
|
||||
});
|
||||
});
|
||||
|
||||
// ── expectedValue matching ───────────────────────────────────────
|
||||
|
||||
describe("expectedValue with matchMethod", () => {
|
||||
it("matches with 'equal' method", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate(
|
||||
{ status: "ok" },
|
||||
makeMonitor({ useAdvancedMatching: true, jsonPath: "status", matchMethod: "equal", expectedValue: "ok" })
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("fails with 'equal' method on mismatch", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate(
|
||||
{ status: "error" },
|
||||
makeMonitor({ useAdvancedMatching: true, jsonPath: "status", matchMethod: "equal", expectedValue: "ok" })
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toBe("Expected value did not match");
|
||||
});
|
||||
|
||||
it("matches with 'include' method", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate(
|
||||
{ msg: "server is healthy" },
|
||||
makeMonitor({ useAdvancedMatching: true, jsonPath: "msg", matchMethod: "include", expectedValue: "healthy" })
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("fails with 'include' method when substring not found", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate(
|
||||
{ msg: "server is down" },
|
||||
makeMonitor({ useAdvancedMatching: true, jsonPath: "msg", matchMethod: "include", expectedValue: "healthy" })
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("matches with 'regex' method", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate(
|
||||
{ code: "200" },
|
||||
makeMonitor({ useAdvancedMatching: true, jsonPath: "code", matchMethod: "regex", expectedValue: "^2\\d{2}$" })
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("fails with 'regex' method on mismatch", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate(
|
||||
{ code: "500" },
|
||||
makeMonitor({ useAdvancedMatching: true, jsonPath: "code", matchMethod: "regex", expectedValue: "^2\\d{2}$" })
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults to 'equal' comparison when matchMethod is undefined", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate({ val: "exact" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "val", expectedValue: "exact" }));
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults to 'equal' comparison for unknown matchMethod", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate(
|
||||
{ val: "exact" },
|
||||
makeMonitor({ useAdvancedMatching: true, jsonPath: "val", matchMethod: "unknown" as any, expectedValue: "exact" })
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("compares entire payload when jsonPath is not set", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate(
|
||||
"hello",
|
||||
makeMonitor({ useAdvancedMatching: true, jsonPath: undefined, matchMethod: "equal", expectedValue: "hello" })
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.extracted).toBe("hello");
|
||||
});
|
||||
|
||||
it("includes extracted value in result", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate(
|
||||
{ nested: { value: "42" } },
|
||||
makeMonitor({ useAdvancedMatching: true, jsonPath: "nested.value", matchMethod: "equal", expectedValue: "42" })
|
||||
);
|
||||
|
||||
expect(result.extracted).toBe("42");
|
||||
});
|
||||
});
|
||||
|
||||
// ── falsy value check (no expectedValue) ────────────────────────
|
||||
|
||||
describe("falsy value check (no expectedValue)", () => {
|
||||
it("returns ok for truthy extracted value", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate({ status: "up" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "status" }));
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.extracted).toBe("up");
|
||||
});
|
||||
|
||||
it("returns not ok for false", () => {
|
||||
const jmespath = { search: jest.fn().mockReturnValue(false) };
|
||||
const matcher = new AdvancedMatcher(jmespath as any);
|
||||
|
||||
const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "missing" }));
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toBe("Extracted value is falsy");
|
||||
});
|
||||
|
||||
it("returns not ok for string 'false'", () => {
|
||||
const jmespath = { search: jest.fn().mockReturnValue("false") };
|
||||
const matcher = new AdvancedMatcher(jmespath as any);
|
||||
|
||||
const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" }));
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("returns not ok for undefined", () => {
|
||||
const jmespath = { search: jest.fn().mockReturnValue(undefined) };
|
||||
const matcher = new AdvancedMatcher(jmespath as any);
|
||||
|
||||
const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" }));
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("returns not ok for null", () => {
|
||||
const jmespath = { search: jest.fn().mockReturnValue(null) };
|
||||
const matcher = new AdvancedMatcher(jmespath as any);
|
||||
|
||||
const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" }));
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("returns ok for zero (not in falsy list)", () => {
|
||||
const jmespath = { search: jest.fn().mockReturnValue(0) };
|
||||
const matcher = new AdvancedMatcher(jmespath as any);
|
||||
|
||||
const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" }));
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("returns ok for empty string (not in falsy list)", () => {
|
||||
const jmespath = { search: jest.fn().mockReturnValue("") };
|
||||
const matcher = new AdvancedMatcher(jmespath as any);
|
||||
|
||||
const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" }));
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { DockerProvider } from "../../../../src/service/infrastructure/network/DockerProvider.ts";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
|
||||
import type { Monitor } from "../../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "docker",
|
||||
url: "my-container",
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const makeContainer = (overrides?: Record<string, any>) => ({
|
||||
Id: "abc123def456abc123def456abc123def456abc123def456abc123def456abcd",
|
||||
Names: ["/my-container"],
|
||||
State: "running",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockDocker = (containers: any[] = [makeContainer()], inspectResult?: any) => {
|
||||
const inspect = jest.fn().mockResolvedValue(inspectResult ?? { State: { Status: "running" } });
|
||||
const getContainer = jest.fn().mockReturnValue({ inspect });
|
||||
|
||||
const instance = {
|
||||
listContainers: jest.fn().mockResolvedValue(containers),
|
||||
getContainer,
|
||||
};
|
||||
|
||||
const DockerLib = jest.fn().mockReturnValue(instance) as any;
|
||||
|
||||
return { DockerLib, instance, inspect, getContainer };
|
||||
};
|
||||
|
||||
const createProvider = (opts?: { containers?: any[]; inspectResult?: any }) => {
|
||||
const logger = createMockLogger();
|
||||
const { DockerLib, instance, inspect, getContainer } = createMockDocker(opts?.containers, opts?.inspectResult);
|
||||
const provider = new DockerProvider(logger as any, DockerLib);
|
||||
return { provider, logger, docker: instance, inspect, getContainer };
|
||||
};
|
||||
|
||||
// ── Contract ─────────────────────────────────────────────────────────────────
|
||||
|
||||
testStatusProviderContract("DockerProvider", {
|
||||
create: () => createProvider().provider,
|
||||
supportedType: "docker",
|
||||
unsupportedType: "http",
|
||||
makeMonitor: () => makeMonitor(),
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("DockerProvider", () => {
|
||||
// ── Container matching ───────────────────────────────────────────────
|
||||
|
||||
describe("container matching", () => {
|
||||
it("matches by exact container name", async () => {
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor({ url: "my-container" }));
|
||||
|
||||
expect(result.status).toBe(true);
|
||||
expect(result.code).toBe(200);
|
||||
});
|
||||
|
||||
it("matches by exact full ID (64 chars)", async () => {
|
||||
const fullId = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd";
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor({ url: fullId }));
|
||||
|
||||
expect(result.status).toBe(true);
|
||||
});
|
||||
|
||||
it("matches by partial ID prefix", async () => {
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor({ url: "abc123" }));
|
||||
|
||||
expect(result.status).toBe(true);
|
||||
});
|
||||
|
||||
it("strips leading slashes from input", async () => {
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor({ url: "///my-container" }));
|
||||
|
||||
expect(result.status).toBe(true);
|
||||
});
|
||||
|
||||
it("matches case-insensitively", async () => {
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor({ url: "MY-CONTAINER" }));
|
||||
|
||||
expect(result.status).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 404 when no container matches", async () => {
|
||||
const { provider, logger } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor({ url: "nonexistent" }));
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.code).toBe(404);
|
||||
expect(result.message).toBe("Docker container not found");
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("No container found"),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Ambiguity detection ──────────────────────────────────────────────
|
||||
|
||||
describe("ambiguity detection", () => {
|
||||
it("returns error when input matches both exact name and partial ID", async () => {
|
||||
// Container whose name matches the input AND whose ID starts with the input
|
||||
const container = makeContainer({
|
||||
Id: "my-containerabc123def456abc123def456abc123def456abc123def456abcd",
|
||||
Names: ["/my-container"],
|
||||
});
|
||||
const { provider, logger } = createProvider({ containers: [container] });
|
||||
|
||||
const result = await provider.handle(makeMonitor({ url: "my-container" }));
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.code).toBe(NETWORK_ERROR);
|
||||
expect(result.message).toContain("Ambiguous");
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("Ambiguous"),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("reports 'exact ID' in ambiguity message when exact ID and name both match", async () => {
|
||||
const fullId = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd";
|
||||
const container = makeContainer({
|
||||
Id: fullId,
|
||||
Names: ["/" + fullId],
|
||||
});
|
||||
const { provider } = createProvider({ containers: [container] });
|
||||
|
||||
const result = await provider.handle(makeMonitor({ url: fullId }));
|
||||
|
||||
expect(result.message).toContain("Using exact ID");
|
||||
});
|
||||
|
||||
it("reports 'exact name' in ambiguity message when name and partial ID match", async () => {
|
||||
const container = makeContainer({
|
||||
Id: "my-containerabc123def456abc123def456abc123def456abc123def456abcd",
|
||||
Names: ["/my-container"],
|
||||
});
|
||||
const { provider } = createProvider({ containers: [container] });
|
||||
|
||||
const result = await provider.handle(makeMonitor({ url: "my-container" }));
|
||||
|
||||
expect(result.message).toContain("Using exact name");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Container inspection ─────────────────────────────────────────────
|
||||
|
||||
describe("container inspection", () => {
|
||||
it("returns running status for running container", async () => {
|
||||
const { provider } = createProvider({ inspectResult: { State: { Status: "running" } } });
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(true);
|
||||
expect(result.message).toBe("Docker container status fetched successfully");
|
||||
});
|
||||
|
||||
it("returns not-running status for stopped container", async () => {
|
||||
const { provider } = createProvider({ inspectResult: { State: { Status: "exited" } } });
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
});
|
||||
|
||||
it("returns NETWORK_ERROR when inspect response is null", async () => {
|
||||
const logger = createMockLogger();
|
||||
const inspect = jest.fn().mockResolvedValue(null);
|
||||
const getContainer = jest.fn().mockReturnValue({ inspect });
|
||||
const instance = { listContainers: jest.fn().mockResolvedValue([makeContainer()]), getContainer };
|
||||
const DockerLib = jest.fn().mockReturnValue(instance) as any;
|
||||
const provider = new DockerProvider(logger as any, DockerLib);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.code).toBe(NETWORK_ERROR);
|
||||
expect(result.message).toBe("No response from Docker container inspect");
|
||||
});
|
||||
|
||||
it("returns false status when inspect response has no State", async () => {
|
||||
const { provider } = createProvider({ inspectResult: {} });
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false status when inspect response has State but no Status", async () => {
|
||||
const { provider } = createProvider({ inspectResult: { State: {} } });
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
});
|
||||
|
||||
it("handles inspect error with Docker-specific error", async () => {
|
||||
const { provider } = createProvider();
|
||||
// Make inspect throw a Docker error
|
||||
const dockerErr = Object.assign(new Error("container not found"), {
|
||||
statusCode: 404,
|
||||
reason: "no such container",
|
||||
json: { message: "Container not found" },
|
||||
});
|
||||
provider["docker"] = {
|
||||
listContainers: jest.fn().mockResolvedValue([makeContainer()]),
|
||||
getContainer: jest.fn().mockReturnValue({
|
||||
inspect: jest.fn().mockRejectedValue(dockerErr),
|
||||
}),
|
||||
} as any;
|
||||
|
||||
// Need to re-assign; simpler to use the createProvider mock
|
||||
const { provider: p2 } = createProvider();
|
||||
// Override getContainer to return failing inspect
|
||||
p2["docker"].getContainer = jest.fn().mockReturnValue({
|
||||
inspect: jest.fn().mockRejectedValue(dockerErr),
|
||||
});
|
||||
|
||||
const result = await p2.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.code).toBe(404);
|
||||
expect(result.message).toBe("Container not found");
|
||||
});
|
||||
|
||||
it("handles inspect error with Docker error using reason fallback", async () => {
|
||||
const { provider } = createProvider();
|
||||
const dockerErr = Object.assign(new Error("error"), {
|
||||
statusCode: 500,
|
||||
reason: "internal error",
|
||||
json: {},
|
||||
});
|
||||
provider["docker"].getContainer = jest.fn().mockReturnValue({
|
||||
inspect: jest.fn().mockRejectedValue(dockerErr),
|
||||
});
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.message).toBe("internal error");
|
||||
});
|
||||
|
||||
it("handles inspect error with Docker error using Error message fallback", async () => {
|
||||
const { provider } = createProvider();
|
||||
const dockerErr = Object.assign(new Error("base error"), {
|
||||
statusCode: 503,
|
||||
reason: undefined,
|
||||
json: undefined,
|
||||
});
|
||||
provider["docker"].getContainer = jest.fn().mockReturnValue({
|
||||
inspect: jest.fn().mockRejectedValue(dockerErr),
|
||||
});
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.message).toBe("base error");
|
||||
});
|
||||
|
||||
it("handles inspect error with NETWORK_ERROR when no statusCode", async () => {
|
||||
const { provider } = createProvider();
|
||||
const dockerErr = Object.assign(new Error("unknown"), {
|
||||
statusCode: undefined,
|
||||
});
|
||||
provider["docker"].getContainer = jest.fn().mockReturnValue({
|
||||
inspect: jest.fn().mockRejectedValue(dockerErr),
|
||||
});
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.code).toBe(NETWORK_ERROR);
|
||||
});
|
||||
|
||||
it("handles inspect error with standard Error (not Docker error)", async () => {
|
||||
const { provider } = createProvider();
|
||||
provider["docker"].getContainer = jest.fn().mockReturnValue({
|
||||
inspect: jest.fn().mockRejectedValue(new Error("ECONNREFUSED")),
|
||||
});
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.message).toBe("ECONNREFUSED");
|
||||
});
|
||||
|
||||
it("handles inspect error with non-Error non-Docker thrown value", async () => {
|
||||
const { provider } = createProvider();
|
||||
provider["docker"].getContainer = jest.fn().mockReturnValue({
|
||||
inspect: jest.fn().mockRejectedValue("string error"),
|
||||
});
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.message).toBe("Failed to fetch Docker container information");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Outer catch ──────────────────────────────────────────────────────
|
||||
|
||||
describe("outer error handling", () => {
|
||||
it("throws AppError when containerInput is missing", async () => {
|
||||
const { provider } = createProvider();
|
||||
|
||||
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("Container name or ID is required for Docker monitor");
|
||||
});
|
||||
|
||||
it("throws AppError when listContainers throws", async () => {
|
||||
const { provider } = createProvider();
|
||||
provider["docker"].listContainers = jest.fn().mockRejectedValue(new Error("Docker daemon unavailable"));
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("Docker daemon unavailable");
|
||||
});
|
||||
|
||||
it("throws AppError with default message for non-Error thrown values", async () => {
|
||||
const { provider } = createProvider();
|
||||
provider["docker"].listContainers = jest.fn().mockRejectedValue(null);
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing Docker request");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { GameProvider } from "../../../../src/service/infrastructure/network/GameProvider.ts";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
|
||||
import type { Monitor } from "../../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "game",
|
||||
url: "play.example.com",
|
||||
port: 25565,
|
||||
gameId: "minecraft",
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const createMockGameDig = (state?: Record<string, any> | null) => ({
|
||||
query: jest.fn().mockResolvedValue(
|
||||
state ?? {
|
||||
name: "My Server",
|
||||
map: "world",
|
||||
players: [],
|
||||
ping: 45,
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
const createProvider = (gameDig?: any) => {
|
||||
const logger = createMockLogger();
|
||||
const dig = gameDig ?? createMockGameDig();
|
||||
const provider = new GameProvider(logger as any, dig as any);
|
||||
return { provider, logger, gameDig: dig };
|
||||
};
|
||||
|
||||
// ── Contract ─────────────────────────────────────────────────────────────────
|
||||
|
||||
testStatusProviderContract("GameProvider", {
|
||||
create: () => createProvider().provider,
|
||||
supportedType: "game",
|
||||
unsupportedType: "http",
|
||||
makeMonitor: () => makeMonitor(),
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GameProvider", () => {
|
||||
it("returns success with server state", async () => {
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "game",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "Success",
|
||||
responseTime: 45,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("queries with correct host, port, and gameId", async () => {
|
||||
const { provider, gameDig } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ url: "https://play.example.com/path", port: 27015, gameId: "csgo" }));
|
||||
|
||||
expect(gameDig.query).toHaveBeenCalledWith({
|
||||
type: "csgo",
|
||||
host: "play.example.com",
|
||||
port: 27015,
|
||||
});
|
||||
});
|
||||
|
||||
it("strips protocol and path from url for host", async () => {
|
||||
const { provider, gameDig } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ url: "http://game.server.com:8080/info" }));
|
||||
|
||||
expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ host: "game.server.com" }));
|
||||
});
|
||||
|
||||
it("defaults gameId to 'unknown' when not set", async () => {
|
||||
const { provider, gameDig } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ gameId: undefined }));
|
||||
|
||||
expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ type: "unknown" }));
|
||||
});
|
||||
|
||||
it("defaults port to 0 when not set", async () => {
|
||||
const { provider, gameDig } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ port: undefined }));
|
||||
|
||||
expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ port: 0 }));
|
||||
});
|
||||
|
||||
it("defaults responseTime to 0 when ping is undefined", async () => {
|
||||
const { provider } = createProvider(createMockGameDig({ ping: undefined }));
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.responseTime).toBe(0);
|
||||
});
|
||||
|
||||
it("returns failure when query resolves to undefined (caught error)", async () => {
|
||||
const gameDig = { query: jest.fn().mockRejectedValue(new Error("Server offline")) };
|
||||
// GameProvider catches the error internally via .catch()
|
||||
const { provider, logger } = createProvider(gameDig);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: "No response from game server",
|
||||
responseTime: 0,
|
||||
})
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Server offline",
|
||||
method: "handle",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs non-Error thrown values from query", async () => {
|
||||
const gameDig = { query: jest.fn().mockRejectedValue("string error") };
|
||||
const { provider, logger } = createProvider(gameDig);
|
||||
|
||||
await provider.handle(makeMonitor());
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "string error" }));
|
||||
});
|
||||
|
||||
it("uses empty string as host when url is undefined", async () => {
|
||||
const { provider, gameDig } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ url: undefined }));
|
||||
|
||||
expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ host: "" }));
|
||||
});
|
||||
|
||||
it("throws AppError with default message when error stringifies to empty", async () => {
|
||||
const gameDig = {
|
||||
query: jest.fn().mockReturnValue({
|
||||
catch: () => {
|
||||
throw new Error("");
|
||||
},
|
||||
}),
|
||||
};
|
||||
const { provider } = createProvider(gameDig);
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing game server check");
|
||||
});
|
||||
|
||||
it("throws AppError with default message for non-Error that stringifies to empty", async () => {
|
||||
const gameDig = {
|
||||
query: jest.fn().mockReturnValue({
|
||||
catch: () => {
|
||||
throw "";
|
||||
},
|
||||
}),
|
||||
};
|
||||
const { provider } = createProvider(gameDig);
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing game server check");
|
||||
});
|
||||
|
||||
it("throws AppError with Error message in outer catch", async () => {
|
||||
const gameDig = {
|
||||
query: jest.fn().mockReturnValue({
|
||||
catch: () => {
|
||||
throw new Error("unexpected");
|
||||
},
|
||||
}),
|
||||
};
|
||||
const { provider } = createProvider(gameDig);
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("unexpected");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,378 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { GrpcProvider } from "../../../../src/service/infrastructure/network/GrpcProvider.ts";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import type { Monitor } from "../../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "grpc",
|
||||
url: "grpc.example.com",
|
||||
port: 50051,
|
||||
grpcServiceName: "my.Service",
|
||||
ignoreTlsErrors: false,
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const createMockClient = (behavior: "serving" | "not-serving" | "error" | "grpc-error" = "serving") => {
|
||||
const close = jest.fn();
|
||||
const Check = jest.fn((_req: any, _opts: any, cb: Function) => {
|
||||
if (behavior === "serving") {
|
||||
cb(null, { status: "SERVING" });
|
||||
} else if (behavior === "not-serving") {
|
||||
cb(null, { status: "NOT_SERVING" });
|
||||
} else if (behavior === "error") {
|
||||
cb({ code: 14, details: "Connection refused", message: "unavailable" });
|
||||
} else if (behavior === "grpc-error") {
|
||||
cb({ code: 2, message: "Unknown error" });
|
||||
}
|
||||
});
|
||||
return { Check, close };
|
||||
};
|
||||
|
||||
const createMockGrpc = (client?: ReturnType<typeof createMockClient>) => {
|
||||
const c = client ?? createMockClient();
|
||||
const Health = jest.fn().mockReturnValue(c);
|
||||
|
||||
return {
|
||||
grpc: {
|
||||
loadPackageDefinition: jest.fn().mockReturnValue({
|
||||
grpc: { health: { v1: { Health } } },
|
||||
}),
|
||||
credentials: {
|
||||
createInsecure: jest.fn().mockReturnValue("insecure-creds"),
|
||||
createSsl: jest.fn().mockReturnValue("ssl-creds"),
|
||||
},
|
||||
},
|
||||
protoLoader: {
|
||||
loadSync: jest.fn().mockReturnValue({}),
|
||||
},
|
||||
Health,
|
||||
client: c,
|
||||
};
|
||||
};
|
||||
|
||||
const createProvider = (opts?: { behavior?: "serving" | "not-serving" | "error" | "grpc-error" }) => {
|
||||
const client = createMockClient(opts?.behavior ?? "serving");
|
||||
const mocks = createMockGrpc(client);
|
||||
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
|
||||
return { provider, ...mocks, client };
|
||||
};
|
||||
|
||||
// ── Contract ─────────────────────────────────────────────────────────────────
|
||||
|
||||
testStatusProviderContract("GrpcProvider", {
|
||||
create: () => createProvider().provider,
|
||||
supportedType: "grpc",
|
||||
unsupportedType: "http",
|
||||
makeMonitor: () => makeMonitor(),
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GrpcProvider", () => {
|
||||
// ── Success paths ────────────────────────────────────────────────────
|
||||
|
||||
describe("success responses", () => {
|
||||
it("returns healthy status for SERVING response", async () => {
|
||||
const { provider } = createProvider({ behavior: "serving" });
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "grpc",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: expect.stringContaining("SERVING"),
|
||||
})
|
||||
);
|
||||
expect(result.payload).toEqual(
|
||||
expect.objectContaining({
|
||||
grpcStatusCode: 0,
|
||||
grpcStatusName: "OK",
|
||||
serviceName: "my.Service",
|
||||
servingStatus: "SERVING",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns unhealthy status for NOT_SERVING response", async () => {
|
||||
const { provider } = createProvider({ behavior: "not-serving" });
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.code).toBe(5000);
|
||||
expect(result.message).toContain("NOT_SERVING");
|
||||
});
|
||||
|
||||
it("defaults to UNKNOWN when response has no status", async () => {
|
||||
const client = { Check: jest.fn((_r: any, _o: any, cb: Function) => cb(null, {})), close: jest.fn() };
|
||||
const mocks = createMockGrpc(client as any);
|
||||
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.payload).toEqual(expect.objectContaining({ servingStatus: "UNKNOWN" }));
|
||||
});
|
||||
|
||||
it("defaults to UNKNOWN when response is undefined", async () => {
|
||||
const client = { Check: jest.fn((_r: any, _o: any, cb: Function) => cb(null, undefined)), close: jest.fn() };
|
||||
const mocks = createMockGrpc(client as any);
|
||||
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.payload).toEqual(expect.objectContaining({ servingStatus: "UNKNOWN" }));
|
||||
});
|
||||
});
|
||||
|
||||
// ── gRPC errors ──────────────────────────────────────────────────────
|
||||
|
||||
describe("gRPC errors", () => {
|
||||
it("returns failure with gRPC error details", async () => {
|
||||
const { provider } = createProvider({ behavior: "error" });
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.code).toBe(14);
|
||||
expect(result.message).toBe("Connection refused");
|
||||
expect(result.payload).toEqual(
|
||||
expect.objectContaining({
|
||||
grpcStatusCode: 14,
|
||||
grpcStatusName: "UNAVAILABLE",
|
||||
servingStatus: "UNKNOWN",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to message when details is missing", async () => {
|
||||
const client = {
|
||||
Check: jest.fn((_r: any, _o: any, cb: Function) => cb({ code: 2, message: "Unknown error" })),
|
||||
close: jest.fn(),
|
||||
};
|
||||
const mocks = createMockGrpc(client as any);
|
||||
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.message).toBe("Unknown error");
|
||||
});
|
||||
|
||||
it("uses default message and code when error has no code/details/message", async () => {
|
||||
const client = {
|
||||
Check: jest.fn((_r: any, _o: any, cb: Function) => cb({})),
|
||||
close: jest.fn(),
|
||||
};
|
||||
const mocks = createMockGrpc(client as any);
|
||||
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.code).toBe(5000);
|
||||
expect(result.message).toBe("gRPC error");
|
||||
expect(result.payload).toEqual(expect.objectContaining({ grpcStatusCode: -1, grpcStatusName: "UNKNOWN" }));
|
||||
});
|
||||
|
||||
it("defaults grpcCode to 5000 when error has no grpcCode", async () => {
|
||||
const client = {
|
||||
Check: jest.fn((_r: any, _o: any, cb: Function) => {
|
||||
const err = new Error("test") as any;
|
||||
// no grpcPayload or grpcCode
|
||||
cb(err);
|
||||
}),
|
||||
close: jest.fn(),
|
||||
};
|
||||
const mocks = createMockGrpc(client as any);
|
||||
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.code).toBe(5000);
|
||||
});
|
||||
|
||||
it("includes grpcPayload in error result", async () => {
|
||||
const { provider } = createProvider({ behavior: "error" });
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.payload).toEqual(
|
||||
expect.objectContaining({
|
||||
grpcStatusCode: 14,
|
||||
grpcStatusName: "UNAVAILABLE",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults code, message, and payload when error lacks gRPC properties", async () => {
|
||||
// client.Check throws synchronously — timeRequest catches a plain object with no gRPC props
|
||||
const client = {
|
||||
Check: jest.fn(() => {
|
||||
throw { unexpected: true };
|
||||
}),
|
||||
close: jest.fn(),
|
||||
};
|
||||
const mocks = createMockGrpc(client as any);
|
||||
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.code).toBe(5000);
|
||||
expect(result.message).toBe("gRPC health check failed");
|
||||
expect(result.payload).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Credentials ──────────────────────────────────────────────────────
|
||||
|
||||
describe("credentials", () => {
|
||||
it("uses insecure credentials by default", async () => {
|
||||
const { provider, grpc } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ ignoreTlsErrors: false }));
|
||||
|
||||
expect(grpc.credentials.createInsecure).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses SSL credentials with checkServerIdentity override when ignoreTlsErrors is true", async () => {
|
||||
const { provider, grpc } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ ignoreTlsErrors: true }));
|
||||
|
||||
expect(grpc.credentials.createSsl).toHaveBeenCalledWith(null, null, null, {
|
||||
checkServerIdentity: expect.any(Function),
|
||||
});
|
||||
|
||||
// Verify checkServerIdentity returns undefined
|
||||
const opts = (grpc.credentials.createSsl as jest.Mock).mock.calls[0][3];
|
||||
expect(opts.checkServerIdentity()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── URL parsing ──────────────────────────────────────────────────────
|
||||
|
||||
describe("URL and target construction", () => {
|
||||
it("strips protocol from URL", async () => {
|
||||
const { provider, Health } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ url: "https://grpc.example.com", port: 443 }));
|
||||
|
||||
expect(Health).toHaveBeenCalledWith("grpc.example.com:443", expect.anything());
|
||||
});
|
||||
|
||||
it("uses grpcServiceName in Check request", async () => {
|
||||
const { provider, client } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ grpcServiceName: "health.v1" }));
|
||||
|
||||
expect(client.Check).toHaveBeenCalledWith(
|
||||
{ service: "health.v1" },
|
||||
expect.objectContaining({ deadline: expect.any(Date) }),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults grpcServiceName to empty string when not set", async () => {
|
||||
const { provider, client } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ grpcServiceName: undefined }));
|
||||
|
||||
expect(client.Check).toHaveBeenCalledWith({ service: "" }, expect.anything(), expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
// ── Validation ───────────────────────────────────────────────────────
|
||||
|
||||
describe("validation", () => {
|
||||
it("throws AppError when url is missing", async () => {
|
||||
const { provider } = createProvider();
|
||||
|
||||
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("Monitor host is required");
|
||||
});
|
||||
|
||||
it("throws AppError when port is missing", async () => {
|
||||
const { provider } = createProvider();
|
||||
|
||||
await expect(provider.handle(makeMonitor({ port: undefined }))).rejects.toThrow("Monitor port is required");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Outer catch ──────────────────────────────────────────────────────
|
||||
|
||||
describe("outer error handling", () => {
|
||||
it("throws AppError when protoLoader.loadSync throws", async () => {
|
||||
const mocks = createMockGrpc();
|
||||
mocks.protoLoader.loadSync = jest.fn().mockImplementation(() => {
|
||||
throw new Error("Proto file not found");
|
||||
});
|
||||
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("Proto file not found");
|
||||
});
|
||||
|
||||
it("throws AppError with default message when Error has empty message", async () => {
|
||||
const mocks = createMockGrpc();
|
||||
mocks.protoLoader.loadSync = jest.fn().mockImplementation(() => {
|
||||
throw new Error("");
|
||||
});
|
||||
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing gRPC health check");
|
||||
});
|
||||
|
||||
it("throws AppError with stringified message for non-Error thrown values", async () => {
|
||||
const mocks = createMockGrpc();
|
||||
mocks.protoLoader.loadSync = jest.fn().mockImplementation(() => {
|
||||
throw "proto load failed";
|
||||
});
|
||||
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("proto load failed");
|
||||
});
|
||||
});
|
||||
|
||||
// ── getGrpcStatusName ────────────────────────────────────────────────
|
||||
|
||||
describe("gRPC status name mapping", () => {
|
||||
const statusCodes: [number, string][] = [
|
||||
[0, "OK"],
|
||||
[1, "CANCELLED"],
|
||||
[2, "UNKNOWN"],
|
||||
[3, "INVALID_ARGUMENT"],
|
||||
[4, "DEADLINE_EXCEEDED"],
|
||||
[5, "NOT_FOUND"],
|
||||
[6, "ALREADY_EXISTS"],
|
||||
[7, "PERMISSION_DENIED"],
|
||||
[8, "RESOURCE_EXHAUSTED"],
|
||||
[9, "FAILED_PRECONDITION"],
|
||||
[10, "ABORTED"],
|
||||
[11, "OUT_OF_RANGE"],
|
||||
[12, "UNIMPLEMENTED"],
|
||||
[13, "INTERNAL"],
|
||||
[14, "UNAVAILABLE"],
|
||||
[15, "DATA_LOSS"],
|
||||
[16, "UNAUTHENTICATED"],
|
||||
[99, "UNKNOWN"],
|
||||
];
|
||||
|
||||
it.each(statusCodes)("maps gRPC code %i to %s", async (code, expectedName) => {
|
||||
const client = {
|
||||
Check: jest.fn((_r: any, _o: any, cb: Function) => cb({ code, details: "test" })),
|
||||
close: jest.fn(),
|
||||
};
|
||||
const mocks = createMockGrpc(client as any);
|
||||
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.payload).toEqual(expect.objectContaining({ grpcStatusName: expectedName }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { HardwareProvider } from "../../../../src/service/infrastructure/network/HardwareProvider.ts";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import type { HttpProvider } from "../../../../src/service/infrastructure/network/HttpProvider.ts";
|
||||
import type { Monitor } from "../../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "hardware",
|
||||
url: "https://capture.example.com",
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const createMockHttpProvider = () =>
|
||||
({
|
||||
handle: jest.fn().mockResolvedValue({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "hardware",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "OK",
|
||||
responseTime: 50,
|
||||
payload: { data: { cpu: { usage_percent: 0.5 } } },
|
||||
}),
|
||||
}) as unknown as jest.Mocked<HttpProvider>;
|
||||
|
||||
const createProvider = () => {
|
||||
const httpProvider = createMockHttpProvider();
|
||||
const provider = new HardwareProvider(httpProvider);
|
||||
return { provider, httpProvider };
|
||||
};
|
||||
|
||||
// ── Contract ─────────────────────────────────────────────────────────────────
|
||||
|
||||
testStatusProviderContract("HardwareProvider", {
|
||||
create: () => createProvider().provider,
|
||||
supportedType: "hardware",
|
||||
unsupportedType: "http",
|
||||
makeMonitor: () => makeMonitor(),
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("HardwareProvider", () => {
|
||||
it("delegates to httpProvider.handle", async () => {
|
||||
const { provider, httpProvider } = createProvider();
|
||||
const monitor = makeMonitor();
|
||||
|
||||
await provider.handle(monitor);
|
||||
|
||||
expect(httpProvider.handle).toHaveBeenCalledWith(monitor);
|
||||
});
|
||||
|
||||
it("returns the response from httpProvider", async () => {
|
||||
const { provider } = createProvider();
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
monitorId: "mon-1",
|
||||
status: true,
|
||||
code: 200,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("throws AppError when url is missing", async () => {
|
||||
const { provider } = createProvider();
|
||||
|
||||
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for Hardware monitor");
|
||||
});
|
||||
|
||||
it("throws AppError when httpProvider.handle throws", async () => {
|
||||
const { provider, httpProvider } = createProvider();
|
||||
(httpProvider.handle as jest.Mock).mockRejectedValue(new Error("connection failed"));
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("connection failed");
|
||||
});
|
||||
|
||||
it("throws AppError with default message for non-Error thrown values", async () => {
|
||||
const { provider, httpProvider } = createProvider();
|
||||
(httpProvider.handle as jest.Mock).mockRejectedValue("string error");
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing Hardware request");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
|
||||
import type { Monitor } from "../../../../src/types/index.ts";
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
jest.unstable_mockModule("cacheable-lookup", () => ({
|
||||
default: jest.fn().mockImplementation(() => ({})),
|
||||
}));
|
||||
|
||||
const mockGot = jest.fn();
|
||||
// got instance returned by got.extend()
|
||||
mockGot.mockImplementation(() => Promise.resolve());
|
||||
(mockGot as any).extend = jest.fn().mockReturnValue(mockGot);
|
||||
|
||||
jest.unstable_mockModule("got", () => ({
|
||||
type: { Got: {} },
|
||||
HTTPError: class HTTPError extends Error {
|
||||
response: any;
|
||||
timings: any;
|
||||
constructor(msg: string, response?: any, timings?: any) {
|
||||
super(msg);
|
||||
this.name = "HTTPError";
|
||||
this.response = response;
|
||||
this.timings = timings;
|
||||
}
|
||||
},
|
||||
RequestError: class RequestError extends Error {
|
||||
response: any;
|
||||
timings: any;
|
||||
constructor(msg: string, response?: any, timings?: any) {
|
||||
super(msg);
|
||||
this.name = "RequestError";
|
||||
this.response = response;
|
||||
this.timings = timings;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const { HttpProvider } = await import("../../../../src/service/infrastructure/network/HttpProvider.ts");
|
||||
const gotModule = await import("got");
|
||||
const { HTTPError, RequestError } = gotModule;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "http",
|
||||
url: "https://example.com",
|
||||
secret: undefined,
|
||||
jsonPath: undefined,
|
||||
ignoreTlsErrors: false,
|
||||
useAdvancedMatching: false,
|
||||
matchMethod: undefined,
|
||||
expectedValue: undefined,
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const createMockMatcher = (result?: { ok: boolean; message: string; extracted?: unknown }) => ({
|
||||
validate: jest.fn().mockReturnValue(result ?? { ok: true, message: "Success" }),
|
||||
});
|
||||
|
||||
const makeGotResponse = (overrides?: Record<string, any>) => ({
|
||||
ok: true,
|
||||
statusCode: 200,
|
||||
statusMessage: "OK",
|
||||
headers: { "content-type": "text/html" },
|
||||
body: "<html></html>",
|
||||
timings: { phases: { total: 100 } },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createProvider = (matcher?: any) => {
|
||||
const advancedMatcher = matcher ?? createMockMatcher();
|
||||
const provider = new HttpProvider(mockGot as any, advancedMatcher);
|
||||
return { provider, advancedMatcher };
|
||||
};
|
||||
|
||||
// ── Contract ─────────────────────────────────────────────────────────────────
|
||||
|
||||
testStatusProviderContract("HttpProvider", {
|
||||
create: () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse());
|
||||
return createProvider().provider;
|
||||
},
|
||||
supportedType: "http",
|
||||
unsupportedType: "ping",
|
||||
makeMonitor: () => makeMonitor(),
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("HttpProvider", () => {
|
||||
// ── Success paths ────────────────────────────────────────────────────
|
||||
|
||||
describe("success responses", () => {
|
||||
it("returns success for a standard HTML response", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse());
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "http",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "OK",
|
||||
responseTime: 100,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("parses JSON body when content-type is application/json", async () => {
|
||||
mockGot.mockResolvedValue(
|
||||
makeGotResponse({
|
||||
headers: { "content-type": "application/json" },
|
||||
body: '{"status":"ok"}',
|
||||
})
|
||||
);
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.payload).toEqual({ status: "ok" });
|
||||
});
|
||||
|
||||
it("returns raw body when JSON parsing fails", async () => {
|
||||
mockGot.mockResolvedValue(
|
||||
makeGotResponse({
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "not-json",
|
||||
})
|
||||
);
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.payload).toBe("not-json");
|
||||
});
|
||||
|
||||
it("returns raw body for non-JSON content", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse({ body: "<html>test</html>" }));
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.payload).toBe("<html>test</html>");
|
||||
});
|
||||
|
||||
it("passes Authorization header when secret is set", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse());
|
||||
const { provider } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ secret: "my-token" }));
|
||||
|
||||
expect(mockGot).toHaveBeenCalledWith(
|
||||
"https://example.com",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer my-token" },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("passes undefined headers when no secret", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse());
|
||||
const { provider } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ secret: undefined }));
|
||||
|
||||
expect(mockGot).toHaveBeenCalledWith(
|
||||
"https://example.com",
|
||||
expect.objectContaining({
|
||||
headers: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults responseTime to 0 when timings.phases.total is undefined", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse({ timings: { phases: { total: undefined } } }));
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.responseTime).toBe(0);
|
||||
});
|
||||
|
||||
it("defaults statusMessage to 'OK' when undefined", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse({ statusMessage: undefined }));
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.message).toBe("OK");
|
||||
});
|
||||
|
||||
it("uses empty string for content-type when header is missing", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse({ headers: {} }));
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
// Should treat as non-JSON
|
||||
expect(result.payload).toBe("<html></html>");
|
||||
});
|
||||
});
|
||||
|
||||
// ── jsonPath + non-JSON response ─────────────────────────────────────
|
||||
|
||||
describe("jsonPath validation", () => {
|
||||
it("returns failure when jsonPath is set but response is not JSON", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse({ headers: { "content-type": "text/html" } }));
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor({ jsonPath: "status" }));
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.message).toBe("Response is not JSON");
|
||||
});
|
||||
|
||||
it("defaults responseTime to 0 in non-JSON jsonPath response when total is undefined", async () => {
|
||||
mockGot.mockResolvedValue(
|
||||
makeGotResponse({
|
||||
headers: { "content-type": "text/html" },
|
||||
timings: { phases: { total: undefined } },
|
||||
})
|
||||
);
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor({ jsonPath: "status" }));
|
||||
|
||||
expect(result.responseTime).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── AdvancedMatcher integration ──────────────────────────────────────
|
||||
|
||||
describe("advanced matching", () => {
|
||||
it("uses matcher result for status and message", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse());
|
||||
const matcher = createMockMatcher({ ok: false, message: "Mismatch", extracted: "value" });
|
||||
const { provider } = createProvider(matcher);
|
||||
|
||||
const result = await provider.handle(makeMonitor({ useAdvancedMatching: true }));
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.message).toBe("Mismatch");
|
||||
expect(result.extracted).toBe("value");
|
||||
});
|
||||
|
||||
it("sets status to false when response.ok is false even if matcher passes", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse({ ok: false, statusCode: 301 }));
|
||||
const matcher = createMockMatcher({ ok: true, message: "Success" });
|
||||
const { provider } = createProvider(matcher);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
// status = response.ok && matchResult.ok
|
||||
expect(result.status).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error handling ───────────────────────────────────────────────────
|
||||
|
||||
describe("error handling", () => {
|
||||
it("handles HTTPError with response and timings", async () => {
|
||||
const err = new HTTPError("Not Found");
|
||||
(err as any).response = { statusCode: 404 };
|
||||
(err as any).timings = { phases: { total: 50 } };
|
||||
mockGot.mockRejectedValue(err);
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: false,
|
||||
code: 404,
|
||||
message: "Not Found",
|
||||
responseTime: 50,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("handles RequestError with response and timings", async () => {
|
||||
const err = new RequestError("ECONNREFUSED");
|
||||
(err as any).response = { statusCode: undefined };
|
||||
(err as any).timings = { phases: { total: undefined } };
|
||||
mockGot.mockRejectedValue(err);
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.code).toBe(NETWORK_ERROR);
|
||||
expect(result.responseTime).toBe(0);
|
||||
});
|
||||
|
||||
it("handles HTTPError without response (defaults to NETWORK_ERROR)", async () => {
|
||||
const err = new HTTPError("Timeout");
|
||||
(err as any).response = undefined;
|
||||
(err as any).timings = undefined;
|
||||
mockGot.mockRejectedValue(err);
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.code).toBe(NETWORK_ERROR);
|
||||
expect(result.responseTime).toBe(0);
|
||||
});
|
||||
|
||||
it("handles generic Error (non-HTTPError/RequestError)", async () => {
|
||||
mockGot.mockRejectedValue(new Error("DNS lookup failed"));
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: "DNS lookup failed",
|
||||
responseTime: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("handles non-Error thrown values", async () => {
|
||||
mockGot.mockRejectedValue("string error");
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.message).toBe("string error");
|
||||
expect(result.code).toBe(NETWORK_ERROR);
|
||||
});
|
||||
|
||||
it("throws when url is missing", async () => {
|
||||
const { provider } = createProvider();
|
||||
|
||||
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for HTTP monitor");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { PageSpeedProvider } from "../../../../src/service/infrastructure/network/PageSpeedProvider.ts";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import type { HttpProvider } from "../../../../src/service/infrastructure/network/HttpProvider.ts";
|
||||
import type { ISettingsService } from "../../../../src/service/system/settingsService.ts";
|
||||
import type { Monitor } from "../../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "pagespeed",
|
||||
url: "https://example.com",
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const createMockHttpProvider = () =>
|
||||
({
|
||||
handle: jest.fn().mockResolvedValue({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "pagespeed",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "OK",
|
||||
responseTime: 2000,
|
||||
payload: { lighthouseResult: {} },
|
||||
}),
|
||||
}) as unknown as jest.Mocked<HttpProvider>;
|
||||
|
||||
const createMockSettingsService = (apiKey?: string) =>
|
||||
({
|
||||
getDBSettings: jest.fn().mockResolvedValue({ pagespeedApiKey: apiKey }),
|
||||
}) as unknown as jest.Mocked<ISettingsService>;
|
||||
|
||||
const createProvider = (opts?: { apiKey?: string }) => {
|
||||
const logger = createMockLogger();
|
||||
const httpProvider = createMockHttpProvider();
|
||||
const settingsService = createMockSettingsService(opts?.apiKey);
|
||||
const provider = new PageSpeedProvider(httpProvider, settingsService, logger as any);
|
||||
return { provider, httpProvider, settingsService, logger };
|
||||
};
|
||||
|
||||
// ── Contract ─────────────────────────────────────────────────────────────────
|
||||
|
||||
testStatusProviderContract("PageSpeedProvider", {
|
||||
create: () => createProvider({ apiKey: "test-key" }).provider,
|
||||
supportedType: "pagespeed",
|
||||
unsupportedType: "http",
|
||||
makeMonitor: () => makeMonitor(),
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("PageSpeedProvider", () => {
|
||||
it("delegates to httpProvider with PageSpeed API URL", async () => {
|
||||
const { provider, httpProvider } = createProvider({ apiKey: "my-key" });
|
||||
|
||||
await provider.handle(makeMonitor({ url: "https://example.com" }));
|
||||
|
||||
expect(httpProvider.handle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: expect.stringContaining("pagespeedonline.googleapis.com"),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("includes API key in URL when available", async () => {
|
||||
const { provider, httpProvider } = createProvider({ apiKey: "my-key" });
|
||||
|
||||
await provider.handle(makeMonitor());
|
||||
|
||||
const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url;
|
||||
expect(calledUrl).toContain("key=my-key");
|
||||
});
|
||||
|
||||
it("omits API key and logs warning when not configured", async () => {
|
||||
const { provider, httpProvider, logger } = createProvider({ apiKey: undefined });
|
||||
|
||||
await provider.handle(makeMonitor());
|
||||
|
||||
const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url;
|
||||
expect(calledUrl).not.toContain("key=");
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("API key not found"),
|
||||
service: "PageSpeedProvider",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes the monitor URL in the PageSpeed API URL", async () => {
|
||||
const { provider, httpProvider } = createProvider({ apiKey: "key" });
|
||||
|
||||
await provider.handle(makeMonitor({ url: "https://example.com/path?q=1" }));
|
||||
|
||||
const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url;
|
||||
expect(calledUrl).toContain(encodeURIComponent("https://example.com/path?q=1"));
|
||||
});
|
||||
|
||||
it("includes all PageSpeed categories in the URL", async () => {
|
||||
const { provider, httpProvider } = createProvider({ apiKey: "key" });
|
||||
|
||||
await provider.handle(makeMonitor());
|
||||
|
||||
const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url;
|
||||
expect(calledUrl).toContain("category=seo");
|
||||
expect(calledUrl).toContain("category=accessibility");
|
||||
expect(calledUrl).toContain("category=best-practices");
|
||||
expect(calledUrl).toContain("category=performance");
|
||||
});
|
||||
|
||||
it("passes all monitor properties through to httpProvider", async () => {
|
||||
const { provider, httpProvider } = createProvider({ apiKey: "key" });
|
||||
const monitor = makeMonitor({ id: "mon-5", teamId: "team-9" });
|
||||
|
||||
await provider.handle(monitor);
|
||||
|
||||
expect(httpProvider.handle).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-5", teamId: "team-9" }));
|
||||
});
|
||||
|
||||
it("throws AppError when url is missing", async () => {
|
||||
const { provider } = createProvider();
|
||||
|
||||
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for PageSpeed monitor");
|
||||
});
|
||||
|
||||
it("throws AppError when httpProvider.handle throws", async () => {
|
||||
const { provider, httpProvider } = createProvider({ apiKey: "key" });
|
||||
(httpProvider.handle as jest.Mock).mockRejectedValue(new Error("timeout"));
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("timeout");
|
||||
});
|
||||
|
||||
it("throws AppError with default message for non-Error thrown values", async () => {
|
||||
const { provider, httpProvider } = createProvider({ apiKey: "key" });
|
||||
(httpProvider.handle as jest.Mock).mockRejectedValue("string error");
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing PageSpeed request");
|
||||
});
|
||||
|
||||
it("handles null apiKey from DB settings", async () => {
|
||||
const { provider, logger } = createProvider({ apiKey: null as any });
|
||||
|
||||
await provider.handle(makeMonitor());
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("API key not found") }));
|
||||
});
|
||||
|
||||
it("handles null dbSettings response", async () => {
|
||||
const logger = createMockLogger();
|
||||
const httpProvider = createMockHttpProvider();
|
||||
const settingsService = {
|
||||
getDBSettings: jest.fn().mockResolvedValue(null),
|
||||
} as unknown as jest.Mocked<ISettingsService>;
|
||||
const provider = new PageSpeedProvider(httpProvider, settingsService, logger as any);
|
||||
|
||||
await provider.handle(makeMonitor());
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("API key not found") }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { PingProvider } from "../../../../src/service/infrastructure/network/PingProvider.ts";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import type { Monitor } from "../../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "ping",
|
||||
url: "example.com",
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const createMockPing = (result?: Partial<{ alive: boolean; time: number | string }>) => ({
|
||||
promise: {
|
||||
probe: jest.fn().mockResolvedValue({
|
||||
alive: true,
|
||||
time: 25,
|
||||
...result,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// ── Contract ─────────────────────────────────────────────────────────────────
|
||||
|
||||
testStatusProviderContract("PingProvider", {
|
||||
create: () => new PingProvider(createMockPing() as any),
|
||||
supportedType: "ping",
|
||||
unsupportedType: "http",
|
||||
makeMonitor: () => makeMonitor(),
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("PingProvider", () => {
|
||||
it("returns success when host is alive", async () => {
|
||||
const provider = new PingProvider(createMockPing({ alive: true, time: 30 }) as any);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "ping",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "Success",
|
||||
responseTime: 30,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns failure when host is not alive", async () => {
|
||||
const provider = new PingProvider(createMockPing({ alive: false, time: 0 }) as any);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: false,
|
||||
code: 5000,
|
||||
message: "Ping failed",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("parses string time values", async () => {
|
||||
const provider = new PingProvider(createMockPing({ alive: true, time: "42.5" }) as any);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.responseTime).toBe(42.5);
|
||||
});
|
||||
|
||||
it("defaults responseTime to 0 for non-numeric time", async () => {
|
||||
const provider = new PingProvider(createMockPing({ alive: true, time: "unknown" }) as any);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.responseTime).toBe(0);
|
||||
});
|
||||
|
||||
it("defaults status to false when alive is undefined", async () => {
|
||||
const provider = new PingProvider(createMockPing({ alive: undefined as any }) as any);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
});
|
||||
|
||||
it("sanitizes URL by stripping protocol and path", async () => {
|
||||
const mockPing = createMockPing();
|
||||
const provider = new PingProvider(mockPing as any);
|
||||
|
||||
await provider.handle(makeMonitor({ url: "https://example.com/path?query=1" }));
|
||||
|
||||
expect(mockPing.promise.probe).toHaveBeenCalledWith("example.com");
|
||||
});
|
||||
|
||||
it("sanitizes URL by stripping port", async () => {
|
||||
const mockPing = createMockPing();
|
||||
const provider = new PingProvider(mockPing as any);
|
||||
|
||||
await provider.handle(makeMonitor({ url: "example.com:8080" }));
|
||||
|
||||
expect(mockPing.promise.probe).toHaveBeenCalledWith("example.com");
|
||||
});
|
||||
|
||||
it("throws AppError when url is missing", async () => {
|
||||
const provider = new PingProvider(createMockPing() as any);
|
||||
|
||||
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for ping monitor");
|
||||
});
|
||||
|
||||
it("throws AppError when ping.probe rejects", async () => {
|
||||
const mockPing = { promise: { probe: jest.fn().mockRejectedValue(new Error("Host unreachable")) } };
|
||||
const provider = new PingProvider(mockPing as any);
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("Host unreachable");
|
||||
});
|
||||
|
||||
it("throws AppError when probe returns no response", async () => {
|
||||
const mockPing = { promise: { probe: jest.fn().mockResolvedValue(null) } };
|
||||
const provider = new PingProvider(mockPing as any);
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("No response from ping");
|
||||
});
|
||||
|
||||
it("throws AppError with stringified message for non-Error throws", async () => {
|
||||
const mockPing = { promise: { probe: jest.fn().mockRejectedValue(42) } };
|
||||
const provider = new PingProvider(mockPing as any);
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("42");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { PortProvider } from "../../../../src/service/infrastructure/network/PortProvider.ts";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
|
||||
import type { Monitor } from "../../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "port",
|
||||
url: "example.com",
|
||||
port: 443,
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const createMockNet = (behavior: "connect" | "timeout" | "error" = "connect") => {
|
||||
const socket = {
|
||||
setTimeout: jest.fn(),
|
||||
on: jest.fn(),
|
||||
end: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
|
||||
const createConnection = jest.fn((opts: any, onConnect: () => void) => {
|
||||
if (behavior === "connect") {
|
||||
// Simulate immediate connection
|
||||
process.nextTick(onConnect);
|
||||
} else if (behavior === "timeout") {
|
||||
process.nextTick(() => {
|
||||
const timeoutCb = socket.on.mock.calls.find((c: any) => c[0] === "timeout");
|
||||
// Simulate via setTimeout handler
|
||||
const setTimeoutCb = socket.setTimeout.mock.calls[0];
|
||||
if (setTimeoutCb) {
|
||||
// The timeout event handler is registered via socket.on("timeout", ...)
|
||||
}
|
||||
});
|
||||
} else if (behavior === "error") {
|
||||
process.nextTick(() => {
|
||||
const errorCb = socket.on.mock.calls.find((c: any) => c[0] === "error");
|
||||
if (errorCb) {
|
||||
errorCb[1](new Error("ECONNREFUSED"));
|
||||
}
|
||||
});
|
||||
}
|
||||
return socket;
|
||||
});
|
||||
|
||||
return { createConnection, __socket: socket } as any;
|
||||
};
|
||||
|
||||
const createSuccessNet = () => {
|
||||
const socket = {
|
||||
setTimeout: jest.fn(),
|
||||
on: jest.fn(),
|
||||
end: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
return {
|
||||
createConnection: jest.fn((_opts: any, onConnect: () => void) => {
|
||||
process.nextTick(onConnect);
|
||||
return socket;
|
||||
}),
|
||||
} as any;
|
||||
};
|
||||
|
||||
const createErrorNet = (error: Error = new Error("ECONNREFUSED")) => {
|
||||
const socket = {
|
||||
setTimeout: jest.fn(),
|
||||
on: jest.fn((event: string, cb: Function) => {
|
||||
if (event === "error") {
|
||||
process.nextTick(() => cb(error));
|
||||
}
|
||||
}),
|
||||
end: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
return {
|
||||
createConnection: jest.fn(() => socket),
|
||||
} as any;
|
||||
};
|
||||
|
||||
const createTimeoutNet = () => {
|
||||
const socket = {
|
||||
setTimeout: jest.fn(),
|
||||
on: jest.fn((event: string, cb: Function) => {
|
||||
if (event === "timeout") {
|
||||
process.nextTick(() => cb());
|
||||
}
|
||||
}),
|
||||
end: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
return {
|
||||
createConnection: jest.fn(() => socket),
|
||||
} as any;
|
||||
};
|
||||
|
||||
// ── Contract ─────────────────────────────────────────────────────────────────
|
||||
|
||||
testStatusProviderContract("PortProvider", {
|
||||
create: () => new PortProvider(createSuccessNet()),
|
||||
supportedType: "port",
|
||||
unsupportedType: "http",
|
||||
makeMonitor: () => makeMonitor(),
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("PortProvider", () => {
|
||||
it("returns success when port connection succeeds", async () => {
|
||||
const provider = new PortProvider(createSuccessNet());
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "port",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "Port check successful",
|
||||
payload: { success: true },
|
||||
})
|
||||
);
|
||||
expect(result.responseTime).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("returns failure when connection errors", async () => {
|
||||
const provider = new PortProvider(createErrorNet());
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: "ECONNREFUSED",
|
||||
payload: { success: false },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns failure with generic message for non-Error connection error", async () => {
|
||||
const net = createErrorNet();
|
||||
// Override the on handler to throw a non-Error
|
||||
const socket = {
|
||||
setTimeout: jest.fn(),
|
||||
on: jest.fn((event: string, cb: Function) => {
|
||||
if (event === "error") {
|
||||
process.nextTick(() => cb("string error"));
|
||||
}
|
||||
}),
|
||||
end: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
net.createConnection = jest.fn(() => socket);
|
||||
|
||||
const provider = new PortProvider(net);
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.message).toBe("Port check failed");
|
||||
});
|
||||
|
||||
it("returns failure when connection times out", async () => {
|
||||
const provider = new PortProvider(createTimeoutNet());
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: "Connection timeout",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("throws AppError when url is missing", async () => {
|
||||
const provider = new PortProvider(createSuccessNet());
|
||||
|
||||
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL and port are required for port monitoring");
|
||||
});
|
||||
|
||||
it("throws AppError when port is missing", async () => {
|
||||
const provider = new PortProvider(createSuccessNet());
|
||||
|
||||
await expect(provider.handle(makeMonitor({ port: undefined }))).rejects.toThrow("URL and port are required for port monitoring");
|
||||
});
|
||||
|
||||
it("returns failure with generic message when timeRequest catches non-Error", async () => {
|
||||
const socket = {
|
||||
setTimeout: jest.fn(),
|
||||
on: jest.fn((event: string, cb: Function) => {
|
||||
if (event === "error") {
|
||||
process.nextTick(() => cb({ notAnError: true }));
|
||||
}
|
||||
}),
|
||||
end: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
const net = { createConnection: jest.fn(() => socket) } as any;
|
||||
const provider = new PortProvider(net);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.message).toBe("Port check failed");
|
||||
});
|
||||
|
||||
it("throws AppError wrapping the original Error message", async () => {
|
||||
const provider = new PortProvider(createSuccessNet());
|
||||
|
||||
try {
|
||||
await provider.handle(makeMonitor({ url: "" }));
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err: any) {
|
||||
expect(err.service).toBe("PortProvider");
|
||||
expect(err.method).toBe("handle");
|
||||
}
|
||||
});
|
||||
|
||||
it("throws AppError when an unexpected error occurs in outer try", async () => {
|
||||
const provider = new PortProvider(createSuccessNet());
|
||||
const monitor = makeMonitor();
|
||||
let calls = 0;
|
||||
Object.defineProperty(monitor, "url", {
|
||||
get() {
|
||||
calls++;
|
||||
if (calls === 1) throw new Error("getter exploded");
|
||||
return "example.com";
|
||||
},
|
||||
});
|
||||
|
||||
await expect(provider.handle(monitor)).rejects.toThrow("getter exploded");
|
||||
});
|
||||
|
||||
it("throws AppError with fallback message when Error has empty message", async () => {
|
||||
const provider = new PortProvider(createSuccessNet());
|
||||
const monitor = makeMonitor();
|
||||
let calls = 0;
|
||||
Object.defineProperty(monitor, "url", {
|
||||
get() {
|
||||
calls++;
|
||||
if (calls === 1) throw new Error("");
|
||||
return "example.com";
|
||||
},
|
||||
});
|
||||
|
||||
await expect(provider.handle(monitor)).rejects.toThrow("Error performing port check");
|
||||
});
|
||||
|
||||
it("throws AppError with stringified message for non-Error thrown values", async () => {
|
||||
const provider = new PortProvider(createSuccessNet());
|
||||
const monitor = makeMonitor();
|
||||
let calls = 0;
|
||||
Object.defineProperty(monitor, "url", {
|
||||
get() {
|
||||
calls++;
|
||||
if (calls === 1) throw 42;
|
||||
return "example.com";
|
||||
},
|
||||
});
|
||||
|
||||
await expect(provider.handle(monitor)).rejects.toThrow("42");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import { timeRequest, NETWORK_ERROR, PING_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
|
||||
|
||||
describe("network utils", () => {
|
||||
describe("timeRequest", () => {
|
||||
it("returns response and responseTime on success", async () => {
|
||||
const result = await timeRequest(async () => "hello");
|
||||
|
||||
expect(result.response).toBe("hello");
|
||||
expect(result.responseTime).toBeGreaterThanOrEqual(0);
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it("returns error and responseTime on failure", async () => {
|
||||
const err = new Error("boom");
|
||||
const result = await timeRequest(async () => {
|
||||
throw err;
|
||||
});
|
||||
|
||||
expect(result.response).toBeNull();
|
||||
expect(result.responseTime).toBeGreaterThanOrEqual(0);
|
||||
expect(result.error).toBe(err);
|
||||
});
|
||||
|
||||
it("measures elapsed time", async () => {
|
||||
const result = await timeRequest(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
return "done";
|
||||
});
|
||||
|
||||
expect(result.responseTime).toBeGreaterThanOrEqual(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe("constants", () => {
|
||||
it("NETWORK_ERROR is 5000", () => {
|
||||
expect(NETWORK_ERROR).toBe(5000);
|
||||
});
|
||||
|
||||
it("PING_ERROR is 5001", () => {
|
||||
expect(PING_ERROR).toBe(5001);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals";
|
||||
import { WebSocketProvider } from "../../../../src/service/infrastructure/network/WebSocketProvider.ts";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
|
||||
import type { Monitor } from "../../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "websocket",
|
||||
url: "wss://example.com/ws",
|
||||
ignoreTlsErrors: false,
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const createMockWS = (behavior: "open" | "error" = "open") => {
|
||||
return jest.fn().mockImplementation((_url: string, _opts: any) => {
|
||||
const handlers: Record<string, Function> = {};
|
||||
const ws = {
|
||||
on: jest.fn((event: string, cb: Function) => {
|
||||
handlers[event] = cb;
|
||||
if (behavior === "open" && event === "open") {
|
||||
process.nextTick(() => cb());
|
||||
}
|
||||
if (behavior === "error" && event === "error") {
|
||||
process.nextTick(() => cb(new Error("Connection failed")));
|
||||
}
|
||||
}),
|
||||
close: jest.fn(),
|
||||
};
|
||||
return ws;
|
||||
}) as any;
|
||||
};
|
||||
|
||||
// ── Contract ─────────────────────────────────────────────────────────────────
|
||||
|
||||
testStatusProviderContract("WebSocketProvider", {
|
||||
create: () => new WebSocketProvider(createMockWS("open")),
|
||||
supportedType: "websocket",
|
||||
unsupportedType: "http",
|
||||
makeMonitor: () => makeMonitor(),
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("WebSocketProvider", () => {
|
||||
it("returns success when connection opens", async () => {
|
||||
const provider = new WebSocketProvider(createMockWS("open"));
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "websocket",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "WebSocket check successful",
|
||||
payload: { connected: true },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns failure when connection errors", async () => {
|
||||
const provider = new WebSocketProvider(createMockWS("error"));
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: "Connection failed",
|
||||
payload: { connected: false },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns failure with generic message for non-Error connection error", async () => {
|
||||
const WS = jest.fn().mockImplementation(() => ({
|
||||
on: jest.fn((event: string, cb: Function) => {
|
||||
if (event === "error") {
|
||||
process.nextTick(() => cb("string error"));
|
||||
}
|
||||
}),
|
||||
close: jest.fn(),
|
||||
})) as any;
|
||||
|
||||
const provider = new WebSocketProvider(WS);
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.message).toBe("WebSocket check failed");
|
||||
});
|
||||
|
||||
it("passes rejectUnauthorized: false when ignoreTlsErrors is true", async () => {
|
||||
const WS = jest.fn().mockImplementation((_url: string, opts: any) => {
|
||||
expect(opts.rejectUnauthorized).toBe(false);
|
||||
return {
|
||||
on: jest.fn((event: string, cb: Function) => {
|
||||
if (event === "open") process.nextTick(() => cb());
|
||||
}),
|
||||
close: jest.fn(),
|
||||
};
|
||||
}) as any;
|
||||
|
||||
const provider = new WebSocketProvider(WS);
|
||||
await provider.handle(makeMonitor({ ignoreTlsErrors: true }));
|
||||
|
||||
expect(WS).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws AppError when url is missing", async () => {
|
||||
const provider = new WebSocketProvider(createMockWS("open"));
|
||||
|
||||
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for WebSocket monitoring");
|
||||
});
|
||||
|
||||
it("throws AppError when an unexpected error occurs in outer try", async () => {
|
||||
const provider = new WebSocketProvider(createMockWS("open"));
|
||||
const monitor = makeMonitor();
|
||||
let calls = 0;
|
||||
Object.defineProperty(monitor, "url", {
|
||||
get() {
|
||||
calls++;
|
||||
if (calls === 1) throw new Error("getter failed");
|
||||
return "wss://example.com";
|
||||
},
|
||||
});
|
||||
|
||||
await expect(provider.handle(monitor)).rejects.toThrow("getter failed");
|
||||
});
|
||||
|
||||
it("throws AppError with fallback message when Error has empty message", async () => {
|
||||
const provider = new WebSocketProvider(createMockWS("open"));
|
||||
const monitor = makeMonitor();
|
||||
let calls = 0;
|
||||
Object.defineProperty(monitor, "url", {
|
||||
get() {
|
||||
calls++;
|
||||
if (calls === 1) throw new Error("");
|
||||
return "wss://example.com";
|
||||
},
|
||||
});
|
||||
|
||||
await expect(provider.handle(monitor)).rejects.toThrow("Error performing WebSocket check");
|
||||
});
|
||||
|
||||
it("throws AppError with stringified message for non-Error thrown values", async () => {
|
||||
const provider = new WebSocketProvider(createMockWS("open"));
|
||||
const monitor = makeMonitor();
|
||||
let calls = 0;
|
||||
Object.defineProperty(monitor, "url", {
|
||||
get() {
|
||||
calls++;
|
||||
if (calls === 1) throw 99;
|
||||
return "wss://example.com";
|
||||
},
|
||||
});
|
||||
|
||||
await expect(provider.handle(monitor)).rejects.toThrow("99");
|
||||
});
|
||||
|
||||
describe("connection timeout", () => {
|
||||
beforeEach(() => jest.useFakeTimers());
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it("returns failure on connection timeout", async () => {
|
||||
// WS that never fires open or error
|
||||
const WS = jest.fn().mockImplementation(() => ({
|
||||
on: jest.fn(),
|
||||
close: jest.fn(),
|
||||
})) as any;
|
||||
|
||||
const provider = new WebSocketProvider(WS);
|
||||
const promise = provider.handle(makeMonitor());
|
||||
|
||||
await jest.advanceTimersByTimeAsync(10000);
|
||||
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: "WebSocket connection timeout",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
|
||||
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
|
||||
|
||||
const mockGotPost = jest.fn().mockResolvedValue({});
|
||||
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
|
||||
|
||||
const { DiscordProvider } = await import("../../../../src/service/infrastructure/notificationProviders/discord.ts");
|
||||
|
||||
const createProvider = () => {
|
||||
const logger = createMockLogger();
|
||||
const provider = new DiscordProvider(logger as any);
|
||||
return { provider, logger };
|
||||
};
|
||||
|
||||
testNotificationProviderContract("DiscordProvider", {
|
||||
create: () => {
|
||||
mockGotPost.mockResolvedValue({});
|
||||
return createProvider().provider;
|
||||
},
|
||||
makeNotification: () => makeNotification(),
|
||||
});
|
||||
|
||||
describe("DiscordProvider", () => {
|
||||
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
|
||||
|
||||
describe("sendTestAlert", () => {
|
||||
it("sends test message and returns true", async () => {
|
||||
const { provider } = createProvider();
|
||||
const result = await provider.sendTestAlert(makeNotification());
|
||||
expect(result).toBe(true);
|
||||
expect(mockGotPost).toHaveBeenCalledWith(
|
||||
"https://hooks.example.com/webhook",
|
||||
expect.objectContaining({ json: expect.objectContaining({ content: expect.any(String) }) })
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("network"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert" }));
|
||||
});
|
||||
|
||||
it("handles undefined thrown values", async () => {
|
||||
mockGotPost.mockRejectedValue(undefined);
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("sends embed and returns true", async () => {
|
||||
const { provider } = createProvider();
|
||||
const result = await provider.sendMessage(makeNotification() as any, makeMessage());
|
||||
expect(result).toBe(true);
|
||||
expect(mockGotPost).toHaveBeenCalledWith(
|
||||
"https://hooks.example.com/webhook",
|
||||
expect.objectContaining({ json: expect.objectContaining({ embeds: expect.any(Array) }) })
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" }));
|
||||
});
|
||||
|
||||
it("handles undefined thrown values in sendMessage", async () => {
|
||||
mockGotPost.mockRejectedValue(undefined);
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
|
||||
});
|
||||
|
||||
it("includes threshold breaches in embed fields", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
|
||||
const payload = mockGotPost.mock.calls[0][1].json;
|
||||
const fields = payload.embeds[0].fields;
|
||||
expect(fields.some((f: any) => f.name === "Threshold Breaches")).toBe(true);
|
||||
});
|
||||
|
||||
it("includes details in embed fields", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage());
|
||||
const fields = mockGotPost.mock.calls[0][1].json.embeds[0].fields;
|
||||
expect(fields.some((f: any) => f.name === "Details")).toBe(true);
|
||||
});
|
||||
|
||||
it("maps all severity levels to colors", async () => {
|
||||
const { provider } = createProvider();
|
||||
for (const severity of ["critical", "warning", "success", "info"] as const) {
|
||||
mockGotPost.mockClear();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ severity }));
|
||||
const color = mockGotPost.mock.calls[0][1].json.embeds[0].color;
|
||||
expect(color).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses info color for unknown severity", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ severity: "unknown" as any }));
|
||||
const color = mockGotPost.mock.calls[0][1].json.embeds[0].color;
|
||||
expect(color).toBe(0x3b82f6);
|
||||
});
|
||||
|
||||
it("omits threshold fields when not present", async () => {
|
||||
const { provider } = createProvider();
|
||||
const msg = makeMessage();
|
||||
msg.content.thresholds = undefined;
|
||||
msg.content.details = undefined;
|
||||
await provider.sendMessage(makeNotification() as any, msg);
|
||||
const fields = mockGotPost.mock.calls[0][1].json.embeds[0].fields;
|
||||
expect(fields.some((f: any) => f.name === "Threshold Breaches")).toBe(false);
|
||||
expect(fields.some((f: any) => f.name === "Details")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { EmailProvider } from "../../../../src/service/infrastructure/notificationProviders/email.ts";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import { makeNotification, makeMessage } from "../../../helpers/notificationMessage.ts";
|
||||
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
|
||||
import type { IEmailService } from "../../../../src/service/infrastructure/emailService.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const createMockEmailService = () =>
|
||||
({
|
||||
buildEmail: jest.fn().mockResolvedValue("<html>email</html>"),
|
||||
sendEmail: jest.fn().mockResolvedValue("msg-123"),
|
||||
}) as unknown as jest.Mocked<IEmailService>;
|
||||
|
||||
const createProvider = () => {
|
||||
const logger = createMockLogger();
|
||||
const emailService = createMockEmailService();
|
||||
const provider = new EmailProvider(emailService, logger as any);
|
||||
return { provider, logger, emailService };
|
||||
};
|
||||
|
||||
testNotificationProviderContract("EmailProvider", {
|
||||
create: () => createProvider().provider,
|
||||
makeNotification: () => makeNotification(),
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("EmailProvider", () => {
|
||||
describe("sendTestAlert", () => {
|
||||
it("builds test email and sends it", async () => {
|
||||
const { provider, emailService } = createProvider();
|
||||
const result = await provider.sendTestAlert(makeNotification());
|
||||
expect(result).toBe(true);
|
||||
expect(emailService.buildEmail).toHaveBeenCalledWith("testEmailTemplate", { testName: "Monitoring System" });
|
||||
expect(emailService.sendEmail).toHaveBeenCalledWith("https://hooks.example.com/webhook", "Test notification", "<html>email</html>");
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Missing address" }));
|
||||
});
|
||||
|
||||
it("returns false when buildEmail returns undefined", async () => {
|
||||
const { provider, emailService, logger } = createProvider();
|
||||
(emailService.buildEmail as jest.Mock).mockResolvedValue(undefined);
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build test email content" }));
|
||||
});
|
||||
|
||||
it("returns false when sendEmail returns falsy", async () => {
|
||||
const { provider, emailService, logger } = createProvider();
|
||||
(emailService.sendEmail as jest.Mock).mockResolvedValue(false);
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Email test alert failed" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("builds email from message and sends it", async () => {
|
||||
const { provider, emailService } = createProvider();
|
||||
const result = await provider.sendMessage(makeNotification() as any, makeMessage());
|
||||
expect(result).toBe(true);
|
||||
expect(emailService.buildEmail).toHaveBeenCalledWith(
|
||||
"unifiedNotificationTemplate",
|
||||
expect.objectContaining({ title: "Monitor Down: Test Monitor" })
|
||||
);
|
||||
expect(emailService.sendEmail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when buildEmail returns undefined", async () => {
|
||||
const { provider, emailService, logger } = createProvider();
|
||||
(emailService.buildEmail as jest.Mock).mockResolvedValue(undefined);
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build email content" }));
|
||||
});
|
||||
|
||||
it("returns false when sendEmail returns falsy", async () => {
|
||||
const { provider, emailService, logger } = createProvider();
|
||||
(emailService.sendEmail as jest.Mock).mockResolvedValue(undefined);
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Email notification failed" }));
|
||||
});
|
||||
|
||||
it("builds correct subject for monitor_down", async () => {
|
||||
const { provider, emailService } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "monitor_down" }));
|
||||
expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Monitor Test Monitor is down", expect.anything());
|
||||
});
|
||||
|
||||
it("builds correct subject for monitor_up", async () => {
|
||||
const { provider, emailService } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "monitor_up" }));
|
||||
expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Monitor Test Monitor is back up", expect.anything());
|
||||
});
|
||||
|
||||
it("builds correct subject for threshold_breach", async () => {
|
||||
const { provider, emailService } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "threshold_breach" }));
|
||||
expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Monitor Test Monitor threshold exceeded", expect.anything());
|
||||
});
|
||||
|
||||
it("builds correct subject for threshold_resolved", async () => {
|
||||
const { provider, emailService } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "threshold_resolved" }));
|
||||
expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Monitor Test Monitor thresholds resolved", expect.anything());
|
||||
});
|
||||
|
||||
it("builds default subject for unknown type", async () => {
|
||||
const { provider, emailService } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "test" }));
|
||||
expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Alert: Test Monitor", expect.anything());
|
||||
});
|
||||
|
||||
it("passes incident url in context when present", async () => {
|
||||
const { provider, emailService } = createProvider();
|
||||
const msg = makeMessage();
|
||||
msg.content.incident = { id: "inc-1", url: "https://app.example.com/incidents/inc-1", createdAt: new Date() };
|
||||
await provider.sendMessage(makeNotification() as any, msg);
|
||||
expect(emailService.buildEmail).toHaveBeenCalledWith(
|
||||
"unifiedNotificationTemplate",
|
||||
expect.objectContaining({ incidentUrl: "https://app.example.com/incidents/inc-1" })
|
||||
);
|
||||
});
|
||||
|
||||
it("maps all severity levels to colors", async () => {
|
||||
const { provider, emailService } = createProvider();
|
||||
const severityColors: Record<string, string> = { critical: "red", warning: "#f59e0b", info: "#3b82f6", success: "green" };
|
||||
for (const [severity, color] of Object.entries(severityColors)) {
|
||||
(emailService.buildEmail as jest.Mock).mockClear();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ severity: severity as any }));
|
||||
expect(emailService.buildEmail).toHaveBeenCalledWith("unifiedNotificationTemplate", expect.objectContaining({ headerColor: color }));
|
||||
}
|
||||
});
|
||||
|
||||
it("uses default color for unknown severity", async () => {
|
||||
const { provider, emailService } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ severity: "unknown" as any }));
|
||||
expect(emailService.buildEmail).toHaveBeenCalledWith("unifiedNotificationTemplate", expect.objectContaining({ headerColor: "#3b82f6" }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
|
||||
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
|
||||
|
||||
const mockGotPut = jest.fn().mockResolvedValue({});
|
||||
jest.unstable_mockModule("got", () => ({ default: { put: mockGotPut } }));
|
||||
jest.unstable_mockModule("crypto", () => ({ randomUUID: () => "test-uuid-1234" }));
|
||||
|
||||
const { MatrixProvider } = await import("../../../../src/service/infrastructure/notificationProviders/matrix.ts");
|
||||
|
||||
const createProvider = () => {
|
||||
const logger = createMockLogger();
|
||||
return { provider: new MatrixProvider(logger as any), logger };
|
||||
};
|
||||
|
||||
testNotificationProviderContract("MatrixProvider", {
|
||||
create: () => {
|
||||
mockGotPut.mockResolvedValue({});
|
||||
return createProvider().provider;
|
||||
},
|
||||
makeNotification: () => makeNotification(),
|
||||
});
|
||||
|
||||
describe("MatrixProvider", () => {
|
||||
beforeEach(() => mockGotPut.mockReset().mockResolvedValue({}));
|
||||
|
||||
describe("sendTestAlert", () => {
|
||||
it("sends to Matrix API and returns true", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
|
||||
expect(mockGotPut).toHaveBeenCalledWith(
|
||||
expect.stringContaining("matrix.example.com/_matrix/client/v3/rooms/!room:example.com/send/m.room.message/test-uuid-1234"),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: "Bearer token-abc" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when homeserverUrl is missing", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNotification({ homeserverUrl: undefined }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when accessToken is missing", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNotification({ accessToken: undefined }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when roomId is missing", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNotification({ roomId: undefined }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPut.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles non-Error thrown values", async () => {
|
||||
mockGotPut.mockRejectedValue({ message: "custom obj" });
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("sends formatted HTML and plain text", async () => {
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
|
||||
const body = mockGotPut.mock.calls[0][1].json;
|
||||
expect(body.msgtype).toBe("m.text");
|
||||
expect(body.format).toBe("org.matrix.custom.html");
|
||||
expect(body.body).toContain("Monitor Down");
|
||||
expect(body.formatted_body).toContain("<h2");
|
||||
});
|
||||
|
||||
it("returns false when homeserverUrl is missing", async () => {
|
||||
expect(await createProvider().provider.sendMessage(makeNotification({ homeserverUrl: undefined }) as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPut.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes threshold breaches", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
|
||||
const body = mockGotPut.mock.calls[0][1].json;
|
||||
expect(body.body).toContain("CPU");
|
||||
expect(body.formatted_body).toContain("CPU");
|
||||
});
|
||||
|
||||
it("includes incident link", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessageWithIncident());
|
||||
const body = mockGotPut.mock.calls[0][1].json;
|
||||
expect(body.body).toContain("View Incident");
|
||||
expect(body.formatted_body).toContain("View Incident");
|
||||
});
|
||||
|
||||
it("omits optional sections when not present", async () => {
|
||||
const { provider } = createProvider();
|
||||
const msg = makeMessage();
|
||||
msg.content.thresholds = undefined;
|
||||
msg.content.details = undefined;
|
||||
msg.content.incident = undefined;
|
||||
await provider.sendMessage(makeNotification() as any, msg);
|
||||
const body = mockGotPut.mock.calls[0][1].json;
|
||||
expect(body.body).not.toContain("Threshold");
|
||||
expect(body.body).not.toContain("Additional");
|
||||
expect(body.body).not.toContain("View Incident");
|
||||
});
|
||||
|
||||
it("escapes HTML special characters", async () => {
|
||||
const { provider } = createProvider();
|
||||
const msg = makeMessage();
|
||||
msg.monitor.name = '<script>alert("xss")</script>';
|
||||
await provider.sendMessage(makeNotification() as any, msg);
|
||||
const html = mockGotPut.mock.calls[0][1].json.formatted_body;
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).toContain("<script>");
|
||||
});
|
||||
|
||||
it("maps all severity levels to colors", async () => {
|
||||
const { provider } = createProvider();
|
||||
for (const severity of ["critical", "warning", "success", "info"] as const) {
|
||||
mockGotPut.mockClear();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ severity }));
|
||||
const html = mockGotPut.mock.calls[0][1].json.formatted_body;
|
||||
expect(html).toContain("color:");
|
||||
}
|
||||
});
|
||||
|
||||
it("uses default color for unknown severity", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ severity: "unknown" as any }));
|
||||
const html = mockGotPut.mock.calls[0][1].json.formatted_body;
|
||||
expect(html).toContain("#808080");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { buildTestEmail, getTestMessage } from "../../../../src/service/infrastructure/notificationProviders/utils.ts";
|
||||
import type { IEmailService } from "../../../../src/service/infrastructure/emailService.ts";
|
||||
|
||||
describe("notification utils", () => {
|
||||
describe("getTestMessage", () => {
|
||||
it("returns a non-empty string", () => {
|
||||
expect(getTestMessage()).toBe("This is a test notification from Checkmate");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTestEmail", () => {
|
||||
it("calls emailService.buildEmail with test template and context", async () => {
|
||||
const emailService = {
|
||||
buildEmail: jest.fn().mockResolvedValue("<html>test</html>"),
|
||||
} as unknown as IEmailService;
|
||||
|
||||
const result = await buildTestEmail(emailService);
|
||||
|
||||
expect(emailService.buildEmail).toHaveBeenCalledWith("testEmailTemplate", { testName: "Monitoring System" });
|
||||
expect(result).toBe("<html>test</html>");
|
||||
});
|
||||
|
||||
it("returns undefined when buildEmail returns undefined", async () => {
|
||||
const emailService = {
|
||||
buildEmail: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as IEmailService;
|
||||
|
||||
const result = await buildTestEmail(emailService);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import { makeNotification, makeMessage, makeMessageWithThresholds } from "../../../helpers/notificationMessage.ts";
|
||||
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
|
||||
|
||||
const mockGotPost = jest.fn().mockResolvedValue({});
|
||||
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
|
||||
|
||||
const { PagerDutyProvider } = await import("../../../../src/service/infrastructure/notificationProviders/pagerduty.ts");
|
||||
|
||||
const createProvider = () => {
|
||||
const logger = createMockLogger();
|
||||
return { provider: new PagerDutyProvider(logger as any), logger };
|
||||
};
|
||||
|
||||
testNotificationProviderContract("PagerDutyProvider", {
|
||||
create: () => {
|
||||
mockGotPost.mockResolvedValue({});
|
||||
return createProvider().provider;
|
||||
},
|
||||
makeNotification: () => makeNotification(),
|
||||
});
|
||||
|
||||
describe("PagerDutyProvider", () => {
|
||||
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
|
||||
|
||||
describe("sendTestAlert", () => {
|
||||
it("sends to PagerDuty API and returns true", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
|
||||
expect(mockGotPost).toHaveBeenCalledWith(
|
||||
"https://events.pagerduty.com/v2/enqueue",
|
||||
expect.objectContaining({
|
||||
json: expect.objectContaining({ routing_key: "https://hooks.example.com/webhook", event_action: "trigger" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles non-Error thrown values", async () => {
|
||||
mockGotPost.mockRejectedValue(null);
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("sends payload and returns true", async () => {
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
|
||||
const payload = mockGotPost.mock.calls[0][1].json;
|
||||
expect(payload.routing_key).toBe("https://hooks.example.com/webhook");
|
||||
expect(payload.event_action).toBe("trigger");
|
||||
expect(payload.dedup_key).toBe("checkmate-mon-1");
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles non-Error thrown values in sendMessage", async () => {
|
||||
mockGotPost.mockRejectedValue(null);
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("uses 'resolve' event_action for monitor_up", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "monitor_up" }));
|
||||
expect(mockGotPost.mock.calls[0][1].json.event_action).toBe("resolve");
|
||||
});
|
||||
|
||||
it("uses 'resolve' event_action for threshold_resolved", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "threshold_resolved" }));
|
||||
expect(mockGotPost.mock.calls[0][1].json.event_action).toBe("resolve");
|
||||
});
|
||||
|
||||
it("includes threshold info in summary and custom_details", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
|
||||
const payload = mockGotPost.mock.calls[0][1].json;
|
||||
expect(payload.payload.summary).toContain("CPU");
|
||||
expect(payload.payload.custom_details.threshold_breaches).toBeDefined();
|
||||
});
|
||||
|
||||
it("includes details in custom_details", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage());
|
||||
expect(mockGotPost.mock.calls[0][1].json.payload.custom_details.details).toBeDefined();
|
||||
});
|
||||
|
||||
it("maps all severity levels", async () => {
|
||||
const { provider } = createProvider();
|
||||
for (const severity of ["critical", "warning", "info", "success"] as const) {
|
||||
mockGotPost.mockClear();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ severity }));
|
||||
expect(mockGotPost.mock.calls[0][1].json.payload.severity).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses 'error' for unknown severity", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ severity: "unknown" as any }));
|
||||
expect(mockGotPost.mock.calls[0][1].json.payload.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("omits threshold and details from custom_details when not present", async () => {
|
||||
const { provider } = createProvider();
|
||||
const msg = makeMessage();
|
||||
msg.content.thresholds = undefined;
|
||||
msg.content.details = undefined;
|
||||
await provider.sendMessage(makeNotification() as any, msg);
|
||||
const cd = mockGotPost.mock.calls[0][1].json.payload.custom_details;
|
||||
expect(cd.threshold_breaches).toBeUndefined();
|
||||
expect(cd.details).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
|
||||
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
|
||||
|
||||
const mockGotPost = jest.fn().mockResolvedValue({});
|
||||
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost }, HTTPError: class extends Error {} }));
|
||||
|
||||
const { SlackProvider } = await import("../../../../src/service/infrastructure/notificationProviders/slack.ts");
|
||||
|
||||
const createProvider = () => {
|
||||
const logger = createMockLogger();
|
||||
return { provider: new SlackProvider(logger as any), logger };
|
||||
};
|
||||
|
||||
testNotificationProviderContract("SlackProvider", {
|
||||
create: () => {
|
||||
mockGotPost.mockResolvedValue({});
|
||||
return createProvider().provider;
|
||||
},
|
||||
makeNotification: () => makeNotification(),
|
||||
});
|
||||
|
||||
describe("SlackProvider", () => {
|
||||
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
|
||||
|
||||
describe("sendTestAlert", () => {
|
||||
it("returns true on success", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles undefined thrown values", async () => {
|
||||
mockGotPost.mockRejectedValue(undefined);
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("sends Block Kit payload and returns true", async () => {
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
|
||||
const payload = mockGotPost.mock.calls[0][1].json;
|
||||
expect(payload.blocks).toBeDefined();
|
||||
expect(payload.attachments).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
expect(await createProvider().provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles undefined thrown values in sendMessage", async () => {
|
||||
mockGotPost.mockRejectedValue(undefined);
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("includes threshold section when thresholds present", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
|
||||
const blocks = mockGotPost.mock.calls[0][1].json.blocks;
|
||||
const text = JSON.stringify(blocks);
|
||||
expect(text).toContain("Threshold Breaches");
|
||||
});
|
||||
|
||||
it("includes incident button when incident present", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessageWithIncident());
|
||||
const blocks = mockGotPost.mock.calls[0][1].json.blocks;
|
||||
expect(blocks.some((b: any) => b.type === "actions")).toBe(true);
|
||||
});
|
||||
|
||||
it("includes details section when details are present", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage());
|
||||
const blocks = mockGotPost.mock.calls[0][1].json.blocks;
|
||||
const text = JSON.stringify(blocks);
|
||||
expect(text).toContain("Additional Information");
|
||||
});
|
||||
|
||||
it("omits threshold, details, and incident sections when not present", async () => {
|
||||
const { provider } = createProvider();
|
||||
const msg = makeMessage();
|
||||
msg.content.thresholds = undefined;
|
||||
msg.content.details = undefined;
|
||||
msg.content.incident = undefined;
|
||||
await provider.sendMessage(makeNotification() as any, msg);
|
||||
const blocks = mockGotPost.mock.calls[0][1].json.blocks;
|
||||
const text = JSON.stringify(blocks);
|
||||
expect(text).not.toContain("Additional Information");
|
||||
expect(blocks.some((b: any) => b.type === "actions")).toBe(false);
|
||||
});
|
||||
|
||||
it("maps all severity levels to colors", async () => {
|
||||
const { provider } = createProvider();
|
||||
for (const severity of ["critical", "warning", "success", "info"] as const) {
|
||||
mockGotPost.mockClear();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ severity }));
|
||||
expect(mockGotPost.mock.calls[0][1].json.attachments[0].color).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses default color for unknown severity", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ severity: "unknown" as any }));
|
||||
expect(mockGotPost.mock.calls[0][1].json.attachments[0].color).toBe("#808080");
|
||||
});
|
||||
});
|
||||
});
|
||||
+47
-4
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import type { Notification } from "../../../src/types/notification.ts";
|
||||
import type { NotificationMessage } from "../../../src/types/notificationMessage.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
import type { Notification } from "../../../../src/types/notification.ts";
|
||||
import type { NotificationMessage } from "../../../../src/types/notificationMessage.ts";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
|
||||
const mockPost = jest.fn();
|
||||
jest.unstable_mockModule("got", () => ({
|
||||
@@ -9,7 +9,7 @@ jest.unstable_mockModule("got", () => ({
|
||||
HTTPError: class HTTPError extends Error {},
|
||||
}));
|
||||
|
||||
const { TeamsProvider } = await import("../../../src/service/infrastructure/notificationProviders/teams.ts");
|
||||
const { TeamsProvider } = await import("../../../../src/service/infrastructure/notificationProviders/teams.ts");
|
||||
|
||||
const createNotification = (overrides?: Partial<Notification>): Notification => ({
|
||||
id: "notif-1",
|
||||
@@ -248,5 +248,48 @@ describe("TeamsProvider", () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("handles non-Error thrown values in sendMessage", async () => {
|
||||
mockPost.mockRejectedValue(null);
|
||||
const result = await provider.sendMessage(createNotification(), createMessage());
|
||||
expect(result).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
|
||||
});
|
||||
|
||||
it("includes details section in Adaptive Card when details are present", async () => {
|
||||
const notification = createNotification();
|
||||
const message = createMessage({
|
||||
content: {
|
||||
title: "Test",
|
||||
summary: "Summary",
|
||||
details: ["URL: https://example.com", "Status: Down"],
|
||||
timestamp: new Date(),
|
||||
},
|
||||
});
|
||||
await provider.sendMessage(notification, message);
|
||||
const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
|
||||
const card = options.json.attachments[0].content;
|
||||
const text = JSON.stringify(card.body);
|
||||
expect(text).toContain("Additional Information");
|
||||
expect(text).toContain("URL: https://example.com");
|
||||
});
|
||||
|
||||
it("uses default color for unknown severity", async () => {
|
||||
const notification = createNotification();
|
||||
const message = createMessage({ severity: "unknown" as any });
|
||||
await provider.sendMessage(notification, message);
|
||||
const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
|
||||
const card = options.json.attachments[0].content;
|
||||
expect(card.body[0].color).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendTestAlert error handling", () => {
|
||||
it("handles non-Error thrown values", async () => {
|
||||
mockPost.mockRejectedValue(null);
|
||||
const result = await provider.sendTestAlert(createNotification());
|
||||
expect(result).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
|
||||
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
|
||||
|
||||
const mockGotPost = jest.fn().mockResolvedValue({});
|
||||
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
|
||||
|
||||
const { TelegramProvider } = await import("../../../../src/service/infrastructure/notificationProviders/telegram.ts");
|
||||
|
||||
const createProvider = () => {
|
||||
const logger = createMockLogger();
|
||||
return { provider: new TelegramProvider(logger as any), logger };
|
||||
};
|
||||
|
||||
testNotificationProviderContract("TelegramProvider", {
|
||||
create: () => {
|
||||
mockGotPost.mockResolvedValue({});
|
||||
return createProvider().provider;
|
||||
},
|
||||
makeNotification: () => makeNotification(),
|
||||
});
|
||||
|
||||
describe("TelegramProvider", () => {
|
||||
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
|
||||
|
||||
describe("sendTestAlert", () => {
|
||||
it("sends to Telegram API and returns true", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
|
||||
expect(mockGotPost).toHaveBeenCalledWith(
|
||||
expect.stringContaining("api.telegram.org/bottoken-abc/sendMessage"),
|
||||
expect.objectContaining({ json: expect.objectContaining({ chat_id: "https://hooks.example.com/webhook" }) })
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when accessToken is missing", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNotification({ accessToken: undefined }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert", details: { error: "fail" } }));
|
||||
});
|
||||
|
||||
it("handles non-Error thrown values in sendTestAlert", async () => {
|
||||
mockGotPost.mockRejectedValue("string error");
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined, details: { error: "unknown error" } }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("sends HTML-formatted text and returns true", async () => {
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
|
||||
const json = mockGotPost.mock.calls[0][1].json;
|
||||
expect(json.parse_mode).toBe("HTML");
|
||||
expect(json.text).toContain("<b>Monitor Down");
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
expect(await createProvider().provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when accessToken is missing", async () => {
|
||||
expect(await createProvider().provider.sendMessage(makeNotification({ accessToken: undefined }) as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" }));
|
||||
});
|
||||
|
||||
it("handles non-Error thrown values in sendMessage", async () => {
|
||||
mockGotPost.mockRejectedValue(42);
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ details: { error: "unknown error" } }));
|
||||
});
|
||||
|
||||
it("includes thresholds in text", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
|
||||
expect(mockGotPost.mock.calls[0][1].json.text).toContain("CPU");
|
||||
});
|
||||
|
||||
it("includes incident link in text", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessageWithIncident());
|
||||
expect(mockGotPost.mock.calls[0][1].json.text).toContain("View Incident");
|
||||
});
|
||||
|
||||
it("omits optional sections when not present", async () => {
|
||||
const { provider } = createProvider();
|
||||
const msg = makeMessage();
|
||||
msg.content.thresholds = undefined;
|
||||
msg.content.details = undefined;
|
||||
msg.content.incident = undefined;
|
||||
await provider.sendMessage(makeNotification() as any, msg);
|
||||
const text = mockGotPost.mock.calls[0][1].json.text;
|
||||
expect(text).not.toContain("Threshold");
|
||||
expect(text).not.toContain("Additional");
|
||||
expect(text).not.toContain("View Incident");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
|
||||
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
|
||||
|
||||
const mockGotPost = jest.fn().mockResolvedValue({});
|
||||
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
|
||||
|
||||
const { WebhookProvider } = await import("../../../../src/service/infrastructure/notificationProviders/webhook.ts");
|
||||
|
||||
const createProvider = () => {
|
||||
const logger = createMockLogger();
|
||||
return { provider: new WebhookProvider(logger as any), logger };
|
||||
};
|
||||
|
||||
testNotificationProviderContract("WebhookProvider", {
|
||||
create: () => {
|
||||
mockGotPost.mockResolvedValue({});
|
||||
return createProvider().provider;
|
||||
},
|
||||
makeNotification: () => makeNotification(),
|
||||
});
|
||||
|
||||
describe("WebhookProvider", () => {
|
||||
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
|
||||
|
||||
describe("sendTestAlert", () => {
|
||||
it("returns true on success", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles non-Error thrown values", async () => {
|
||||
mockGotPost.mockRejectedValue(null);
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("sends payload with text and structured data", async () => {
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
|
||||
const payload = mockGotPost.mock.calls[0][1].json;
|
||||
expect(payload.text).toContain("Monitor Down");
|
||||
expect(payload.severity).toBe("critical");
|
||||
expect(payload.monitor.id).toBe("mon-1");
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
expect(await createProvider().provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles non-Error thrown values in sendMessage", async () => {
|
||||
mockGotPost.mockRejectedValue(null);
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("includes threshold breaches in text", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
|
||||
expect(mockGotPost.mock.calls[0][1].json.text).toContain("CPU");
|
||||
});
|
||||
|
||||
it("includes incident link in text", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessageWithIncident());
|
||||
expect(mockGotPost.mock.calls[0][1].json.text).toContain("View Incident");
|
||||
});
|
||||
|
||||
it("omits threshold and incident sections when not present", async () => {
|
||||
const { provider } = createProvider();
|
||||
const msg = makeMessage();
|
||||
msg.content.thresholds = undefined;
|
||||
msg.content.details = undefined;
|
||||
msg.content.incident = undefined;
|
||||
await provider.sendMessage(makeNotification() as any, msg);
|
||||
const text = mockGotPost.mock.calls[0][1].json.text;
|
||||
expect(text).not.toContain("Threshold");
|
||||
expect(text).not.toContain("Additional Information");
|
||||
expect(text).not.toContain("View Incident");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,454 @@
|
||||
import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals";
|
||||
import { BufferService } from "../../../src/service/infrastructure/bufferService.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
import type { ICheckService } from "../../../src/service/business/checkService.ts";
|
||||
import type { IGeoChecksService } from "../../../src/service/business/geoChecksService.ts";
|
||||
import type { ISettingsService } from "../../../src/service/system/settingsService.ts";
|
||||
import type { Check, GeoCheck } from "../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const createMockCheckService = () =>
|
||||
({
|
||||
createChecks: jest.fn().mockResolvedValue([]),
|
||||
}) as unknown as jest.Mocked<ICheckService>;
|
||||
|
||||
const createMockGeoChecksService = () =>
|
||||
({
|
||||
createGeoChecks: jest.fn().mockResolvedValue([]),
|
||||
}) as unknown as jest.Mocked<IGeoChecksService>;
|
||||
|
||||
const createMockSettingsService = (nodeEnv: string = "development") =>
|
||||
({
|
||||
getSettings: jest.fn().mockReturnValue({ nodeEnv }),
|
||||
}) as unknown as jest.Mocked<ISettingsService>;
|
||||
|
||||
const makeCheck = (overrides?: Partial<Check>): Check =>
|
||||
({
|
||||
id: "check-1",
|
||||
metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" },
|
||||
status: true,
|
||||
statusCode: 200,
|
||||
responseTime: 100,
|
||||
message: "OK",
|
||||
...overrides,
|
||||
}) as Check;
|
||||
|
||||
const makeGeoCheck = (overrides?: Partial<GeoCheck>): GeoCheck =>
|
||||
({
|
||||
id: "geo-1",
|
||||
monitorId: "mon-1",
|
||||
...overrides,
|
||||
}) as GeoCheck;
|
||||
|
||||
const createService = (nodeEnv: string = "development") => {
|
||||
const logger = createMockLogger();
|
||||
const checkService = createMockCheckService();
|
||||
const geoChecksService = createMockGeoChecksService();
|
||||
const settingsService = createMockSettingsService(nodeEnv);
|
||||
const service = new BufferService(logger as any, checkService, geoChecksService, settingsService);
|
||||
return { service, logger, checkService, geoChecksService };
|
||||
};
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("BufferService", () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Static / instance properties ─────────────────────────────────────
|
||||
|
||||
describe("serviceName", () => {
|
||||
it("returns BufferService from static property", () => {
|
||||
expect(BufferService.SERVICE_NAME).toBe("BufferService");
|
||||
});
|
||||
|
||||
it("returns BufferService from instance getter", () => {
|
||||
const { service } = createService();
|
||||
expect(service.serviceName).toBe("BufferService");
|
||||
});
|
||||
});
|
||||
|
||||
// ── constructor ──────────────────────────────────────────────────────
|
||||
|
||||
describe("constructor", () => {
|
||||
it("logs initialization with development timeout", () => {
|
||||
const { logger } = createService("development");
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("0.01s"),
|
||||
service: "BufferService",
|
||||
method: "constructor",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("uses 60s timeout in non-development environment", () => {
|
||||
const { logger } = createService("production");
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("60s"),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("schedules a flush on construction", () => {
|
||||
createService();
|
||||
expect(jest.getTimerCount()).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── addToBuffer ──────────────────────────────────────────────────────
|
||||
|
||||
describe("addToBuffer", () => {
|
||||
it("adds a check to the buffer", async () => {
|
||||
const { service, checkService } = createService();
|
||||
const check = makeCheck();
|
||||
|
||||
service.addToBuffer(check);
|
||||
await service.flushBuffer();
|
||||
|
||||
expect(checkService.createChecks).toHaveBeenCalledWith([check]);
|
||||
});
|
||||
|
||||
it("adds multiple checks to the buffer", async () => {
|
||||
const { service, checkService } = createService();
|
||||
const check1 = makeCheck({ id: "c1" });
|
||||
const check2 = makeCheck({ id: "c2" });
|
||||
|
||||
service.addToBuffer(check1);
|
||||
service.addToBuffer(check2);
|
||||
await service.flushBuffer();
|
||||
|
||||
expect(checkService.createChecks).toHaveBeenCalledWith([check1, check2]);
|
||||
});
|
||||
|
||||
it("logs error if push throws", () => {
|
||||
const { service, logger } = createService();
|
||||
// Force buffer.push to throw by making buffer non-extensible
|
||||
Object.defineProperty(service, "buffer", { value: Object.freeze([]) });
|
||||
|
||||
service.addToBuffer(makeCheck());
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
service: "BufferService",
|
||||
method: "addToBuffer",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error thrown values", () => {
|
||||
const { service, logger } = createService();
|
||||
Object.defineProperty(service, "buffer", {
|
||||
value: {
|
||||
push: () => {
|
||||
throw "string error";
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
service.addToBuffer(makeCheck());
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Unknown error",
|
||||
stack: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── addGeoCheckToBuffer ──────────────────────────────────────────────
|
||||
|
||||
describe("addGeoCheckToBuffer", () => {
|
||||
it("adds a geo check to the buffer", async () => {
|
||||
const { service, geoChecksService } = createService();
|
||||
const geoCheck = makeGeoCheck();
|
||||
|
||||
service.addGeoCheckToBuffer(geoCheck);
|
||||
await service.flushGeoBuffer();
|
||||
|
||||
expect(geoChecksService.createGeoChecks).toHaveBeenCalledWith([geoCheck]);
|
||||
});
|
||||
|
||||
it("logs error if push throws", () => {
|
||||
const { service, logger } = createService();
|
||||
Object.defineProperty(service, "geoBuffer", { value: Object.freeze([]) });
|
||||
|
||||
service.addGeoCheckToBuffer(makeGeoCheck());
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
service: "BufferService",
|
||||
method: "addGeoCheckToBuffer",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error thrown values", () => {
|
||||
const { service, logger } = createService();
|
||||
Object.defineProperty(service, "geoBuffer", {
|
||||
value: {
|
||||
push: () => {
|
||||
throw 42;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
service.addGeoCheckToBuffer(makeGeoCheck());
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Unknown error",
|
||||
stack: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── scheduleNextFlush ────────────────────────────────────────────────
|
||||
|
||||
describe("scheduleNextFlush", () => {
|
||||
it("clears existing timer and sets a new one", () => {
|
||||
const { service } = createService();
|
||||
// Constructor already scheduled one flush
|
||||
const initialTimerCount = jest.getTimerCount();
|
||||
|
||||
service.scheduleNextFlush();
|
||||
|
||||
// Should still have timers (cleared old, set new)
|
||||
expect(jest.getTimerCount()).toBe(initialTimerCount);
|
||||
});
|
||||
|
||||
it("flushes buffer and geo buffer when timer fires", async () => {
|
||||
const { service, checkService, geoChecksService } = createService();
|
||||
service.addToBuffer(makeCheck());
|
||||
service.addGeoCheckToBuffer(makeGeoCheck());
|
||||
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(checkService.createChecks).toHaveBeenCalled();
|
||||
expect(geoChecksService.createGeoChecks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reschedules after flush completes", async () => {
|
||||
const { service, checkService } = createService();
|
||||
(checkService.createChecks as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
service.addToBuffer(makeCheck());
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Add another check and advance again to confirm rescheduling
|
||||
service.addToBuffer(makeCheck({ id: "c2" }));
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(checkService.createChecks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reschedules even when flush throws", async () => {
|
||||
const { service, checkService, logger } = createService();
|
||||
(checkService.createChecks as jest.Mock).mockRejectedValueOnce(new Error("DB down"));
|
||||
service.addToBuffer(makeCheck());
|
||||
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "DB down",
|
||||
method: "flushBuffer",
|
||||
})
|
||||
);
|
||||
|
||||
// Should still reschedule — add another check and flush
|
||||
(checkService.createChecks as jest.Mock).mockResolvedValue([]);
|
||||
service.addToBuffer(makeCheck({ id: "c2" }));
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(checkService.createChecks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("logs error and reschedules when flush throws past its own catch", async () => {
|
||||
const { service, logger } = createService();
|
||||
// Override flushBuffer to throw past its own try/catch
|
||||
service.flushBuffer = jest.fn<() => Promise<void>>().mockRejectedValueOnce(new Error("unexpected"));
|
||||
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "unexpected",
|
||||
method: "scheduleNextFlush",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' when flush throws non-Error past its own catch", async () => {
|
||||
const { service, logger } = createService();
|
||||
service.flushBuffer = jest.fn<() => Promise<void>>().mockRejectedValueOnce("string error");
|
||||
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Unknown error",
|
||||
method: "scheduleNextFlush",
|
||||
stack: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── flushBuffer ──────────────────────────────────────────────────────
|
||||
|
||||
describe("flushBuffer", () => {
|
||||
it("does nothing when buffer is empty", async () => {
|
||||
const { service, checkService } = createService();
|
||||
|
||||
await service.flushBuffer();
|
||||
|
||||
expect(checkService.createChecks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("flushes checks to checksService and clears buffer", async () => {
|
||||
const { service, checkService } = createService();
|
||||
const check = makeCheck();
|
||||
service.addToBuffer(check);
|
||||
|
||||
await service.flushBuffer();
|
||||
|
||||
expect(checkService.createChecks).toHaveBeenCalledWith([check]);
|
||||
// Buffer should be empty now
|
||||
await service.flushBuffer();
|
||||
expect(checkService.createChecks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("logs debug message before flushing", async () => {
|
||||
const { service, logger } = createService();
|
||||
service.addToBuffer(makeCheck());
|
||||
|
||||
await service.flushBuffer();
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Flushing 1 checks to database",
|
||||
service: "BufferService",
|
||||
method: "flushBuffer",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("clears buffer even on error to prevent infinite retries", async () => {
|
||||
const { service, checkService, logger } = createService();
|
||||
(checkService.createChecks as jest.Mock).mockRejectedValue(new Error("DB write failed"));
|
||||
service.addToBuffer(makeCheck());
|
||||
|
||||
await service.flushBuffer();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "DB write failed",
|
||||
method: "flushBuffer",
|
||||
})
|
||||
);
|
||||
// Buffer should be cleared
|
||||
await service.flushBuffer();
|
||||
expect(checkService.createChecks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error thrown values", async () => {
|
||||
const { service, checkService, logger } = createService();
|
||||
(checkService.createChecks as jest.Mock).mockRejectedValue(null);
|
||||
service.addToBuffer(makeCheck());
|
||||
|
||||
await service.flushBuffer();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Unknown error",
|
||||
method: "flushBuffer",
|
||||
stack: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── flushGeoBuffer ───────────────────────────────────────────────────
|
||||
|
||||
describe("flushGeoBuffer", () => {
|
||||
it("does nothing when geo buffer is empty", async () => {
|
||||
const { service, geoChecksService } = createService();
|
||||
|
||||
await service.flushGeoBuffer();
|
||||
|
||||
expect(geoChecksService.createGeoChecks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("flushes geo checks and clears buffer", async () => {
|
||||
const { service, geoChecksService } = createService();
|
||||
const geoCheck = makeGeoCheck();
|
||||
service.addGeoCheckToBuffer(geoCheck);
|
||||
|
||||
await service.flushGeoBuffer();
|
||||
|
||||
expect(geoChecksService.createGeoChecks).toHaveBeenCalledWith([geoCheck]);
|
||||
// Buffer should be empty now
|
||||
await service.flushGeoBuffer();
|
||||
expect(geoChecksService.createGeoChecks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("logs debug message before flushing", async () => {
|
||||
const { service, logger } = createService();
|
||||
service.addGeoCheckToBuffer(makeGeoCheck());
|
||||
|
||||
await service.flushGeoBuffer();
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Flushing 1 geo checks to database",
|
||||
service: "BufferService",
|
||||
method: "flushGeoBuffer",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("clears geo buffer even on error", async () => {
|
||||
const { service, geoChecksService, logger } = createService();
|
||||
(geoChecksService.createGeoChecks as jest.Mock).mockRejectedValue(new Error("DB error"));
|
||||
service.addGeoCheckToBuffer(makeGeoCheck());
|
||||
|
||||
await service.flushGeoBuffer();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "DB error",
|
||||
method: "flushGeoBuffer",
|
||||
})
|
||||
);
|
||||
// Buffer should be cleared
|
||||
await service.flushGeoBuffer();
|
||||
expect(geoChecksService.createGeoChecks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error thrown values", async () => {
|
||||
const { service, geoChecksService, logger } = createService();
|
||||
(geoChecksService.createGeoChecks as jest.Mock).mockRejectedValue(undefined);
|
||||
service.addGeoCheckToBuffer(makeGeoCheck());
|
||||
|
||||
await service.flushGeoBuffer();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Unknown error",
|
||||
method: "flushGeoBuffer",
|
||||
stack: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals";
|
||||
import { DiagnosticService } from "../../../src/service/business/diagnosticService.ts";
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("DiagnosticService", () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe("serviceName", () => {
|
||||
it("returns diagnosticService from static property", () => {
|
||||
expect(DiagnosticService.SERVICE_NAME).toBe("diagnosticService");
|
||||
});
|
||||
|
||||
it("returns diagnosticService from instance getter", () => {
|
||||
const service = new DiagnosticService();
|
||||
expect(service.serviceName).toBe("diagnosticService");
|
||||
});
|
||||
});
|
||||
|
||||
// ── constructor ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("constructor", () => {
|
||||
it("sets up PerformanceObserver that clears marks on callback", () => {
|
||||
let capturedCallback: ((list: PerformanceObserverEntryList) => void) | null = null;
|
||||
const originalObserver = globalThis.PerformanceObserver;
|
||||
const mockObserve = jest.fn();
|
||||
|
||||
globalThis.PerformanceObserver = jest.fn().mockImplementation((cb: any) => {
|
||||
capturedCallback = cb;
|
||||
return { observe: mockObserve };
|
||||
}) as any;
|
||||
|
||||
const clearMarksSpy = jest.spyOn(performance, "clearMarks");
|
||||
const service = new DiagnosticService();
|
||||
|
||||
expect(mockObserve).toHaveBeenCalledWith({ entryTypes: ["measure"] });
|
||||
expect(capturedCallback).not.toBeNull();
|
||||
|
||||
// Invoke the callback manually
|
||||
const mockEntryList = { getEntries: jest.fn().mockReturnValue([]) } as unknown as PerformanceObserverEntryList;
|
||||
capturedCallback!(mockEntryList);
|
||||
|
||||
expect(mockEntryList.getEntries).toHaveBeenCalled();
|
||||
expect(clearMarksSpy).toHaveBeenCalled();
|
||||
|
||||
clearMarksSpy.mockRestore();
|
||||
globalThis.PerformanceObserver = originalObserver;
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── getCPUUsage ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("getCPUUsage", () => {
|
||||
it("returns cpu usage metrics after timing period", async () => {
|
||||
const service = new DiagnosticService();
|
||||
const promise = service.getCPUUsage();
|
||||
jest.advanceTimersByTime(1000);
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toHaveProperty("userUsageMs");
|
||||
expect(result).toHaveProperty("systemUsageMs");
|
||||
expect(result).toHaveProperty("usagePercentage");
|
||||
expect(typeof result.userUsageMs).toBe("number");
|
||||
expect(typeof result.systemUsageMs).toBe("number");
|
||||
expect(typeof result.usagePercentage).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
// ── getSystemStats ──────────────────────────────────────────────────────
|
||||
|
||||
describe("getSystemStats", () => {
|
||||
it("returns all diagnostic sections", async () => {
|
||||
const service = new DiagnosticService();
|
||||
const promise = service.getSystemStats();
|
||||
// getCPUUsage has 1s timeout, then event loop delay has 0ms timeout
|
||||
jest.advanceTimersByTime(1000);
|
||||
// Need to flush the microtask queue and advance again for the nested setTimeout(0)
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toHaveProperty("osStats");
|
||||
expect(result.osStats).toHaveProperty("freeMemoryBytes");
|
||||
expect(result.osStats).toHaveProperty("totalMemoryBytes");
|
||||
|
||||
expect(result).toHaveProperty("memoryUsage");
|
||||
expect(result.memoryUsage).toHaveProperty("rss");
|
||||
expect(result.memoryUsage).toHaveProperty("heapTotal");
|
||||
expect(result.memoryUsage).toHaveProperty("heapUsed");
|
||||
expect(result.memoryUsage).toHaveProperty("external");
|
||||
expect(result.memoryUsage).toHaveProperty("arrayBuffers");
|
||||
|
||||
expect(result).toHaveProperty("cpuUsage");
|
||||
expect(result.cpuUsage).toHaveProperty("userUsageMs");
|
||||
|
||||
expect(result).toHaveProperty("v8HeapStats");
|
||||
expect(result.v8HeapStats).toHaveProperty("totalHeapSizeBytes");
|
||||
expect(result.v8HeapStats).toHaveProperty("usedHeapSizeBytes");
|
||||
expect(result.v8HeapStats).toHaveProperty("heapSizeLimitBytes");
|
||||
|
||||
expect(typeof result.eventLoopDelayMs).toBe("number");
|
||||
expect(result.eventLoopDelayMs).toBeGreaterThanOrEqual(0);
|
||||
expect(typeof result.uptimeMs).toBe("number");
|
||||
expect(result.uptimeMs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("reads eventLoopDelay from real performance entries", async () => {
|
||||
jest.useRealTimers();
|
||||
const service = new DiagnosticService();
|
||||
|
||||
// Call getSystemStats with real timers — slow but guarantees real perf entries
|
||||
const result = await service.getSystemStats();
|
||||
|
||||
expect(typeof result.eventLoopDelayMs).toBe("number");
|
||||
expect(result.eventLoopDelayMs).toBeGreaterThanOrEqual(0);
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "empty entries array", entries: [] },
|
||||
{ label: "undefined entry in array", entries: [undefined] },
|
||||
])("defaults eventLoopDelayMs to 0 when $label", async ({ entries }) => {
|
||||
const service = new DiagnosticService();
|
||||
const originalGetEntries = performance.getEntriesByName;
|
||||
const originalMeasure = performance.measure;
|
||||
const originalMark = performance.mark;
|
||||
|
||||
performance.mark = jest.fn() as any;
|
||||
performance.measure = jest.fn() as any;
|
||||
performance.getEntriesByName = jest.fn().mockReturnValue(entries) as any;
|
||||
|
||||
const promise = service.getSystemStats();
|
||||
jest.advanceTimersByTime(1000);
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
const result = await promise;
|
||||
|
||||
expect(result.eventLoopDelayMs).toBe(0);
|
||||
expect(performance.getEntriesByName).toHaveBeenCalledWith("eventLoopDelay");
|
||||
|
||||
performance.getEntriesByName = originalGetEntries;
|
||||
performance.measure = originalMeasure;
|
||||
performance.mark = originalMark;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { EmailService } from "../../../src/service/infrastructure/emailService.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
import type { ISettingsService } from "../../../src/service/system/settingsService.ts";
|
||||
import type { EmailTransportConfig } from "../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeTransportConfig = (overrides?: Partial<EmailTransportConfig>): EmailTransportConfig =>
|
||||
({
|
||||
systemEmailHost: "smtp.example.com",
|
||||
systemEmailPort: 587,
|
||||
systemEmailSecure: false,
|
||||
systemEmailPool: false,
|
||||
systemEmailUser: "user@example.com",
|
||||
systemEmailAddress: "noreply@example.com",
|
||||
systemEmailPassword: "password123",
|
||||
systemEmailConnectionHost: "mail.example.com",
|
||||
systemEmailTLSServername: "smtp.example.com",
|
||||
systemEmailIgnoreTLS: false,
|
||||
systemEmailRequireTLS: true,
|
||||
systemEmailRejectUnauthorized: true,
|
||||
...overrides,
|
||||
}) as EmailTransportConfig;
|
||||
|
||||
const createMockSettingsService = () =>
|
||||
({
|
||||
getDBSettings: jest.fn().mockResolvedValue(makeTransportConfig()),
|
||||
}) as unknown as jest.Mocked<ISettingsService>;
|
||||
|
||||
const createMockFs = (templateContent: string = "<mjml><mj-body></mj-body></mjml>") =>
|
||||
({
|
||||
readFileSync: jest.fn().mockReturnValue(templateContent),
|
||||
}) as any;
|
||||
|
||||
const createMockPath = () =>
|
||||
({
|
||||
join: jest.fn((...args: string[]) => args.join("/")),
|
||||
dirname: jest.fn().mockReturnValue("/mock/dir"),
|
||||
}) as any;
|
||||
|
||||
const createMockCompile = (result: string = "<mjml>compiled</mjml>") => {
|
||||
const compiledFn = jest.fn().mockReturnValue(result);
|
||||
const compile = jest.fn().mockReturnValue(compiledFn) as any;
|
||||
compile.__compiledFn = compiledFn;
|
||||
return compile;
|
||||
};
|
||||
|
||||
const createMockMjml = (html: string = "<html>rendered</html>") => jest.fn().mockReturnValue({ html }) as any;
|
||||
|
||||
const createMockTransporter = () => ({
|
||||
verify: jest.fn().mockResolvedValue(true),
|
||||
sendMail: jest.fn().mockResolvedValue({ messageId: "msg-123" }),
|
||||
});
|
||||
|
||||
const createMockNodemailer = (transporter?: ReturnType<typeof createMockTransporter>) => {
|
||||
const t = transporter ?? createMockTransporter();
|
||||
return {
|
||||
createTransport: jest.fn().mockReturnValue(t),
|
||||
__transporter: t,
|
||||
} as any;
|
||||
};
|
||||
|
||||
const createService = (overrides?: { settingsService?: any; fs?: any; path?: any; compile?: any; mjml?: any; nodemailer?: any }) => {
|
||||
const logger = createMockLogger();
|
||||
const settingsService = overrides?.settingsService ?? createMockSettingsService();
|
||||
const mockFs = overrides?.fs ?? createMockFs();
|
||||
const mockPath = overrides?.path ?? createMockPath();
|
||||
const compile = overrides?.compile ?? createMockCompile();
|
||||
const mjml = overrides?.mjml ?? createMockMjml();
|
||||
const mockNodemailer = overrides?.nodemailer ?? createMockNodemailer();
|
||||
|
||||
const service = new EmailService(settingsService, mockFs, mockPath, compile, mjml, mockNodemailer, logger as any);
|
||||
|
||||
return { service, logger, settingsService, mockFs, mockPath, compile, mjml, mockNodemailer };
|
||||
};
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("EmailService", () => {
|
||||
describe("SERVICE_NAME", () => {
|
||||
it("returns EmailService from static property", () => {
|
||||
expect(EmailService.SERVICE_NAME).toBe("EmailService");
|
||||
});
|
||||
|
||||
it("returns EmailService from instance getter", () => {
|
||||
const { service } = createService();
|
||||
expect(service.serviceName).toBe("EmailService");
|
||||
});
|
||||
});
|
||||
|
||||
// ── init / loadTemplate ──────────────────────────────────────────────
|
||||
|
||||
describe("init", () => {
|
||||
it("loads all expected templates on construction", () => {
|
||||
const { mockFs } = createService();
|
||||
|
||||
// 6 templates loaded
|
||||
expect(mockFs.readFileSync).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it("compiles each loaded template", () => {
|
||||
const { compile } = createService();
|
||||
|
||||
expect(compile).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it("logs error when a template file is not found", () => {
|
||||
const failFs = {
|
||||
readFileSync: jest.fn().mockImplementation(() => {
|
||||
throw new Error("ENOENT: no such file");
|
||||
}),
|
||||
};
|
||||
|
||||
const { logger } = createService({ fs: failFs });
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "ENOENT: no such file",
|
||||
service: "EmailService",
|
||||
method: "loadTemplate",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error thrown values in loadTemplate", () => {
|
||||
const failFs = {
|
||||
readFileSync: jest.fn().mockImplementation(() => {
|
||||
throw "string error";
|
||||
}),
|
||||
};
|
||||
|
||||
const { logger } = createService({ fs: failFs });
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Unknown error",
|
||||
stack: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("can be called again to reinitialize templates", () => {
|
||||
const { service, mockFs } = createService();
|
||||
const initialCalls = mockFs.readFileSync.mock.calls.length;
|
||||
|
||||
service.init();
|
||||
|
||||
expect(mockFs.readFileSync).toHaveBeenCalledTimes(initialCalls + 6);
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildEmail ───────────────────────────────────────────────────────
|
||||
|
||||
describe("buildEmail", () => {
|
||||
it("compiles template with context and returns rendered HTML", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
const result = await service.buildEmail("welcomeEmailTemplate", { name: "Test" });
|
||||
|
||||
expect(result).toBe("<html>rendered</html>");
|
||||
});
|
||||
|
||||
it("passes context to the compiled template function", async () => {
|
||||
const compile = createMockCompile();
|
||||
const { service } = createService({ compile });
|
||||
const context = { name: "Alex", url: "https://example.com" };
|
||||
|
||||
await service.buildEmail("welcomeEmailTemplate", context);
|
||||
|
||||
expect(compile.__compiledFn).toHaveBeenCalledWith(context);
|
||||
});
|
||||
|
||||
it("returns undefined and logs error when template is not found", async () => {
|
||||
const { service, logger } = createService();
|
||||
|
||||
const result = await service.buildEmail("nonExistentTemplate", {});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Template nonExistentTemplate not found",
|
||||
service: "EmailService",
|
||||
method: "buildEmail",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined and logs error when template function returns falsy", async () => {
|
||||
const compile = jest.fn().mockReturnValue(jest.fn().mockReturnValue(undefined)) as any;
|
||||
const { service, logger } = createService({ compile });
|
||||
|
||||
const result = await service.buildEmail("welcomeEmailTemplate", {});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Template welcomeEmailTemplate not found",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined and logs error when mjml throws", async () => {
|
||||
const mjml = jest.fn().mockImplementation(() => {
|
||||
throw new Error("MJML parse error");
|
||||
}) as any;
|
||||
const { service, logger } = createService({ mjml });
|
||||
|
||||
const result = await service.buildEmail("welcomeEmailTemplate", {});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "MJML parse error",
|
||||
method: "buildEmail",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error thrown values in buildEmail", async () => {
|
||||
const mjml = jest.fn().mockImplementation(() => {
|
||||
throw null;
|
||||
}) as any;
|
||||
const { service, logger } = createService({ mjml });
|
||||
|
||||
const result = await service.buildEmail("welcomeEmailTemplate", {});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Unknown error",
|
||||
method: "buildEmail",
|
||||
stack: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── sendEmail ────────────────────────────────────────────────────────
|
||||
|
||||
describe("sendEmail", () => {
|
||||
it("sends email with provided transport config and returns messageId", async () => {
|
||||
const transporter = createMockTransporter();
|
||||
const nodemailer = createMockNodemailer(transporter);
|
||||
const { service } = createService({ nodemailer });
|
||||
const config = makeTransportConfig();
|
||||
|
||||
const result = await service.sendEmail("to@example.com", "Subject", "<p>body</p>", config);
|
||||
|
||||
expect(result).toBe("msg-123");
|
||||
expect(transporter.verify).toHaveBeenCalled();
|
||||
expect(transporter.sendMail).toHaveBeenCalledWith({
|
||||
to: "to@example.com",
|
||||
from: "noreply@example.com",
|
||||
subject: "Subject",
|
||||
html: "<p>body</p>",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to DB settings when no transport config is provided", async () => {
|
||||
const settingsService = createMockSettingsService();
|
||||
const transporter = createMockTransporter();
|
||||
const nodemailer = createMockNodemailer(transporter);
|
||||
const { service } = createService({ settingsService, nodemailer });
|
||||
|
||||
await service.sendEmail("to@example.com", "Subject", "<p>body</p>");
|
||||
|
||||
expect(settingsService.getDBSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates transporter with correct email config", async () => {
|
||||
const transporter = createMockTransporter();
|
||||
const nodemailer = createMockNodemailer(transporter);
|
||||
const { service } = createService({ nodemailer });
|
||||
const config = makeTransportConfig({
|
||||
systemEmailHost: "smtp.test.com",
|
||||
systemEmailPort: 465,
|
||||
systemEmailSecure: true,
|
||||
systemEmailPool: true,
|
||||
systemEmailUser: "testuser",
|
||||
systemEmailPassword: "testpass",
|
||||
systemEmailConnectionHost: "conn.test.com",
|
||||
systemEmailRejectUnauthorized: false,
|
||||
systemEmailIgnoreTLS: true,
|
||||
systemEmailRequireTLS: false,
|
||||
systemEmailTLSServername: "tls.test.com",
|
||||
});
|
||||
|
||||
await service.sendEmail("to@example.com", "Subject", "<p>body</p>", config);
|
||||
|
||||
expect(nodemailer.createTransport).toHaveBeenCalledWith({
|
||||
host: "smtp.test.com",
|
||||
port: 465,
|
||||
secure: true,
|
||||
auth: { user: "testuser", pass: "testpass" },
|
||||
name: "conn.test.com",
|
||||
connectionTimeout: 5000,
|
||||
pool: true,
|
||||
tls: {
|
||||
rejectUnauthorized: false,
|
||||
ignoreTLS: true,
|
||||
requireTLS: false,
|
||||
servername: "tls.test.com",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses systemEmailAddress as auth user when systemEmailUser is falsy", async () => {
|
||||
const transporter = createMockTransporter();
|
||||
const nodemailer = createMockNodemailer(transporter);
|
||||
const { service } = createService({ nodemailer });
|
||||
const config = makeTransportConfig({ systemEmailUser: "", systemEmailAddress: "fallback@example.com" });
|
||||
|
||||
await service.sendEmail("to@example.com", "Subject", "<p>body</p>", config);
|
||||
|
||||
expect(nodemailer.createTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
auth: expect.objectContaining({ user: "fallback@example.com" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("uses 'localhost' as name when systemEmailConnectionHost is falsy", async () => {
|
||||
const transporter = createMockTransporter();
|
||||
const nodemailer = createMockNodemailer(transporter);
|
||||
const { service } = createService({ nodemailer });
|
||||
const config = makeTransportConfig({ systemEmailConnectionHost: "" });
|
||||
|
||||
await service.sendEmail("to@example.com", "Subject", "<p>body</p>", config);
|
||||
|
||||
expect(nodemailer.createTransport).toHaveBeenCalledWith(expect.objectContaining({ name: "localhost" }));
|
||||
});
|
||||
|
||||
it("returns false when transporter verification fails", async () => {
|
||||
const transporter = createMockTransporter();
|
||||
(transporter.verify as jest.Mock).mockRejectedValue(new Error("SMTP auth failed"));
|
||||
const nodemailer = createMockNodemailer(transporter);
|
||||
const { service, logger } = createService({ nodemailer });
|
||||
|
||||
const result = await service.sendEmail("to@example.com", "Subject", "<p>body</p>", makeTransportConfig());
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Email transporter verification failed",
|
||||
service: "EmailService",
|
||||
method: "verifyTransporter",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs stack as undefined when verification fails with non-Error", async () => {
|
||||
const transporter = createMockTransporter();
|
||||
(transporter.verify as jest.Mock).mockRejectedValue("connection refused");
|
||||
const nodemailer = createMockNodemailer(transporter);
|
||||
const { service, logger } = createService({ nodemailer });
|
||||
|
||||
const result = await service.sendEmail("to@example.com", "Subject", "<p>body</p>", makeTransportConfig());
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
|
||||
});
|
||||
|
||||
it("returns undefined and logs error when sendMail throws", async () => {
|
||||
const transporter = createMockTransporter();
|
||||
(transporter.sendMail as jest.Mock).mockRejectedValue(new Error("Recipient rejected"));
|
||||
const nodemailer = createMockNodemailer(transporter);
|
||||
const { service, logger } = createService({ nodemailer });
|
||||
|
||||
const result = await service.sendEmail("to@example.com", "Subject", "<p>body</p>", makeTransportConfig());
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Recipient rejected",
|
||||
service: "EmailService",
|
||||
method: "sendEmail",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error thrown values in sendMail", async () => {
|
||||
const transporter = createMockTransporter();
|
||||
(transporter.sendMail as jest.Mock).mockRejectedValue(42);
|
||||
const nodemailer = createMockNodemailer(transporter);
|
||||
const { service, logger } = createService({ nodemailer });
|
||||
|
||||
const result = await service.sendEmail("to@example.com", "Subject", "<p>body</p>", makeTransportConfig());
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Unknown error",
|
||||
method: "sendEmail",
|
||||
stack: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { GeoChecksService } from "../../../src/service/business/geoChecksService.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
import type { Monitor } from "../../../src/types/index.ts";
|
||||
import type { GeoCheckResult } from "../../../src/types/geoCheck.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "http",
|
||||
url: "https://example.com",
|
||||
geoCheckEnabled: true,
|
||||
geoCheckLocations: ["NA", "EU"],
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const makeGeoResult = (overrides?: Partial<GeoCheckResult>): GeoCheckResult => ({
|
||||
continent: "NA",
|
||||
country: "US",
|
||||
city: "New York",
|
||||
status: "up",
|
||||
responseTime: 50,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createService = (overrides?: Record<string, unknown>) => {
|
||||
const logger = createMockLogger();
|
||||
const geoChecksRepository = {
|
||||
createGeoChecks: jest.fn().mockResolvedValue([]),
|
||||
findByMonitorId: jest.fn().mockResolvedValue({ geoChecksCount: 0, geoChecks: [] }),
|
||||
};
|
||||
const globalPingService = {
|
||||
createMeasurement: jest.fn().mockResolvedValue("measurement-123"),
|
||||
pollForResults: jest.fn().mockResolvedValue([makeGeoResult()]),
|
||||
};
|
||||
const monitorsRepository = {
|
||||
findById: jest.fn().mockResolvedValue(makeMonitor()),
|
||||
};
|
||||
|
||||
const defaults = { logger, geoChecksRepository, globalPingService, monitorsRepository, ...overrides };
|
||||
const service = new GeoChecksService(defaults as any);
|
||||
return { service, ...defaults };
|
||||
};
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GeoChecksService", () => {
|
||||
describe("serviceName", () => {
|
||||
it("returns GeoChecksService from static property", () => {
|
||||
expect(GeoChecksService.SERVICE_NAME).toBe("GeoChecksService");
|
||||
});
|
||||
|
||||
it("returns GeoChecksService from instance getter", () => {
|
||||
const { service } = createService();
|
||||
expect(service.serviceName).toBe("GeoChecksService");
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildGeoCheck ───────────────────────────────────────────────────────
|
||||
|
||||
describe("buildGeoCheck", () => {
|
||||
it("returns a geo check document on success", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
const result = await service.buildGeoCheck(makeMonitor());
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.metadata).toEqual({ monitorId: "mon-1", teamId: "team-1", type: "http" });
|
||||
expect(result!.results).toHaveLength(1);
|
||||
expect(result!.results[0].continent).toBe("NA");
|
||||
expect(result!.id).toBeDefined();
|
||||
expect(result!.expiry).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns null and warns when monitor has no URL", async () => {
|
||||
const { service, logger } = createService();
|
||||
|
||||
const result = await service.buildGeoCheck(makeMonitor({ url: "" }));
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Monitor missing URL for geo check" }));
|
||||
});
|
||||
|
||||
it("returns null and warns when geoCheckLocations is empty", async () => {
|
||||
const { service, logger } = createService();
|
||||
|
||||
const result = await service.buildGeoCheck(makeMonitor({ geoCheckLocations: [] }));
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Monitor missing geo check locations" }));
|
||||
});
|
||||
|
||||
it("returns null and warns when geoCheckLocations is undefined", async () => {
|
||||
const { service, logger } = createService();
|
||||
|
||||
const result = await service.buildGeoCheck(makeMonitor({ geoCheckLocations: undefined as any }));
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Monitor missing geo check locations" }));
|
||||
});
|
||||
|
||||
it("returns null when createMeasurement returns null (API unavailable)", async () => {
|
||||
const { service, logger } = createService({
|
||||
globalPingService: {
|
||||
createMeasurement: jest.fn().mockResolvedValue(null),
|
||||
pollForResults: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.buildGeoCheck(makeMonitor());
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Skipping geo check due to API unavailability" }));
|
||||
});
|
||||
|
||||
it("returns null when pollForResults returns empty array", async () => {
|
||||
const { service, logger } = createService({
|
||||
globalPingService: {
|
||||
createMeasurement: jest.fn().mockResolvedValue("measurement-123"),
|
||||
pollForResults: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.buildGeoCheck(makeMonitor());
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "No successful geo check results" }));
|
||||
});
|
||||
|
||||
it("returns null and logs error when an exception is thrown", async () => {
|
||||
const { service, logger } = createService({
|
||||
globalPingService: {
|
||||
createMeasurement: jest.fn().mockRejectedValue(new Error("API error")),
|
||||
pollForResults: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.buildGeoCheck(makeMonitor());
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Error executing geo check",
|
||||
details: expect.objectContaining({ error: "API error" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error exceptions", async () => {
|
||||
const { service, logger } = createService({
|
||||
globalPingService: {
|
||||
createMeasurement: jest.fn().mockRejectedValue("string error"),
|
||||
pollForResults: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.buildGeoCheck(makeMonitor());
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ details: expect.objectContaining({ error: "Unknown error" }) }));
|
||||
});
|
||||
});
|
||||
|
||||
// ── createGeoChecks ─────────────────────────────────────────────────────
|
||||
|
||||
describe("createGeoChecks", () => {
|
||||
it("delegates to repository", async () => {
|
||||
const { service, geoChecksRepository } = createService();
|
||||
const geoChecks = [{ id: "gc-1" }] as any;
|
||||
|
||||
await service.createGeoChecks(geoChecks);
|
||||
|
||||
expect(geoChecksRepository.createGeoChecks).toHaveBeenCalledWith(geoChecks);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getGeoChecksByMonitor ────────────────────────────────────────────────
|
||||
|
||||
describe("getGeoChecksByMonitor", () => {
|
||||
it("returns geo checks from repository", async () => {
|
||||
const expected = { geoChecksCount: 1, geoChecks: [{ id: "gc-1" }] };
|
||||
const { service, geoChecksRepository } = createService();
|
||||
(geoChecksRepository.findByMonitorId as jest.Mock).mockResolvedValue(expected);
|
||||
|
||||
const result = await service.getGeoChecksByMonitor({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
sortOrder: "desc",
|
||||
dateRange: "day",
|
||||
continent: "NA" as any,
|
||||
});
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
expect(geoChecksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "desc", "day", 0, 5, ["NA"]);
|
||||
});
|
||||
|
||||
it("passes array of continents through", async () => {
|
||||
const { service, geoChecksRepository } = createService();
|
||||
|
||||
await service.getGeoChecksByMonitor({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
sortOrder: "desc",
|
||||
dateRange: "day",
|
||||
continent: ["NA", "EU"] as any,
|
||||
});
|
||||
|
||||
expect(geoChecksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "desc", "day", 0, 5, ["NA", "EU"]);
|
||||
});
|
||||
|
||||
it("passes undefined continents when not provided", async () => {
|
||||
const { service, geoChecksRepository } = createService();
|
||||
|
||||
await service.getGeoChecksByMonitor({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
sortOrder: "desc",
|
||||
dateRange: "day",
|
||||
continent: undefined as any,
|
||||
});
|
||||
|
||||
expect(geoChecksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "desc", "day", 0, 5, undefined);
|
||||
});
|
||||
|
||||
it("uses provided page and rowsPerPage", async () => {
|
||||
const { service, geoChecksRepository } = createService();
|
||||
|
||||
await service.getGeoChecksByMonitor({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
sortOrder: "asc",
|
||||
dateRange: "week",
|
||||
page: 3,
|
||||
rowsPerPage: 20,
|
||||
continent: "EU" as any,
|
||||
});
|
||||
|
||||
expect(geoChecksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "asc", "week", 3, 20, ["EU"]);
|
||||
});
|
||||
|
||||
it("throws when monitorId is missing", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
await expect(
|
||||
service.getGeoChecksByMonitor({ monitorId: "", teamId: "team-1", sortOrder: "desc", dateRange: "day", continent: "NA" as any })
|
||||
).rejects.toThrow("No monitor ID in request");
|
||||
});
|
||||
|
||||
it("throws when teamId is missing", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
await expect(
|
||||
service.getGeoChecksByMonitor({ monitorId: "mon-1", teamId: "", sortOrder: "desc", dateRange: "day", continent: "NA" as any })
|
||||
).rejects.toThrow("No team ID in request");
|
||||
});
|
||||
|
||||
it("throws 404 when monitor is not found", async () => {
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.getGeoChecksByMonitor({ monitorId: "mon-1", teamId: "team-1", sortOrder: "desc", dateRange: "day", continent: "NA" as any })
|
||||
).rejects.toThrow("Monitor with ID mon-1 not found.");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,499 @@
|
||||
import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
import type { GeoContinent } from "../../../src/types/geoCheck.ts";
|
||||
|
||||
// ── got mock ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockGotPost = jest.fn();
|
||||
const mockGotGet = jest.fn();
|
||||
|
||||
jest.unstable_mockModule("got", () => ({
|
||||
default: {
|
||||
post: mockGotPost,
|
||||
get: mockGotGet,
|
||||
},
|
||||
}));
|
||||
|
||||
// Dynamic import AFTER mock registration
|
||||
const { GlobalPingService } = await import("../../../src/service/infrastructure/globalPingService.ts");
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const createService = () => {
|
||||
const logger = createMockLogger();
|
||||
const service = new GlobalPingService(logger as any);
|
||||
return { service, logger };
|
||||
};
|
||||
|
||||
const makeProbeResult = (overrides?: Record<string, any>) => ({
|
||||
probe: {
|
||||
continent: "NA" as GeoContinent,
|
||||
region: "Northern America",
|
||||
country: "US",
|
||||
state: "CA",
|
||||
city: "San Francisco",
|
||||
longitude: -122.4,
|
||||
latitude: 37.77,
|
||||
},
|
||||
result: {
|
||||
status: "finished",
|
||||
statusCode: 200,
|
||||
statusCodeName: "OK",
|
||||
timings: {
|
||||
total: 150,
|
||||
dns: 10,
|
||||
tcp: 20,
|
||||
tls: 30,
|
||||
firstByte: 50,
|
||||
download: 40,
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GlobalPingService", () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockGotPost.mockReset();
|
||||
mockGotGet.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Static / instance properties ─────────────────────────────────────
|
||||
|
||||
describe("serviceName", () => {
|
||||
it("returns GlobalPingService from static property", () => {
|
||||
expect(GlobalPingService.SERVICE_NAME).toBe("GlobalPingService");
|
||||
});
|
||||
|
||||
it("returns GlobalPingService from instance getter", () => {
|
||||
const { service } = createService();
|
||||
expect(service.serviceName).toBe("GlobalPingService");
|
||||
});
|
||||
});
|
||||
|
||||
// ── createMeasurement ────────────────────────────────────────────────
|
||||
|
||||
describe("createMeasurement", () => {
|
||||
it("creates a measurement and returns the id", async () => {
|
||||
const { service, logger } = createService();
|
||||
mockGotPost.mockResolvedValue({ body: { id: "meas-123" } });
|
||||
|
||||
const result = await service.createMeasurement("http", "https://example.com", ["NA", "EU"] as GeoContinent[]);
|
||||
|
||||
expect(result).toBe("meas-123");
|
||||
expect(mockGotPost).toHaveBeenCalledWith(
|
||||
"https://api.globalping.io/v1/measurements",
|
||||
expect.objectContaining({
|
||||
json: {
|
||||
type: "http",
|
||||
target: "example.com",
|
||||
locations: [{ continent: "NA" }, { continent: "EU" }],
|
||||
limit: 2,
|
||||
},
|
||||
responseType: "json",
|
||||
timeout: { request: 10000 },
|
||||
})
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("meas-123"),
|
||||
method: "createMeasurement",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("strips http:// protocol from target URL", async () => {
|
||||
const { service } = createService();
|
||||
mockGotPost.mockResolvedValue({ body: { id: "meas-1" } });
|
||||
|
||||
await service.createMeasurement("http", "http://example.com", ["NA"] as GeoContinent[]);
|
||||
|
||||
expect(mockGotPost).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
json: expect.objectContaining({ target: "example.com" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("strips https:// protocol from target URL", async () => {
|
||||
const { service } = createService();
|
||||
mockGotPost.mockResolvedValue({ body: { id: "meas-1" } });
|
||||
|
||||
await service.createMeasurement("http", "https://example.com/path", ["EU"] as GeoContinent[]);
|
||||
|
||||
expect(mockGotPost).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
json: expect.objectContaining({ target: "example.com/path" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null and logs error for unsupported monitor type", async () => {
|
||||
const { service, logger } = createService();
|
||||
|
||||
const result = await service.createMeasurement("hardware" as any, "https://example.com", ["NA"] as GeoContinent[]);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "GlobalPing API unavailable, skipping geo check",
|
||||
method: "createMeasurement",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null and logs error when API call fails", async () => {
|
||||
const { service, logger } = createService();
|
||||
mockGotPost.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const result = await service.createMeasurement("http", "https://example.com", ["NA"] as GeoContinent[]);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "GlobalPing API unavailable, skipping geo check",
|
||||
method: "createMeasurement",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs stack as undefined for non-Error thrown values", async () => {
|
||||
const { service, logger } = createService();
|
||||
mockGotPost.mockRejectedValue("string error");
|
||||
|
||||
await service.createMeasurement("http", "https://example.com", ["NA"] as GeoContinent[]);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
|
||||
});
|
||||
|
||||
it("supports ping monitor type", async () => {
|
||||
const { service } = createService();
|
||||
mockGotPost.mockResolvedValue({ body: { id: "meas-ping" } });
|
||||
|
||||
const result = await service.createMeasurement("ping", "example.com", ["NA"] as GeoContinent[]);
|
||||
|
||||
expect(result).toBe("meas-ping");
|
||||
});
|
||||
});
|
||||
|
||||
// ── pollForResults ───────────────────────────────────────────────────
|
||||
|
||||
describe("pollForResults", () => {
|
||||
it("returns transformed results when measurement is finished", async () => {
|
||||
const { service, logger } = createService();
|
||||
mockGotGet.mockResolvedValue({
|
||||
body: {
|
||||
status: "finished",
|
||||
results: [makeProbeResult()],
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.pollForResults("meas-123");
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
location: expect.objectContaining({
|
||||
continent: "NA",
|
||||
city: "San Francisco",
|
||||
}),
|
||||
status: true,
|
||||
statusCode: 200,
|
||||
timings: expect.objectContaining({
|
||||
total: 150,
|
||||
dns: 10,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("meas-123"),
|
||||
method: "pollForResults",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults results to empty array when finished with no results", async () => {
|
||||
const { service } = createService();
|
||||
mockGotGet.mockResolvedValue({
|
||||
body: { status: "finished", results: undefined },
|
||||
});
|
||||
|
||||
const results = await service.pollForResults("meas-123");
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when measurement has failed", async () => {
|
||||
const { service, logger } = createService();
|
||||
mockGotGet.mockResolvedValue({
|
||||
body: { status: "failed" },
|
||||
});
|
||||
|
||||
const results = await service.pollForResults("meas-123");
|
||||
|
||||
expect(results).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("failed"),
|
||||
method: "pollForResults",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("polls again when status is in-progress, then returns on finished", async () => {
|
||||
const { service } = createService();
|
||||
mockGotGet.mockResolvedValueOnce({ body: { status: "in-progress" } }).mockResolvedValueOnce({
|
||||
body: {
|
||||
status: "finished",
|
||||
results: [makeProbeResult()],
|
||||
},
|
||||
});
|
||||
|
||||
const promise = service.pollForResults("meas-123");
|
||||
|
||||
// Advance past the sleep(2000)
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
|
||||
const results = await promise;
|
||||
|
||||
expect(mockGotGet).toHaveBeenCalledTimes(2);
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns empty array and logs error when API call throws", async () => {
|
||||
const { service, logger } = createService();
|
||||
mockGotGet.mockRejectedValue(new Error("Connection refused"));
|
||||
|
||||
const results = await service.pollForResults("meas-123");
|
||||
|
||||
expect(results).toEqual([]);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Error polling GlobalPing API",
|
||||
method: "pollForResults",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs stack as undefined for non-Error thrown values in poll", async () => {
|
||||
const { service, logger } = createService();
|
||||
mockGotGet.mockRejectedValue(42);
|
||||
|
||||
await service.pollForResults("meas-123");
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
|
||||
});
|
||||
|
||||
it("returns empty array and logs warning on timeout", async () => {
|
||||
const { service, logger } = createService();
|
||||
// Always return in-progress so we hit the timeout
|
||||
mockGotGet.mockImplementation(async () => ({ body: { status: "in-progress" } }));
|
||||
|
||||
const promise = service.pollForResults("meas-123", 100);
|
||||
|
||||
// Advance time well past the timeout
|
||||
await jest.advanceTimersByTimeAsync(35000);
|
||||
|
||||
const results = await promise;
|
||||
|
||||
expect(results).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("timeout"),
|
||||
method: "pollForResults",
|
||||
details: expect.objectContaining({ measurementId: "meas-123", timeoutMs: 100 }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── transformResults (via pollForResults) ────────────────────────────
|
||||
|
||||
describe("transformResults", () => {
|
||||
it("skips probes with non-finished status", async () => {
|
||||
const { service } = createService();
|
||||
mockGotGet.mockResolvedValue({
|
||||
body: {
|
||||
status: "finished",
|
||||
results: [makeProbeResult({ result: { status: "failed" } }), makeProbeResult({ result: { status: "timeout" } })],
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.pollForResults("meas-123");
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("transforms HTTP results with statusCode and timings", async () => {
|
||||
const { service } = createService();
|
||||
mockGotGet.mockResolvedValue({
|
||||
body: {
|
||||
status: "finished",
|
||||
results: [makeProbeResult()],
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.pollForResults("meas-123");
|
||||
|
||||
expect(results[0].status).toBe(true);
|
||||
expect(results[0].statusCode).toBe(200);
|
||||
expect(results[0].timings.total).toBe(150);
|
||||
});
|
||||
|
||||
it("marks HTTP result as failed for non-2xx status codes", async () => {
|
||||
const { service } = createService();
|
||||
mockGotGet.mockResolvedValue({
|
||||
body: {
|
||||
status: "finished",
|
||||
results: [
|
||||
makeProbeResult({
|
||||
result: {
|
||||
status: "finished",
|
||||
statusCode: 500,
|
||||
timings: { total: 100, dns: 5, tcp: 10, tls: 15, firstByte: 30, download: 40 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.pollForResults("meas-123");
|
||||
|
||||
expect(results[0].status).toBe(false);
|
||||
expect(results[0].statusCode).toBe(500);
|
||||
});
|
||||
|
||||
it("transforms ping results with stats (no loss)", async () => {
|
||||
const { service } = createService();
|
||||
mockGotGet.mockResolvedValue({
|
||||
body: {
|
||||
status: "finished",
|
||||
results: [
|
||||
makeProbeResult({
|
||||
result: {
|
||||
status: "finished",
|
||||
stats: { min: 10, max: 30, avg: 20, total: 3, loss: 0, rcv: 3, drop: 0 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.pollForResults("meas-123");
|
||||
|
||||
expect(results[0].status).toBe(true);
|
||||
expect(results[0].statusCode).toBe(200);
|
||||
expect(results[0].timings.total).toBe(20);
|
||||
expect(results[0].timings.dns).toBe(0);
|
||||
});
|
||||
|
||||
it("transforms ping results with loss as failed", async () => {
|
||||
const { service } = createService();
|
||||
mockGotGet.mockResolvedValue({
|
||||
body: {
|
||||
status: "finished",
|
||||
results: [
|
||||
makeProbeResult({
|
||||
result: {
|
||||
status: "finished",
|
||||
stats: { min: 10, max: 30, avg: 20, total: 3, loss: 2, rcv: 1, drop: 2 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.pollForResults("meas-123");
|
||||
|
||||
expect(results[0].status).toBe(false);
|
||||
expect(results[0].statusCode).toBe(5000);
|
||||
});
|
||||
|
||||
it("uses empty string for null state in location", async () => {
|
||||
const { service } = createService();
|
||||
mockGotGet.mockResolvedValue({
|
||||
body: {
|
||||
status: "finished",
|
||||
results: [
|
||||
makeProbeResult({
|
||||
probe: {
|
||||
continent: "EU",
|
||||
region: "Western Europe",
|
||||
country: "DE",
|
||||
state: null,
|
||||
city: "Berlin",
|
||||
longitude: 13.4,
|
||||
latitude: 52.5,
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.pollForResults("meas-123");
|
||||
|
||||
expect(results[0].location.state).toBe("");
|
||||
});
|
||||
|
||||
it("skips probes with no statusCode/timings and no stats", async () => {
|
||||
const { service } = createService();
|
||||
mockGotGet.mockResolvedValue({
|
||||
body: {
|
||||
status: "finished",
|
||||
results: [
|
||||
makeProbeResult({
|
||||
result: { status: "finished" },
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.pollForResults("meas-123");
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("transforms multiple probes from different locations", async () => {
|
||||
const { service } = createService();
|
||||
mockGotGet.mockResolvedValue({
|
||||
body: {
|
||||
status: "finished",
|
||||
results: [
|
||||
makeProbeResult(),
|
||||
makeProbeResult({
|
||||
probe: {
|
||||
continent: "EU",
|
||||
region: "Western Europe",
|
||||
country: "GB",
|
||||
state: null,
|
||||
city: "London",
|
||||
longitude: -0.12,
|
||||
latitude: 51.5,
|
||||
},
|
||||
result: {
|
||||
status: "finished",
|
||||
statusCode: 200,
|
||||
timings: { total: 80, dns: 5, tcp: 10, tls: 15, firstByte: 30, download: 20 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.pollForResults("meas-123");
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].location.continent).toBe("NA");
|
||||
expect(results[1].location.continent).toBe("EU");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { InviteService } from "../../../src/service/business/inviteService.ts";
|
||||
import type { Invite, UserRole } from "../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeInvite = (overrides?: Partial<Invite>): Invite => ({
|
||||
id: "inv-1",
|
||||
email: "invited@example.com",
|
||||
teamId: "team-1",
|
||||
role: ["user"] as UserRole[],
|
||||
token: "invite-token-123",
|
||||
expiry: "2026-12-31T00:00:00Z",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createService = (overrides?: Record<string, unknown>) => {
|
||||
const invitesRepository = {
|
||||
create: jest.fn().mockResolvedValue(makeInvite()),
|
||||
findByToken: jest.fn().mockResolvedValue(makeInvite()),
|
||||
};
|
||||
const settingsService = {
|
||||
getSettings: jest.fn().mockReturnValue({ clientHost: "http://localhost:5173" }),
|
||||
};
|
||||
const emailService = {
|
||||
buildEmail: jest.fn().mockResolvedValue("<html>Invite</html>"),
|
||||
sendEmail: jest.fn().mockResolvedValue("msg-id-123"),
|
||||
};
|
||||
|
||||
const defaults = { invitesRepository, settingsService, emailService, ...overrides };
|
||||
|
||||
const service = new InviteService(defaults as any);
|
||||
return { service, ...defaults };
|
||||
};
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("InviteService", () => {
|
||||
describe("serviceName", () => {
|
||||
it("returns inviteService from static property", () => {
|
||||
expect(InviteService.SERVICE_NAME).toBe("inviteService");
|
||||
});
|
||||
|
||||
it("returns inviteService from instance getter", () => {
|
||||
const { service } = createService();
|
||||
expect(service.serviceName).toBe("inviteService");
|
||||
});
|
||||
});
|
||||
|
||||
// ── getInviteToken ──────────────────────────────────────────────────────
|
||||
|
||||
describe("getInviteToken", () => {
|
||||
it("creates an invite with teamId assigned", async () => {
|
||||
const { service, invitesRepository } = createService();
|
||||
const invite: Partial<Invite> = { email: "new@example.com", role: ["user"] };
|
||||
|
||||
const result = await service.getInviteToken({ invite, teamId: "team-1", userRoles: ["superadmin"] });
|
||||
|
||||
expect(invitesRepository.create).toHaveBeenCalledWith(expect.objectContaining({ email: "new@example.com", teamId: "team-1" }));
|
||||
expect(result).toEqual(makeInvite());
|
||||
});
|
||||
|
||||
it("allows creation when role is undefined (defaults to empty)", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
const result = await service.getInviteToken({ invite: { email: "new@example.com" }, teamId: "team-1", userRoles: ["user"] });
|
||||
|
||||
expect(result).toEqual(makeInvite());
|
||||
});
|
||||
|
||||
it("throws 403 when actor cannot manage the target role", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
await expect(service.getInviteToken({ invite: { role: ["superadmin"] }, teamId: "team-1", userRoles: ["admin"] })).rejects.toThrow(
|
||||
"You do not have permission to create this invite"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── sendInviteEmail ─────────────────────────────────────────────────────
|
||||
|
||||
describe("sendInviteEmail", () => {
|
||||
it("creates invite, builds email, and sends it", async () => {
|
||||
const { service, invitesRepository, emailService, settingsService } = createService();
|
||||
|
||||
await service.sendInviteEmail({
|
||||
invite: { email: "new@example.com", role: ["user"] },
|
||||
firstName: "Test",
|
||||
userRoles: ["superadmin"],
|
||||
});
|
||||
|
||||
expect(invitesRepository.create).toHaveBeenCalledWith(expect.objectContaining({ email: "new@example.com" }));
|
||||
expect(settingsService.getSettings).toHaveBeenCalled();
|
||||
expect(emailService.buildEmail).toHaveBeenCalledWith("employeeActivationTemplate", {
|
||||
name: "Test",
|
||||
link: "http://localhost:5173/register/invite-token-123",
|
||||
});
|
||||
expect(emailService.sendEmail).toHaveBeenCalledWith("new@example.com", "Welcome to Uptime Monitor", "<html>Invite</html>");
|
||||
});
|
||||
|
||||
it("throws 400 when invite email is missing", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
await expect(service.sendInviteEmail({ invite: {}, firstName: "Test", userRoles: ["superadmin"] })).rejects.toThrow(
|
||||
"Invite email is required to send an invite"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws 403 when actor cannot manage the target role", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
await expect(
|
||||
service.sendInviteEmail({ invite: { email: "new@example.com", role: ["superadmin"] }, firstName: "Test", userRoles: ["admin"] })
|
||||
).rejects.toThrow("You do not have permission to create this invite");
|
||||
});
|
||||
|
||||
it("allows sending when role is undefined (defaults to empty)", async () => {
|
||||
const { service, emailService } = createService();
|
||||
|
||||
await service.sendInviteEmail({ invite: { email: "new@example.com" }, firstName: "Test", userRoles: ["user"] });
|
||||
|
||||
expect(emailService.sendEmail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws 500 when buildEmail returns falsy", async () => {
|
||||
const { service } = createService({
|
||||
emailService: {
|
||||
buildEmail: jest.fn().mockResolvedValue(null),
|
||||
sendEmail: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.sendInviteEmail({ invite: { email: "new@example.com", role: ["user"] }, firstName: "Test", userRoles: ["superadmin"] })
|
||||
).rejects.toThrow("Failed to build invite e-mail");
|
||||
});
|
||||
|
||||
it("throws 500 when sendEmail returns falsy", async () => {
|
||||
const { service } = createService({
|
||||
emailService: {
|
||||
buildEmail: jest.fn().mockResolvedValue("<html>ok</html>"),
|
||||
sendEmail: jest.fn().mockResolvedValue(false),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.sendInviteEmail({ invite: { email: "new@example.com", role: ["user"] }, firstName: "Test", userRoles: ["superadmin"] })
|
||||
).rejects.toThrow("Failed to send invite e-mail");
|
||||
});
|
||||
});
|
||||
|
||||
// ── verifyInviteToken ───────────────────────────────────────────────────
|
||||
|
||||
describe("verifyInviteToken", () => {
|
||||
it("delegates to repository", async () => {
|
||||
const { service, invitesRepository } = createService();
|
||||
|
||||
const result = await service.verifyInviteToken({ inviteToken: "invite-token-123" });
|
||||
|
||||
expect(invitesRepository.findByToken).toHaveBeenCalledWith("invite-token-123");
|
||||
expect(result).toEqual(makeInvite());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { MaintenanceWindowService } from "../../../src/service/business/maintenanceWindowService.ts";
|
||||
import type { IMaintenanceWindowsRepository } from "../../../src/repositories/maintenance-windows/IMaintenanceWindowsRepository.ts";
|
||||
import type { IMonitorsRepository } from "../../../src/repositories/monitors/IMonitorsRepository.ts";
|
||||
import type { MaintenanceWindow } from "../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const createMaintenanceWindowsRepo = () =>
|
||||
({
|
||||
create: jest.fn().mockResolvedValue(makeWindow()),
|
||||
findById: jest.fn().mockResolvedValue(makeWindow()),
|
||||
findByMonitorId: jest.fn().mockResolvedValue([makeWindow()]),
|
||||
findByTeamId: jest.fn().mockResolvedValue([makeWindow()]),
|
||||
updateById: jest.fn().mockResolvedValue(makeWindow()),
|
||||
deleteById: jest.fn().mockResolvedValue(makeWindow()),
|
||||
countByTeamId: jest.fn().mockResolvedValue(1),
|
||||
}) as unknown as jest.Mocked<IMaintenanceWindowsRepository>;
|
||||
|
||||
const createMonitorsRepo = () =>
|
||||
({
|
||||
findByIds: jest.fn().mockResolvedValue([
|
||||
{ id: "mon-1", teamId: "team-1" },
|
||||
{ id: "mon-2", teamId: "team-1" },
|
||||
]),
|
||||
}) as unknown as jest.Mocked<IMonitorsRepository>;
|
||||
|
||||
const createService = (overrides?: {
|
||||
monitorsRepository?: ReturnType<typeof createMonitorsRepo>;
|
||||
maintenanceWindowsRepository?: ReturnType<typeof createMaintenanceWindowsRepo>;
|
||||
}) => {
|
||||
const monitorsRepository = overrides?.monitorsRepository ?? createMonitorsRepo();
|
||||
const maintenanceWindowsRepository = overrides?.maintenanceWindowsRepository ?? createMaintenanceWindowsRepo();
|
||||
const service = new MaintenanceWindowService({ monitorsRepository, maintenanceWindowsRepository });
|
||||
return { service, monitorsRepository, maintenanceWindowsRepository };
|
||||
};
|
||||
|
||||
const makeWindow = (overrides?: Partial<MaintenanceWindow>): MaintenanceWindow => ({
|
||||
id: "mw-1",
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
active: true,
|
||||
name: "Scheduled Maintenance",
|
||||
duration: 60,
|
||||
durationUnit: "minutes",
|
||||
repeat: 0,
|
||||
start: "2026-04-10T02:00:00Z",
|
||||
end: "2026-04-10T03:00:00Z",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const defaultCreateParams = {
|
||||
teamId: "team-1",
|
||||
monitorIDs: ["mon-1", "mon-2"],
|
||||
name: "Scheduled Maintenance",
|
||||
active: true,
|
||||
duration: 60,
|
||||
durationUnit: "minutes" as const,
|
||||
repeat: 0,
|
||||
start: "2026-04-10T02:00:00Z",
|
||||
end: "2026-04-10T03:00:00Z",
|
||||
};
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MaintenanceWindowService", () => {
|
||||
describe("serviceName", () => {
|
||||
it("returns maintenanceWindowService from static property", () => {
|
||||
expect(MaintenanceWindowService.SERVICE_NAME).toBe("maintenanceWindowService");
|
||||
});
|
||||
|
||||
it("returns maintenanceWindowService from instance getter", () => {
|
||||
const { service } = createService();
|
||||
expect(service.serviceName).toBe("maintenanceWindowService");
|
||||
});
|
||||
});
|
||||
|
||||
// ── createMaintenanceWindow ──────────────────────────────────────────────
|
||||
|
||||
describe("createMaintenanceWindow", () => {
|
||||
it("creates a maintenance window for each monitor", async () => {
|
||||
const { service, maintenanceWindowsRepository } = createService();
|
||||
|
||||
await service.createMaintenanceWindow(defaultCreateParams);
|
||||
|
||||
expect(maintenanceWindowsRepository.create).toHaveBeenCalledTimes(2);
|
||||
expect(maintenanceWindowsRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamId: "team-1",
|
||||
monitorId: "mon-1",
|
||||
name: "Scheduled Maintenance",
|
||||
active: true,
|
||||
duration: 60,
|
||||
durationUnit: "minutes",
|
||||
repeat: 0,
|
||||
start: "2026-04-10T02:00:00Z",
|
||||
end: "2026-04-10T03:00:00Z",
|
||||
})
|
||||
);
|
||||
expect(maintenanceWindowsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ monitorId: "mon-2" }));
|
||||
});
|
||||
|
||||
it("verifies monitor ownership via findByIds", async () => {
|
||||
const { service, monitorsRepository } = createService();
|
||||
|
||||
await service.createMaintenanceWindow(defaultCreateParams);
|
||||
|
||||
expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"]);
|
||||
});
|
||||
|
||||
it("throws 403 when a monitor belongs to a different team", async () => {
|
||||
const monitorsRepository = createMonitorsRepo();
|
||||
(monitorsRepository.findByIds as jest.Mock).mockResolvedValue([
|
||||
{ id: "mon-1", teamId: "team-1" },
|
||||
{ id: "mon-2", teamId: "team-other" },
|
||||
]);
|
||||
const { service, maintenanceWindowsRepository } = createService({ monitorsRepository });
|
||||
|
||||
await expect(service.createMaintenanceWindow(defaultCreateParams)).rejects.toThrow(
|
||||
"Unauthorized to create maintenance window for one or more monitors"
|
||||
);
|
||||
expect(maintenanceWindowsRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not throw when all monitors belong to the team", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
await expect(service.createMaintenanceWindow(defaultCreateParams)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates a single window when monitorIDs has one entry", async () => {
|
||||
const { service, maintenanceWindowsRepository } = createService();
|
||||
|
||||
await service.createMaintenanceWindow({ ...defaultCreateParams, monitorIDs: ["mon-1"] });
|
||||
|
||||
expect(maintenanceWindowsRepository.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getMaintenanceWindowById ─────────────────────────────────────────────
|
||||
|
||||
describe("getMaintenanceWindowById", () => {
|
||||
it("delegates to repository with id and teamId", async () => {
|
||||
const expected = makeWindow();
|
||||
const { service, maintenanceWindowsRepository } = createService();
|
||||
(maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(expected);
|
||||
|
||||
const result = await service.getMaintenanceWindowById({ id: "mw-1", teamId: "team-1" });
|
||||
|
||||
expect(result).toBe(expected);
|
||||
expect(maintenanceWindowsRepository.findById).toHaveBeenCalledWith("mw-1", "team-1");
|
||||
});
|
||||
});
|
||||
|
||||
// ── getMaintenanceWindowsByTeamId ────────────────────────────────────────
|
||||
|
||||
describe("getMaintenanceWindowsByTeamId", () => {
|
||||
it("returns windows and count", async () => {
|
||||
const windows = [makeWindow()];
|
||||
const { service, maintenanceWindowsRepository } = createService();
|
||||
(maintenanceWindowsRepository.findByTeamId as jest.Mock).mockResolvedValue(windows);
|
||||
(maintenanceWindowsRepository.countByTeamId as jest.Mock).mockResolvedValue(1);
|
||||
|
||||
const result = await service.getMaintenanceWindowsByTeamId({ teamId: "team-1" });
|
||||
|
||||
expect(result).toEqual({ maintenanceWindows: windows, maintenanceWindowCount: 1 });
|
||||
});
|
||||
|
||||
it("defaults page to 0 and rowsPerPage to 10 when not provided", async () => {
|
||||
const { service, maintenanceWindowsRepository } = createService();
|
||||
|
||||
await service.getMaintenanceWindowsByTeamId({ teamId: "team-1" });
|
||||
|
||||
expect(maintenanceWindowsRepository.findByTeamId).toHaveBeenCalledWith("team-1", 0, 10, undefined, undefined, undefined);
|
||||
});
|
||||
|
||||
it("passes pagination and filter parameters through", async () => {
|
||||
const { service, maintenanceWindowsRepository } = createService();
|
||||
|
||||
await service.getMaintenanceWindowsByTeamId({
|
||||
teamId: "team-1",
|
||||
active: true,
|
||||
page: 2,
|
||||
rowsPerPage: 5,
|
||||
field: "name",
|
||||
order: "asc",
|
||||
});
|
||||
|
||||
expect(maintenanceWindowsRepository.findByTeamId).toHaveBeenCalledWith("team-1", 2, 5, "name", "asc", true);
|
||||
expect(maintenanceWindowsRepository.countByTeamId).toHaveBeenCalledWith("team-1", true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getMaintenanceWindowsByMonitorId ─────────────────────────────────────
|
||||
|
||||
describe("getMaintenanceWindowsByMonitorId", () => {
|
||||
it("delegates to repository with monitorId and teamId", async () => {
|
||||
const windows = [makeWindow()];
|
||||
const { service, maintenanceWindowsRepository } = createService();
|
||||
(maintenanceWindowsRepository.findByMonitorId as jest.Mock).mockResolvedValue(windows);
|
||||
|
||||
const result = await service.getMaintenanceWindowsByMonitorId({ monitorId: "mon-1", teamId: "team-1" });
|
||||
|
||||
expect(result).toBe(windows);
|
||||
expect(maintenanceWindowsRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "team-1");
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteMaintenanceWindow ──────────────────────────────────────────────
|
||||
|
||||
describe("deleteMaintenanceWindow", () => {
|
||||
it("delegates to repository and returns deleted window", async () => {
|
||||
const deleted = makeWindow();
|
||||
const { service, maintenanceWindowsRepository } = createService();
|
||||
(maintenanceWindowsRepository.deleteById as jest.Mock).mockResolvedValue(deleted);
|
||||
|
||||
const result = await service.deleteMaintenanceWindow({ id: "mw-1", teamId: "team-1" });
|
||||
|
||||
expect(result).toBe(deleted);
|
||||
expect(maintenanceWindowsRepository.deleteById).toHaveBeenCalledWith("mw-1", "team-1");
|
||||
});
|
||||
});
|
||||
|
||||
// ── editMaintenanceWindow ────────────────────────────────────────────────
|
||||
|
||||
describe("editMaintenanceWindow", () => {
|
||||
it("delegates to repository with id, teamId, and body", async () => {
|
||||
const updated = makeWindow({ name: "Updated Name" });
|
||||
const { service, maintenanceWindowsRepository } = createService();
|
||||
(maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(updated);
|
||||
|
||||
const result = await service.editMaintenanceWindow({ id: "mw-1", teamId: "team-1", body: { name: "Updated Name" } });
|
||||
|
||||
expect(result).toBe(updated);
|
||||
expect(maintenanceWindowsRepository.updateById).toHaveBeenCalledWith("mw-1", "team-1", { name: "Updated Name" });
|
||||
});
|
||||
|
||||
it("passes partial body fields through", async () => {
|
||||
const { service, maintenanceWindowsRepository } = createService();
|
||||
|
||||
await service.editMaintenanceWindow({
|
||||
id: "mw-1",
|
||||
teamId: "team-1",
|
||||
body: { active: false, repeat: 3600000 },
|
||||
});
|
||||
|
||||
expect(maintenanceWindowsRepository.updateById).toHaveBeenCalledWith("mw-1", "team-1", { active: false, repeat: 3600000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,482 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { NetworkService } from "../../../src/service/infrastructure/networkService.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
import { NETWORK_ERROR } from "../../../src/service/infrastructure/network/utils.ts";
|
||||
import type { IStatusProvider } from "../../../src/service/infrastructure/network/IStatusProvider.ts";
|
||||
import type { Monitor, MonitorType } from "../../../src/types/index.ts";
|
||||
import type { AxiosStatic } from "axios";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const createMockAxios = () =>
|
||||
({
|
||||
post: jest.fn(),
|
||||
}) as unknown as jest.Mocked<AxiosStatic>;
|
||||
|
||||
const createMockProvider = (type: string, result?: unknown) => {
|
||||
const provider: jest.Mocked<IStatusProvider<unknown>> = {
|
||||
type,
|
||||
supports: jest.fn((t: MonitorType) => t === type) as any,
|
||||
handle: jest.fn().mockResolvedValue(
|
||||
result ?? {
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type,
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "OK",
|
||||
}
|
||||
),
|
||||
};
|
||||
return provider;
|
||||
};
|
||||
|
||||
const createService = (providers: IStatusProvider<unknown>[] = []) => {
|
||||
const logger = createMockLogger();
|
||||
const axios = createMockAxios();
|
||||
const service = new NetworkService(axios, logger as any, providers);
|
||||
return { service, logger, axios };
|
||||
};
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("NetworkService", () => {
|
||||
// ── Static / instance properties ─────────────────────────────────────────
|
||||
|
||||
describe("serviceName", () => {
|
||||
it("returns NetworkService from static property", () => {
|
||||
expect(NetworkService.SERVICE_NAME).toBe("NetworkService");
|
||||
});
|
||||
|
||||
it("returns NetworkService from instance getter", () => {
|
||||
const { service } = createService();
|
||||
expect(service.serviceName).toBe("NetworkService");
|
||||
});
|
||||
});
|
||||
|
||||
// ── requestStatus ────────────────────────────────────────────────────────
|
||||
|
||||
describe("requestStatus", () => {
|
||||
it("delegates to the matching provider", async () => {
|
||||
const provider = createMockProvider("http");
|
||||
const { service } = createService([provider]);
|
||||
const monitor = { type: "http", _id: "mon-1" } as unknown as Monitor & { type: "http" };
|
||||
|
||||
const result = await service.requestStatus(monitor);
|
||||
|
||||
expect(provider.supports).toHaveBeenCalledWith("http");
|
||||
expect(provider.handle).toHaveBeenCalledWith(monitor);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
monitorId: "mon-1",
|
||||
status: true,
|
||||
code: 200,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("selects the correct provider when multiple are registered", async () => {
|
||||
const httpProvider = createMockProvider("http");
|
||||
const pingProvider = createMockProvider("ping");
|
||||
const { service } = createService([httpProvider, pingProvider]);
|
||||
const monitor = { type: "ping", _id: "mon-2" } as unknown as Monitor & { type: "ping" };
|
||||
|
||||
await service.requestStatus(monitor);
|
||||
|
||||
expect(httpProvider.handle).not.toHaveBeenCalled();
|
||||
expect(pingProvider.handle).toHaveBeenCalledWith(monitor);
|
||||
});
|
||||
|
||||
it("returns unsupported-type response when no provider matches", async () => {
|
||||
const { service } = createService([]);
|
||||
const monitor = { type: "unknown_type" as any, _id: "mon-1" } as unknown as Monitor & { type: any };
|
||||
|
||||
const result = await service.requestStatus(monitor);
|
||||
|
||||
expect(result).toEqual({
|
||||
monitorId: "unknown",
|
||||
teamId: "unknown",
|
||||
type: "unknown",
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: "Unsupported type: unknown_type",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── requestWebhook ───────────────────────────────────────────────────────
|
||||
|
||||
describe("requestWebhook", () => {
|
||||
it("sends a POST and returns success response", async () => {
|
||||
const { service, axios } = createService();
|
||||
(axios.post as jest.Mock).mockResolvedValue({ status: 200, data: { ok: true } });
|
||||
|
||||
const result = await service.requestWebhook("slack", "https://hooks.example.com", { text: "hello" });
|
||||
|
||||
expect(axios.post).toHaveBeenCalledWith("https://hooks.example.com", { text: "hello" }, { headers: { "Content-Type": "application/json" } });
|
||||
expect(result).toEqual({
|
||||
type: "webhook",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "Successfully sent slack notification",
|
||||
payload: { ok: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns failure with response data on axios error with response", async () => {
|
||||
const { service, axios, logger } = createService();
|
||||
const axiosError = Object.assign(new Error("Request failed"), {
|
||||
response: { status: 403, data: { error: "forbidden" } },
|
||||
});
|
||||
(axios.post as jest.Mock).mockRejectedValue(axiosError);
|
||||
|
||||
const result = await service.requestWebhook("discord", "https://hooks.example.com", {});
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "webhook",
|
||||
status: false,
|
||||
code: 403,
|
||||
message: "Failed to send discord notification",
|
||||
payload: { error: "forbidden" },
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Request failed",
|
||||
service: "NetworkService",
|
||||
method: "requestWebhook",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns failure with NETWORK_ERROR when axios error has no response status", async () => {
|
||||
const { service, axios } = createService();
|
||||
const axiosError = Object.assign(new Error("timeout"), {
|
||||
response: { status: undefined, data: undefined },
|
||||
});
|
||||
(axios.post as jest.Mock).mockRejectedValue(axiosError);
|
||||
|
||||
const result = await service.requestWebhook("slack", "https://hooks.example.com", {});
|
||||
|
||||
expect(result.code).toBe(NETWORK_ERROR);
|
||||
});
|
||||
|
||||
it("returns failure with NETWORK_ERROR on non-axios error (no response property)", async () => {
|
||||
const { service, axios } = createService();
|
||||
(axios.post as jest.Mock).mockRejectedValue(new Error("DNS failure"));
|
||||
|
||||
const result = await service.requestWebhook("slack", "https://hooks.example.com", {});
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "webhook",
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: "Failed to send slack notification",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles non-Error thrown values", async () => {
|
||||
const { service, axios, logger } = createService();
|
||||
(axios.post as jest.Mock).mockRejectedValue("string error");
|
||||
|
||||
const result = await service.requestWebhook("slack", "https://hooks.example.com", {});
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "string error" }));
|
||||
expect(result).toEqual({
|
||||
type: "webhook",
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: "Failed to send slack notification",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns NETWORK_ERROR when error has response key but response is undefined", async () => {
|
||||
const { service, axios } = createService();
|
||||
const axiosError = Object.assign(new Error("fail"), { response: undefined });
|
||||
(axios.post as jest.Mock).mockRejectedValue(axiosError);
|
||||
|
||||
const result = await service.requestWebhook("slack", "https://hooks.example.com", {});
|
||||
|
||||
expect(result.code).toBe(NETWORK_ERROR);
|
||||
expect(result.payload).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── requestPagerDuty ─────────────────────────────────────────────────────
|
||||
|
||||
describe("requestPagerDuty", () => {
|
||||
const args = {
|
||||
message: "Server down",
|
||||
routingKey: "routing-key-123",
|
||||
monitorUrl: "https://monitor.example.com",
|
||||
};
|
||||
|
||||
it("sends correct payload and returns true on success", async () => {
|
||||
const { service, axios } = createService();
|
||||
(axios.post as jest.Mock).mockResolvedValue({ data: { status: "success" } });
|
||||
|
||||
const result = await service.requestPagerDuty(args);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
"https://events.pagerduty.com/v2/enqueue",
|
||||
expect.objectContaining({
|
||||
routing_key: "routing-key-123",
|
||||
event_action: "trigger",
|
||||
payload: expect.objectContaining({
|
||||
summary: "Server down",
|
||||
severity: "critical",
|
||||
source: "https://monitor.example.com",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when response status is not success", async () => {
|
||||
const { service, axios } = createService();
|
||||
(axios.post as jest.Mock).mockResolvedValue({ data: { status: "invalid event" } });
|
||||
|
||||
const result = await service.requestPagerDuty(args);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when response data is undefined", async () => {
|
||||
const { service, axios } = createService();
|
||||
(axios.post as jest.Mock).mockResolvedValue({ data: undefined });
|
||||
|
||||
const result = await service.requestPagerDuty(args);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("throws AppError on axios failure with response data", async () => {
|
||||
const { service, axios } = createService();
|
||||
const axiosError = Object.assign(new Error("Request failed"), {
|
||||
response: { data: { message: "invalid routing key" } },
|
||||
});
|
||||
(axios.post as jest.Mock).mockRejectedValue(axiosError);
|
||||
|
||||
await expect(service.requestPagerDuty(args)).rejects.toThrow("Request failed");
|
||||
});
|
||||
|
||||
it("throws AppError on network error without response", async () => {
|
||||
const { service, axios } = createService();
|
||||
(axios.post as jest.Mock).mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
|
||||
await expect(service.requestPagerDuty(args)).rejects.toThrow("ECONNREFUSED");
|
||||
});
|
||||
|
||||
it("throws AppError with default message for non-Error thrown value", async () => {
|
||||
const { service, axios } = createService();
|
||||
(axios.post as jest.Mock).mockRejectedValue(null);
|
||||
|
||||
await expect(service.requestPagerDuty(args)).rejects.toThrow("null");
|
||||
});
|
||||
|
||||
it("uses fallback message when Error has empty string message", async () => {
|
||||
const { service, axios } = createService();
|
||||
(axios.post as jest.Mock).mockRejectedValue(new Error(""));
|
||||
|
||||
try {
|
||||
await service.requestPagerDuty(args);
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err: any) {
|
||||
expect(err.message).toBe("Error sending PagerDuty notification");
|
||||
}
|
||||
});
|
||||
|
||||
it("includes responseData when non-Error object has response property", async () => {
|
||||
const { service, axios } = createService();
|
||||
const errorObj = { response: { data: { detail: "bad key" } } };
|
||||
(axios.post as jest.Mock).mockRejectedValue(errorObj);
|
||||
|
||||
try {
|
||||
await service.requestPagerDuty(args);
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err: any) {
|
||||
expect(err.details).toEqual({ responseData: { detail: "bad key" } });
|
||||
}
|
||||
});
|
||||
|
||||
it("sets responseData to undefined when error is a primitive (non-object)", async () => {
|
||||
const { service, axios } = createService();
|
||||
(axios.post as jest.Mock).mockRejectedValue(42);
|
||||
|
||||
try {
|
||||
await service.requestPagerDuty(args);
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err: any) {
|
||||
expect(err.details).toEqual({ responseData: undefined });
|
||||
}
|
||||
});
|
||||
|
||||
it("sets responseData to undefined when object has response key set to undefined", async () => {
|
||||
const { service, axios } = createService();
|
||||
(axios.post as jest.Mock).mockRejectedValue({ response: undefined });
|
||||
|
||||
try {
|
||||
await service.requestPagerDuty(args);
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err: any) {
|
||||
expect(err.details).toEqual({ responseData: undefined });
|
||||
}
|
||||
});
|
||||
|
||||
it("includes responseData in AppError details when available", async () => {
|
||||
const { service, axios } = createService();
|
||||
const axiosError = Object.assign(new Error("Bad request"), {
|
||||
response: { data: { errors: ["invalid key"] } },
|
||||
});
|
||||
(axios.post as jest.Mock).mockRejectedValue(axiosError);
|
||||
|
||||
try {
|
||||
await service.requestPagerDuty(args);
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err: any) {
|
||||
expect(err.details).toEqual({
|
||||
responseData: { errors: ["invalid key"] },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("sets responseData to undefined in AppError when error has no response", async () => {
|
||||
const { service, axios } = createService();
|
||||
(axios.post as jest.Mock).mockRejectedValue(new Error("timeout"));
|
||||
|
||||
try {
|
||||
await service.requestPagerDuty(args);
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err: any) {
|
||||
expect(err.details).toEqual({ responseData: undefined });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── requestMatrix ────────────────────────────────────────────────────────
|
||||
|
||||
describe("requestMatrix", () => {
|
||||
const args = {
|
||||
homeserverUrl: "https://matrix.example.com",
|
||||
accessToken: "token-abc",
|
||||
roomId: "!room:example.com",
|
||||
message: "<b>Alert</b>",
|
||||
};
|
||||
|
||||
it("sends correct payload and returns success response", async () => {
|
||||
const { service, axios } = createService();
|
||||
(axios.post as jest.Mock).mockResolvedValue({ status: 200, data: { event_id: "$abc" } });
|
||||
|
||||
const result = await service.requestMatrix(args);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "Successfully sent Matrix notification",
|
||||
});
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
"https://matrix.example.com/_matrix/client/v3/rooms/!room:example.com/send/m.room.message?access_token=token-abc",
|
||||
{
|
||||
msgtype: "m.text",
|
||||
body: "<b>Alert</b>",
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: "<b>Alert</b>",
|
||||
},
|
||||
{ headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
});
|
||||
|
||||
it("returns failure with response data on axios error with response (Error instance)", async () => {
|
||||
const { service, axios, logger } = createService();
|
||||
const axiosError = Object.assign(new Error("Forbidden"), {
|
||||
response: { status: 403, data: { errcode: "M_FORBIDDEN" } },
|
||||
});
|
||||
(axios.post as jest.Mock).mockRejectedValue(axiosError);
|
||||
|
||||
const result = await service.requestMatrix(args);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: false,
|
||||
code: 403,
|
||||
message: "Failed to send Matrix notification",
|
||||
payload: { errcode: "M_FORBIDDEN" },
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Forbidden",
|
||||
service: "NetworkService",
|
||||
method: "requestMatrix",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns NETWORK_ERROR when axios error response has no status", async () => {
|
||||
const { service, axios } = createService();
|
||||
const axiosError = Object.assign(new Error("timeout"), {
|
||||
response: { status: 0, data: undefined },
|
||||
});
|
||||
(axios.post as jest.Mock).mockRejectedValue(axiosError);
|
||||
|
||||
const result = await service.requestMatrix(args);
|
||||
|
||||
expect(result.code).toBe(NETWORK_ERROR);
|
||||
});
|
||||
|
||||
it("returns failure with NETWORK_ERROR on Error without response property", async () => {
|
||||
const { service, axios, logger } = createService();
|
||||
(axios.post as jest.Mock).mockRejectedValue(new Error("DNS failure"));
|
||||
|
||||
const result = await service.requestMatrix(args);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: "Failed to send Matrix notification",
|
||||
});
|
||||
// First warn call is from the Error instanceof branch
|
||||
expect(logger.warn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns failure with NETWORK_ERROR on non-Error thrown value", async () => {
|
||||
const { service, axios, logger } = createService();
|
||||
(axios.post as jest.Mock).mockRejectedValue("string error");
|
||||
|
||||
const result = await service.requestMatrix(args);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: "Failed to send Matrix notification",
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "string error",
|
||||
service: "NetworkService",
|
||||
method: "requestMatrix",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns NETWORK_ERROR when Error has response key but response is undefined", async () => {
|
||||
const { service, axios } = createService();
|
||||
const axiosError = Object.assign(new Error("fail"), { response: undefined });
|
||||
(axios.post as jest.Mock).mockRejectedValue(axiosError);
|
||||
|
||||
const result = await service.requestMatrix(args);
|
||||
|
||||
expect(result.code).toBe(NETWORK_ERROR);
|
||||
});
|
||||
|
||||
it("returns NETWORK_ERROR when Error response has undefined status", async () => {
|
||||
const { service, axios } = createService();
|
||||
const axiosError = Object.assign(new Error("fail"), {
|
||||
response: { status: undefined, data: { error: "unknown" } },
|
||||
});
|
||||
(axios.post as jest.Mock).mockRejectedValue(axiosError);
|
||||
|
||||
const result = await service.requestMatrix(args);
|
||||
|
||||
expect(result.code).toBe(NETWORK_ERROR);
|
||||
expect(result.payload).toEqual({ error: "unknown" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,761 @@
|
||||
import { describe, expect, it, beforeEach } from "@jest/globals";
|
||||
import { NotificationMessageBuilder } from "../../../src/service/infrastructure/notificationMessageBuilder.ts";
|
||||
import type { Monitor, MonitorStatusResponse, HardwareStatusPayload } from "../../../src/types/index.ts";
|
||||
import type { MonitorActionDecision } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
name: "Test Monitor",
|
||||
url: "https://example.com",
|
||||
type: "http",
|
||||
status: "down",
|
||||
teamId: "team-1",
|
||||
cpuAlertThreshold: undefined,
|
||||
memoryAlertThreshold: undefined,
|
||||
diskAlertThreshold: undefined,
|
||||
tempAlertThreshold: undefined,
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
|
||||
shouldCreateIncident: false,
|
||||
shouldResolveIncident: false,
|
||||
shouldSendNotification: true,
|
||||
incidentReason: null,
|
||||
notificationReason: "status_change",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeStatusResponse = (overrides?: Partial<MonitorStatusResponse>): MonitorStatusResponse =>
|
||||
({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "http",
|
||||
status: false,
|
||||
code: 500,
|
||||
message: "Internal Server Error",
|
||||
...overrides,
|
||||
}) as MonitorStatusResponse;
|
||||
|
||||
const makeHardwarePayload = (overrides?: Partial<HardwareStatusPayload["data"]>): HardwareStatusPayload =>
|
||||
({
|
||||
data: {
|
||||
cpu: { usage_percent: 0.5, temperature: [45] },
|
||||
memory: { usage_percent: 0.6 },
|
||||
disk: [{ usage_percent: 0.7 }],
|
||||
...overrides,
|
||||
},
|
||||
}) as HardwareStatusPayload;
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("NotificationMessageBuilder", () => {
|
||||
let builder: NotificationMessageBuilder;
|
||||
|
||||
beforeEach(() => {
|
||||
builder = new NotificationMessageBuilder();
|
||||
});
|
||||
|
||||
describe("SERVICE_NAME", () => {
|
||||
it("returns NotificationMessageBuilder", () => {
|
||||
expect(NotificationMessageBuilder.SERVICE_NAME).toBe("NotificationMessageBuilder");
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildMessage ─────────────────────────────────────────────────────
|
||||
|
||||
describe("buildMessage", () => {
|
||||
it("builds a monitor_down message", () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
const decision = makeDecision();
|
||||
const response = makeStatusResponse();
|
||||
|
||||
const msg = builder.buildMessage(monitor, response, decision, "https://app.example.com");
|
||||
|
||||
expect(msg.type).toBe("monitor_down");
|
||||
expect(msg.severity).toBe("critical");
|
||||
expect(msg.monitor).toEqual({
|
||||
id: "mon-1",
|
||||
name: "Test Monitor",
|
||||
url: "https://example.com",
|
||||
type: "http",
|
||||
status: "down",
|
||||
});
|
||||
expect(msg.content.title).toBe("Monitor Down: Test Monitor");
|
||||
expect(msg.clientHost).toBe("https://app.example.com");
|
||||
expect(msg.metadata).toEqual({
|
||||
teamId: "team-1",
|
||||
notificationReason: "status_change",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a monitor_up message", () => {
|
||||
const monitor = makeMonitor({ status: "up", type: "http" });
|
||||
const decision = makeDecision({ notificationReason: "status_change" });
|
||||
|
||||
const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "https://app.example.com");
|
||||
|
||||
expect(msg.type).toBe("monitor_up");
|
||||
expect(msg.severity).toBe("success");
|
||||
expect(msg.content.title).toBe("Monitor Recovered: Test Monitor");
|
||||
});
|
||||
|
||||
it("builds a threshold_breach message", () => {
|
||||
const monitor = makeMonitor({ status: "up", type: "hardware", cpuAlertThreshold: 80 });
|
||||
const decision = makeDecision({ notificationReason: "threshold_breach" });
|
||||
const response = makeStatusResponse({
|
||||
type: "hardware",
|
||||
payload: makeHardwarePayload({ cpu: { usage_percent: 0.9, temperature: [50] } as any }),
|
||||
} as any);
|
||||
|
||||
const msg = builder.buildMessage(monitor, response, decision, "https://app.example.com");
|
||||
|
||||
expect(msg.type).toBe("threshold_breach");
|
||||
expect(msg.severity).toBe("warning");
|
||||
expect(msg.content.title).toBe("Threshold Exceeded: Test Monitor");
|
||||
expect(msg.content.thresholds).toBeDefined();
|
||||
expect(msg.content.thresholds!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("builds a threshold_resolved message for hardware monitor recovering", () => {
|
||||
const monitor = makeMonitor({ status: "up", type: "hardware" });
|
||||
const decision = makeDecision({ notificationReason: "status_change" });
|
||||
|
||||
const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "https://app.example.com");
|
||||
|
||||
expect(msg.type).toBe("threshold_resolved");
|
||||
expect(msg.severity).toBe("success");
|
||||
expect(msg.content.title).toBe("Thresholds Resolved: Test Monitor");
|
||||
});
|
||||
|
||||
it("uses notificationReason from decision, falling back to status_change", () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
const decision = makeDecision({ notificationReason: null });
|
||||
|
||||
const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "https://app.example.com");
|
||||
|
||||
expect(msg.metadata.notificationReason).toBe("status_change");
|
||||
});
|
||||
});
|
||||
|
||||
// ── determineNotificationType (via buildMessage) ─────────────────────
|
||||
|
||||
describe("determineNotificationType", () => {
|
||||
it("returns monitor_down when status is down, even if notificationReason is threshold_breach", () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
const decision = makeDecision({ notificationReason: "threshold_breach" });
|
||||
|
||||
const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "");
|
||||
|
||||
expect(msg.type).toBe("monitor_down");
|
||||
});
|
||||
|
||||
it("returns threshold_breach when reason is threshold_breach and status is not down", () => {
|
||||
const monitor = makeMonitor({ status: "up", type: "hardware" });
|
||||
const decision = makeDecision({ notificationReason: "threshold_breach" });
|
||||
|
||||
const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "");
|
||||
|
||||
expect(msg.type).toBe("threshold_breach");
|
||||
});
|
||||
|
||||
it("returns threshold_resolved for hardware monitor with status_change and up", () => {
|
||||
const monitor = makeMonitor({ status: "up", type: "hardware" });
|
||||
const decision = makeDecision({ notificationReason: "status_change" });
|
||||
|
||||
const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "");
|
||||
|
||||
expect(msg.type).toBe("threshold_resolved");
|
||||
});
|
||||
|
||||
it("returns monitor_up for non-hardware monitor with status up", () => {
|
||||
const monitor = makeMonitor({ status: "up", type: "http" });
|
||||
const decision = makeDecision({ notificationReason: "status_change" });
|
||||
|
||||
const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "");
|
||||
|
||||
expect(msg.type).toBe("monitor_up");
|
||||
});
|
||||
|
||||
it("returns monitor_up as default for unrecognized status", () => {
|
||||
const monitor = makeMonitor({ status: "unknown" as any });
|
||||
const decision = makeDecision({ notificationReason: null });
|
||||
|
||||
const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "");
|
||||
|
||||
expect(msg.type).toBe("monitor_up");
|
||||
});
|
||||
});
|
||||
|
||||
// ── determineSeverity (via buildMessage) ─────────────────────────────
|
||||
|
||||
describe("determineSeverity", () => {
|
||||
it("returns critical for monitor_down", () => {
|
||||
const msg = builder.buildMessage(makeMonitor({ status: "down" }), makeStatusResponse(), makeDecision(), "");
|
||||
expect(msg.severity).toBe("critical");
|
||||
});
|
||||
|
||||
it("returns warning for threshold_breach", () => {
|
||||
const msg = builder.buildMessage(
|
||||
makeMonitor({ status: "up", type: "hardware" }),
|
||||
makeStatusResponse(),
|
||||
makeDecision({ notificationReason: "threshold_breach" }),
|
||||
""
|
||||
);
|
||||
expect(msg.severity).toBe("warning");
|
||||
});
|
||||
|
||||
it("returns success for monitor_up", () => {
|
||||
const msg = builder.buildMessage(
|
||||
makeMonitor({ status: "up", type: "http" }),
|
||||
makeStatusResponse(),
|
||||
makeDecision({ notificationReason: "status_change" }),
|
||||
""
|
||||
);
|
||||
expect(msg.severity).toBe("success");
|
||||
});
|
||||
|
||||
it("returns success for threshold_resolved", () => {
|
||||
const msg = builder.buildMessage(
|
||||
makeMonitor({ status: "up", type: "hardware" }),
|
||||
makeStatusResponse(),
|
||||
makeDecision({ notificationReason: "status_change" }),
|
||||
""
|
||||
);
|
||||
expect(msg.severity).toBe("success");
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildContent variants ────────────────────────────────────────────
|
||||
|
||||
describe("buildContent", () => {
|
||||
describe("monitor_down", () => {
|
||||
it("includes response code and error message when present", () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
const response = makeStatusResponse({ code: 503, message: "Service Unavailable" });
|
||||
|
||||
const msg = builder.buildMessage(monitor, response, makeDecision(), "");
|
||||
|
||||
expect(msg.content.details).toContain("Response Code: 503");
|
||||
expect(msg.content.details).toContain("Error: Service Unavailable");
|
||||
});
|
||||
|
||||
it("omits response code when falsy", () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
const response = makeStatusResponse({ code: 0, message: "" });
|
||||
|
||||
const msg = builder.buildMessage(monitor, response, makeDecision(), "");
|
||||
|
||||
expect(msg.content.details).not.toContainEqual(expect.stringContaining("Response Code:"));
|
||||
expect(msg.content.details).not.toContainEqual(expect.stringContaining("Error:"));
|
||||
});
|
||||
|
||||
it("includes URL, Status, and Type in details", () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), "");
|
||||
|
||||
expect(msg.content.details).toContain("URL: https://example.com");
|
||||
expect(msg.content.details).toContain("Status: Down");
|
||||
expect(msg.content.details).toContain("Type: http");
|
||||
});
|
||||
|
||||
it("sets summary text", () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), "");
|
||||
expect(msg.content.summary).toBe('Monitor "Test Monitor" is currently down and unreachable.');
|
||||
});
|
||||
|
||||
it("sets timestamp", () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), "");
|
||||
expect(msg.content.timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe("monitor_up", () => {
|
||||
it("includes recovery details", () => {
|
||||
const monitor = makeMonitor({ status: "up", type: "http" });
|
||||
const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), "");
|
||||
|
||||
expect(msg.content.title).toBe("Monitor Recovered: Test Monitor");
|
||||
expect(msg.content.summary).toBe('Monitor "Test Monitor" is back up and operational.');
|
||||
expect(msg.content.details).toContain("Status: Up");
|
||||
});
|
||||
});
|
||||
|
||||
describe("threshold_breach", () => {
|
||||
it("includes threshold breaches in content", () => {
|
||||
const monitor = makeMonitor({ status: "up", type: "hardware", cpuAlertThreshold: 80 });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({ cpu: { usage_percent: 0.9, temperature: [50] } as any }),
|
||||
} as any);
|
||||
|
||||
const msg = builder.buildMessage(monitor, response, makeDecision({ notificationReason: "threshold_breach" }), "");
|
||||
|
||||
expect(msg.content.thresholds).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
metric: "cpu",
|
||||
currentValue: 90,
|
||||
threshold: 80,
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("threshold_resolved", () => {
|
||||
it("includes resolved details", () => {
|
||||
const monitor = makeMonitor({ status: "up", type: "hardware" });
|
||||
const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), "");
|
||||
|
||||
expect(msg.content.title).toBe("Thresholds Resolved: Test Monitor");
|
||||
expect(msg.content.summary).toBe('Monitor "Test Monitor" thresholds have returned to normal.');
|
||||
expect(msg.content.details).toContain("Status: Up");
|
||||
});
|
||||
});
|
||||
|
||||
describe("default content", () => {
|
||||
it("builds default content for unhandled notification type", () => {
|
||||
// Force default by creating a scenario where type falls through
|
||||
// We can test buildContent default by subclassing or testing indirectly
|
||||
// The default case in buildContent handles "test" type among others
|
||||
// Since determineNotificationType never returns "test", we test via
|
||||
// a monitor with unrecognized status that maps to monitor_up
|
||||
// Instead, let's verify the default case exists by checking that
|
||||
// non-standard types would get default content
|
||||
const monitor = makeMonitor({ status: "up", type: "http" });
|
||||
const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), "");
|
||||
|
||||
// monitor_up is handled, so content should be monitor_up specific
|
||||
expect(msg.content.title).toBe("Monitor Recovered: Test Monitor");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── determineSeverity edge cases (via private method) ────────────────
|
||||
|
||||
describe("determineSeverity edge cases", () => {
|
||||
it("returns info for test type", () => {
|
||||
const result = (builder as any).determineSeverity("test");
|
||||
expect(result).toBe("info");
|
||||
});
|
||||
|
||||
it("returns info for unknown type (default)", () => {
|
||||
const result = (builder as any).determineSeverity("unknown_type");
|
||||
expect(result).toBe("info");
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildContent edge cases (via private method) ─────────────────────
|
||||
|
||||
describe("buildContent edge cases", () => {
|
||||
it("returns default content for unhandled notification type", () => {
|
||||
const monitor = makeMonitor({ status: "up", type: "http" });
|
||||
const result = (builder as any).buildContent("test", monitor, makeStatusResponse());
|
||||
|
||||
expect(result.title).toBe("Monitor: Test Monitor");
|
||||
expect(result.summary).toBe('Status update for monitor "Test Monitor".');
|
||||
expect(result.details).toContain("URL: https://example.com");
|
||||
expect(result.details).toContain("Status: up");
|
||||
expect(result.details).toContain("Type: http");
|
||||
});
|
||||
});
|
||||
|
||||
// ── extractThresholdBreaches ─────────────────────────────────────────
|
||||
|
||||
describe("extractThresholdBreaches", () => {
|
||||
it("returns empty array for non-hardware monitor", () => {
|
||||
const monitor = makeMonitor({ type: "http" });
|
||||
const response = makeStatusResponse();
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when payload is missing", () => {
|
||||
const monitor = makeMonitor({ type: "hardware" });
|
||||
const response = makeStatusResponse({ payload: undefined } as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when hardware data is missing", () => {
|
||||
const monitor = makeMonitor({ type: "hardware" });
|
||||
const response = makeStatusResponse({ payload: { data: undefined } } as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches).toEqual([]);
|
||||
});
|
||||
|
||||
// ── CPU ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("cpu threshold", () => {
|
||||
it("detects CPU breach when usage exceeds threshold", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: 80 });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({ cpu: { usage_percent: 0.9, temperature: [50] } as any }),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches).toContainEqual(
|
||||
expect.objectContaining({
|
||||
metric: "cpu",
|
||||
currentValue: 90,
|
||||
threshold: 80,
|
||||
unit: "%",
|
||||
formattedValue: "90.0%",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("does not report CPU breach when usage is below threshold", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: 80 });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [50] } as any }),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips CPU check when cpuAlertThreshold is undefined", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: undefined });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({ cpu: { usage_percent: 0.99 } as any }),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips CPU check when cpuAlertThreshold is null", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: null as any });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({ cpu: { usage_percent: 0.99 } as any }),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips CPU check when cpu usage_percent is undefined", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: 80 });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({ cpu: { usage_percent: undefined } as any }),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips CPU check when cpu object is undefined", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: 80 });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({ cpu: undefined as any }),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Memory ───────────────────────────────────────────────────────
|
||||
|
||||
describe("memory threshold", () => {
|
||||
it("detects memory breach when usage exceeds threshold", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: 70 });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({ memory: { usage_percent: 0.85 } as any }),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches).toContainEqual(
|
||||
expect.objectContaining({
|
||||
metric: "memory",
|
||||
currentValue: 85,
|
||||
threshold: 70,
|
||||
unit: "%",
|
||||
formattedValue: "85.0%",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("does not report memory breach when usage is below threshold", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: 90 });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({ memory: { usage_percent: 0.5 } as any }),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches.find((b) => b.metric === "memory")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips memory check when memoryAlertThreshold is undefined", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: undefined });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({ memory: { usage_percent: 0.99 } as any }),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches.find((b) => b.metric === "memory")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips memory check when memoryAlertThreshold is null", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: null as any });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({ memory: { usage_percent: 0.99 } as any }),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches.find((b) => b.metric === "memory")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips memory check when memory usage_percent is undefined", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: 70 });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({ memory: { usage_percent: undefined } as any }),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches.find((b) => b.metric === "memory")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips memory check when memory object is undefined", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: 70 });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({ memory: undefined as any }),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches.find((b) => b.metric === "memory")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Disk ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("disk threshold", () => {
|
||||
it("detects disk breach using highest usage across multiple disks", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: 80 });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({
|
||||
disk: [{ usage_percent: 0.5 }, { usage_percent: 0.95 }, { usage_percent: 0.7 }] as any,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches).toContainEqual(
|
||||
expect.objectContaining({
|
||||
metric: "disk",
|
||||
currentValue: 95,
|
||||
threshold: 80,
|
||||
formattedValue: "95.0%",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("does not report disk breach when all disks are below threshold", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: 90 });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({
|
||||
disk: [{ usage_percent: 0.5 }, { usage_percent: 0.6 }] as any,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches.find((b) => b.metric === "disk")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips disk check when diskAlertThreshold is undefined", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: undefined });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({ disk: [{ usage_percent: 0.99 }] as any }),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches.find((b) => b.metric === "disk")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips disk check when diskAlertThreshold is null", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: null as any });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({ disk: [{ usage_percent: 0.99 }] as any }),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches.find((b) => b.metric === "disk")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips disk check when disk is not an array", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: 80 });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({ disk: "not-an-array" as any }),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches.find((b) => b.metric === "disk")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips disks with undefined usage_percent", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: 50 });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({
|
||||
disk: [{ usage_percent: undefined }, { usage_percent: 0.3 }] as any,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches.find((b) => b.metric === "disk")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Temperature ──────────────────────────────────────────────────
|
||||
|
||||
describe("temperature threshold", () => {
|
||||
it("detects temperature breach from array of temperatures", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 70 });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [65, 75, 68] } as any }),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches).toContainEqual(
|
||||
expect.objectContaining({
|
||||
metric: "temp",
|
||||
currentValue: 75,
|
||||
threshold: 70,
|
||||
unit: "°C",
|
||||
formattedValue: "75.0°C",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("handles single temperature value (non-array)", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 70 });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: 80 } as any }),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches).toContainEqual(
|
||||
expect.objectContaining({
|
||||
metric: "temp",
|
||||
currentValue: 80,
|
||||
threshold: 70,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("does not report temperature breach when below threshold", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 80 });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [60, 65] } as any }),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches.find((b) => b.metric === "temp")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips temperature check when tempAlertThreshold is undefined", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: undefined });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [99] } as any }),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches.find((b) => b.metric === "temp")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips temperature check when tempAlertThreshold is null", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: null as any });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [99] } as any }),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches.find((b) => b.metric === "temp")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips temperature check when cpu.temperature is falsy", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 70 });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: null } as any }),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches.find((b) => b.metric === "temp")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips temperature check when cpu object is undefined", () => {
|
||||
const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 70 });
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({ cpu: undefined as any }),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
expect(breaches.find((b) => b.metric === "temp")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Combined ─────────────────────────────────────────────────────
|
||||
|
||||
describe("combined thresholds", () => {
|
||||
it("detects multiple breaches simultaneously", () => {
|
||||
const monitor = makeMonitor({
|
||||
type: "hardware",
|
||||
cpuAlertThreshold: 80,
|
||||
memoryAlertThreshold: 70,
|
||||
diskAlertThreshold: 85,
|
||||
tempAlertThreshold: 65,
|
||||
});
|
||||
const response = makeStatusResponse({
|
||||
payload: makeHardwarePayload({
|
||||
cpu: { usage_percent: 0.9, temperature: [70] } as any,
|
||||
memory: { usage_percent: 0.85 } as any,
|
||||
disk: [{ usage_percent: 0.95 }] as any,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const breaches = builder.extractThresholdBreaches(monitor, response);
|
||||
|
||||
const metrics = breaches.map((b) => b.metric);
|
||||
expect(metrics).toContain("cpu");
|
||||
expect(metrics).toContain("memory");
|
||||
expect(metrics).toContain("disk");
|
||||
expect(metrics).toContain("temp");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { SettingsService } from "../../../src/service/system/settingsService.ts";
|
||||
import type { ISettingsRepository } from "../../../src/repositories/settings/ISettingsRepository.ts";
|
||||
import type { ValidatedEnv } from "../../../src/validation/envValidation.ts";
|
||||
import type { Settings } from "../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeEnv = (overrides?: Partial<ValidatedEnv>): ValidatedEnv =>
|
||||
({
|
||||
JWT_SECRET: "test-secret",
|
||||
TOKEN_TTL: "99d",
|
||||
NODE_ENV: "development",
|
||||
LOG_LEVEL: "debug",
|
||||
CLIENT_HOST: "http://localhost:5173",
|
||||
DB_CONNECTION_STRING: "mongodb://localhost:27017/test_db",
|
||||
DB_TYPE: "mongodb",
|
||||
...overrides,
|
||||
}) as ValidatedEnv;
|
||||
|
||||
const makeSettings = (overrides?: Partial<Settings>): Settings =>
|
||||
({
|
||||
id: "settings-1",
|
||||
checkTTL: 30,
|
||||
language: "en",
|
||||
systemEmailSecure: false,
|
||||
systemEmailPool: false,
|
||||
systemEmailIgnoreTLS: false,
|
||||
systemEmailRequireTLS: false,
|
||||
systemEmailRejectUnauthorized: false,
|
||||
showURL: true,
|
||||
singleton: true,
|
||||
version: 1,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
}) as Settings;
|
||||
|
||||
const createSettingsRepo = () =>
|
||||
({
|
||||
create: jest.fn().mockResolvedValue(makeSettings()),
|
||||
findSingleton: jest.fn().mockResolvedValue(makeSettings()),
|
||||
update: jest.fn().mockResolvedValue(makeSettings()),
|
||||
deleteLegacy: jest.fn().mockResolvedValue(true),
|
||||
}) as unknown as jest.Mocked<ISettingsRepository>;
|
||||
|
||||
const createService = (envOverrides?: Partial<ValidatedEnv>) => {
|
||||
const env = makeEnv(envOverrides);
|
||||
const service = new SettingsService(env);
|
||||
const settingsRepository = createSettingsRepo();
|
||||
service.setRepository(settingsRepository);
|
||||
return { service, settingsRepository, env };
|
||||
};
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SettingsService", () => {
|
||||
describe("serviceName", () => {
|
||||
it("returns SettingsService from static property", () => {
|
||||
expect(SettingsService.SERVICE_NAME).toBe("SettingsService");
|
||||
});
|
||||
|
||||
it("returns SettingsService from instance getter", () => {
|
||||
const { service } = createService();
|
||||
expect(service.serviceName).toBe("SettingsService");
|
||||
});
|
||||
});
|
||||
|
||||
// ── loadSettings ────────────────────────────────────────────────────────
|
||||
|
||||
describe("loadSettings", () => {
|
||||
it("returns env config mapped from constructor", () => {
|
||||
const { service } = createService();
|
||||
const settings = service.loadSettings();
|
||||
|
||||
expect(settings).toEqual({
|
||||
jwtSecret: "test-secret",
|
||||
jwtTTL: "99d",
|
||||
nodeEnv: "development",
|
||||
logLevel: "debug",
|
||||
clientHost: "http://localhost:5173",
|
||||
dbConnectionString: "mongodb://localhost:27017/test_db",
|
||||
dbType: "mongodb",
|
||||
});
|
||||
});
|
||||
|
||||
it("reflects custom env values", () => {
|
||||
const { service } = createService({ NODE_ENV: "production", LOG_LEVEL: "error" });
|
||||
const settings = service.loadSettings();
|
||||
|
||||
expect(settings.nodeEnv).toBe("production");
|
||||
expect(settings.logLevel).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
// ── getSettings ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("getSettings", () => {
|
||||
it("returns the same config as loadSettings", () => {
|
||||
const { service } = createService();
|
||||
|
||||
expect(service.getSettings()).toEqual(service.loadSettings());
|
||||
});
|
||||
});
|
||||
|
||||
// ── setRepository ───────────────────────────────────────────────────────
|
||||
|
||||
describe("setRepository", () => {
|
||||
it("allows DB methods to work after being called", async () => {
|
||||
const env = makeEnv();
|
||||
const service = new SettingsService(env);
|
||||
const repo = createSettingsRepo();
|
||||
|
||||
service.setRepository(repo);
|
||||
const settings = await service.getDBSettings();
|
||||
|
||||
expect(settings).toEqual(makeSettings());
|
||||
});
|
||||
});
|
||||
|
||||
// ── getDBSettings ───────────────────────────────────────────────────────
|
||||
|
||||
describe("getDBSettings", () => {
|
||||
it("deletes legacy settings and returns singleton", async () => {
|
||||
const { service, settingsRepository } = createService();
|
||||
const expected = makeSettings();
|
||||
(settingsRepository.findSingleton as jest.Mock).mockResolvedValue(expected);
|
||||
|
||||
const result = await service.getDBSettings();
|
||||
|
||||
expect(settingsRepository.deleteLegacy).toHaveBeenCalled();
|
||||
expect(settingsRepository.findSingleton).toHaveBeenCalled();
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it("creates default settings when singleton is null, then returns them", async () => {
|
||||
const { service, settingsRepository } = createService();
|
||||
const created = makeSettings();
|
||||
(settingsRepository.findSingleton as jest.Mock).mockResolvedValueOnce(null).mockResolvedValueOnce(created);
|
||||
|
||||
const result = await service.getDBSettings();
|
||||
|
||||
expect(settingsRepository.create).toHaveBeenCalledWith({});
|
||||
expect(settingsRepository.findSingleton).toHaveBeenCalledTimes(2);
|
||||
expect(result).toBe(created);
|
||||
});
|
||||
|
||||
it("throws when settings are still null after creation attempt", async () => {
|
||||
const { service, settingsRepository } = createService();
|
||||
(settingsRepository.findSingleton as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
await expect(service.getDBSettings()).rejects.toThrow("Settings not found");
|
||||
});
|
||||
|
||||
it("throws when repository is not set", async () => {
|
||||
const env = makeEnv();
|
||||
const service = new SettingsService(env);
|
||||
|
||||
await expect(service.getDBSettings()).rejects.toThrow("Settings repository not initialized");
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateDbSettings ────────────────────────────────────────────────────
|
||||
|
||||
describe("updateDbSettings", () => {
|
||||
it("delegates to repository and returns updated settings", async () => {
|
||||
const updated = makeSettings({ checkTTL: 60 });
|
||||
const { service, settingsRepository } = createService();
|
||||
(settingsRepository.update as jest.Mock).mockResolvedValue(updated);
|
||||
|
||||
const result = await service.updateDbSettings({ checkTTL: 60 });
|
||||
|
||||
expect(result).toBe(updated);
|
||||
expect(settingsRepository.update).toHaveBeenCalledWith({ checkTTL: 60 });
|
||||
});
|
||||
|
||||
it("throws when repository is not set", async () => {
|
||||
const env = makeEnv();
|
||||
const service = new SettingsService(env);
|
||||
|
||||
await expect(service.updateDbSettings({ checkTTL: 60 })).rejects.toThrow("Settings repository not initialized");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { StatusPageService } from "../../../src/service/business/statusPageService.ts";
|
||||
import type { IStatusPagesRepository } from "../../../src/repositories/status-pages/IStatusPagesRepository.ts";
|
||||
import type { StatusPage } from "../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeStatusPage = (overrides?: Partial<StatusPage>): StatusPage =>
|
||||
({
|
||||
id: "sp-1",
|
||||
teamId: "team-1",
|
||||
userId: "user-1",
|
||||
url: "my-status-page",
|
||||
companyName: "Test Co",
|
||||
monitors: ["mon-1"],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
}) as StatusPage;
|
||||
|
||||
const createRepo = () =>
|
||||
({
|
||||
create: jest.fn().mockResolvedValue(makeStatusPage()),
|
||||
findByUrl: jest.fn().mockResolvedValue(makeStatusPage()),
|
||||
findByTeamId: jest.fn().mockResolvedValue([makeStatusPage()]),
|
||||
updateById: jest.fn().mockResolvedValue(makeStatusPage()),
|
||||
deleteById: jest.fn().mockResolvedValue(makeStatusPage()),
|
||||
removeMonitorFromStatusPages: jest.fn().mockResolvedValue(1),
|
||||
}) as unknown as jest.Mocked<IStatusPagesRepository>;
|
||||
|
||||
const createService = () => {
|
||||
const repo = createRepo();
|
||||
const service = new StatusPageService(repo);
|
||||
return { service, repo };
|
||||
};
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("StatusPageService", () => {
|
||||
describe("createStatusPage", () => {
|
||||
it("delegates to repository with all parameters", async () => {
|
||||
const { service, repo } = createService();
|
||||
const data = { companyName: "New Co" };
|
||||
const file = { originalname: "logo.png" } as Express.Multer.File;
|
||||
|
||||
const result = await service.createStatusPage("user-1", "team-1", file, data);
|
||||
|
||||
expect(repo.create).toHaveBeenCalledWith("user-1", "team-1", file, data);
|
||||
expect(result).toEqual(makeStatusPage());
|
||||
});
|
||||
|
||||
it("passes undefined image when not provided", async () => {
|
||||
const { service, repo } = createService();
|
||||
|
||||
await service.createStatusPage("user-1", "team-1", undefined, {});
|
||||
|
||||
expect(repo.create).toHaveBeenCalledWith("user-1", "team-1", undefined, {});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStatusPageByUrl", () => {
|
||||
it("delegates to repository", async () => {
|
||||
const { service, repo } = createService();
|
||||
|
||||
const result = await service.getStatusPageByUrl("my-status-page");
|
||||
|
||||
expect(repo.findByUrl).toHaveBeenCalledWith("my-status-page");
|
||||
expect(result).toEqual(makeStatusPage());
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStatusPagesByTeamId", () => {
|
||||
it("delegates to repository", async () => {
|
||||
const { service, repo } = createService();
|
||||
|
||||
const result = await service.getStatusPagesByTeamId("team-1");
|
||||
|
||||
expect(repo.findByTeamId).toHaveBeenCalledWith("team-1");
|
||||
expect(result).toEqual([makeStatusPage()]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateStatusPage", () => {
|
||||
it("delegates to repository with all parameters", async () => {
|
||||
const { service, repo } = createService();
|
||||
const updated = makeStatusPage({ companyName: "Updated Co" });
|
||||
(repo.updateById as jest.Mock).mockResolvedValue(updated);
|
||||
const file = { originalname: "new-logo.png" } as Express.Multer.File;
|
||||
|
||||
const result = await service.updateStatusPage("sp-1", "team-1", file, { companyName: "Updated Co" });
|
||||
|
||||
expect(repo.updateById).toHaveBeenCalledWith("sp-1", "team-1", file, { companyName: "Updated Co" });
|
||||
expect(result).toBe(updated);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteStatusPage", () => {
|
||||
it("delegates to repository and returns deleted page", async () => {
|
||||
const { service, repo } = createService();
|
||||
|
||||
const result = await service.deleteStatusPage("sp-1", "team-1");
|
||||
|
||||
expect(repo.deleteById).toHaveBeenCalledWith("sp-1", "team-1");
|
||||
expect(result).toEqual(makeStatusPage());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -810,80 +810,4 @@ describe("StatusService", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── insertCheck ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("insertCheck", () => {
|
||||
it("adds check to buffer and returns true", async () => {
|
||||
const { service, buffer } = createService();
|
||||
const check = makeCheck();
|
||||
|
||||
const result = await service.insertCheck(check);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(buffer.addToBuffer).toHaveBeenCalledWith(check);
|
||||
});
|
||||
|
||||
it("returns false and logs warning when check is undefined", async () => {
|
||||
const { service, logger } = createService();
|
||||
|
||||
const result = await service.insertCheck(undefined as any);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Failed to build check",
|
||||
service: "StatusService",
|
||||
method: "insertCheck",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs error when buffer throws", async () => {
|
||||
const buffer = createBuffer();
|
||||
(buffer.addToBuffer as jest.Mock).mockImplementation(() => {
|
||||
throw new Error("buffer full");
|
||||
});
|
||||
const { service, logger } = createService({ buffer });
|
||||
|
||||
const result = await service.insertCheck(makeCheck());
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "buffer full",
|
||||
service: "StatusService",
|
||||
method: "insertCheck",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("handles nullish check in error path", async () => {
|
||||
const buffer = createBuffer();
|
||||
(buffer.addToBuffer as jest.Mock).mockImplementation(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
const { service, logger } = createService({ buffer });
|
||||
|
||||
await service.insertCheck(null as any);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
details: { msg: "Error inserting check for monitor: undefined" },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error exceptions in insertCheck", async () => {
|
||||
const buffer = createBuffer();
|
||||
(buffer.addToBuffer as jest.Mock).mockImplementation(() => {
|
||||
throw "string error";
|
||||
});
|
||||
const { service, logger } = createService({ buffer });
|
||||
|
||||
await service.insertCheck(makeCheck());
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -535,16 +535,6 @@ describe("SuperSimpleQueue", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── obliterate ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("obliterate", () => {
|
||||
it("logs not-implemented warning", async () => {
|
||||
const { queue, logger } = createQueue();
|
||||
await queue.obliterate();
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "obliterate method not implemented" }));
|
||||
});
|
||||
});
|
||||
|
||||
// ── static create ────────────────────────────────────────────────────────
|
||||
|
||||
describe("static create", () => {
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { UserService } from "../../../src/service/business/userService.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
import type { User, UserRole } from "../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeUser = (overrides?: Partial<User>): User => ({
|
||||
id: "user-1",
|
||||
firstName: "Test",
|
||||
lastName: "User",
|
||||
email: "test@example.com",
|
||||
password: "$2a$10$hashedpassword",
|
||||
isActive: true,
|
||||
isVerified: true,
|
||||
role: ["user"] as UserRole[],
|
||||
teamId: "team-1",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeAppSettings = () => ({
|
||||
jwtSecret: "test-secret",
|
||||
jwtTTL: "99d" as const,
|
||||
nodeEnv: "development",
|
||||
logLevel: "debug",
|
||||
clientHost: "http://localhost:5173",
|
||||
dbConnectionString: "mongodb://localhost:27017/test_db",
|
||||
dbType: "mongodb" as const,
|
||||
});
|
||||
|
||||
const createService = (overrides?: Record<string, unknown>) => {
|
||||
const logger = createMockLogger();
|
||||
const usersRepository = {
|
||||
create: jest.fn().mockResolvedValue(makeUser()),
|
||||
findByEmail: jest.fn().mockResolvedValue(makeUser()),
|
||||
findById: jest.fn().mockResolvedValue(makeUser()),
|
||||
findAll: jest.fn().mockResolvedValue([makeUser()]),
|
||||
updateById: jest.fn().mockResolvedValue(makeUser()),
|
||||
deleteById: jest.fn().mockResolvedValue(makeUser()),
|
||||
findSuperAdmin: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const invitesRepository = {
|
||||
findByTokenAndDelete: jest.fn().mockResolvedValue({ role: ["user"], teamId: "team-1", email: "invited@example.com" }),
|
||||
};
|
||||
const recoveryTokensRepository = {
|
||||
create: jest.fn().mockResolvedValue({ token: "recovery-token-123", email: "test@example.com" }),
|
||||
findByToken: jest.fn().mockResolvedValue({ token: "recovery-token-123", email: "test@example.com" }),
|
||||
deleteManyByEmail: jest.fn().mockResolvedValue(1),
|
||||
};
|
||||
const settingsRepository = {
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
const teamsRepository = {
|
||||
create: jest.fn().mockResolvedValue({ id: "team-new" }),
|
||||
};
|
||||
const monitorsRepository = {
|
||||
findByTeamId: jest.fn().mockResolvedValue([{ id: "mon-1" }, { id: "mon-2" }]),
|
||||
};
|
||||
const emailService = {
|
||||
buildEmail: jest.fn().mockResolvedValue("<html>Welcome</html>"),
|
||||
sendEmail: jest.fn().mockResolvedValue("msg-id-123"),
|
||||
};
|
||||
const settingsService = {
|
||||
getSettings: jest.fn().mockReturnValue(makeAppSettings()),
|
||||
};
|
||||
const jobQueue = {
|
||||
deleteJob: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const jwtMock = {
|
||||
sign: jest.fn().mockReturnValue("jwt-token-123"),
|
||||
};
|
||||
const cryptoMock = {
|
||||
randomBytes: jest.fn().mockReturnValue({ toString: () => "random-hex-secret" }),
|
||||
};
|
||||
|
||||
const defaults = {
|
||||
logger,
|
||||
usersRepository,
|
||||
invitesRepository,
|
||||
recoveryTokensRepository,
|
||||
settingsRepository,
|
||||
teamsRepository,
|
||||
monitorsRepository,
|
||||
emailService,
|
||||
settingsService,
|
||||
jobQueue,
|
||||
jwt: jwtMock,
|
||||
crypto: cryptoMock,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const service = new UserService(defaults as any);
|
||||
return { service, ...defaults };
|
||||
};
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("UserService", () => {
|
||||
describe("serviceName", () => {
|
||||
it("returns userService", () => {
|
||||
const { service } = createService();
|
||||
expect(service.serviceName).toBe("userService");
|
||||
});
|
||||
});
|
||||
|
||||
// ── issueToken ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("issueToken", () => {
|
||||
it("signs a JWT with the payload and settings", () => {
|
||||
const { service, jwt: jwtMock } = createService();
|
||||
const user = makeUser();
|
||||
const settings = makeAppSettings();
|
||||
|
||||
const token = service.issueToken(user, settings);
|
||||
|
||||
expect(token).toBe("jwt-token-123");
|
||||
expect(jwtMock.sign).toHaveBeenCalledWith(user, "test-secret", { expiresIn: "99d" });
|
||||
});
|
||||
});
|
||||
|
||||
// ── registerUser ────────────────────────────────────────────────────────
|
||||
|
||||
describe("registerUser", () => {
|
||||
it("registers an invited user when superadmin exists", async () => {
|
||||
const { service, usersRepository, invitesRepository } = createService();
|
||||
|
||||
const result = await service.registerUser({ password: "password123" }, "invite-token", null);
|
||||
|
||||
expect(invitesRepository.findByTokenAndDelete).toHaveBeenCalledWith("invite-token");
|
||||
expect(usersRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ role: ["user"], teamId: "team-1", email: "invited@example.com" }),
|
||||
null
|
||||
);
|
||||
expect(result.token).toBe("jwt-token-123");
|
||||
expect(result.user).toBeDefined();
|
||||
});
|
||||
|
||||
it("creates first user as superadmin with new team and JWT secret", async () => {
|
||||
const {
|
||||
service,
|
||||
usersRepository,
|
||||
teamsRepository,
|
||||
settingsRepository,
|
||||
crypto: cryptoMock,
|
||||
} = createService({
|
||||
usersRepository: {
|
||||
create: jest.fn().mockResolvedValue(makeUser({ role: ["superadmin"], teamId: "team-new" })),
|
||||
findByEmail: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
updateById: jest.fn(),
|
||||
deleteById: jest.fn(),
|
||||
findSuperAdmin: jest.fn().mockResolvedValue(false),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.registerUser({ email: "first@example.com", password: "password123" }, "", null);
|
||||
|
||||
expect(cryptoMock.randomBytes).toHaveBeenCalledWith(64);
|
||||
expect(settingsRepository.update).toHaveBeenCalledWith({ jwtSecret: "random-hex-secret" });
|
||||
expect(teamsRepository.create).toHaveBeenCalledWith("first@example.com");
|
||||
expect(result.user).toBeDefined();
|
||||
});
|
||||
|
||||
it("throws when first user has no email", async () => {
|
||||
const { service } = createService({
|
||||
usersRepository: {
|
||||
create: jest.fn(),
|
||||
findByEmail: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
updateById: jest.fn(),
|
||||
deleteById: jest.fn(),
|
||||
findSuperAdmin: jest.fn().mockResolvedValue(false),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.registerUser({ password: "pass" }, "", null)).rejects.toThrow("Email is required for first user");
|
||||
});
|
||||
|
||||
it("sends welcome email after registration", async () => {
|
||||
const { service, emailService } = createService();
|
||||
|
||||
await service.registerUser({ password: "pass" }, "invite-token", null);
|
||||
|
||||
expect(emailService.buildEmail).toHaveBeenCalledWith("welcomeEmailTemplate", expect.objectContaining({ name: "Test" }));
|
||||
expect(emailService.sendEmail).toHaveBeenCalledWith("test@example.com", "Welcome to Uptime Monitor", "<html>Welcome</html>");
|
||||
});
|
||||
|
||||
it("logs warning when welcome email build returns null", async () => {
|
||||
const { service, logger } = createService({
|
||||
emailService: {
|
||||
buildEmail: jest.fn().mockResolvedValue(null),
|
||||
sendEmail: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.registerUser({ password: "pass" }, "invite-token", null);
|
||||
|
||||
expect(result.user).toBeDefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "registerUser" }));
|
||||
});
|
||||
|
||||
it("uses default role when invite has no role", async () => {
|
||||
const { service, usersRepository } = createService({
|
||||
invitesRepository: {
|
||||
findByTokenAndDelete: jest.fn().mockResolvedValue({ role: undefined, teamId: "team-1", email: "invited@example.com" }),
|
||||
},
|
||||
});
|
||||
|
||||
await service.registerUser({ password: "pass" }, "invite-token", null);
|
||||
|
||||
expect(usersRepository.create).toHaveBeenCalledWith(expect.objectContaining({ role: ["user"] }), null);
|
||||
});
|
||||
|
||||
it("registers user without password", async () => {
|
||||
const { service, usersRepository } = createService();
|
||||
|
||||
await service.registerUser({}, "invite-token", null);
|
||||
|
||||
const call = (usersRepository.create as jest.Mock).mock.calls[0] as any[];
|
||||
// Password should not be hashed since it was never provided
|
||||
expect(call[0].password).toBeUndefined();
|
||||
});
|
||||
|
||||
it("logs warning when sendEmail rejects (fire-and-forget)", async () => {
|
||||
const { service, logger } = createService({
|
||||
emailService: {
|
||||
buildEmail: jest.fn().mockResolvedValue("<html>ok</html>"),
|
||||
sendEmail: jest.fn().mockRejectedValue(new Error("SMTP down")),
|
||||
},
|
||||
});
|
||||
|
||||
await service.registerUser({ password: "pass" }, "invite-token", null);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "SMTP down" }));
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' when sendEmail rejects with non-Error", async () => {
|
||||
const { service, logger } = createService({
|
||||
emailService: {
|
||||
buildEmail: jest.fn().mockResolvedValue("<html>ok</html>"),
|
||||
sendEmail: jest.fn().mockRejectedValue("string error"),
|
||||
},
|
||||
});
|
||||
|
||||
await service.registerUser({ password: "pass" }, "invite-token", null);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error" }));
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' when buildEmail throws non-Error", async () => {
|
||||
const { service, logger } = createService({
|
||||
emailService: {
|
||||
buildEmail: jest.fn().mockRejectedValue(42),
|
||||
sendEmail: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.registerUser({ password: "pass" }, "invite-token", null);
|
||||
|
||||
expect(result.user).toBeDefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error" }));
|
||||
});
|
||||
});
|
||||
|
||||
// ── createUser ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("createUser", () => {
|
||||
it("creates a user with hashed password and assigned team", async () => {
|
||||
const { service, usersRepository } = createService();
|
||||
|
||||
const result = await service.createUser({ password: "pass", role: ["user"] }, "team-1", ["superadmin"], null);
|
||||
|
||||
expect(usersRepository.create).toHaveBeenCalledWith(expect.objectContaining({ teamId: "team-1" }), null);
|
||||
expect(result.password).toBe("");
|
||||
});
|
||||
|
||||
it("creates user without password when not provided", async () => {
|
||||
const { service, usersRepository } = createService();
|
||||
|
||||
await service.createUser({ role: ["user"] }, "team-1", ["superadmin"], null);
|
||||
|
||||
const call = (usersRepository.create as jest.Mock).mock.calls[0] as any[];
|
||||
expect(call[0].password).toBeUndefined();
|
||||
});
|
||||
|
||||
it("defaults to empty roles when userData.role is undefined", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
// No role provided, targetRoles defaults to [] via ?? [], loop doesn't execute, no permission error
|
||||
const result = await service.createUser({ password: "pass" }, "team-1", ["user"], null);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it("throws when actor cannot manage the target role", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
await expect(service.createUser({ role: ["superadmin"] }, "team-1", ["admin"], null)).rejects.toThrow(
|
||||
"You do not have permission to assign this role"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── loginUser ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("loginUser", () => {
|
||||
it("returns user and token on correct password", async () => {
|
||||
const { service, usersRepository } = createService();
|
||||
const bcrypt = await import("bcryptjs");
|
||||
const hashed = bcrypt.hashSync("correct-password", 10);
|
||||
(usersRepository.findByEmail as jest.Mock).mockResolvedValue(makeUser({ password: hashed }));
|
||||
|
||||
const result = await service.loginUser("test@example.com", "correct-password");
|
||||
|
||||
expect(result.token).toBe("jwt-token-123");
|
||||
expect(result.user.password).toBe("");
|
||||
});
|
||||
|
||||
it("throws on incorrect password", async () => {
|
||||
const { service, usersRepository } = createService();
|
||||
const bcrypt = await import("bcryptjs");
|
||||
const hashed = bcrypt.hashSync("correct-password", 10);
|
||||
(usersRepository.findByEmail as jest.Mock).mockResolvedValue(makeUser({ password: hashed }));
|
||||
|
||||
await expect(service.loginUser("test@example.com", "wrong-password")).rejects.toThrow("Incorrect password");
|
||||
});
|
||||
});
|
||||
|
||||
// ── editUser ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("editUser", () => {
|
||||
it("updates user without password change", async () => {
|
||||
const { service, usersRepository } = createService();
|
||||
|
||||
await service.editUser({ firstName: "Updated" }, null, "user-1", "test@example.com");
|
||||
|
||||
expect(usersRepository.updateById).toHaveBeenCalledWith("user-1", { firstName: "Updated" }, null);
|
||||
});
|
||||
|
||||
it("skips password flow when only password is provided without newPassword", async () => {
|
||||
const { service, usersRepository } = createService();
|
||||
|
||||
await service.editUser({ password: "old-pass" }, null, "user-1", "test@example.com");
|
||||
|
||||
expect(usersRepository.findByEmail).not.toHaveBeenCalled();
|
||||
expect(usersRepository.updateById).toHaveBeenCalledWith("user-1", expect.objectContaining({ password: "old-pass" }), null);
|
||||
});
|
||||
|
||||
it("skips password flow when only newPassword is provided without password", async () => {
|
||||
const { service, usersRepository } = createService();
|
||||
|
||||
await service.editUser({ newPassword: "new-pass" } as any, null, "user-1", "test@example.com");
|
||||
|
||||
expect(usersRepository.findByEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("changes password when old and new password provided", async () => {
|
||||
const { service, usersRepository } = createService();
|
||||
const bcrypt = await import("bcryptjs");
|
||||
const hashed = bcrypt.hashSync("old-password", 10);
|
||||
(usersRepository.findByEmail as jest.Mock).mockResolvedValue(makeUser({ password: hashed }));
|
||||
|
||||
await service.editUser({ password: "old-password", newPassword: "new-password" }, null, "user-1", "test@example.com");
|
||||
|
||||
expect(usersRepository.updateById).toHaveBeenCalledWith("user-1", expect.objectContaining({ email: "test@example.com" }), null);
|
||||
const call = (usersRepository.updateById as jest.Mock).mock.calls[0] as any[];
|
||||
expect(call[1].newPassword).toBeUndefined();
|
||||
expect(call[1].password).not.toBe("old-password");
|
||||
});
|
||||
|
||||
it("throws when current password is incorrect", async () => {
|
||||
const { service, usersRepository } = createService();
|
||||
const bcrypt = await import("bcryptjs");
|
||||
const hashed = bcrypt.hashSync("actual-password", 10);
|
||||
(usersRepository.findByEmail as jest.Mock).mockResolvedValue(makeUser({ password: hashed }));
|
||||
|
||||
await expect(service.editUser({ password: "wrong-password", newPassword: "new-pass" }, null, "user-1", "test@example.com")).rejects.toThrow(
|
||||
"Incorrect current password"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── checkSuperadminExists ───────────────────────────────────────────────
|
||||
|
||||
describe("checkSuperadminExists", () => {
|
||||
it("delegates to repository", async () => {
|
||||
const { service, usersRepository } = createService();
|
||||
|
||||
const result = await service.checkSuperadminExists();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(usersRepository.findSuperAdmin).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── requestRecovery ─────────────────────────────────────────────────────
|
||||
|
||||
describe("requestRecovery", () => {
|
||||
it("creates recovery token and sends email", async () => {
|
||||
const { service, recoveryTokensRepository, emailService } = createService();
|
||||
|
||||
const result = await service.requestRecovery("test@example.com");
|
||||
|
||||
expect(recoveryTokensRepository.deleteManyByEmail).toHaveBeenCalledWith("test@example.com");
|
||||
expect(recoveryTokensRepository.create).toHaveBeenCalledWith("test@example.com");
|
||||
expect(emailService.buildEmail).toHaveBeenCalledWith(
|
||||
"passwordResetTemplate",
|
||||
expect.objectContaining({
|
||||
name: "Test",
|
||||
email: "test@example.com",
|
||||
url: "http://localhost:5173/set-new-password/recovery-token-123",
|
||||
})
|
||||
);
|
||||
expect(result).toBe("msg-id-123");
|
||||
});
|
||||
|
||||
it("throws when email HTML fails to build", async () => {
|
||||
const { service } = createService({
|
||||
emailService: {
|
||||
buildEmail: jest.fn().mockResolvedValue(null),
|
||||
sendEmail: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.requestRecovery("test@example.com")).rejects.toThrow("Failed to build password reset email HTML");
|
||||
});
|
||||
});
|
||||
|
||||
// ── validateRecovery ────────────────────────────────────────────────────
|
||||
|
||||
describe("validateRecovery", () => {
|
||||
it("delegates to repository findByToken", async () => {
|
||||
const { service, recoveryTokensRepository } = createService();
|
||||
|
||||
await service.validateRecovery("recovery-token-123");
|
||||
|
||||
expect(recoveryTokensRepository.findByToken).toHaveBeenCalledWith("recovery-token-123");
|
||||
});
|
||||
});
|
||||
|
||||
// ── resetPassword ───────────────────────────────────────────────────────
|
||||
|
||||
describe("resetPassword", () => {
|
||||
it("resets password and returns user with token", async () => {
|
||||
const { service, usersRepository, recoveryTokensRepository } = createService();
|
||||
// Ensure old password doesn't match new one (bcrypt.compare returns false)
|
||||
(usersRepository.findByEmail as jest.Mock).mockResolvedValue(makeUser({ password: "$2a$10$differenthash" }));
|
||||
|
||||
const result = await service.resetPassword("new-password", "recovery-token-123");
|
||||
|
||||
expect(recoveryTokensRepository.findByToken).toHaveBeenCalledWith("recovery-token-123");
|
||||
expect(usersRepository.updateById).toHaveBeenCalledWith("user-1", expect.objectContaining({ password: expect.any(String) }), null);
|
||||
expect(recoveryTokensRepository.deleteManyByEmail).toHaveBeenCalledWith("test@example.com");
|
||||
expect(result.token).toBe("jwt-token-123");
|
||||
expect(result.user.password).toBe("");
|
||||
});
|
||||
|
||||
it("throws when new password matches old password", async () => {
|
||||
const { service, usersRepository } = createService();
|
||||
const bcrypt = await import("bcryptjs");
|
||||
const hashed = bcrypt.hashSync("same-password", 10);
|
||||
(usersRepository.findByEmail as jest.Mock).mockResolvedValue(makeUser({ password: hashed }));
|
||||
|
||||
await expect(service.resetPassword("same-password", "recovery-token-123")).rejects.toThrow("New password cannot be same as old password");
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteUser ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("deleteUser", () => {
|
||||
it("deletes user and cleans up monitor jobs for superadmin", async () => {
|
||||
const { service, usersRepository, jobQueue, monitorsRepository } = createService();
|
||||
|
||||
await service.deleteUser({ userId: "user-1", teamId: "team-1", roles: ["superadmin"] });
|
||||
|
||||
expect(monitorsRepository.findByTeamId).toHaveBeenCalledWith("team-1", {});
|
||||
expect(jobQueue.deleteJob).toHaveBeenCalledTimes(2);
|
||||
expect(usersRepository.deleteById).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("handles null monitors for superadmin gracefully", async () => {
|
||||
const { service, usersRepository, jobQueue } = createService({
|
||||
monitorsRepository: { findByTeamId: jest.fn().mockResolvedValue(null) },
|
||||
});
|
||||
|
||||
await service.deleteUser({ userId: "user-1", teamId: "team-1", roles: ["superadmin"] });
|
||||
|
||||
expect(jobQueue.deleteJob).not.toHaveBeenCalled();
|
||||
expect(usersRepository.deleteById).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("skips monitor cleanup for non-superadmin", async () => {
|
||||
const { service, usersRepository, monitorsRepository } = createService();
|
||||
|
||||
await service.deleteUser({ userId: "user-1", teamId: "team-1", roles: ["user"] });
|
||||
|
||||
expect(monitorsRepository.findByTeamId).not.toHaveBeenCalled();
|
||||
expect(usersRepository.deleteById).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("throws when deleting a demo user", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
await expect(service.deleteUser({ userId: "user-1", teamId: "team-1", roles: ["demo"] })).rejects.toThrow("Demo user cannot be deleted");
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteUserById ──────────────────────────────────────────────────────
|
||||
|
||||
describe("deleteUserById", () => {
|
||||
it("deletes target user when actor has permission", async () => {
|
||||
const { service, usersRepository, logger } = createService();
|
||||
|
||||
await service.deleteUserById({ actorId: "admin-1", actorTeamId: "team-1", actorRoles: ["superadmin"], targetUserId: "user-1" });
|
||||
|
||||
expect(usersRepository.deleteById).toHaveBeenCalledWith("user-1");
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "User user-1 deleted by admin-1" }));
|
||||
});
|
||||
|
||||
it("throws when actor tries to delete themselves", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
await expect(
|
||||
service.deleteUserById({ actorId: "user-1", actorTeamId: "team-1", actorRoles: ["superadmin"], targetUserId: "user-1" })
|
||||
).rejects.toThrow("Cannot delete your own account from here");
|
||||
});
|
||||
|
||||
it("throws when target is on a different team", async () => {
|
||||
const { service, usersRepository } = createService();
|
||||
(usersRepository.findById as jest.Mock).mockResolvedValue(makeUser({ teamId: "team-other" }));
|
||||
|
||||
await expect(
|
||||
service.deleteUserById({ actorId: "admin-1", actorTeamId: "team-1", actorRoles: ["superadmin"], targetUserId: "user-1" })
|
||||
).rejects.toThrow("User is not on your team");
|
||||
});
|
||||
|
||||
it("throws when target is a demo user", async () => {
|
||||
const { service, usersRepository } = createService();
|
||||
(usersRepository.findById as jest.Mock).mockResolvedValue(makeUser({ role: ["demo"] }));
|
||||
|
||||
await expect(
|
||||
service.deleteUserById({ actorId: "admin-1", actorTeamId: "team-1", actorRoles: ["superadmin"], targetUserId: "user-1" })
|
||||
).rejects.toThrow("Demo user cannot be deleted");
|
||||
});
|
||||
|
||||
it("throws when actor lacks permission to manage target role", async () => {
|
||||
const { service, usersRepository } = createService();
|
||||
(usersRepository.findById as jest.Mock).mockResolvedValue(makeUser({ role: ["superadmin"] }));
|
||||
|
||||
await expect(
|
||||
service.deleteUserById({ actorId: "admin-1", actorTeamId: "team-1", actorRoles: ["admin"], targetUserId: "user-1" })
|
||||
).rejects.toThrow("You do not have permission to remove this user");
|
||||
});
|
||||
});
|
||||
|
||||
// ── getAllUsers ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("getAllUsers", () => {
|
||||
it("delegates to repository", async () => {
|
||||
const { service, usersRepository } = createService();
|
||||
|
||||
const result = await service.getAllUsers();
|
||||
|
||||
expect(result).toEqual([makeUser()]);
|
||||
expect(usersRepository.findAll).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── getUserById ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("getUserById", () => {
|
||||
it("returns user for admin", async () => {
|
||||
const { service, usersRepository } = createService();
|
||||
|
||||
const result = await service.getUserById(["admin"], "user-1");
|
||||
|
||||
expect(result).toEqual(makeUser());
|
||||
expect(usersRepository.findById).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("returns user for superadmin", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
const result = await service.getUserById(["superadmin"], "user-1");
|
||||
|
||||
expect(result).toEqual(makeUser());
|
||||
});
|
||||
|
||||
it("throws for non-admin roles", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
await expect(service.getUserById(["user"], "user-1")).rejects.toThrow("Insufficient permissions");
|
||||
});
|
||||
});
|
||||
|
||||
// ── editUserById ────────────────────────────────────────────────────────
|
||||
|
||||
describe("editUserById", () => {
|
||||
it("delegates to repository", async () => {
|
||||
const { service, usersRepository } = createService();
|
||||
|
||||
await service.editUserById("user-1", { firstName: "New" });
|
||||
|
||||
expect(usersRepository.updateById).toHaveBeenCalledWith("user-1", { firstName: "New" }, null);
|
||||
});
|
||||
});
|
||||
|
||||
// ── setPasswordByUserId ─────────────────────────────────────────────────
|
||||
|
||||
describe("setPasswordByUserId", () => {
|
||||
it("hashes and sets password", async () => {
|
||||
const { service, usersRepository } = createService();
|
||||
|
||||
const result = await service.setPasswordByUserId("user-1", "new-password");
|
||||
|
||||
expect(usersRepository.updateById).toHaveBeenCalledWith("user-1", expect.objectContaining({ password: expect.any(String) }), null);
|
||||
// Ensure the stored password is hashed, not plaintext
|
||||
const call = (usersRepository.updateById as jest.Mock).mock.calls[0] as any[];
|
||||
expect(call[1].password).not.toBe("new-password");
|
||||
expect(result).toEqual(makeUser());
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user