Compare commits

...
Author SHA1 Message Date
Alex Holliday db378120be add queue tests 2026-04-08 12:03:26 -07:00
Alex Holliday ba5368f942 add notificationsService tests 2026-04-08 11:32:08 -07:00
Alex Holliday 7329306ccf add incident serivce tests 2026-04-08 10:43:47 -07:00
Alex Holliday 365987c287 relocate tests, add statusService tests 2026-04-08 10:31:24 -07:00
Gorkem Cetin 09d6147f75 Remove Translations section from README
Removed the Translations section from the README.
2026-04-08 01:26:16 -04:00
Gorkem Cetin 86ab1f2146 Add Coolify and Cloudzy 2026-04-07 18:34:28 -04:00
Alexander Holliday 934f85f505 Merge pull request #3460 from bluewave-labs/fix/server-test-suite-failures
fix: align server test suite with current source implementations
2026-04-07 11:11:51 -07:00
Alex Holliday 96beda5c47 update mock logger and tests 2026-04-07 11:10:24 -07:00
Alexander Holliday 504d6157e5 Merge pull request #3473 from bluewave-labs/fix/actions
fix: disable poeditor workflow
2026-04-07 09:58:41 -07:00
Alex Holliday 628bca3e29 disable poeditor workflow 2026-04-07 09:56:11 -07:00
Alexander Holliday 2c76484bad Merge pull request #3471 from bluewave-labs/feat/zh-tw
feat: add remaining zh-tw strings
2026-04-07 09:53:58 -07:00
Alex Holliday 22498756cd add remaining zh-tw strings 2026-04-07 09:46:07 -07:00
Alexander Holliday 7c61ca490c Merge pull request #3461 from bluewave-labs/fix/i18n-locale-migration
fix: restructure and translate all i18n locale files
2026-04-03 20:46:13 -07:00
gorkem-bwl 96f9360280 style: format migration script with prettier 2026-04-03 23:43:34 -04:00
gorkem-bwl eb4a92c84f fix: restructure and translate all i18n locale files
All 15 non-English locale files had a structural mismatch with en.json:
- en.json used nested keys (common.buttons.cancel)
- Other locales used flat keys (cancel, DeleteAccountButton)
- Result: 0.3% effective translation coverage at runtime

## Changes

Migration script (client/scripts/migrate-locales.ts):
- Restructures all locale files to match en.json's nested key structure
- Recovers existing translations via safe key-matching strategies
- Backs up originals to locales/backup/

Translation of all 15 languages:
- ar (94.6%), cs (91.6%), de (89.4%), es (92.6%), fi (94.2%)
- fr (86.7%), ja (93.3%), pt-BR (91.0%), ru (93.0%), th (93.4%)
- tr (91.9%), uk (94.3%), vi (92.5%), zh-CN (94.0%), zh-TW (94.2%)

Keys intentionally left in English are technical terms (HTTP, CPU,
Docker, gRPC, etc.), placeholder examples, and brand names.

en.json was NOT modified.
2026-04-03 23:35:14 -04:00
gorkem-bwl c80720c8c9 fix: align server test suite with current source implementations
## Changes
- teamsProvider.test.ts: fix got mock wiring (use top-level mockPost
  with dynamic import so mock intercepts correctly), fix getTestMessage
  assertion, fix incident URL path
- superSimpleQueueHelper.test.ts: change default import to named import,
  update constructor from object to 16 positional params, rename
  getMonitorJob -> getHeartbeatJob, add all missing dependencies
- monitorService.test.ts: remove stale constructor params (db,
  stringService, papaparse, errorService), add missing params
  (geoChecksRepository, incidentsRepository), fix method names
  (findLatestChecksByMonitorIds -> findLatestByMonitorIds,
  findDateRangeChecksByMonitor -> findByDateRangeAndMonitorId),
  update assertions from checks -> recentChecks
- jest.config.ts: consolidate @/utils path mapping to resolve .ts files
- Extract shared createMockLogger to test/helpers/

