Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db378120be | ||
|
|
ba5368f942 | ||
|
|
7329306ccf | ||
|
|
365987c287 | ||
|
|
09d6147f75 | ||
|
|
86ab1f2146 | ||
|
|
934f85f505 | ||
|
|
96beda5c47 | ||
|
|
504d6157e5 | ||
|
|
628bca3e29 | ||
|
|
2c76484bad | ||
|
|
c80720c8c9 |
No files matched your search
+105
-105
@@ -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>
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
-86
@@ -69,7 +69,6 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||
"@typescript-eslint/parser": "^8.56.1",
|
||||
"c8": "10.1.3",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-mocha": "^10.5.0",
|
||||
"esm": "3.2.25",
|
||||
@@ -1358,16 +1357,6 @@
|
||||
"integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@bcoe/v8-coverage": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@colors/colors": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
|
||||
@@ -6053,40 +6042,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/c8": {
|
||||
"version": "10.1.3",
|
||||
"resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
|
||||
"integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.1",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"find-up": "^5.0.0",
|
||||
"foreground-child": "^3.1.1",
|
||||
"istanbul-lib-coverage": "^3.2.0",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.1.6",
|
||||
"test-exclude": "^7.0.1",
|
||||
"v8-to-istanbul": "^9.0.0",
|
||||
"yargs": "^17.7.2",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"c8": "bin/c8.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"monocart-coverage-reports": "^2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"monocart-coverage-reports": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/cacheable-lookup": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
|
||||
@@ -14666,47 +14621,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
|
||||
"integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@istanbuljs/schema": "^0.1.2",
|
||||
"glob": "^10.4.1",
|
||||
"minimatch": "^9.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/text-hex": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
|
||||
|
||||
+2
-2
@@ -5,7 +5,8 @@
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "NODE_OPTIONS=--experimental-vm-modules c8 jest --runInBand",
|
||||
"test": "NODE_OPTIONS=--experimental-vm-modules jest --coverage",
|
||||
"test:services": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/service/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",
|
||||
@@ -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",
|
||||
|
||||
@@ -107,16 +107,16 @@ export class CheckService implements ICheckService {
|
||||
numericUnit: audit.numericUnit,
|
||||
};
|
||||
};
|
||||
check.accessibility = (categories?.accessibility?.score || 0) * 100;
|
||||
check.bestPractices = (categories?.["best-practices"]?.score || 0) * 100;
|
||||
check.seo = (categories?.seo?.score || 0) * 100;
|
||||
check.performance = (categories?.performance?.score || 0) * 100;
|
||||
check.accessibility = (categories.accessibility?.score || 0) * 100;
|
||||
check.bestPractices = (categories["best-practices"]?.score || 0) * 100;
|
||||
check.seo = (categories.seo?.score || 0) * 100;
|
||||
check.performance = (categories.performance?.score || 0) * 100;
|
||||
check.audits = {
|
||||
cls: mapAudit(audits?.["cumulative-layout-shift"]),
|
||||
si: mapAudit(audits?.["speed-index"]),
|
||||
fcp: mapAudit(audits?.["first-contentful-paint"]),
|
||||
lcp: mapAudit(audits?.["largest-contentful-paint"]),
|
||||
tbt: mapAudit(audits?.["total-blocking-time"]),
|
||||
cls: mapAudit(audits["cumulative-layout-shift"]),
|
||||
si: mapAudit(audits["speed-index"]),
|
||||
fcp: mapAudit(audits["first-contentful-paint"]),
|
||||
lcp: mapAudit(audits["largest-contentful-paint"]),
|
||||
tbt: mapAudit(audits["total-blocking-time"]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ export class CheckService implements ICheckService {
|
||||
const hardwarePayload = payload as HardwareStatusPayload | undefined;
|
||||
const { cpu, memory, disk, host, net } = hardwarePayload?.data ?? {};
|
||||
const errorsSource = Array.isArray(hardwarePayload?.errors)
|
||||
? hardwarePayload?.errors
|
||||
? hardwarePayload.errors
|
||||
: (hardwarePayload?.errors as { errors?: CheckErrorInfo[] } | undefined)?.errors;
|
||||
check.cpu = cpu;
|
||||
check.memory = memory;
|
||||
|
||||
@@ -95,17 +95,14 @@ export class IncidentService implements IIncidentService {
|
||||
}
|
||||
}
|
||||
|
||||
if (decision.shouldResolveIncident) {
|
||||
if (!activeIncident) {
|
||||
return null;
|
||||
}
|
||||
activeIncident.status = false;
|
||||
activeIncident.endTime = Date.now().toString();
|
||||
activeIncident.resolutionType = "automatic";
|
||||
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
|
||||
if (!decision.shouldResolveIncident || !activeIncident) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
activeIncident.status = false;
|
||||
activeIncident.endTime = Date.now().toString();
|
||||
activeIncident.resolutionType = "automatic";
|
||||
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
|
||||
};
|
||||
|
||||
private buildThresholdBreachMessage(monitor: Monitor, monitorStatusResponse?: MonitorStatusResponse): string {
|
||||
|
||||
@@ -306,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,
|
||||
@@ -339,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);
|
||||
|
||||
@@ -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() }),
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+30
-45
@@ -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 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
+58
-63
@@ -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 },
|
||||
{
|
||||
id: "m1",
|
||||
name: "Monitor 1",
|
||||
type: "http",
|
||||
interval: 60000,
|
||||
recentChecks: [
|
||||
{ responseTime: 10, status: true, message: "OK" },
|
||||
{ responseTime: 20, status: true, message: "OK" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "m2",
|
||||
name: "Monitor 2",
|
||||
type: "http",
|
||||
interval: 60000,
|
||||
recentChecks: [{ responseTime: 50, status: true, message: "OK" }],
|
||||
},
|
||||
]);
|
||||
|
||||
const checksRepository = createChecksRepositoryMock();
|
||||
(checksRepository.findLatestChecksByMonitorIds as jest.Mock).mockResolvedValue({
|
||||
m1: [
|
||||
{ responseTime: 10, status: true, message: "OK" },
|
||||
{ responseTime: 20, status: true, message: "OK" },
|
||||
],
|
||||
m2: [{ responseTime: 50, status: true, message: "OK" }],
|
||||
});
|
||||
|
||||
const service = createService({ monitorsRepository, checksRepository });
|
||||
const 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()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user