Compare commits

..
53 changed files with 16647 additions and 23275 deletions

No files matched your search

+105 -105
View File
@@ -1,120 +1,120 @@
# name: POEditor Translation Synchronization
name: POEditor Translation Synchronization
# on:
# # For manual triggering
# workflow_dispatch:
# inputs:
# languages:
# description: "Languages to synchronize (comma separated, e.g.: tr,en,es)"
# required: false
# default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th"
# format:
# description: "Export format (key_value_json or json)"
# required: false
# default: "key_value_json"
on:
# For manual triggering
workflow_dispatch:
inputs:
languages:
description: "Languages to synchronize (comma separated, e.g.: tr,en,es)"
required: false
default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th"
format:
description: "Export format (key_value_json or json)"
required: false
default: "key_value_json"
# permissions:
# contents: write
# pull-requests: write
permissions:
contents: write
pull-requests: write
# jobs:
# sync-translations:
# runs-on: ubuntu-latest
# permissions:
# contents: write
# issues: write
# pull-requests: write
jobs:
sync-translations:
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
# steps:
# - name: Checkout code
# uses: actions/checkout@v3
# with:
# token: ${{ secrets.GITHUB_TOKEN }}
# fetch-depth: 0
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
# - name: Setup Node.js
# uses: actions/setup-node@v3
# with:
# node-version: "22"
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "22"
# - name: Create package.json for scripts
# run: |
# mkdir -p .github/scripts
# cat > .github/scripts/package.json << EOF
# {
# "name": "poeditor-scripts",
# "version": "1.0.0",
# "private": true,
# "type": "module",
# "dependencies": {
# "axios": "^1.6.0",
# "fs-extra": "^11.1.1"
# }
# }
# EOF
- name: Create package.json for scripts
run: |
mkdir -p .github/scripts
cat > .github/scripts/package.json << EOF
{
"name": "poeditor-scripts",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"axios": "^1.6.0",
"fs-extra": "^11.1.1"
}
}
EOF
# - name: Install dependencies
# run: |
# cd .github/scripts
# npm install
- name: Install dependencies
run: |
cd .github/scripts
npm install
# - name: Download translations from POEditor
# env:
# POEDITOR_API: ${{ secrets.POEDITOR_API }}
# POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
# LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }}
# EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }}
# run: |
# mkdir -p temp
# node .github/scripts/download-translations.js
- name: Download translations from POEditor
env:
POEDITOR_API: ${{ secrets.POEDITOR_API }}
POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }}
EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }}
run: |
mkdir -p temp
node .github/scripts/download-translations.js
# - name: Verify translation files
# run: |
# echo "Verifying translation files..."
# for file in temp/*.json; do
# echo "Checking $file"
# if [ ! -s "$file" ]; then
# echo "Error: $file is empty or does not exist"
# exit 1
# fi
# # Validate JSON format
# cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; }
# done
# echo "All translation files are valid"
- name: Verify translation files
run: |
echo "Verifying translation files..."
for file in temp/*.json; do
echo "Checking $file"
if [ ! -s "$file" ]; then
echo "Error: $file is empty or does not exist"
exit 1
fi
# Validate JSON format
cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; }
done
echo "All translation files are valid"
# - name: Copy translations to project
# run: |
# mkdir -p client/src/locales
# cp -r temp/* client/src/locales/
# echo "Translation files copied to client/src/locales/"
- name: Copy translations to project
run: |
mkdir -p client/src/locales
cp -r temp/* client/src/locales/
echo "Translation files copied to client/src/locales/"
# - name: Get current date
# id: date
# run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
- name: Get current date
id: date
run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
# - name: Format client code
# run: |
# cd client
# npm ci
# npm run format
- name: Format client code
run: |
cd client
npm ci
npm run format
# - name: Create Pull Request
# uses: peter-evans/create-pull-request@v7
# with:
# token: ${{ secrets.GITHUB_TOKEN }}
# commit-message: "feat: translations updated from POEditor"
# title: "🌐 Updated Translations from POEditor"
# body: |
# This PR contains the latest translations from POEditor.
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "feat: translations updated from POEditor"
title: "🌐 Updated Translations from POEditor"
body: |
This PR contains the latest translations from POEditor.
# 📅 Update Date: ${{ steps.date.outputs.date }}
# 🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }}
📅 Update Date: ${{ steps.date.outputs.date }}
🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }}
# ⚠️ Please review the translations and approve the PR if everything looks correct.
# branch: translation-update-${{ github.run_number }}
# delete-branch: true
# base: develop
# add-paths: |
# client/src/locales/*.json
# committer: GitHub Action <github-actions[bot]@users.noreply.github.com>
# author: GitHub Action <github-actions[bot]@users.noreply.github.com>
⚠️ Please review the translations and approve the PR if everything looks correct.
branch: translation-update-${{ github.run_number }}
delete-branch: true
base: develop
add-paths: |
client/src/locales/*.json
committer: GitHub Action <github-actions[bot]@users.noreply.github.com>
author: GitHub Action <github-actions[bot]@users.noreply.github.com>
+9 -7
View File
@@ -29,6 +29,7 @@ Checkmate has been stress-tested with 1000+ active monitors without any particul
- [📦 Demo](#demo)
- [🔗 User's guide](#users-guide)
- [🛠️ Installation](#installation)
- [🏁 Translations](#translations)
- [🚀 Performance](#performance)
- [💚 Questions & Ideas](#questions--ideas)
- [🧩 Features](#features)
@@ -47,15 +48,11 @@ You can see the latest build of [Checkmate](https://checkmate-demo.bluewavelabs.
Usage instructions can be found [here](https://checkmate.so/docs).
## Prerequisites
- [Docker](https://www.docker.com/) installed
- [Git](https://git-scm.com/) installed
## Installation
See installation instructions in [Checkmate documentation portal](https://checkmate.so/docs).
Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
Alternatively, you can also use [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
### Using a Custom CA
@@ -63,6 +60,11 @@ If you need to monitor internal HTTPS endpoints with certificates from private C
For more documentation, see the [docs directory](./docs/).
## Translations
If you would like to use Checkmate in your language, please [go to this page](https://poeditor.com/join/project/lRUoGZFCsJ) and register for the language you would like to translate Checkmate to.
## Performance
Thanks to extensive optimizations, Checkmate operates with an exceptionally small memory footprint, requiring minimal memory and CPU resources. Here’s the memory usage of a Node.js instance running on a server that monitors 323 servers every minute:
@@ -78,7 +80,7 @@ You can see the memory footprint of MongoDB and Redis on the same server (398Mb
If you have any questions, suggestions or comments, you have several options:
- [Discord channel](https://discord.gg/NAb6H3UTjK) (preferred)
- [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions) (we check here from time to time)
- [GitHub Discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions) (we check here from time to time)
Feel free to ask questions or share your ideas - we'd love to hear from you!
@@ -165,7 +167,7 @@ Here's how you can contribute:
<img src="https://contrib.rocks/image?repo=bluewave-labs/checkmate" />
</a>
[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/Checkmate&Date)
[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/bluewave-uptime&Date)
## Our sponsors
-258
View File
@@ -1,258 +0,0 @@
/**
* Locale Migration Script
*
* Restructures all non-English locale files to match en.json's nested key structure.
* Recovers translations via safe key-matching strategies, uses English placeholders
* for unmatched keys.
*
* Usage: npx tsx client/scripts/migrate-locales.ts
*
* SAFETY: en.json is NEVER modified. Originals are backed up before overwriting.
*/
import fs from "fs";
import path from "path";
const LOCALES_DIR = path.resolve(import.meta.dirname, "../src/locales");
const BACKUP_DIR = path.join(LOCALES_DIR, "backup");
const REPORT_PATH = path.join(LOCALES_DIR, "migration-report.json");
// ── Helpers ──────────────────────────────────────────────────────────────────
/** Flatten a nested object into { "a.b.c": value } pairs */
function flattenKeys(obj: Record<string, unknown>, prefix = ""): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(obj)) {
const fullPath = prefix ? `${prefix}.${key}` : key;
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
Object.assign(result, flattenKeys(value as Record<string, unknown>, fullPath));
} else {
result[fullPath] = String(value);
}
}
return result;
}
/** Set a value at a dot-separated path in a nested object */
function setNestedValue(
obj: Record<string, unknown>,
keyPath: string,
value: string
): void {
const parts = keyPath.split(".");
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!(part in current) || typeof current[part] !== "object") {
current[part] = {};
}
current = current[part] as Record<string, unknown>;
}
current[parts[parts.length - 1]] = value;
}
// ── Build mapping indexes from en.json ───────────────────────────────────────
function buildIndexes(enFlat: Record<string, string>) {
const enKeys = Object.keys(enFlat);
// last segment -> en key paths
const lastSegIndex: Record<string, string[]> = {};
// last 2 segments -> en key paths
const suffixIndex: Record<string, string[]> = {};
for (const keyPath of enKeys) {
const parts = keyPath.split(".");
const lastSeg = parts[parts.length - 1].toLowerCase();
if (!lastSegIndex[lastSeg]) lastSegIndex[lastSeg] = [];
lastSegIndex[lastSeg].push(keyPath);
if (parts.length >= 2) {
const suffix = parts.slice(-2).join(".").toLowerCase();
if (!suffixIndex[suffix]) suffixIndex[suffix] = [];
suffixIndex[suffix].push(keyPath);
}
}
return { lastSegIndex, suffixIndex };
}
// ── Mapping logic ────────────────────────────────────────────────────────────
type MatchStrategy = "exact" | "suffix-2" | "unique-name" | "english-placeholder";
interface KeyMatch {
enKey: string;
value: string;
strategy: MatchStrategy;
}
function buildMigratedLocale(
enFlat: Record<string, string>,
langFlat: Record<string, string>,
indexes: ReturnType<typeof buildIndexes>
): { matches: KeyMatch[]; result: Record<string, unknown> } {
const { lastSegIndex, suffixIndex } = indexes;
const matches: KeyMatch[] = [];
const result: Record<string, unknown> = {};
// Build reverse lookup: for each orphan key in lang, index by last segment and suffix
const langByLastSeg: Record<string, { key: string; value: string }[]> = {};
const langBySuffix: Record<string, { key: string; value: string }[]> = {};
for (const [langKey, langValue] of Object.entries(langFlat)) {
const parts = langKey.split(".");
const lastSeg = parts[parts.length - 1].toLowerCase();
if (!langByLastSeg[lastSeg]) langByLastSeg[lastSeg] = [];
langByLastSeg[lastSeg].push({ key: langKey, value: langValue });
if (parts.length >= 2) {
const suffix = parts.slice(-2).join(".").toLowerCase();
if (!langBySuffix[suffix]) langBySuffix[suffix] = [];
langBySuffix[suffix].push({ key: langKey, value: langValue });
}
}
for (const enKey of Object.keys(enFlat)) {
const enValue = enFlat[enKey];
const parts = enKey.split(".");
const lastSeg = parts[parts.length - 1].toLowerCase();
const suffix = parts.length >= 2 ? parts.slice(-2).join(".").toLowerCase() : null;
// Strategy 1: Exact path match
if (langFlat[enKey] !== undefined) {
const value = langFlat[enKey];
setNestedValue(result, enKey, value);
matches.push({ enKey, value, strategy: "exact" });
continue;
}
// Strategy 2: 2-segment suffix match (lang key suffix matches en key suffix, both unique)
if (suffix) {
const enCandidates = suffixIndex[suffix];
const langCandidates = langBySuffix[suffix];
if (
enCandidates &&
enCandidates.length === 1 &&
langCandidates &&
langCandidates.length === 1
) {
const value = langCandidates[0].value;
setNestedValue(result, enKey, value);
matches.push({ enKey, value, strategy: "suffix-2" });
continue;
}
}
// Strategy 3: Unique last-segment match
const enCandidates = lastSegIndex[lastSeg];
const langCandidates = langByLastSeg[lastSeg];
if (
enCandidates &&
enCandidates.length === 1 &&
langCandidates &&
langCandidates.length === 1
) {
const value = langCandidates[0].value;
setNestedValue(result, enKey, value);
matches.push({ enKey, value, strategy: "unique-name" });
continue;
}
// No match — use English placeholder
setNestedValue(result, enKey, enValue);
matches.push({ enKey, value: enValue, strategy: "english-placeholder" });
}
return { matches, result };
}
// ── Main ─────────────────────────────────────────────────────────────────────
function main() {
// Load en.json
const enPath = path.join(LOCALES_DIR, "en.json");
const en = JSON.parse(fs.readFileSync(enPath, "utf8"));
const enFlat = flattenKeys(en);
const enKeyCount = Object.keys(enFlat).length;
const indexes = buildIndexes(enFlat);
console.log(`English: ${enKeyCount} keys (source of truth — NOT modified)\n`);
// Find all non-English locale files
const localeFiles = fs
.readdirSync(LOCALES_DIR)
.filter(
(f: string) =>
f.endsWith(".json") && f !== "en.json" && f !== "migration-report.json"
);
if (localeFiles.length === 0) {
console.log("No locale files to migrate.");
return;
}
// Create backup directory
if (!fs.existsSync(BACKUP_DIR)) {
fs.mkdirSync(BACKUP_DIR, { recursive: true });
}
const report: Record<
string,
{
total: number;
exact: number;
suffix: number;
uniqueName: number;
placeholder: number;
}
> = {};
for (const file of localeFiles) {
const lang = file.replace(".json", "");
const filePath = path.join(LOCALES_DIR, file);
// Backup original
const backupPath = path.join(BACKUP_DIR, file);
fs.copyFileSync(filePath, backupPath);
// Load and flatten
const langData = JSON.parse(fs.readFileSync(filePath, "utf8"));
const langFlat = flattenKeys(langData);
// Migrate
const { matches, result } = buildMigratedLocale(enFlat, langFlat, indexes);
// Write migrated file
fs.writeFileSync(filePath, JSON.stringify(result, null, "\t") + "\n", "utf8");
// Compute stats
const stats = {
total: enKeyCount,
exact: matches.filter((m) => m.strategy === "exact").length,
suffix: matches.filter((m) => m.strategy === "suffix-2").length,
uniqueName: matches.filter((m) => m.strategy === "unique-name").length,
placeholder: matches.filter((m) => m.strategy === "english-placeholder").length,
};
report[lang] = stats;
const translated = stats.exact + stats.suffix + stats.uniqueName;
const pct = ((translated / enKeyCount) * 100).toFixed(1);
console.log(
`${lang.padEnd(8)} ${String(translated).padStart(4)} translated (${pct}%) | ` +
`exact: ${stats.exact} suffix: ${stats.suffix} name: ${stats.uniqueName} ` +
`placeholder: ${stats.placeholder}`
);
}
// Write report
fs.writeFileSync(REPORT_PATH, JSON.stringify(report, null, "\t") + "\n", "utf8");
const totalLangs = localeFiles.length;
console.log(
`\nMigrated ${totalLangs} locale files. Originals backed up to locales/backup/`
);
console.log(`Report written to ${REPORT_PATH}`);
}
main();
@@ -3,7 +3,7 @@ import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useTheme, type Theme } from "@mui/material/styles";
import { useMemo, useState, useEffect } from "react";
import { getInfraGaugeColor } from "@/Utils/MonitorUtils";
@@ -17,6 +17,10 @@ export const Gauge = ({
strokeWidth = 15,
precision = 1,
unit = "%",
// colorFn lets callers override the default color logic.
// The Gauge was originally designed for metrics where higher values are worse (e.g., CPU usage).
// PageSpeed scores are the opposite—higher is better—so a custom colorFn can be used to invert the behavior.
colorFn,
}: {
isLoading?: boolean;
progress?: number;
@@ -24,6 +28,7 @@ export const Gauge = ({
strokeWidth?: number;
precision?: number;
unit?: string;
colorFn?: (val: number, theme: Theme) => string;
}) => {
const theme = useTheme();
const progressWithinRange = Math.max(MINIMUM_VALUE, Math.min(progress, MAXIMUM_VALUE));
@@ -48,7 +53,8 @@ export const Gauge = ({
return () => clearTimeout(timer);
}, [progress, circumference, strokeLength]);
const fillColor = getInfraGaugeColor(progressWithinRange, theme);
const resolvedColorFn = colorFn ?? getInfraGaugeColor;
const fillColor = resolvedColorFn(progressWithinRange, theme);
if (isLoading) {
return;
+1
View File
@@ -35,6 +35,7 @@ export const useStatusPageForm = ({
showUptimePercentage: data?.showUptimePercentage ?? true,
showAdminLoginLink: data?.showAdminLoginLink ?? false,
showInfrastructure: data?.showInfrastructure ?? false,
showPageSpeed: data?.showPageSpeed ?? false,
customCSS: data?.customCSS || "",
logo: transformLogo(data?.logo),
};
+36 -8
View File
@@ -28,7 +28,7 @@ import { useStatusPageForm } from "@/Hooks/useStatusPageForm";
import type { StatusPageFormData } from "@/Validation/statusPage";
import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage";
import type { StatusPageType, StatusPageResponse } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import timezones from "@/Utils/timezones.json";
import { useNavigate, useParams } from "react-router-dom";
@@ -37,9 +37,17 @@ import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusContr
const monitorsUrl = (() => {
const params = new URLSearchParams();
["http", "ping", "port", "docker", "game", "grpc", "websocket", "hardware"].forEach(
(type) => params.append("type", type)
);
[
"http",
"ping",
"port",
"docker",
"game",
"grpc",
"websocket",
"hardware",
"pagespeed",
].forEach((type) => params.append("type", type));
return `/monitors/team?${params.toString()}`;
})();
@@ -59,7 +67,9 @@ const CreateStatusPage = () => {
// Fetch existing status page data when configuring
const { data: statusPageData, isLoading: isLoadingStatusPage } =
useGet<StatusPageResponse>(
isCreate ? null : `/status-page/${url}?type=uptime&type=infrastructure`
isCreate
? null
: `/status-page/${url}?type=uptime&type=infrastructure&type=pagespeed`
);
const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl);
@@ -91,14 +101,16 @@ const CreateStatusPage = () => {
}, [defaults, reset]);
const watchedMonitorIds: string[] = form.watch("monitors") ?? [];
const computedTypes: MonitorDisplayType[] = useMemo(() => {
const computedTypes: StatusPageType[] = useMemo(() => {
const selectedMonitors = (watchedMonitorIds ?? [])
.map((id) => monitors.find((m) => m.id === id))
.filter((m): m is Monitor => m !== undefined);
const typesSet = new Set<MonitorDisplayType>();
const typesSet = new Set<StatusPageType>();
selectedMonitors.forEach((m) => {
typesSet.add(m.type === "hardware" ? "infrastructure" : "uptime");
if (m.type === "hardware") typesSet.add("infrastructure");
else if (m.type === "pagespeed") typesSet.add("pagespeed");
else typesSet.add("uptime");
});
return typesSet.size ? Array.from(typesSet) : ["uptime"];
@@ -139,6 +151,7 @@ const CreateStatusPage = () => {
fd.append("showUptimePercentage", String(data.showUptimePercentage));
fd.append("showAdminLoginLink", String(data.showAdminLoginLink));
fd.append("showInfrastructure", String(data.showInfrastructure));
fd.append("showPageSpeed", String(data.showPageSpeed));
data.monitors.forEach((monitorId) => {
fd.append("monitors[]", monitorId);
@@ -475,6 +488,21 @@ const CreateStatusPage = () => {
/>
)}
/>
<Controller
name="showPageSpeed"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
/>
}
label={t("pages.statusPages.form.features.option.showPageSpeed.label")}
/>
)}
/>
{/* <Controller
name="showUptimePercentage"
control={control}
@@ -6,6 +6,7 @@ import { HistogramResponseTime, HeatmapResponseTime } from "@/Components/common"
import { StatusLabel, BaseBox } from "@/Components/design-elements";
import { SwitchComponent } from "@/Components/inputs";
import { InfrastructureMetrics } from "@/Pages/StatusPage/Status/Components/InfrastructureMetrics";
import { PageSpeedMetrics } from "@/Pages/StatusPage/Status/Components/PageSpeedMetrics";
import { useTheme, type Theme } from "@mui/material/styles";
import { useSelector } from "react-redux";
@@ -26,8 +27,12 @@ interface MonitorsListProps {
}
const getMonitorBadgeStyles = (monitorType: string, theme: Theme) => {
const bg =
monitorType === "hardware" ? theme.palette.info.light : theme.palette.success.light;
const monitorColors: Record<string, string> = {
hardware: theme.palette.info.light,
pagespeed: theme.palette.warning.light,
};
const bg = monitorColors[monitorType] || theme.palette.success.light;
return {
backgroundColor: bg,
color: theme.palette.background.paper,
@@ -105,6 +110,12 @@ const MonitorContent = ({
return <InfrastructureMetrics monitor={monitor} />;
}
if (monitor.type === "pagespeed") {
return statusPage.showPageSpeed === false ? null : (
<PageSpeedMetrics monitor={monitor} />
);
}
if (statusPage.showCharts === false) return null;
const checks = monitor.checks?.slice().reverse() ?? [];
@@ -0,0 +1,122 @@
import Typography from "@mui/material/Typography";
import { Gauge } from "@/Components/design-elements";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import type { Monitor } from "@/Types/Monitor";
import type { CheckSnapshot } from "@/Types/Check";
import Grid from "@mui/material/Grid";
import Box from "@mui/material/Box";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import { getPageSpeedGaugeColor } from "@/Utils/MonitorUtils";
const GAUGE_RADIUS = 60;
const GAUGE_STROKE_WIDTH = 12;
interface StatusPageMonitor extends Monitor {
checks?: Monitor["recentChecks"];
}
interface MetricConfig {
key: string;
label: string;
progress: number;
}
const MetricItem = ({ label, progress }: MetricConfig) => {
const theme = useTheme();
return (
<Grid
size={{ xs: 12, md: 3 }}
display="flex"
flexDirection="column"
alignItems="center"
textAlign="center"
gap={theme.spacing(SPACING.LG)}
padding={theme.spacing(LAYOUT.XS)}
sx={{
borderRight: { xs: "none", md: `1px solid ${theme.palette.divider}` },
borderBottom: { xs: `1px solid ${theme.palette.divider}`, md: "none" },
"&:last-child": {
borderRight: "none",
borderBottom: "none",
paddingBottom: theme.spacing(SPACING.LG),
},
}}
>
<Box
display="flex"
flexDirection="column"
alignItems="center"
>
<Gauge
progress={progress}
radius={GAUGE_RADIUS}
strokeWidth={GAUGE_STROKE_WIDTH}
colorFn={getPageSpeedGaugeColor}
/>
<Typography variant="body2">{label}</Typography>
</Box>
</Grid>
);
};
const buildMetrics = (check: CheckSnapshot, t: (key: string) => string): MetricConfig[] =>
[
{
key: "performance",
label: t("pages.statusPages.monitorsList.pagespeed.performance"),
progress: check.performance,
},
{
key: "accessibility",
label: t("pages.statusPages.monitorsList.pagespeed.accessibility"),
progress: check.accessibility,
},
{
key: "bestPractices",
label: t("pages.statusPages.monitorsList.pagespeed.bestPractices"),
progress: check.bestPractices,
},
{
key: "seo",
label: t("pages.statusPages.monitorsList.pagespeed.seo"),
progress: check.seo,
},
].filter((m): m is MetricConfig => m.progress !== undefined);
export const PageSpeedMetrics = ({ monitor }: { monitor: StatusPageMonitor }) => {
const theme = useTheme();
const { t } = useTranslation();
const latestCheck = monitor.recentChecks?.[0] ?? monitor.checks?.[0];
if (!latestCheck) {
return (
<Typography
variant="body2"
color={theme.palette.text.secondary}
>
{t("pages.statusPages.monitorsList.noData")}
</Typography>
);
}
const metrics = buildMetrics(latestCheck, t);
return (
<Grid
container
alignItems="center"
padding={theme.spacing(LAYOUT.LG)}
flex={1}
>
{metrics.map((metric) => (
<MetricItem
key={metric.key}
label={metric.label}
progress={metric.progress}
/>
))}
</Grid>
);
};
+3 -1
View File
@@ -23,7 +23,9 @@ const StatusPageView = () => {
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isPublic = location.pathname.startsWith("/status/public");
const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null;
const apiUrl = url
? `/status-page/${url}?type=uptime&type=infrastructure&type=pagespeed`
: null;
const { data, isLoading, error } = useGet<StatusPageResponse>(
apiUrl,
+4 -2
View File
@@ -1,5 +1,6 @@
import type { Monitor, MonitorType } from "@/Types/Monitor";
export type MonitorDisplayType = "uptime" | "infrastructure";
export const StatusPageTypes = ["uptime", "infrastructure", "pagespeed"] as const;
export type StatusPageType = (typeof StatusPageTypes)[number];
export const MONITOR_TYPE_KEYS: Partial<Record<MonitorType, string>> = {
http: "pages.common.monitors.monitorTypes.optionHttp",
@@ -25,7 +26,7 @@ export interface StatusPage {
id: string;
userId: string;
teamId: string;
type: MonitorDisplayType[];
type: StatusPageType[];
companyName: string;
url: string;
timezone?: string;
@@ -42,6 +43,7 @@ export interface StatusPage {
showUptimePercentage: boolean;
showAdminLoginLink: boolean;
showInfrastructure: boolean;
showPageSpeed: boolean;
customCSS: string;
createdAt: string;
updatedAt: string;
+6
View File
@@ -78,6 +78,12 @@ export const getPageSpeedPalette = (score: number): PaletteKey => {
else return "error";
};
export const getPageSpeedGaugeColor = (val: number, theme: any) => {
if (val >= 90) return theme.palette.success.main;
else if (val >= 50) return theme.palette.warning.light;
else return theme.palette.error.light;
};
export const formatUrl = (url: string, maxLength: number = 55) => {
if (!url) return "";
+3 -3
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { StatusPageTypes } from "@/Types/StatusPage";
export const statusPageSchema = z.object({
companyName: z
@@ -14,9 +15,7 @@ export const statusPageSchema = z.object({
"URL can only contain lowercase letters, numbers, and hyphens"
),
timezone: z.string().optional(),
type: z
.array(z.enum(["uptime", "infrastructure"]))
.min(1, "At least one type is required"),
type: z.array(z.enum(StatusPageTypes)).min(1, "At least one type is required"),
color: z.string().min(1, "Color is required"),
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
isPublished: z.boolean(),
@@ -24,6 +23,7 @@ export const statusPageSchema = z.object({
showUptimePercentage: z.boolean(),
showAdminLoginLink: z.boolean(),
showInfrastructure: z.boolean(),
showPageSpeed: z.boolean(),
customCSS: z.string().optional(),
logo: z
.object({
+1
View File
@@ -324,6 +324,7 @@ const statusPageValidation = joi.object({
showCharts: joi.boolean(),
showAdminLoginLink: joi.boolean(),
showInfrastructure: joi.boolean(),
showPageSpeed: joi.boolean(),
});
const settingsValidation = joi.object({
+1085 -1282
View File
File diff suppressed because it is too large. Load diff
+1085 -1282
View File
File diff suppressed because it is too large. Load diff
+1107 -1272
View File
File diff suppressed because it is too large. Load diff
+10
View File
@@ -1202,6 +1202,13 @@
"used": "Used",
"total": "Total"
},
"pagespeed": {
"title": "PageSpeed",
"performance": "Performance",
"accessibility": "Accessibility",
"bestPractices": "Best Practices",
"seo": "SEO"
},
"uptime": {
"title": "Uptime",
"responseTime": "Response time"
@@ -1300,6 +1307,9 @@
},
"showInfrastructure": {
"label": "Show infrastructure metrics"
},
"showPageSpeed": {
"label": "Show PageSpeed scores"
}
}
}
+1085 -1282
View File
File diff suppressed because it is too large. Load diff
+1085 -1282
View File
File diff suppressed because it is too large. Load diff
+1107 -1272
View File
File diff suppressed because it is too large. Load diff
+1086 -1272
View File
File diff suppressed because it is too large. Load diff
+1107 -1272
View File
File diff suppressed because it is too large. Load diff
+1107 -1272
View File
File diff suppressed because it is too large. Load diff
+1107 -1272
View File
File diff suppressed because it is too large. Load diff
+1085 -1282
View File
File diff suppressed because it is too large. Load diff
+792 -1259
View File
File diff suppressed because it is too large. Load diff
+792 -1259
View File
File diff suppressed because it is too large. Load diff
+1099 -1272
View File
File diff suppressed because it is too large. Load diff
+1089 -1300
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -9,7 +9,8 @@ const config: Config = {
},
moduleNameMapper: {
"^@/validation/(.*)\\.js$": "<rootDir>/src/validation/$1.js",
"^@/utils/(.*)\\.js$": "<rootDir>/src/utils/$1.ts",
"^@/utils/(AppError)\\.js$": "<rootDir>/src/utils/$1.ts",
"^@/utils/(.*)\\.js$": "<rootDir>/src/utils/$1.js",
"^@/(.*)\\.ts$": "<rootDir>/src/$1.ts",
"^@/(.*)\\.js$": "<rootDir>/src/$1.ts",
"^@/(.*)$": "<rootDir>/src/$1",
+200 -99
View File
@@ -41,7 +41,7 @@
"ping": "0.4.4",
"sharp": "0.33.5",
"ssl-checker": "2.0.10",
"super-simple-scheduler": "1.9.1",
"super-simple-scheduler": "1.4.5",
"swagger-ui-express": "5.0.1",
"winston": "^3.13.0",
"ws": "^8.19.0",
@@ -69,6 +69,7 @@
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"c8": "10.1.3",
"eslint": "^9.17.0",
"eslint-plugin-mocha": "^10.5.0",
"esm": "3.2.25",
@@ -800,20 +801,40 @@
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.16",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.16.tgz",
"integrity": "sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A==",
"version": "3.972.10",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.10.tgz",
"integrity": "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.13.1",
"fast-xml-parser": "5.5.8",
"@smithy/types": "^4.13.0",
"fast-xml-parser": "5.4.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/xml-builder/node_modules/fast-xml-parser": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz",
"integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.0.0",
"strnum": "^2.1.2"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz",
@@ -1357,6 +1378,16 @@
"integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==",
"license": "Apache-2.0"
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@colors/colors": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
@@ -3824,9 +3855,9 @@
}
},
"node_modules/@smithy/types": {
"version": "4.13.1",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz",
"integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==",
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz",
"integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -4931,9 +4962,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5874,9 +5905,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6042,6 +6073,40 @@
"node": ">= 0.8"
}
},
"node_modules/c8": {
"version": "10.1.3",
"resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
"integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==",
"dev": true,
"license": "ISC",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.1",
"@istanbuljs/schema": "^0.1.3",
"find-up": "^5.0.0",
"foreground-child": "^3.1.1",
"istanbul-lib-coverage": "^3.2.0",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.1.6",
"test-exclude": "^7.0.1",
"v8-to-istanbul": "^9.0.0",
"yargs": "^17.7.2",
"yargs-parser": "^21.1.1"
},
"bin": {
"c8": "bin/c8.js"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"monocart-coverage-reports": "^2"
},
"peerDependenciesMeta": {
"monocart-coverage-reports": {
"optional": true
}
}
},
"node_modules/cacheable-lookup": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
@@ -8087,9 +8152,9 @@
"license": "MIT"
},
"node_modules/fast-xml-builder": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz",
"integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==",
"dev": true,
"funding": [
{
@@ -8097,31 +8162,7 @@
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.1.3"
}
},
"node_modules/fast-xml-parser": {
"version": "5.5.8",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz",
"integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.1.4",
"path-expression-matcher": "^1.2.0",
"strnum": "^2.2.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
"license": "MIT"
},
"node_modules/fastq": {
"version": "1.20.1",
@@ -8262,9 +8303,9 @@
}
},
"node_modules/flatted": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"dev": true,
"license": "ISC"
},
@@ -8711,9 +8752,9 @@
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -8813,9 +8854,9 @@
"license": "ISC"
},
"node_modules/handlebars": {
"version": "4.7.9",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
"integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
"version": "4.7.8",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.5",
@@ -10115,9 +10156,9 @@
}
},
"node_modules/jest-util/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -10652,9 +10693,9 @@
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.camelcase": {
@@ -11196,9 +11237,9 @@
}
},
"node_modules/mjml-cli/node_modules/brace-expansion": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -12292,22 +12333,6 @@
"node": ">=8"
}
},
"node_modules/path-expression-matcher": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.1.tgz",
"integrity": "sha512-d7gQQmLvAKXKXE2GeP9apIGbMYKz88zWdsn/BN2HRWVQsDFdUY36WSLTY0Jvd4HWi7Fb30gQ62oAOzdgJA6fZw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -12344,9 +12369,9 @@
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/path-type": {
@@ -12456,9 +12481,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -14484,9 +14509,9 @@
}
},
"node_modules/strnum": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
"integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz",
"integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==",
"dev": true,
"funding": [
{
@@ -14513,12 +14538,25 @@
}
},
"node_modules/super-simple-scheduler": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.9.1.tgz",
"integrity": "sha512-JSiEQLk7MsnlSkJ87uoyFKIseG2N27ikB0FSxlh25nnQHWC7R5Lrd0VcoLpwSBanyLzlAslhK4W2rQ0mCU8jCQ==",
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.4.5.tgz",
"integrity": "sha512-xHR3a+pLywY7HtwjWj/+OaANdEwAieQhckC/i0s6OoHtVJQ4+VLO1VfkKUbxp+oSROGXcaZet8PTGONVdKNUUw==",
"license": "MIT",
"dependencies": {
"uuid": "11.1.0"
"uuid": "11.1.0",
"winston": "3.17.0"
}
},
"node_modules/super-simple-scheduler/node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/super-simple-scheduler/node_modules/uuid": {
@@ -14534,6 +14572,28 @@
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/super-simple-scheduler/node_modules/winston": {
"version": "3.17.0",
"resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz",
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
"license": "MIT",
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.2",
"async": "^3.2.3",
"is-stream": "^2.0.0",
"logform": "^2.7.0",
"one-time": "^1.0.0",
"readable-stream": "^3.4.0",
"safe-stable-stringify": "^2.3.1",
"stack-trace": "0.0.x",
"triple-beam": "^1.3.0",
"winston-transport": "^4.9.0"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -14621,6 +14681,47 @@
"node": ">=6"
}
},
"node_modules/test-exclude": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
"integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^10.4.1",
"minimatch": "^9.0.4"
},
"engines": {
"node": ">=18"
}
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/test-exclude/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/text-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
@@ -14663,9 +14764,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -15054,9 +15155,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "6.24.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
"integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
"license": "MIT",
"engines": {
"node": ">=18.17"
@@ -15724,9 +15825,9 @@
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"license": "ISC",
"bin": {
+3 -3
View File
@@ -5,8 +5,7 @@
"main": "index.js",
"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": "NODE_OPTIONS=--experimental-vm-modules c8 jest --runInBand",
"dev": "nodemon --exec tsx src/index.js",
"start": "node --watch ./dist/index.js",
"build": "tsc && tsc-alias && cp -r src/templates dist/templates",
@@ -57,7 +56,7 @@
"ping": "0.4.4",
"sharp": "0.33.5",
"ssl-checker": "2.0.10",
"super-simple-scheduler": "1.9.1",
"super-simple-scheduler": "1.4.5",
"swagger-ui-express": "5.0.1",
"winston": "^3.13.0",
"ws": "^8.19.0",
@@ -85,6 +84,7 @@
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"c8": "10.1.3",
"eslint": "^9.17.0",
"eslint-plugin-mocha": "^10.5.0",
"esm": "3.2.25",
@@ -0,0 +1,15 @@
import type { Pool } from "pg";
export const addShowPageSpeedToStatusPages = async (pool: Pool) => {
await pool.query(`
ALTER TABLE status_pages
ADD COLUMN IF NOT EXISTS show_page_speed BOOLEAN DEFAULT FALSE;
`);
};
export const dropShowPageSpeedFromStatusPages = async (pool: Pool) => {
await pool.query(`
ALTER TABLE status_pages
DROP COLUMN IF EXISTS show_page_speed;
`);
};
@@ -21,6 +21,7 @@ import { createStatusPages, dropStatusPages } from "./0018_create_status_pages.j
import { createAppSettings, dropAppSettings } from "./0019_create_app_settings.js";
import { createContinuousAggregates, dropContinuousAggregates } from "./0020_create_continuous_aggregates.js";
import { createRetentionCompression, dropRetentionCompression } from "./0021_create_retention_compression.js";
import { addShowPageSpeedToStatusPages, dropShowPageSpeedFromStatusPages } from "./0022_add_show_page_speed_to_status_pages.js";
const SERVICE_NAME = "TimescaleDB Migrations";
@@ -52,6 +53,7 @@ const migrations: MigrationEntry[] = [
{ name: "0019_create_app_settings", up: createAppSettings, down: dropAppSettings },
{ name: "0020_create_continuous_aggregates", up: createContinuousAggregates, down: dropContinuousAggregates },
{ name: "0021_create_retention_compression", up: createRetentionCompression, down: dropRetentionCompression },
{ name: "0022_add_show_page_speed_to_status_pages", up: addShowPageSpeedToStatusPages, down: dropShowPageSpeedFromStatusPages },
];
const ensureMigrationsTable = async (pool: Pool) => {
+4
View File
@@ -104,6 +104,10 @@ const StatusPageSchema = new Schema<StatusPageDocument>(
type: Boolean,
default: false,
},
showPageSpeed: {
type: Boolean,
default: false,
},
customCSS: {
type: String,
default: "",
@@ -58,6 +58,7 @@ class MongoStatusPagesRepository implements IStatusPagesRepository {
showUptimePercentage: doc.showUptimePercentage,
showAdminLoginLink: doc.showAdminLoginLink,
showInfrastructure: doc.showInfrastructure,
showPageSpeed: doc.showPageSpeed,
customCSS: doc.customCSS,
createdAt: this.toDateString(doc.createdAt),
updatedAt: this.toDateString(doc.updatedAt),
@@ -26,6 +26,7 @@ interface StatusPageRow {
show_uptime_percentage: boolean;
show_admin_login_link: boolean;
show_infrastructure: boolean;
show_page_speed: boolean;
custom_css: string | null;
created_at: Date;
updated_at: Date;
@@ -33,7 +34,7 @@ interface StatusPageRow {
const COLUMNS = `id, user_id, team_id, types, company_name, url, timezone, color,
logo_data, logo_content_type, is_published, show_charts, show_uptime_percentage,
show_admin_login_link, show_infrastructure, custom_css, created_at, updated_at`;
show_admin_login_link, show_infrastructure, show_page_speed, custom_css, created_at, updated_at`;
export class TimescaleStatusPagesRepository implements IStatusPagesRepository {
constructor(private pool: Pool) {}
@@ -42,8 +43,8 @@ export class TimescaleStatusPagesRepository implements IStatusPagesRepository {
const result = await this.pool.query<StatusPageRow>(
`INSERT INTO status_pages (user_id, team_id, types, company_name, url, timezone, color,
logo_data, logo_content_type, is_published, show_charts, show_uptime_percentage,
show_admin_login_link, show_infrastructure, custom_css)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
show_admin_login_link, show_infrastructure, show_page_speed, custom_css)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
RETURNING ${COLUMNS}`,
[
userId,
@@ -60,6 +61,7 @@ export class TimescaleStatusPagesRepository implements IStatusPagesRepository {
data.showUptimePercentage ?? false,
data.showAdminLoginLink ?? false,
data.showInfrastructure ?? false,
data.showPageSpeed ?? false,
data.customCSS ?? null,
]
);
@@ -128,6 +130,7 @@ export class TimescaleStatusPagesRepository implements IStatusPagesRepository {
["showUptimePercentage", "show_uptime_percentage"],
["showAdminLoginLink", "show_admin_login_link"],
["showInfrastructure", "show_infrastructure"],
["showPageSpeed", "show_page_speed"],
["customCSS", "custom_css"],
];
@@ -252,6 +255,7 @@ export class TimescaleStatusPagesRepository implements IStatusPagesRepository {
showUptimePercentage: row.show_uptime_percentage,
showAdminLoginLink: row.show_admin_login_link,
showInfrastructure: row.show_infrastructure,
showPageSpeed: row.show_page_speed,
customCSS: row.custom_css ?? "",
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
+10 -10
View File
@@ -107,16 +107,16 @@ export class CheckService implements ICheckService {
numericUnit: audit.numericUnit,
};
};
check.accessibility = (categories.accessibility?.score || 0) * 100;
check.bestPractices = (categories["best-practices"]?.score || 0) * 100;
check.seo = (categories.seo?.score || 0) * 100;
check.performance = (categories.performance?.score || 0) * 100;
check.accessibility = (categories?.accessibility?.score || 0) * 100;
check.bestPractices = (categories?.["best-practices"]?.score || 0) * 100;
check.seo = (categories?.seo?.score || 0) * 100;
check.performance = (categories?.performance?.score || 0) * 100;
check.audits = {
cls: mapAudit(audits["cumulative-layout-shift"]),
si: mapAudit(audits["speed-index"]),
fcp: mapAudit(audits["first-contentful-paint"]),
lcp: mapAudit(audits["largest-contentful-paint"]),
tbt: mapAudit(audits["total-blocking-time"]),
cls: mapAudit(audits?.["cumulative-layout-shift"]),
si: mapAudit(audits?.["speed-index"]),
fcp: mapAudit(audits?.["first-contentful-paint"]),
lcp: mapAudit(audits?.["largest-contentful-paint"]),
tbt: mapAudit(audits?.["total-blocking-time"]),
};
}
@@ -124,7 +124,7 @@ export class CheckService implements ICheckService {
const hardwarePayload = payload as HardwareStatusPayload | undefined;
const { cpu, memory, disk, host, net } = hardwarePayload?.data ?? {};
const errorsSource = Array.isArray(hardwarePayload?.errors)
? hardwarePayload.errors
? hardwarePayload?.errors
: (hardwarePayload?.errors as { errors?: CheckErrorInfo[] } | undefined)?.errors;
check.cpu = cpu;
check.memory = memory;
@@ -95,14 +95,17 @@ export class IncidentService implements IIncidentService {
}
}
if (!decision.shouldResolveIncident || !activeIncident) {
return null;
if (decision.shouldResolveIncident) {
if (!activeIncident) {
return null;
}
activeIncident.status = false;
activeIncident.endTime = Date.now().toString();
activeIncident.resolutionType = "automatic";
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
}
activeIncident.status = false;
activeIncident.endTime = Date.now().toString();
activeIncident.resolutionType = "automatic";
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
return null;
};
private buildThresholdBreachMessage(monitor: Monitor, monitorStatusResponse?: MonitorStatusResponse): string {
@@ -73,91 +73,22 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
return SuperSimpleQueue.SERVICE_NAME;
}
private registerListeners = () => {
this.scheduler.on("scheduler:start", () => {
this.logger.info({
message: "Scheduler started",
service: SERVICE_NAME,
});
});
this.scheduler.on("scheduler:stop", () => {
this.logger.info({
message: "Scheduler stopped",
service: SERVICE_NAME,
});
});
this.scheduler.on("scheduler:error", (error) => {
this.logger.error({
message: `Scheduler error: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
stack: error instanceof Error ? error.stack : undefined,
});
});
this.scheduler.on("job:abort", (job, reason) => {
this.logger.warn({
message: `${job.id} aborted: ${reason}`,
service: SERVICE_NAME,
});
});
this.scheduler.on("job:attempt", (job, attempt) => {
this.logger.debug({
message: `${job.id} attempt ${attempt}`,
service: SERVICE_NAME,
});
});
this.scheduler.on("job:complete", (job) => {
this.logger.debug({
message: `${job.id} completed successfully`,
service: SERVICE_NAME,
});
});
this.scheduler.on("job:exhausted", (job, error) => {
this.logger.error({
message: `${job.id} exhausted all retries: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
stack: error instanceof Error ? error.stack : undefined,
});
});
this.scheduler.on("job:fail", (job, error, attempt) => {
this.logger.warn({
message: `${job.id} failed on attempt ${attempt}: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
stack: error instanceof Error ? error.stack : undefined,
});
});
this.scheduler.on("job:start", (job) => {
this.logger.debug({
message: `${job.id} started`,
service: SERVICE_NAME,
});
});
};
static async create(logger: ILogger, helper: ISuperSimpleQueueHelper, monitorsRepository: IMonitorsRepository) {
const scheduler = new Scheduler({
// storeType: "mongo",
// storeType: "redis",
logLevel: "debug",
// dbUri: envSettings.dbConnectionString,
});
const instance = new SuperSimpleQueue(logger, helper, monitorsRepository, scheduler);
await instance.init();
return instance;
}
init = async () => {
try {
this.registerListeners();
this.scheduler.start();
this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob());
this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob());
this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob());
@@ -215,8 +146,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
deleteJob = async (monitor: Monitor) => {
this.scheduler.removeJob(monitor.id);
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
if (geoJob) await this.scheduler.removeJob(`${monitor.id}-geo`);
this.scheduler.removeJob(`${monitor.id}-geo`);
};
pauseJob = async (monitor: Monitor) => {
@@ -224,9 +154,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
if (result === false) {
throw new Error("Failed to pause monitor");
}
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
if (geoJob) await this.scheduler.removeJob(`${monitor.id}-geo`);
await this.scheduler.pauseJob(`${monitor.id}-geo`);
this.logger.debug({
message: `Paused monitor ${monitor.id}`,
service: SERVICE_NAME,
@@ -249,35 +177,31 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
});
};
private syncGeoJob = async (monitor: Monitor) => {
const geoJobId = `${monitor.id}-geo`;
const existingGeoJob = await this.scheduler.getJob(geoJobId);
// If geoChecks have been disabled, or the monitor type doesn't support them, remove
if (!monitor.geoCheckEnabled || !supportsGeoCheck(monitor.type)) {
if (existingGeoJob) this.scheduler.removeJob(geoJobId);
return;
}
// If the job exists, update it
if (existingGeoJob) {
this.scheduler.updateJob(geoJobId, { repeat: monitor.geoCheckInterval, active: monitor.isActive, data: monitor });
return;
}
// Otherwise, create it
this.scheduler.addJob({
id: geoJobId,
template: "geo-check-job",
repeat: monitor.geoCheckInterval,
active: monitor.isActive,
data: monitor,
});
};
updateJob = async (monitor: Monitor) => {
this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor });
await this.syncGeoJob(monitor);
// Handle geo check job lifecycle
const geoJobId = `${monitor.id}-geo`;
if (monitor.geoCheckEnabled && supportsGeoCheck(monitor.type)) {
// Check if geo job exists
const existingGeoJob = await this.scheduler.getJob(geoJobId);
if (existingGeoJob) {
// Update existing geo job
this.scheduler.updateJob(geoJobId, { repeat: monitor.geoCheckInterval, active: monitor.isActive, data: monitor });
} else {
// Create new geo job
this.scheduler.addJob({
id: geoJobId,
template: "geo-check-job",
repeat: monitor.geoCheckInterval,
active: monitor.isActive,
data: monitor,
});
}
} else {
// Remove geo job if disabled or monitor type changed
this.scheduler.removeJob(geoJobId);
}
};
shutdown = async () => {
@@ -306,8 +230,8 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
if (failCount > 0) {
acc.jobsWithFailures.push({
monitorId: job.id,
monitorUrl: job.data?.url || null,
monitorType: job.data?.type || null,
monitorUrl: job?.data?.url || null,
monitorType: job?.data?.type || null,
failedAt: job.lastFailedAt ?? null,
failCount,
failReason: job.lastFailReason ?? null,
@@ -339,9 +263,9 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
return jobs.map((job) => {
return {
monitorId: job.id,
monitorUrl: job.data?.url || null,
monitorType: job.data?.type || null,
monitorInterval: job.data?.interval || null,
monitorUrl: job?.data?.url || null,
monitorType: job?.data?.type || null,
monitorInterval: job?.data?.interval || null,
active: job.active,
lockedAt: job.lockedAt ?? null,
runCount: job.runCount ?? 0,
@@ -147,7 +147,13 @@ export class StatusService implements IStatusService {
}
// Calculate uptime percentage
stats.uptimePercentage = stats.totalUpChecks / stats.totalChecks;
let uptimePercentage;
if (stats.totalChecks > 0) {
uptimePercentage = stats.totalUpChecks / stats.totalChecks;
} else {
uptimePercentage = status === true ? 100 : 0;
}
stats.uptimePercentage = uptimePercentage;
// latest check
stats.lastCheckTimestamp = new Date().getTime();
@@ -262,7 +268,7 @@ export class StatusService implements IStatusService {
let thresholdBreaches: { cpu: boolean; memory: boolean; disk: boolean; temp: boolean } | undefined;
if (monitor.type === "hardware" && statusResponse.payload) {
const payload = statusResponse.payload as HardwareStatusPayload;
const metrics = payload.data;
const metrics = payload?.data;
if (metrics) {
// Evaluate threshold breaches
@@ -272,11 +278,9 @@ export class StatusService implements IStatusService {
const memoryUsage = metrics.memory?.usage_percent ?? -1;
const memoryBreach = memoryUsage !== -1 && memoryUsage > monitor.memoryAlertThreshold / 100;
const diskBreach = metrics.disk
? metrics.disk.some(
(d: CheckDiskInfo) => d != null && typeof d.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100
)
: false;
const diskBreach =
metrics.disk?.some((d: CheckDiskInfo) => typeof d?.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100) ??
false;
const temps = metrics.cpu?.temperature ?? [];
const tempBreach = temps.some((temp: number) => temp > monitor.tempAlertThreshold);
+2 -1
View File
@@ -1,4 +1,4 @@
export const StatusPageTypes = ["uptime", "infrastructure"] as const;
export const StatusPageTypes = ["uptime", "infrastructure", "pagespeed"] as const;
export type StatusPageType = (typeof StatusPageTypes)[number];
export interface StatusPageLogo {
@@ -29,6 +29,7 @@ export interface StatusPage {
showUptimePercentage: boolean;
showAdminLoginLink: boolean;
showInfrastructure: boolean;
showPageSpeed: boolean;
customCSS: string;
createdAt: string;
updatedAt: string;
@@ -32,6 +32,7 @@ export const createStatusPageBodyValidation = z
showUptimePercentage: booleanCoercion,
showAdminLoginLink: booleanCoercion.optional(),
showInfrastructure: booleanCoercion.optional(),
showPageSpeed: booleanCoercion.optional(),
removeLogo: z.union([z.literal("true"), z.literal("false")]).optional(),
})
.strip();
-12
View File
@@ -1,12 +0,0 @@
import { jest } from "@jest/globals";
export const createMockLogger = () => ({
serviceName: "test",
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
cacheLog: jest.fn(),
getLogs: jest.fn().mockReturnValue([]),
buildLogEntry: jest.fn().mockReturnValue({ level: "info", timestamp: new Date().toISOString() }),
});
@@ -1,13 +1,6 @@
import { jest } from "@jest/globals";
import { MonitorService } from "../../../src/service/business/monitorService.ts";
import type {
IChecksRepository,
IGeoChecksRepository,
IIncidentsRepository,
IMonitorStatsRepository,
IMonitorsRepository,
IStatusPagesRepository,
} from "../../../src/repositories/index.ts";
import { MonitorService } from "../src/service/business/monitorService.ts";
import type { IChecksRepository, IMonitorStatsRepository, IMonitorsRepository, IStatusPagesRepository } from "../src/repositories/index.ts";
const createMonitorsRepositoryMock = () =>
({
@@ -15,11 +8,16 @@ const createMonitorsRepositoryMock = () =>
findByTeamId: jest.fn(),
findById: jest.fn(),
findMonitorsSummaryByTeamId: jest.fn(),
findGroupsByTeamId: jest.fn(),
create: jest.fn(),
createBulkMonitors: jest.fn(),
deleteByTeamId: jest.fn(),
}) as unknown as IMonitorsRepository;
const createChecksRepositoryMock = () =>
({
findByDateRangeAndMonitorId: jest.fn(),
findLatestChecksByMonitorIds: jest.fn(),
findDateRangeChecksByMonitor: jest.fn(),
}) as unknown as IChecksRepository;
const createMonitorStatsRepositoryMock = () =>
@@ -33,84 +31,76 @@ const createStatusPagesRepositoryMock = () =>
removeMonitorFromStatusPages: jest.fn(),
}) as unknown as IStatusPagesRepository;
const createGeoChecksRepositoryMock = () => ({}) as unknown as IGeoChecksRepository;
const createIncidentsRepositoryMock = () => ({}) as unknown as IIncidentsRepository;
const createService = ({
monitorsRepository = createMonitorsRepositoryMock(),
checksRepository = createChecksRepositoryMock(),
monitorStatsRepository = createMonitorStatsRepositoryMock(),
statusPagesRepository = createStatusPagesRepositoryMock(),
geoChecksRepository = createGeoChecksRepositoryMock(),
incidentsRepository = createIncidentsRepositoryMock(),
}: {
monitorsRepository?: IMonitorsRepository;
checksRepository?: IChecksRepository;
monitorStatsRepository?: IMonitorStatsRepository;
statusPagesRepository?: IStatusPagesRepository;
geoChecksRepository?: IGeoChecksRepository;
incidentsRepository?: IIncidentsRepository;
} = {}) => {
return new MonitorService({
db: {
checkModule: { deleteChecks: jest.fn() },
statusPageModule: { deleteStatusPagesByMonitorId: jest.fn() },
pageSpeedCheckModule: { deletePageSpeedChecksByMonitorId: jest.fn() },
notificationModule: { deleteNotificationsByMonitorId: jest.fn() },
},
jobQueue: {
addJob: jest.fn(),
updateJob: jest.fn(),
resumeJob: jest.fn(),
pauseJob: jest.fn(),
deleteJob: jest.fn(),
} as any,
emailService: { buildEmail: jest.fn(), sendEmail: jest.fn() } as any,
logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() } as any,
},
stringService: {},
emailService: { buildEmail: jest.fn(), sendEmail: jest.fn() },
papaparse: { parse: jest.fn(), unparse: jest.fn() },
logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
errorService: {
createAuthorizationError: jest.fn(() => new Error("unauthorized")),
createServerError: jest.fn(() => new Error("server")),
createBadRequestError: jest.fn(() => new Error("bad request")),
createNotFoundError: jest.fn(() => new Error("not found")),
},
games: [],
monitorsRepository,
checksRepository,
geoChecksRepository,
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
});
};
describe("MonitorService", () => {
describe("getMonitorsWithChecksByTeamId", () => {
it("returns monitors enriched with normalized recentChecks", async () => {
it("returns monitors enriched with normalized checks", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(2);
(monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({
totalMonitors: 2,
upMonitors: 2,
downMonitors: 0,
pausedMonitors: 0,
});
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([
{
id: "m1",
name: "Monitor 1",
type: "http",
interval: 60000,
recentChecks: [
{ responseTime: 10, status: true, message: "OK" },
{ responseTime: 20, status: true, message: "OK" },
],
},
{
id: "m2",
name: "Monitor 2",
type: "http",
interval: 60000,
recentChecks: [{ responseTime: 50, status: true, message: "OK" }],
},
{ id: "m1", name: "Monitor 1", interval: 60000 },
{ id: "m2", name: "Monitor 2", interval: 60000 },
]);
const service = createService({ monitorsRepository });
const checksRepository = createChecksRepositoryMock();
(checksRepository.findLatestChecksByMonitorIds as jest.Mock).mockResolvedValue({
m1: [
{ responseTime: 10, status: true, message: "OK" },
{ responseTime: 20, status: true, message: "OK" },
],
m2: [{ responseTime: 50, status: true, message: "OK" }],
});
const service = createService({ monitorsRepository, checksRepository });
const result = await service.getMonitorsWithChecksByTeamId({ teamId: "team" });
expect(result).toMatchObject({ count: 2 });
expect(result.monitors).toHaveLength(2);
expect(result.monitors[0]).toHaveProperty("recentChecks");
expect(result.monitors[0].recentChecks.length).toBeGreaterThan(0);
expect(result.monitors[0].recentChecks[0]).toEqual(
expect(result.monitors[0]).toHaveProperty("checks");
expect(result.monitors[0].checks.length).toBeGreaterThan(0);
expect(result.monitors[0].checks[0]).toEqual(
expect.objectContaining({
responseTime: expect.any(Number),
status: expect.any(Boolean),
@@ -130,15 +120,15 @@ describe("MonitorService", () => {
const service = createService({ monitorsRepository });
const result = await service.getMonitorsByTeamId({ teamId: "team" } as any);
expect(result).toHaveLength(2);
expect(result![0]).toHaveProperty("id", "m1");
expect(result[0]).toHaveProperty("id", "m1");
});
});
describe("getMonitorsWithChecksByTeamId summary", () => {
it("includes summary and monitors with recentChecks", async () => {
it("includes summary and monitors with checks", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1);
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([{ id: "m1", type: "http", recentChecks: [] }]);
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([{ id: "m1", type: "http" }]);
(monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({
totalMonitors: 1,
upMonitors: 1,
@@ -146,12 +136,15 @@ describe("MonitorService", () => {
pausedMonitors: 0,
});
const service = createService({ monitorsRepository });
const checksRepository = createChecksRepositoryMock();
(checksRepository.findLatestChecksByMonitorIds as jest.Mock).mockResolvedValue({ m1: [] });
const service = createService({ monitorsRepository, checksRepository });
const result = await service.getMonitorsWithChecksByTeamId({ teamId: "team" });
expect(result).toEqual({
summary: { totalMonitors: 1, upMonitors: 1, downMonitors: 0, pausedMonitors: 0 },
count: 1,
monitors: [{ id: "m1", type: "http", recentChecks: [] }],
monitors: [{ id: "m1", type: "http", checks: [] }],
});
});
});
@@ -162,11 +155,23 @@ describe("MonitorService", () => {
const monitor = {
id: "monitor-1",
teamId: TEAM_ID,
name: "HTTP monitor",
name: "Hardware monitor",
interval: 60000,
statusWindow: [],
statusWindowSize: 5,
statusWindowThreshold: 60,
type: "http",
ignoreTlsErrors: false,
url: "https://example.com",
isActive: true,
alertThreshold: 5,
cpuAlertThreshold: 5,
memoryAlertThreshold: 5,
diskAlertThreshold: 5,
tempAlertThreshold: 5,
selectedDisks: [],
notifications: [],
group: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
@@ -174,7 +179,7 @@ describe("MonitorService", () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
const checksRepository = createChecksRepositoryMock();
(checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({
(checksRepository.findDateRangeChecksByMonitor as jest.Mock).mockResolvedValue({
monitorType: "http",
groupedChecks: [{ _id: "2024-01-01", avgResponseTime: 100, totalChecks: 2 }],
groupedUpChecks: [{ _id: "2024-01-01", totalChecks: 2, avgResponseTime: 90 }],
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it, jest } from "@jest/globals";
import SuperSimpleQueueHelper from "../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
import type { Monitor } from "../src/types/monitor.ts";
const createLogger = () => ({ info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() });
const createHelper = (overrides?: Partial<ConstructorParameters<typeof SuperSimpleQueueHelper>[0]>) => {
const maintenanceWindowsRepository = {
findByMonitorId: jest.fn().mockResolvedValue([]),
};
const statusServiceMock = {
updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1" }, statusChanged: true, prevStatus: false }),
};
const helper = new SuperSimpleQueueHelper({
logger: createLogger(),
networkService: { requestStatus: jest.fn() },
statusService: statusServiceMock,
notificationsService: { handleNotifications: jest.fn().mockResolvedValue(undefined) },
checkService: { buildCheck: jest.fn().mockResolvedValue({}) },
buffer: { addToBuffer: jest.fn() },
incidentService: { handleIncident: jest.fn().mockResolvedValue(undefined) },
maintenanceWindowsRepository,
...overrides,
});
return { helper, maintenanceWindowsRepository };
};
describe("SuperSimpleQueueHelper", () => {
describe("getMonitorJob", () => {
it("skips execution when monitor is in maintenance window", async () => {
const { helper } = createHelper();
const spy = jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true);
const job = helper.getMonitorJob();
await job({ id: "m1", teamId: "team", interval: 60000 } as Monitor);
expect(helper["networkService"].requestStatus).not.toHaveBeenCalled();
expect(helper["logger"].debug).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("Monitor m1 is in maintenance window") })
);
spy.mockRestore();
});
it("processes monitor status and notifications when active", async () => {
const networkResponse = { monitor: { id: "m1" }, status: true };
const updatedMonitor = { id: "m1", status: true };
const { helper } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
statusService: {
updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: updatedMonitor, statusChanged: true, prevStatus: false, code: 200 }),
},
notificationsService: { handleNotifications: jest.fn().mockResolvedValue(undefined) },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getMonitorJob();
const monitor = { id: "m1", teamId: "team" } as Monitor;
await job(monitor);
expect(helper["networkService"].requestStatus).toHaveBeenCalledWith(monitor);
});
it("throws when monitor id is missing", async () => {
const { helper } = createHelper();
const job = helper.getMonitorJob();
await expect(job({} as Monitor)).rejects.toThrow("No monitor id");
expect(helper["logger"].warn).toHaveBeenCalled();
});
});
describe("isInMaintenanceWindow", () => {
it("returns true when an active window spans now", async () => {
const now = new Date();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now.getTime() - 1000).toISOString(),
end: new Date(now.getTime() + 1000).toISOString(),
repeat: 0,
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
});
it("returns true when repeat interval advances window into current time", async () => {
const now = Date.now();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now - 7200000).toISOString(),
end: new Date(now - 6600000).toISOString(),
repeat: 3600000,
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
});
it("returns false when no active windows exist", async () => {
const { helper } = createHelper();
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
});
});
});
@@ -1,15 +1,19 @@
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 { TeamsProvider } from "../src/service/infrastructure/notificationProviders/teams.ts";
import type { Notification } from "../src/types/notification.ts";
import type { NotificationMessage } from "../src/types/notificationMessage.ts";
const mockPost = jest.fn();
// Mock got
jest.unstable_mockModule("got", () => ({
default: { post: mockPost },
HTTPError: class HTTPError extends Error {},
default: { post: jest.fn() },
}));
const { TeamsProvider } = await import("../../../src/service/infrastructure/notificationProviders/teams.ts");
const createLogger = () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
});
const createNotification = (overrides?: Partial<Notification>): Notification => ({
id: "notif-1",
@@ -54,14 +58,17 @@ const createMessage = (overrides?: Partial<NotificationMessage>): NotificationMe
});
describe("TeamsProvider", () => {
let provider: InstanceType<typeof TeamsProvider>;
let logger: ReturnType<typeof createMockLogger>;
let provider: TeamsProvider;
let logger: ReturnType<typeof createLogger>;
let gotPost: jest.Mock;
beforeEach(() => {
logger = createMockLogger();
beforeEach(async () => {
logger = createLogger();
provider = new TeamsProvider(logger);
mockPost.mockReset();
mockPost.mockResolvedValue({});
const got = await import("got");
gotPost = got.default.post as jest.Mock;
gotPost.mockReset();
gotPost.mockResolvedValue({});
});
describe("sendTestAlert", () => {
@@ -69,33 +76,35 @@ describe("TeamsProvider", () => {
const notification = createNotification({ address: undefined });
const result = await provider.sendTestAlert(notification);
expect(result).toBe(false);
expect(mockPost).not.toHaveBeenCalled();
expect(gotPost).not.toHaveBeenCalled();
});
it("sends an Adaptive Card test message and returns true on success", async () => {
const notification = createNotification();
const result = await provider.sendTestAlert(notification);
expect(result).toBe(true);
expect(mockPost).toHaveBeenCalledTimes(1);
expect(gotPost).toHaveBeenCalledTimes(1);
const [url, options] = mockPost.mock.calls[0] as [string, { json: any; headers: any }];
const [url, options] = gotPost.mock.calls[0] as [string, { json: any; headers: any }];
expect(url).toBe(notification.address);
expect(options.headers["Content-Type"]).toBe("application/json");
// Verify Teams webhook envelope
const payload = options.json;
expect(payload.type).toBe("message");
expect(payload.attachments).toHaveLength(1);
expect(payload.attachments[0].contentType).toBe("application/vnd.microsoft.card.adaptive");
// Verify Adaptive Card structure
const card = payload.attachments[0].content;
expect(card.type).toBe("AdaptiveCard");
expect(card.version).toBe("1.4");
expect(card.body[0].type).toBe("TextBlock");
expect(card.body[0].text).toBe("This is a test notification from Checkmate");
expect(card.body[0].text).toContain("test notification");
});
it("returns false and logs warning on HTTP error", async () => {
mockPost.mockRejectedValue(new Error("Network error"));
gotPost.mockRejectedValue(new Error("Network error"));
const notification = createNotification();
const result = await provider.sendTestAlert(notification);
expect(result).toBe(false);
@@ -113,7 +122,7 @@ describe("TeamsProvider", () => {
const notification = createNotification({ address: undefined });
const result = await provider.sendMessage(notification, createMessage());
expect(result).toBe(false);
expect(mockPost).not.toHaveBeenCalled();
expect(gotPost).not.toHaveBeenCalled();
});
it("sends a well-formed Adaptive Card and returns true", async () => {
@@ -122,9 +131,9 @@ describe("TeamsProvider", () => {
const result = await provider.sendMessage(notification, message);
expect(result).toBe(true);
expect(mockPost).toHaveBeenCalledTimes(1);
expect(gotPost).toHaveBeenCalledTimes(1);
const [url, options] = mockPost.mock.calls[0] as [string, { json: any }];
const [url, options] = gotPost.mock.calls[0] as [string, { json: any }];
expect(url).toBe(notification.address);
const payload = options.json;
@@ -134,13 +143,18 @@ describe("TeamsProvider", () => {
expect(card.type).toBe("AdaptiveCard");
expect(card.version).toBe("1.4");
// Verify body contains expected elements
const textBlocks = card.body.filter((b: any) => b.type === "TextBlock");
const factSets = card.body.filter((b: any) => b.type === "FactSet");
// Title block
expect(textBlocks[0].text).toBe(message.content.title);
expect(textBlocks[0].color).toBe("attention");
expect(textBlocks[0].color).toBe("attention"); // critical -> attention
// Summary block
expect(textBlocks[1].text).toBe(message.content.summary);
// FactSet with monitor details
expect(factSets).toHaveLength(1);
const facts = factSets[0].facts;
expect(facts).toEqual(
@@ -151,10 +165,11 @@ describe("TeamsProvider", () => {
])
);
// Incident action button
expect(card.actions).toHaveLength(1);
expect(card.actions[0].type).toBe("Action.OpenUrl");
expect(card.actions[0].title).toBe("View Incident");
expect(card.actions[0].url).toContain("/incidents/inc-1");
expect(card.actions[0].url).toContain("/infrastructure/mon-1");
});
it("omits actions when no incident is present", async () => {
@@ -169,7 +184,7 @@ describe("TeamsProvider", () => {
const result = await provider.sendMessage(notification, message);
expect(result).toBe(true);
const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
const [, options] = gotPost.mock.calls[0] as [string, { json: any }];
const card = options.json.attachments[0].content;
expect(card.actions).toBeUndefined();
});
@@ -197,7 +212,7 @@ describe("TeamsProvider", () => {
await provider.sendMessage(notification, message);
const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
const [, options] = gotPost.mock.calls[0] as [string, { json: any }];
const card = options.json.attachments[0].content;
// Title should use "warning" color
@@ -207,7 +222,7 @@ describe("TeamsProvider", () => {
const thresholdHeader = card.body.find((b: any) => b.type === "TextBlock" && b.text === "**Threshold Breaches**");
expect(thresholdHeader).toBeDefined();
const cpuBlock = card.body.find((b: any) => b.type === "TextBlock" && b.text?.includes("**CPU**"));
const cpuBlock = card.body.find((b: any) => b.type === "TextBlock" && b.text?.includes("CPU"));
expect(cpuBlock).toBeDefined();
expect(cpuBlock.text).toContain("95%");
expect(cpuBlock.text).toContain("threshold: 80%");
@@ -224,20 +239,20 @@ describe("TeamsProvider", () => {
] as const;
for (const { severity, expected } of severityMap) {
mockPost.mockReset();
mockPost.mockResolvedValue({});
gotPost.mockReset();
gotPost.mockResolvedValue({});
const message = createMessage({ severity });
await provider.sendMessage(notification, message);
const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
const [, options] = gotPost.mock.calls[0] as [string, { json: any }];
const card = options.json.attachments[0].content;
expect(card.body[0].color).toBe(expected);
}
});
it("returns false and logs warning on HTTP error", async () => {
mockPost.mockRejectedValue(new Error("502 Bad Gateway"));
gotPost.mockRejectedValue(new Error("502 Bad Gateway"));
const notification = createNotification();
const result = await provider.sendMessage(notification, createMessage());
expect(result).toBe(false);
@@ -1,427 +0,0 @@
import { describe, expect, it, jest } from "@jest/globals";
import { CheckService } from "../../../src/service/business/checkService.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { IChecksRepository, IMonitorsRepository } from "../../../src/repositories/index.ts";
import type { MonitorStatusResponse, HardwareStatusPayload, PageSpeedStatusPayload } from "../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createMonitorsRepo = () =>
({
findById: jest.fn().mockResolvedValue({}),
}) as unknown as jest.Mocked<IMonitorsRepository>;
const createChecksRepo = () =>
({
createChecks: jest.fn().mockImplementation((checks: unknown) => Promise.resolve(checks)),
findByMonitorId: jest.fn().mockResolvedValue({ checks: [], count: 0 }),
findByTeamId: jest.fn().mockResolvedValue({ checks: [], count: 0 }),
findSummaryByTeamId: jest.fn().mockResolvedValue({}),
deleteByMonitorId: jest.fn().mockResolvedValue(5),
deleteByTeamId: jest.fn().mockResolvedValue(10),
deleteOlderThan: jest.fn().mockResolvedValue(3),
}) as unknown as jest.Mocked<IChecksRepository>;
const createService = () => {
const logger = createMockLogger();
const monitorsRepository = createMonitorsRepo();
const checksRepository = createChecksRepo();
const service = new CheckService(monitorsRepository, logger as any, checksRepository);
return { service, logger, monitorsRepository, checksRepository };
};
const makeStatusResponse = (overrides?: Partial<MonitorStatusResponse>): MonitorStatusResponse =>
({
monitorId: "mon-1",
teamId: "team-1",
type: "http",
status: true,
code: 200,
message: "OK",
responseTime: 100,
...overrides,
}) as MonitorStatusResponse;
// ── Tests ────────────────────────────────────────────────────────────────────
describe("CheckService", () => {
describe("serviceName", () => {
it("returns checkService", () => {
const { service } = createService();
expect(service.serviceName).toBe("checkService");
});
});
// ── buildCheck ───────────────────────────────────────────────────────────
describe("buildCheck", () => {
it("builds a basic HTTP check with correct fields", () => {
const { service } = createService();
const check = service.buildCheck(makeStatusResponse());
expect(check).toBeDefined();
expect(check!.metadata).toEqual({ monitorId: "mon-1", teamId: "team-1", type: "http" });
expect(check!.status).toBe(true);
expect(check!.statusCode).toBe(200);
expect(check!.responseTime).toBe(100);
expect(check!.message).toBe("OK");
expect(check!.id).toBeDefined();
expect(check!.createdAt).toBeDefined();
});
it("defaults responseTime to 0 when falsy", () => {
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ responseTime: undefined }));
expect(check!.responseTime).toBe(0);
});
it("includes timings when provided", () => {
const timings = { start: 0, socket: 10, lookup: 20 };
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ timings } as any));
expect(check!.timings).toBe(timings);
});
// ── PageSpeed ────────────────────────────────────────────────────────
describe("pagespeed type", () => {
it("extracts category scores and audits from lighthouse result", () => {
const payload: PageSpeedStatusPayload = {
lighthouseResult: {
categories: {
accessibility: { score: 0.9 },
"best-practices": { score: 0.8 },
seo: { score: 0.95 },
performance: { score: 0.7 },
},
audits: {
"cumulative-layout-shift": { id: "cls", title: "CLS", score: 0.1, displayValue: "0.1", numericValue: 0.1, numericUnit: "unitless" },
"speed-index": { id: "si", title: "SI", score: 0.5, displayValue: "3.0s", numericValue: 3000, numericUnit: "millisecond" },
"first-contentful-paint": { id: "fcp", title: "FCP", score: 0.8 },
"largest-contentful-paint": { id: "lcp", title: "LCP", score: 0.6 },
"total-blocking-time": { id: "tbt", title: "TBT", score: 0.9 },
},
},
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.accessibility).toBe(90);
expect(check!.bestPractices).toBe(80);
expect(check!.seo).toBe(95);
expect(check!.performance).toBe(70);
expect(check!.audits!.cls).toEqual(expect.objectContaining({ id: "cls", score: 0.1, numericValue: 0.1 }));
expect(check!.audits!.si).toEqual(expect.objectContaining({ id: "si", numericValue: 3000 }));
expect(check!.audits!.fcp).toEqual(expect.objectContaining({ id: "fcp" }));
});
it("returns undefined when pagespeed payload is missing", () => {
const { service, logger } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload: undefined } as any));
expect(check).toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build check" }));
});
it("defaults categories and audits to empty when lighthouseResult is missing", () => {
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload: {} } as any));
expect(check!.accessibility).toBe(0);
expect(check!.bestPractices).toBe(0);
expect(check!.seo).toBe(0);
expect(check!.performance).toBe(0);
expect(check!.audits!.cls).toBeUndefined();
});
it("handles audit with non-number score (string)", () => {
const payload = {
lighthouseResult: {
categories: {},
audits: {
"cumulative-layout-shift": { id: "cls", title: "CLS", score: "informative" },
},
},
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.audits!.cls!.score).toBe("informative");
});
it("handles audit with null score", () => {
const payload = {
lighthouseResult: {
categories: {},
audits: {
"cumulative-layout-shift": { id: "cls", title: "CLS", score: null },
},
},
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.audits!.cls!.score).toBeNull();
});
it("handles audit with non-number numericValue", () => {
const payload = {
lighthouseResult: {
categories: {},
audits: {
"speed-index": { id: "si", title: "SI", score: 0.5, numericValue: "n/a" },
},
},
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.audits!.si!.numericValue).toBeUndefined();
});
it("returns undefined for non-object audit", () => {
const payload = {
lighthouseResult: {
categories: {},
audits: {
"cumulative-layout-shift": null,
"speed-index": "not an object",
},
},
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.audits!.cls).toBeUndefined();
expect(check!.audits!.si).toBeUndefined();
});
});
// ── Hardware ─────────────────────────────────────────────────────────
describe("hardware type", () => {
it("extracts cpu, memory, disk, host, net, and capture from payload", () => {
const payload: HardwareStatusPayload = {
data: {
cpu: { usage_percent: 0.5 },
memory: { usage_percent: 0.6 },
disk: [{ device: "/dev/sda", usage_percent: 0.7 }],
host: { os: "linux" },
net: [{ name: "eth0" }],
},
capture: { screenshot: "base64data" },
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any));
expect(check!.cpu).toEqual({ usage_percent: 0.5 });
expect(check!.memory).toEqual({ usage_percent: 0.6 });
expect(check!.disk).toHaveLength(1);
expect(check!.host).toEqual({ os: "linux" });
expect(check!.net).toHaveLength(1);
expect(check!.capture).toEqual({ screenshot: "base64data" });
});
it("extracts errors from array format", () => {
const payload = {
data: {},
errors: [{ message: "timeout" }],
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any));
expect(check!.errors).toEqual([{ message: "timeout" }]);
});
it("extracts errors from nested object format", () => {
const payload = {
data: {},
errors: { errors: [{ message: "nested error" }] },
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any));
expect(check!.errors).toEqual([{ message: "nested error" }]);
});
it("handles undefined payload gracefully", () => {
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload: undefined } as any));
expect(check).toBeDefined();
expect(check!.cpu).toBeUndefined();
expect(check!.memory).toBeUndefined();
expect(check!.disk).toBeUndefined();
expect(check!.errors).toBeUndefined();
});
});
});
// ── createChecks ─────────────────────────────────────────────────────────
describe("createChecks", () => {
it("delegates to repository", async () => {
const { service, checksRepository } = createService();
const checks = [{ id: "c1" }] as any;
await service.createChecks(checks);
expect(checksRepository.createChecks).toHaveBeenCalledWith(checks);
});
});
// ── getChecksByMonitor ───────────────────────────────────────────────────
describe("getChecksByMonitor", () => {
it("returns checks from repository", async () => {
const expected = { checks: [{ id: "c1" }], count: 1 };
const { service, checksRepository } = createService();
(checksRepository.findByMonitorId as jest.Mock).mockResolvedValue(expected);
const result = await service.getChecksByMonitor({
monitorId: "mon-1",
teamId: "team-1",
sortOrder: "desc",
dateRange: "day",
page: 0,
rowsPerPage: 10,
});
expect(result).toBe(expected);
});
it("defaults page to 0 and rowsPerPage to 5 when nullish", async () => {
const { service, checksRepository } = createService();
await service.getChecksByMonitor({
monitorId: "mon-1",
teamId: "team-1",
sortOrder: "desc",
dateRange: "day",
page: null as any,
rowsPerPage: null as any,
});
expect(checksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "desc", "day", undefined, 0, 5, undefined);
});
it("passes filter and status parameters", async () => {
const { service, checksRepository } = createService();
await service.getChecksByMonitor({
monitorId: "mon-1",
teamId: "team-1",
sortOrder: "asc",
dateRange: "week",
page: 2,
rowsPerPage: 20,
filter: "error",
status: false,
});
expect(checksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "asc", "week", "error", 2, 20, false);
});
it("throws when monitorId is missing", async () => {
const { service } = createService();
await expect(
service.getChecksByMonitor({ monitorId: "", teamId: "team-1", sortOrder: "desc", dateRange: "day", page: 0, rowsPerPage: 10 })
).rejects.toThrow("No monitor ID in request");
});
it("throws when teamId is missing", async () => {
const { service } = createService();
await expect(
service.getChecksByMonitor({ monitorId: "mon-1", teamId: "", sortOrder: "desc", dateRange: "day", page: 0, rowsPerPage: 10 })
).rejects.toThrow("No team ID in request");
});
it("verifies monitor belongs to team via repository", async () => {
const { service, monitorsRepository } = createService();
await service.getChecksByMonitor({
monitorId: "mon-1",
teamId: "team-1",
sortOrder: "desc",
dateRange: "day",
page: 0,
rowsPerPage: 10,
});
expect(monitorsRepository.findById).toHaveBeenCalledWith("mon-1", "team-1");
});
});
// ── getChecksByTeam ──────────────────────────────────────────────────────
describe("getChecksByTeam", () => {
it("returns checks from repository", async () => {
const expected = { checks: [], count: 0 };
const { service, checksRepository } = createService();
(checksRepository.findByTeamId as jest.Mock).mockResolvedValue(expected);
const result = await service.getChecksByTeam({ teamId: "team-1", sortOrder: "desc", dateRange: "day", page: 0, rowsPerPage: 10 });
expect(result).toBe(expected);
});
it("defaults page to 0 and rowsPerPage to 5 when nullish", async () => {
const { service, checksRepository } = createService();
await service.getChecksByTeam({ teamId: "team-1", sortOrder: "desc", dateRange: "day", page: null as any, rowsPerPage: null as any });
expect(checksRepository.findByTeamId).toHaveBeenCalledWith("desc", "day", undefined, 0, 5, "team-1");
});
});
// ── getChecksSummaryByTeamId ──────────────────────────────────────────────
describe("getChecksSummaryByTeamId", () => {
it("delegates to repository", async () => {
const summary = { total: 5, up: 4, down: 1 };
const { service, checksRepository } = createService();
(checksRepository.findSummaryByTeamId as jest.Mock).mockResolvedValue(summary);
const result = await service.getChecksSummaryByTeamId({ teamId: "team-1", dateRange: "day" });
expect(result).toBe(summary);
expect(checksRepository.findSummaryByTeamId).toHaveBeenCalledWith("team-1", "day");
});
});
// ── deleteChecks ─────────────────────────────────────────────────────────
describe("deleteChecks", () => {
it("verifies monitor ownership and deletes", async () => {
const { service, monitorsRepository, checksRepository } = createService();
const result = await service.deleteChecks({ monitorId: "mon-1", teamId: "team-1" });
expect(monitorsRepository.findById).toHaveBeenCalledWith("mon-1", "team-1");
expect(checksRepository.deleteByMonitorId).toHaveBeenCalledWith("mon-1");
expect(result).toBe(5);
});
});
describe("deleteChecksByTeamId", () => {
it("delegates to repository", async () => {
const { service, checksRepository } = createService();
const result = await service.deleteChecksByTeamId({ teamId: "team-1" });
expect(checksRepository.deleteByTeamId).toHaveBeenCalledWith("team-1");
expect(result).toBe(10);
});
});
describe("deleteOlderThan", () => {
it("delegates to repository", async () => {
const date = new Date();
const { service, checksRepository } = createService();
const result = await service.deleteOlderThan(date);
expect(checksRepository.deleteOlderThan).toHaveBeenCalledWith(date);
expect(result).toBe(3);
});
});
});
@@ -1,472 +0,0 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { IncidentService } from "../../../src/service/business/incidentService.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from "../../../src/repositories/index.ts";
import type { INotificationMessageBuilder } from "../../../src/service/infrastructure/notificationMessageBuilder.ts";
import type { Monitor } from "../../../src/types/monitor.ts";
import type { Incident } from "../../../src/types/index.ts";
import type { MonitorActionDecision } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createIncidentsRepo = () =>
({
findActiveByMonitorId: jest.fn(),
findActiveByIncidentId: jest.fn(),
create: jest.fn(),
updateById: jest.fn(),
findById: jest.fn(),
findByTeamId: jest.fn(),
countByTeamId: jest.fn(),
findSummaryByTeamId: jest.fn(),
}) as unknown as jest.Mocked<IIncidentsRepository>;
const createMonitorsRepo = () =>
({
findById: jest.fn(),
}) as unknown as jest.Mocked<IMonitorsRepository>;
const createUsersRepo = () =>
({
findById: jest.fn(),
}) as unknown as jest.Mocked<IUsersRepository>;
const createMessageBuilder = () =>
({
extractThresholdBreaches: jest.fn(),
}) as unknown as jest.Mocked<INotificationMessageBuilder>;
const createService = (overrides?: {
logger?: ReturnType<typeof createMockLogger>;
incidentsRepository?: ReturnType<typeof createIncidentsRepo>;
monitorsRepository?: ReturnType<typeof createMonitorsRepo>;
usersRepository?: ReturnType<typeof createUsersRepo>;
notificationMessageBuilder?: ReturnType<typeof createMessageBuilder>;
}) => {
const logger = overrides?.logger ?? createMockLogger();
const incidentsRepository = overrides?.incidentsRepository ?? createIncidentsRepo();
const monitorsRepository = overrides?.monitorsRepository ?? createMonitorsRepo();
const usersRepository = overrides?.usersRepository ?? createUsersRepo();
const notificationMessageBuilder = overrides?.notificationMessageBuilder ?? createMessageBuilder();
const service = new IncidentService(logger as any, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder);
return { service, logger, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder };
};
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
name: "Test Monitor",
type: "http",
url: "https://example.com",
status: "down",
...overrides,
}) as Monitor;
const makeIncident = (overrides?: Partial<Incident>): Incident =>
({
id: "inc-1",
monitorId: "mon-1",
teamId: "team-1",
startTime: "1700000000000",
status: true,
statusCode: 500,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
}) as Incident;
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: false,
incidentReason: null,
notificationReason: null,
...overrides,
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("IncidentService", () => {
describe("serviceName", () => {
it("returns incidentService", () => {
const { service } = createService();
expect(service.serviceName).toBe("incidentService");
});
});
// ── handleIncident ───────────────────────────────────────────────────────
describe("handleIncident", () => {
it("returns null when neither create nor resolve is requested", async () => {
const { service } = createService();
const result = await service.handleIncident(makeMonitor(), 200, makeDecision());
expect(result).toBeNull();
});
it("returns existing active incident when shouldCreateIncident and one already exists", async () => {
const existing = makeIncident();
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(existing);
const result = await service.handleIncident(makeMonitor(), 500, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }));
expect(result).toBe(existing);
expect(incidentsRepository.create).not.toHaveBeenCalled();
});
it("creates a new incident when shouldCreateIncident and no active incident exists", async () => {
const created = makeIncident();
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
const result = await service.handleIncident(makeMonitor(), 500, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }));
expect(result).toBe(created);
expect(incidentsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
monitorId: "mon-1",
teamId: "team-1",
status: true,
statusCode: 500,
})
);
});
it("uses status code 9999 and builds message for threshold_breach incidents", async () => {
const created = makeIncident();
const { service, incidentsRepository, notificationMessageBuilder } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
(notificationMessageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([
{ metric: "cpu", formattedValue: "95%", threshold: 80, unit: "%" },
]);
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
await service.handleIncident(makeMonitor(), 200, decision, { monitorId: "mon-1" } as any);
expect(incidentsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: 9999,
message: "CPU: 95% (threshold: 80%)",
})
);
});
it("uses fallback message when monitorStatusResponse is undefined for threshold_breach", async () => {
const created = makeIncident();
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
await service.handleIncident(makeMonitor(), 200, decision, undefined);
expect(incidentsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ message: "Threshold breach detected" }));
});
it("uses fallback message when extractThresholdBreaches returns empty array", async () => {
const created = makeIncident();
const { service, incidentsRepository, notificationMessageBuilder } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
(notificationMessageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([]);
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
await service.handleIncident(makeMonitor(), 200, decision, { monitorId: "mon-1" } as any);
expect(incidentsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ message: "Threshold breach detected" }));
});
it("joins multiple threshold breaches with commas", async () => {
const created = makeIncident();
const { service, incidentsRepository, notificationMessageBuilder } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
(notificationMessageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([
{ metric: "cpu", formattedValue: "95%", threshold: 80, unit: "%" },
{ metric: "memory", formattedValue: "90%", threshold: 80, unit: "%" },
]);
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
await service.handleIncident(makeMonitor(), 200, decision, { monitorId: "mon-1" } as any);
expect(incidentsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ message: "CPU: 95% (threshold: 80%), MEMORY: 90% (threshold: 80%)" })
);
});
it("resolves active incident when shouldResolveIncident", async () => {
const active = makeIncident();
const resolved = makeIncident({ status: false, endTime: "123", resolutionType: "automatic" });
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(active);
(incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved);
const result = await service.handleIncident(makeMonitor(), 200, makeDecision({ shouldResolveIncident: true }));
expect(result).toBe(resolved);
expect(incidentsRepository.updateById).toHaveBeenCalledWith(
"inc-1",
"team-1",
expect.objectContaining({ status: false, resolutionType: "automatic" })
);
});
it("returns null when shouldResolveIncident but no active incident exists", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
const result = await service.handleIncident(makeMonitor(), 200, makeDecision({ shouldResolveIncident: true }));
expect(result).toBeNull();
});
});
// ── resolveIncident ──────────────────────────────────────────────────────
describe("resolveIncident", () => {
it("resolves incident with manual resolution", async () => {
const active = makeIncident();
const resolved = makeIncident({ status: false });
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(active);
(incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved);
const result = await service.resolveIncident("inc-1", "user-1", "team-1", "Fixed it", "user@test.com");
expect(result).toBe(resolved);
expect(incidentsRepository.updateById).toHaveBeenCalledWith(
"inc-1",
"team-1",
expect.objectContaining({
resolutionType: "manual",
status: false,
resolvedBy: "user-1",
resolvedByEmail: "user@test.com",
comment: "Fixed it",
})
);
});
it("sets resolvedByEmail and comment to null when not provided", async () => {
const active = makeIncident();
const resolved = makeIncident({ status: false });
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(active);
(incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved);
await service.resolveIncident("inc-1", "user-1", "team-1");
expect(incidentsRepository.updateById).toHaveBeenCalledWith(
"inc-1",
"team-1",
expect.objectContaining({
resolvedByEmail: null,
comment: null,
})
);
});
it("throws when incidentId is missing", async () => {
const { service } = createService();
await expect(service.resolveIncident("", "user-1", "team-1")).rejects.toThrow("No incident ID in request");
});
it("throws when userId is missing", async () => {
const { service } = createService();
await expect(service.resolveIncident("inc-1", "", "team-1")).rejects.toThrow("No user ID in request");
});
it("throws when teamId is missing", async () => {
const { service } = createService();
await expect(service.resolveIncident("inc-1", "user-1", "")).rejects.toThrow("No team ID in request");
});
it("throws when incident is not found", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(null);
await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toThrow("Incident not found");
});
it("throws when incident is already resolved", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(makeIncident({ status: false }));
await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toThrow("Incident is already resolved");
});
it("logs error and rethrows on unexpected failure", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockRejectedValue(new Error("db error"));
await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toThrow("db error");
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({ service: "incidentService", method: "resolveIncident", message: "db error" })
);
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockRejectedValue("string error");
await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toBe("string error");
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
// ── getIncidentsByTeam ───────────────────────────────────────────────────
describe("getIncidentsByTeam", () => {
it("returns incidents and count", async () => {
const incidents = [makeIncident()];
const { service, incidentsRepository } = createService();
(incidentsRepository.findByTeamId as jest.Mock).mockResolvedValue(incidents);
(incidentsRepository.countByTeamId as jest.Mock).mockResolvedValue(1);
const result = await service.getIncidentsByTeam("team-1", "desc", "day", 0, 20, undefined, undefined, undefined);
expect(result).toEqual({ incidents, count: 1 });
});
it("defaults page to 0 and rowsPerPage to 20 when nullish", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findByTeamId as jest.Mock).mockResolvedValue([]);
(incidentsRepository.countByTeamId as jest.Mock).mockResolvedValue(0);
await service.getIncidentsByTeam("team-1", "desc", "day", null as any, null as any, undefined, undefined, undefined);
expect(incidentsRepository.findByTeamId).toHaveBeenCalledWith("team-1", expect.anything(), 0, 20, "desc", undefined, undefined, undefined);
});
it("passes filter parameters through to repository", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findByTeamId as jest.Mock).mockResolvedValue([]);
(incidentsRepository.countByTeamId as jest.Mock).mockResolvedValue(0);
await service.getIncidentsByTeam("team-1", "asc", "week", 2, 10, true, "mon-1", "manual");
expect(incidentsRepository.findByTeamId).toHaveBeenCalledWith("team-1", expect.any(Date), 2, 10, "asc", true, "mon-1", "manual");
expect(incidentsRepository.countByTeamId).toHaveBeenCalledWith("team-1", expect.any(Date), true, "mon-1", "manual");
});
it("throws when teamId is missing", async () => {
const { service } = createService();
await expect(service.getIncidentsByTeam("", "desc", "day", 0, 20, undefined, undefined, undefined)).rejects.toThrow("No team ID in request");
});
it("logs error and rethrows on unexpected failure", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findByTeamId as jest.Mock).mockRejectedValue(new Error("db error"));
await expect(service.getIncidentsByTeam("team-1", "desc", "day", 0, 20, undefined, undefined, undefined)).rejects.toThrow("db error");
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ service: "incidentService", method: "getIncidentsByTeam" }));
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findByTeamId as jest.Mock).mockRejectedValue(42);
await expect(service.getIncidentsByTeam("team-1", "desc", "day", 0, 20, undefined, undefined, undefined)).rejects.toBe(42);
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
// ── getIncidentSummary ───────────────────────────────────────────────────
describe("getIncidentSummary", () => {
it("returns summary from repository", async () => {
const summary = { recentIncidents: [], totalIncidents: 0 };
const { service, incidentsRepository } = createService();
(incidentsRepository.findSummaryByTeamId as jest.Mock).mockResolvedValue(summary);
const result = await service.getIncidentSummary("team-1", 5);
expect(result).toBe(summary);
expect(incidentsRepository.findSummaryByTeamId).toHaveBeenCalledWith("team-1", 5);
});
it("defaults limit to 10 when not provided", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findSummaryByTeamId as jest.Mock).mockResolvedValue({});
await service.getIncidentSummary("team-1");
expect(incidentsRepository.findSummaryByTeamId).toHaveBeenCalledWith("team-1", 10);
});
it("throws when teamId is missing", async () => {
const { service } = createService();
await expect(service.getIncidentSummary("")).rejects.toThrow("No team ID in request");
});
it("logs error and rethrows on unexpected failure", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findSummaryByTeamId as jest.Mock).mockRejectedValue(new Error("db error"));
await expect(service.getIncidentSummary("team-1")).rejects.toThrow("db error");
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ service: "incidentService", method: "getIncidentSummary" }));
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findSummaryByTeamId as jest.Mock).mockRejectedValue("boom");
await expect(service.getIncidentSummary("team-1")).rejects.toBe("boom");
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
// ── getIncidentById ──────────────────────────────────────────────────────
describe("getIncidentById", () => {
it("returns incident, monitor, and user when resolvedBy exists", async () => {
const incident = makeIncident({ resolvedBy: "user-1" });
const monitor = makeMonitor();
const user = { id: "user-1", email: "user@test.com" };
const { service, incidentsRepository, monitorsRepository, usersRepository } = createService();
(incidentsRepository.findById as jest.Mock).mockResolvedValue(incident);
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(usersRepository.findById as jest.Mock).mockResolvedValue(user);
const result = await service.getIncidentById("inc-1", "team-1");
expect(result).toEqual({ incident, monitor, user });
expect(usersRepository.findById).toHaveBeenCalledWith("user-1");
});
it("returns user as null when resolvedBy is not set", async () => {
const incident = makeIncident({ resolvedBy: undefined });
const monitor = makeMonitor();
const { service, incidentsRepository, monitorsRepository, usersRepository } = createService();
(incidentsRepository.findById as jest.Mock).mockResolvedValue(incident);
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
const result = await service.getIncidentById("inc-1", "team-1");
expect(result.user).toBeNull();
expect(usersRepository.findById).not.toHaveBeenCalled();
});
it("logs error and rethrows on unexpected failure", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findById as jest.Mock).mockRejectedValue(new Error("not found"));
await expect(service.getIncidentById("inc-1", "team-1")).rejects.toThrow("not found");
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({ service: "incidentService", method: "getIncidentById", details: { incidentId: "inc-1" } })
);
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findById as jest.Mock).mockRejectedValue(null);
await expect(service.getIncidentById("inc-1", "team-1")).rejects.toBeNull();
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
});
@@ -1,347 +0,0 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { NotificationsService } from "../../../src/service/infrastructure/notificationsService.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { Notification, Monitor, MonitorStatusResponse } from "../../../src/types/index.ts";
import type { MonitorActionDecision } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createProvider = () => ({
sendMessage: jest.fn<() => Promise<boolean>>().mockResolvedValue(true),
sendTestAlert: jest.fn<() => Promise<boolean>>().mockResolvedValue(true),
});
const createNotificationsRepo = () => ({
create: jest.fn(),
findById: jest.fn(),
findNotificationsByIds: jest.fn(),
findByTeamId: jest.fn(),
updateById: jest.fn(),
deleteById: jest.fn(),
});
const createMonitorsRepo = () => ({
removeNotificationFromMonitors: jest.fn(),
});
const createSettingsService = (clientHost = "https://app.example.com") => ({
getSettings: jest.fn().mockReturnValue({ clientHost }),
});
const createMessageBuilder = () => ({
buildMessage: jest.fn().mockReturnValue({ type: "monitor_down", content: { title: "Down" } }),
extractThresholdBreaches: jest.fn(),
});
const createService = (overrides?: Record<string, unknown>) => {
const logger = createMockLogger();
const notificationsRepository = createNotificationsRepo();
const monitorsRepository = createMonitorsRepo();
const webhookProvider = createProvider();
const emailProvider = createProvider();
const slackProvider = createProvider();
const discordProvider = createProvider();
const pagerDutyProvider = createProvider();
const matrixProvider = createProvider();
const teamsProvider = createProvider();
const telegramProvider = createProvider();
const settingsService = createSettingsService();
const notificationMessageBuilder = createMessageBuilder();
const defaults = {
logger,
notificationsRepository,
monitorsRepository,
webhookProvider,
emailProvider,
slackProvider,
discordProvider,
pagerDutyProvider,
matrixProvider,
teamsProvider,
telegramProvider,
settingsService,
notificationMessageBuilder,
...overrides,
};
const service = new NotificationsService(
defaults.notificationsRepository as any,
defaults.monitorsRepository as any,
defaults.webhookProvider as any,
defaults.emailProvider as any,
defaults.slackProvider as any,
defaults.discordProvider as any,
defaults.pagerDutyProvider as any,
defaults.matrixProvider as any,
defaults.teamsProvider as any,
defaults.telegramProvider as any,
defaults.settingsService as any,
defaults.logger as any,
defaults.notificationMessageBuilder as any
);
return { service, ...defaults };
};
const makeNotification = (overrides?: Partial<Notification>): Notification =>
({
id: "notif-1",
userId: "user-1",
teamId: "team-1",
type: "email",
notificationName: "Email Alert",
address: "test@example.com",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
}) as Notification;
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
name: "Test Monitor",
type: "http",
notifications: ["notif-1"],
...overrides,
}) as Monitor;
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: true,
incidentReason: null,
notificationReason: "status_change",
...overrides,
});
const makeStatusResponse = () => ({ monitorId: "mon-1", status: false, code: 500 }) as unknown as MonitorStatusResponse;
// ── Tests ────────────────────────────────────────────────────────────────────
describe("NotificationsService", () => {
// ── handleNotifications ───────────────────────────────────────────────────
describe("handleNotifications", () => {
it("returns false when shouldSendNotification is false", async () => {
const { service } = createService();
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision({ shouldSendNotification: false }));
expect(result).toBe(false);
});
it("sends notifications to all configured providers and returns true", async () => {
const { service, notificationsRepository, emailProvider } = createService();
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type: "email" })]);
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
expect(result).toBe(true);
expect(emailProvider.sendMessage).toHaveBeenCalledTimes(1);
});
it("routes to correct provider for each notification type", async () => {
const types = ["webhook", "slack", "matrix", "pager_duty", "discord", "email", "teams", "telegram"] as const;
for (const type of types) {
const deps = createService();
(deps.notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type })]);
await deps.service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
const providerMap: Record<string, ReturnType<typeof createProvider>> = {
webhook: deps.webhookProvider,
slack: deps.slackProvider,
matrix: deps.matrixProvider,
pager_duty: deps.pagerDutyProvider,
discord: deps.discordProvider,
email: deps.emailProvider,
teams: deps.teamsProvider,
telegram: deps.telegramProvider,
};
expect(providerMap[type].sendMessage).toHaveBeenCalledTimes(1);
}
});
it("returns false and logs warning for unknown notification type", async () => {
const { service, notificationsRepository, logger } = createService();
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type: "carrier_pigeon" as any })]);
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("Unknown notification type: carrier_pigeon") })
);
});
it("returns false and logs warning when notificationMessage is undefined", async () => {
const notificationMessageBuilder = createMessageBuilder();
notificationMessageBuilder.buildMessage.mockReturnValue(undefined);
const { service, notificationsRepository, logger } = createService({ notificationMessageBuilder });
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification()]);
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Notification message not provided" }));
});
it("handles monitors with no notification IDs", async () => {
const { service, notificationsRepository } = createService();
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([]);
const result = await service.handleNotifications(makeMonitor({ notifications: undefined as any }), makeStatusResponse(), makeDecision());
expect(result).toBe(true);
expect(notificationsRepository.findNotificationsByIds).toHaveBeenCalledWith([]);
});
it("returns false and logs when some notifications fail", async () => {
const { service, notificationsRepository, emailProvider, slackProvider, logger } = createService();
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([
makeNotification({ id: "n1", type: "email" }),
makeNotification({ id: "n2", type: "slack" }),
]);
emailProvider.sendMessage.mockResolvedValue(true);
slackProvider.sendMessage.mockResolvedValue(false);
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("1 success, 1 failure") }));
});
it("uses fallback clientHost when settings.clientHost is empty", async () => {
const settingsService = createSettingsService("");
const { service, notificationsRepository, notificationMessageBuilder } = createService({ settingsService });
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification()]);
await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
expect(notificationMessageBuilder.buildMessage).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
"Host not defined"
);
});
});
// ── sendTestNotification ─────────────────────────────────────────────────
describe("sendTestNotification", () => {
it.each([["email"], ["slack"], ["discord"], ["pager_duty"], ["matrix"], ["webhook"], ["teams"], ["telegram"]] as const)(
"routes %s to the correct provider",
async (type) => {
const deps = createService();
const notification = makeNotification({ type: type as any });
const result = await deps.service.sendTestNotification(notification);
expect(result).toBe(true);
const providerMap: Record<string, ReturnType<typeof createProvider>> = {
webhook: deps.webhookProvider,
slack: deps.slackProvider,
matrix: deps.matrixProvider,
pager_duty: deps.pagerDutyProvider,
discord: deps.discordProvider,
email: deps.emailProvider,
teams: deps.teamsProvider,
telegram: deps.telegramProvider,
};
expect(providerMap[type].sendTestAlert).toHaveBeenCalledWith(notification);
}
);
it("returns false for unknown notification type", async () => {
const { service } = createService();
const result = await service.sendTestNotification(makeNotification({ type: "unknown" as any }));
expect(result).toBe(false);
});
});
// ── testAllNotifications ─────────────────────────────────────────────────
describe("testAllNotifications", () => {
it("returns true when all test alerts succeed", async () => {
const { service, notificationsRepository } = createService();
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([
makeNotification({ type: "email" }),
makeNotification({ type: "slack" }),
]);
const result = await service.testAllNotifications(["notif-1", "notif-2"]);
expect(result).toBe(true);
});
it("returns false when any test alert fails", async () => {
const { service, notificationsRepository, emailProvider } = createService();
emailProvider.sendTestAlert.mockResolvedValue(false);
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type: "email" })]);
const result = await service.testAllNotifications(["notif-1"]);
expect(result).toBe(false);
});
});
// ── CRUD operations ──────────────────────────────────────────────────────
describe("createNotification", () => {
it("sets userId and teamId and delegates to repository", async () => {
const created = makeNotification();
const { service, notificationsRepository } = createService();
(notificationsRepository.create as jest.Mock).mockResolvedValue(created);
const result = await service.createNotification({ type: "email", address: "a@b.com" }, "user-1", "team-1");
expect(result).toBe(created);
expect(notificationsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ userId: "user-1", teamId: "team-1", type: "email" }));
});
});
describe("findById", () => {
it("delegates to repository", async () => {
const notification = makeNotification();
const { service, notificationsRepository } = createService();
(notificationsRepository.findById as jest.Mock).mockResolvedValue(notification);
const result = await service.findById("notif-1", "team-1");
expect(result).toBe(notification);
});
});
describe("findNotificationsByTeamId", () => {
it("delegates to repository", async () => {
const notifications = [makeNotification()];
const { service, notificationsRepository } = createService();
(notificationsRepository.findByTeamId as jest.Mock).mockResolvedValue(notifications);
const result = await service.findNotificationsByTeamId("team-1");
expect(result).toBe(notifications);
});
});
describe("updateById", () => {
it("delegates to repository", async () => {
const updated = makeNotification({ address: "new@example.com" });
const { service, notificationsRepository } = createService();
(notificationsRepository.updateById as jest.Mock).mockResolvedValue(updated);
const result = await service.updateById("notif-1", "team-1", { address: "new@example.com" });
expect(result).toBe(updated);
});
});
describe("deleteById", () => {
it("deletes notification and removes from monitors", async () => {
const deleted = makeNotification();
const { service, notificationsRepository, monitorsRepository } = createService();
(notificationsRepository.deleteById as jest.Mock).mockResolvedValue(deleted);
const result = await service.deleteById("notif-1", "team-1");
expect(result).toBe(deleted);
expect(monitorsRepository.removeNotificationFromMonitors).toHaveBeenCalledWith("notif-1");
});
});
});
@@ -1,889 +0,0 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { StatusService } from "../../../src/service/infrastructure/statusService.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { Monitor, MonitorStatusResponse, Check, HardwareStatusPayload } from "../../../src/types/index.ts";
import type { IMonitorsRepository, IMonitorStatsRepository, IChecksRepository } from "../../../src/repositories/index.ts";
import type { IBufferService } from "../../../src/service/infrastructure/bufferService.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createBuffer = (): jest.Mocked<Pick<IBufferService, "addToBuffer">> => ({
addToBuffer: jest.fn(),
});
const createMonitorsRepo = () =>
({
findById: jest.fn(),
updateById: jest.fn(),
}) as unknown as jest.Mocked<IMonitorsRepository>;
const createMonitorStatsRepo = () =>
({
findByMonitorId: jest.fn(),
create: jest.fn(),
updateByMonitorId: jest.fn(),
}) as unknown as jest.Mocked<IMonitorStatsRepository>;
const createChecksRepo = () => ({}) as unknown as jest.Mocked<IChecksRepository>;
const createService = (overrides?: {
logger?: ReturnType<typeof createMockLogger>;
buffer?: ReturnType<typeof createBuffer>;
monitorsRepository?: ReturnType<typeof createMonitorsRepo>;
monitorStatsRepository?: ReturnType<typeof createMonitorStatsRepo>;
checksRepository?: ReturnType<typeof createChecksRepo>;
}) => {
const logger = overrides?.logger ?? createMockLogger();
const buffer = overrides?.buffer ?? createBuffer();
const monitorsRepository = overrides?.monitorsRepository ?? createMonitorsRepo();
const monitorStatsRepository = overrides?.monitorStatsRepository ?? createMonitorStatsRepo();
const checksRepository = overrides?.checksRepository ?? createChecksRepo();
const service = new StatusService(logger as any, buffer as any, monitorsRepository, monitorStatsRepository, checksRepository);
return { service, logger, buffer, monitorsRepository, monitorStatsRepository, checksRepository };
};
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
userId: "user-1",
teamId: "team-1",
name: "Test Monitor",
type: "http",
url: "https://example.com",
isActive: true,
interval: 60000,
status: "up",
statusWindow: [],
statusWindowSize: 5,
statusWindowThreshold: 80,
recentChecks: [],
cpuAlertThreshold: 80,
memoryAlertThreshold: 80,
diskAlertThreshold: 80,
tempAlertThreshold: 80,
cpuAlertCounter: 5,
memoryAlertCounter: 5,
diskAlertCounter: 5,
tempAlertCounter: 5,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
}) as Monitor;
const makeStatusResponse = (overrides?: Partial<MonitorStatusResponse>): MonitorStatusResponse =>
({
monitorId: "mon-1",
teamId: "team-1",
type: "http",
status: true,
code: 200,
message: "OK",
responseTime: 100,
...overrides,
}) as MonitorStatusResponse;
const makeCheck = (overrides?: Partial<Check>): Check =>
({
id: "check-1",
metadata: { monitorId: "mon-1", teamId: "team-1" },
status: true,
responseTime: 100,
statusCode: 200,
message: "OK",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
}) as Check;
const makeExistingStats = (overrides?: Record<string, unknown>) => ({
id: "stats-1",
monitorId: "mon-1",
avgResponseTime: 100,
maxResponseTime: 200,
totalChecks: 10,
totalUpChecks: 9,
totalDownChecks: 1,
uptimePercentage: 0.9,
lastResponseTime: 90,
lastCheckTimestamp: 1000,
timeOfLastFailure: 500,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("StatusService", () => {
describe("serviceName", () => {
it("returns StatusService", () => {
const { service } = createService();
expect(service.serviceName).toBe("StatusService");
});
});
// ── updateRunningStats ───────────────────────────────────────────────────
describe("updateRunningStats", () => {
it("creates new stats when none exist", async () => {
const { service, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockRejectedValue(new Error("not found"));
(monitorStatsRepository.create as jest.Mock).mockResolvedValue({});
const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 50 }));
expect(result).toBe(true);
expect(monitorStatsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
monitorId: "mon-1",
totalChecks: 1,
totalUpChecks: 1,
avgResponseTime: 50,
lastResponseTime: 50,
})
);
});
it("updates existing stats for a successful check", async () => {
const { service, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats());
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 110 }));
expect(result).toBe(true);
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith(
"mon-1",
expect.objectContaining({
totalChecks: 11,
totalUpChecks: 10,
totalDownChecks: 1,
})
);
});
it("increments totalDownChecks and resets timeOfLastFailure on failure", async () => {
const { service, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats());
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ status: false, responseTime: 100 }));
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith(
"mon-1",
expect.objectContaining({
totalDownChecks: 2,
timeOfLastFailure: 0,
})
);
});
it("sets timeOfLastFailure when status is up and it was previously 0", async () => {
const { service, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ timeOfLastFailure: 0 }));
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ status: true }));
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith(
"mon-1",
expect.objectContaining({
timeOfLastFailure: expect.any(Number),
})
);
const call = (monitorStatsRepository.updateByMonitorId as jest.Mock).mock.calls[0] as [string, Record<string, unknown>];
expect(call[1].timeOfLastFailure).toBeGreaterThan(0);
});
it("updates maxResponseTime when new response is higher", async () => {
const { service, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ maxResponseTime: 200 }));
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 300 }));
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ maxResponseTime: 300 }));
});
it("does not update maxResponseTime when new response is lower", async () => {
const { service, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ maxResponseTime: 200 }));
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 50 }));
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ maxResponseTime: 200 }));
});
it("initializes avgResponseTime when previous avg was 0", async () => {
const { service, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ avgResponseTime: 0 }));
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 75 }));
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ avgResponseTime: 75 }));
});
it("computes running average when avgResponseTime is nonzero", async () => {
const { service, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ avgResponseTime: 100, totalChecks: 10 }));
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 210 }));
const call = (monitorStatsRepository.updateByMonitorId as jest.Mock).mock.calls[0] as [string, Record<string, unknown>];
// (100 * 10 + 210) / 11 = 110
expect(call[1].avgResponseTime).toBe(110);
});
it("leaves avgResponseTime unchanged when responseTime is undefined", async () => {
const { service, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ avgResponseTime: 100 }));
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: undefined }));
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ avgResponseTime: 100 }));
});
it("handles responseTime of 0 (falsy but defined)", async () => {
const { service, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ avgResponseTime: 100, maxResponseTime: 200 }));
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 0 }));
const call = (monitorStatsRepository.updateByMonitorId as jest.Mock).mock.calls[0] as [string, Record<string, unknown>];
expect(call[1].lastResponseTime).toBe(0);
// responseTime 0 is falsy, so maxResponseTime stays unchanged
expect(call[1].maxResponseTime).toBe(200);
});
it("returns false and logs error when an exception is thrown", async () => {
const { service, logger, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats());
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockRejectedValue(new Error("db write failed"));
const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse());
expect(result).toBe(false);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
service: "StatusService",
method: "updateRunningStats",
message: "db write failed",
})
);
});
it("logs error with 'Unknown error' for non-Error exceptions", async () => {
const { service, logger, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats());
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockRejectedValue("string error");
const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse());
expect(result).toBe(false);
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
it("creates new stats when findByMonitorId resolves to null", async () => {
const { service, monitorStatsRepository } = createService();
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(null);
(monitorStatsRepository.create as jest.Mock).mockResolvedValue({});
const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 50, status: true }));
expect(result).toBe(true);
expect(monitorStatsRepository.create).toHaveBeenCalled();
});
});
// ── updateMonitorStatus ──────────────────────────────────────────────────
describe("updateMonitorStatus", () => {
it("returns early with no status change when statusWindow is not full", async () => {
const monitor = makeMonitor({ statusWindow: [], statusWindowSize: 5, status: "up" });
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck());
expect(result.statusChanged).toBe(false);
expect(result.prevStatus).toBe("up");
});
it("pushes to statusWindow and trims to statusWindowSize", async () => {
const monitor = makeMonitor({ statusWindow: [true, true, true, true, true], statusWindowSize: 5 });
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck());
// Window should have shifted: [true, true, true, true, false]
expect(monitor.statusWindow).toHaveLength(5);
expect(monitor.statusWindow[4]).toBe(false);
});
it("pushes check snapshot to recentChecks and trims to 25", async () => {
const existingChecks = Array.from({ length: 25 }, (_, i) => ({ id: `old-${i}` }));
const monitor = makeMonitor({ recentChecks: existingChecks as any, statusWindow: [], statusWindowSize: 5 });
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
await service.updateMonitorStatus(makeStatusResponse(), makeCheck({ id: "new-check" }));
expect(monitor.recentChecks).toHaveLength(25);
expect(monitor.recentChecks[24]).toEqual(expect.objectContaining({ id: "new-check" }));
});
it("marks status as down when failure threshold is met", async () => {
// 5/5 failures = 100% >= 80% threshold
const monitor = makeMonitor({
statusWindow: [false, false, false, false],
statusWindowSize: 5,
statusWindowThreshold: 80,
status: "up",
});
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }));
expect(result.statusChanged).toBe(true);
expect(result.monitor.status).toBe("down");
});
it("recovers to up when failure rate drops below threshold", async () => {
// 1/5 failures = 20% < 80% threshold, and monitor was down
const monitor = makeMonitor({
statusWindow: [true, true, true, false],
statusWindowSize: 5,
statusWindowThreshold: 80,
status: "down",
});
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true }));
expect(result.statusChanged).toBe(true);
expect(result.monitor.status).toBe("up");
});
it("does not change status when already up and below threshold", async () => {
const monitor = makeMonitor({
statusWindow: [true, true, true, true],
statusWindowSize: 5,
statusWindowThreshold: 80,
status: "up",
});
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck());
expect(result.statusChanged).toBe(false);
expect(result.monitor.status).toBe("up");
});
it("does not change status when already down and still above threshold", async () => {
const monitor = makeMonitor({
statusWindow: [false, false, false, false],
statusWindowSize: 5,
statusWindowThreshold: 80,
status: "down",
});
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }));
expect(result.statusChanged).toBe(false);
expect(result.monitor.status).toBe("down");
});
it("initializes statusWindow when undefined", async () => {
const monitor = makeMonitor({ statusWindow: undefined as any, statusWindowSize: 5 });
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
await service.updateMonitorStatus(makeStatusResponse(), makeCheck());
expect(monitor.statusWindow).toEqual([true]);
});
it("initializes recentChecks when undefined", async () => {
const monitor = makeMonitor({ recentChecks: undefined as any, statusWindowSize: 5 });
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
await service.updateMonitorStatus(makeStatusResponse(), makeCheck());
expect(monitor.recentChecks).toHaveLength(1);
});
it("throws AppError when repository throws", async () => {
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockRejectedValue(new Error("db error"));
await expect(service.updateMonitorStatus(makeStatusResponse(), makeCheck())).rejects.toThrow("Failed to update monitor");
});
it("throws AppError with 'Unknown error' for non-Error exceptions", async () => {
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockRejectedValue("string error");
await expect(service.updateMonitorStatus(makeStatusResponse(), makeCheck())).rejects.toThrow("Unknown error");
});
it("returns code and timestamp in result", async () => {
const monitor = makeMonitor({ statusWindow: [true, true, true, true], statusWindowSize: 5 });
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse({ code: 201 }), makeCheck());
expect(result.code).toBe(201);
expect(result.timestamp).toBeGreaterThan(0);
});
// ── Hardware threshold breach tests ──────────────────────────────────
describe("hardware threshold breaches", () => {
const makeHardwareMonitor = (overrides?: Partial<Monitor>) =>
makeMonitor({
type: "hardware",
statusWindow: [true, true, true, true],
statusWindowSize: 5,
status: "up",
cpuAlertThreshold: 80,
memoryAlertThreshold: 80,
diskAlertThreshold: 80,
tempAlertThreshold: 80,
cpuAlertCounter: 5,
memoryAlertCounter: 5,
diskAlertCounter: 5,
tempAlertCounter: 5,
...overrides,
});
const makeHardwareResponse = (payload: HardwareStatusPayload, overrides?: Partial<MonitorStatusResponse<HardwareStatusPayload>>) =>
makeStatusResponse({
type: "hardware",
status: true,
payload,
...overrides,
} as any) as MonitorStatusResponse<HardwareStatusPayload>;
it("detects CPU threshold breach and decrements counter", async () => {
const monitor = makeHardwareMonitor({ cpuAlertCounter: 1 });
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.5 }, disk: [], host: {} } } as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.cpu).toBe(true);
expect(result.thresholdBreaches?.memory).toBe(false);
expect(monitor.cpuAlertCounter).toBe(0);
expect(result.statusChanged).toBe(true);
expect(result.monitor.status).toBe("breached");
});
it("detects memory threshold breach", async () => {
const monitor = makeHardwareMonitor({ memoryAlertCounter: 1 });
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.5 }, memory: { usage_percent: 0.9 }, disk: [], host: {} } } as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.memory).toBe(true);
expect(monitor.memoryAlertCounter).toBe(0);
expect(result.monitor.status).toBe("breached");
});
it("detects disk threshold breach", async () => {
const monitor = makeHardwareMonitor({ diskAlertCounter: 1 });
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.95 }], host: {} },
} as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.disk).toBe(true);
expect(monitor.diskAlertCounter).toBe(0);
expect(result.monitor.status).toBe("breached");
});
it("detects temperature threshold breach", async () => {
const monitor = makeHardwareMonitor({ tempAlertCounter: 1 });
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
data: { cpu: { usage_percent: 0.1, temperature: [90] }, memory: { usage_percent: 0.1 }, disk: [], host: {} },
} as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.temp).toBe(true);
expect(monitor.tempAlertCounter).toBe(0);
expect(result.monitor.status).toBe("breached");
});
it("resets counters to 5 when thresholds are not breached", async () => {
const monitor = makeHardwareMonitor({
cpuAlertCounter: 2,
memoryAlertCounter: 2,
diskAlertCounter: 2,
tempAlertCounter: 2,
});
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
data: { cpu: { usage_percent: 0.1, temperature: [30] }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.1 }], host: {} },
} as any);
await service.updateMonitorStatus(response, makeCheck());
expect(monitor.cpuAlertCounter).toBe(5);
expect(monitor.memoryAlertCounter).toBe(5);
expect(monitor.diskAlertCounter).toBe(5);
expect(monitor.tempAlertCounter).toBe(5);
});
it("stays breached without statusChanged when already breached and counter still at 0", async () => {
const monitor = makeHardwareMonitor({
status: "breached",
cpuAlertCounter: 1,
});
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} } } as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.monitor.status).toBe("breached");
expect(result.statusChanged).toBe(false);
});
it("recovers from breached to up when all thresholds return to normal", async () => {
const monitor = makeHardwareMonitor({ status: "breached" });
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
data: { cpu: { usage_percent: 0.1, temperature: [30] }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.1 }], host: {} },
} as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.statusChanged).toBe(true);
expect(result.monitor.status).toBe("up");
});
it("does not override down status with breached", async () => {
// Monitor is down due to failure threshold, hardware breaches should not override
const monitor = makeHardwareMonitor({
statusWindow: [false, false, false, false],
statusWindowSize: 5,
statusWindowThreshold: 80,
status: "up",
cpuAlertCounter: 1,
});
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
status: false,
data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} },
} as any);
const result = await service.updateMonitorStatus(response, makeCheck({ status: false }));
expect(result.monitor.status).toBe("down");
});
it("handles missing cpu usage_percent (returns -1)", async () => {
const monitor = makeHardwareMonitor();
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ data: { cpu: {}, memory: { usage_percent: 0.1 }, disk: [], host: {} } } as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.cpu).toBe(false);
});
it("handles missing memory usage_percent", async () => {
const monitor = makeHardwareMonitor();
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.1 }, memory: {}, disk: [], host: {} } } as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.memory).toBe(false);
});
it("handles empty temperature array", async () => {
const monitor = makeHardwareMonitor();
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
data: { cpu: { usage_percent: 0.1, temperature: [] }, memory: { usage_percent: 0.1 }, disk: [], host: {} },
} as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.temp).toBe(false);
});
it("handles undefined temperature", async () => {
const monitor = makeHardwareMonitor();
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [], host: {} },
} as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.temp).toBe(false);
});
it("skips threshold evaluation when payload is undefined", async () => {
const monitor = makeHardwareMonitor();
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse(undefined as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches).toBeUndefined();
});
it("skips threshold evaluation when payload.data is undefined", async () => {
const monitor = makeHardwareMonitor();
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({} as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches).toBeUndefined();
});
it("does not set thresholdBreaches for non-hardware monitors", async () => {
const monitor = makeMonitor({
type: "http",
statusWindow: [true, true, true, true],
statusWindowSize: 5,
});
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const result = await service.updateMonitorStatus(makeStatusResponse(), makeCheck());
expect(result.thresholdBreaches).toBeUndefined();
});
it("handles null entry in disk array", async () => {
const monitor = makeHardwareMonitor();
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [null as any], host: {} },
} as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.disk).toBe(false);
});
it("handles disk with undefined usage_percent entries", async () => {
const monitor = makeHardwareMonitor();
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [{ device: "/dev/sda" }], host: {} },
} as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.disk).toBe(false);
});
it("handles nullish cpu in metrics", async () => {
const monitor = makeHardwareMonitor();
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
data: { cpu: undefined, memory: { usage_percent: 0.1 }, disk: [], host: {} },
} as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.cpu).toBe(false);
expect(result.thresholdBreaches?.temp).toBe(false);
});
it("handles nullish memory in metrics", async () => {
const monitor = makeHardwareMonitor();
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
data: { cpu: { usage_percent: 0.1 }, memory: undefined, disk: [], host: {} },
} as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.memory).toBe(false);
});
it("handles nullish disk in metrics", async () => {
const monitor = makeHardwareMonitor();
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: undefined, host: {} },
} as any);
const result = await service.updateMonitorStatus(response, makeCheck());
expect(result.thresholdBreaches?.disk).toBe(false);
});
it("counters do not go below 0", async () => {
const monitor = makeHardwareMonitor({
cpuAlertCounter: 0,
memoryAlertCounter: 0,
diskAlertCounter: 0,
tempAlertCounter: 0,
});
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
const response = makeHardwareResponse({
data: { cpu: { usage_percent: 0.9, temperature: [90] }, memory: { usage_percent: 0.9 }, disk: [{ usage_percent: 0.95 }], host: {} },
} as any);
await service.updateMonitorStatus(response, makeCheck());
expect(monitor.cpuAlertCounter).toBe(0);
expect(monitor.memoryAlertCounter).toBe(0);
expect(monitor.diskAlertCounter).toBe(0);
expect(monitor.tempAlertCounter).toBe(0);
});
});
});
// ── 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 }));
});
});
});
@@ -1,564 +0,0 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { Monitor } from "../../../src/types/monitor.ts";
// ── Mock Scheduler ───────────────────────────────────────────────────────────
const createScheduler = () => {
const listeners: Record<string, Function[]> = {};
return {
on: jest.fn((event: string, cb: Function) => {
listeners[event] = listeners[event] || [];
listeners[event].push(cb);
}),
emit: (event: string, ...args: unknown[]) => {
(listeners[event] || []).forEach((cb) => cb(...args));
},
start: jest.fn(),
stop: jest.fn().mockResolvedValue(true),
addTemplate: jest.fn(),
addJob: jest.fn(),
removeJob: jest.fn(),
getJob: jest.fn().mockResolvedValue(null),
getJobs: jest.fn().mockResolvedValue([]),
pauseJob: jest.fn().mockResolvedValue(true),
resumeJob: jest.fn().mockResolvedValue(true),
updateJob: jest.fn(),
flushJobs: jest.fn().mockResolvedValue(true),
};
};
const mockSchedulerInstance = createScheduler();
const MockScheduler = jest.fn().mockReturnValue(mockSchedulerInstance);
jest.unstable_mockModule("super-simple-scheduler", () => ({
default: MockScheduler,
}));
const { SuperSimpleQueue } = await import("../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts");
const createQueueHelper = () => ({
getHeartbeatJob: jest.fn().mockReturnValue(() => Promise.resolve()),
getHeartbeatGeoJob: jest.fn().mockReturnValue(() => Promise.resolve()),
getCleanupOrphanedJob: jest.fn().mockReturnValue(() => Promise.resolve()),
getCleanupRetentionJob: jest.fn().mockReturnValue(() => Promise.resolve()),
});
const createMonitorsRepo = () => ({
findAll: jest.fn().mockResolvedValue([]),
});
const createQueue = (overrides?: Record<string, unknown>) => {
const logger = createMockLogger();
const helper = createQueueHelper();
const monitorsRepository = createMonitorsRepo();
const scheduler = createScheduler();
const defaults = { logger, helper, monitorsRepository, scheduler, ...overrides };
const queue = new SuperSimpleQueue(defaults.logger as any, defaults.helper as any, defaults.monitorsRepository as any, defaults.scheduler as any);
return { queue, ...defaults };
};
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "http",
interval: 60000,
isActive: true,
geoCheckEnabled: false,
geoCheckInterval: 300000,
geoCheckLocations: [],
...overrides,
}) as Monitor;
// ── Tests ────────────────────────────────────────────────────────────────────
describe("SuperSimpleQueue", () => {
describe("serviceName", () => {
it("returns JobQueue", () => {
const { queue } = createQueue();
expect(queue.serviceName).toBe("JobQueue");
});
});
// ── init ─────────────────────────────────────────────────────────────────
describe("init", () => {
it("registers listeners, starts scheduler, adds templates and system jobs", async () => {
const { queue, scheduler, helper } = createQueue();
const result = await queue.init();
expect(result).toBe(true);
expect(scheduler.start).toHaveBeenCalled();
expect(scheduler.addTemplate).toHaveBeenCalledWith("monitor-job", expect.any(Function));
expect(scheduler.addTemplate).toHaveBeenCalledWith("geo-check-job", expect.any(Function));
expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-orphaned", expect.any(Function));
expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-retention-job", expect.any(Function));
expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-orphaned" }));
expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-retention" }));
});
it("schedules existing monitors with staggered offsets", async () => {
jest.useFakeTimers();
const monitors = [makeMonitor({ id: "m1" }), makeMonitor({ id: "m2" })];
const { queue, scheduler, monitorsRepository } = createQueue();
(monitorsRepository.findAll as jest.Mock).mockResolvedValue(monitors);
await queue.init();
jest.runAllTimers();
// Each monitor gets addJob called (once for the monitor itself via addJob method)
// The init schedules via setTimeout → addJob, plus the two system jobs
expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "m1", template: "monitor-job" }));
expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "m2", template: "monitor-job" }));
jest.useRealTimers();
});
it("returns true when findAll returns null", async () => {
const { queue, monitorsRepository, scheduler } = createQueue();
(monitorsRepository.findAll as jest.Mock).mockResolvedValue(null);
const result = await queue.init();
expect(result).toBe(true);
// System jobs should not be added when monitors is null (early return)
expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-orphaned" }));
});
it("returns false and logs error on failure", async () => {
const { queue, logger, monitorsRepository } = createQueue();
(monitorsRepository.findAll as jest.Mock).mockRejectedValue(new Error("db down"));
const result = await queue.init();
expect(result).toBe(false);
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("db down") }));
});
it("logs error with String(error) for non-Error exceptions", async () => {
const { queue, logger, monitorsRepository } = createQueue();
(monitorsRepository.findAll as jest.Mock).mockRejectedValue("string error");
const result = await queue.init();
expect(result).toBe(false);
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("string error") }));
});
});
// ── registerListeners ────────────────────────────────────────────────────
describe("registerListeners (via init)", () => {
it("logs on scheduler:start", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.init();
scheduler.emit("scheduler:start");
expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler started" }));
});
it("logs on scheduler:stop", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.init();
scheduler.emit("scheduler:stop");
expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler stopped" }));
});
it("logs on scheduler:error with Error", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.init();
scheduler.emit("scheduler:error", new Error("boom"));
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler error: boom" }));
});
it("logs on scheduler:error with non-Error", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.init();
scheduler.emit("scheduler:error", "string error");
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler error: string error", stack: undefined }));
});
it("logs on job:abort", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.init();
scheduler.emit("job:abort", { id: "m1" }, "timeout");
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 aborted: timeout" }));
});
it("logs on job:attempt", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.init();
scheduler.emit("job:attempt", { id: "m1" }, 2);
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 attempt 2" }));
});
it("logs on job:complete", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.init();
scheduler.emit("job:complete", { id: "m1" });
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 completed successfully" }));
});
it("logs on job:exhausted with Error", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.init();
scheduler.emit("job:exhausted", { id: "m1" }, new Error("gave up"));
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 exhausted all retries: gave up" }));
});
it("logs on job:exhausted with non-Error", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.init();
scheduler.emit("job:exhausted", { id: "m1" }, "gave up");
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 exhausted all retries: gave up", stack: undefined }));
});
it("logs on job:fail with Error", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.init();
scheduler.emit("job:fail", { id: "m1" }, new Error("oops"), 1);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 failed on attempt 1: oops" }));
});
it("logs on job:fail with non-Error", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.init();
scheduler.emit("job:fail", { id: "m1" }, "oops", 1);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 failed on attempt 1: oops", stack: undefined }));
});
it("logs on job:start", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.init();
scheduler.emit("job:start", { id: "m1" });
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 started" }));
});
});
// ── addJob ───────────────────────────────────────────────────────────────
describe("addJob", () => {
it("adds monitor job to scheduler", async () => {
const { queue, scheduler } = createQueue();
await queue.addJob("mon-1", makeMonitor());
expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1", template: "monitor-job", repeat: 60000, active: true }));
});
it("adds geo check job when geoCheckEnabled and type supports it", async () => {
const { queue, scheduler } = createQueue();
await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 }));
expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1-geo", template: "geo-check-job", repeat: 300000 }));
});
it("skips geo job when type does not support geo checks", async () => {
const { queue, scheduler } = createQueue();
await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: true, type: "hardware" }));
expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1-geo" }));
});
it("skips geo job when geoCheckEnabled is false", async () => {
const { queue, scheduler } = createQueue();
await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: false, type: "http" }));
expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1-geo" }));
});
});
// ── deleteJob ────────────────────────────────────────────────────────────
describe("deleteJob", () => {
it("removes monitor job", async () => {
const { queue, scheduler } = createQueue();
await queue.deleteJob(makeMonitor());
expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1");
});
it("removes geo job if it exists", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
await queue.deleteJob(makeMonitor());
expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo");
});
it("does not remove geo job if it does not exist", async () => {
const { queue, scheduler } = createQueue();
await queue.deleteJob(makeMonitor());
expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo");
});
});
// ── pauseJob ─────────────────────────────────────────────────────────────
describe("pauseJob", () => {
it("pauses monitor and removes geo job if it exists", async () => {
const { queue, scheduler, logger } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
await queue.pauseJob(makeMonitor());
expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1");
expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo");
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Paused monitor mon-1" }));
});
it("does not remove geo job if it does not exist", async () => {
const { queue, scheduler } = createQueue();
await queue.pauseJob(makeMonitor());
expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo");
});
it("throws when scheduler.pauseJob returns false", async () => {
const { queue, scheduler } = createQueue();
(scheduler.pauseJob as jest.Mock).mockResolvedValue(false);
await expect(queue.pauseJob(makeMonitor())).rejects.toThrow("Failed to pause monitor");
});
});
// ── resumeJob ────────────────────────────────────────────────────────────
describe("resumeJob", () => {
it("resumes monitor and geo job", async () => {
const { queue, scheduler, logger } = createQueue();
await queue.resumeJob(makeMonitor());
expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1");
expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1-geo");
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Resumed monitor mon-1" }));
});
it("throws when scheduler.resumeJob returns false", async () => {
const { queue, scheduler } = createQueue();
(scheduler.resumeJob as jest.Mock).mockResolvedValue(false);
await expect(queue.resumeJob(makeMonitor())).rejects.toThrow("Failed to resume monitor");
});
});
// ── updateJob ────────────────────────────────────────────────────────────
describe("updateJob", () => {
it("updates monitor job and syncs geo job", async () => {
const { queue, scheduler } = createQueue();
const monitor = makeMonitor({ interval: 120000 });
await queue.updateJob(monitor);
expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", expect.objectContaining({ repeat: 120000, data: monitor }));
});
});
// ── syncGeoJob (via updateJob) ───────────────────────────────────────────
describe("syncGeoJob (via updateJob)", () => {
it("removes existing geo job when geoCheckEnabled is false", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
await queue.updateJob(makeMonitor({ geoCheckEnabled: false, type: "http" }));
expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo");
});
it("removes existing geo job when type does not support geo checks", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
await queue.updateJob(makeMonitor({ geoCheckEnabled: true, type: "hardware" }));
expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo");
});
it("does not remove when geo disabled and no existing geo job", async () => {
const { queue, scheduler } = createQueue();
await queue.updateJob(makeMonitor({ geoCheckEnabled: false }));
expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo");
});
it("updates existing geo job when geoCheckEnabled", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
const monitor = makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 });
await queue.updateJob(monitor);
expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1-geo", expect.objectContaining({ repeat: 300000 }));
});
it("creates new geo job when enabled but no existing job", async () => {
const { queue, scheduler } = createQueue();
const monitor = makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 });
await queue.updateJob(monitor);
expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1-geo", template: "geo-check-job" }));
});
});
// ── shutdown ──────────────────────────────────────────────────────────────
describe("shutdown", () => {
it("stops the scheduler", async () => {
const { queue, scheduler } = createQueue();
await queue.shutdown();
expect(scheduler.stop).toHaveBeenCalled();
});
});
// ── getMetrics ───────────────────────────────────────────────────────────
describe("getMetrics", () => {
it("returns empty metrics when no jobs", async () => {
const { queue } = createQueue();
const metrics = await queue.getMetrics();
expect(metrics).toEqual({ jobs: 0, activeJobs: 0, failingJobs: 0, jobsWithFailures: [], totalRuns: 0, totalFailures: 0 });
});
it("aggregates metrics from jobs", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJobs as jest.Mock).mockResolvedValue([
{ id: "m1", runCount: 10, failCount: 2, lastFailedAt: 200, lastRunAt: 100, lockedAt: null, data: { url: "http://a.com", type: "http" } },
{ id: "m2", runCount: 5, failCount: 0, lastFailedAt: 0, lastRunAt: 50, lockedAt: 999, data: { url: "http://b.com", type: "ping" } },
]);
const metrics = await queue.getMetrics();
expect(metrics.jobs).toBe(2);
expect(metrics.totalRuns).toBe(15);
expect(metrics.totalFailures).toBe(2);
expect(metrics.activeJobs).toBe(1);
expect(metrics.failingJobs).toBe(1);
expect(metrics.jobsWithFailures).toHaveLength(1);
expect(metrics.jobsWithFailures[0]).toEqual(expect.objectContaining({ monitorId: "m1", failCount: 2, monitorUrl: "http://a.com" }));
});
it("handles jobs with undefined fields", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJobs as jest.Mock).mockResolvedValue([{ id: "m1", data: null }]);
const metrics = await queue.getMetrics();
expect(metrics.jobs).toBe(1);
expect(metrics.totalRuns).toBe(0);
});
it("handles failing job with null data", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJobs as jest.Mock).mockResolvedValue([
{ id: "m1", runCount: 1, failCount: 1, lastFailedAt: 100, lastRunAt: 50, data: null, lastFailReason: "timeout" },
]);
const metrics = await queue.getMetrics();
expect(metrics.jobsWithFailures).toHaveLength(1);
expect(metrics.jobsWithFailures[0].monitorUrl).toBeNull();
expect(metrics.jobsWithFailures[0].monitorType).toBeNull();
expect(metrics.jobsWithFailures[0].failReason).toBe("timeout");
});
it("handles failing job with undefined lastFailedAt and lastFailReason", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJobs as jest.Mock).mockResolvedValue([{ id: "m1", runCount: 1, failCount: 1, data: { url: "http://a.com", type: "http" } }]);
const metrics = await queue.getMetrics();
expect(metrics.jobsWithFailures[0].failedAt).toBeNull();
expect(metrics.jobsWithFailures[0].failReason).toBeNull();
});
it("does not count as failing when lastFailedAt < lastRunAt", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJobs as jest.Mock).mockResolvedValue([
{ id: "m1", runCount: 10, failCount: 1, lastFailedAt: 50, lastRunAt: 100, data: { url: "a" } },
]);
const metrics = await queue.getMetrics();
expect(metrics.failingJobs).toBe(0);
expect(metrics.jobsWithFailures).toHaveLength(1);
});
});
// ── getJobs ──────────────────────────────────────────────────────────────
describe("getJobs", () => {
it("maps scheduler jobs to summaries", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJobs as jest.Mock).mockResolvedValue([
{
id: "m1",
active: true,
lockedAt: null,
runCount: 5,
failCount: 1,
lastFailReason: "timeout",
lastRunAt: 1000,
lastFinishedAt: 1100,
lastFailedAt: 900,
data: { url: "http://a.com", type: "http", interval: 60000 },
},
]);
const jobs = await queue.getJobs();
expect(jobs).toHaveLength(1);
expect(jobs[0]).toEqual({
monitorId: "m1",
monitorUrl: "http://a.com",
monitorType: "http",
monitorInterval: 60000,
active: true,
lockedAt: null,
runCount: 5,
failCount: 1,
failReason: "timeout",
lastRunAt: 1000,
lastFinishedAt: 1100,
lastRunTook: 100,
lastFailedAt: 900,
});
});
it("returns null lastRunTook when job is locked", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJobs as jest.Mock).mockResolvedValue([
{ id: "m1", active: true, lockedAt: 999, runCount: 1, data: { url: "a", type: "http", interval: 60000 } },
]);
const jobs = await queue.getJobs();
expect(jobs[0].lastRunTook).toBeNull();
});
it("handles jobs with missing data", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJobs as jest.Mock).mockResolvedValue([{ id: "m1", active: true, data: null }]);
const jobs = await queue.getJobs();
expect(jobs[0].monitorUrl).toBeNull();
expect(jobs[0].monitorType).toBeNull();
expect(jobs[0].monitorInterval).toBeNull();
});
});
// ── flushQueues ──────────────────────────────────────────────────────────
describe("flushQueues", () => {
it("stops, flushes, and reinitializes", async () => {
const { queue, scheduler } = createQueue();
const result = await queue.flushQueues();
expect(scheduler.stop).toHaveBeenCalled();
expect(scheduler.flushJobs).toHaveBeenCalled();
expect(result.success).toBe(true);
});
it("returns false when any step fails", async () => {
const { queue, scheduler } = createQueue();
(scheduler.flushJobs as jest.Mock).mockResolvedValue(false);
const result = await queue.flushQueues();
expect(result.success).toBe(false);
});
});
// ── 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", () => {
it("creates a SuperSimpleQueue instance with a Scheduler and calls init", async () => {
const logger = createMockLogger();
const helper = createQueueHelper();
const monitorsRepository = createMonitorsRepo();
const instance = await SuperSimpleQueue.create(logger as any, helper as any, monitorsRepository as any);
expect(MockScheduler).toHaveBeenCalled();
expect(instance).toBeInstanceOf(SuperSimpleQueue);
// init was called, which calls scheduler.start
expect(mockSchedulerInstance.start).toHaveBeenCalled();
});
});
});
@@ -1,718 +0,0 @@
import { describe, expect, it, jest } from "@jest/globals";
import { SuperSimpleQueueHelper } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
import type { Monitor } from "../../../src/types/monitor.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createHelper = (overrides?: Record<string, unknown>) => {
const maintenanceWindowsRepository = {
findByMonitorId: jest.fn().mockResolvedValue([]),
};
const monitorsRepository = {
updateById: jest.fn().mockResolvedValue({}),
findAllMonitorIds: jest.fn().mockResolvedValue(["m1"]),
deleteByTeamIdsNotIn: jest.fn().mockResolvedValue(0),
};
const teamsRepository = {
findAllTeamIds: jest.fn().mockResolvedValue(["team"]),
};
const monitorStatsRepository = {
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
};
const checksRepository = {
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
};
const incidentsRepository = {
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
};
const geoChecksRepository = {
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
};
const statusServiceMock = {
updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1", status: "up" }, statusChanged: false, prevStatus: "up", code: 200 }),
};
const settingsServiceMock = {
getDBSettings: jest.fn().mockResolvedValue({ checkTTL: 30 }),
};
const geoChecksServiceMock = {
buildGeoCheck: jest.fn().mockResolvedValue(null),
};
const defaults = {
logger: createMockLogger(),
networkService: { requestStatus: jest.fn() },
statusService: statusServiceMock,
notificationsService: { handleNotifications: jest.fn().mockResolvedValue(undefined) },
checkService: { buildCheck: jest.fn().mockReturnValue({ id: "check-1" }), deleteOlderThan: jest.fn().mockResolvedValue(0) },
settingsService: settingsServiceMock,
buffer: { addToBuffer: jest.fn(), addGeoCheckToBuffer: jest.fn() },
incidentService: { handleIncident: jest.fn().mockResolvedValue(undefined) },
maintenanceWindowsRepository,
monitorsRepository,
teamsRepository,
monitorStatsRepository,
checksRepository,
incidentsRepository,
geoChecksService: geoChecksServiceMock,
geoChecksRepository,
...overrides,
};
const helper = new SuperSimpleQueueHelper(
defaults.logger as any,
defaults.networkService as any,
defaults.statusService as any,
defaults.notificationsService as any,
defaults.checkService as any,
defaults.settingsService as any,
defaults.buffer as any,
defaults.incidentService as any,
defaults.maintenanceWindowsRepository as any,
defaults.monitorsRepository as any,
defaults.teamsRepository as any,
defaults.monitorStatsRepository as any,
defaults.checksRepository as any,
defaults.incidentsRepository as any,
defaults.geoChecksService as any,
defaults.geoChecksRepository as any
);
return { helper, defaults, maintenanceWindowsRepository };
};
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "m1",
teamId: "team",
type: "http",
interval: 60000,
status: "up",
geoCheckEnabled: false,
geoCheckLocations: [],
...overrides,
}) as Monitor;
// ── Tests ────────────────────────────────────────────────────────────────────
describe("SuperSimpleQueueHelper", () => {
describe("serviceName", () => {
it("returns JobQueueHelper", () => {
const { helper } = createHelper();
expect(helper.serviceName).toBe("JobQueueHelper");
});
});
// ── getHeartbeatJob ──────────────────────────────────────────────────────
describe("getHeartbeatJob", () => {
it("throws when monitor id is missing", async () => {
const { helper } = createHelper();
const job = helper.getHeartbeatJob();
await expect(job({} as Monitor)).rejects.toThrow("No monitor id");
});
it("skips execution and sets maintenance status when in maintenance window", async () => {
const { helper, defaults } = createHelper();
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true);
const job = helper.getHeartbeatJob();
await job(makeMonitor({ status: "up" }));
expect(defaults.networkService.requestStatus).not.toHaveBeenCalled();
expect(defaults.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { status: "maintenance" });
});
it("skips monitor status update when already in maintenance", async () => {
const { helper, defaults } = createHelper();
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true);
const job = helper.getHeartbeatJob();
await job(makeMonitor({ status: "maintenance" }));
expect(defaults.monitorsRepository.updateById).not.toHaveBeenCalled();
});
it("throws when network response is null", async () => {
const { helper } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(null) },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await expect(job(makeMonitor())).rejects.toThrow("No network response");
});
it("returns early and warns when buildCheck returns null", async () => {
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
checkService: { buildCheck: jest.fn().mockReturnValue(null), deleteOlderThan: jest.fn() },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No check could be built") }));
expect(defaults.statusService.updateMonitorStatus).not.toHaveBeenCalled();
});
it("processes full pipeline: network → check → status → incident", async () => {
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
expect(defaults.buffer.addToBuffer).toHaveBeenCalled();
expect(defaults.statusService.updateMonitorStatus).toHaveBeenCalled();
expect(defaults.incidentService.handleIncident).toHaveBeenCalled();
});
it("sends notifications when decision says shouldSendNotification", async () => {
const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" };
const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
expect(defaults.notificationsService.handleNotifications).toHaveBeenCalledWith(
statusResult.monitor,
networkResponse,
expect.objectContaining({ shouldSendNotification: true, shouldCreateIncident: true })
);
});
it("logs error when notifications fail (fire-and-forget)", async () => {
const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" };
const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 };
const notificationsService = { handleNotifications: jest.fn().mockRejectedValue(new Error("smtp down")) };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) },
notificationsService,
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
// Wait for the fire-and-forget catch to execute
await new Promise((r) => setTimeout(r, 10));
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("smtp down") }));
});
it("logs error when notifications fail with non-Error (fire-and-forget)", async () => {
const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" };
const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 };
const notificationsService = { handleNotifications: jest.fn().mockRejectedValue("string error") };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) },
notificationsService,
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
await new Promise((r) => setTimeout(r, 10));
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Unknown error") }));
});
it("logs warning when incident handling fails (fire-and-forget)", async () => {
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
const incidentService = { handleIncident: jest.fn().mockRejectedValue(new Error("db error")) };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
incidentService,
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
await new Promise((r) => setTimeout(r, 10));
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("db error") }));
});
it("logs warning when incident handling fails with non-Error", async () => {
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
const incidentService = { handleIncident: jest.fn().mockRejectedValue(42) };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
incidentService,
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
await new Promise((r) => setTimeout(r, 10));
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Unknown error") }));
});
it("logs and rethrows on unexpected error with non-Error", async () => {
const { helper } = createHelper({
networkService: { requestStatus: jest.fn().mockRejectedValue("unexpected") },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await expect(job(makeMonitor())).rejects.toBe("unexpected");
});
});
// ── getHeartbeatGeoJob ───────────────────────────────────────────────────
describe("getHeartbeatGeoJob", () => {
it("throws nothing when monitor id is missing (logs error)", async () => {
const { helper, defaults } = createHelper();
const job = helper.getHeartbeatGeoJob();
await job({} as Monitor);
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "No monitor id" }));
});
it("returns early when geoCheckEnabled is false", async () => {
const { helper, defaults } = createHelper();
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: false }));
expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled();
});
it("returns early when monitor type does not support geo checks", async () => {
const { helper, defaults } = createHelper();
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "hardware", geoCheckLocations: ["us-east"] }));
expect(defaults.logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("does not support geo checks") })
);
expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled();
});
it("returns early when geoCheckLocations is empty", async () => {
const { helper, defaults } = createHelper();
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: [] }));
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") }));
});
it("returns early when geoCheckLocations is undefined", async () => {
const { helper, defaults } = createHelper();
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: undefined as any }));
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") }));
});
it("skips when in maintenance window", async () => {
const { helper, defaults } = createHelper();
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true);
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("maintenance window") }));
expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled();
});
it("warns when buildGeoCheck returns null", async () => {
const { helper, defaults } = createHelper();
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check could be built") }));
});
it("adds geo check to buffer on success", async () => {
const geoCheck = { id: "gc-1", monitorId: "m1" };
const { helper, defaults } = createHelper({
geoChecksService: { buildGeoCheck: jest.fn().mockResolvedValue(geoCheck) },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
expect(defaults.buffer.addGeoCheckToBuffer).toHaveBeenCalledWith(geoCheck);
expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Geo check job executed") }));
});
it("logs error on unexpected failure without rethrowing", async () => {
const { helper, defaults } = createHelper({
geoChecksService: { buildGeoCheck: jest.fn().mockRejectedValue(new Error("api timeout")) },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "api timeout" }));
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { helper, defaults } = createHelper({
geoChecksService: { buildGeoCheck: jest.fn().mockRejectedValue("string error") },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
// ── getCleanupOrphanedJob ────────────────────────────────────────────────
describe("getCleanupOrphanedJob", () => {
it("cleans up orphaned data across all repositories", async () => {
const { helper, defaults } = createHelper();
(defaults.monitorsRepository.deleteByTeamIdsNotIn as jest.Mock).mockResolvedValue(2);
(defaults.monitorStatsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(3);
(defaults.checksRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(4);
(defaults.incidentsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(1);
(defaults.geoChecksRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(5);
const job = helper.getCleanupOrphanedJob();
await job();
expect(defaults.teamsRepository.findAllTeamIds).toHaveBeenCalled();
expect(defaults.monitorsRepository.deleteByTeamIdsNotIn).toHaveBeenCalledWith(["team"]);
expect(defaults.monitorsRepository.findAllMonitorIds).toHaveBeenCalled();
expect(defaults.monitorStatsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
expect(defaults.checksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
expect(defaults.incidentsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
expect(defaults.geoChecksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
// Logs info for each deleted count > 0
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("2 orphaned monitors") }));
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("3 orphaned monitor stats") }));
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("4 orphaned checks") }));
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("1 orphaned incidents") }));
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("5 orphaned geo checks") }));
});
it("skips info logs when no orphaned data is found", async () => {
const { helper, defaults } = createHelper();
const job = helper.getCleanupOrphanedJob();
await job();
// Only start and completion logs, no deletion logs
const infoMessages = (defaults.logger.info as jest.Mock).mock.calls.map((c: any) => c[0].message);
expect(infoMessages).toContain("Starting cleanup of orphaned data");
expect(infoMessages).toContain("Cleanup of orphaned data completed");
expect(infoMessages).not.toContain(expect.stringContaining("orphaned monitors"));
});
it("logs warning and rethrows on error", async () => {
const { helper, defaults } = createHelper();
(defaults.teamsRepository.findAllTeamIds as jest.Mock).mockRejectedValue(new Error("db down"));
const job = helper.getCleanupOrphanedJob();
await expect(job()).rejects.toThrow("db down");
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "db down" }));
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { helper, defaults } = createHelper();
(defaults.teamsRepository.findAllTeamIds as jest.Mock).mockRejectedValue(null);
const job = helper.getCleanupOrphanedJob();
await expect(job()).rejects.toBeNull();
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
// ── getCleanupRetentionJob ───────────────────────────────────────────────
describe("getCleanupRetentionJob", () => {
it("deletes checks older than TTL cutoff", async () => {
const { helper, defaults } = createHelper();
const job = helper.getCleanupRetentionJob();
await job();
expect(defaults.checkService.deleteOlderThan).toHaveBeenCalledWith(expect.any(Date));
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Deleted") }));
});
it("skips cleanup when checkTTL is sentinel value (366)", async () => {
const { helper, defaults } = createHelper({
settingsService: { getDBSettings: jest.fn().mockResolvedValue({ checkTTL: 366 }) },
});
const job = helper.getCleanupRetentionJob();
await job();
expect(defaults.checkService.deleteOlderThan).not.toHaveBeenCalled();
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("unlimited") }));
});
it("logs error on failure without rethrowing", async () => {
const { helper, defaults } = createHelper({
settingsService: { getDBSettings: jest.fn().mockRejectedValue(new Error("db error")) },
});
const job = helper.getCleanupRetentionJob();
await job(); // Should not throw
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "db error" }));
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { helper, defaults } = createHelper({
settingsService: { getDBSettings: jest.fn().mockRejectedValue("boom") },
});
const job = helper.getCleanupRetentionJob();
await job();
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
// ── isInMaintenanceWindow ────────────────────────────────────────────────
describe("isInMaintenanceWindow", () => {
it("returns true when an active window spans now", async () => {
const now = new Date();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now.getTime() - 1000).toISOString(),
end: new Date(now.getTime() + 1000).toISOString(),
repeat: 0,
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
});
it("returns true when repeat interval advances window into current time", async () => {
const now = Date.now();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now - 7200000).toISOString(),
end: new Date(now - 6600000).toISOString(),
repeat: 3600000,
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
});
it("returns false when no active windows exist", async () => {
const { helper } = createHelper();
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
});
it("returns false when window is inactive", async () => {
const now = new Date();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: false,
start: new Date(now.getTime() - 1000).toISOString(),
end: new Date(now.getTime() + 1000).toISOString(),
repeat: 0,
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
});
it("returns false when active window is in the past without repeat", async () => {
const now = Date.now();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now - 5000).toISOString(),
end: new Date(now - 1000).toISOString(),
repeat: 0,
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
});
it("returns false when repeat advances past current time without match", async () => {
const now = Date.now();
const { helper, maintenanceWindowsRepository } = createHelper();
// Window was 2 hours ago, lasts 10 minutes, repeats every hour
// First repeat: -1h to -50min (past), second repeat: now to +10min... actually this would match
// Use a repeat that skips over now
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now - 3600000 * 3 - 300000).toISOString(), // 3h5min ago
end: new Date(now - 3600000 * 3).toISOString(), // 3h ago, 5min window
repeat: 3600000, // 1 hour repeat
},
]);
// Advances: -2h5m to -2h, -1h5m to -1h, -5m to 0 — that last one might match if now is within 5min
// Let's use a different setup that clearly doesn't match
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now - 1800000 - 60000).toISOString(), // 31 min ago
end: new Date(now - 1800000).toISOString(), // 30 min ago, 1 min window
repeat: 3600000, // 1 hour repeat — next window would be in 29 min
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
});
it("preserves previous true result via accumulator", async () => {
const now = new Date();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now.getTime() - 1000).toISOString(),
end: new Date(now.getTime() + 1000).toISOString(),
repeat: 0,
},
{
active: false,
start: new Date(now.getTime() - 1000).toISOString(),
end: new Date(now.getTime() + 1000).toISOString(),
repeat: 0,
},
]);
// Second window is inactive but first already matched — accumulator should preserve true
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
});
});
// ── evaluateMonitorAction (tested indirectly via getHeartbeatJob) ─────────
describe("evaluateMonitorAction", () => {
const runJobAndGetDecision = async (statusResult: Record<string, unknown>) => {
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
const incidentService = { handleIncident: jest.fn().mockResolvedValue(undefined) };
const notificationsService = { handleNotifications: jest.fn().mockResolvedValue(undefined) };
const { helper } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) },
incidentService,
notificationsService,
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
return { incidentService, notificationsService };
};
it("does nothing when statusChanged is false", async () => {
const { incidentService, notificationsService } = await runJobAndGetDecision({
monitor: { id: "m1", status: "up" },
statusChanged: false,
prevStatus: "up",
code: 200,
});
expect(incidentService.handleIncident).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ shouldCreateIncident: false, shouldResolveIncident: false, shouldSendNotification: false }),
expect.anything()
);
});
it("creates incident and notifies when monitor goes down", async () => {
const { incidentService } = await runJobAndGetDecision({
monitor: { id: "m1", status: "down" },
statusChanged: true,
prevStatus: "up",
code: 500,
});
expect(incidentService.handleIncident).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
shouldCreateIncident: true,
shouldSendNotification: true,
incidentReason: "status_down",
notificationReason: "status_change",
}),
expect.anything()
);
});
it("creates incident and notifies when monitor is breached", async () => {
const { incidentService } = await runJobAndGetDecision({
monitor: { id: "m1", status: "breached" },
statusChanged: true,
prevStatus: "up",
code: 200,
});
expect(incidentService.handleIncident).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
shouldCreateIncident: true,
incidentReason: "threshold_breach",
notificationReason: "threshold_breach",
}),
expect.anything()
);
});
it("resolves incident when monitor recovers from down", async () => {
const { incidentService } = await runJobAndGetDecision({
monitor: { id: "m1", status: "up" },
statusChanged: true,
prevStatus: "down",
code: 200,
});
expect(incidentService.handleIncident).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ shouldResolveIncident: true, shouldSendNotification: true }),
expect.anything()
);
});
it("resolves incident when monitor recovers from breached", async () => {
const { incidentService } = await runJobAndGetDecision({
monitor: { id: "m1", status: "up" },
statusChanged: true,
prevStatus: "breached",
code: 200,
});
expect(incidentService.handleIncident).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ shouldResolveIncident: true }),
expect.anything()
);
});
it("does not create or resolve for unhandled status transitions", async () => {
const { incidentService } = await runJobAndGetDecision({
monitor: { id: "m1", status: "paused" },
statusChanged: true,
prevStatus: "up",
code: 200,
});
expect(incidentService.handleIncident).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ shouldCreateIncident: false, shouldResolveIncident: false, shouldSendNotification: false }),
expect.anything()
);
});
});
});