Closes #3457
2026-04-03 21:56:14 -04:00
Alexander Holliday 42e82af9bb Merge pull request #3458 from bluewave-labs/feat/scheduler/1.9.1
feat: bump scheduler, consume scheduler events
2026-04-03 15:38:12 -07:00
Alex 7636d2d090 error -> warn on job fail, register listeners before starting scheduler 2026-04-03 15:36:37 -07:00
Alex Holliday 72728748f8 bump scheduler, consume scheduler events 2026-04-03 13:21:16 -07:00
35 changed files with 21999 additions and 15165 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>
+1 -7
View File
@@ -29,7 +29,6 @@ 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)
@@ -56,7 +55,7 @@ Usage instructions can be found [here](https://checkmate.so/docs).
See installation instructions in [Checkmate documentation portal](https://checkmate.so/docs).
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.
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.
### Using a Custom CA
@@ -64,11 +63,6 @@ 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:
+258
View File
@@ -0,0 +1,258 @@
/**
* 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();
+1196 -999
View File
File diff suppressed because it is too large. Load diff
+1196 -999
View File
File diff suppressed because it is too large. Load diff
+1187 -1022
View File
File diff suppressed because it is too large. Load diff
+1196 -999
View File
File diff suppressed because it is too large. Load diff
+1194 -997
View File
File diff suppressed because it is too large. Load diff
+1187 -1022
View File
File diff suppressed because it is too large. Load diff
+1180 -994
View File
File diff suppressed because it is too large. Load diff
+1186 -1021
View File
File diff suppressed because it is too large. Load diff
+1179 -1014
View File
File diff suppressed because it is too large. Load diff
+1188 -1023
View File
File diff suppressed because it is too large. Load diff
+1194 -997
View File
File diff suppressed because it is too large. Load diff
+1213 -746
View File
File diff suppressed because it is too large. Load diff
+1213 -746
View File
File diff suppressed because it is too large. Load diff
+1180 -1007
View File
File diff suppressed because it is too large. Load diff
+1233 -1022
View File
File diff suppressed because it is too large. Load diff
+1 -2
View File
@@ -9,8 +9,7 @@ const config: Config = {
},
moduleNameMapper: {
"^@/validation/(.*)\\.js$": "<rootDir>/src/validation/$1.js",
"^@/utils/(AppError)\\.js$": "<rootDir>/src/utils/$1.ts",
"^@/utils/(.*)\\.js$": "<rootDir>/src/utils/$1.js",
"^@/utils/(.*)\\.js$": "<rootDir>/src/utils/$1.ts",
"^@/(.*)\\.ts$": "<rootDir>/src/$1.ts",
"^@/(.*)\\.js$": "<rootDir>/src/$1.ts",
"^@/(.*)$": "<rootDir>/src/$1",
+99 -200
View File
@@ -41,7 +41,7 @@
"ping": "0.4.4",
"sharp": "0.33.5",
"ssl-checker": "2.0.10",
"super-simple-scheduler": "1.4.5",
"super-simple-scheduler": "1.9.1",
"swagger-ui-express": "5.0.1",
"winston": "^3.13.0",
"ws": "^8.19.0",
@@ -69,7 +69,6 @@
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"c8": "10.1.3",
"eslint": "^9.17.0",
"eslint-plugin-mocha": "^10.5.0",
"esm": "3.2.25",
@@ -801,40 +800,20 @@
}
},
"node_modules/@aws-sdk/xml-builder": {
"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==",
"version": "3.972.16",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.16.tgz",
"integrity": "sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.13.0",
"fast-xml-parser": "5.4.1",
"@smithy/types": "^4.13.1",
"fast-xml-parser": "5.5.8",
"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",
@@ -1378,16 +1357,6 @@
"integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==",
"license": "Apache-2.0"
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@colors/colors": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
@@ -3855,9 +3824,9 @@
}
},
"node_modules/@smithy/types": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz",
"integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==",
"version": "4.13.1",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz",
"integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -4962,9 +4931,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5905,9 +5874,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6073,40 +6042,6 @@
"node": ">= 0.8"
}
},
"node_modules/c8": {
"version": "10.1.3",
"resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
"integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==",
"dev": true,
"license": "ISC",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.1",
"@istanbuljs/schema": "^0.1.3",
"find-up": "^5.0.0",
"foreground-child": "^3.1.1",
"istanbul-lib-coverage": "^3.2.0",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.1.6",
"test-exclude": "^7.0.1",
"v8-to-istanbul": "^9.0.0",
"yargs": "^17.7.2",
"yargs-parser": "^21.1.1"
},
"bin": {
"c8": "bin/c8.js"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"monocart-coverage-reports": "^2"
},
"peerDependenciesMeta": {
"monocart-coverage-reports": {
"optional": true
}
}
},
"node_modules/cacheable-lookup": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
@@ -8152,9 +8087,9 @@
"license": "MIT"
},
"node_modules/fast-xml-builder": {
"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==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
"dev": true,
"funding": [
{
@@ -8162,7 +8097,31 @@
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
"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"
}
},
"node_modules/fastq": {
"version": "1.20.1",
@@ -8303,9 +8262,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
@@ -8752,9 +8711,9 @@
}
},
"node_modules/glob/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==",
"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==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -8854,9 +8813,9 @@
"license": "ISC"
},
"node_modules/handlebars": {
"version": "4.7.8",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
"version": "4.7.9",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
"integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.5",
@@ -10156,9 +10115,9 @@
}
},
"node_modules/jest-util/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -10693,9 +10652,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.camelcase": {
@@ -11237,9 +11196,9 @@
}
},
"node_modules/mjml-cli/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==",
"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==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -12333,6 +12292,22 @@
"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",
@@ -12369,9 +12344,9 @@
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"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==",
"license": "MIT"
},
"node_modules/path-type": {
@@ -12481,9 +12456,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -14509,9 +14484,9 @@
}
},
"node_modules/strnum": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz",
"integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==",
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
"integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==",
"dev": true,
"funding": [
{
@@ -14538,25 +14513,12 @@
}
},
"node_modules/super-simple-scheduler": {
"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==",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.9.1.tgz",
"integrity": "sha512-JSiEQLk7MsnlSkJ87uoyFKIseG2N27ikB0FSxlh25nnQHWC7R5Lrd0VcoLpwSBanyLzlAslhK4W2rQ0mCU8jCQ==",
"license": "MIT",
"dependencies": {
"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"
"uuid": "11.1.0"
}
},
"node_modules/super-simple-scheduler/node_modules/uuid": {
@@ -14572,28 +14534,6 @@
"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",
@@ -14681,47 +14621,6 @@
"node": ">=6"
}
},
"node_modules/test-exclude": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
"integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^10.4.1",
"minimatch": "^9.0.4"
},
"engines": {
"node": ">=18"
}
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "2.0.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",
@@ -14764,9 +14663,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -15155,9 +15054,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
"version": "6.24.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
"integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
"license": "MIT",
"engines": {
"node": ">=18.17"
@@ -15825,9 +15724,9 @@
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"dev": true,
"license": "ISC",
"bin": {
+3 -3
View File
@@ -5,7 +5,8 @@
"main": "index.js",
"type": "module",
"scripts": {
"test": "NODE_OPTIONS=--experimental-vm-modules c8 jest --runInBand",
"test": "NODE_OPTIONS=--experimental-vm-modules jest --coverage",
"test:services": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/service/business/*.ts' --collectCoverageFrom='src/service/infrastructure/statusService.ts' --collectCoverageFrom='src/service/infrastructure/notificationsService.ts' --collectCoverageFrom='src/service/infrastructure/SuperSimpleQueue/*.ts' test/unit/services",
"dev": "nodemon --exec tsx src/index.js",
"start": "node --watch ./dist/index.js",
"build": "tsc && tsc-alias && cp -r src/templates dist/templates",
@@ -56,7 +57,7 @@
"ping": "0.4.4",
"sharp": "0.33.5",
"ssl-checker": "2.0.10",
"super-simple-scheduler": "1.4.5",
"super-simple-scheduler": "1.9.1",
"swagger-ui-express": "5.0.1",
"winston": "^3.13.0",
"ws": "^8.19.0",
@@ -84,7 +85,6 @@
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"c8": "10.1.3",
"eslint": "^9.17.0",
"eslint-plugin-mocha": "^10.5.0",
"esm": "3.2.25",
+10 -10
View File
@@ -107,16 +107,16 @@ export class CheckService implements ICheckService {
numericUnit: audit.numericUnit,
};
};
check.accessibility = (categories?.accessibility?.score || 0) * 100;
check.bestPractices = (categories?.["best-practices"]?.score || 0) * 100;
check.seo = (categories?.seo?.score || 0) * 100;
check.performance = (categories?.performance?.score || 0) * 100;
check.accessibility = (categories.accessibility?.score || 0) * 100;
check.bestPractices = (categories["best-practices"]?.score || 0) * 100;
check.seo = (categories.seo?.score || 0) * 100;
check.performance = (categories.performance?.score || 0) * 100;
check.audits = {
cls: mapAudit(audits?.["cumulative-layout-shift"]),
si: mapAudit(audits?.["speed-index"]),
fcp: mapAudit(audits?.["first-contentful-paint"]),
lcp: mapAudit(audits?.["largest-contentful-paint"]),
tbt: mapAudit(audits?.["total-blocking-time"]),
cls: mapAudit(audits["cumulative-layout-shift"]),
si: mapAudit(audits["speed-index"]),
fcp: mapAudit(audits["first-contentful-paint"]),
lcp: mapAudit(audits["largest-contentful-paint"]),
tbt: mapAudit(audits["total-blocking-time"]),
};
}
@@ -124,7 +124,7 @@ export class CheckService implements ICheckService {
const hardwarePayload = payload as HardwareStatusPayload | undefined;
const { cpu, memory, disk, host, net } = hardwarePayload?.data ?? {};
const errorsSource = Array.isArray(hardwarePayload?.errors)
? hardwarePayload?.errors
? hardwarePayload.errors
: (hardwarePayload?.errors as { errors?: CheckErrorInfo[] } | undefined)?.errors;
check.cpu = cpu;
check.memory = memory;
@@ -95,17 +95,14 @@ export class IncidentService implements IIncidentService {
}
}
if (decision.shouldResolveIncident) {
if (!activeIncident) {
if (!decision.shouldResolveIncident || !activeIncident) {
return null;
}
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,22 +73,91 @@ 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.scheduler.start();
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());
@@ -237,8 +306,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,
@@ -270,9 +339,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,13 +147,7 @@ export class StatusService implements IStatusService {
}
// Calculate uptime percentage
let uptimePercentage;
if (stats.totalChecks > 0) {
uptimePercentage = stats.totalUpChecks / stats.totalChecks;
} else {
uptimePercentage = status === true ? 100 : 0;
}
stats.uptimePercentage = uptimePercentage;
stats.uptimePercentage = stats.totalUpChecks / stats.totalChecks;
// latest check
stats.lastCheckTimestamp = new Date().getTime();
@@ -268,7 +262,7 @@ export class StatusService implements IStatusService {
let thresholdBreaches: { cpu: boolean; memory: boolean; disk: boolean; temp: boolean } | undefined;
if (monitor.type === "hardware" && statusResponse.payload) {
const payload = statusResponse.payload as HardwareStatusPayload;
const metrics = payload?.data;
const metrics = payload.data;
if (metrics) {
// Evaluate threshold breaches
@@ -278,9 +272,11 @@ export class StatusService implements IStatusService {
const memoryUsage = metrics.memory?.usage_percent ?? -1;
const memoryBreach = memoryUsage !== -1 && memoryUsage > monitor.memoryAlertThreshold / 100;
const diskBreach =
metrics.disk?.some((d: CheckDiskInfo) => typeof d?.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100) ??
false;
const diskBreach = metrics.disk
? metrics.disk.some(
(d: CheckDiskInfo) => d != null && typeof d.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100
)
: false;
const temps = metrics.cpu?.temperature ?? [];
const tempBreach = temps.some((temp: number) => temp > monitor.tempAlertThreshold);
+12
View File
@@ -0,0 +1,12 @@
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() }),
});
-101
View File
@@ -1,101 +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";
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,19 +1,15 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
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";
import type { Notification } from "../../../src/types/notification.ts";
import type { NotificationMessage } from "../../../src/types/notificationMessage.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
// Mock got
const mockPost = jest.fn();
jest.unstable_mockModule("got", () => ({
default: { post: jest.fn() },
default: { post: mockPost },
HTTPError: class HTTPError extends Error {},
}));
const createLogger = () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
});
const { TeamsProvider } = await import("../../../src/service/infrastructure/notificationProviders/teams.ts");
const createNotification = (overrides?: Partial<Notification>): Notification => ({
id: "notif-1",
@@ -58,17 +54,14 @@ const createMessage = (overrides?: Partial<NotificationMessage>): NotificationMe
});
describe("TeamsProvider", () => {
let provider: TeamsProvider;
let logger: ReturnType<typeof createLogger>;
let gotPost: jest.Mock;
let provider: InstanceType<typeof TeamsProvider>;
let logger: ReturnType<typeof createMockLogger>;
beforeEach(async () => {
logger = createLogger();
beforeEach(() => {
logger = createMockLogger();
provider = new TeamsProvider(logger);
const got = await import("got");
gotPost = got.default.post as jest.Mock;
gotPost.mockReset();
gotPost.mockResolvedValue({});
mockPost.mockReset();
mockPost.mockResolvedValue({});
});
describe("sendTestAlert", () => {
@@ -76,35 +69,33 @@ describe("TeamsProvider", () => {
const notification = createNotification({ address: undefined });
const result = await provider.sendTestAlert(notification);
expect(result).toBe(false);
expect(gotPost).not.toHaveBeenCalled();
expect(mockPost).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(gotPost).toHaveBeenCalledTimes(1);
expect(mockPost).toHaveBeenCalledTimes(1);
const [url, options] = gotPost.mock.calls[0] as [string, { json: any; headers: any }];
const [url, options] = mockPost.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).toContain("test notification");
expect(card.body[0].text).toBe("This is a test notification from Checkmate");
});
it("returns false and logs warning on HTTP error", async () => {
gotPost.mockRejectedValue(new Error("Network error"));
mockPost.mockRejectedValue(new Error("Network error"));
const notification = createNotification();
const result = await provider.sendTestAlert(notification);
expect(result).toBe(false);
@@ -122,7 +113,7 @@ describe("TeamsProvider", () => {
const notification = createNotification({ address: undefined });
const result = await provider.sendMessage(notification, createMessage());
expect(result).toBe(false);
expect(gotPost).not.toHaveBeenCalled();
expect(mockPost).not.toHaveBeenCalled();
});
it("sends a well-formed Adaptive Card and returns true", async () => {
@@ -131,9 +122,9 @@ describe("TeamsProvider", () => {
const result = await provider.sendMessage(notification, message);
expect(result).toBe(true);
expect(gotPost).toHaveBeenCalledTimes(1);
expect(mockPost).toHaveBeenCalledTimes(1);
const [url, options] = gotPost.mock.calls[0] as [string, { json: any }];
const [url, options] = mockPost.mock.calls[0] as [string, { json: any }];
expect(url).toBe(notification.address);
const payload = options.json;
@@ -143,18 +134,13 @@ 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"); // critical -> attention
// Summary block
expect(textBlocks[0].color).toBe("attention");
expect(textBlocks[1].text).toBe(message.content.summary);
// FactSet with monitor details
expect(factSets).toHaveLength(1);
const facts = factSets[0].facts;
expect(facts).toEqual(
@@ -165,11 +151,10 @@ 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("/infrastructure/mon-1");
expect(card.actions[0].url).toContain("/incidents/inc-1");
});
it("omits actions when no incident is present", async () => {
@@ -184,7 +169,7 @@ describe("TeamsProvider", () => {
const result = await provider.sendMessage(notification, message);
expect(result).toBe(true);
const [, options] = gotPost.mock.calls[0] as [string, { json: any }];
const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
const card = options.json.attachments[0].content;
expect(card.actions).toBeUndefined();
});
@@ -212,7 +197,7 @@ describe("TeamsProvider", () => {
await provider.sendMessage(notification, message);
const [, options] = gotPost.mock.calls[0] as [string, { json: any }];
const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
const card = options.json.attachments[0].content;
// Title should use "warning" color
@@ -222,7 +207,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%");
@@ -239,20 +224,20 @@ describe("TeamsProvider", () => {
] as const;
for (const { severity, expected } of severityMap) {
gotPost.mockReset();
gotPost.mockResolvedValue({});
mockPost.mockReset();
mockPost.mockResolvedValue({});
const message = createMessage({ severity });
await provider.sendMessage(notification, message);
const [, options] = gotPost.mock.calls[0] as [string, { json: any }];
const [, options] = mockPost.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 () => {
gotPost.mockRejectedValue(new Error("502 Bad Gateway"));
mockPost.mockRejectedValue(new Error("502 Bad Gateway"));
const notification = createNotification();
const result = await provider.sendMessage(notification, createMessage());
expect(result).toBe(false);
@@ -0,0 +1,427 @@
import { describe, expect, it, jest } from "@jest/globals";
import { CheckService } from "../../../src/service/business/checkService.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { IChecksRepository, IMonitorsRepository } from "../../../src/repositories/index.ts";
import type { MonitorStatusResponse, HardwareStatusPayload, PageSpeedStatusPayload } from "../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createMonitorsRepo = () =>
({
findById: jest.fn().mockResolvedValue({}),
}) as unknown as jest.Mocked<IMonitorsRepository>;
const createChecksRepo = () =>
({
createChecks: jest.fn().mockImplementation((checks: unknown) => Promise.resolve(checks)),
findByMonitorId: jest.fn().mockResolvedValue({ checks: [], count: 0 }),
findByTeamId: jest.fn().mockResolvedValue({ checks: [], count: 0 }),
findSummaryByTeamId: jest.fn().mockResolvedValue({}),
deleteByMonitorId: jest.fn().mockResolvedValue(5),
deleteByTeamId: jest.fn().mockResolvedValue(10),
deleteOlderThan: jest.fn().mockResolvedValue(3),
}) as unknown as jest.Mocked<IChecksRepository>;
const createService = () => {
const logger = createMockLogger();
const monitorsRepository = createMonitorsRepo();
const checksRepository = createChecksRepo();
const service = new CheckService(monitorsRepository, logger as any, checksRepository);
return { service, logger, monitorsRepository, checksRepository };
};
const makeStatusResponse = (overrides?: Partial<MonitorStatusResponse>): MonitorStatusResponse =>
({
monitorId: "mon-1",
teamId: "team-1",
type: "http",
status: true,
code: 200,
message: "OK",
responseTime: 100,
...overrides,
}) as MonitorStatusResponse;
// ── Tests ────────────────────────────────────────────────────────────────────
describe("CheckService", () => {
describe("serviceName", () => {
it("returns checkService", () => {
const { service } = createService();
expect(service.serviceName).toBe("checkService");
});
});
// ── buildCheck ───────────────────────────────────────────────────────────
describe("buildCheck", () => {
it("builds a basic HTTP check with correct fields", () => {
const { service } = createService();
const check = service.buildCheck(makeStatusResponse());
expect(check).toBeDefined();
expect(check!.metadata).toEqual({ monitorId: "mon-1", teamId: "team-1", type: "http" });
expect(check!.status).toBe(true);
expect(check!.statusCode).toBe(200);
expect(check!.responseTime).toBe(100);
expect(check!.message).toBe("OK");
expect(check!.id).toBeDefined();
expect(check!.createdAt).toBeDefined();
});
it("defaults responseTime to 0 when falsy", () => {
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ responseTime: undefined }));
expect(check!.responseTime).toBe(0);
});
it("includes timings when provided", () => {
const timings = { start: 0, socket: 10, lookup: 20 };
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ timings } as any));
expect(check!.timings).toBe(timings);
});
// ── PageSpeed ────────────────────────────────────────────────────────
describe("pagespeed type", () => {
it("extracts category scores and audits from lighthouse result", () => {
const payload: PageSpeedStatusPayload = {
lighthouseResult: {
categories: {
accessibility: { score: 0.9 },
"best-practices": { score: 0.8 },
seo: { score: 0.95 },
performance: { score: 0.7 },
},
audits: {
"cumulative-layout-shift": { id: "cls", title: "CLS", score: 0.1, displayValue: "0.1", numericValue: 0.1, numericUnit: "unitless" },
"speed-index": { id: "si", title: "SI", score: 0.5, displayValue: "3.0s", numericValue: 3000, numericUnit: "millisecond" },
"first-contentful-paint": { id: "fcp", title: "FCP", score: 0.8 },
"largest-contentful-paint": { id: "lcp", title: "LCP", score: 0.6 },
"total-blocking-time": { id: "tbt", title: "TBT", score: 0.9 },
},
},
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.accessibility).toBe(90);
expect(check!.bestPractices).toBe(80);
expect(check!.seo).toBe(95);
expect(check!.performance).toBe(70);
expect(check!.audits!.cls).toEqual(expect.objectContaining({ id: "cls", score: 0.1, numericValue: 0.1 }));
expect(check!.audits!.si).toEqual(expect.objectContaining({ id: "si", numericValue: 3000 }));
expect(check!.audits!.fcp).toEqual(expect.objectContaining({ id: "fcp" }));
});
it("returns undefined when pagespeed payload is missing", () => {
const { service, logger } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload: undefined } as any));
expect(check).toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build check" }));
});
it("defaults categories and audits to empty when lighthouseResult is missing", () => {
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload: {} } as any));
expect(check!.accessibility).toBe(0);
expect(check!.bestPractices).toBe(0);
expect(check!.seo).toBe(0);
expect(check!.performance).toBe(0);
expect(check!.audits!.cls).toBeUndefined();
});
it("handles audit with non-number score (string)", () => {
const payload = {
lighthouseResult: {
categories: {},
audits: {
"cumulative-layout-shift": { id: "cls", title: "CLS", score: "informative" },
},
},
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.audits!.cls!.score).toBe("informative");
});
it("handles audit with null score", () => {
const payload = {
lighthouseResult: {
categories: {},
audits: {
"cumulative-layout-shift": { id: "cls", title: "CLS", score: null },
},
},
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.audits!.cls!.score).toBeNull();
});
it("handles audit with non-number numericValue", () => {
const payload = {
lighthouseResult: {
categories: {},
audits: {
"speed-index": { id: "si", title: "SI", score: 0.5, numericValue: "n/a" },
},
},
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.audits!.si!.numericValue).toBeUndefined();
});
it("returns undefined for non-object audit", () => {
const payload = {
lighthouseResult: {
categories: {},
audits: {
"cumulative-layout-shift": null,
"speed-index": "not an object",
},
},
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.audits!.cls).toBeUndefined();
expect(check!.audits!.si).toBeUndefined();
});
});
// ── Hardware ─────────────────────────────────────────────────────────
describe("hardware type", () => {
it("extracts cpu, memory, disk, host, net, and capture from payload", () => {
const payload: HardwareStatusPayload = {
data: {
cpu: { usage_percent: 0.5 },
memory: { usage_percent: 0.6 },
disk: [{ device: "/dev/sda", usage_percent: 0.7 }],
host: { os: "linux" },
net: [{ name: "eth0" }],
},
capture: { screenshot: "base64data" },
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any));
expect(check!.cpu).toEqual({ usage_percent: 0.5 });
expect(check!.memory).toEqual({ usage_percent: 0.6 });
expect(check!.disk).toHaveLength(1);
expect(check!.host).toEqual({ os: "linux" });
expect(check!.net).toHaveLength(1);
expect(check!.capture).toEqual({ screenshot: "base64data" });
});
it("extracts errors from array format", () => {
const payload = {
data: {},
errors: [{ message: "timeout" }],
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any));
expect(check!.errors).toEqual([{ message: "timeout" }]);
});
it("extracts errors from nested object format", () => {
const payload = {
data: {},
errors: { errors: [{ message: "nested error" }] },
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any));
expect(check!.errors).toEqual([{ message: "nested error" }]);
});
it("handles undefined payload gracefully", () => {
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload: undefined } as any));
expect(check).toBeDefined();
expect(check!.cpu).toBeUndefined();
expect(check!.memory).toBeUndefined();
expect(check!.disk).toBeUndefined();
expect(check!.errors).toBeUndefined();
});
});
});
// ── createChecks ─────────────────────────────────────────────────────────
describe("createChecks", () => {
it("delegates to repository", async () => {
const { service, checksRepository } = createService();
const checks = [{ id: "c1" }] as any;
await service.createChecks(checks);
expect(checksRepository.createChecks).toHaveBeenCalledWith(checks);
});
});
// ── getChecksByMonitor ───────────────────────────────────────────────────
describe("getChecksByMonitor", () => {
it("returns checks from repository", async () => {
const expected = { checks: [{ id: "c1" }], count: 1 };
const { service, checksRepository } = createService();
(checksRepository.findByMonitorId as jest.Mock).mockResolvedValue(expected);
const result = await service.getChecksByMonitor({
monitorId: "mon-1",
teamId: "team-1",
sortOrder: "desc",
dateRange: "day",
page: 0,
rowsPerPage: 10,
});
expect(result).toBe(expected);
});
it("defaults page to 0 and rowsPerPage to 5 when nullish", async () => {
const { service, checksRepository } = createService();
await service.getChecksByMonitor({
monitorId: "mon-1",
teamId: "team-1",
sortOrder: "desc",
dateRange: "day",
page: null as any,
rowsPerPage: null as any,
});
expect(checksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "desc", "day", undefined, 0, 5, undefined);
});
it("passes filter and status parameters", async () => {
const { service, checksRepository } = createService();
await service.getChecksByMonitor({
monitorId: "mon-1",
teamId: "team-1",
sortOrder: "asc",
dateRange: "week",
page: 2,
rowsPerPage: 20,
filter: "error",
status: false,
});
expect(checksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "asc", "week", "error", 2, 20, false);
});
it("throws when monitorId is missing", async () => {
const { service } = createService();
await expect(
service.getChecksByMonitor({ monitorId: "", teamId: "team-1", sortOrder: "desc", dateRange: "day", page: 0, rowsPerPage: 10 })
).rejects.toThrow("No monitor ID in request");
});
it("throws when teamId is missing", async () => {
const { service } = createService();
await expect(
service.getChecksByMonitor({ monitorId: "mon-1", teamId: "", sortOrder: "desc", dateRange: "day", page: 0, rowsPerPage: 10 })
).rejects.toThrow("No team ID in request");
});
it("verifies monitor belongs to team via repository", async () => {
const { service, monitorsRepository } = createService();
await service.getChecksByMonitor({
monitorId: "mon-1",
teamId: "team-1",
sortOrder: "desc",
dateRange: "day",
page: 0,
rowsPerPage: 10,
});
expect(monitorsRepository.findById).toHaveBeenCalledWith("mon-1", "team-1");
});
});
// ── getChecksByTeam ──────────────────────────────────────────────────────
describe("getChecksByTeam", () => {
it("returns checks from repository", async () => {
const expected = { checks: [], count: 0 };
const { service, checksRepository } = createService();
(checksRepository.findByTeamId as jest.Mock).mockResolvedValue(expected);
const result = await service.getChecksByTeam({ teamId: "team-1", sortOrder: "desc", dateRange: "day", page: 0, rowsPerPage: 10 });
expect(result).toBe(expected);
});
it("defaults page to 0 and rowsPerPage to 5 when nullish", async () => {
const { service, checksRepository } = createService();
await service.getChecksByTeam({ teamId: "team-1", sortOrder: "desc", dateRange: "day", page: null as any, rowsPerPage: null as any });
expect(checksRepository.findByTeamId).toHaveBeenCalledWith("desc", "day", undefined, 0, 5, "team-1");
});
});
// ── getChecksSummaryByTeamId ──────────────────────────────────────────────
describe("getChecksSummaryByTeamId", () => {
it("delegates to repository", async () => {
const summary = { total: 5, up: 4, down: 1 };
const { service, checksRepository } = createService();
(checksRepository.findSummaryByTeamId as jest.Mock).mockResolvedValue(summary);
const result = await service.getChecksSummaryByTeamId({ teamId: "team-1", dateRange: "day" });
expect(result).toBe(summary);
expect(checksRepository.findSummaryByTeamId).toHaveBeenCalledWith("team-1", "day");
});
});
// ── deleteChecks ─────────────────────────────────────────────────────────
describe("deleteChecks", () => {
it("verifies monitor ownership and deletes", async () => {
const { service, monitorsRepository, checksRepository } = createService();
const result = await service.deleteChecks({ monitorId: "mon-1", teamId: "team-1" });
expect(monitorsRepository.findById).toHaveBeenCalledWith("mon-1", "team-1");
expect(checksRepository.deleteByMonitorId).toHaveBeenCalledWith("mon-1");
expect(result).toBe(5);
});
});
describe("deleteChecksByTeamId", () => {
it("delegates to repository", async () => {
const { service, checksRepository } = createService();
const result = await service.deleteChecksByTeamId({ teamId: "team-1" });
expect(checksRepository.deleteByTeamId).toHaveBeenCalledWith("team-1");
expect(result).toBe(10);
});
});
describe("deleteOlderThan", () => {
it("delegates to repository", async () => {
const date = new Date();
const { service, checksRepository } = createService();
const result = await service.deleteOlderThan(date);
expect(checksRepository.deleteOlderThan).toHaveBeenCalledWith(date);
expect(result).toBe(3);
});
});
});
@@ -0,0 +1,472 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { IncidentService } from "../../../src/service/business/incidentService.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from "../../../src/repositories/index.ts";
import type { INotificationMessageBuilder } from "../../../src/service/infrastructure/notificationMessageBuilder.ts";
import type { Monitor } from "../../../src/types/monitor.ts";
import type { Incident } from "../../../src/types/index.ts";
import type { MonitorActionDecision } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createIncidentsRepo = () =>
({
findActiveByMonitorId: jest.fn(),
findActiveByIncidentId: jest.fn(),
create: jest.fn(),
updateById: jest.fn(),
findById: jest.fn(),
findByTeamId: jest.fn(),
countByTeamId: jest.fn(),
findSummaryByTeamId: jest.fn(),
}) as unknown as jest.Mocked<IIncidentsRepository>;
const createMonitorsRepo = () =>
({
findById: jest.fn(),
}) as unknown as jest.Mocked<IMonitorsRepository>;
const createUsersRepo = () =>
({
findById: jest.fn(),
}) as unknown as jest.Mocked<IUsersRepository>;
const createMessageBuilder = () =>
({
extractThresholdBreaches: jest.fn(),
}) as unknown as jest.Mocked<INotificationMessageBuilder>;
const createService = (overrides?: {
logger?: ReturnType<typeof createMockLogger>;
incidentsRepository?: ReturnType<typeof createIncidentsRepo>;
monitorsRepository?: ReturnType<typeof createMonitorsRepo>;
usersRepository?: ReturnType<typeof createUsersRepo>;
notificationMessageBuilder?: ReturnType<typeof createMessageBuilder>;
}) => {
const logger = overrides?.logger ?? createMockLogger();
const incidentsRepository = overrides?.incidentsRepository ?? createIncidentsRepo();
const monitorsRepository = overrides?.monitorsRepository ?? createMonitorsRepo();
const usersRepository = overrides?.usersRepository ?? createUsersRepo();
const notificationMessageBuilder = overrides?.notificationMessageBuilder ?? createMessageBuilder();
const service = new IncidentService(logger as any, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder);
return { service, logger, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder };
};
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
name: "Test Monitor",
type: "http",
url: "https://example.com",
status: "down",
...overrides,
}) as Monitor;
const makeIncident = (overrides?: Partial<Incident>): Incident =>
({
id: "inc-1",
monitorId: "mon-1",
teamId: "team-1",
startTime: "1700000000000",
status: true,
statusCode: 500,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
}) as Incident;
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: false,
incidentReason: null,
notificationReason: null,
...overrides,
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("IncidentService", () => {
describe("serviceName", () => {
it("returns incidentService", () => {
const { service } = createService();
expect(service.serviceName).toBe("incidentService");
});
});
// ── handleIncident ───────────────────────────────────────────────────────
describe("handleIncident", () => {
it("returns null when neither create nor resolve is requested", async () => {
const { service } = createService();
const result = await service.handleIncident(makeMonitor(), 200, makeDecision());
expect(result).toBeNull();
});
it("returns existing active incident when shouldCreateIncident and one already exists", async () => {
const existing = makeIncident();
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(existing);
const result = await service.handleIncident(makeMonitor(), 500, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }));
expect(result).toBe(existing);
expect(incidentsRepository.create).not.toHaveBeenCalled();
});
it("creates a new incident when shouldCreateIncident and no active incident exists", async () => {
const created = makeIncident();
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
const result = await service.handleIncident(makeMonitor(), 500, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }));
expect(result).toBe(created);
expect(incidentsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
monitorId: "mon-1",
teamId: "team-1",
status: true,
statusCode: 500,
})
);
});
it("uses status code 9999 and builds message for threshold_breach incidents", async () => {
const created = makeIncident();
const { service, incidentsRepository, notificationMessageBuilder } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
(notificationMessageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([
{ metric: "cpu", formattedValue: "95%", threshold: 80, unit: "%" },
]);
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
await service.handleIncident(makeMonitor(), 200, decision, { monitorId: "mon-1" } as any);
expect(incidentsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: 9999,
message: "CPU: 95% (threshold: 80%)",
})
);
});
it("uses fallback message when monitorStatusResponse is undefined for threshold_breach", async () => {
const created = makeIncident();
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
await service.handleIncident(makeMonitor(), 200, decision, undefined);
expect(incidentsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ message: "Threshold breach detected" }));
});
it("uses fallback message when extractThresholdBreaches returns empty array", async () => {
const created = makeIncident();
const { service, incidentsRepository, notificationMessageBuilder } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
(notificationMessageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([]);
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
await service.handleIncident(makeMonitor(), 200, decision, { monitorId: "mon-1" } as any);
expect(incidentsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ message: "Threshold breach detected" }));
});
it("joins multiple threshold breaches with commas", async () => {
const created = makeIncident();
const { service, incidentsRepository, notificationMessageBuilder } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
(notificationMessageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([
{ metric: "cpu", formattedValue: "95%", threshold: 80, unit: "%" },
{ metric: "memory", formattedValue: "90%", threshold: 80, unit: "%" },
]);
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
await service.handleIncident(makeMonitor(), 200, decision, { monitorId: "mon-1" } as any);
expect(incidentsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ message: "CPU: 95% (threshold: 80%), MEMORY: 90% (threshold: 80%)" })
);
});
it("resolves active incident when shouldResolveIncident", async () => {
const active = makeIncident();
const resolved = makeIncident({ status: false, endTime: "123", resolutionType: "automatic" });
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(active);
(incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved);
const result = await service.handleIncident(makeMonitor(), 200, makeDecision({ shouldResolveIncident: true }));
expect(result).toBe(resolved);
expect(incidentsRepository.updateById).toHaveBeenCalledWith(
"inc-1",
"team-1",
expect.objectContaining({ status: false, resolutionType: "automatic" })
);
});
it("returns null when shouldResolveIncident but no active incident exists", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
const result = await service.handleIncident(makeMonitor(), 200, makeDecision({ shouldResolveIncident: true }));
expect(result).toBeNull();
});
});
// ── resolveIncident ──────────────────────────────────────────────────────
describe("resolveIncident", () => {
it("resolves incident with manual resolution", async () => {
const active = makeIncident();
const resolved = makeIncident({ status: false });
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(active);
(incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved);
const result = await service.resolveIncident("inc-1", "user-1", "team-1", "Fixed it", "user@test.com");
expect(result).toBe(resolved);
expect(incidentsRepository.updateById).toHaveBeenCalledWith(
"inc-1",
"team-1",
expect.objectContaining({
resolutionType: "manual",
status: false,
resolvedBy: "user-1",
resolvedByEmail: "user@test.com",
comment: "Fixed it",
})
);
});
it("sets resolvedByEmail and comment to null when not provided", async () => {
const active = makeIncident();
const resolved = makeIncident({ status: false });
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(active);
(incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved);
await service.resolveIncident("inc-1", "user-1", "team-1");
expect(incidentsRepository.updateById).toHaveBeenCalledWith(
"inc-1",
"team-1",
expect.objectContaining({
resolvedByEmail: null,
comment: null,
})
);
});
it("throws when incidentId is missing", async () => {
const { service } = createService();
await expect(service.resolveIncident("", "user-1", "team-1")).rejects.toThrow("No incident ID in request");
});
it("throws when userId is missing", async () => {
const { service } = createService();
await expect(service.resolveIncident("inc-1", "", "team-1")).rejects.toThrow("No user ID in request");
});
it("throws when teamId is missing", async () => {
const { service } = createService();
await expect(service.resolveIncident("inc-1", "user-1", "")).rejects.toThrow("No team ID in request");
});
it("throws when incident is not found", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(null);
await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toThrow("Incident not found");
});
it("throws when incident is already resolved", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(makeIncident({ status: false }));
await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toThrow("Incident is already resolved");
});
it("logs error and rethrows on unexpected failure", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockRejectedValue(new Error("db error"));
await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toThrow("db error");
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({ service: "incidentService", method: "resolveIncident", message: "db error" })
);
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockRejectedValue("string error");
await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toBe("string error");
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
// ── getIncidentsByTeam ───────────────────────────────────────────────────
describe("getIncidentsByTeam", () => {
it("returns incidents and count", async () => {
const incidents = [makeIncident()];
const { service, incidentsRepository } = createService();
(incidentsRepository.findByTeamId as jest.Mock).mockResolvedValue(incidents);
(incidentsRepository.countByTeamId as jest.Mock).mockResolvedValue(1);
const result = await service.getIncidentsByTeam("team-1", "desc", "day", 0, 20, undefined, undefined, undefined);
expect(result).toEqual({ incidents, count: 1 });
});
it("defaults page to 0 and rowsPerPage to 20 when nullish", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findByTeamId as jest.Mock).mockResolvedValue([]);
(incidentsRepository.countByTeamId as jest.Mock).mockResolvedValue(0);
await service.getIncidentsByTeam("team-1", "desc", "day", null as any, null as any, undefined, undefined, undefined);
expect(incidentsRepository.findByTeamId).toHaveBeenCalledWith("team-1", expect.anything(), 0, 20, "desc", undefined, undefined, undefined);
});
it("passes filter parameters through to repository", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findByTeamId as jest.Mock).mockResolvedValue([]);
(incidentsRepository.countByTeamId as jest.Mock).mockResolvedValue(0);
await service.getIncidentsByTeam("team-1", "asc", "week", 2, 10, true, "mon-1", "manual");
expect(incidentsRepository.findByTeamId).toHaveBeenCalledWith("team-1", expect.any(Date), 2, 10, "asc", true, "mon-1", "manual");
expect(incidentsRepository.countByTeamId).toHaveBeenCalledWith("team-1", expect.any(Date), true, "mon-1", "manual");
});
it("throws when teamId is missing", async () => {
const { service } = createService();
await expect(service.getIncidentsByTeam("", "desc", "day", 0, 20, undefined, undefined, undefined)).rejects.toThrow("No team ID in request");
});
it("logs error and rethrows on unexpected failure", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findByTeamId as jest.Mock).mockRejectedValue(new Error("db error"));
await expect(service.getIncidentsByTeam("team-1", "desc", "day", 0, 20, undefined, undefined, undefined)).rejects.toThrow("db error");
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ service: "incidentService", method: "getIncidentsByTeam" }));
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findByTeamId as jest.Mock).mockRejectedValue(42);
await expect(service.getIncidentsByTeam("team-1", "desc", "day", 0, 20, undefined, undefined, undefined)).rejects.toBe(42);
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
// ── getIncidentSummary ───────────────────────────────────────────────────
describe("getIncidentSummary", () => {
it("returns summary from repository", async () => {
const summary = { recentIncidents: [], totalIncidents: 0 };
const { service, incidentsRepository } = createService();
(incidentsRepository.findSummaryByTeamId as jest.Mock).mockResolvedValue(summary);
const result = await service.getIncidentSummary("team-1", 5);
expect(result).toBe(summary);
expect(incidentsRepository.findSummaryByTeamId).toHaveBeenCalledWith("team-1", 5);
});
it("defaults limit to 10 when not provided", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findSummaryByTeamId as jest.Mock).mockResolvedValue({});
await service.getIncidentSummary("team-1");
expect(incidentsRepository.findSummaryByTeamId).toHaveBeenCalledWith("team-1", 10);
});
it("throws when teamId is missing", async () => {
const { service } = createService();
await expect(service.getIncidentSummary("")).rejects.toThrow("No team ID in request");
});
it("logs error and rethrows on unexpected failure", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findSummaryByTeamId as jest.Mock).mockRejectedValue(new Error("db error"));
await expect(service.getIncidentSummary("team-1")).rejects.toThrow("db error");
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ service: "incidentService", method: "getIncidentSummary" }));
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findSummaryByTeamId as jest.Mock).mockRejectedValue("boom");
await expect(service.getIncidentSummary("team-1")).rejects.toBe("boom");
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
// ── getIncidentById ──────────────────────────────────────────────────────
describe("getIncidentById", () => {
it("returns incident, monitor, and user when resolvedBy exists", async () => {
const incident = makeIncident({ resolvedBy: "user-1" });
const monitor = makeMonitor();
const user = { id: "user-1", email: "user@test.com" };
const { service, incidentsRepository, monitorsRepository, usersRepository } = createService();
(incidentsRepository.findById as jest.Mock).mockResolvedValue(incident);
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(usersRepository.findById as jest.Mock).mockResolvedValue(user);
const result = await service.getIncidentById("inc-1", "team-1");
expect(result).toEqual({ incident, monitor, user });
expect(usersRepository.findById).toHaveBeenCalledWith("user-1");
});
it("returns user as null when resolvedBy is not set", async () => {
const incident = makeIncident({ resolvedBy: undefined });
const monitor = makeMonitor();
const { service, incidentsRepository, monitorsRepository, usersRepository } = createService();
(incidentsRepository.findById as jest.Mock).mockResolvedValue(incident);
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
const result = await service.getIncidentById("inc-1", "team-1");
expect(result.user).toBeNull();
expect(usersRepository.findById).not.toHaveBeenCalled();
});
it("logs error and rethrows on unexpected failure", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findById as jest.Mock).mockRejectedValue(new Error("not found"));
await expect(service.getIncidentById("inc-1", "team-1")).rejects.toThrow("not found");
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({ service: "incidentService", method: "getIncidentById", details: { incidentId: "inc-1" } })
);
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findById as jest.Mock).mockRejectedValue(null);
await expect(service.getIncidentById("inc-1", "team-1")).rejects.toBeNull();
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
});
@@ -1,6 +1,13 @@
import { jest } from "@jest/globals";
import { MonitorService } from "../src/service/business/monitorService.ts";
import type { IChecksRepository, IMonitorStatsRepository, IMonitorsRepository, IStatusPagesRepository } from "../src/repositories/index.ts";
import { MonitorService } from "../../../src/service/business/monitorService.ts";
import type {
IChecksRepository,
IGeoChecksRepository,
IIncidentsRepository,
IMonitorStatsRepository,
IMonitorsRepository,
IStatusPagesRepository,
} from "../../../src/repositories/index.ts";
const createMonitorsRepositoryMock = () =>
({
@@ -8,16 +15,11 @@ 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 = () =>
({
findLatestChecksByMonitorIds: jest.fn(),
findDateRangeChecksByMonitor: jest.fn(),
findByDateRangeAndMonitorId: jest.fn(),
}) as unknown as IChecksRepository;
const createMonitorStatsRepositoryMock = () =>
@@ -31,76 +33,84 @@ 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(),
},
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")),
},
} as any,
emailService: { buildEmail: jest.fn(), sendEmail: jest.fn() } as any,
logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() } as any,
games: [],
monitorsRepository,
checksRepository,
geoChecksRepository,
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
});
};
describe("MonitorService", () => {
describe("getMonitorsWithChecksByTeamId", () => {
it("returns monitors enriched with normalized checks", async () => {
it("returns monitors enriched with normalized recentChecks", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(2);
(monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({
totalMonitors: 2,
upMonitors: 2,
downMonitors: 0,
pausedMonitors: 0,
});
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([
{ id: "m1", name: "Monitor 1", interval: 60000 },
{ id: "m2", name: "Monitor 2", interval: 60000 },
]);
const checksRepository = createChecksRepositoryMock();
(checksRepository.findLatestChecksByMonitorIds as jest.Mock).mockResolvedValue({
m1: [
{
id: "m1",
name: "Monitor 1",
type: "http",
interval: 60000,
recentChecks: [
{ responseTime: 10, status: true, message: "OK" },
{ responseTime: 20, status: true, message: "OK" },
],
m2: [{ responseTime: 50, status: true, message: "OK" }],
});
},
{
id: "m2",
name: "Monitor 2",
type: "http",
interval: 60000,
recentChecks: [{ responseTime: 50, status: true, message: "OK" }],
},
]);
const service = createService({ monitorsRepository, checksRepository });
const service = createService({ monitorsRepository });
const result = await service.getMonitorsWithChecksByTeamId({ teamId: "team" });
expect(result).toMatchObject({ count: 2 });
expect(result.monitors).toHaveLength(2);
expect(result.monitors[0]).toHaveProperty("checks");
expect(result.monitors[0].checks.length).toBeGreaterThan(0);
expect(result.monitors[0].checks[0]).toEqual(
expect(result.monitors[0]).toHaveProperty("recentChecks");
expect(result.monitors[0].recentChecks.length).toBeGreaterThan(0);
expect(result.monitors[0].recentChecks[0]).toEqual(
expect.objectContaining({
responseTime: expect.any(Number),
status: expect.any(Boolean),
@@ -120,15 +130,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 checks", async () => {
it("includes summary and monitors with recentChecks", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1);
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([{ id: "m1", type: "http" }]);
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([{ id: "m1", type: "http", recentChecks: [] }]);
(monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({
totalMonitors: 1,
upMonitors: 1,
@@ -136,15 +146,12 @@ describe("MonitorService", () => {
pausedMonitors: 0,
});
const checksRepository = createChecksRepositoryMock();
(checksRepository.findLatestChecksByMonitorIds as jest.Mock).mockResolvedValue({ m1: [] });
const service = createService({ monitorsRepository, checksRepository });
const service = createService({ monitorsRepository });
const result = await service.getMonitorsWithChecksByTeamId({ teamId: "team" });
expect(result).toEqual({
summary: { totalMonitors: 1, upMonitors: 1, downMonitors: 0, pausedMonitors: 0 },
count: 1,
monitors: [{ id: "m1", type: "http", checks: [] }],
monitors: [{ id: "m1", type: "http", recentChecks: [] }],
});
});
});
@@ -155,23 +162,11 @@ describe("MonitorService", () => {
const monitor = {
id: "monitor-1",
teamId: TEAM_ID,
name: "Hardware monitor",
name: "HTTP 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(),
};
@@ -179,7 +174,7 @@ describe("MonitorService", () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
const checksRepository = createChecksRepositoryMock();
(checksRepository.findDateRangeChecksByMonitor as jest.Mock).mockResolvedValue({
(checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({
monitorType: "http",
groupedChecks: [{ _id: "2024-01-01", avgResponseTime: 100, totalChecks: 2 }],
groupedUpChecks: [{ _id: "2024-01-01", totalChecks: 2, avgResponseTime: 90 }],
@@ -0,0 +1,347 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { NotificationsService } from "../../../src/service/infrastructure/notificationsService.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { Notification, Monitor, MonitorStatusResponse } from "../../../src/types/index.ts";
import type { MonitorActionDecision } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createProvider = () => ({
sendMessage: jest.fn<() => Promise<boolean>>().mockResolvedValue(true),
sendTestAlert: jest.fn<() => Promise<boolean>>().mockResolvedValue(true),
});
const createNotificationsRepo = () => ({
create: jest.fn(),
findById: jest.fn(),
findNotificationsByIds: jest.fn(),
findByTeamId: jest.fn(),
updateById: jest.fn(),
deleteById: jest.fn(),
});
const createMonitorsRepo = () => ({
removeNotificationFromMonitors: jest.fn(),
});
const createSettingsService = (clientHost = "https://app.example.com") => ({
getSettings: jest.fn().mockReturnValue({ clientHost }),
});
const createMessageBuilder = () => ({
buildMessage: jest.fn().mockReturnValue({ type: "monitor_down", content: { title: "Down" } }),
extractThresholdBreaches: jest.fn(),
});
const createService = (overrides?: Record<string, unknown>) => {
const logger = createMockLogger();
const notificationsRepository = createNotificationsRepo();
const monitorsRepository = createMonitorsRepo();
const webhookProvider = createProvider();
const emailProvider = createProvider();
const slackProvider = createProvider();
const discordProvider = createProvider();
const pagerDutyProvider = createProvider();
const matrixProvider = createProvider();
const teamsProvider = createProvider();
const telegramProvider = createProvider();
const settingsService = createSettingsService();
const notificationMessageBuilder = createMessageBuilder();
const defaults = {
logger,
notificationsRepository,
monitorsRepository,
webhookProvider,
emailProvider,
slackProvider,
discordProvider,
pagerDutyProvider,
matrixProvider,
teamsProvider,
telegramProvider,
settingsService,
notificationMessageBuilder,
...overrides,
};
const service = new NotificationsService(
defaults.notificationsRepository as any,
defaults.monitorsRepository as any,
defaults.webhookProvider as any,
defaults.emailProvider as any,
defaults.slackProvider as any,
defaults.discordProvider as any,
defaults.pagerDutyProvider as any,
defaults.matrixProvider as any,
defaults.teamsProvider as any,
defaults.telegramProvider as any,
defaults.settingsService as any,
defaults.logger as any,
defaults.notificationMessageBuilder as any
);
return { service, ...defaults };
};
const makeNotification = (overrides?: Partial<Notification>): Notification =>
({
id: "notif-1",
userId: "user-1",
teamId: "team-1",
type: "email",
notificationName: "Email Alert",
address: "test@example.com",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
}) as Notification;
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
name: "Test Monitor",
type: "http",
notifications: ["notif-1"],
...overrides,
}) as Monitor;
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: true,
incidentReason: null,
notificationReason: "status_change",
...overrides,
});
const makeStatusResponse = () => ({ monitorId: "mon-1", status: false, code: 500 }) as unknown as MonitorStatusResponse;
// ── Tests ────────────────────────────────────────────────────────────────────
describe("NotificationsService", () => {
// ── handleNotifications ───────────────────────────────────────────────────
describe("handleNotifications", () => {
it("returns false when shouldSendNotification is false", async () => {
const { service } = createService();
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision({ shouldSendNotification: false }));
expect(result).toBe(false);
});
it("sends notifications to all configured providers and returns true", async () => {
const { service, notificationsRepository, emailProvider } = createService();
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type: "email" })]);
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
expect(result).toBe(true);
expect(emailProvider.sendMessage).toHaveBeenCalledTimes(1);
});
it("routes to correct provider for each notification type", async () => {
const types = ["webhook", "slack", "matrix", "pager_duty", "discord", "email", "teams", "telegram"] as const;
for (const type of types) {
const deps = createService();
(deps.notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type })]);
await deps.service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
const providerMap: Record<string, ReturnType<typeof createProvider>> = {
webhook: deps.webhookProvider,
slack: deps.slackProvider,
matrix: deps.matrixProvider,
pager_duty: deps.pagerDutyProvider,
discord: deps.discordProvider,
email: deps.emailProvider,
teams: deps.teamsProvider,
telegram: deps.telegramProvider,
};
expect(providerMap[type].sendMessage).toHaveBeenCalledTimes(1);
}
});
it("returns false and logs warning for unknown notification type", async () => {
const { service, notificationsRepository, logger } = createService();
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type: "carrier_pigeon" as any })]);
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("Unknown notification type: carrier_pigeon") })
);
});
it("returns false and logs warning when notificationMessage is undefined", async () => {
const notificationMessageBuilder = createMessageBuilder();
notificationMessageBuilder.buildMessage.mockReturnValue(undefined);
const { service, notificationsRepository, logger } = createService({ notificationMessageBuilder });
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification()]);
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Notification message not provided" }));
});
it("handles monitors with no notification IDs", async () => {
const { service, notificationsRepository } = createService();
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([]);
const result = await service.handleNotifications(makeMonitor({ notifications: undefined as any }), makeStatusResponse(), makeDecision());
expect(result).toBe(true);
expect(notificationsRepository.findNotificationsByIds).toHaveBeenCalledWith([]);
});
it("returns false and logs when some notifications fail", async () => {
const { service, notificationsRepository, emailProvider, slackProvider, logger } = createService();
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([
makeNotification({ id: "n1", type: "email" }),
makeNotification({ id: "n2", type: "slack" }),
]);
emailProvider.sendMessage.mockResolvedValue(true);
slackProvider.sendMessage.mockResolvedValue(false);
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("1 success, 1 failure") }));
});
it("uses fallback clientHost when settings.clientHost is empty", async () => {
const settingsService = createSettingsService("");
const { service, notificationsRepository, notificationMessageBuilder } = createService({ settingsService });
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification()]);
await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
expect(notificationMessageBuilder.buildMessage).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
"Host not defined"
);
});
});
// ── sendTestNotification ─────────────────────────────────────────────────
describe("sendTestNotification", () => {
it.each([["email"], ["slack"], ["discord"], ["pager_duty"], ["matrix"], ["webhook"], ["teams"], ["telegram"]] as const)(
"routes %s to the correct provider",
async (type) => {
const deps = createService();
const notification = makeNotification({ type: type as any });
const result = await deps.service.sendTestNotification(notification);
expect(result).toBe(true);
const providerMap: Record<string, ReturnType<typeof createProvider>> = {
webhook: deps.webhookProvider,
slack: deps.slackProvider,
matrix: deps.matrixProvider,
pager_duty: deps.pagerDutyProvider,
discord: deps.discordProvider,
email: deps.emailProvider,
teams: deps.teamsProvider,
telegram: deps.telegramProvider,
};
expect(providerMap[type].sendTestAlert).toHaveBeenCalledWith(notification);
}
);
it("returns false for unknown notification type", async () => {
const { service } = createService();
const result = await service.sendTestNotification(makeNotification({ type: "unknown" as any }));
expect(result).toBe(false);
});
});
// ── testAllNotifications ─────────────────────────────────────────────────
describe("testAllNotifications", () => {
it("returns true when all test alerts succeed", async () => {
const { service, notificationsRepository } = createService();
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([
makeNotification({ type: "email" }),
makeNotification({ type: "slack" }),
]);
const result = await service.testAllNotifications(["notif-1", "notif-2"]);
expect(result).toBe(true);
});
it("returns false when any test alert fails", async () => {
const { service, notificationsRepository, emailProvider } = createService();
emailProvider.sendTestAlert.mockResolvedValue(false);
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type: "email" })]);
const result = await service.testAllNotifications(["notif-1"]);
expect(result).toBe(false);
});
});
// ── CRUD operations ──────────────────────────────────────────────────────
describe("createNotification", () => {
it("sets userId and teamId and delegates to repository", async () => {
const created = makeNotification();
const { service, notificationsRepository } = createService();
(notificationsRepository.create as jest.Mock).mockResolvedValue(created);
const result = await service.createNotification({ type: "email", address: "a@b.com" }, "user-1", "team-1");
expect(result).toBe(created);
expect(notificationsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ userId: "user-1", teamId: "team-1", type: "email" }));
});
});
describe("findById", () => {
it("delegates to repository", async () => {
const notification = makeNotification();
const { service, notificationsRepository } = createService();
(notificationsRepository.findById as jest.Mock).mockResolvedValue(notification);
const result = await service.findById("notif-1", "team-1");
expect(result).toBe(notification);
});
});
describe("findNotificationsByTeamId", () => {
it("delegates to repository", async () => {
const notifications = [makeNotification()];
const { service, notificationsRepository } = createService();
(notificationsRepository.findByTeamId as jest.Mock).mockResolvedValue(notifications);
const result = await service.findNotificationsByTeamId("team-1");
expect(result).toBe(notifications);
});
});
describe("updateById", () => {
it("delegates to repository", async () => {
const updated = makeNotification({ address: "new@example.com" });
const { service, notificationsRepository } = createService();
(notificationsRepository.updateById as jest.Mock).mockResolvedValue(updated);
const result = await service.updateById("notif-1", "team-1", { address: "new@example.com" });
expect(result).toBe(updated);
});
});
describe("deleteById", () => {
it("deletes notification and removes from monitors", async () => {
const deleted = makeNotification();
const { service, notificationsRepository, monitorsRepository } = createService();
(notificationsRepository.deleteById as jest.Mock).mockResolvedValue(deleted);
const result = await service.deleteById("notif-1", "team-1");
expect(result).toBe(deleted);
expect(monitorsRepository.removeNotificationFromMonitors).toHaveBeenCalledWith("notif-1");
});
});
});
@@ -0,0 +1,889 @@
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 }));
});
});
});
@@ -0,0 +1,564 @@
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();
});
});
});
@@ -0,0 +1,718 @@
import { describe, expect, it, jest } from "@jest/globals";
import { SuperSimpleQueueHelper } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
import type { Monitor } from "../../../src/types/monitor.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createHelper = (overrides?: Record<string, unknown>) => {
const maintenanceWindowsRepository = {
findByMonitorId: jest.fn().mockResolvedValue([]),
};
const monitorsRepository = {
updateById: jest.fn().mockResolvedValue({}),
findAllMonitorIds: jest.fn().mockResolvedValue(["m1"]),
deleteByTeamIdsNotIn: jest.fn().mockResolvedValue(0),
};
const teamsRepository = {
findAllTeamIds: jest.fn().mockResolvedValue(["team"]),
};
const monitorStatsRepository = {
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
};
const checksRepository = {
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
};
const incidentsRepository = {
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
};
const geoChecksRepository = {
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
};
const statusServiceMock = {
updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1", status: "up" }, statusChanged: false, prevStatus: "up", code: 200 }),
};
const settingsServiceMock = {
getDBSettings: jest.fn().mockResolvedValue({ checkTTL: 30 }),
};
const geoChecksServiceMock = {
buildGeoCheck: jest.fn().mockResolvedValue(null),
};
const defaults = {
logger: createMockLogger(),
networkService: { requestStatus: jest.fn() },
statusService: statusServiceMock,
notificationsService: { handleNotifications: jest.fn().mockResolvedValue(undefined) },
checkService: { buildCheck: jest.fn().mockReturnValue({ id: "check-1" }), deleteOlderThan: jest.fn().mockResolvedValue(0) },
settingsService: settingsServiceMock,
buffer: { addToBuffer: jest.fn(), addGeoCheckToBuffer: jest.fn() },
incidentService: { handleIncident: jest.fn().mockResolvedValue(undefined) },
maintenanceWindowsRepository,
monitorsRepository,
teamsRepository,
monitorStatsRepository,
checksRepository,
incidentsRepository,
geoChecksService: geoChecksServiceMock,
geoChecksRepository,
...overrides,
};
const helper = new SuperSimpleQueueHelper(
defaults.logger as any,
defaults.networkService as any,
defaults.statusService as any,
defaults.notificationsService as any,
defaults.checkService as any,
defaults.settingsService as any,
defaults.buffer as any,
defaults.incidentService as any,
defaults.maintenanceWindowsRepository as any,
defaults.monitorsRepository as any,
defaults.teamsRepository as any,
defaults.monitorStatsRepository as any,
defaults.checksRepository as any,
defaults.incidentsRepository as any,
defaults.geoChecksService as any,
defaults.geoChecksRepository as any
);
return { helper, defaults, maintenanceWindowsRepository };
};
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "m1",
teamId: "team",
type: "http",
interval: 60000,
status: "up",
geoCheckEnabled: false,
geoCheckLocations: [],
...overrides,
}) as Monitor;
// ── Tests ────────────────────────────────────────────────────────────────────
describe("SuperSimpleQueueHelper", () => {
describe("serviceName", () => {
it("returns JobQueueHelper", () => {
const { helper } = createHelper();
expect(helper.serviceName).toBe("JobQueueHelper");
});
});
// ── getHeartbeatJob ──────────────────────────────────────────────────────
describe("getHeartbeatJob", () => {
it("throws when monitor id is missing", async () => {
const { helper } = createHelper();
const job = helper.getHeartbeatJob();
await expect(job({} as Monitor)).rejects.toThrow("No monitor id");
});
it("skips execution and sets maintenance status when in maintenance window", async () => {
const { helper, defaults } = createHelper();
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true);
const job = helper.getHeartbeatJob();
await job(makeMonitor({ status: "up" }));
expect(defaults.networkService.requestStatus).not.toHaveBeenCalled();
expect(defaults.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { status: "maintenance" });
});
it("skips monitor status update when already in maintenance", async () => {
const { helper, defaults } = createHelper();
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true);
const job = helper.getHeartbeatJob();
await job(makeMonitor({ status: "maintenance" }));
expect(defaults.monitorsRepository.updateById).not.toHaveBeenCalled();
});
it("throws when network response is null", async () => {
const { helper } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(null) },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await expect(job(makeMonitor())).rejects.toThrow("No network response");
});
it("returns early and warns when buildCheck returns null", async () => {
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
checkService: { buildCheck: jest.fn().mockReturnValue(null), deleteOlderThan: jest.fn() },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No check could be built") }));
expect(defaults.statusService.updateMonitorStatus).not.toHaveBeenCalled();
});
it("processes full pipeline: network → check → status → incident", async () => {
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
expect(defaults.buffer.addToBuffer).toHaveBeenCalled();
expect(defaults.statusService.updateMonitorStatus).toHaveBeenCalled();
expect(defaults.incidentService.handleIncident).toHaveBeenCalled();
});
it("sends notifications when decision says shouldSendNotification", async () => {
const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" };
const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
expect(defaults.notificationsService.handleNotifications).toHaveBeenCalledWith(
statusResult.monitor,
networkResponse,
expect.objectContaining({ shouldSendNotification: true, shouldCreateIncident: true })
);
});
it("logs error when notifications fail (fire-and-forget)", async () => {
const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" };
const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 };
const notificationsService = { handleNotifications: jest.fn().mockRejectedValue(new Error("smtp down")) };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) },
notificationsService,
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
// Wait for the fire-and-forget catch to execute
await new Promise((r) => setTimeout(r, 10));
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("smtp down") }));
});
it("logs error when notifications fail with non-Error (fire-and-forget)", async () => {
const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" };
const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 };
const notificationsService = { handleNotifications: jest.fn().mockRejectedValue("string error") };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) },
notificationsService,
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
await new Promise((r) => setTimeout(r, 10));
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Unknown error") }));
});
it("logs warning when incident handling fails (fire-and-forget)", async () => {
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
const incidentService = { handleIncident: jest.fn().mockRejectedValue(new Error("db error")) };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
incidentService,
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
await new Promise((r) => setTimeout(r, 10));
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("db error") }));
});
it("logs warning when incident handling fails with non-Error", async () => {
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
const incidentService = { handleIncident: jest.fn().mockRejectedValue(42) };
const { helper, defaults } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
incidentService,
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
await new Promise((r) => setTimeout(r, 10));
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Unknown error") }));
});
it("logs and rethrows on unexpected error with non-Error", async () => {
const { helper } = createHelper({
networkService: { requestStatus: jest.fn().mockRejectedValue("unexpected") },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await expect(job(makeMonitor())).rejects.toBe("unexpected");
});
});
// ── getHeartbeatGeoJob ───────────────────────────────────────────────────
describe("getHeartbeatGeoJob", () => {
it("throws nothing when monitor id is missing (logs error)", async () => {
const { helper, defaults } = createHelper();
const job = helper.getHeartbeatGeoJob();
await job({} as Monitor);
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "No monitor id" }));
});
it("returns early when geoCheckEnabled is false", async () => {
const { helper, defaults } = createHelper();
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: false }));
expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled();
});
it("returns early when monitor type does not support geo checks", async () => {
const { helper, defaults } = createHelper();
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "hardware", geoCheckLocations: ["us-east"] }));
expect(defaults.logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("does not support geo checks") })
);
expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled();
});
it("returns early when geoCheckLocations is empty", async () => {
const { helper, defaults } = createHelper();
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: [] }));
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") }));
});
it("returns early when geoCheckLocations is undefined", async () => {
const { helper, defaults } = createHelper();
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: undefined as any }));
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") }));
});
it("skips when in maintenance window", async () => {
const { helper, defaults } = createHelper();
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true);
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("maintenance window") }));
expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled();
});
it("warns when buildGeoCheck returns null", async () => {
const { helper, defaults } = createHelper();
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check could be built") }));
});
it("adds geo check to buffer on success", async () => {
const geoCheck = { id: "gc-1", monitorId: "m1" };
const { helper, defaults } = createHelper({
geoChecksService: { buildGeoCheck: jest.fn().mockResolvedValue(geoCheck) },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
expect(defaults.buffer.addGeoCheckToBuffer).toHaveBeenCalledWith(geoCheck);
expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Geo check job executed") }));
});
it("logs error on unexpected failure without rethrowing", async () => {
const { helper, defaults } = createHelper({
geoChecksService: { buildGeoCheck: jest.fn().mockRejectedValue(new Error("api timeout")) },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "api timeout" }));
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { helper, defaults } = createHelper({
geoChecksService: { buildGeoCheck: jest.fn().mockRejectedValue("string error") },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatGeoJob();
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
// ── getCleanupOrphanedJob ────────────────────────────────────────────────
describe("getCleanupOrphanedJob", () => {
it("cleans up orphaned data across all repositories", async () => {
const { helper, defaults } = createHelper();
(defaults.monitorsRepository.deleteByTeamIdsNotIn as jest.Mock).mockResolvedValue(2);
(defaults.monitorStatsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(3);
(defaults.checksRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(4);
(defaults.incidentsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(1);
(defaults.geoChecksRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(5);
const job = helper.getCleanupOrphanedJob();
await job();
expect(defaults.teamsRepository.findAllTeamIds).toHaveBeenCalled();
expect(defaults.monitorsRepository.deleteByTeamIdsNotIn).toHaveBeenCalledWith(["team"]);
expect(defaults.monitorsRepository.findAllMonitorIds).toHaveBeenCalled();
expect(defaults.monitorStatsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
expect(defaults.checksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
expect(defaults.incidentsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
expect(defaults.geoChecksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
// Logs info for each deleted count > 0
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("2 orphaned monitors") }));
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("3 orphaned monitor stats") }));
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("4 orphaned checks") }));
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("1 orphaned incidents") }));
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("5 orphaned geo checks") }));
});
it("skips info logs when no orphaned data is found", async () => {
const { helper, defaults } = createHelper();
const job = helper.getCleanupOrphanedJob();
await job();
// Only start and completion logs, no deletion logs
const infoMessages = (defaults.logger.info as jest.Mock).mock.calls.map((c: any) => c[0].message);
expect(infoMessages).toContain("Starting cleanup of orphaned data");
expect(infoMessages).toContain("Cleanup of orphaned data completed");
expect(infoMessages).not.toContain(expect.stringContaining("orphaned monitors"));
});
it("logs warning and rethrows on error", async () => {
const { helper, defaults } = createHelper();
(defaults.teamsRepository.findAllTeamIds as jest.Mock).mockRejectedValue(new Error("db down"));
const job = helper.getCleanupOrphanedJob();
await expect(job()).rejects.toThrow("db down");
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "db down" }));
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { helper, defaults } = createHelper();
(defaults.teamsRepository.findAllTeamIds as jest.Mock).mockRejectedValue(null);
const job = helper.getCleanupOrphanedJob();
await expect(job()).rejects.toBeNull();
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
// ── getCleanupRetentionJob ───────────────────────────────────────────────
describe("getCleanupRetentionJob", () => {
it("deletes checks older than TTL cutoff", async () => {
const { helper, defaults } = createHelper();
const job = helper.getCleanupRetentionJob();
await job();
expect(defaults.checkService.deleteOlderThan).toHaveBeenCalledWith(expect.any(Date));
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Deleted") }));
});
it("skips cleanup when checkTTL is sentinel value (366)", async () => {
const { helper, defaults } = createHelper({
settingsService: { getDBSettings: jest.fn().mockResolvedValue({ checkTTL: 366 }) },
});
const job = helper.getCleanupRetentionJob();
await job();
expect(defaults.checkService.deleteOlderThan).not.toHaveBeenCalled();
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("unlimited") }));
});
it("logs error on failure without rethrowing", async () => {
const { helper, defaults } = createHelper({
settingsService: { getDBSettings: jest.fn().mockRejectedValue(new Error("db error")) },
});
const job = helper.getCleanupRetentionJob();
await job(); // Should not throw
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "db error" }));
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { helper, defaults } = createHelper({
settingsService: { getDBSettings: jest.fn().mockRejectedValue("boom") },
});
const job = helper.getCleanupRetentionJob();
await job();
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
// ── isInMaintenanceWindow ────────────────────────────────────────────────
describe("isInMaintenanceWindow", () => {
it("returns true when an active window spans now", async () => {
const now = new Date();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now.getTime() - 1000).toISOString(),
end: new Date(now.getTime() + 1000).toISOString(),
repeat: 0,
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
});
it("returns true when repeat interval advances window into current time", async () => {
const now = Date.now();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now - 7200000).toISOString(),
end: new Date(now - 6600000).toISOString(),
repeat: 3600000,
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
});
it("returns false when no active windows exist", async () => {
const { helper } = createHelper();
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
});
it("returns false when window is inactive", async () => {
const now = new Date();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: false,
start: new Date(now.getTime() - 1000).toISOString(),
end: new Date(now.getTime() + 1000).toISOString(),
repeat: 0,
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
});
it("returns false when active window is in the past without repeat", async () => {
const now = Date.now();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now - 5000).toISOString(),
end: new Date(now - 1000).toISOString(),
repeat: 0,
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
});
it("returns false when repeat advances past current time without match", async () => {
const now = Date.now();
const { helper, maintenanceWindowsRepository } = createHelper();
// Window was 2 hours ago, lasts 10 minutes, repeats every hour
// First repeat: -1h to -50min (past), second repeat: now to +10min... actually this would match
// Use a repeat that skips over now
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now - 3600000 * 3 - 300000).toISOString(), // 3h5min ago
end: new Date(now - 3600000 * 3).toISOString(), // 3h ago, 5min window
repeat: 3600000, // 1 hour repeat
},
]);
// Advances: -2h5m to -2h, -1h5m to -1h, -5m to 0 — that last one might match if now is within 5min
// Let's use a different setup that clearly doesn't match
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now - 1800000 - 60000).toISOString(), // 31 min ago
end: new Date(now - 1800000).toISOString(), // 30 min ago, 1 min window
repeat: 3600000, // 1 hour repeat — next window would be in 29 min
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
});
it("preserves previous true result via accumulator", async () => {
const now = new Date();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now.getTime() - 1000).toISOString(),
end: new Date(now.getTime() + 1000).toISOString(),
repeat: 0,
},
{
active: false,
start: new Date(now.getTime() - 1000).toISOString(),
end: new Date(now.getTime() + 1000).toISOString(),
repeat: 0,
},
]);
// Second window is inactive but first already matched — accumulator should preserve true
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
});
});
// ── evaluateMonitorAction (tested indirectly via getHeartbeatJob) ─────────
describe("evaluateMonitorAction", () => {
const runJobAndGetDecision = async (statusResult: Record<string, unknown>) => {
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
const incidentService = { handleIncident: jest.fn().mockResolvedValue(undefined) };
const notificationsService = { handleNotifications: jest.fn().mockResolvedValue(undefined) };
const { helper } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) },
incidentService,
notificationsService,
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getHeartbeatJob();
await job(makeMonitor());
return { incidentService, notificationsService };
};
it("does nothing when statusChanged is false", async () => {
const { incidentService, notificationsService } = await runJobAndGetDecision({
monitor: { id: "m1", status: "up" },
statusChanged: false,
prevStatus: "up",
code: 200,
});
expect(incidentService.handleIncident).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ shouldCreateIncident: false, shouldResolveIncident: false, shouldSendNotification: false }),
expect.anything()
);
});
it("creates incident and notifies when monitor goes down", async () => {
const { incidentService } = await runJobAndGetDecision({
monitor: { id: "m1", status: "down" },
statusChanged: true,
prevStatus: "up",
code: 500,
});
expect(incidentService.handleIncident).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
shouldCreateIncident: true,
shouldSendNotification: true,
incidentReason: "status_down",
notificationReason: "status_change",
}),
expect.anything()
);
});
it("creates incident and notifies when monitor is breached", async () => {
const { incidentService } = await runJobAndGetDecision({
monitor: { id: "m1", status: "breached" },
statusChanged: true,
prevStatus: "up",
code: 200,
});
expect(incidentService.handleIncident).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
shouldCreateIncident: true,
incidentReason: "threshold_breach",
notificationReason: "threshold_breach",
}),
expect.anything()
);
});
it("resolves incident when monitor recovers from down", async () => {
const { incidentService } = await runJobAndGetDecision({
monitor: { id: "m1", status: "up" },
statusChanged: true,
prevStatus: "down",
code: 200,
});
expect(incidentService.handleIncident).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ shouldResolveIncident: true, shouldSendNotification: true }),
expect.anything()
);
});
it("resolves incident when monitor recovers from breached", async () => {
const { incidentService } = await runJobAndGetDecision({
monitor: { id: "m1", status: "up" },
statusChanged: true,
prevStatus: "breached",
code: 200,
});
expect(incidentService.handleIncident).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ shouldResolveIncident: true }),
expect.anything()
);
});
it("does not create or resolve for unhandled status transitions", async () => {
const { incidentService } = await runJobAndGetDecision({
monitor: { id: "m1", status: "paused" },
statusChanged: true,
prevStatus: "up",
code: 200,
});
expect(incidentService.handleIncident).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ shouldCreateIncident: false, shouldResolveIncident: false, shouldSendNotification: false }),
expect.anything()
);
});
});
});