Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
206c6df4aa | ||
|
|
e145068287 | ||
|
|
987ed64256 | ||
|
|
ae85af26bf | ||
|
|
023cf5782d | ||
|
|
babe5ae177 |
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,6 +29,7 @@ Checkmate has been stress-tested with 1000+ active monitors without any particul
|
||||
- [📦 Demo](#demo)
|
||||
- [🔗 User's guide](#users-guide)
|
||||
- [🛠️ Installation](#installation)
|
||||
- [🏁 Translations](#translations)
|
||||
- [🚀 Performance](#performance)
|
||||
- [💚 Questions & Ideas](#questions--ideas)
|
||||
- [🧩 Features](#features)
|
||||
@@ -55,7 +56,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 [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
|
||||
Alternatively, you can also use [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
|
||||
|
||||
### Using a Custom CA
|
||||
|
||||
@@ -63,6 +64,11 @@ If you need to monitor internal HTTPS endpoints with certificates from private C
|
||||
|
||||
For more documentation, see the [docs directory](./docs/).
|
||||
|
||||
|
||||
## Translations
|
||||
|
||||
If you would like to use Checkmate in your language, please [go to this page](https://poeditor.com/join/project/lRUoGZFCsJ) and register for the language you would like to translate Checkmate to.
|
||||
|
||||
## Performance
|
||||
|
||||
Thanks to extensive optimizations, Checkmate operates with an exceptionally small memory footprint, requiring minimal memory and CPU resources. Here’s the memory usage of a Node.js instance running on a server that monitors 323 servers every minute:
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
/**
|
||||
* Locale Migration Script
|
||||
*
|
||||
* Restructures all non-English locale files to match en.json's nested key structure.
|
||||
* Recovers translations via safe key-matching strategies, uses English placeholders
|
||||
* for unmatched keys.
|
||||
*
|
||||
* Usage: npx tsx client/scripts/migrate-locales.ts
|
||||
*
|
||||
* SAFETY: en.json is NEVER modified. Originals are backed up before overwriting.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const LOCALES_DIR = path.resolve(import.meta.dirname, "../src/locales");
|
||||
const BACKUP_DIR = path.join(LOCALES_DIR, "backup");
|
||||
const REPORT_PATH = path.join(LOCALES_DIR, "migration-report.json");
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Flatten a nested object into { "a.b.c": value } pairs */
|
||||
function flattenKeys(obj: Record<string, unknown>, prefix = ""): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const fullPath = prefix ? `${prefix}.${key}` : key;
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
Object.assign(result, flattenKeys(value as Record<string, unknown>, fullPath));
|
||||
} else {
|
||||
result[fullPath] = String(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Set a value at a dot-separated path in a nested object */
|
||||
function setNestedValue(
|
||||
obj: Record<string, unknown>,
|
||||
keyPath: string,
|
||||
value: string
|
||||
): void {
|
||||
const parts = keyPath.split(".");
|
||||
let current = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i];
|
||||
if (!(part in current) || typeof current[part] !== "object") {
|
||||
current[part] = {};
|
||||
}
|
||||
current = current[part] as Record<string, unknown>;
|
||||
}
|
||||
current[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
// ── Build mapping indexes from en.json ───────────────────────────────────────
|
||||
|
||||
function buildIndexes(enFlat: Record<string, string>) {
|
||||
const enKeys = Object.keys(enFlat);
|
||||
|
||||
// last segment -> en key paths
|
||||
const lastSegIndex: Record<string, string[]> = {};
|
||||
// last 2 segments -> en key paths
|
||||
const suffixIndex: Record<string, string[]> = {};
|
||||
|
||||
for (const keyPath of enKeys) {
|
||||
const parts = keyPath.split(".");
|
||||
const lastSeg = parts[parts.length - 1].toLowerCase();
|
||||
if (!lastSegIndex[lastSeg]) lastSegIndex[lastSeg] = [];
|
||||
lastSegIndex[lastSeg].push(keyPath);
|
||||
|
||||
if (parts.length >= 2) {
|
||||
const suffix = parts.slice(-2).join(".").toLowerCase();
|
||||
if (!suffixIndex[suffix]) suffixIndex[suffix] = [];
|
||||
suffixIndex[suffix].push(keyPath);
|
||||
}
|
||||
}
|
||||
|
||||
return { lastSegIndex, suffixIndex };
|
||||
}
|
||||
|
||||
// ── Mapping logic ────────────────────────────────────────────────────────────
|
||||
|
||||
type MatchStrategy = "exact" | "suffix-2" | "unique-name" | "english-placeholder";
|
||||
|
||||
interface KeyMatch {
|
||||
enKey: string;
|
||||
value: string;
|
||||
strategy: MatchStrategy;
|
||||
}
|
||||
|
||||
function buildMigratedLocale(
|
||||
enFlat: Record<string, string>,
|
||||
langFlat: Record<string, string>,
|
||||
indexes: ReturnType<typeof buildIndexes>
|
||||
): { matches: KeyMatch[]; result: Record<string, unknown> } {
|
||||
const { lastSegIndex, suffixIndex } = indexes;
|
||||
const matches: KeyMatch[] = [];
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
// Build reverse lookup: for each orphan key in lang, index by last segment and suffix
|
||||
const langByLastSeg: Record<string, { key: string; value: string }[]> = {};
|
||||
const langBySuffix: Record<string, { key: string; value: string }[]> = {};
|
||||
|
||||
for (const [langKey, langValue] of Object.entries(langFlat)) {
|
||||
const parts = langKey.split(".");
|
||||
const lastSeg = parts[parts.length - 1].toLowerCase();
|
||||
if (!langByLastSeg[lastSeg]) langByLastSeg[lastSeg] = [];
|
||||
langByLastSeg[lastSeg].push({ key: langKey, value: langValue });
|
||||
|
||||
if (parts.length >= 2) {
|
||||
const suffix = parts.slice(-2).join(".").toLowerCase();
|
||||
if (!langBySuffix[suffix]) langBySuffix[suffix] = [];
|
||||
langBySuffix[suffix].push({ key: langKey, value: langValue });
|
||||
}
|
||||
}
|
||||
|
||||
for (const enKey of Object.keys(enFlat)) {
|
||||
const enValue = enFlat[enKey];
|
||||
const parts = enKey.split(".");
|
||||
const lastSeg = parts[parts.length - 1].toLowerCase();
|
||||
const suffix = parts.length >= 2 ? parts.slice(-2).join(".").toLowerCase() : null;
|
||||
|
||||
// Strategy 1: Exact path match
|
||||
if (langFlat[enKey] !== undefined) {
|
||||
const value = langFlat[enKey];
|
||||
setNestedValue(result, enKey, value);
|
||||
matches.push({ enKey, value, strategy: "exact" });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strategy 2: 2-segment suffix match (lang key suffix matches en key suffix, both unique)
|
||||
if (suffix) {
|
||||
const enCandidates = suffixIndex[suffix];
|
||||
const langCandidates = langBySuffix[suffix];
|
||||
if (
|
||||
enCandidates &&
|
||||
enCandidates.length === 1 &&
|
||||
langCandidates &&
|
||||
langCandidates.length === 1
|
||||
) {
|
||||
const value = langCandidates[0].value;
|
||||
setNestedValue(result, enKey, value);
|
||||
matches.push({ enKey, value, strategy: "suffix-2" });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Unique last-segment match
|
||||
const enCandidates = lastSegIndex[lastSeg];
|
||||
const langCandidates = langByLastSeg[lastSeg];
|
||||
if (
|
||||
enCandidates &&
|
||||
enCandidates.length === 1 &&
|
||||
langCandidates &&
|
||||
langCandidates.length === 1
|
||||
) {
|
||||
const value = langCandidates[0].value;
|
||||
setNestedValue(result, enKey, value);
|
||||
matches.push({ enKey, value, strategy: "unique-name" });
|
||||
continue;
|
||||
}
|
||||
|
||||
// No match — use English placeholder
|
||||
setNestedValue(result, enKey, enValue);
|
||||
matches.push({ enKey, value: enValue, strategy: "english-placeholder" });
|
||||
}
|
||||
|
||||
return { matches, result };
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function main() {
|
||||
// Load en.json
|
||||
const enPath = path.join(LOCALES_DIR, "en.json");
|
||||
const en = JSON.parse(fs.readFileSync(enPath, "utf8"));
|
||||
const enFlat = flattenKeys(en);
|
||||
const enKeyCount = Object.keys(enFlat).length;
|
||||
const indexes = buildIndexes(enFlat);
|
||||
|
||||
console.log(`English: ${enKeyCount} keys (source of truth — NOT modified)\n`);
|
||||
|
||||
// Find all non-English locale files
|
||||
const localeFiles = fs
|
||||
.readdirSync(LOCALES_DIR)
|
||||
.filter(
|
||||
(f: string) =>
|
||||
f.endsWith(".json") && f !== "en.json" && f !== "migration-report.json"
|
||||
);
|
||||
|
||||
if (localeFiles.length === 0) {
|
||||
console.log("No locale files to migrate.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create backup directory
|
||||
if (!fs.existsSync(BACKUP_DIR)) {
|
||||
fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const report: Record<
|
||||
string,
|
||||
{
|
||||
total: number;
|
||||
exact: number;
|
||||
suffix: number;
|
||||
uniqueName: number;
|
||||
placeholder: number;
|
||||
}
|
||||
> = {};
|
||||
|
||||
for (const file of localeFiles) {
|
||||
const lang = file.replace(".json", "");
|
||||
const filePath = path.join(LOCALES_DIR, file);
|
||||
|
||||
// Backup original
|
||||
const backupPath = path.join(BACKUP_DIR, file);
|
||||
fs.copyFileSync(filePath, backupPath);
|
||||
|
||||
// Load and flatten
|
||||
const langData = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
const langFlat = flattenKeys(langData);
|
||||
|
||||
// Migrate
|
||||
const { matches, result } = buildMigratedLocale(enFlat, langFlat, indexes);
|
||||
|
||||
// Write migrated file
|
||||
fs.writeFileSync(filePath, JSON.stringify(result, null, "\t") + "\n", "utf8");
|
||||
|
||||
// Compute stats
|
||||
const stats = {
|
||||
total: enKeyCount,
|
||||
exact: matches.filter((m) => m.strategy === "exact").length,
|
||||
suffix: matches.filter((m) => m.strategy === "suffix-2").length,
|
||||
uniqueName: matches.filter((m) => m.strategy === "unique-name").length,
|
||||
placeholder: matches.filter((m) => m.strategy === "english-placeholder").length,
|
||||
};
|
||||
report[lang] = stats;
|
||||
|
||||
const translated = stats.exact + stats.suffix + stats.uniqueName;
|
||||
const pct = ((translated / enKeyCount) * 100).toFixed(1);
|
||||
console.log(
|
||||
`${lang.padEnd(8)} ${String(translated).padStart(4)} translated (${pct}%) | ` +
|
||||
`exact: ${stats.exact} suffix: ${stats.suffix} name: ${stats.uniqueName} ` +
|
||||
`placeholder: ${stats.placeholder}`
|
||||
);
|
||||
}
|
||||
|
||||
// Write report
|
||||
fs.writeFileSync(REPORT_PATH, JSON.stringify(report, null, "\t") + "\n", "utf8");
|
||||
|
||||
const totalLangs = localeFiles.length;
|
||||
console.log(
|
||||
`\nMigrated ${totalLangs} locale files. Originals backed up to locales/backup/`
|
||||
);
|
||||
console.log(`Report written to ${REPORT_PATH}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -40,6 +40,7 @@ export const useMonitorForm = ({
|
||||
matchMethod: data?.matchMethod || "",
|
||||
expectedValue: data?.expectedValue || "",
|
||||
jsonPath: data?.jsonPath || "",
|
||||
customUserAgent: data?.customUserAgent || "",
|
||||
};
|
||||
break;
|
||||
case "ping":
|
||||
|
||||
@@ -40,6 +40,7 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
|
||||
: 80,
|
||||
},
|
||||
checkTTL: data?.checkTTL ?? 30,
|
||||
defaultUserAgent: data?.defaultUserAgent || "",
|
||||
pagespeedApiKey: "",
|
||||
systemEmailPassword: "",
|
||||
};
|
||||
|
||||
@@ -795,6 +795,40 @@ const CreateMonitorPage = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{watchedType === "http" && (
|
||||
<ConfigBox
|
||||
title={t("pages.createMonitor.form.userAgent.title")}
|
||||
subtitle={t("pages.createMonitor.form.userAgent.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="customUserAgent"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
type="text"
|
||||
fieldLabel={t("pages.createMonitor.form.userAgent.option.label")}
|
||||
placeholder={t("pages.createMonitor.form.userAgent.option.placeholder")}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
onChange={(e) => {
|
||||
field.onChange(
|
||||
e.target.value
|
||||
.replace(/[<>]/g, "")
|
||||
.replace(/(?:javascript|data|vbscript):/gi, "")
|
||||
.replace(/[^\t\x20-\x7E\x80-\xFF]/g, "") // RFC 7230 header-safe chars only
|
||||
.slice(0, 500)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{watchedType === "http" && (
|
||||
<ConfigBox
|
||||
title={t("pages.createMonitor.form.advanced.title")}
|
||||
|
||||
@@ -425,6 +425,39 @@ export const SettingsPage = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Default User-Agent - Admin Only */}
|
||||
{isAdmin && (
|
||||
<ConfigBox
|
||||
title={t("pages.settings.form.userAgent.title")}
|
||||
subtitle={t("pages.settings.form.userAgent.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="defaultUserAgent"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
fieldLabel={t("pages.settings.form.userAgent.option.label")}
|
||||
placeholder={t("pages.settings.form.userAgent.option.placeholder")}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
onChange={(e) => {
|
||||
field.onChange(
|
||||
e.target.value
|
||||
.replace(/[<>]/g, "")
|
||||
.replace(/(?:javascript|data|vbscript):/gi, "")
|
||||
.replace(/[^\t\x20-\x7E\x80-\xFF]/g, "") // RFC 7230 header-safe chars only
|
||||
.slice(0, 500)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Clear All Stats */}
|
||||
{isAdmin && (
|
||||
<ConfigBox
|
||||
|
||||
@@ -73,6 +73,7 @@ export interface Monitor {
|
||||
gameId?: string;
|
||||
grpcServiceName?: string;
|
||||
group: string | null;
|
||||
customUserAgent?: string | null;
|
||||
geoCheckEnabled?: boolean;
|
||||
geoCheckLocations?: GeoContinent[];
|
||||
geoCheckInterval?: number;
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface Settings {
|
||||
systemEmailRequireTLS: boolean;
|
||||
systemEmailRejectUnauthorized: boolean;
|
||||
showURL: boolean;
|
||||
defaultUserAgent?: string;
|
||||
singleton: boolean;
|
||||
globalThresholds?: SettingsThresholds;
|
||||
createdAt: string;
|
||||
|
||||
@@ -38,6 +38,16 @@ const httpSchema = baseSchema.extend({
|
||||
matchMethod: z.enum(["equal", "include", "regex", ""]).optional(),
|
||||
expectedValue: z.string().optional(),
|
||||
jsonPath: z.string().optional(),
|
||||
customUserAgent: z
|
||||
.string()
|
||||
.max(500)
|
||||
.regex(
|
||||
/^[\t\x20-\x7E\x80-\xFF]*$/,
|
||||
"Only printable characters, spaces, and tabs are allowed (RFC 7230 §3.2.6)"
|
||||
)
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.nullable()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// Ping monitor schema
|
||||
|
||||
@@ -12,6 +12,15 @@ export const settingsSchema = z.object({
|
||||
systemEmailSecure: z.boolean().optional(),
|
||||
systemEmailPool: z.boolean().optional(),
|
||||
showURL: z.boolean().optional(),
|
||||
defaultUserAgent: z
|
||||
.string()
|
||||
.max(500)
|
||||
.regex(
|
||||
/^[\t\x20-\x7E\x80-\xFF]*$/,
|
||||
"Only printable characters, spaces, and tabs are allowed (RFC 7230)"
|
||||
)
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.optional(),
|
||||
checkTTL: z
|
||||
.number()
|
||||
.int()
|
||||
|
||||
+1085
-1282
File diff suppressed because it is too large.
Load diff
+1085
-1282
File diff suppressed because it is too large.
Load diff
+1107
-1272
File diff suppressed because it is too large.
Load diff
@@ -527,6 +527,14 @@
|
||||
},
|
||||
"title": "TLS/SSL settings"
|
||||
},
|
||||
"userAgent": {
|
||||
"title": "Custom User-Agent",
|
||||
"description": "Override the User-Agent header sent with HTTP requests for this monitor. Useful for identifying Checkmate in WAF logs. Leave blank to use the global default.",
|
||||
"option": {
|
||||
"label": "User-Agent",
|
||||
"placeholder": "Checkmate/X.X (uptime monitor)"
|
||||
}
|
||||
},
|
||||
"incidents": {
|
||||
"description": "A sliding window is used to determine when a monitor goes down. The status of a monitor will only change when the percentage of checks in the sliding window meet the specified value.",
|
||||
"option": {
|
||||
@@ -1051,6 +1059,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"userAgent": {
|
||||
"title": "Default User-Agent",
|
||||
"description": "Set a default User-Agent header sent with all HTTP uptime monitor requests. Individual monitors can override this. Useful for identifying Checkmate in WAF logs and whitelists.",
|
||||
"option": {
|
||||
"label": "Default User-Agent",
|
||||
"placeholder": "Checkmate/X.X (uptime monitor)"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "Monitor history",
|
||||
"description": "Clear all monitoring history and statistics for your team. This action is irreversible.",
|
||||
|
||||
+1085
-1282
File diff suppressed because it is too large.
Load diff
+1085
-1282
File diff suppressed because it is too large.
Load diff
+1107
-1272
File diff suppressed because it is too large.
Load diff
+1086
-1272
File diff suppressed because it is too large.
Load diff
+1107
-1272
File diff suppressed because it is too large.
Load diff
+1107
-1272
File diff suppressed because it is too large.
Load diff
+1107
-1272
File diff suppressed because it is too large.
Load diff
+1085
-1282
File diff suppressed because it is too large.
Load diff
+792
-1259
File diff suppressed because it is too large.
Load diff
+792
-1259
File diff suppressed because it is too large.
Load diff
+1099
-1272
File diff suppressed because it is too large.
Load diff
+1089
-1300
File diff suppressed because it is too large.
Load diff
@@ -9,7 +9,8 @@ const config: Config = {
|
||||
},
|
||||
moduleNameMapper: {
|
||||
"^@/validation/(.*)\\.js$": "<rootDir>/src/validation/$1.js",
|
||||
"^@/utils/(.*)\\.js$": "<rootDir>/src/utils/$1.ts",
|
||||
"^@/utils/(AppError)\\.js$": "<rootDir>/src/utils/$1.ts",
|
||||
"^@/utils/(.*)\\.js$": "<rootDir>/src/utils/$1.js",
|
||||
"^@/(.*)\\.ts$": "<rootDir>/src/$1.ts",
|
||||
"^@/(.*)\\.js$": "<rootDir>/src/$1.ts",
|
||||
"^@/(.*)$": "<rootDir>/src/$1",
|
||||
|
||||
Generated
+200
-99
@@ -41,7 +41,7 @@
|
||||
"ping": "0.4.4",
|
||||
"sharp": "0.33.5",
|
||||
"ssl-checker": "2.0.10",
|
||||
"super-simple-scheduler": "1.9.1",
|
||||
"super-simple-scheduler": "1.4.5",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"winston": "^3.13.0",
|
||||
"ws": "^8.19.0",
|
||||
@@ -69,6 +69,7 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||
"@typescript-eslint/parser": "^8.56.1",
|
||||
"c8": "10.1.3",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-mocha": "^10.5.0",
|
||||
"esm": "3.2.25",
|
||||
@@ -800,20 +801,40 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/xml-builder": {
|
||||
"version": "3.972.16",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.16.tgz",
|
||||
"integrity": "sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A==",
|
||||
"version": "3.972.10",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.10.tgz",
|
||||
"integrity": "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.13.1",
|
||||
"fast-xml-parser": "5.5.8",
|
||||
"@smithy/types": "^4.13.0",
|
||||
"fast-xml-parser": "5.4.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/xml-builder/node_modules/fast-xml-parser": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz",
|
||||
"integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-builder": "^1.0.0",
|
||||
"strnum": "^2.1.2"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws/lambda-invoke-store": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz",
|
||||
@@ -1357,6 +1378,16 @@
|
||||
"integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@bcoe/v8-coverage": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@colors/colors": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
|
||||
@@ -3824,9 +3855,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/types": {
|
||||
"version": "4.13.1",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz",
|
||||
"integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==",
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz",
|
||||
"integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -4931,9 +4962,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5874,9 +5905,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6042,6 +6073,40 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/c8": {
|
||||
"version": "10.1.3",
|
||||
"resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
|
||||
"integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.1",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"find-up": "^5.0.0",
|
||||
"foreground-child": "^3.1.1",
|
||||
"istanbul-lib-coverage": "^3.2.0",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.1.6",
|
||||
"test-exclude": "^7.0.1",
|
||||
"v8-to-istanbul": "^9.0.0",
|
||||
"yargs": "^17.7.2",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"c8": "bin/c8.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"monocart-coverage-reports": "^2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"monocart-coverage-reports": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/cacheable-lookup": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
|
||||
@@ -8087,9 +8152,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
|
||||
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz",
|
||||
"integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -8097,31 +8162,7 @@
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-expression-matcher": "^1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.5.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz",
|
||||
"integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-builder": "^1.1.4",
|
||||
"path-expression-matcher": "^1.2.0",
|
||||
"strnum": "^2.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
}
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.20.1",
|
||||
@@ -8262,9 +8303,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -8711,9 +8752,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -8813,9 +8854,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/handlebars": {
|
||||
"version": "4.7.9",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
|
||||
"integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
|
||||
"version": "4.7.8",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
|
||||
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.5",
|
||||
@@ -10115,9 +10156,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jest-util/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -10652,9 +10693,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.camelcase": {
|
||||
@@ -11196,9 +11237,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mjml-cli/node_modules/brace-expansion": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -12292,22 +12333,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.1.tgz",
|
||||
"integrity": "sha512-d7gQQmLvAKXKXE2GeP9apIGbMYKz88zWdsn/BN2HRWVQsDFdUY36WSLTY0Jvd4HWi7Fb30gQ62oAOzdgJA6fZw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
@@ -12344,9 +12369,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
@@ -12456,9 +12481,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@@ -14484,9 +14509,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
|
||||
"integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz",
|
||||
"integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -14513,12 +14538,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/super-simple-scheduler": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.9.1.tgz",
|
||||
"integrity": "sha512-JSiEQLk7MsnlSkJ87uoyFKIseG2N27ikB0FSxlh25nnQHWC7R5Lrd0VcoLpwSBanyLzlAslhK4W2rQ0mCU8jCQ==",
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.4.5.tgz",
|
||||
"integrity": "sha512-xHR3a+pLywY7HtwjWj/+OaANdEwAieQhckC/i0s6OoHtVJQ4+VLO1VfkKUbxp+oSROGXcaZet8PTGONVdKNUUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uuid": "11.1.0"
|
||||
"uuid": "11.1.0",
|
||||
"winston": "3.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/super-simple-scheduler/node_modules/is-stream": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/super-simple-scheduler/node_modules/uuid": {
|
||||
@@ -14534,6 +14572,28 @@
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/super-simple-scheduler/node_modules/winston": {
|
||||
"version": "3.17.0",
|
||||
"resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz",
|
||||
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@colors/colors": "^1.6.0",
|
||||
"@dabh/diagnostics": "^2.0.2",
|
||||
"async": "^3.2.3",
|
||||
"is-stream": "^2.0.0",
|
||||
"logform": "^2.7.0",
|
||||
"one-time": "^1.0.0",
|
||||
"readable-stream": "^3.4.0",
|
||||
"safe-stable-stringify": "^2.3.1",
|
||||
"stack-trace": "0.0.x",
|
||||
"triple-beam": "^1.3.0",
|
||||
"winston-transport": "^4.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
@@ -14621,6 +14681,47 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
|
||||
"integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@istanbuljs/schema": "^0.1.2",
|
||||
"glob": "^10.4.1",
|
||||
"minimatch": "^9.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/text-hex": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
|
||||
@@ -14663,9 +14764,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -15054,9 +15155,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.24.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
|
||||
"integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
|
||||
"version": "6.23.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
|
||||
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
@@ -15724,9 +15825,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
|
||||
+3
-5
@@ -5,10 +5,7 @@
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "NODE_OPTIONS=--experimental-vm-modules jest --coverage",
|
||||
"test:unit": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/**/*.ts' test/unit",
|
||||
"test:unit:services": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/service/**/*.ts' test/unit/services test/unit/providers/network test/unit/providers/notifications",
|
||||
"test:integration": "NODE_OPTIONS=--experimental-vm-modules jest --no-coverage test/integration",
|
||||
"test": "NODE_OPTIONS=--experimental-vm-modules c8 jest --runInBand",
|
||||
"dev": "nodemon --exec tsx src/index.js",
|
||||
"start": "node --watch ./dist/index.js",
|
||||
"build": "tsc && tsc-alias && cp -r src/templates dist/templates",
|
||||
@@ -59,7 +56,7 @@
|
||||
"ping": "0.4.4",
|
||||
"sharp": "0.33.5",
|
||||
"ssl-checker": "2.0.10",
|
||||
"super-simple-scheduler": "1.9.1",
|
||||
"super-simple-scheduler": "1.4.5",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"winston": "^3.13.0",
|
||||
"ws": "^8.19.0",
|
||||
@@ -87,6 +84,7 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||
"@typescript-eslint/parser": "^8.56.1",
|
||||
"c8": "10.1.3",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-mocha": "^10.5.0",
|
||||
"esm": "3.2.25",
|
||||
|
||||
@@ -364,6 +364,7 @@ export const initializeServices = async ({
|
||||
});
|
||||
const monitorService = new MonitorService({
|
||||
jobQueue: superSimpleQueue,
|
||||
emailService,
|
||||
logger,
|
||||
games,
|
||||
monitorsRepository,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Pool } from "pg";
|
||||
|
||||
export const addUserAgentFields = async (pool: Pool) => {
|
||||
await pool.query(`
|
||||
ALTER TABLE monitors
|
||||
ADD COLUMN IF NOT EXISTS custom_user_agent TEXT;
|
||||
|
||||
ALTER TABLE app_settings
|
||||
ADD COLUMN IF NOT EXISTS default_user_agent TEXT;
|
||||
`);
|
||||
};
|
||||
|
||||
export const dropUserAgentFields = async (pool: Pool) => {
|
||||
await pool.query(`
|
||||
ALTER TABLE monitors
|
||||
DROP COLUMN IF EXISTS custom_user_agent;
|
||||
|
||||
ALTER TABLE app_settings
|
||||
DROP COLUMN IF EXISTS default_user_agent;
|
||||
`);
|
||||
};
|
||||
@@ -21,6 +21,7 @@ import { createStatusPages, dropStatusPages } from "./0018_create_status_pages.j
|
||||
import { createAppSettings, dropAppSettings } from "./0019_create_app_settings.js";
|
||||
import { createContinuousAggregates, dropContinuousAggregates } from "./0020_create_continuous_aggregates.js";
|
||||
import { createRetentionCompression, dropRetentionCompression } from "./0021_create_retention_compression.js";
|
||||
import { addUserAgentFields, dropUserAgentFields } from "./0022_add_user_agent_fields.js";
|
||||
|
||||
const SERVICE_NAME = "TimescaleDB Migrations";
|
||||
|
||||
@@ -52,6 +53,7 @@ const migrations: MigrationEntry[] = [
|
||||
{ name: "0019_create_app_settings", up: createAppSettings, down: dropAppSettings },
|
||||
{ name: "0020_create_continuous_aggregates", up: createContinuousAggregates, down: dropContinuousAggregates },
|
||||
{ name: "0021_create_retention_compression", up: createRetentionCompression, down: dropRetentionCompression },
|
||||
{ name: "0022_add_user_agent_fields", up: addUserAgentFields, down: dropUserAgentFields },
|
||||
];
|
||||
|
||||
const ensureMigrationsTable = async (pool: Pool) => {
|
||||
|
||||
@@ -36,6 +36,7 @@ const AppSettingsSchema = new Schema<AppSettingsDocument>(
|
||||
systemEmailRequireTLS: { type: Boolean, default: false },
|
||||
systemEmailRejectUnauthorized: { type: Boolean, default: true },
|
||||
showURL: { type: Boolean, default: false },
|
||||
defaultUserAgent: { type: String },
|
||||
singleton: { type: Boolean, required: true, unique: true, default: true },
|
||||
version: { type: Number, default: 1 },
|
||||
globalThresholds: { type: thresholdsSchema },
|
||||
|
||||
@@ -339,6 +339,9 @@ const MonitorSchema = new Schema<MonitorDocument>(
|
||||
return value && value.trim() ? value.trim() : null;
|
||||
},
|
||||
},
|
||||
customUserAgent: {
|
||||
type: String,
|
||||
},
|
||||
geoCheckEnabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
|
||||
@@ -387,6 +387,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
||||
gameId: doc.gameId ?? undefined,
|
||||
grpcServiceName: doc.grpcServiceName ?? undefined,
|
||||
group: doc.group ?? null,
|
||||
customUserAgent: doc.customUserAgent ?? undefined,
|
||||
recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)),
|
||||
geoCheckEnabled: doc.geoCheckEnabled ?? false,
|
||||
geoCheckLocations: doc.geoCheckLocations ?? [],
|
||||
@@ -446,6 +447,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
||||
gameId: doc.gameId ?? undefined,
|
||||
grpcServiceName: doc.grpcServiceName ?? undefined,
|
||||
group: doc.group ?? null,
|
||||
customUserAgent: doc.customUserAgent ?? undefined,
|
||||
recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)),
|
||||
geoCheckEnabled: doc.geoCheckEnabled ?? false,
|
||||
geoCheckLocations: doc.geoCheckLocations ?? [],
|
||||
|
||||
@@ -37,6 +37,7 @@ interface MonitorRow {
|
||||
game_id: string | null;
|
||||
grpc_service_name: string | null;
|
||||
monitor_group: string | null;
|
||||
custom_user_agent: string | null;
|
||||
geo_check_enabled: boolean;
|
||||
geo_check_locations: GeoContinent[] | null;
|
||||
geo_check_interval_ms: number;
|
||||
@@ -49,7 +50,7 @@ const MONITOR_COLUMNS = `id, user_id, team_id, name, description, type, status,
|
||||
interval_ms, is_active, status_window, status_window_size, status_window_threshold, uptime_percentage,
|
||||
cpu_alert_threshold, cpu_alert_counter, memory_alert_threshold, memory_alert_counter,
|
||||
disk_alert_threshold, disk_alert_counter, temp_alert_threshold, temp_alert_counter, selected_disks,
|
||||
game_id, grpc_service_name, monitor_group, geo_check_enabled, geo_check_locations, geo_check_interval_ms,
|
||||
game_id, grpc_service_name, monitor_group, custom_user_agent, geo_check_enabled, geo_check_locations, geo_check_interval_ms,
|
||||
created_at, updated_at`;
|
||||
|
||||
export class TimescaleMonitorsRepository implements IMonitorsRepository {
|
||||
@@ -62,8 +63,8 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository {
|
||||
interval_ms, is_active, status_window, status_window_size, status_window_threshold,
|
||||
cpu_alert_threshold, cpu_alert_counter, memory_alert_threshold, memory_alert_counter,
|
||||
disk_alert_threshold, disk_alert_counter, temp_alert_threshold, temp_alert_counter, selected_disks,
|
||||
game_id, grpc_service_name, monitor_group, geo_check_enabled, geo_check_locations, geo_check_interval_ms)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34)
|
||||
game_id, grpc_service_name, monitor_group, custom_user_agent, geo_check_enabled, geo_check_locations, geo_check_interval_ms)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35)
|
||||
RETURNING ${MONITOR_COLUMNS}`,
|
||||
[
|
||||
userId,
|
||||
@@ -97,6 +98,7 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository {
|
||||
monitor.gameId ?? null,
|
||||
monitor.grpcServiceName ?? null,
|
||||
monitor.group ?? null,
|
||||
monitor.customUserAgent ?? null,
|
||||
monitor.geoCheckEnabled ?? false,
|
||||
monitor.geoCheckLocations ?? [],
|
||||
monitor.geoCheckInterval ?? 300000,
|
||||
@@ -595,6 +597,7 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository {
|
||||
["gameId", "game_id"],
|
||||
["grpcServiceName", "grpc_service_name"],
|
||||
["group", "monitor_group"],
|
||||
["customUserAgent", "custom_user_agent"],
|
||||
["geoCheckEnabled", "geo_check_enabled"],
|
||||
["geoCheckLocations", "geo_check_locations"],
|
||||
["geoCheckInterval", "geo_check_interval_ms"],
|
||||
@@ -1034,6 +1037,7 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository {
|
||||
gameId: row.game_id ?? undefined,
|
||||
grpcServiceName: row.grpc_service_name ?? undefined,
|
||||
group: row.monitor_group,
|
||||
customUserAgent: row.custom_user_agent ?? undefined,
|
||||
geoCheckEnabled: row.geo_check_enabled,
|
||||
geoCheckLocations: row.geo_check_locations ?? [],
|
||||
geoCheckInterval: row.geo_check_interval_ms,
|
||||
|
||||
@@ -38,6 +38,7 @@ class MongoSettingsRepository implements ISettingsRepository {
|
||||
systemEmailRequireTLS: doc.systemEmailRequireTLS ?? false,
|
||||
systemEmailRejectUnauthorized: doc.systemEmailRejectUnauthorized ?? true,
|
||||
showURL: doc.showURL ?? false,
|
||||
defaultUserAgent: doc.defaultUserAgent ?? undefined,
|
||||
singleton: doc.singleton,
|
||||
version: doc.version ?? 1,
|
||||
globalThresholds: doc.globalThresholds ?? undefined,
|
||||
|
||||
@@ -21,6 +21,7 @@ interface SettingsRow {
|
||||
system_email_require_tls: boolean | null;
|
||||
system_email_reject_unauthorized: boolean | null;
|
||||
show_url: boolean | null;
|
||||
default_user_agent: string | null;
|
||||
version: string | null;
|
||||
threshold_cpu_usage: number | null;
|
||||
threshold_memory_usage: number | null;
|
||||
@@ -34,7 +35,7 @@ const COLUMNS = `id, check_ttl, language, jwt_secret, pagespeed_api_key,
|
||||
system_email_host, system_email_port, system_email_address, system_email_password, system_email_user,
|
||||
system_email_connection_host, system_email_tls_servername, system_email_secure, system_email_pool,
|
||||
system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized,
|
||||
show_url, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature,
|
||||
show_url, default_user_agent, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature,
|
||||
created_at, updated_at`;
|
||||
|
||||
export class TimescaleSettingsRepository implements ISettingsRepository {
|
||||
@@ -46,8 +47,8 @@ export class TimescaleSettingsRepository implements ISettingsRepository {
|
||||
system_email_host, system_email_port, system_email_address, system_email_password, system_email_user,
|
||||
system_email_connection_host, system_email_tls_servername, system_email_secure, system_email_pool,
|
||||
system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized,
|
||||
show_url, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)
|
||||
show_url, default_user_agent, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23)
|
||||
RETURNING ${COLUMNS}`,
|
||||
[
|
||||
settings.checkTTL ?? 30,
|
||||
@@ -67,6 +68,7 @@ export class TimescaleSettingsRepository implements ISettingsRepository {
|
||||
settings.systemEmailRequireTLS ?? false,
|
||||
settings.systemEmailRejectUnauthorized ?? true,
|
||||
settings.showURL ?? false,
|
||||
settings.defaultUserAgent ?? null,
|
||||
settings.version ?? 1,
|
||||
settings.globalThresholds?.cpu ?? null,
|
||||
settings.globalThresholds?.memory ?? null,
|
||||
@@ -113,6 +115,7 @@ export class TimescaleSettingsRepository implements ISettingsRepository {
|
||||
["systemEmailRequireTLS", "system_email_require_tls"],
|
||||
["systemEmailRejectUnauthorized", "system_email_reject_unauthorized"],
|
||||
["showURL", "show_url"],
|
||||
["defaultUserAgent", "default_user_agent"],
|
||||
["version", "version"],
|
||||
];
|
||||
|
||||
@@ -197,6 +200,7 @@ export class TimescaleSettingsRepository implements ISettingsRepository {
|
||||
systemEmailRequireTLS: row.system_email_require_tls ?? false,
|
||||
systemEmailRejectUnauthorized: row.system_email_reject_unauthorized ?? true,
|
||||
showURL: row.show_url ?? false,
|
||||
defaultUserAgent: row.default_user_agent ?? undefined,
|
||||
singleton: true,
|
||||
version: Number(row.version ?? 1),
|
||||
globalThresholds:
|
||||
|
||||
@@ -107,16 +107,16 @@ export class CheckService implements ICheckService {
|
||||
numericUnit: audit.numericUnit,
|
||||
};
|
||||
};
|
||||
check.accessibility = (categories.accessibility?.score || 0) * 100;
|
||||
check.bestPractices = (categories["best-practices"]?.score || 0) * 100;
|
||||
check.seo = (categories.seo?.score || 0) * 100;
|
||||
check.performance = (categories.performance?.score || 0) * 100;
|
||||
check.accessibility = (categories?.accessibility?.score || 0) * 100;
|
||||
check.bestPractices = (categories?.["best-practices"]?.score || 0) * 100;
|
||||
check.seo = (categories?.seo?.score || 0) * 100;
|
||||
check.performance = (categories?.performance?.score || 0) * 100;
|
||||
check.audits = {
|
||||
cls: mapAudit(audits["cumulative-layout-shift"]),
|
||||
si: mapAudit(audits["speed-index"]),
|
||||
fcp: mapAudit(audits["first-contentful-paint"]),
|
||||
lcp: mapAudit(audits["largest-contentful-paint"]),
|
||||
tbt: mapAudit(audits["total-blocking-time"]),
|
||||
cls: mapAudit(audits?.["cumulative-layout-shift"]),
|
||||
si: mapAudit(audits?.["speed-index"]),
|
||||
fcp: mapAudit(audits?.["first-contentful-paint"]),
|
||||
lcp: mapAudit(audits?.["largest-contentful-paint"]),
|
||||
tbt: mapAudit(audits?.["total-blocking-time"]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ export class CheckService implements ICheckService {
|
||||
const hardwarePayload = payload as HardwareStatusPayload | undefined;
|
||||
const { cpu, memory, disk, host, net } = hardwarePayload?.data ?? {};
|
||||
const errorsSource = Array.isArray(hardwarePayload?.errors)
|
||||
? hardwarePayload.errors
|
||||
? hardwarePayload?.errors
|
||||
: (hardwarePayload?.errors as { errors?: CheckErrorInfo[] } | undefined)?.errors;
|
||||
check.cpu = cpu;
|
||||
check.memory = memory;
|
||||
|
||||
@@ -95,14 +95,17 @@ export class IncidentService implements IIncidentService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!decision.shouldResolveIncident || !activeIncident) {
|
||||
return null;
|
||||
if (decision.shouldResolveIncident) {
|
||||
if (!activeIncident) {
|
||||
return null;
|
||||
}
|
||||
activeIncident.status = false;
|
||||
activeIncident.endTime = Date.now().toString();
|
||||
activeIncident.resolutionType = "automatic";
|
||||
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
|
||||
}
|
||||
|
||||
activeIncident.status = false;
|
||||
activeIncident.endTime = Date.now().toString();
|
||||
activeIncident.resolutionType = "automatic";
|
||||
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
|
||||
return null;
|
||||
};
|
||||
|
||||
private buildThresholdBreachMessage(monitor: Monitor, monitorStatusResponse?: MonitorStatusResponse): string {
|
||||
|
||||
@@ -23,6 +23,7 @@ import demoMonitorsData from "@/utils/demoMonitors.json" with { type: "json" };
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import type { ImportedMonitor } from "@/validation/monitorValidation.js";
|
||||
import { ISuperSimpleQueue } from "../infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
|
||||
import { IEmailService } from "../infrastructure/emailService.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
|
||||
const SERVICE_NAME = "MonitorService";
|
||||
@@ -90,6 +91,7 @@ export class MonitorService implements IMonitorService {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
private jobQueue: ISuperSimpleQueue;
|
||||
private emailService: IEmailService;
|
||||
private logger: ILogger;
|
||||
private games: GamesMap;
|
||||
private monitorsRepository: IMonitorsRepository;
|
||||
@@ -101,6 +103,7 @@ export class MonitorService implements IMonitorService {
|
||||
|
||||
constructor({
|
||||
jobQueue,
|
||||
emailService,
|
||||
logger,
|
||||
games,
|
||||
monitorsRepository,
|
||||
@@ -111,6 +114,7 @@ export class MonitorService implements IMonitorService {
|
||||
incidentsRepository,
|
||||
}: {
|
||||
jobQueue: ISuperSimpleQueue;
|
||||
emailService: IEmailService;
|
||||
logger: ILogger;
|
||||
games: GamesMap;
|
||||
monitorsRepository: IMonitorsRepository;
|
||||
@@ -121,6 +125,7 @@ export class MonitorService implements IMonitorService {
|
||||
incidentsRepository: IIncidentsRepository;
|
||||
}) {
|
||||
this.jobQueue = jobQueue;
|
||||
this.emailService = emailService;
|
||||
this.logger = logger;
|
||||
this.games = games;
|
||||
this.monitorsRepository = monitorsRepository;
|
||||
@@ -171,7 +176,7 @@ export class MonitorService implements IMonitorService {
|
||||
|
||||
createMonitors = async (monitors: Array<Monitor>): Promise<Monitor[] | null> => {
|
||||
const createdMonitors = await this.monitorsRepository.createMonitors(monitors);
|
||||
if (!createdMonitors || createdMonitors.length === 0) {
|
||||
if (!monitors || monitors.length === 0) {
|
||||
throw new AppError({ message: "Failed to create monitors", status: 500, service: SERVICE_NAME, method: "createMonitors" });
|
||||
}
|
||||
|
||||
@@ -180,7 +185,8 @@ export class MonitorService implements IMonitorService {
|
||||
};
|
||||
|
||||
addDemoMonitors = async ({ userId, teamId }: { userId: string; teamId: string }): Promise<Monitor[]> => {
|
||||
const monitors = demoMonitorsData.map((monitor) => ({
|
||||
const demoData = demoMonitorsData;
|
||||
const monitors = demoData.map((monitor) => ({
|
||||
userId,
|
||||
teamId,
|
||||
name: monitor.name,
|
||||
@@ -208,7 +214,7 @@ export class MonitorService implements IMonitorService {
|
||||
if (!monitor) {
|
||||
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
|
||||
}
|
||||
const rangeKey = dateRange as DateRangeKey;
|
||||
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
|
||||
const { start, end } = this.getDateRange(rangeKey);
|
||||
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
|
||||
type: monitor.type,
|
||||
@@ -257,7 +263,7 @@ export class MonitorService implements IMonitorService {
|
||||
throw new AppError({ message: `${monitor.type} monitors are not supported for hardware details`, status: 400 });
|
||||
}
|
||||
|
||||
const rangeKey = dateRange as DateRangeKey;
|
||||
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
|
||||
const { start, end } = this.getDateRange(rangeKey);
|
||||
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
|
||||
type: monitor.type,
|
||||
@@ -299,7 +305,7 @@ export class MonitorService implements IMonitorService {
|
||||
throw new AppError({ message: `${monitor.type} monitors are not supported for pagespeed details`, status: 400 });
|
||||
}
|
||||
|
||||
const rangeKey = dateRange as DateRangeKey;
|
||||
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
|
||||
const { start, end } = this.getDateRange(rangeKey);
|
||||
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
|
||||
type: monitor.type,
|
||||
@@ -340,7 +346,7 @@ export class MonitorService implements IMonitorService {
|
||||
return { groupedGeoChecks: [] };
|
||||
}
|
||||
|
||||
const rangeKey = dateRange as DateRangeKey;
|
||||
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
|
||||
const { start, end } = this.getDateRange(rangeKey);
|
||||
const groupedGeoChecks = await this.geoChecksRepository.findGroupedByMonitorIdAndDateRange(
|
||||
monitor.id,
|
||||
@@ -354,7 +360,8 @@ export class MonitorService implements IMonitorService {
|
||||
};
|
||||
|
||||
getMonitorById = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => {
|
||||
return await this.monitorsRepository.findById(monitorId, teamId);
|
||||
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
|
||||
return monitor;
|
||||
};
|
||||
|
||||
getMonitorsByTeamId = async ({
|
||||
@@ -366,7 +373,11 @@ export class MonitorService implements IMonitorService {
|
||||
type?: MonitorType | MonitorType[];
|
||||
filter?: string;
|
||||
}): Promise<Monitor[] | null> => {
|
||||
return await this.monitorsRepository.findByTeamId(teamId, { type, filter });
|
||||
const monitors = await this.monitorsRepository.findByTeamId(teamId, {
|
||||
type,
|
||||
filter,
|
||||
});
|
||||
return monitors;
|
||||
};
|
||||
|
||||
getMonitorsWithChecksByTeamId = async ({
|
||||
@@ -421,7 +432,8 @@ export class MonitorService implements IMonitorService {
|
||||
};
|
||||
|
||||
getGroupsByTeamId = async ({ teamId }: { teamId: string }): Promise<string[]> => {
|
||||
return await this.monitorsRepository.findGroupsByTeamId(teamId);
|
||||
const groups = await this.monitorsRepository.findGroupsByTeamId(teamId);
|
||||
return groups;
|
||||
};
|
||||
|
||||
editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: Partial<Monitor> }) => {
|
||||
@@ -562,6 +574,15 @@ export class MonitorService implements IMonitorService {
|
||||
|
||||
const createdMonitors = await this.createMonitors(cleanedMonitors);
|
||||
|
||||
return { imported: createdMonitors!.length, errors };
|
||||
if (!createdMonitors || createdMonitors.length === 0) {
|
||||
throw new AppError({
|
||||
message: "Failed to import any monitors. Please check the file format and try again.",
|
||||
service: SERVICE_NAME,
|
||||
method: "importMonitorsFromJSON",
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return { imported: createdMonitors.length, errors };
|
||||
};
|
||||
}
|
||||
@@ -110,7 +110,10 @@ export class UserService implements IUserService {
|
||||
}
|
||||
|
||||
issueToken = (payload: Partial<User>, appSettings: EnvConfig) => {
|
||||
return this.jwt.sign(payload, appSettings.jwtSecret, { expiresIn: appSettings.jwtTTL });
|
||||
const tokenTTL = appSettings?.jwtTTL ?? "2h";
|
||||
const tokenSecret = appSettings?.jwtSecret;
|
||||
const payloadData = payload;
|
||||
return this.jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL });
|
||||
};
|
||||
|
||||
registerUser = async (user: Partial<User>, inviteToken: string, file: Express.Multer.File | null) => {
|
||||
@@ -250,10 +253,14 @@ export class UserService implements IUserService {
|
||||
currentUserEmail: string
|
||||
) => {
|
||||
// Change Password check
|
||||
if (updates.password && updates.newPassword) {
|
||||
if (updates?.password && updates?.newPassword) {
|
||||
// Get user's email
|
||||
// Add user email to body for DB operation
|
||||
updates.email = currentUserEmail;
|
||||
// Get user
|
||||
const user = await this.usersRepository.findByEmail(currentUserEmail);
|
||||
const match = await bcrypt.compare(updates.password, user.password);
|
||||
// Compare passwords
|
||||
const match = await bcrypt.compare(updates?.password, user.password);
|
||||
// If not a match, throw a 403
|
||||
// 403 instead of 401 to avoid triggering axios interceptor
|
||||
if (!match) {
|
||||
@@ -279,7 +286,7 @@ export class UserService implements IUserService {
|
||||
const recoveryToken = await this.recoveryTokensRepository.create(email);
|
||||
const name = user.firstName;
|
||||
const settings = this.settingsService.getSettings();
|
||||
const url = `${settings.clientHost}/set-new-password/${recoveryToken.token}`;
|
||||
const url = `${settings.clientHost!}/set-new-password/${recoveryToken.token}`;
|
||||
|
||||
const html = await this.emailService.buildEmail("passwordResetTemplate", {
|
||||
name,
|
||||
@@ -329,10 +336,17 @@ export class UserService implements IUserService {
|
||||
throw new AppError({ message: "Demo user cannot be deleted", service: SERVICE_NAME, method: "deleteUser", status: 400 });
|
||||
}
|
||||
|
||||
// 1. Find all the monitors associated with the team ID if superadmin
|
||||
const res = await this.monitorsRepository.findByTeamId(teamId, {});
|
||||
|
||||
if (roles.includes("superadmin")) {
|
||||
const monitors = await this.monitorsRepository.findByTeamId(teamId, {});
|
||||
if (monitors) {
|
||||
await Promise.all(monitors.map((monitor) => this.jobQueue.deleteJob(monitor)));
|
||||
// 2. Remove all jobs, delete checks and alerts
|
||||
if (res && res.length > 0) {
|
||||
await Promise.all(
|
||||
res.map(async (monitor) => {
|
||||
await this.jobQueue.deleteJob(monitor);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
// 6. Delete the user by id
|
||||
@@ -396,7 +410,9 @@ export class UserService implements IUserService {
|
||||
if (!roles.includes("superadmin") && !roles.includes("admin")) {
|
||||
throw new AppError({ message: "Insufficient permissions", service: SERVICE_NAME, status: 403 });
|
||||
}
|
||||
return await this.usersRepository.findById(userId);
|
||||
const user = await this.usersRepository.findById(userId);
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
editUserById = async (userId: string, patch: Partial<User>) => {
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface ISuperSimpleQueue {
|
||||
getMetrics(): Promise<QueueMetrics>;
|
||||
getJobs(): Promise<QueueJobSummary[]>;
|
||||
flushQueues(): Promise<{ success: boolean }>;
|
||||
obliterate(): Promise<void>;
|
||||
}
|
||||
|
||||
export class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
@@ -72,91 +73,22 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
return SuperSimpleQueue.SERVICE_NAME;
|
||||
}
|
||||
|
||||
private registerListeners = () => {
|
||||
this.scheduler.on("scheduler:start", () => {
|
||||
this.logger.info({
|
||||
message: "Scheduler started",
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
});
|
||||
|
||||
this.scheduler.on("scheduler:stop", () => {
|
||||
this.logger.info({
|
||||
message: "Scheduler stopped",
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
});
|
||||
|
||||
this.scheduler.on("scheduler:error", (error) => {
|
||||
this.logger.error({
|
||||
message: `Scheduler error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
service: SERVICE_NAME,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
this.scheduler.on("job:abort", (job, reason) => {
|
||||
this.logger.warn({
|
||||
message: `${job.id} aborted: ${reason}`,
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
});
|
||||
|
||||
this.scheduler.on("job:attempt", (job, attempt) => {
|
||||
this.logger.debug({
|
||||
message: `${job.id} attempt ${attempt}`,
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
});
|
||||
|
||||
this.scheduler.on("job:complete", (job) => {
|
||||
this.logger.debug({
|
||||
message: `${job.id} completed successfully`,
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
});
|
||||
|
||||
this.scheduler.on("job:exhausted", (job, error) => {
|
||||
this.logger.error({
|
||||
message: `${job.id} exhausted all retries: ${error instanceof Error ? error.message : String(error)}`,
|
||||
service: SERVICE_NAME,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
this.scheduler.on("job:fail", (job, error, attempt) => {
|
||||
this.logger.warn({
|
||||
message: `${job.id} failed on attempt ${attempt}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
service: SERVICE_NAME,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
this.scheduler.on("job:start", (job) => {
|
||||
this.logger.debug({
|
||||
message: `${job.id} started`,
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
static async create(logger: ILogger, helper: ISuperSimpleQueueHelper, monitorsRepository: IMonitorsRepository) {
|
||||
const scheduler = new Scheduler({
|
||||
// storeType: "mongo",
|
||||
// storeType: "redis",
|
||||
logLevel: "debug",
|
||||
// dbUri: envSettings.dbConnectionString,
|
||||
});
|
||||
const instance = new SuperSimpleQueue(logger, helper, monitorsRepository, scheduler);
|
||||
await instance.init();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
init = async () => {
|
||||
try {
|
||||
this.registerListeners();
|
||||
|
||||
this.scheduler.start();
|
||||
|
||||
this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob());
|
||||
this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob());
|
||||
this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob());
|
||||
@@ -305,8 +237,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,
|
||||
@@ -338,9 +270,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,
|
||||
@@ -362,4 +294,12 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
success: Boolean(stopRes && flushRes && initRes),
|
||||
};
|
||||
};
|
||||
|
||||
obliterate = async () => {
|
||||
this.logger.warn({
|
||||
message: "obliterate method not implemented",
|
||||
service: SERVICE_NAME,
|
||||
method: "obliterate",
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -132,7 +132,15 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
|
||||
}
|
||||
|
||||
// Step 2. Request monitor status
|
||||
const status = await this.networkService.requestStatus(monitor);
|
||||
// Resolve default user agent for HTTP monitors (cached, invalidated on settings update)
|
||||
let effectiveMonitor: Monitor = monitor;
|
||||
if (monitor.type === "http" && !monitor.customUserAgent) {
|
||||
const defaultUserAgent = await this.settingsService.getDefaultUserAgent();
|
||||
if (defaultUserAgent) {
|
||||
effectiveMonitor = { ...monitor, customUserAgent: defaultUserAgent };
|
||||
}
|
||||
}
|
||||
const status = await this.networkService.requestStatus(effectiveMonitor);
|
||||
if (!status) {
|
||||
throw new Error("No network response");
|
||||
}
|
||||
@@ -168,17 +176,15 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
|
||||
});
|
||||
}
|
||||
|
||||
// Step 7. Handle incidents
|
||||
try {
|
||||
await this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status);
|
||||
} catch (error: unknown) {
|
||||
// Step 7. Handle incidents (best effort, don't wait)
|
||||
this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status).catch((error: unknown) => {
|
||||
this.logger.warn({
|
||||
message: `Error handling incident for job ${monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getMonitorJob",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn({
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
@@ -379,7 +385,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
return false;
|
||||
}
|
||||
return acc;
|
||||
}, false);
|
||||
|
||||
@@ -9,6 +9,7 @@ const SERVICE_NAME = "BufferService";
|
||||
export interface IBufferService {
|
||||
addToBuffer(check: Check): void;
|
||||
addGeoCheckToBuffer(geoCheck: GeoCheck): void;
|
||||
removeCheckFromBuffer(check: Check): boolean;
|
||||
scheduleNextFlush(): void;
|
||||
flushBuffer(): Promise<void>;
|
||||
flushGeoBuffer(): Promise<void>;
|
||||
@@ -71,6 +72,44 @@ export class BufferService implements IBufferService {
|
||||
}
|
||||
}
|
||||
|
||||
removeCheckFromBuffer(checkToRemove: Check) {
|
||||
try {
|
||||
if (!checkToRemove) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const index = this.buffer.findIndex((check) => {
|
||||
if (checkToRemove.id && check.id) {
|
||||
return check.id.toString() === checkToRemove.id.toString();
|
||||
}
|
||||
return (
|
||||
check.metadata.monitorId?.toString() === checkToRemove.metadata.monitorId &&
|
||||
check.metadata.teamId?.toString() === checkToRemove.metadata.teamId &&
|
||||
check.metadata.type === checkToRemove.metadata.type &&
|
||||
check.status === checkToRemove.status &&
|
||||
check.statusCode === checkToRemove.statusCode &&
|
||||
check.responseTime === checkToRemove.responseTime &&
|
||||
check.message === checkToRemove.message
|
||||
);
|
||||
});
|
||||
|
||||
if (index !== -1) {
|
||||
this.buffer.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error: unknown) {
|
||||
this.logger.error({
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
service: this.SERVICE_NAME,
|
||||
method: "removeCheckFromBuffer",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
scheduleNextFlush() {
|
||||
if (this.bufferTimer) {
|
||||
clearTimeout(this.bufferTimer);
|
||||
|
||||
@@ -35,6 +35,7 @@ export class EmailService implements IEmailService {
|
||||
private logger: ILogger;
|
||||
private transporter: ReturnType<typeof import("nodemailer").createTransport> | null = null;
|
||||
private templateLookup: Record<string, ((context: Record<string, unknown>) => string) | undefined>;
|
||||
private loadTemplate: (templateName: string) => ((context: Record<string, unknown>) => string) | undefined;
|
||||
|
||||
constructor(
|
||||
settingsService: ISettingsService,
|
||||
@@ -53,6 +54,7 @@ export class EmailService implements IEmailService {
|
||||
this.nodemailer = nodemailer;
|
||||
this.logger = logger;
|
||||
this.templateLookup = {};
|
||||
this.loadTemplate = () => undefined;
|
||||
this.init();
|
||||
}
|
||||
|
||||
@@ -61,7 +63,7 @@ export class EmailService implements IEmailService {
|
||||
}
|
||||
|
||||
init = () => {
|
||||
const loadTemplate = (templateName: string) => {
|
||||
this.loadTemplate = (templateName) => {
|
||||
try {
|
||||
const templatePath = this.path.join(__dirname, `../../templates/${templateName}.mjml`);
|
||||
const templateContent = this.fs.readFileSync(templatePath, "utf8");
|
||||
@@ -77,12 +79,12 @@ export class EmailService implements IEmailService {
|
||||
};
|
||||
|
||||
this.templateLookup = {
|
||||
welcomeEmailTemplate: loadTemplate("welcomeEmail"),
|
||||
employeeActivationTemplate: loadTemplate("employeeActivation"),
|
||||
noIncidentsThisWeekTemplate: loadTemplate("noIncidentsThisWeek"),
|
||||
passwordResetTemplate: loadTemplate("passwordReset"),
|
||||
testEmailTemplate: loadTemplate("testEmailTemplate"),
|
||||
unifiedNotificationTemplate: loadTemplate("unifiedNotification"),
|
||||
welcomeEmailTemplate: this.loadTemplate("welcomeEmail"),
|
||||
employeeActivationTemplate: this.loadTemplate("employeeActivation"),
|
||||
noIncidentsThisWeekTemplate: this.loadTemplate("noIncidentsThisWeek"),
|
||||
passwordResetTemplate: this.loadTemplate("passwordReset"),
|
||||
testEmailTemplate: this.loadTemplate("testEmailTemplate"),
|
||||
unifiedNotificationTemplate: this.loadTemplate("unifiedNotification"),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -165,7 +167,7 @@ export class EmailService implements IEmailService {
|
||||
subject: subject,
|
||||
html: html,
|
||||
});
|
||||
return info.messageId;
|
||||
return info?.messageId;
|
||||
} catch (error: unknown) {
|
||||
this.logger.error({
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
|
||||
@@ -90,8 +90,7 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
|
||||
if (partialIdMatch && !exactIdMatch) matchTypes.push("partial ID");
|
||||
|
||||
if (matchTypes.length > 1) {
|
||||
const matchUsed = exactIdMatch ? "exact ID" : "exact name";
|
||||
const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${matchUsed} match.`;
|
||||
const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${exactIdMatch ? "exact ID" : exactNameMatch ? "exact name" : "partial ID"} match.`;
|
||||
|
||||
this.logger.warn({
|
||||
message,
|
||||
@@ -139,24 +138,11 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: "No response from Docker container inspect",
|
||||
responseTime,
|
||||
payload: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
status: response.State?.Status === "running",
|
||||
status: response?.State?.Status === "running",
|
||||
code: 200,
|
||||
message: "Docker container status fetched successfully",
|
||||
responseTime,
|
||||
|
||||
@@ -60,7 +60,7 @@ export class GrpcProvider implements IStatusProvider<GrpcStatusPayload> {
|
||||
throw new AppError({ message: "Monitor port is required", service: SERVICE_NAME, method: "handle" });
|
||||
}
|
||||
|
||||
const host = url.replace(/^https?:\/\//, "").split(/[/?#:]/)[0];
|
||||
const host = url?.replace(/^https?:\/\//, "").split(/[/?#:]/)[0];
|
||||
const target = `${host}:${port}`;
|
||||
|
||||
const currentFilePath = fileURLToPath(import.meta.url);
|
||||
|
||||
@@ -56,15 +56,30 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
};
|
||||
}
|
||||
|
||||
private sanitizeHeaderValue(value: string): string {
|
||||
if (!value) return "";
|
||||
|
||||
return value
|
||||
.replace(/[<>]/g, "")
|
||||
.replace(/(?:javascript|data|vbscript):/gi, "")
|
||||
.replace(/[^\t\x20-\x7E\x80-\xFF]/g, "") // allow only RFC 7230 header-safe characters
|
||||
.trim()
|
||||
.slice(0, 500);
|
||||
}
|
||||
|
||||
async handle<T>(monitor: Monitor): Promise<MonitorStatusResponse<T>> {
|
||||
const { url, secret, jsonPath, ignoreTlsErrors } = monitor;
|
||||
const { url, secret, jsonPath, ignoreTlsErrors, customUserAgent } = monitor;
|
||||
|
||||
if (!url) {
|
||||
throw new Error("URL is required for HTTP monitor");
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (secret) headers["Authorization"] = `Bearer ${secret}`;
|
||||
if (customUserAgent) headers["User-Agent"] = this.sanitizeHeaderValue(customUserAgent);
|
||||
|
||||
const options: Record<string, unknown> = {
|
||||
headers: monitor.secret ? { Authorization: `Bearer ${secret}` } : undefined,
|
||||
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||
};
|
||||
|
||||
options.agent = {
|
||||
|
||||
@@ -118,7 +118,7 @@ export class NetworkService implements INetworkService {
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data?.status !== "success") return false;
|
||||
if (response?.data?.status !== "success") return false;
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
const originalMessage = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { AlertMatrixPayload, Notification } from "@/types/index.js";
|
||||
import type { NotificationMessage } from "@/types/notificationMessage.js";
|
||||
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export class MatrixProvider implements INotificationProvider {
|
||||
private logger: ILogger;
|
||||
@@ -19,51 +18,63 @@ export class MatrixProvider implements INotificationProvider {
|
||||
if (!homeserverUrl || !accessToken || !roomId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message?access_token=${accessToken}`;
|
||||
const body = {
|
||||
msgtype: "m.text",
|
||||
body: getTestMessage(),
|
||||
};
|
||||
return await this.sendMessageWithBody(notification, body);
|
||||
};
|
||||
|
||||
sendMessage = async (notification: Notification, message: NotificationMessage): Promise<boolean> => {
|
||||
const { homeserverUrl, accessToken, roomId } = notification;
|
||||
if (!homeserverUrl || !accessToken || !roomId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { plainText, htmlText } = this.buildMatrixMessage(message);
|
||||
const body = {
|
||||
msgtype: "m.text",
|
||||
body: plainText,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: htmlText,
|
||||
};
|
||||
return this.sendMessageWithBody(notification, body);
|
||||
};
|
||||
|
||||
private sendMessageWithBody = async (notification: Partial<Notification>, body: unknown): Promise<boolean> => {
|
||||
const { homeserverUrl, accessToken, roomId } = notification;
|
||||
const txnId = randomUUID();
|
||||
const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message/${txnId}`;
|
||||
|
||||
try {
|
||||
await got.put(url, {
|
||||
await got.post(url, {
|
||||
json: body,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.warn({
|
||||
message: `Matrix notification failed : ${err.message}`,
|
||||
message: "Matrix test alert failed",
|
||||
service: SERVICE_NAME,
|
||||
method: "sendTestAlert",
|
||||
stack: err?.stack,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
sendMessage = async (notification: Notification, message: NotificationMessage): Promise<boolean> => {
|
||||
const { homeserverUrl, accessToken, roomId } = notification;
|
||||
|
||||
if (!homeserverUrl || !accessToken || !roomId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { plainText, htmlText } = this.buildMatrixMessage(message);
|
||||
|
||||
const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message?access_token=${accessToken}`;
|
||||
const body = {
|
||||
msgtype: "m.text",
|
||||
body: plainText,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: htmlText,
|
||||
};
|
||||
|
||||
try {
|
||||
await got.post(url, {
|
||||
json: body,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.warn({
|
||||
message: "Matrix notification failed",
|
||||
service: SERVICE_NAME,
|
||||
method: "sendMessage",
|
||||
stack: err.stack,
|
||||
stack: err?.stack,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -96,7 +96,6 @@ export class StatusService implements IStatusService {
|
||||
uptimePercentage: 0,
|
||||
lastResponseTime: 0,
|
||||
lastCheckTimestamp: 0,
|
||||
timeOfLastFailure: 0,
|
||||
};
|
||||
} else {
|
||||
// Use existing stats (omit id, monitorId, createdAt, updatedAt)
|
||||
@@ -148,7 +147,13 @@ export class StatusService implements IStatusService {
|
||||
}
|
||||
|
||||
// Calculate uptime percentage
|
||||
stats.uptimePercentage = stats.totalUpChecks / stats.totalChecks;
|
||||
let uptimePercentage;
|
||||
if (stats.totalChecks > 0) {
|
||||
uptimePercentage = stats.totalUpChecks / stats.totalChecks;
|
||||
} else {
|
||||
uptimePercentage = status === true ? 100 : 0;
|
||||
}
|
||||
stats.uptimePercentage = uptimePercentage;
|
||||
|
||||
// latest check
|
||||
stats.lastCheckTimestamp = new Date().getTime();
|
||||
@@ -263,7 +268,7 @@ export class StatusService implements IStatusService {
|
||||
let thresholdBreaches: { cpu: boolean; memory: boolean; disk: boolean; temp: boolean } | undefined;
|
||||
if (monitor.type === "hardware" && statusResponse.payload) {
|
||||
const payload = statusResponse.payload as HardwareStatusPayload;
|
||||
const metrics = payload.data;
|
||||
const metrics = payload?.data;
|
||||
|
||||
if (metrics) {
|
||||
// Evaluate threshold breaches
|
||||
@@ -273,11 +278,9 @@ export class StatusService implements IStatusService {
|
||||
const memoryUsage = metrics.memory?.usage_percent ?? -1;
|
||||
const memoryBreach = memoryUsage !== -1 && memoryUsage > monitor.memoryAlertThreshold / 100;
|
||||
|
||||
const diskBreach = metrics.disk
|
||||
? metrics.disk.some(
|
||||
(d: CheckDiskInfo) => d != null && typeof d.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100
|
||||
)
|
||||
: false;
|
||||
const diskBreach =
|
||||
metrics.disk?.some((d: CheckDiskInfo) => typeof d?.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100) ??
|
||||
false;
|
||||
|
||||
const temps = metrics.cpu?.temperature ?? [];
|
||||
const tempBreach = temps.some((temp: number) => temp > monitor.tempAlertThreshold);
|
||||
@@ -363,4 +366,27 @@ export class StatusService implements IStatusService {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
insertCheck = async (check: Check) => {
|
||||
try {
|
||||
if (typeof check === "undefined") {
|
||||
this.logger.warn({
|
||||
message: "Failed to build check",
|
||||
service: SERVICE_NAME,
|
||||
method: "insertCheck",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
this.buffer.addToBuffer(check);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
this.logger.error({
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
service: SERVICE_NAME,
|
||||
method: "insertCheck",
|
||||
details: { msg: `Error inserting check for monitor: ${check?.metadata.monitorId}` },
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export interface ISettingsService {
|
||||
loadSettings(): EnvConfig;
|
||||
getSettings(): EnvConfig;
|
||||
getDBSettings(): Promise<Settings>;
|
||||
getDefaultUserAgent(): Promise<string | undefined>;
|
||||
updateDbSettings(newSettings: SettingsUpdate): Promise<Settings>;
|
||||
}
|
||||
|
||||
@@ -27,6 +28,7 @@ export class SettingsService implements ISettingsService {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
private settings: EnvConfig;
|
||||
private settingsRepository: ISettingsRepository | null = null;
|
||||
private cachedDefaultUserAgent: string | null | undefined = undefined;
|
||||
|
||||
constructor(env: ValidatedEnv) {
|
||||
this.settings = {
|
||||
@@ -53,6 +55,9 @@ export class SettingsService implements ISettingsService {
|
||||
}
|
||||
|
||||
getSettings() {
|
||||
if (!this.settings) {
|
||||
throw new Error("Settings have not been loaded");
|
||||
}
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
@@ -67,8 +72,21 @@ export class SettingsService implements ISettingsService {
|
||||
return this.settingsRepository;
|
||||
}
|
||||
|
||||
getDefaultUserAgent = async (): Promise<string | undefined> => {
|
||||
if (this.cachedDefaultUserAgent !== undefined) {
|
||||
return this.cachedDefaultUserAgent ?? undefined;
|
||||
}
|
||||
const settings = await this.getDBSettings();
|
||||
this.cachedDefaultUserAgent = settings.defaultUserAgent ?? null;
|
||||
return this.cachedDefaultUserAgent ?? undefined;
|
||||
};
|
||||
|
||||
updateDbSettings = async (newSettings: SettingsUpdate) => {
|
||||
return await this.getRepository().update(newSettings);
|
||||
const updated = await this.getRepository().update(newSettings);
|
||||
if ("defaultUserAgent" in newSettings) {
|
||||
this.cachedDefaultUserAgent = newSettings.defaultUserAgent ?? null;
|
||||
}
|
||||
return updated;
|
||||
};
|
||||
|
||||
getDBSettings = async () => {
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface Monitor {
|
||||
gameId?: string;
|
||||
grpcServiceName?: string;
|
||||
group: string | null;
|
||||
customUserAgent?: string | null;
|
||||
geoCheckEnabled?: boolean;
|
||||
geoCheckLocations?: GeoContinent[];
|
||||
geoCheckInterval?: number;
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface Settings {
|
||||
systemEmailRequireTLS: boolean;
|
||||
systemEmailRejectUnauthorized: boolean;
|
||||
showURL: boolean;
|
||||
defaultUserAgent?: string;
|
||||
singleton: boolean;
|
||||
version: number;
|
||||
globalThresholds?: SettingsThresholds;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { booleanCoercion } from "./shared.js";
|
||||
import { booleanCoercion, httpHeaderValueSchema } from "./shared.js";
|
||||
import { GeoContinents } from "@/types/geoCheck.js";
|
||||
import { MonitorMatchMethods, MonitorTypes } from "@/types/monitor.js";
|
||||
|
||||
@@ -75,6 +75,7 @@ export const createMonitorBodyValidation = z.object({
|
||||
grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
|
||||
selectedDisks: z.array(z.string()).optional(),
|
||||
group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(),
|
||||
customUserAgent: httpHeaderValueSchema.nullable().optional(),
|
||||
geoCheckEnabled: z.boolean().optional(),
|
||||
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
|
||||
geoCheckInterval: z.number().min(300000).optional(),
|
||||
@@ -104,6 +105,7 @@ export const editMonitorBodyValidation = z.object({
|
||||
grpcServiceName: z.union([z.string(), z.literal("")]).optional(),
|
||||
selectedDisks: z.array(z.string()).optional(),
|
||||
group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(),
|
||||
customUserAgent: httpHeaderValueSchema.nullable().optional(),
|
||||
geoCheckEnabled: z.boolean().optional(),
|
||||
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
|
||||
geoCheckInterval: z.number().min(300000).optional(),
|
||||
@@ -157,6 +159,7 @@ const importedMonitorSchema = z.object({
|
||||
gameId: z.union([z.string(), z.literal("")]).optional(),
|
||||
grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
|
||||
group: z.union([z.string().max(50).trim(), z.null()]).default(null),
|
||||
customUserAgent: httpHeaderValueSchema.nullable().optional(),
|
||||
geoCheckEnabled: z.boolean().default(false),
|
||||
geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
|
||||
geoCheckInterval: z.number().min(300000).default(300000),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CHECK_TTL_SENTINEL } from "@/types/check.js";
|
||||
import { z } from "zod";
|
||||
import { httpHeaderValueSchema } from "@/validation/shared.js";
|
||||
|
||||
//****************************************
|
||||
// Settings Validations
|
||||
@@ -20,6 +21,7 @@ export const updateAppSettingsBodyValidation = z
|
||||
systemEmailTLSServername: z.string().nullable().optional(),
|
||||
|
||||
showURL: z.boolean().optional(),
|
||||
defaultUserAgent: httpHeaderValueSchema.nullable().optional(),
|
||||
systemEmailSecure: z.boolean().optional(),
|
||||
systemEmailPool: z.boolean().optional(),
|
||||
systemEmailIgnoreTLS: z.boolean().optional(),
|
||||
|
||||
@@ -21,6 +21,12 @@ export const booleanCoercion = z.preprocess((val) => {
|
||||
return val; // Let Zod validation handle invalid values
|
||||
}, z.boolean());
|
||||
|
||||
// RFC 7230: validate HTTP header field-value characters
|
||||
export const httpHeaderValueSchema = z
|
||||
.string()
|
||||
.max(500)
|
||||
.regex(/^[\t\x20-\x7E\x80-\xFF]*$/, "Must contain only valid HTTP header characters (RFC 7230)");
|
||||
|
||||
export const roleValidator = (allowedRoles: UserRole[]) => {
|
||||
return z.array(z.custom<UserRole>()).refine((userRoles) => allowedRoles.some((role) => userRoles.includes(role)), {
|
||||
message: `You do not have the required authorization. Required roles: ${allowedRoles.join(", ")}`,
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import type { IIncidentsRepository } from "../../src/repositories/incidents/IIncidentsRepository.ts";
|
||||
import type { Incident, IncidentSummary } from "../../src/types/index.ts";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export class InMemoryIncidentsRepository implements IIncidentsRepository {
|
||||
private incidents: Incident[] = [];
|
||||
|
||||
async create(incident: Partial<Incident>): Promise<Incident> {
|
||||
const now = new Date().toISOString();
|
||||
const full: Incident = {
|
||||
id: randomUUID(),
|
||||
monitorId: incident.monitorId ?? "",
|
||||
teamId: incident.teamId ?? "",
|
||||
startTime: incident.startTime ?? now,
|
||||
endTime: incident.endTime ?? null,
|
||||
status: incident.status ?? true,
|
||||
message: incident.message ?? null,
|
||||
statusCode: incident.statusCode ?? null,
|
||||
resolutionType: incident.resolutionType ?? null,
|
||||
resolvedBy: incident.resolvedBy ?? null,
|
||||
resolvedByEmail: incident.resolvedByEmail ?? null,
|
||||
comment: incident.comment ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
this.incidents.push(full);
|
||||
return { ...full };
|
||||
}
|
||||
|
||||
async findById(incidentId: string, teamId: string): Promise<Incident> {
|
||||
const incident = this.incidents.find((i) => i.id === incidentId && i.teamId === teamId);
|
||||
if (!incident) {
|
||||
throw new Error(`Incident ${incidentId} not found`);
|
||||
}
|
||||
return { ...incident };
|
||||
}
|
||||
|
||||
async findActiveByIncidentId(incidentId: string, teamId: string): Promise<Incident | null> {
|
||||
const incident = this.incidents.find((i) => i.id === incidentId && i.teamId === teamId && i.status === true);
|
||||
return incident ? { ...incident } : null;
|
||||
}
|
||||
|
||||
async findActiveByMonitorId(monitorId: string, teamId: string): Promise<Incident | null> {
|
||||
const incident = this.incidents.find((i) => i.monitorId === monitorId && i.teamId === teamId && i.status === true);
|
||||
return incident ? { ...incident } : null;
|
||||
}
|
||||
|
||||
async findByTeamId(
|
||||
teamId: string,
|
||||
startDate: Date | undefined,
|
||||
page: number,
|
||||
rowsPerPage: number,
|
||||
sortOrder?: string,
|
||||
status?: boolean,
|
||||
monitorId?: string,
|
||||
resolutionType?: string
|
||||
): Promise<Incident[]> {
|
||||
let results = this.incidents.filter((i) => i.teamId === teamId);
|
||||
if (startDate) {
|
||||
results = results.filter((i) => new Date(i.startTime) >= startDate);
|
||||
}
|
||||
if (status !== undefined) {
|
||||
results = results.filter((i) => i.status === status);
|
||||
}
|
||||
if (monitorId) {
|
||||
results = results.filter((i) => i.monitorId === monitorId);
|
||||
}
|
||||
if (resolutionType) {
|
||||
results = results.filter((i) => i.resolutionType === resolutionType);
|
||||
}
|
||||
results.sort((a, b) => {
|
||||
const cmp = new Date(a.startTime).getTime() - new Date(b.startTime).getTime();
|
||||
return sortOrder === "desc" ? -cmp : cmp;
|
||||
});
|
||||
return results.slice(page * rowsPerPage, (page + 1) * rowsPerPage).map((i) => ({ ...i }));
|
||||
}
|
||||
|
||||
async findSummaryByTeamId(_teamId: string, _limit?: number): Promise<IncidentSummary> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
async countByTeamId(teamId: string, startDate: Date | undefined, status?: boolean, monitorId?: string, resolutionType?: string): Promise<number> {
|
||||
let results = this.incidents.filter((i) => i.teamId === teamId);
|
||||
if (startDate) {
|
||||
results = results.filter((i) => new Date(i.startTime) >= startDate);
|
||||
}
|
||||
if (status !== undefined) {
|
||||
results = results.filter((i) => i.status === status);
|
||||
}
|
||||
if (monitorId) {
|
||||
results = results.filter((i) => i.monitorId === monitorId);
|
||||
}
|
||||
if (resolutionType) {
|
||||
results = results.filter((i) => i.resolutionType === resolutionType);
|
||||
}
|
||||
return results.length;
|
||||
}
|
||||
|
||||
async updateById(incidentId: string, teamId: string, updateData: Partial<Incident>): Promise<Incident> {
|
||||
const index = this.incidents.findIndex((i) => i.id === incidentId && i.teamId === teamId);
|
||||
if (index === -1) {
|
||||
throw new Error(`Incident ${incidentId} not found`);
|
||||
}
|
||||
const updated: Incident = {
|
||||
...this.incidents[index],
|
||||
...updateData,
|
||||
id: this.incidents[index].id,
|
||||
monitorId: this.incidents[index].monitorId,
|
||||
teamId: this.incidents[index].teamId,
|
||||
createdAt: this.incidents[index].createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
this.incidents[index] = updated;
|
||||
return { ...updated };
|
||||
}
|
||||
|
||||
async deleteByMonitorId(monitorId: string, teamId: string): Promise<number> {
|
||||
const before = this.incidents.length;
|
||||
this.incidents = this.incidents.filter((i) => !(i.monitorId === monitorId && i.teamId === teamId));
|
||||
return before - this.incidents.length;
|
||||
}
|
||||
|
||||
async deleteByMonitorIdsNotIn(monitorIds: string[]): Promise<number> {
|
||||
const before = this.incidents.length;
|
||||
this.incidents = this.incidents.filter((i) => monitorIds.includes(i.monitorId));
|
||||
return before - this.incidents.length;
|
||||
}
|
||||
|
||||
// Test helpers — not part of the interface
|
||||
|
||||
getAll(): Incident[] {
|
||||
return this.incidents.map((i) => ({ ...i }));
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.incidents = [];
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import type { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "../../src/repositories/monitors/IMonitorsRepository.ts";
|
||||
import type { Monitor, MonitorsSummary } from "../../src/types/index.ts";
|
||||
|
||||
export class InMemoryMonitorsRepository implements IMonitorsRepository {
|
||||
private monitors: Monitor[] = [];
|
||||
|
||||
async create(monitor: Monitor, _teamId: string, _userId: string): Promise<Monitor> {
|
||||
this.monitors.push({ ...monitor });
|
||||
return { ...monitor };
|
||||
}
|
||||
|
||||
async createMonitors(monitors: Monitor[]): Promise<Monitor[]> {
|
||||
const created = monitors.map((m) => ({ ...m }));
|
||||
this.monitors.push(...created);
|
||||
return created;
|
||||
}
|
||||
|
||||
async findById(monitorId: string, teamId: string): Promise<Monitor> {
|
||||
const monitor = this.monitors.find((m) => m.id === monitorId && m.teamId === teamId);
|
||||
if (!monitor) {
|
||||
throw new Error(`Monitor ${monitorId} not found`);
|
||||
}
|
||||
return { ...monitor };
|
||||
}
|
||||
|
||||
async findAll(): Promise<Monitor[]> {
|
||||
return this.monitors.map((m) => ({ ...m }));
|
||||
}
|
||||
|
||||
async findByTeamId(_teamId: string, _config: TeamQueryConfig): Promise<Monitor[]> {
|
||||
return this.monitors.filter((m) => m.teamId === _teamId).map((m) => ({ ...m }));
|
||||
}
|
||||
|
||||
async findByIds(monitorIds: string[]): Promise<Monitor[]> {
|
||||
return this.monitors.filter((m) => monitorIds.includes(m.id)).map((m) => ({ ...m }));
|
||||
}
|
||||
|
||||
async findByIdsWithChecks(monitorIds: string[], _checksCount?: number): Promise<Monitor[]> {
|
||||
return this.findByIds(monitorIds);
|
||||
}
|
||||
|
||||
async updateById(monitorId: string, teamId: string, updates: Partial<Monitor>): Promise<Monitor> {
|
||||
const index = this.monitors.findIndex((m) => m.id === monitorId && m.teamId === teamId);
|
||||
if (index === -1) {
|
||||
throw new Error(`Monitor ${monitorId} not found`);
|
||||
}
|
||||
const updated = { ...this.monitors[index], ...updates, id: this.monitors[index].id, teamId: this.monitors[index].teamId };
|
||||
this.monitors[index] = updated;
|
||||
return { ...updated };
|
||||
}
|
||||
|
||||
async togglePauseById(monitorId: string, teamId: string): Promise<Monitor> {
|
||||
const monitor = await this.findById(monitorId, teamId);
|
||||
const newStatus = monitor.status === "paused" ? "up" : "paused";
|
||||
return this.updateById(monitorId, teamId, { status: newStatus });
|
||||
}
|
||||
|
||||
async deleteById(monitorId: string, teamId: string): Promise<Monitor> {
|
||||
const index = this.monitors.findIndex((m) => m.id === monitorId && m.teamId === teamId);
|
||||
if (index === -1) {
|
||||
throw new Error(`Monitor ${monitorId} not found`);
|
||||
}
|
||||
const [deleted] = this.monitors.splice(index, 1);
|
||||
return { ...deleted };
|
||||
}
|
||||
|
||||
async deleteByTeamId(teamId: string): Promise<{ monitors: Monitor[]; deletedCount: number }> {
|
||||
const toDelete = this.monitors.filter((m) => m.teamId === teamId);
|
||||
this.monitors = this.monitors.filter((m) => m.teamId !== teamId);
|
||||
return { monitors: toDelete, deletedCount: toDelete.length };
|
||||
}
|
||||
|
||||
async findMonitorCountByTeamIdAndType(teamId: string, _config: TeamQueryConfig): Promise<number> {
|
||||
return this.monitors.filter((m) => m.teamId === teamId).length;
|
||||
}
|
||||
|
||||
async findMonitorsSummaryByTeamId(_teamId: string, _config?: SummaryConfig): Promise<MonitorsSummary> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
async findGroupsByTeamId(_teamId: string): Promise<string[]> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
async removeNotificationFromMonitors(_notificationId: string): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
async updateNotifications(_teamId: string, _monitorIds: string[], _notificationIds: string[], _action: "add" | "remove" | "set"): Promise<number> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
async deleteByTeamIdsNotIn(teamIds: string[]): Promise<number> {
|
||||
const before = this.monitors.length;
|
||||
this.monitors = this.monitors.filter((m) => teamIds.includes(m.teamId));
|
||||
return before - this.monitors.length;
|
||||
}
|
||||
|
||||
async findAllMonitorIds(): Promise<string[]> {
|
||||
return this.monitors.map((m) => m.id);
|
||||
}
|
||||
|
||||
// Test helpers
|
||||
|
||||
seed(monitor: Monitor): void {
|
||||
this.monitors.push({ ...monitor });
|
||||
}
|
||||
|
||||
getAll(): Monitor[] {
|
||||
return this.monitors.map((m) => ({ ...m }));
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.monitors = [];
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { jest } from "@jest/globals";
|
||||
|
||||
export const createMockLogger = () => ({
|
||||
serviceName: "test",
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
cacheLog: jest.fn(),
|
||||
getLogs: jest.fn().mockReturnValue([]),
|
||||
buildLogEntry: jest.fn().mockReturnValue({ level: "info", timestamp: new Date().toISOString() }),
|
||||
});
|
||||
@@ -1,179 +0,0 @@
|
||||
import { jest } from "@jest/globals";
|
||||
import { SuperSimpleQueueHelper } from "../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
|
||||
import { StatusService } from "../../src/service/infrastructure/statusService.ts";
|
||||
import { IncidentService } from "../../src/service/business/incidentService.ts";
|
||||
import { InMemoryMonitorsRepository } from "./InMemoryMonitorsRepository.ts";
|
||||
import { InMemoryIncidentsRepository } from "./InMemoryIncidentsRepository.ts";
|
||||
import { createMockLogger } from "./createMockLogger.ts";
|
||||
import type { Monitor } from "../../src/types/monitor.ts";
|
||||
import type { MonitorStatusResponse } from "../../src/types/network.ts";
|
||||
import type { Check } from "../../src/types/check.ts";
|
||||
import type { MaintenanceWindow } from "../../src/types/maintenanceWindow.ts";
|
||||
|
||||
let checkCounter = 0;
|
||||
|
||||
export const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
name: "Test Monitor",
|
||||
type: "http",
|
||||
url: "https://example.com",
|
||||
status: "up",
|
||||
// Pre-fill window so threshold evaluation runs immediately.
|
||||
// Without this, the warm-up path sets monitor.status directly
|
||||
// from each check, bypassing the threshold logic.
|
||||
statusWindow: [true, true, true, true, true],
|
||||
statusWindowSize: 5,
|
||||
statusWindowThreshold: 60,
|
||||
recentChecks: [],
|
||||
notifications: [],
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
export const makeStatusResponse = (status: boolean, code: number): MonitorStatusResponse => ({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "http",
|
||||
status,
|
||||
code,
|
||||
message: status ? "OK" : "Service Unavailable",
|
||||
responseTime: status ? 150 : 0,
|
||||
});
|
||||
|
||||
export const makeCheck = (status: boolean, code: number): Check => {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: `check-${++checkCounter}`,
|
||||
metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" },
|
||||
status,
|
||||
statusCode: code,
|
||||
responseTime: status ? 150 : 0,
|
||||
message: status ? "OK" : "Service Unavailable",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
};
|
||||
|
||||
const createStubMonitorStatsRepo = () => ({
|
||||
findByMonitorId: jest.fn().mockRejectedValue(new Error("no stats")),
|
||||
create: jest.fn().mockResolvedValue({}),
|
||||
updateByMonitorId: jest.fn().mockResolvedValue({}),
|
||||
deleteByMonitorId: jest.fn(),
|
||||
deleteByMonitorIds: jest.fn(),
|
||||
deleteByMonitorIdsNotIn: jest.fn(),
|
||||
});
|
||||
|
||||
const createStubChecksRepo = () => ({
|
||||
create: jest.fn(),
|
||||
createChecks: jest.fn(),
|
||||
findByMonitorId: jest.fn(),
|
||||
findByTeamId: jest.fn(),
|
||||
findLatestByMonitorIds: jest.fn(),
|
||||
findByDateRangeAndMonitorId: jest.fn(),
|
||||
findSummaryByTeamId: jest.fn(),
|
||||
deleteByMonitorId: jest.fn(),
|
||||
deleteByTeamId: jest.fn(),
|
||||
deleteByMonitorIdsNotIn: jest.fn(),
|
||||
deleteOlderThan: jest.fn(),
|
||||
});
|
||||
|
||||
export interface HeartbeatTestHarness {
|
||||
monitorsRepo: InMemoryMonitorsRepository;
|
||||
incidentsRepo: InMemoryIncidentsRepository;
|
||||
statusService: StatusService;
|
||||
incidentService: IncidentService;
|
||||
notificationsService: { handleNotifications: jest.Mock };
|
||||
networkService: { requestStatus: jest.Mock };
|
||||
bufferStub: { addToBuffer: jest.Mock; addGeoCheckToBuffer: jest.Mock; scheduleNextFlush: jest.Mock };
|
||||
maintenanceWindowsRepo: { findByMonitorId: jest.Mock };
|
||||
messageBuilder: { extractThresholdBreaches: jest.Mock };
|
||||
heartbeatJob: (monitor: Monitor) => Promise<void>;
|
||||
setNextResponse: (status: boolean, code: number) => void;
|
||||
setNextResponseFull: (response: MonitorStatusResponse) => void;
|
||||
}
|
||||
|
||||
export function createHeartbeatTestHarness(): HeartbeatTestHarness {
|
||||
checkCounter = 0;
|
||||
|
||||
const monitorsRepo = new InMemoryMonitorsRepository();
|
||||
const incidentsRepo = new InMemoryIncidentsRepository();
|
||||
const logger = createMockLogger() as any;
|
||||
const bufferStub = { addToBuffer: jest.fn(), addGeoCheckToBuffer: jest.fn(), scheduleNextFlush: jest.fn() };
|
||||
|
||||
const statusService = new StatusService(
|
||||
logger,
|
||||
bufferStub as any,
|
||||
monitorsRepo as any,
|
||||
createStubMonitorStatsRepo() as any,
|
||||
createStubChecksRepo() as any
|
||||
);
|
||||
|
||||
const messageBuilder = { extractThresholdBreaches: jest.fn().mockReturnValue([]) };
|
||||
const incidentService = new IncidentService(logger, incidentsRepo, monitorsRepo as any, { findById: jest.fn() } as any, messageBuilder as any);
|
||||
|
||||
const notificationsService = { handleNotifications: jest.fn().mockResolvedValue(true) };
|
||||
|
||||
let nextResponse: MonitorStatusResponse | null = null;
|
||||
let nextStatus = true;
|
||||
let nextCode = 200;
|
||||
const networkService = {
|
||||
requestStatus: jest.fn().mockImplementation(() => {
|
||||
if (nextResponse) {
|
||||
return Promise.resolve(nextResponse);
|
||||
}
|
||||
return Promise.resolve(makeStatusResponse(nextStatus, nextCode));
|
||||
}),
|
||||
};
|
||||
const checkService = {
|
||||
buildCheck: jest.fn().mockImplementation((response: MonitorStatusResponse) => {
|
||||
return makeCheck(response.status, response.code);
|
||||
}),
|
||||
};
|
||||
|
||||
const setNextResponse = (status: boolean, code: number) => {
|
||||
nextResponse = null;
|
||||
nextStatus = status;
|
||||
nextCode = code;
|
||||
};
|
||||
|
||||
const setNextResponseFull = (response: MonitorStatusResponse) => {
|
||||
nextResponse = response;
|
||||
};
|
||||
|
||||
const maintenanceWindowsRepo = { findByMonitorId: jest.fn().mockResolvedValue([]) };
|
||||
|
||||
const helper = new SuperSimpleQueueHelper(
|
||||
logger,
|
||||
networkService as any,
|
||||
statusService as any,
|
||||
notificationsService as any,
|
||||
checkService as any,
|
||||
{ getDBSettings: jest.fn() } as any,
|
||||
bufferStub as any,
|
||||
incidentService as any,
|
||||
maintenanceWindowsRepo as any,
|
||||
monitorsRepo as any,
|
||||
{ findAllTeamIds: jest.fn() } as any,
|
||||
createStubMonitorStatsRepo() as any,
|
||||
createStubChecksRepo() as any,
|
||||
incidentsRepo as any,
|
||||
{ buildGeoCheck: jest.fn() } as any,
|
||||
{ deleteByMonitorIdsNotIn: jest.fn() } as any
|
||||
);
|
||||
|
||||
return {
|
||||
monitorsRepo,
|
||||
incidentsRepo,
|
||||
statusService,
|
||||
incidentService,
|
||||
notificationsService,
|
||||
networkService,
|
||||
bufferStub,
|
||||
maintenanceWindowsRepo,
|
||||
messageBuilder,
|
||||
heartbeatJob: helper.getHeartbeatJob(),
|
||||
setNextResponse,
|
||||
setNextResponseFull,
|
||||
};
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { Notification } from "../../src/types/index.ts";
|
||||
import type { NotificationMessage } from "../../src/types/notificationMessage.ts";
|
||||
|
||||
export const makeNotification = (overrides?: Partial<Notification>): Notification =>
|
||||
({
|
||||
address: "https://hooks.example.com/webhook",
|
||||
accessToken: "token-abc",
|
||||
homeserverUrl: "https://matrix.example.com",
|
||||
roomId: "!room:example.com",
|
||||
...overrides,
|
||||
}) as Notification;
|
||||
|
||||
export const makeMessage = (overrides?: Partial<NotificationMessage>): NotificationMessage => ({
|
||||
type: "monitor_down",
|
||||
severity: "critical",
|
||||
monitor: { id: "mon-1", name: "Test Monitor", url: "https://example.com", type: "http", status: "down" },
|
||||
content: {
|
||||
title: "Monitor Down: Test Monitor",
|
||||
summary: 'Monitor "Test Monitor" is currently down.',
|
||||
details: ["URL: https://example.com", "Status: Down"],
|
||||
timestamp: new Date("2025-01-01T00:00:00Z"),
|
||||
},
|
||||
clientHost: "https://app.example.com",
|
||||
metadata: { teamId: "team-1", notificationReason: "status_change" },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const makeMessageWithThresholds = (): NotificationMessage =>
|
||||
makeMessage({
|
||||
type: "threshold_breach",
|
||||
severity: "warning",
|
||||
monitor: { id: "mon-1", name: "Infra Server", url: "https://infra.example.com", type: "hardware", status: "up" },
|
||||
content: {
|
||||
title: "Threshold Exceeded: Infra Server",
|
||||
summary: "Thresholds exceeded.",
|
||||
details: ["URL: https://infra.example.com"],
|
||||
thresholds: [
|
||||
{ metric: "cpu", currentValue: 90, threshold: 80, unit: "%", formattedValue: "90.0%" },
|
||||
{ metric: "memory", currentValue: 85, threshold: 70, unit: "%", formattedValue: "85.0%" },
|
||||
],
|
||||
timestamp: new Date("2025-01-01T00:00:00Z"),
|
||||
},
|
||||
});
|
||||
|
||||
export const makeMessageWithIncident = (): NotificationMessage =>
|
||||
makeMessage({
|
||||
content: {
|
||||
...makeMessage().content,
|
||||
incident: { id: "inc-1", url: "https://app.example.com/incidents/inc-1", createdAt: new Date("2025-01-01T00:00:00Z") },
|
||||
},
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import type { INotificationProvider } from "../../src/service/infrastructure/notificationProviders/INotificationProvider.ts";
|
||||
import type { Notification } from "../../src/types/index.ts";
|
||||
import type { NotificationMessage } from "../../src/types/notificationMessage.ts";
|
||||
|
||||
const makeMessage = (): NotificationMessage => ({
|
||||
type: "monitor_down",
|
||||
severity: "critical",
|
||||
monitor: { id: "mon-1", name: "Test", url: "https://example.com", type: "http", status: "down" },
|
||||
content: {
|
||||
title: "Monitor Down: Test",
|
||||
summary: "Test is down",
|
||||
details: ["URL: https://example.com"],
|
||||
timestamp: new Date("2025-01-01T00:00:00Z"),
|
||||
},
|
||||
clientHost: "https://app.example.com",
|
||||
metadata: { teamId: "team-1", notificationReason: "status_change" },
|
||||
});
|
||||
|
||||
export const testNotificationProviderContract = (
|
||||
name: string,
|
||||
opts: {
|
||||
create: () => INotificationProvider;
|
||||
makeNotification: () => Partial<Notification>;
|
||||
}
|
||||
) => {
|
||||
describe(`INotificationProvider contract: ${name}`, () => {
|
||||
it("sendTestAlert returns a boolean", async () => {
|
||||
const provider = opts.create();
|
||||
const result = await provider.sendTestAlert(opts.makeNotification());
|
||||
expect(typeof result).toBe("boolean");
|
||||
});
|
||||
|
||||
it("sendMessage returns a boolean", async () => {
|
||||
const provider = opts.create();
|
||||
const result = await provider.sendMessage(opts.makeNotification() as Notification, makeMessage());
|
||||
expect(typeof result).toBe("boolean");
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import type { IStatusProvider } from "../../src/service/infrastructure/network/IStatusProvider.ts";
|
||||
import type { Monitor } from "../../src/types/index.ts";
|
||||
|
||||
/**
|
||||
* Shared contract tests for all IStatusProvider implementations.
|
||||
* Tests the interface guarantees that every provider must satisfy.
|
||||
*/
|
||||
export const testStatusProviderContract = (
|
||||
name: string,
|
||||
opts: {
|
||||
create: () => IStatusProvider<unknown>;
|
||||
supportedType: string;
|
||||
unsupportedType: string;
|
||||
makeMonitor: () => Monitor;
|
||||
}
|
||||
) => {
|
||||
describe(`IStatusProvider contract: ${name}`, () => {
|
||||
it("has a readonly type property matching the supported type", () => {
|
||||
const provider = opts.create();
|
||||
expect(provider.type).toBe(opts.supportedType);
|
||||
});
|
||||
|
||||
it("supports() returns true for its own type", () => {
|
||||
const provider = opts.create();
|
||||
expect(provider.supports(opts.supportedType as any)).toBe(true);
|
||||
});
|
||||
|
||||
it("supports() returns false for a different type", () => {
|
||||
const provider = opts.create();
|
||||
expect(provider.supports(opts.unsupportedType as any)).toBe(false);
|
||||
});
|
||||
|
||||
it("handle() returns an object with required MonitorStatusResponse fields", async () => {
|
||||
const provider = opts.create();
|
||||
try {
|
||||
const result = await provider.handle(opts.makeMonitor());
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
monitorId: expect.anything(),
|
||||
teamId: expect.anything(),
|
||||
type: expect.anything(),
|
||||
status: expect.any(Boolean),
|
||||
code: expect.any(Number),
|
||||
message: expect.any(String),
|
||||
})
|
||||
);
|
||||
} catch (err: any) {
|
||||
// Some providers throw AppError on failure — that's also valid behavior
|
||||
expect(err).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, expect, it, beforeEach } from "@jest/globals";
|
||||
import { createHeartbeatTestHarness, makeMonitor, type HeartbeatTestHarness } from "../helpers/heartbeatTestHarness.ts";
|
||||
|
||||
describe("Heartbeat job: down detection", () => {
|
||||
let h: HeartbeatTestHarness;
|
||||
|
||||
beforeEach(() => {
|
||||
h = createHeartbeatTestHarness();
|
||||
});
|
||||
|
||||
it("transitions monitor to down and creates incident after threshold failures", async () => {
|
||||
// Monitor starts with full passing window: [true, true, true, true, true]
|
||||
const monitor = makeMonitor();
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// 2 failures: window = [true, true, true, false, false] = 40% < 60%
|
||||
h.setNextResponse(false, 503);
|
||||
await h.heartbeatJob(monitor);
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
let storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(storedMonitor.status).toBe("up");
|
||||
expect(h.incidentsRepo.getAll()).toHaveLength(0);
|
||||
|
||||
// 3rd failure: window = [true, true, false, false, false] = 60% >= 60%
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(storedMonitor.status).toBe("down");
|
||||
|
||||
// Incident should have been created
|
||||
const incidents = h.incidentsRepo.getAll();
|
||||
expect(incidents).toHaveLength(1);
|
||||
expect(incidents[0].monitorId).toBe("mon-1");
|
||||
expect(incidents[0].status).toBe(true);
|
||||
expect(incidents[0].statusCode).toBe(503);
|
||||
|
||||
// Notification should have been triggered
|
||||
expect(h.notificationsService.handleNotifications).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not create duplicate incidents on continued failures", async () => {
|
||||
const monitor = makeMonitor();
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
h.setNextResponse(false, 500);
|
||||
|
||||
// 3 failures trigger down, 4 more continue failing
|
||||
for (let i = 0; i < 7; i++) {
|
||||
await h.heartbeatJob(monitor);
|
||||
}
|
||||
|
||||
// Only one incident despite 7 heartbeats
|
||||
expect(h.incidentsRepo.getAll()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("stays up when failures are below threshold", async () => {
|
||||
const monitor = makeMonitor();
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// 2 failures: window = [true, true, true, false, false] = 40% < 60%
|
||||
h.setNextResponse(false, 500);
|
||||
await h.heartbeatJob(monitor);
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
// 3 passes: window = [false, false, true, true, true] = 40% < 60%
|
||||
h.setNextResponse(true, 200);
|
||||
await h.heartbeatJob(monitor);
|
||||
await h.heartbeatJob(monitor);
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(storedMonitor.status).toBe("up");
|
||||
expect(h.incidentsRepo.getAll()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
import { describe, expect, it, beforeEach } from "@jest/globals";
|
||||
import { createHeartbeatTestHarness, makeMonitor, type HeartbeatTestHarness } from "../helpers/heartbeatTestHarness.ts";
|
||||
import type { MaintenanceWindow } from "../../src/types/maintenanceWindow.ts";
|
||||
|
||||
const makeMaintenanceWindow = (overrides?: Partial<MaintenanceWindow>): MaintenanceWindow => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 60 * 60 * 1000); // 1 hour ago
|
||||
const end = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour from now
|
||||
return {
|
||||
id: "mw-1",
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
active: true,
|
||||
name: "Test Maintenance",
|
||||
duration: 2,
|
||||
durationUnit: "hours",
|
||||
repeat: 0,
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
describe("Heartbeat job: maintenance windows", () => {
|
||||
let h: HeartbeatTestHarness;
|
||||
|
||||
beforeEach(() => {
|
||||
h = createHeartbeatTestHarness();
|
||||
});
|
||||
|
||||
it("skips checks and sets monitor to maintenance when window is active", async () => {
|
||||
const monitor = makeMonitor();
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// Configure an active maintenance window covering the current time
|
||||
h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([makeMaintenanceWindow()]);
|
||||
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
// Monitor status should be set to maintenance
|
||||
const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(storedMonitor.status).toBe("maintenance");
|
||||
|
||||
// No network request should have been made
|
||||
expect(h.networkService.requestStatus).not.toHaveBeenCalled();
|
||||
|
||||
// No check should have been buffered
|
||||
expect(h.bufferStub.addToBuffer).not.toHaveBeenCalled();
|
||||
|
||||
// No incident should have been created
|
||||
expect(h.incidentsRepo.getAll()).toHaveLength(0);
|
||||
|
||||
// No notification should have been sent
|
||||
expect(h.notificationsService.handleNotifications).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not update status if monitor is already in maintenance", async () => {
|
||||
const monitor = makeMonitor({ status: "maintenance" });
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([makeMaintenanceWindow()]);
|
||||
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
// findById reads the stored monitor; updateById should not have been called
|
||||
// since status was already "maintenance"
|
||||
const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(storedMonitor.status).toBe("maintenance");
|
||||
expect(h.networkService.requestStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resumes normal checks when maintenance window expires", async () => {
|
||||
const monitor = makeMonitor();
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// First heartbeat: inside maintenance window
|
||||
h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([makeMaintenanceWindow()]);
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
let storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(storedMonitor.status).toBe("maintenance");
|
||||
|
||||
// Second heartbeat: maintenance window expired (no windows returned)
|
||||
h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([]);
|
||||
h.setNextResponse(true, 200);
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
// Network request should have been made this time
|
||||
expect(h.networkService.requestStatus).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Check should have been buffered
|
||||
expect(h.bufferStub.addToBuffer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips checks when window is inactive (active: false)", async () => {
|
||||
const monitor = makeMonitor();
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// Inactive maintenance window — should be ignored
|
||||
h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([makeMaintenanceWindow({ active: false })]);
|
||||
|
||||
h.setNextResponse(true, 200);
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
// Normal check should proceed since window is inactive
|
||||
expect(h.networkService.requestStatus).toHaveBeenCalledTimes(1);
|
||||
expect(h.bufferStub.addToBuffer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips checks when window is in the past (expired)", async () => {
|
||||
const monitor = makeMonitor();
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// Window ended an hour ago
|
||||
const pastEnd = new Date(Date.now() - 60 * 60 * 1000);
|
||||
const pastStart = new Date(pastEnd.getTime() - 2 * 60 * 60 * 1000);
|
||||
h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([
|
||||
makeMaintenanceWindow({ start: pastStart.toISOString(), end: pastEnd.toISOString() }),
|
||||
]);
|
||||
|
||||
h.setNextResponse(true, 200);
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
// Window is in the past — normal check should proceed
|
||||
expect(h.networkService.requestStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, expect, it, beforeEach } from "@jest/globals";
|
||||
import { createHeartbeatTestHarness, makeMonitor, type HeartbeatTestHarness } from "../helpers/heartbeatTestHarness.ts";
|
||||
|
||||
describe("Heartbeat job: notification failure isolation", () => {
|
||||
let h: HeartbeatTestHarness;
|
||||
|
||||
beforeEach(() => {
|
||||
h = createHeartbeatTestHarness();
|
||||
});
|
||||
|
||||
it("creates incident even when notification dispatch throws", async () => {
|
||||
const monitor = makeMonitor();
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// Make notifications throw
|
||||
h.notificationsService.handleNotifications.mockRejectedValue(new Error("SMTP connection refused"));
|
||||
|
||||
// Drive monitor down
|
||||
h.setNextResponse(false, 503);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await h.heartbeatJob(monitor);
|
||||
}
|
||||
|
||||
// Incident should still have been created
|
||||
const incidents = h.incidentsRepo.getAll();
|
||||
expect(incidents).toHaveLength(1);
|
||||
expect(incidents[0].status).toBe(true);
|
||||
expect(incidents[0].statusCode).toBe(503);
|
||||
|
||||
// Monitor should still be marked down
|
||||
const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(storedMonitor.status).toBe("down");
|
||||
});
|
||||
|
||||
it("auto-resolves incident even when recovery notification throws", async () => {
|
||||
const monitor = makeMonitor();
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// Drive monitor down (notifications succeed here)
|
||||
h.setNextResponse(false, 503);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await h.heartbeatJob(monitor);
|
||||
}
|
||||
expect(h.incidentsRepo.getAll()[0].status).toBe(true);
|
||||
|
||||
// Now make notifications throw for the recovery
|
||||
h.notificationsService.handleNotifications.mockRejectedValue(new Error("Slack API timeout"));
|
||||
|
||||
// Recover
|
||||
h.setNextResponse(true, 200);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await h.heartbeatJob(monitor);
|
||||
}
|
||||
|
||||
// Incident should still be resolved despite notification failure
|
||||
const incidents = h.incidentsRepo.getAll();
|
||||
expect(incidents).toHaveLength(1);
|
||||
expect(incidents[0].status).toBe(false);
|
||||
expect(incidents[0].resolutionType).toBe("automatic");
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import { describe, expect, it, beforeEach } from "@jest/globals";
|
||||
import { createHeartbeatTestHarness, makeMonitor, type HeartbeatTestHarness } from "../helpers/heartbeatTestHarness.ts";
|
||||
|
||||
describe("Heartbeat job: recovery", () => {
|
||||
let h: HeartbeatTestHarness;
|
||||
|
||||
beforeEach(() => {
|
||||
h = createHeartbeatTestHarness();
|
||||
});
|
||||
|
||||
it("recovers monitor and auto-resolves incident when checks pass", async () => {
|
||||
const monitor = makeMonitor();
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// 3 failures to trigger down (60% threshold)
|
||||
h.setNextResponse(false, 503);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await h.heartbeatJob(monitor);
|
||||
}
|
||||
expect(h.incidentsRepo.getAll()).toHaveLength(1);
|
||||
expect(h.incidentsRepo.getAll()[0].status).toBe(true);
|
||||
|
||||
// Send passing checks to push failure rate below threshold
|
||||
// Window after 3 fails: [false, false, false, false, false] (prior trues shifted out)
|
||||
// Need enough passes to drop below 60%
|
||||
h.setNextResponse(true, 200);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await h.heartbeatJob(monitor);
|
||||
}
|
||||
|
||||
// Monitor should be back up
|
||||
const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(storedMonitor.status).toBe("up");
|
||||
|
||||
// Incident should be auto-resolved
|
||||
const incidents = h.incidentsRepo.getAll();
|
||||
expect(incidents).toHaveLength(1);
|
||||
expect(incidents[0].status).toBe(false);
|
||||
expect(incidents[0].resolutionType).toBe("automatic");
|
||||
expect(incidents[0].endTime).not.toBeNull();
|
||||
});
|
||||
|
||||
it("creates a new incident after recovery if monitor goes down again", async () => {
|
||||
const monitor = makeMonitor();
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// First outage: 3 failures to trigger (window shifts from all-true)
|
||||
h.setNextResponse(false, 503);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await h.heartbeatJob(monitor);
|
||||
}
|
||||
|
||||
// Recovery: enough passes to drop below threshold
|
||||
h.setNextResponse(true, 200);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await h.heartbeatJob(monitor);
|
||||
}
|
||||
|
||||
// Second outage: 3 more failures to trigger again
|
||||
h.setNextResponse(false, 502);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await h.heartbeatJob(monitor);
|
||||
}
|
||||
|
||||
const incidents = h.incidentsRepo.getAll();
|
||||
expect(incidents).toHaveLength(2);
|
||||
|
||||
// First incident resolved
|
||||
expect(incidents[0].status).toBe(false);
|
||||
expect(incidents[0].resolutionType).toBe("automatic");
|
||||
expect(incidents[0].statusCode).toBe(503);
|
||||
|
||||
// Second incident active
|
||||
expect(incidents[1].status).toBe(true);
|
||||
expect(incidents[1].statusCode).toBe(502);
|
||||
});
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
import { describe, expect, it, beforeEach } from "@jest/globals";
|
||||
import { createHeartbeatTestHarness, makeMonitor, type HeartbeatTestHarness } from "../helpers/heartbeatTestHarness.ts";
|
||||
import type { MonitorStatusResponse } from "../../src/types/network.ts";
|
||||
import type { HardwareStatusPayload } from "../../src/types/network.ts";
|
||||
|
||||
const makeHardwareResponse = (cpuUsage: number): MonitorStatusResponse<HardwareStatusPayload> => ({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "hardware",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "OK",
|
||||
responseTime: 50,
|
||||
payload: {
|
||||
data: {
|
||||
cpu: { usage_percent: cpuUsage, temperature: [] },
|
||||
memory: { usage_percent: 0.3 },
|
||||
disk: [{ usage_percent: 0.2 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe("Heartbeat job: hardware threshold breach", () => {
|
||||
let h: HeartbeatTestHarness;
|
||||
|
||||
beforeEach(() => {
|
||||
h = createHeartbeatTestHarness();
|
||||
});
|
||||
|
||||
it("transitions to breached and creates incident after alert counter hits zero", async () => {
|
||||
// Hardware monitor with CPU threshold at 80% (stored as 80, compared as 80/100 = 0.8)
|
||||
// Alert counters start at 5, decrement each breach, trigger at 0
|
||||
const monitor = makeMonitor({
|
||||
type: "hardware",
|
||||
status: "up",
|
||||
cpuAlertThreshold: 80,
|
||||
cpuAlertCounter: 5,
|
||||
memoryAlertThreshold: 100,
|
||||
memoryAlertCounter: 5,
|
||||
diskAlertThreshold: 100,
|
||||
diskAlertCounter: 5,
|
||||
tempAlertThreshold: 100,
|
||||
tempAlertCounter: 5,
|
||||
selectedDisks: [],
|
||||
});
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// CPU at 90% (0.9) exceeds threshold of 80% (0.8)
|
||||
h.setNextResponseFull(makeHardwareResponse(0.9));
|
||||
|
||||
// Configure message builder for when incident is created
|
||||
h.messageBuilder.extractThresholdBreaches.mockReturnValue([{ metric: "cpu", formattedValue: "90%", threshold: 80, unit: "%" }]);
|
||||
|
||||
// 4 heartbeats: counter goes 5→4→3→2→1, status still "up"
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await h.heartbeatJob(monitor);
|
||||
}
|
||||
|
||||
let storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(storedMonitor.status).toBe("up");
|
||||
expect(storedMonitor.cpuAlertCounter).toBe(1);
|
||||
expect(h.incidentsRepo.getAll()).toHaveLength(0);
|
||||
|
||||
// 5th heartbeat: counter hits 0, status transitions to "breached"
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(storedMonitor.status).toBe("breached");
|
||||
expect(storedMonitor.cpuAlertCounter).toBe(0);
|
||||
|
||||
// Incident created with statusCode 9999 and threshold message
|
||||
const incidents = h.incidentsRepo.getAll();
|
||||
expect(incidents).toHaveLength(1);
|
||||
expect(incidents[0].statusCode).toBe(9999);
|
||||
expect(incidents[0].message).toBe("CPU: 90% (threshold: 80%)");
|
||||
expect(incidents[0].status).toBe(true);
|
||||
|
||||
// Notification should have been triggered
|
||||
expect(h.notificationsService.handleNotifications).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recovers from breached to up when metrics return to normal", async () => {
|
||||
// Start with a monitor already in breached state with counter at 0
|
||||
const monitor = makeMonitor({
|
||||
type: "hardware",
|
||||
status: "breached",
|
||||
cpuAlertThreshold: 80,
|
||||
cpuAlertCounter: 0,
|
||||
memoryAlertThreshold: 100,
|
||||
memoryAlertCounter: 5,
|
||||
diskAlertThreshold: 100,
|
||||
diskAlertCounter: 5,
|
||||
tempAlertThreshold: 100,
|
||||
tempAlertCounter: 5,
|
||||
selectedDisks: [],
|
||||
});
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// Also seed an active incident for this monitor
|
||||
await h.incidentsRepo.create({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
status: true,
|
||||
statusCode: 9999,
|
||||
message: "CPU: 90% (threshold: 80%)",
|
||||
});
|
||||
|
||||
// CPU at 50% (0.5) — below threshold of 80% (0.8)
|
||||
h.setNextResponseFull(makeHardwareResponse(0.5));
|
||||
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
// Monitor should recover to "up"
|
||||
const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(storedMonitor.status).toBe("up");
|
||||
// Counter resets to 5 when not breaching
|
||||
expect(storedMonitor.cpuAlertCounter).toBe(5);
|
||||
|
||||
// Incident should be auto-resolved
|
||||
const incidents = h.incidentsRepo.getAll();
|
||||
expect(incidents).toHaveLength(1);
|
||||
expect(incidents[0].status).toBe(false);
|
||||
expect(incidents[0].resolutionType).toBe("automatic");
|
||||
});
|
||||
|
||||
it("does not create duplicate incidents while remaining breached", async () => {
|
||||
const monitor = makeMonitor({
|
||||
type: "hardware",
|
||||
status: "up",
|
||||
cpuAlertThreshold: 80,
|
||||
cpuAlertCounter: 5,
|
||||
memoryAlertThreshold: 100,
|
||||
memoryAlertCounter: 5,
|
||||
diskAlertThreshold: 100,
|
||||
diskAlertCounter: 5,
|
||||
tempAlertThreshold: 100,
|
||||
tempAlertCounter: 5,
|
||||
selectedDisks: [],
|
||||
});
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
h.setNextResponseFull(makeHardwareResponse(0.9));
|
||||
h.messageBuilder.extractThresholdBreaches.mockReturnValue([{ metric: "cpu", formattedValue: "90%", threshold: 80, unit: "%" }]);
|
||||
|
||||
// 5 heartbeats to trigger breach, then 5 more while still breaching
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await h.heartbeatJob(monitor);
|
||||
}
|
||||
|
||||
// Only one incident despite 10 heartbeats
|
||||
expect(h.incidentsRepo.getAll()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,216 +0,0 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { IncidentService } from "../../src/service/business/incidentService.ts";
|
||||
import { InMemoryIncidentsRepository } from "../helpers/InMemoryIncidentsRepository.ts";
|
||||
import { createMockLogger } from "../helpers/createMockLogger.ts";
|
||||
import type { IMonitorsRepository, IUsersRepository } from "../../src/repositories/index.ts";
|
||||
import type { INotificationMessageBuilder } from "../../src/service/infrastructure/notificationMessageBuilder.ts";
|
||||
import type { Monitor } from "../../src/types/monitor.ts";
|
||||
import type { MonitorActionDecision } from "../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
name: "Test Monitor",
|
||||
type: "http",
|
||||
url: "https://example.com",
|
||||
status: "down",
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
|
||||
shouldCreateIncident: false,
|
||||
shouldResolveIncident: false,
|
||||
shouldSendNotification: false,
|
||||
incidentReason: null,
|
||||
notificationReason: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ── Test suite ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Incident lifecycle (integration)", () => {
|
||||
let repo: InMemoryIncidentsRepository;
|
||||
let service: IncidentService;
|
||||
let monitorsRepo: jest.Mocked<IMonitorsRepository>;
|
||||
let usersRepo: jest.Mocked<IUsersRepository>;
|
||||
let messageBuilder: jest.Mocked<INotificationMessageBuilder>;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = new InMemoryIncidentsRepository();
|
||||
monitorsRepo = { findById: jest.fn() } as unknown as jest.Mocked<IMonitorsRepository>;
|
||||
usersRepo = { findById: jest.fn() } as unknown as jest.Mocked<IUsersRepository>;
|
||||
messageBuilder = { extractThresholdBreaches: jest.fn() } as unknown as jest.Mocked<INotificationMessageBuilder>;
|
||||
service = new IncidentService(createMockLogger() as any, repo, monitorsRepo, usersRepo, messageBuilder);
|
||||
});
|
||||
|
||||
// ── Creation ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("creates an incident when monitor goes down", async () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
|
||||
|
||||
const incident = await service.handleIncident(monitor, 503, decision);
|
||||
|
||||
expect(incident).not.toBeNull();
|
||||
expect(incident!.monitorId).toBe("mon-1");
|
||||
expect(incident!.teamId).toBe("team-1");
|
||||
expect(incident!.status).toBe(true);
|
||||
expect(incident!.statusCode).toBe(503);
|
||||
expect(incident!.endTime).toBeNull();
|
||||
expect(incident!.resolutionType).toBeNull();
|
||||
|
||||
const stored = repo.getAll();
|
||||
expect(stored).toHaveLength(1);
|
||||
expect(stored[0].id).toBe(incident!.id);
|
||||
});
|
||||
|
||||
// ── Idempotency ──────────────────────────────────────────────────────────
|
||||
|
||||
it("does not create a duplicate incident for the same monitor", async () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
|
||||
|
||||
const first = await service.handleIncident(monitor, 503, decision);
|
||||
const second = await service.handleIncident(monitor, 503, decision);
|
||||
|
||||
expect(second!.id).toBe(first!.id);
|
||||
expect(repo.getAll()).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ── Auto-resolve ─────────────────────────────────────────────────────────
|
||||
|
||||
it("auto-resolves an incident when monitor recovers", async () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
const createDecision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
|
||||
const created = await service.handleIncident(monitor, 503, createDecision);
|
||||
|
||||
const resolveDecision = makeDecision({ shouldResolveIncident: true });
|
||||
const resolved = await service.handleIncident(monitor, 200, resolveDecision);
|
||||
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.id).toBe(created!.id);
|
||||
expect(resolved!.status).toBe(false);
|
||||
expect(resolved!.endTime).toBeDefined();
|
||||
expect(resolved!.resolutionType).toBe("automatic");
|
||||
});
|
||||
|
||||
// ── Resolve with nothing active ──────────────────────────────────────────
|
||||
|
||||
it("returns null when resolving with no active incident", async () => {
|
||||
const monitor = makeMonitor({ status: "up" });
|
||||
const decision = makeDecision({ shouldResolveIncident: true });
|
||||
|
||||
const result = await service.handleIncident(monitor, 200, decision);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
// ── Manual resolution ────────────────────────────────────────────────────
|
||||
|
||||
it("manually resolves an active incident with comment", async () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
|
||||
const created = await service.handleIncident(monitor, 500, decision);
|
||||
|
||||
const resolved = await service.resolveIncident(created!.id, "user-1", "team-1", "Root cause identified", "user@test.com");
|
||||
|
||||
expect(resolved.status).toBe(false);
|
||||
expect(resolved.resolutionType).toBe("manual");
|
||||
expect(resolved.resolvedBy).toBe("user-1");
|
||||
expect(resolved.resolvedByEmail).toBe("user@test.com");
|
||||
expect(resolved.comment).toBe("Root cause identified");
|
||||
expect(resolved.endTime).toBeDefined();
|
||||
});
|
||||
|
||||
it("throws when manually resolving an already-resolved incident", async () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
|
||||
const created = await service.handleIncident(monitor, 500, decision);
|
||||
|
||||
await service.resolveIncident(created!.id, "user-1", "team-1");
|
||||
|
||||
await expect(service.resolveIncident(created!.id, "user-1", "team-1")).rejects.toThrow("Incident not found");
|
||||
});
|
||||
|
||||
// ── Threshold breach ─────────────────────────────────────────────────────
|
||||
|
||||
it("creates a threshold breach incident with statusCode 9999 and descriptive message", async () => {
|
||||
const monitor = makeMonitor({ status: "breached", type: "hardware" });
|
||||
(messageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([
|
||||
{ metric: "cpu", formattedValue: "92%", threshold: 80, unit: "%" },
|
||||
{ metric: "memory", formattedValue: "88%", threshold: 85, unit: "%" },
|
||||
]);
|
||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
|
||||
|
||||
const incident = await service.handleIncident(monitor, 200, decision, { monitorId: "mon-1" } as any);
|
||||
|
||||
expect(incident!.statusCode).toBe(9999);
|
||||
expect(incident!.message).toBe("CPU: 92% (threshold: 80%), MEMORY: 88% (threshold: 85%)");
|
||||
});
|
||||
|
||||
// ── No action ────────────────────────────────────────────────────────────
|
||||
|
||||
it("returns null when no action is needed", async () => {
|
||||
const monitor = makeMonitor({ status: "up" });
|
||||
const decision = makeDecision();
|
||||
|
||||
const result = await service.handleIncident(monitor, 200, decision);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(repo.getAll()).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── Full lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
it("handles a complete lifecycle: create -> manual resolve -> new incident -> auto-resolve", async () => {
|
||||
const monitor = makeMonitor({ status: "down" });
|
||||
|
||||
// First outage
|
||||
const first = await service.handleIncident(monitor, 503, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }));
|
||||
expect(first!.status).toBe(true);
|
||||
|
||||
// Manually resolved
|
||||
await service.resolveIncident(first!.id, "user-1", "team-1", "Restarted server");
|
||||
|
||||
// Second outage — new incident since previous was resolved
|
||||
const second = await service.handleIncident(monitor, 502, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }));
|
||||
expect(second!.id).not.toBe(first!.id);
|
||||
expect(second!.statusCode).toBe(502);
|
||||
|
||||
// Auto-resolve
|
||||
const resolved = await service.handleIncident(monitor, 200, makeDecision({ shouldResolveIncident: true }));
|
||||
expect(resolved!.id).toBe(second!.id);
|
||||
expect(resolved!.status).toBe(false);
|
||||
expect(resolved!.resolutionType).toBe("automatic");
|
||||
|
||||
// Both incidents stored, both resolved
|
||||
const all = repo.getAll();
|
||||
expect(all).toHaveLength(2);
|
||||
expect(all.every((i) => i.status === false)).toBe(true);
|
||||
});
|
||||
|
||||
// ── Cross-monitor isolation ──────────────────────────────────────────────
|
||||
|
||||
it("incidents for different monitors do not interfere", async () => {
|
||||
const monitorA = makeMonitor({ id: "mon-a", status: "down" });
|
||||
const monitorB = makeMonitor({ id: "mon-b", status: "down" });
|
||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
|
||||
|
||||
const incidentA = await service.handleIncident(monitorA, 500, decision);
|
||||
const incidentB = await service.handleIncident(monitorB, 502, decision);
|
||||
|
||||
expect(incidentA!.id).not.toBe(incidentB!.id);
|
||||
expect(repo.getAll()).toHaveLength(2);
|
||||
|
||||
// Resolving monitor A does not affect monitor B
|
||||
const resolved = await service.handleIncident(monitorA, 200, makeDecision({ shouldResolveIncident: true }));
|
||||
expect(resolved!.id).toBe(incidentA!.id);
|
||||
|
||||
const bStillActive = await repo.findActiveByMonitorId("mon-b", "team-1");
|
||||
expect(bStillActive).not.toBeNull();
|
||||
expect(bStillActive!.status).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { jest } from "@jest/globals";
|
||||
import { MonitorService } from "../src/service/business/monitorService.ts";
|
||||
import type { IChecksRepository, IMonitorStatsRepository, IMonitorsRepository, IStatusPagesRepository } from "../src/repositories/index.ts";
|
||||
|
||||
const createMonitorsRepositoryMock = () =>
|
||||
({
|
||||
findMonitorCountByTeamIdAndType: jest.fn(),
|
||||
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(),
|
||||
}) as unknown as IChecksRepository;
|
||||
|
||||
const createMonitorStatsRepositoryMock = () =>
|
||||
({
|
||||
findByMonitorId: jest.fn(),
|
||||
deleteByMonitorId: jest.fn(),
|
||||
}) as unknown as IMonitorStatsRepository;
|
||||
|
||||
const createStatusPagesRepositoryMock = () =>
|
||||
({
|
||||
removeMonitorFromStatusPages: jest.fn(),
|
||||
}) as unknown as IStatusPagesRepository;
|
||||
|
||||
const createService = ({
|
||||
monitorsRepository = createMonitorsRepositoryMock(),
|
||||
checksRepository = createChecksRepositoryMock(),
|
||||
monitorStatsRepository = createMonitorStatsRepositoryMock(),
|
||||
statusPagesRepository = createStatusPagesRepositoryMock(),
|
||||
}: {
|
||||
monitorsRepository?: IMonitorsRepository;
|
||||
checksRepository?: IChecksRepository;
|
||||
monitorStatsRepository?: IMonitorStatsRepository;
|
||||
statusPagesRepository?: IStatusPagesRepository;
|
||||
} = {}) => {
|
||||
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")),
|
||||
},
|
||||
games: [],
|
||||
monitorsRepository,
|
||||
checksRepository,
|
||||
monitorStatsRepository,
|
||||
statusPagesRepository,
|
||||
});
|
||||
};
|
||||
|
||||
describe("MonitorService", () => {
|
||||
describe("getMonitorsWithChecksByTeamId", () => {
|
||||
it("returns monitors enriched with normalized checks", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
(monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(2);
|
||||
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([
|
||||
{ id: "m1", name: "Monitor 1", interval: 60000 },
|
||||
{ id: "m2", name: "Monitor 2", interval: 60000 },
|
||||
]);
|
||||
|
||||
const checksRepository = createChecksRepositoryMock();
|
||||
(checksRepository.findLatestChecksByMonitorIds as jest.Mock).mockResolvedValue({
|
||||
m1: [
|
||||
{ responseTime: 10, status: true, message: "OK" },
|
||||
{ responseTime: 20, status: true, message: "OK" },
|
||||
],
|
||||
m2: [{ responseTime: 50, status: true, message: "OK" }],
|
||||
});
|
||||
|
||||
const service = createService({ monitorsRepository, checksRepository });
|
||||
const result = await service.getMonitorsWithChecksByTeamId({ teamId: "team" });
|
||||
|
||||
expect(result).toMatchObject({ count: 2 });
|
||||
expect(result.monitors).toHaveLength(2);
|
||||
expect(result.monitors[0]).toHaveProperty("checks");
|
||||
expect(result.monitors[0].checks.length).toBeGreaterThan(0);
|
||||
expect(result.monitors[0].checks[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
responseTime: expect.any(Number),
|
||||
status: expect.any(Boolean),
|
||||
message: expect.any(String),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMonitorsByTeamId", () => {
|
||||
it("returns monitors array from repository", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([
|
||||
{ id: "m1", name: "Monitor 1" },
|
||||
{ id: "m2", name: "Monitor 2" },
|
||||
]);
|
||||
const service = createService({ monitorsRepository });
|
||||
const result = await service.getMonitorsByTeamId({ teamId: "team" } as any);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toHaveProperty("id", "m1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMonitorsWithChecksByTeamId summary", () => {
|
||||
it("includes summary and monitors with checks", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
(monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1);
|
||||
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([{ id: "m1", type: "http" }]);
|
||||
(monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({
|
||||
totalMonitors: 1,
|
||||
upMonitors: 1,
|
||||
downMonitors: 0,
|
||||
pausedMonitors: 0,
|
||||
});
|
||||
|
||||
const checksRepository = createChecksRepositoryMock();
|
||||
(checksRepository.findLatestChecksByMonitorIds as jest.Mock).mockResolvedValue({ m1: [] });
|
||||
|
||||
const service = createService({ monitorsRepository, checksRepository });
|
||||
const result = await service.getMonitorsWithChecksByTeamId({ teamId: "team" });
|
||||
expect(result).toEqual({
|
||||
summary: { totalMonitors: 1, upMonitors: 1, downMonitors: 0, pausedMonitors: 0 },
|
||||
count: 1,
|
||||
monitors: [{ id: "m1", type: "http", checks: [] }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUptimeDetailsById", () => {
|
||||
it("returns monitorData and monitorStats with expected shape", async () => {
|
||||
const TEAM_ID = "team";
|
||||
const monitor = {
|
||||
id: "monitor-1",
|
||||
teamId: TEAM_ID,
|
||||
name: "Hardware monitor",
|
||||
interval: 60000,
|
||||
statusWindow: [],
|
||||
statusWindowSize: 5,
|
||||
statusWindowThreshold: 60,
|
||||
type: "http",
|
||||
ignoreTlsErrors: false,
|
||||
url: "https://example.com",
|
||||
isActive: true,
|
||||
alertThreshold: 5,
|
||||
cpuAlertThreshold: 5,
|
||||
memoryAlertThreshold: 5,
|
||||
diskAlertThreshold: 5,
|
||||
tempAlertThreshold: 5,
|
||||
selectedDisks: [],
|
||||
notifications: [],
|
||||
group: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
const checksRepository = createChecksRepositoryMock();
|
||||
(checksRepository.findDateRangeChecksByMonitor as jest.Mock).mockResolvedValue({
|
||||
monitorType: "http",
|
||||
groupedChecks: [{ _id: "2024-01-01", avgResponseTime: 100, totalChecks: 2 }],
|
||||
groupedUpChecks: [{ _id: "2024-01-01", totalChecks: 2, avgResponseTime: 90 }],
|
||||
groupedDownChecks: [{ _id: "2024-01-01", totalChecks: 0, avgResponseTime: 0 }],
|
||||
uptimePercentage: 0.99,
|
||||
avgResponseTime: 95,
|
||||
});
|
||||
|
||||
const monitorStatsRepository = createMonitorStatsRepositoryMock();
|
||||
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue({
|
||||
id: "stats-1",
|
||||
monitorId: monitor.id,
|
||||
avgResponseTime: 90,
|
||||
totalChecks: 10,
|
||||
totalUpChecks: 9,
|
||||
totalDownChecks: 1,
|
||||
uptimePercentage: 0.9,
|
||||
lastCheckTimestamp: 123456789,
|
||||
lastResponseTime: 80,
|
||||
timeOfLastFailure: 123456700,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const service = createService({ monitorsRepository, checksRepository, monitorStatsRepository });
|
||||
const result = await service.getUptimeDetailsById({ teamId: TEAM_ID, monitorId: "monitor-1", dateRange: "recent" });
|
||||
|
||||
expect(result).toHaveProperty("monitorData");
|
||||
expect(result.monitorData.monitor).toMatchObject({ id: monitor.id, name: monitor.name });
|
||||
expect(result.monitorData.groupedChecks[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
_id: expect.any(String),
|
||||
avgResponseTime: expect.any(Number),
|
||||
totalChecks: expect.any(Number),
|
||||
})
|
||||
);
|
||||
expect(result.monitorStats).toEqual(
|
||||
expect.objectContaining({
|
||||
monitorId: monitor.id,
|
||||
avgResponseTime: 90,
|
||||
totalChecks: 10,
|
||||
totalUpChecks: 9,
|
||||
totalDownChecks: 1,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import SuperSimpleQueueHelper from "../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
|
||||
import type { Monitor } from "../src/types/monitor.ts";
|
||||
|
||||
const createLogger = () => ({ info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() });
|
||||
|
||||
const createHelper = (overrides?: Partial<ConstructorParameters<typeof SuperSimpleQueueHelper>[0]>) => {
|
||||
const maintenanceWindowsRepository = {
|
||||
findByMonitorId: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const statusServiceMock = {
|
||||
updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1" }, statusChanged: true, prevStatus: false }),
|
||||
};
|
||||
const helper = new SuperSimpleQueueHelper({
|
||||
logger: createLogger(),
|
||||
networkService: { requestStatus: jest.fn() },
|
||||
statusService: statusServiceMock,
|
||||
notificationsService: { handleNotifications: jest.fn().mockResolvedValue(undefined) },
|
||||
checkService: { buildCheck: jest.fn().mockResolvedValue({}) },
|
||||
buffer: { addToBuffer: jest.fn() },
|
||||
incidentService: { handleIncident: jest.fn().mockResolvedValue(undefined) },
|
||||
maintenanceWindowsRepository,
|
||||
...overrides,
|
||||
});
|
||||
return { helper, maintenanceWindowsRepository };
|
||||
};
|
||||
|
||||
describe("SuperSimpleQueueHelper", () => {
|
||||
describe("getMonitorJob", () => {
|
||||
it("skips execution when monitor is in maintenance window", async () => {
|
||||
const { helper } = createHelper();
|
||||
const spy = jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true);
|
||||
const job = helper.getMonitorJob();
|
||||
await job({ id: "m1", teamId: "team", interval: 60000 } as Monitor);
|
||||
expect(helper["networkService"].requestStatus).not.toHaveBeenCalled();
|
||||
expect(helper["logger"].debug).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining("Monitor m1 is in maintenance window") })
|
||||
);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("processes monitor status and notifications when active", async () => {
|
||||
const networkResponse = { monitor: { id: "m1" }, status: true };
|
||||
const updatedMonitor = { id: "m1", status: true };
|
||||
const { helper } = createHelper({
|
||||
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
|
||||
statusService: {
|
||||
updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: updatedMonitor, statusChanged: true, prevStatus: false, code: 200 }),
|
||||
},
|
||||
notificationsService: { handleNotifications: jest.fn().mockResolvedValue(undefined) },
|
||||
});
|
||||
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
|
||||
const job = helper.getMonitorJob();
|
||||
const monitor = { id: "m1", teamId: "team" } as Monitor;
|
||||
await job(monitor);
|
||||
expect(helper["networkService"].requestStatus).toHaveBeenCalledWith(monitor);
|
||||
});
|
||||
|
||||
it("throws when monitor id is missing", async () => {
|
||||
const { helper } = createHelper();
|
||||
const job = helper.getMonitorJob();
|
||||
await expect(job({} as Monitor)).rejects.toThrow("No monitor id");
|
||||
expect(helper["logger"].warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInMaintenanceWindow", () => {
|
||||
it("returns true when an active window spans now", async () => {
|
||||
const now = new Date();
|
||||
const { helper, maintenanceWindowsRepository } = createHelper();
|
||||
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
|
||||
{
|
||||
active: true,
|
||||
start: new Date(now.getTime() - 1000).toISOString(),
|
||||
end: new Date(now.getTime() + 1000).toISOString(),
|
||||
repeat: 0,
|
||||
},
|
||||
]);
|
||||
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when repeat interval advances window into current time", async () => {
|
||||
const now = Date.now();
|
||||
const { helper, maintenanceWindowsRepository } = createHelper();
|
||||
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
|
||||
{
|
||||
active: true,
|
||||
start: new Date(now - 7200000).toISOString(),
|
||||
end: new Date(now - 6600000).toISOString(),
|
||||
repeat: 3600000,
|
||||
},
|
||||
]);
|
||||
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when no active windows exist", async () => {
|
||||
const { helper } = createHelper();
|
||||
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
+45
-73
@@ -1,15 +1,19 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import type { Notification } from "../../../../src/types/notification.ts";
|
||||
import type { NotificationMessage } from "../../../../src/types/notificationMessage.ts";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import { TeamsProvider } from "../src/service/infrastructure/notificationProviders/teams.ts";
|
||||
import type { Notification } from "../src/types/notification.ts";
|
||||
import type { NotificationMessage } from "../src/types/notificationMessage.ts";
|
||||
|
||||
const mockPost = jest.fn();
|
||||
// Mock got
|
||||
jest.unstable_mockModule("got", () => ({
|
||||
default: { post: mockPost },
|
||||
HTTPError: class HTTPError extends Error {},
|
||||
default: { post: jest.fn() },
|
||||
}));
|
||||
|
||||
const { TeamsProvider } = await import("../../../../src/service/infrastructure/notificationProviders/teams.ts");
|
||||
const createLogger = () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
});
|
||||
|
||||
const createNotification = (overrides?: Partial<Notification>): Notification => ({
|
||||
id: "notif-1",
|
||||
@@ -54,14 +58,17 @@ const createMessage = (overrides?: Partial<NotificationMessage>): NotificationMe
|
||||
});
|
||||
|
||||
describe("TeamsProvider", () => {
|
||||
let provider: InstanceType<typeof TeamsProvider>;
|
||||
let logger: ReturnType<typeof createMockLogger>;
|
||||
let provider: TeamsProvider;
|
||||
let logger: ReturnType<typeof createLogger>;
|
||||
let gotPost: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
logger = createMockLogger();
|
||||
beforeEach(async () => {
|
||||
logger = createLogger();
|
||||
provider = new TeamsProvider(logger);
|
||||
mockPost.mockReset();
|
||||
mockPost.mockResolvedValue({});
|
||||
const got = await import("got");
|
||||
gotPost = got.default.post as jest.Mock;
|
||||
gotPost.mockReset();
|
||||
gotPost.mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe("sendTestAlert", () => {
|
||||
@@ -69,33 +76,35 @@ describe("TeamsProvider", () => {
|
||||
const notification = createNotification({ address: undefined });
|
||||
const result = await provider.sendTestAlert(notification);
|
||||
expect(result).toBe(false);
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
expect(gotPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends an Adaptive Card test message and returns true on success", async () => {
|
||||
const notification = createNotification();
|
||||
const result = await provider.sendTestAlert(notification);
|
||||
expect(result).toBe(true);
|
||||
expect(mockPost).toHaveBeenCalledTimes(1);
|
||||
expect(gotPost).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [url, options] = mockPost.mock.calls[0] as [string, { json: any; headers: any }];
|
||||
const [url, options] = gotPost.mock.calls[0] as [string, { json: any; headers: any }];
|
||||
expect(url).toBe(notification.address);
|
||||
expect(options.headers["Content-Type"]).toBe("application/json");
|
||||
|
||||
// Verify Teams webhook envelope
|
||||
const payload = options.json;
|
||||
expect(payload.type).toBe("message");
|
||||
expect(payload.attachments).toHaveLength(1);
|
||||
expect(payload.attachments[0].contentType).toBe("application/vnd.microsoft.card.adaptive");
|
||||
|
||||
// Verify Adaptive Card structure
|
||||
const card = payload.attachments[0].content;
|
||||
expect(card.type).toBe("AdaptiveCard");
|
||||
expect(card.version).toBe("1.4");
|
||||
expect(card.body[0].type).toBe("TextBlock");
|
||||
expect(card.body[0].text).toBe("This is a test notification from Checkmate");
|
||||
expect(card.body[0].text).toContain("test notification");
|
||||
});
|
||||
|
||||
it("returns false and logs warning on HTTP error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("Network error"));
|
||||
gotPost.mockRejectedValue(new Error("Network error"));
|
||||
const notification = createNotification();
|
||||
const result = await provider.sendTestAlert(notification);
|
||||
expect(result).toBe(false);
|
||||
@@ -113,7 +122,7 @@ describe("TeamsProvider", () => {
|
||||
const notification = createNotification({ address: undefined });
|
||||
const result = await provider.sendMessage(notification, createMessage());
|
||||
expect(result).toBe(false);
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
expect(gotPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends a well-formed Adaptive Card and returns true", async () => {
|
||||
@@ -122,9 +131,9 @@ describe("TeamsProvider", () => {
|
||||
const result = await provider.sendMessage(notification, message);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockPost).toHaveBeenCalledTimes(1);
|
||||
expect(gotPost).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [url, options] = mockPost.mock.calls[0] as [string, { json: any }];
|
||||
const [url, options] = gotPost.mock.calls[0] as [string, { json: any }];
|
||||
expect(url).toBe(notification.address);
|
||||
|
||||
const payload = options.json;
|
||||
@@ -134,13 +143,18 @@ describe("TeamsProvider", () => {
|
||||
expect(card.type).toBe("AdaptiveCard");
|
||||
expect(card.version).toBe("1.4");
|
||||
|
||||
// Verify body contains expected elements
|
||||
const textBlocks = card.body.filter((b: any) => b.type === "TextBlock");
|
||||
const factSets = card.body.filter((b: any) => b.type === "FactSet");
|
||||
|
||||
// Title block
|
||||
expect(textBlocks[0].text).toBe(message.content.title);
|
||||
expect(textBlocks[0].color).toBe("attention");
|
||||
expect(textBlocks[0].color).toBe("attention"); // critical -> attention
|
||||
|
||||
// Summary block
|
||||
expect(textBlocks[1].text).toBe(message.content.summary);
|
||||
|
||||
// FactSet with monitor details
|
||||
expect(factSets).toHaveLength(1);
|
||||
const facts = factSets[0].facts;
|
||||
expect(facts).toEqual(
|
||||
@@ -151,10 +165,11 @@ describe("TeamsProvider", () => {
|
||||
])
|
||||
);
|
||||
|
||||
// Incident action button
|
||||
expect(card.actions).toHaveLength(1);
|
||||
expect(card.actions[0].type).toBe("Action.OpenUrl");
|
||||
expect(card.actions[0].title).toBe("View Incident");
|
||||
expect(card.actions[0].url).toContain("/incidents/inc-1");
|
||||
expect(card.actions[0].url).toContain("/infrastructure/mon-1");
|
||||
});
|
||||
|
||||
it("omits actions when no incident is present", async () => {
|
||||
@@ -169,7 +184,7 @@ describe("TeamsProvider", () => {
|
||||
const result = await provider.sendMessage(notification, message);
|
||||
expect(result).toBe(true);
|
||||
|
||||
const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
|
||||
const [, options] = gotPost.mock.calls[0] as [string, { json: any }];
|
||||
const card = options.json.attachments[0].content;
|
||||
expect(card.actions).toBeUndefined();
|
||||
});
|
||||
@@ -197,7 +212,7 @@ describe("TeamsProvider", () => {
|
||||
|
||||
await provider.sendMessage(notification, message);
|
||||
|
||||
const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
|
||||
const [, options] = gotPost.mock.calls[0] as [string, { json: any }];
|
||||
const card = options.json.attachments[0].content;
|
||||
|
||||
// Title should use "warning" color
|
||||
@@ -207,7 +222,7 @@ describe("TeamsProvider", () => {
|
||||
const thresholdHeader = card.body.find((b: any) => b.type === "TextBlock" && b.text === "**Threshold Breaches**");
|
||||
expect(thresholdHeader).toBeDefined();
|
||||
|
||||
const cpuBlock = card.body.find((b: any) => b.type === "TextBlock" && b.text?.includes("**CPU**"));
|
||||
const cpuBlock = card.body.find((b: any) => b.type === "TextBlock" && b.text?.includes("CPU"));
|
||||
expect(cpuBlock).toBeDefined();
|
||||
expect(cpuBlock.text).toContain("95%");
|
||||
expect(cpuBlock.text).toContain("threshold: 80%");
|
||||
@@ -224,20 +239,20 @@ describe("TeamsProvider", () => {
|
||||
] as const;
|
||||
|
||||
for (const { severity, expected } of severityMap) {
|
||||
mockPost.mockReset();
|
||||
mockPost.mockResolvedValue({});
|
||||
gotPost.mockReset();
|
||||
gotPost.mockResolvedValue({});
|
||||
|
||||
const message = createMessage({ severity });
|
||||
await provider.sendMessage(notification, message);
|
||||
|
||||
const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
|
||||
const [, options] = gotPost.mock.calls[0] as [string, { json: any }];
|
||||
const card = options.json.attachments[0].content;
|
||||
expect(card.body[0].color).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false and logs warning on HTTP error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("502 Bad Gateway"));
|
||||
gotPost.mockRejectedValue(new Error("502 Bad Gateway"));
|
||||
const notification = createNotification();
|
||||
const result = await provider.sendMessage(notification, createMessage());
|
||||
expect(result).toBe(false);
|
||||
@@ -248,48 +263,5 @@ describe("TeamsProvider", () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("handles non-Error thrown values in sendMessage", async () => {
|
||||
mockPost.mockRejectedValue(null);
|
||||
const result = await provider.sendMessage(createNotification(), createMessage());
|
||||
expect(result).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
|
||||
});
|
||||
|
||||
it("includes details section in Adaptive Card when details are present", async () => {
|
||||
const notification = createNotification();
|
||||
const message = createMessage({
|
||||
content: {
|
||||
title: "Test",
|
||||
summary: "Summary",
|
||||
details: ["URL: https://example.com", "Status: Down"],
|
||||
timestamp: new Date(),
|
||||
},
|
||||
});
|
||||
await provider.sendMessage(notification, message);
|
||||
const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
|
||||
const card = options.json.attachments[0].content;
|
||||
const text = JSON.stringify(card.body);
|
||||
expect(text).toContain("Additional Information");
|
||||
expect(text).toContain("URL: https://example.com");
|
||||
});
|
||||
|
||||
it("uses default color for unknown severity", async () => {
|
||||
const notification = createNotification();
|
||||
const message = createMessage({ severity: "unknown" as any });
|
||||
await provider.sendMessage(notification, message);
|
||||
const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
|
||||
const card = options.json.attachments[0].content;
|
||||
expect(card.body[0].color).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendTestAlert error handling", () => {
|
||||
it("handles non-Error thrown values", async () => {
|
||||
mockPost.mockRejectedValue(null);
|
||||
const result = await provider.sendTestAlert(createNotification());
|
||||
expect(result).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,228 +0,0 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { AdvancedMatcher } from "../../../../src/service/infrastructure/network/AdvancedMatcher.ts";
|
||||
import type { Monitor } from "../../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const createMockJmespath = () => ({
|
||||
search: jest.fn((data: any, expr: string) => {
|
||||
// Simple dot-path evaluation for tests
|
||||
return expr.split(".").reduce((acc: any, key: string) => acc?.[key], data);
|
||||
}),
|
||||
});
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
useAdvancedMatching: false,
|
||||
jsonPath: undefined,
|
||||
matchMethod: undefined,
|
||||
expectedValue: undefined,
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AdvancedMatcher", () => {
|
||||
describe("validate", () => {
|
||||
it("returns ok when advanced matching is disabled", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate({ data: "test" }, makeMonitor({ useAdvancedMatching: false }));
|
||||
|
||||
expect(result).toEqual({ ok: true, message: "Success" });
|
||||
});
|
||||
|
||||
// ── jsonPath extraction ──────────────────────────────────────────
|
||||
|
||||
describe("jsonPath extraction", () => {
|
||||
it("extracts value using jsonPath", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate({ status: "healthy" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "status" }));
|
||||
|
||||
expect(result).toEqual({ ok: true, message: "Success", extracted: "healthy" });
|
||||
});
|
||||
|
||||
it("returns error when jsonPath evaluation throws", () => {
|
||||
const jmespath = {
|
||||
search: jest.fn().mockImplementation(() => {
|
||||
throw new Error("bad expression");
|
||||
}),
|
||||
};
|
||||
const matcher = new AdvancedMatcher(jmespath as any);
|
||||
|
||||
const result = matcher.validate({ data: "test" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "invalid[" }));
|
||||
|
||||
expect(result).toEqual({ ok: false, message: "Error evaluating JSON path" });
|
||||
});
|
||||
});
|
||||
|
||||
// ── expectedValue matching ───────────────────────────────────────
|
||||
|
||||
describe("expectedValue with matchMethod", () => {
|
||||
it("matches with 'equal' method", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate(
|
||||
{ status: "ok" },
|
||||
makeMonitor({ useAdvancedMatching: true, jsonPath: "status", matchMethod: "equal", expectedValue: "ok" })
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("fails with 'equal' method on mismatch", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate(
|
||||
{ status: "error" },
|
||||
makeMonitor({ useAdvancedMatching: true, jsonPath: "status", matchMethod: "equal", expectedValue: "ok" })
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toBe("Expected value did not match");
|
||||
});
|
||||
|
||||
it("matches with 'include' method", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate(
|
||||
{ msg: "server is healthy" },
|
||||
makeMonitor({ useAdvancedMatching: true, jsonPath: "msg", matchMethod: "include", expectedValue: "healthy" })
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("fails with 'include' method when substring not found", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate(
|
||||
{ msg: "server is down" },
|
||||
makeMonitor({ useAdvancedMatching: true, jsonPath: "msg", matchMethod: "include", expectedValue: "healthy" })
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("matches with 'regex' method", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate(
|
||||
{ code: "200" },
|
||||
makeMonitor({ useAdvancedMatching: true, jsonPath: "code", matchMethod: "regex", expectedValue: "^2\\d{2}$" })
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("fails with 'regex' method on mismatch", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate(
|
||||
{ code: "500" },
|
||||
makeMonitor({ useAdvancedMatching: true, jsonPath: "code", matchMethod: "regex", expectedValue: "^2\\d{2}$" })
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults to 'equal' comparison when matchMethod is undefined", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate({ val: "exact" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "val", expectedValue: "exact" }));
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults to 'equal' comparison for unknown matchMethod", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate(
|
||||
{ val: "exact" },
|
||||
makeMonitor({ useAdvancedMatching: true, jsonPath: "val", matchMethod: "unknown" as any, expectedValue: "exact" })
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("compares entire payload when jsonPath is not set", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate(
|
||||
"hello",
|
||||
makeMonitor({ useAdvancedMatching: true, jsonPath: undefined, matchMethod: "equal", expectedValue: "hello" })
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.extracted).toBe("hello");
|
||||
});
|
||||
|
||||
it("includes extracted value in result", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate(
|
||||
{ nested: { value: "42" } },
|
||||
makeMonitor({ useAdvancedMatching: true, jsonPath: "nested.value", matchMethod: "equal", expectedValue: "42" })
|
||||
);
|
||||
|
||||
expect(result.extracted).toBe("42");
|
||||
});
|
||||
});
|
||||
|
||||
// ── falsy value check (no expectedValue) ────────────────────────
|
||||
|
||||
describe("falsy value check (no expectedValue)", () => {
|
||||
it("returns ok for truthy extracted value", () => {
|
||||
const matcher = new AdvancedMatcher(createMockJmespath() as any);
|
||||
const result = matcher.validate({ status: "up" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "status" }));
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.extracted).toBe("up");
|
||||
});
|
||||
|
||||
it("returns not ok for false", () => {
|
||||
const jmespath = { search: jest.fn().mockReturnValue(false) };
|
||||
const matcher = new AdvancedMatcher(jmespath as any);
|
||||
|
||||
const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "missing" }));
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toBe("Extracted value is falsy");
|
||||
});
|
||||
|
||||
it("returns not ok for string 'false'", () => {
|
||||
const jmespath = { search: jest.fn().mockReturnValue("false") };
|
||||
const matcher = new AdvancedMatcher(jmespath as any);
|
||||
|
||||
const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" }));
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("returns not ok for undefined", () => {
|
||||
const jmespath = { search: jest.fn().mockReturnValue(undefined) };
|
||||
const matcher = new AdvancedMatcher(jmespath as any);
|
||||
|
||||
const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" }));
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("returns not ok for null", () => {
|
||||
const jmespath = { search: jest.fn().mockReturnValue(null) };
|
||||
const matcher = new AdvancedMatcher(jmespath as any);
|
||||
|
||||
const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" }));
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("returns ok for zero (not in falsy list)", () => {
|
||||
const jmespath = { search: jest.fn().mockReturnValue(0) };
|
||||
const matcher = new AdvancedMatcher(jmespath as any);
|
||||
|
||||
const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" }));
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("returns ok for empty string (not in falsy list)", () => {
|
||||
const jmespath = { search: jest.fn().mockReturnValue("") };
|
||||
const matcher = new AdvancedMatcher(jmespath as any);
|
||||
|
||||
const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" }));
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,343 +0,0 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { DockerProvider } from "../../../../src/service/infrastructure/network/DockerProvider.ts";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
|
||||
import type { Monitor } from "../../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "docker",
|
||||
url: "my-container",
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const makeContainer = (overrides?: Record<string, any>) => ({
|
||||
Id: "abc123def456abc123def456abc123def456abc123def456abc123def456abcd",
|
||||
Names: ["/my-container"],
|
||||
State: "running",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockDocker = (containers: any[] = [makeContainer()], inspectResult?: any) => {
|
||||
const inspect = jest.fn().mockResolvedValue(inspectResult ?? { State: { Status: "running" } });
|
||||
const getContainer = jest.fn().mockReturnValue({ inspect });
|
||||
|
||||
const instance = {
|
||||
listContainers: jest.fn().mockResolvedValue(containers),
|
||||
getContainer,
|
||||
};
|
||||
|
||||
const DockerLib = jest.fn().mockReturnValue(instance) as any;
|
||||
|
||||
return { DockerLib, instance, inspect, getContainer };
|
||||
};
|
||||
|
||||
const createProvider = (opts?: { containers?: any[]; inspectResult?: any }) => {
|
||||
const logger = createMockLogger();
|
||||
const { DockerLib, instance, inspect, getContainer } = createMockDocker(opts?.containers, opts?.inspectResult);
|
||||
const provider = new DockerProvider(logger as any, DockerLib);
|
||||
return { provider, logger, docker: instance, inspect, getContainer };
|
||||
};
|
||||
|
||||
// ── Contract ─────────────────────────────────────────────────────────────────
|
||||
|
||||
testStatusProviderContract("DockerProvider", {
|
||||
create: () => createProvider().provider,
|
||||
supportedType: "docker",
|
||||
unsupportedType: "http",
|
||||
makeMonitor: () => makeMonitor(),
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("DockerProvider", () => {
|
||||
// ── Container matching ───────────────────────────────────────────────
|
||||
|
||||
describe("container matching", () => {
|
||||
it("matches by exact container name", async () => {
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor({ url: "my-container" }));
|
||||
|
||||
expect(result.status).toBe(true);
|
||||
expect(result.code).toBe(200);
|
||||
});
|
||||
|
||||
it("matches by exact full ID (64 chars)", async () => {
|
||||
const fullId = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd";
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor({ url: fullId }));
|
||||
|
||||
expect(result.status).toBe(true);
|
||||
});
|
||||
|
||||
it("matches by partial ID prefix", async () => {
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor({ url: "abc123" }));
|
||||
|
||||
expect(result.status).toBe(true);
|
||||
});
|
||||
|
||||
it("strips leading slashes from input", async () => {
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor({ url: "///my-container" }));
|
||||
|
||||
expect(result.status).toBe(true);
|
||||
});
|
||||
|
||||
it("matches case-insensitively", async () => {
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor({ url: "MY-CONTAINER" }));
|
||||
|
||||
expect(result.status).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 404 when no container matches", async () => {
|
||||
const { provider, logger } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor({ url: "nonexistent" }));
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.code).toBe(404);
|
||||
expect(result.message).toBe("Docker container not found");
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("No container found"),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Ambiguity detection ──────────────────────────────────────────────
|
||||
|
||||
describe("ambiguity detection", () => {
|
||||
it("returns error when input matches both exact name and partial ID", async () => {
|
||||
// Container whose name matches the input AND whose ID starts with the input
|
||||
const container = makeContainer({
|
||||
Id: "my-containerabc123def456abc123def456abc123def456abc123def456abcd",
|
||||
Names: ["/my-container"],
|
||||
});
|
||||
const { provider, logger } = createProvider({ containers: [container] });
|
||||
|
||||
const result = await provider.handle(makeMonitor({ url: "my-container" }));
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.code).toBe(NETWORK_ERROR);
|
||||
expect(result.message).toContain("Ambiguous");
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("Ambiguous"),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("reports 'exact ID' in ambiguity message when exact ID and name both match", async () => {
|
||||
const fullId = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd";
|
||||
const container = makeContainer({
|
||||
Id: fullId,
|
||||
Names: ["/" + fullId],
|
||||
});
|
||||
const { provider } = createProvider({ containers: [container] });
|
||||
|
||||
const result = await provider.handle(makeMonitor({ url: fullId }));
|
||||
|
||||
expect(result.message).toContain("Using exact ID");
|
||||
});
|
||||
|
||||
it("reports 'exact name' in ambiguity message when name and partial ID match", async () => {
|
||||
const container = makeContainer({
|
||||
Id: "my-containerabc123def456abc123def456abc123def456abc123def456abcd",
|
||||
Names: ["/my-container"],
|
||||
});
|
||||
const { provider } = createProvider({ containers: [container] });
|
||||
|
||||
const result = await provider.handle(makeMonitor({ url: "my-container" }));
|
||||
|
||||
expect(result.message).toContain("Using exact name");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Container inspection ─────────────────────────────────────────────
|
||||
|
||||
describe("container inspection", () => {
|
||||
it("returns running status for running container", async () => {
|
||||
const { provider } = createProvider({ inspectResult: { State: { Status: "running" } } });
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(true);
|
||||
expect(result.message).toBe("Docker container status fetched successfully");
|
||||
});
|
||||
|
||||
it("returns not-running status for stopped container", async () => {
|
||||
const { provider } = createProvider({ inspectResult: { State: { Status: "exited" } } });
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
});
|
||||
|
||||
it("returns NETWORK_ERROR when inspect response is null", async () => {
|
||||
const logger = createMockLogger();
|
||||
const inspect = jest.fn().mockResolvedValue(null);
|
||||
const getContainer = jest.fn().mockReturnValue({ inspect });
|
||||
const instance = { listContainers: jest.fn().mockResolvedValue([makeContainer()]), getContainer };
|
||||
const DockerLib = jest.fn().mockReturnValue(instance) as any;
|
||||
const provider = new DockerProvider(logger as any, DockerLib);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.code).toBe(NETWORK_ERROR);
|
||||
expect(result.message).toBe("No response from Docker container inspect");
|
||||
});
|
||||
|
||||
it("returns false status when inspect response has no State", async () => {
|
||||
const { provider } = createProvider({ inspectResult: {} });
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false status when inspect response has State but no Status", async () => {
|
||||
const { provider } = createProvider({ inspectResult: { State: {} } });
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
});
|
||||
|
||||
it("handles inspect error with Docker-specific error", async () => {
|
||||
const { provider } = createProvider();
|
||||
// Make inspect throw a Docker error
|
||||
const dockerErr = Object.assign(new Error("container not found"), {
|
||||
statusCode: 404,
|
||||
reason: "no such container",
|
||||
json: { message: "Container not found" },
|
||||
});
|
||||
provider["docker"] = {
|
||||
listContainers: jest.fn().mockResolvedValue([makeContainer()]),
|
||||
getContainer: jest.fn().mockReturnValue({
|
||||
inspect: jest.fn().mockRejectedValue(dockerErr),
|
||||
}),
|
||||
} as any;
|
||||
|
||||
// Need to re-assign; simpler to use the createProvider mock
|
||||
const { provider: p2 } = createProvider();
|
||||
// Override getContainer to return failing inspect
|
||||
p2["docker"].getContainer = jest.fn().mockReturnValue({
|
||||
inspect: jest.fn().mockRejectedValue(dockerErr),
|
||||
});
|
||||
|
||||
const result = await p2.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.code).toBe(404);
|
||||
expect(result.message).toBe("Container not found");
|
||||
});
|
||||
|
||||
it("handles inspect error with Docker error using reason fallback", async () => {
|
||||
const { provider } = createProvider();
|
||||
const dockerErr = Object.assign(new Error("error"), {
|
||||
statusCode: 500,
|
||||
reason: "internal error",
|
||||
json: {},
|
||||
});
|
||||
provider["docker"].getContainer = jest.fn().mockReturnValue({
|
||||
inspect: jest.fn().mockRejectedValue(dockerErr),
|
||||
});
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.message).toBe("internal error");
|
||||
});
|
||||
|
||||
it("handles inspect error with Docker error using Error message fallback", async () => {
|
||||
const { provider } = createProvider();
|
||||
const dockerErr = Object.assign(new Error("base error"), {
|
||||
statusCode: 503,
|
||||
reason: undefined,
|
||||
json: undefined,
|
||||
});
|
||||
provider["docker"].getContainer = jest.fn().mockReturnValue({
|
||||
inspect: jest.fn().mockRejectedValue(dockerErr),
|
||||
});
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.message).toBe("base error");
|
||||
});
|
||||
|
||||
it("handles inspect error with NETWORK_ERROR when no statusCode", async () => {
|
||||
const { provider } = createProvider();
|
||||
const dockerErr = Object.assign(new Error("unknown"), {
|
||||
statusCode: undefined,
|
||||
});
|
||||
provider["docker"].getContainer = jest.fn().mockReturnValue({
|
||||
inspect: jest.fn().mockRejectedValue(dockerErr),
|
||||
});
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.code).toBe(NETWORK_ERROR);
|
||||
});
|
||||
|
||||
it("handles inspect error with standard Error (not Docker error)", async () => {
|
||||
const { provider } = createProvider();
|
||||
provider["docker"].getContainer = jest.fn().mockReturnValue({
|
||||
inspect: jest.fn().mockRejectedValue(new Error("ECONNREFUSED")),
|
||||
});
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.message).toBe("ECONNREFUSED");
|
||||
});
|
||||
|
||||
it("handles inspect error with non-Error non-Docker thrown value", async () => {
|
||||
const { provider } = createProvider();
|
||||
provider["docker"].getContainer = jest.fn().mockReturnValue({
|
||||
inspect: jest.fn().mockRejectedValue("string error"),
|
||||
});
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.message).toBe("Failed to fetch Docker container information");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Outer catch ──────────────────────────────────────────────────────
|
||||
|
||||
describe("outer error handling", () => {
|
||||
it("throws AppError when containerInput is missing", async () => {
|
||||
const { provider } = createProvider();
|
||||
|
||||
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("Container name or ID is required for Docker monitor");
|
||||
});
|
||||
|
||||
it("throws AppError when listContainers throws", async () => {
|
||||
const { provider } = createProvider();
|
||||
provider["docker"].listContainers = jest.fn().mockRejectedValue(new Error("Docker daemon unavailable"));
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("Docker daemon unavailable");
|
||||
});
|
||||
|
||||
it("throws AppError with default message for non-Error thrown values", async () => {
|
||||
const { provider } = createProvider();
|
||||
provider["docker"].listContainers = jest.fn().mockRejectedValue(null);
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing Docker request");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,191 +0,0 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { GameProvider } from "../../../../src/service/infrastructure/network/GameProvider.ts";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
|
||||
import type { Monitor } from "../../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "game",
|
||||
url: "play.example.com",
|
||||
port: 25565,
|
||||
gameId: "minecraft",
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const createMockGameDig = (state?: Record<string, any> | null) => ({
|
||||
query: jest.fn().mockResolvedValue(
|
||||
state ?? {
|
||||
name: "My Server",
|
||||
map: "world",
|
||||
players: [],
|
||||
ping: 45,
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
const createProvider = (gameDig?: any) => {
|
||||
const logger = createMockLogger();
|
||||
const dig = gameDig ?? createMockGameDig();
|
||||
const provider = new GameProvider(logger as any, dig as any);
|
||||
return { provider, logger, gameDig: dig };
|
||||
};
|
||||
|
||||
// ── Contract ─────────────────────────────────────────────────────────────────
|
||||
|
||||
testStatusProviderContract("GameProvider", {
|
||||
create: () => createProvider().provider,
|
||||
supportedType: "game",
|
||||
unsupportedType: "http",
|
||||
makeMonitor: () => makeMonitor(),
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GameProvider", () => {
|
||||
it("returns success with server state", async () => {
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "game",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "Success",
|
||||
responseTime: 45,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("queries with correct host, port, and gameId", async () => {
|
||||
const { provider, gameDig } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ url: "https://play.example.com/path", port: 27015, gameId: "csgo" }));
|
||||
|
||||
expect(gameDig.query).toHaveBeenCalledWith({
|
||||
type: "csgo",
|
||||
host: "play.example.com",
|
||||
port: 27015,
|
||||
});
|
||||
});
|
||||
|
||||
it("strips protocol and path from url for host", async () => {
|
||||
const { provider, gameDig } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ url: "http://game.server.com:8080/info" }));
|
||||
|
||||
expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ host: "game.server.com" }));
|
||||
});
|
||||
|
||||
it("defaults gameId to 'unknown' when not set", async () => {
|
||||
const { provider, gameDig } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ gameId: undefined }));
|
||||
|
||||
expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ type: "unknown" }));
|
||||
});
|
||||
|
||||
it("defaults port to 0 when not set", async () => {
|
||||
const { provider, gameDig } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ port: undefined }));
|
||||
|
||||
expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ port: 0 }));
|
||||
});
|
||||
|
||||
it("defaults responseTime to 0 when ping is undefined", async () => {
|
||||
const { provider } = createProvider(createMockGameDig({ ping: undefined }));
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.responseTime).toBe(0);
|
||||
});
|
||||
|
||||
it("returns failure when query resolves to undefined (caught error)", async () => {
|
||||
const gameDig = { query: jest.fn().mockRejectedValue(new Error("Server offline")) };
|
||||
// GameProvider catches the error internally via .catch()
|
||||
const { provider, logger } = createProvider(gameDig);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: "No response from game server",
|
||||
responseTime: 0,
|
||||
})
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Server offline",
|
||||
method: "handle",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs non-Error thrown values from query", async () => {
|
||||
const gameDig = { query: jest.fn().mockRejectedValue("string error") };
|
||||
const { provider, logger } = createProvider(gameDig);
|
||||
|
||||
await provider.handle(makeMonitor());
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "string error" }));
|
||||
});
|
||||
|
||||
it("uses empty string as host when url is undefined", async () => {
|
||||
const { provider, gameDig } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ url: undefined }));
|
||||
|
||||
expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ host: "" }));
|
||||
});
|
||||
|
||||
it("throws AppError with default message when error stringifies to empty", async () => {
|
||||
const gameDig = {
|
||||
query: jest.fn().mockReturnValue({
|
||||
catch: () => {
|
||||
throw new Error("");
|
||||
},
|
||||
}),
|
||||
};
|
||||
const { provider } = createProvider(gameDig);
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing game server check");
|
||||
});
|
||||
|
||||
it("throws AppError with default message for non-Error that stringifies to empty", async () => {
|
||||
const gameDig = {
|
||||
query: jest.fn().mockReturnValue({
|
||||
catch: () => {
|
||||
throw "";
|
||||
},
|
||||
}),
|
||||
};
|
||||
const { provider } = createProvider(gameDig);
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing game server check");
|
||||
});
|
||||
|
||||
it("throws AppError with Error message in outer catch", async () => {
|
||||
const gameDig = {
|
||||
query: jest.fn().mockReturnValue({
|
||||
catch: () => {
|
||||
throw new Error("unexpected");
|
||||
},
|
||||
}),
|
||||
};
|
||||
const { provider } = createProvider(gameDig);
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("unexpected");
|
||||
});
|
||||
});
|
||||
@@ -1,378 +0,0 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { GrpcProvider } from "../../../../src/service/infrastructure/network/GrpcProvider.ts";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import type { Monitor } from "../../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "grpc",
|
||||
url: "grpc.example.com",
|
||||
port: 50051,
|
||||
grpcServiceName: "my.Service",
|
||||
ignoreTlsErrors: false,
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const createMockClient = (behavior: "serving" | "not-serving" | "error" | "grpc-error" = "serving") => {
|
||||
const close = jest.fn();
|
||||
const Check = jest.fn((_req: any, _opts: any, cb: Function) => {
|
||||
if (behavior === "serving") {
|
||||
cb(null, { status: "SERVING" });
|
||||
} else if (behavior === "not-serving") {
|
||||
cb(null, { status: "NOT_SERVING" });
|
||||
} else if (behavior === "error") {
|
||||
cb({ code: 14, details: "Connection refused", message: "unavailable" });
|
||||
} else if (behavior === "grpc-error") {
|
||||
cb({ code: 2, message: "Unknown error" });
|
||||
}
|
||||
});
|
||||
return { Check, close };
|
||||
};
|
||||
|
||||
const createMockGrpc = (client?: ReturnType<typeof createMockClient>) => {
|
||||
const c = client ?? createMockClient();
|
||||
const Health = jest.fn().mockReturnValue(c);
|
||||
|
||||
return {
|
||||
grpc: {
|
||||
loadPackageDefinition: jest.fn().mockReturnValue({
|
||||
grpc: { health: { v1: { Health } } },
|
||||
}),
|
||||
credentials: {
|
||||
createInsecure: jest.fn().mockReturnValue("insecure-creds"),
|
||||
createSsl: jest.fn().mockReturnValue("ssl-creds"),
|
||||
},
|
||||
},
|
||||
protoLoader: {
|
||||
loadSync: jest.fn().mockReturnValue({}),
|
||||
},
|
||||
Health,
|
||||
client: c,
|
||||
};
|
||||
};
|
||||
|
||||
const createProvider = (opts?: { behavior?: "serving" | "not-serving" | "error" | "grpc-error" }) => {
|
||||
const client = createMockClient(opts?.behavior ?? "serving");
|
||||
const mocks = createMockGrpc(client);
|
||||
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
|
||||
return { provider, ...mocks, client };
|
||||
};
|
||||
|
||||
// ── Contract ─────────────────────────────────────────────────────────────────
|
||||
|
||||
testStatusProviderContract("GrpcProvider", {
|
||||
create: () => createProvider().provider,
|
||||
supportedType: "grpc",
|
||||
unsupportedType: "http",
|
||||
makeMonitor: () => makeMonitor(),
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GrpcProvider", () => {
|
||||
// ── Success paths ────────────────────────────────────────────────────
|
||||
|
||||
describe("success responses", () => {
|
||||
it("returns healthy status for SERVING response", async () => {
|
||||
const { provider } = createProvider({ behavior: "serving" });
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "grpc",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: expect.stringContaining("SERVING"),
|
||||
})
|
||||
);
|
||||
expect(result.payload).toEqual(
|
||||
expect.objectContaining({
|
||||
grpcStatusCode: 0,
|
||||
grpcStatusName: "OK",
|
||||
serviceName: "my.Service",
|
||||
servingStatus: "SERVING",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns unhealthy status for NOT_SERVING response", async () => {
|
||||
const { provider } = createProvider({ behavior: "not-serving" });
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.code).toBe(5000);
|
||||
expect(result.message).toContain("NOT_SERVING");
|
||||
});
|
||||
|
||||
it("defaults to UNKNOWN when response has no status", async () => {
|
||||
const client = { Check: jest.fn((_r: any, _o: any, cb: Function) => cb(null, {})), close: jest.fn() };
|
||||
const mocks = createMockGrpc(client as any);
|
||||
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.payload).toEqual(expect.objectContaining({ servingStatus: "UNKNOWN" }));
|
||||
});
|
||||
|
||||
it("defaults to UNKNOWN when response is undefined", async () => {
|
||||
const client = { Check: jest.fn((_r: any, _o: any, cb: Function) => cb(null, undefined)), close: jest.fn() };
|
||||
const mocks = createMockGrpc(client as any);
|
||||
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.payload).toEqual(expect.objectContaining({ servingStatus: "UNKNOWN" }));
|
||||
});
|
||||
});
|
||||
|
||||
// ── gRPC errors ──────────────────────────────────────────────────────
|
||||
|
||||
describe("gRPC errors", () => {
|
||||
it("returns failure with gRPC error details", async () => {
|
||||
const { provider } = createProvider({ behavior: "error" });
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.code).toBe(14);
|
||||
expect(result.message).toBe("Connection refused");
|
||||
expect(result.payload).toEqual(
|
||||
expect.objectContaining({
|
||||
grpcStatusCode: 14,
|
||||
grpcStatusName: "UNAVAILABLE",
|
||||
servingStatus: "UNKNOWN",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to message when details is missing", async () => {
|
||||
const client = {
|
||||
Check: jest.fn((_r: any, _o: any, cb: Function) => cb({ code: 2, message: "Unknown error" })),
|
||||
close: jest.fn(),
|
||||
};
|
||||
const mocks = createMockGrpc(client as any);
|
||||
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.message).toBe("Unknown error");
|
||||
});
|
||||
|
||||
it("uses default message and code when error has no code/details/message", async () => {
|
||||
const client = {
|
||||
Check: jest.fn((_r: any, _o: any, cb: Function) => cb({})),
|
||||
close: jest.fn(),
|
||||
};
|
||||
const mocks = createMockGrpc(client as any);
|
||||
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.code).toBe(5000);
|
||||
expect(result.message).toBe("gRPC error");
|
||||
expect(result.payload).toEqual(expect.objectContaining({ grpcStatusCode: -1, grpcStatusName: "UNKNOWN" }));
|
||||
});
|
||||
|
||||
it("defaults grpcCode to 5000 when error has no grpcCode", async () => {
|
||||
const client = {
|
||||
Check: jest.fn((_r: any, _o: any, cb: Function) => {
|
||||
const err = new Error("test") as any;
|
||||
// no grpcPayload or grpcCode
|
||||
cb(err);
|
||||
}),
|
||||
close: jest.fn(),
|
||||
};
|
||||
const mocks = createMockGrpc(client as any);
|
||||
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.code).toBe(5000);
|
||||
});
|
||||
|
||||
it("includes grpcPayload in error result", async () => {
|
||||
const { provider } = createProvider({ behavior: "error" });
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.payload).toEqual(
|
||||
expect.objectContaining({
|
||||
grpcStatusCode: 14,
|
||||
grpcStatusName: "UNAVAILABLE",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults code, message, and payload when error lacks gRPC properties", async () => {
|
||||
// client.Check throws synchronously — timeRequest catches a plain object with no gRPC props
|
||||
const client = {
|
||||
Check: jest.fn(() => {
|
||||
throw { unexpected: true };
|
||||
}),
|
||||
close: jest.fn(),
|
||||
};
|
||||
const mocks = createMockGrpc(client as any);
|
||||
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.code).toBe(5000);
|
||||
expect(result.message).toBe("gRPC health check failed");
|
||||
expect(result.payload).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Credentials ──────────────────────────────────────────────────────
|
||||
|
||||
describe("credentials", () => {
|
||||
it("uses insecure credentials by default", async () => {
|
||||
const { provider, grpc } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ ignoreTlsErrors: false }));
|
||||
|
||||
expect(grpc.credentials.createInsecure).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses SSL credentials with checkServerIdentity override when ignoreTlsErrors is true", async () => {
|
||||
const { provider, grpc } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ ignoreTlsErrors: true }));
|
||||
|
||||
expect(grpc.credentials.createSsl).toHaveBeenCalledWith(null, null, null, {
|
||||
checkServerIdentity: expect.any(Function),
|
||||
});
|
||||
|
||||
// Verify checkServerIdentity returns undefined
|
||||
const opts = (grpc.credentials.createSsl as jest.Mock).mock.calls[0][3];
|
||||
expect(opts.checkServerIdentity()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── URL parsing ──────────────────────────────────────────────────────
|
||||
|
||||
describe("URL and target construction", () => {
|
||||
it("strips protocol from URL", async () => {
|
||||
const { provider, Health } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ url: "https://grpc.example.com", port: 443 }));
|
||||
|
||||
expect(Health).toHaveBeenCalledWith("grpc.example.com:443", expect.anything());
|
||||
});
|
||||
|
||||
it("uses grpcServiceName in Check request", async () => {
|
||||
const { provider, client } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ grpcServiceName: "health.v1" }));
|
||||
|
||||
expect(client.Check).toHaveBeenCalledWith(
|
||||
{ service: "health.v1" },
|
||||
expect.objectContaining({ deadline: expect.any(Date) }),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults grpcServiceName to empty string when not set", async () => {
|
||||
const { provider, client } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ grpcServiceName: undefined }));
|
||||
|
||||
expect(client.Check).toHaveBeenCalledWith({ service: "" }, expect.anything(), expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
// ── Validation ───────────────────────────────────────────────────────
|
||||
|
||||
describe("validation", () => {
|
||||
it("throws AppError when url is missing", async () => {
|
||||
const { provider } = createProvider();
|
||||
|
||||
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("Monitor host is required");
|
||||
});
|
||||
|
||||
it("throws AppError when port is missing", async () => {
|
||||
const { provider } = createProvider();
|
||||
|
||||
await expect(provider.handle(makeMonitor({ port: undefined }))).rejects.toThrow("Monitor port is required");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Outer catch ──────────────────────────────────────────────────────
|
||||
|
||||
describe("outer error handling", () => {
|
||||
it("throws AppError when protoLoader.loadSync throws", async () => {
|
||||
const mocks = createMockGrpc();
|
||||
mocks.protoLoader.loadSync = jest.fn().mockImplementation(() => {
|
||||
throw new Error("Proto file not found");
|
||||
});
|
||||
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("Proto file not found");
|
||||
});
|
||||
|
||||
it("throws AppError with default message when Error has empty message", async () => {
|
||||
const mocks = createMockGrpc();
|
||||
mocks.protoLoader.loadSync = jest.fn().mockImplementation(() => {
|
||||
throw new Error("");
|
||||
});
|
||||
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing gRPC health check");
|
||||
});
|
||||
|
||||
it("throws AppError with stringified message for non-Error thrown values", async () => {
|
||||
const mocks = createMockGrpc();
|
||||
mocks.protoLoader.loadSync = jest.fn().mockImplementation(() => {
|
||||
throw "proto load failed";
|
||||
});
|
||||
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("proto load failed");
|
||||
});
|
||||
});
|
||||
|
||||
// ── getGrpcStatusName ────────────────────────────────────────────────
|
||||
|
||||
describe("gRPC status name mapping", () => {
|
||||
const statusCodes: [number, string][] = [
|
||||
[0, "OK"],
|
||||
[1, "CANCELLED"],
|
||||
[2, "UNKNOWN"],
|
||||
[3, "INVALID_ARGUMENT"],
|
||||
[4, "DEADLINE_EXCEEDED"],
|
||||
[5, "NOT_FOUND"],
|
||||
[6, "ALREADY_EXISTS"],
|
||||
[7, "PERMISSION_DENIED"],
|
||||
[8, "RESOURCE_EXHAUSTED"],
|
||||
[9, "FAILED_PRECONDITION"],
|
||||
[10, "ABORTED"],
|
||||
[11, "OUT_OF_RANGE"],
|
||||
[12, "UNIMPLEMENTED"],
|
||||
[13, "INTERNAL"],
|
||||
[14, "UNAVAILABLE"],
|
||||
[15, "DATA_LOSS"],
|
||||
[16, "UNAUTHENTICATED"],
|
||||
[99, "UNKNOWN"],
|
||||
];
|
||||
|
||||
it.each(statusCodes)("maps gRPC code %i to %s", async (code, expectedName) => {
|
||||
const client = {
|
||||
Check: jest.fn((_r: any, _o: any, cb: Function) => cb({ code, details: "test" })),
|
||||
close: jest.fn(),
|
||||
};
|
||||
const mocks = createMockGrpc(client as any);
|
||||
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.payload).toEqual(expect.objectContaining({ grpcStatusName: expectedName }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { HardwareProvider } from "../../../../src/service/infrastructure/network/HardwareProvider.ts";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import type { HttpProvider } from "../../../../src/service/infrastructure/network/HttpProvider.ts";
|
||||
import type { Monitor } from "../../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "hardware",
|
||||
url: "https://capture.example.com",
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const createMockHttpProvider = () =>
|
||||
({
|
||||
handle: jest.fn().mockResolvedValue({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "hardware",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "OK",
|
||||
responseTime: 50,
|
||||
payload: { data: { cpu: { usage_percent: 0.5 } } },
|
||||
}),
|
||||
}) as unknown as jest.Mocked<HttpProvider>;
|
||||
|
||||
const createProvider = () => {
|
||||
const httpProvider = createMockHttpProvider();
|
||||
const provider = new HardwareProvider(httpProvider);
|
||||
return { provider, httpProvider };
|
||||
};
|
||||
|
||||
// ── Contract ─────────────────────────────────────────────────────────────────
|
||||
|
||||
testStatusProviderContract("HardwareProvider", {
|
||||
create: () => createProvider().provider,
|
||||
supportedType: "hardware",
|
||||
unsupportedType: "http",
|
||||
makeMonitor: () => makeMonitor(),
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("HardwareProvider", () => {
|
||||
it("delegates to httpProvider.handle", async () => {
|
||||
const { provider, httpProvider } = createProvider();
|
||||
const monitor = makeMonitor();
|
||||
|
||||
await provider.handle(monitor);
|
||||
|
||||
expect(httpProvider.handle).toHaveBeenCalledWith(monitor);
|
||||
});
|
||||
|
||||
it("returns the response from httpProvider", async () => {
|
||||
const { provider } = createProvider();
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
monitorId: "mon-1",
|
||||
status: true,
|
||||
code: 200,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("throws AppError when url is missing", async () => {
|
||||
const { provider } = createProvider();
|
||||
|
||||
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for Hardware monitor");
|
||||
});
|
||||
|
||||
it("throws AppError when httpProvider.handle throws", async () => {
|
||||
const { provider, httpProvider } = createProvider();
|
||||
(httpProvider.handle as jest.Mock).mockRejectedValue(new Error("connection failed"));
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("connection failed");
|
||||
});
|
||||
|
||||
it("throws AppError with default message for non-Error thrown values", async () => {
|
||||
const { provider, httpProvider } = createProvider();
|
||||
(httpProvider.handle as jest.Mock).mockRejectedValue("string error");
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing Hardware request");
|
||||
});
|
||||
});
|
||||
@@ -1,348 +0,0 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
|
||||
import type { Monitor } from "../../../../src/types/index.ts";
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
jest.unstable_mockModule("cacheable-lookup", () => ({
|
||||
default: jest.fn().mockImplementation(() => ({})),
|
||||
}));
|
||||
|
||||
const mockGot = jest.fn();
|
||||
// got instance returned by got.extend()
|
||||
mockGot.mockImplementation(() => Promise.resolve());
|
||||
(mockGot as any).extend = jest.fn().mockReturnValue(mockGot);
|
||||
|
||||
jest.unstable_mockModule("got", () => ({
|
||||
type: { Got: {} },
|
||||
HTTPError: class HTTPError extends Error {
|
||||
response: any;
|
||||
timings: any;
|
||||
constructor(msg: string, response?: any, timings?: any) {
|
||||
super(msg);
|
||||
this.name = "HTTPError";
|
||||
this.response = response;
|
||||
this.timings = timings;
|
||||
}
|
||||
},
|
||||
RequestError: class RequestError extends Error {
|
||||
response: any;
|
||||
timings: any;
|
||||
constructor(msg: string, response?: any, timings?: any) {
|
||||
super(msg);
|
||||
this.name = "RequestError";
|
||||
this.response = response;
|
||||
this.timings = timings;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const { HttpProvider } = await import("../../../../src/service/infrastructure/network/HttpProvider.ts");
|
||||
const gotModule = await import("got");
|
||||
const { HTTPError, RequestError } = gotModule;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "http",
|
||||
url: "https://example.com",
|
||||
secret: undefined,
|
||||
jsonPath: undefined,
|
||||
ignoreTlsErrors: false,
|
||||
useAdvancedMatching: false,
|
||||
matchMethod: undefined,
|
||||
expectedValue: undefined,
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const createMockMatcher = (result?: { ok: boolean; message: string; extracted?: unknown }) => ({
|
||||
validate: jest.fn().mockReturnValue(result ?? { ok: true, message: "Success" }),
|
||||
});
|
||||
|
||||
const makeGotResponse = (overrides?: Record<string, any>) => ({
|
||||
ok: true,
|
||||
statusCode: 200,
|
||||
statusMessage: "OK",
|
||||
headers: { "content-type": "text/html" },
|
||||
body: "<html></html>",
|
||||
timings: { phases: { total: 100 } },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createProvider = (matcher?: any) => {
|
||||
const advancedMatcher = matcher ?? createMockMatcher();
|
||||
const provider = new HttpProvider(mockGot as any, advancedMatcher);
|
||||
return { provider, advancedMatcher };
|
||||
};
|
||||
|
||||
// ── Contract ─────────────────────────────────────────────────────────────────
|
||||
|
||||
testStatusProviderContract("HttpProvider", {
|
||||
create: () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse());
|
||||
return createProvider().provider;
|
||||
},
|
||||
supportedType: "http",
|
||||
unsupportedType: "ping",
|
||||
makeMonitor: () => makeMonitor(),
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("HttpProvider", () => {
|
||||
// ── Success paths ────────────────────────────────────────────────────
|
||||
|
||||
describe("success responses", () => {
|
||||
it("returns success for a standard HTML response", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse());
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "http",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "OK",
|
||||
responseTime: 100,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("parses JSON body when content-type is application/json", async () => {
|
||||
mockGot.mockResolvedValue(
|
||||
makeGotResponse({
|
||||
headers: { "content-type": "application/json" },
|
||||
body: '{"status":"ok"}',
|
||||
})
|
||||
);
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.payload).toEqual({ status: "ok" });
|
||||
});
|
||||
|
||||
it("returns raw body when JSON parsing fails", async () => {
|
||||
mockGot.mockResolvedValue(
|
||||
makeGotResponse({
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "not-json",
|
||||
})
|
||||
);
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.payload).toBe("not-json");
|
||||
});
|
||||
|
||||
it("returns raw body for non-JSON content", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse({ body: "<html>test</html>" }));
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.payload).toBe("<html>test</html>");
|
||||
});
|
||||
|
||||
it("passes Authorization header when secret is set", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse());
|
||||
const { provider } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ secret: "my-token" }));
|
||||
|
||||
expect(mockGot).toHaveBeenCalledWith(
|
||||
"https://example.com",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer my-token" },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("passes undefined headers when no secret", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse());
|
||||
const { provider } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ secret: undefined }));
|
||||
|
||||
expect(mockGot).toHaveBeenCalledWith(
|
||||
"https://example.com",
|
||||
expect.objectContaining({
|
||||
headers: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults responseTime to 0 when timings.phases.total is undefined", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse({ timings: { phases: { total: undefined } } }));
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.responseTime).toBe(0);
|
||||
});
|
||||
|
||||
it("defaults statusMessage to 'OK' when undefined", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse({ statusMessage: undefined }));
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.message).toBe("OK");
|
||||
});
|
||||
|
||||
it("uses empty string for content-type when header is missing", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse({ headers: {} }));
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
// Should treat as non-JSON
|
||||
expect(result.payload).toBe("<html></html>");
|
||||
});
|
||||
});
|
||||
|
||||
// ── jsonPath + non-JSON response ─────────────────────────────────────
|
||||
|
||||
describe("jsonPath validation", () => {
|
||||
it("returns failure when jsonPath is set but response is not JSON", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse({ headers: { "content-type": "text/html" } }));
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor({ jsonPath: "status" }));
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.message).toBe("Response is not JSON");
|
||||
});
|
||||
|
||||
it("defaults responseTime to 0 in non-JSON jsonPath response when total is undefined", async () => {
|
||||
mockGot.mockResolvedValue(
|
||||
makeGotResponse({
|
||||
headers: { "content-type": "text/html" },
|
||||
timings: { phases: { total: undefined } },
|
||||
})
|
||||
);
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor({ jsonPath: "status" }));
|
||||
|
||||
expect(result.responseTime).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── AdvancedMatcher integration ──────────────────────────────────────
|
||||
|
||||
describe("advanced matching", () => {
|
||||
it("uses matcher result for status and message", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse());
|
||||
const matcher = createMockMatcher({ ok: false, message: "Mismatch", extracted: "value" });
|
||||
const { provider } = createProvider(matcher);
|
||||
|
||||
const result = await provider.handle(makeMonitor({ useAdvancedMatching: true }));
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.message).toBe("Mismatch");
|
||||
expect(result.extracted).toBe("value");
|
||||
});
|
||||
|
||||
it("sets status to false when response.ok is false even if matcher passes", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse({ ok: false, statusCode: 301 }));
|
||||
const matcher = createMockMatcher({ ok: true, message: "Success" });
|
||||
const { provider } = createProvider(matcher);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
// status = response.ok && matchResult.ok
|
||||
expect(result.status).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error handling ───────────────────────────────────────────────────
|
||||
|
||||
describe("error handling", () => {
|
||||
it("handles HTTPError with response and timings", async () => {
|
||||
const err = new HTTPError("Not Found");
|
||||
(err as any).response = { statusCode: 404 };
|
||||
(err as any).timings = { phases: { total: 50 } };
|
||||
mockGot.mockRejectedValue(err);
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: false,
|
||||
code: 404,
|
||||
message: "Not Found",
|
||||
responseTime: 50,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("handles RequestError with response and timings", async () => {
|
||||
const err = new RequestError("ECONNREFUSED");
|
||||
(err as any).response = { statusCode: undefined };
|
||||
(err as any).timings = { phases: { total: undefined } };
|
||||
mockGot.mockRejectedValue(err);
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.code).toBe(NETWORK_ERROR);
|
||||
expect(result.responseTime).toBe(0);
|
||||
});
|
||||
|
||||
it("handles HTTPError without response (defaults to NETWORK_ERROR)", async () => {
|
||||
const err = new HTTPError("Timeout");
|
||||
(err as any).response = undefined;
|
||||
(err as any).timings = undefined;
|
||||
mockGot.mockRejectedValue(err);
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.code).toBe(NETWORK_ERROR);
|
||||
expect(result.responseTime).toBe(0);
|
||||
});
|
||||
|
||||
it("handles generic Error (non-HTTPError/RequestError)", async () => {
|
||||
mockGot.mockRejectedValue(new Error("DNS lookup failed"));
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: "DNS lookup failed",
|
||||
responseTime: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("handles non-Error thrown values", async () => {
|
||||
mockGot.mockRejectedValue("string error");
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.message).toBe("string error");
|
||||
expect(result.code).toBe(NETWORK_ERROR);
|
||||
});
|
||||
|
||||
it("throws when url is missing", async () => {
|
||||
const { provider } = createProvider();
|
||||
|
||||
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for HTTP monitor");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,165 +0,0 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { PageSpeedProvider } from "../../../../src/service/infrastructure/network/PageSpeedProvider.ts";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import type { HttpProvider } from "../../../../src/service/infrastructure/network/HttpProvider.ts";
|
||||
import type { ISettingsService } from "../../../../src/service/system/settingsService.ts";
|
||||
import type { Monitor } from "../../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "pagespeed",
|
||||
url: "https://example.com",
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const createMockHttpProvider = () =>
|
||||
({
|
||||
handle: jest.fn().mockResolvedValue({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "pagespeed",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "OK",
|
||||
responseTime: 2000,
|
||||
payload: { lighthouseResult: {} },
|
||||
}),
|
||||
}) as unknown as jest.Mocked<HttpProvider>;
|
||||
|
||||
const createMockSettingsService = (apiKey?: string) =>
|
||||
({
|
||||
getDBSettings: jest.fn().mockResolvedValue({ pagespeedApiKey: apiKey }),
|
||||
}) as unknown as jest.Mocked<ISettingsService>;
|
||||
|
||||
const createProvider = (opts?: { apiKey?: string }) => {
|
||||
const logger = createMockLogger();
|
||||
const httpProvider = createMockHttpProvider();
|
||||
const settingsService = createMockSettingsService(opts?.apiKey);
|
||||
const provider = new PageSpeedProvider(httpProvider, settingsService, logger as any);
|
||||
return { provider, httpProvider, settingsService, logger };
|
||||
};
|
||||
|
||||
// ── Contract ─────────────────────────────────────────────────────────────────
|
||||
|
||||
testStatusProviderContract("PageSpeedProvider", {
|
||||
create: () => createProvider({ apiKey: "test-key" }).provider,
|
||||
supportedType: "pagespeed",
|
||||
unsupportedType: "http",
|
||||
makeMonitor: () => makeMonitor(),
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("PageSpeedProvider", () => {
|
||||
it("delegates to httpProvider with PageSpeed API URL", async () => {
|
||||
const { provider, httpProvider } = createProvider({ apiKey: "my-key" });
|
||||
|
||||
await provider.handle(makeMonitor({ url: "https://example.com" }));
|
||||
|
||||
expect(httpProvider.handle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: expect.stringContaining("pagespeedonline.googleapis.com"),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("includes API key in URL when available", async () => {
|
||||
const { provider, httpProvider } = createProvider({ apiKey: "my-key" });
|
||||
|
||||
await provider.handle(makeMonitor());
|
||||
|
||||
const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url;
|
||||
expect(calledUrl).toContain("key=my-key");
|
||||
});
|
||||
|
||||
it("omits API key and logs warning when not configured", async () => {
|
||||
const { provider, httpProvider, logger } = createProvider({ apiKey: undefined });
|
||||
|
||||
await provider.handle(makeMonitor());
|
||||
|
||||
const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url;
|
||||
expect(calledUrl).not.toContain("key=");
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("API key not found"),
|
||||
service: "PageSpeedProvider",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes the monitor URL in the PageSpeed API URL", async () => {
|
||||
const { provider, httpProvider } = createProvider({ apiKey: "key" });
|
||||
|
||||
await provider.handle(makeMonitor({ url: "https://example.com/path?q=1" }));
|
||||
|
||||
const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url;
|
||||
expect(calledUrl).toContain(encodeURIComponent("https://example.com/path?q=1"));
|
||||
});
|
||||
|
||||
it("includes all PageSpeed categories in the URL", async () => {
|
||||
const { provider, httpProvider } = createProvider({ apiKey: "key" });
|
||||
|
||||
await provider.handle(makeMonitor());
|
||||
|
||||
const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url;
|
||||
expect(calledUrl).toContain("category=seo");
|
||||
expect(calledUrl).toContain("category=accessibility");
|
||||
expect(calledUrl).toContain("category=best-practices");
|
||||
expect(calledUrl).toContain("category=performance");
|
||||
});
|
||||
|
||||
it("passes all monitor properties through to httpProvider", async () => {
|
||||
const { provider, httpProvider } = createProvider({ apiKey: "key" });
|
||||
const monitor = makeMonitor({ id: "mon-5", teamId: "team-9" });
|
||||
|
||||
await provider.handle(monitor);
|
||||
|
||||
expect(httpProvider.handle).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-5", teamId: "team-9" }));
|
||||
});
|
||||
|
||||
it("throws AppError when url is missing", async () => {
|
||||
const { provider } = createProvider();
|
||||
|
||||
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for PageSpeed monitor");
|
||||
});
|
||||
|
||||
it("throws AppError when httpProvider.handle throws", async () => {
|
||||
const { provider, httpProvider } = createProvider({ apiKey: "key" });
|
||||
(httpProvider.handle as jest.Mock).mockRejectedValue(new Error("timeout"));
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("timeout");
|
||||
});
|
||||
|
||||
it("throws AppError with default message for non-Error thrown values", async () => {
|
||||
const { provider, httpProvider } = createProvider({ apiKey: "key" });
|
||||
(httpProvider.handle as jest.Mock).mockRejectedValue("string error");
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing PageSpeed request");
|
||||
});
|
||||
|
||||
it("handles null apiKey from DB settings", async () => {
|
||||
const { provider, logger } = createProvider({ apiKey: null as any });
|
||||
|
||||
await provider.handle(makeMonitor());
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("API key not found") }));
|
||||
});
|
||||
|
||||
it("handles null dbSettings response", async () => {
|
||||
const logger = createMockLogger();
|
||||
const httpProvider = createMockHttpProvider();
|
||||
const settingsService = {
|
||||
getDBSettings: jest.fn().mockResolvedValue(null),
|
||||
} as unknown as jest.Mocked<ISettingsService>;
|
||||
const provider = new PageSpeedProvider(httpProvider, settingsService, logger as any);
|
||||
|
||||
await provider.handle(makeMonitor());
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("API key not found") }));
|
||||
});
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { PingProvider } from "../../../../src/service/infrastructure/network/PingProvider.ts";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import type { Monitor } from "../../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "ping",
|
||||
url: "example.com",
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const createMockPing = (result?: Partial<{ alive: boolean; time: number | string }>) => ({
|
||||
promise: {
|
||||
probe: jest.fn().mockResolvedValue({
|
||||
alive: true,
|
||||
time: 25,
|
||||
...result,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// ── Contract ─────────────────────────────────────────────────────────────────
|
||||
|
||||
testStatusProviderContract("PingProvider", {
|
||||
create: () => new PingProvider(createMockPing() as any),
|
||||
supportedType: "ping",
|
||||
unsupportedType: "http",
|
||||
makeMonitor: () => makeMonitor(),
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("PingProvider", () => {
|
||||
it("returns success when host is alive", async () => {
|
||||
const provider = new PingProvider(createMockPing({ alive: true, time: 30 }) as any);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "ping",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "Success",
|
||||
responseTime: 30,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns failure when host is not alive", async () => {
|
||||
const provider = new PingProvider(createMockPing({ alive: false, time: 0 }) as any);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: false,
|
||||
code: 5000,
|
||||
message: "Ping failed",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("parses string time values", async () => {
|
||||
const provider = new PingProvider(createMockPing({ alive: true, time: "42.5" }) as any);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.responseTime).toBe(42.5);
|
||||
});
|
||||
|
||||
it("defaults responseTime to 0 for non-numeric time", async () => {
|
||||
const provider = new PingProvider(createMockPing({ alive: true, time: "unknown" }) as any);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.responseTime).toBe(0);
|
||||
});
|
||||
|
||||
it("defaults status to false when alive is undefined", async () => {
|
||||
const provider = new PingProvider(createMockPing({ alive: undefined as any }) as any);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
});
|
||||
|
||||
it("sanitizes URL by stripping protocol and path", async () => {
|
||||
const mockPing = createMockPing();
|
||||
const provider = new PingProvider(mockPing as any);
|
||||
|
||||
await provider.handle(makeMonitor({ url: "https://example.com/path?query=1" }));
|
||||
|
||||
expect(mockPing.promise.probe).toHaveBeenCalledWith("example.com");
|
||||
});
|
||||
|
||||
it("sanitizes URL by stripping port", async () => {
|
||||
const mockPing = createMockPing();
|
||||
const provider = new PingProvider(mockPing as any);
|
||||
|
||||
await provider.handle(makeMonitor({ url: "example.com:8080" }));
|
||||
|
||||
expect(mockPing.promise.probe).toHaveBeenCalledWith("example.com");
|
||||
});
|
||||
|
||||
it("throws AppError when url is missing", async () => {
|
||||
const provider = new PingProvider(createMockPing() as any);
|
||||
|
||||
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for ping monitor");
|
||||
});
|
||||
|
||||
it("throws AppError when ping.probe rejects", async () => {
|
||||
const mockPing = { promise: { probe: jest.fn().mockRejectedValue(new Error("Host unreachable")) } };
|
||||
const provider = new PingProvider(mockPing as any);
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("Host unreachable");
|
||||
});
|
||||
|
||||
it("throws AppError when probe returns no response", async () => {
|
||||
const mockPing = { promise: { probe: jest.fn().mockResolvedValue(null) } };
|
||||
const provider = new PingProvider(mockPing as any);
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("No response from ping");
|
||||
});
|
||||
|
||||
it("throws AppError with stringified message for non-Error throws", async () => {
|
||||
const mockPing = { promise: { probe: jest.fn().mockRejectedValue(42) } };
|
||||
const provider = new PingProvider(mockPing as any);
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("42");
|
||||
});
|
||||
});
|
||||
@@ -1,270 +0,0 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { PortProvider } from "../../../../src/service/infrastructure/network/PortProvider.ts";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
|
||||
import type { Monitor } from "../../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "port",
|
||||
url: "example.com",
|
||||
port: 443,
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const createMockNet = (behavior: "connect" | "timeout" | "error" = "connect") => {
|
||||
const socket = {
|
||||
setTimeout: jest.fn(),
|
||||
on: jest.fn(),
|
||||
end: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
|
||||
const createConnection = jest.fn((opts: any, onConnect: () => void) => {
|
||||
if (behavior === "connect") {
|
||||
// Simulate immediate connection
|
||||
process.nextTick(onConnect);
|
||||
} else if (behavior === "timeout") {
|
||||
process.nextTick(() => {
|
||||
const timeoutCb = socket.on.mock.calls.find((c: any) => c[0] === "timeout");
|
||||
// Simulate via setTimeout handler
|
||||
const setTimeoutCb = socket.setTimeout.mock.calls[0];
|
||||
if (setTimeoutCb) {
|
||||
// The timeout event handler is registered via socket.on("timeout", ...)
|
||||
}
|
||||
});
|
||||
} else if (behavior === "error") {
|
||||
process.nextTick(() => {
|
||||
const errorCb = socket.on.mock.calls.find((c: any) => c[0] === "error");
|
||||
if (errorCb) {
|
||||
errorCb[1](new Error("ECONNREFUSED"));
|
||||
}
|
||||
});
|
||||
}
|
||||
return socket;
|
||||
});
|
||||
|
||||
return { createConnection, __socket: socket } as any;
|
||||
};
|
||||
|
||||
const createSuccessNet = () => {
|
||||
const socket = {
|
||||
setTimeout: jest.fn(),
|
||||
on: jest.fn(),
|
||||
end: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
return {
|
||||
createConnection: jest.fn((_opts: any, onConnect: () => void) => {
|
||||
process.nextTick(onConnect);
|
||||
return socket;
|
||||
}),
|
||||
} as any;
|
||||
};
|
||||
|
||||
const createErrorNet = (error: Error = new Error("ECONNREFUSED")) => {
|
||||
const socket = {
|
||||
setTimeout: jest.fn(),
|
||||
on: jest.fn((event: string, cb: Function) => {
|
||||
if (event === "error") {
|
||||
process.nextTick(() => cb(error));
|
||||
}
|
||||
}),
|
||||
end: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
return {
|
||||
createConnection: jest.fn(() => socket),
|
||||
} as any;
|
||||
};
|
||||
|
||||
const createTimeoutNet = () => {
|
||||
const socket = {
|
||||
setTimeout: jest.fn(),
|
||||
on: jest.fn((event: string, cb: Function) => {
|
||||
if (event === "timeout") {
|
||||
process.nextTick(() => cb());
|
||||
}
|
||||
}),
|
||||
end: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
return {
|
||||
createConnection: jest.fn(() => socket),
|
||||
} as any;
|
||||
};
|
||||
|
||||
// ── Contract ─────────────────────────────────────────────────────────────────
|
||||
|
||||
testStatusProviderContract("PortProvider", {
|
||||
create: () => new PortProvider(createSuccessNet()),
|
||||
supportedType: "port",
|
||||
unsupportedType: "http",
|
||||
makeMonitor: () => makeMonitor(),
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("PortProvider", () => {
|
||||
it("returns success when port connection succeeds", async () => {
|
||||
const provider = new PortProvider(createSuccessNet());
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "port",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "Port check successful",
|
||||
payload: { success: true },
|
||||
})
|
||||
);
|
||||
expect(result.responseTime).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("returns failure when connection errors", async () => {
|
||||
const provider = new PortProvider(createErrorNet());
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: "ECONNREFUSED",
|
||||
payload: { success: false },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns failure with generic message for non-Error connection error", async () => {
|
||||
const net = createErrorNet();
|
||||
// Override the on handler to throw a non-Error
|
||||
const socket = {
|
||||
setTimeout: jest.fn(),
|
||||
on: jest.fn((event: string, cb: Function) => {
|
||||
if (event === "error") {
|
||||
process.nextTick(() => cb("string error"));
|
||||
}
|
||||
}),
|
||||
end: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
net.createConnection = jest.fn(() => socket);
|
||||
|
||||
const provider = new PortProvider(net);
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.message).toBe("Port check failed");
|
||||
});
|
||||
|
||||
it("returns failure when connection times out", async () => {
|
||||
const provider = new PortProvider(createTimeoutNet());
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: "Connection timeout",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("throws AppError when url is missing", async () => {
|
||||
const provider = new PortProvider(createSuccessNet());
|
||||
|
||||
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL and port are required for port monitoring");
|
||||
});
|
||||
|
||||
it("throws AppError when port is missing", async () => {
|
||||
const provider = new PortProvider(createSuccessNet());
|
||||
|
||||
await expect(provider.handle(makeMonitor({ port: undefined }))).rejects.toThrow("URL and port are required for port monitoring");
|
||||
});
|
||||
|
||||
it("returns failure with generic message when timeRequest catches non-Error", async () => {
|
||||
const socket = {
|
||||
setTimeout: jest.fn(),
|
||||
on: jest.fn((event: string, cb: Function) => {
|
||||
if (event === "error") {
|
||||
process.nextTick(() => cb({ notAnError: true }));
|
||||
}
|
||||
}),
|
||||
end: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
const net = { createConnection: jest.fn(() => socket) } as any;
|
||||
const provider = new PortProvider(net);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.message).toBe("Port check failed");
|
||||
});
|
||||
|
||||
it("throws AppError wrapping the original Error message", async () => {
|
||||
const provider = new PortProvider(createSuccessNet());
|
||||
|
||||
try {
|
||||
await provider.handle(makeMonitor({ url: "" }));
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err: any) {
|
||||
expect(err.service).toBe("PortProvider");
|
||||
expect(err.method).toBe("handle");
|
||||
}
|
||||
});
|
||||
|
||||
it("throws AppError when an unexpected error occurs in outer try", async () => {
|
||||
const provider = new PortProvider(createSuccessNet());
|
||||
const monitor = makeMonitor();
|
||||
let calls = 0;
|
||||
Object.defineProperty(monitor, "url", {
|
||||
get() {
|
||||
calls++;
|
||||
if (calls === 1) throw new Error("getter exploded");
|
||||
return "example.com";
|
||||
},
|
||||
});
|
||||
|
||||
await expect(provider.handle(monitor)).rejects.toThrow("getter exploded");
|
||||
});
|
||||
|
||||
it("throws AppError with fallback message when Error has empty message", async () => {
|
||||
const provider = new PortProvider(createSuccessNet());
|
||||
const monitor = makeMonitor();
|
||||
let calls = 0;
|
||||
Object.defineProperty(monitor, "url", {
|
||||
get() {
|
||||
calls++;
|
||||
if (calls === 1) throw new Error("");
|
||||
return "example.com";
|
||||
},
|
||||
});
|
||||
|
||||
await expect(provider.handle(monitor)).rejects.toThrow("Error performing port check");
|
||||
});
|
||||
|
||||
it("throws AppError with stringified message for non-Error thrown values", async () => {
|
||||
const provider = new PortProvider(createSuccessNet());
|
||||
const monitor = makeMonitor();
|
||||
let calls = 0;
|
||||
Object.defineProperty(monitor, "url", {
|
||||
get() {
|
||||
calls++;
|
||||
if (calls === 1) throw 42;
|
||||
return "example.com";
|
||||
},
|
||||
});
|
||||
|
||||
await expect(provider.handle(monitor)).rejects.toThrow("42");
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import { timeRequest, NETWORK_ERROR, PING_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
|
||||
|
||||
describe("network utils", () => {
|
||||
describe("timeRequest", () => {
|
||||
it("returns response and responseTime on success", async () => {
|
||||
const result = await timeRequest(async () => "hello");
|
||||
|
||||
expect(result.response).toBe("hello");
|
||||
expect(result.responseTime).toBeGreaterThanOrEqual(0);
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it("returns error and responseTime on failure", async () => {
|
||||
const err = new Error("boom");
|
||||
const result = await timeRequest(async () => {
|
||||
throw err;
|
||||
});
|
||||
|
||||
expect(result.response).toBeNull();
|
||||
expect(result.responseTime).toBeGreaterThanOrEqual(0);
|
||||
expect(result.error).toBe(err);
|
||||
});
|
||||
|
||||
it("measures elapsed time", async () => {
|
||||
const result = await timeRequest(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
return "done";
|
||||
});
|
||||
|
||||
expect(result.responseTime).toBeGreaterThanOrEqual(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe("constants", () => {
|
||||
it("NETWORK_ERROR is 5000", () => {
|
||||
expect(NETWORK_ERROR).toBe(5000);
|
||||
});
|
||||
|
||||
it("PING_ERROR is 5001", () => {
|
||||
expect(PING_ERROR).toBe(5001);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,194 +0,0 @@
|
||||
import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals";
|
||||
import { WebSocketProvider } from "../../../../src/service/infrastructure/network/WebSocketProvider.ts";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
|
||||
import type { Monitor } from "../../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "websocket",
|
||||
url: "wss://example.com/ws",
|
||||
ignoreTlsErrors: false,
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const createMockWS = (behavior: "open" | "error" = "open") => {
|
||||
return jest.fn().mockImplementation((_url: string, _opts: any) => {
|
||||
const handlers: Record<string, Function> = {};
|
||||
const ws = {
|
||||
on: jest.fn((event: string, cb: Function) => {
|
||||
handlers[event] = cb;
|
||||
if (behavior === "open" && event === "open") {
|
||||
process.nextTick(() => cb());
|
||||
}
|
||||
if (behavior === "error" && event === "error") {
|
||||
process.nextTick(() => cb(new Error("Connection failed")));
|
||||
}
|
||||
}),
|
||||
close: jest.fn(),
|
||||
};
|
||||
return ws;
|
||||
}) as any;
|
||||
};
|
||||
|
||||
// ── Contract ─────────────────────────────────────────────────────────────────
|
||||
|
||||
testStatusProviderContract("WebSocketProvider", {
|
||||
create: () => new WebSocketProvider(createMockWS("open")),
|
||||
supportedType: "websocket",
|
||||
unsupportedType: "http",
|
||||
makeMonitor: () => makeMonitor(),
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("WebSocketProvider", () => {
|
||||
it("returns success when connection opens", async () => {
|
||||
const provider = new WebSocketProvider(createMockWS("open"));
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "websocket",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "WebSocket check successful",
|
||||
payload: { connected: true },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns failure when connection errors", async () => {
|
||||
const provider = new WebSocketProvider(createMockWS("error"));
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: "Connection failed",
|
||||
payload: { connected: false },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns failure with generic message for non-Error connection error", async () => {
|
||||
const WS = jest.fn().mockImplementation(() => ({
|
||||
on: jest.fn((event: string, cb: Function) => {
|
||||
if (event === "error") {
|
||||
process.nextTick(() => cb("string error"));
|
||||
}
|
||||
}),
|
||||
close: jest.fn(),
|
||||
})) as any;
|
||||
|
||||
const provider = new WebSocketProvider(WS);
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.message).toBe("WebSocket check failed");
|
||||
});
|
||||
|
||||
it("passes rejectUnauthorized: false when ignoreTlsErrors is true", async () => {
|
||||
const WS = jest.fn().mockImplementation((_url: string, opts: any) => {
|
||||
expect(opts.rejectUnauthorized).toBe(false);
|
||||
return {
|
||||
on: jest.fn((event: string, cb: Function) => {
|
||||
if (event === "open") process.nextTick(() => cb());
|
||||
}),
|
||||
close: jest.fn(),
|
||||
};
|
||||
}) as any;
|
||||
|
||||
const provider = new WebSocketProvider(WS);
|
||||
await provider.handle(makeMonitor({ ignoreTlsErrors: true }));
|
||||
|
||||
expect(WS).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws AppError when url is missing", async () => {
|
||||
const provider = new WebSocketProvider(createMockWS("open"));
|
||||
|
||||
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for WebSocket monitoring");
|
||||
});
|
||||
|
||||
it("throws AppError when an unexpected error occurs in outer try", async () => {
|
||||
const provider = new WebSocketProvider(createMockWS("open"));
|
||||
const monitor = makeMonitor();
|
||||
let calls = 0;
|
||||
Object.defineProperty(monitor, "url", {
|
||||
get() {
|
||||
calls++;
|
||||
if (calls === 1) throw new Error("getter failed");
|
||||
return "wss://example.com";
|
||||
},
|
||||
});
|
||||
|
||||
await expect(provider.handle(monitor)).rejects.toThrow("getter failed");
|
||||
});
|
||||
|
||||
it("throws AppError with fallback message when Error has empty message", async () => {
|
||||
const provider = new WebSocketProvider(createMockWS("open"));
|
||||
const monitor = makeMonitor();
|
||||
let calls = 0;
|
||||
Object.defineProperty(monitor, "url", {
|
||||
get() {
|
||||
calls++;
|
||||
if (calls === 1) throw new Error("");
|
||||
return "wss://example.com";
|
||||
},
|
||||
});
|
||||
|
||||
await expect(provider.handle(monitor)).rejects.toThrow("Error performing WebSocket check");
|
||||
});
|
||||
|
||||
it("throws AppError with stringified message for non-Error thrown values", async () => {
|
||||
const provider = new WebSocketProvider(createMockWS("open"));
|
||||
const monitor = makeMonitor();
|
||||
let calls = 0;
|
||||
Object.defineProperty(monitor, "url", {
|
||||
get() {
|
||||
calls++;
|
||||
if (calls === 1) throw 99;
|
||||
return "wss://example.com";
|
||||
},
|
||||
});
|
||||
|
||||
await expect(provider.handle(monitor)).rejects.toThrow("99");
|
||||
});
|
||||
|
||||
describe("connection timeout", () => {
|
||||
beforeEach(() => jest.useFakeTimers());
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it("returns failure on connection timeout", async () => {
|
||||
// WS that never fires open or error
|
||||
const WS = jest.fn().mockImplementation(() => ({
|
||||
on: jest.fn(),
|
||||
close: jest.fn(),
|
||||
})) as any;
|
||||
|
||||
const provider = new WebSocketProvider(WS);
|
||||
const promise = provider.handle(makeMonitor());
|
||||
|
||||
await jest.advanceTimersByTimeAsync(10000);
|
||||
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: "WebSocket connection timeout",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
|
||||
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
|
||||
|
||||
const mockGotPost = jest.fn().mockResolvedValue({});
|
||||
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
|
||||
|
||||
const { DiscordProvider } = await import("../../../../src/service/infrastructure/notificationProviders/discord.ts");
|
||||
|
||||
const createProvider = () => {
|
||||
const logger = createMockLogger();
|
||||
const provider = new DiscordProvider(logger as any);
|
||||
return { provider, logger };
|
||||
};
|
||||
|
||||
testNotificationProviderContract("DiscordProvider", {
|
||||
create: () => {
|
||||
mockGotPost.mockResolvedValue({});
|
||||
return createProvider().provider;
|
||||
},
|
||||
makeNotification: () => makeNotification(),
|
||||
});
|
||||
|
||||
describe("DiscordProvider", () => {
|
||||
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
|
||||
|
||||
describe("sendTestAlert", () => {
|
||||
it("sends test message and returns true", async () => {
|
||||
const { provider } = createProvider();
|
||||
const result = await provider.sendTestAlert(makeNotification());
|
||||
expect(result).toBe(true);
|
||||
expect(mockGotPost).toHaveBeenCalledWith(
|
||||
"https://hooks.example.com/webhook",
|
||||
expect.objectContaining({ json: expect.objectContaining({ content: expect.any(String) }) })
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("network"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert" }));
|
||||
});
|
||||
|
||||
it("handles undefined thrown values", async () => {
|
||||
mockGotPost.mockRejectedValue(undefined);
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("sends embed and returns true", async () => {
|
||||
const { provider } = createProvider();
|
||||
const result = await provider.sendMessage(makeNotification() as any, makeMessage());
|
||||
expect(result).toBe(true);
|
||||
expect(mockGotPost).toHaveBeenCalledWith(
|
||||
"https://hooks.example.com/webhook",
|
||||
expect.objectContaining({ json: expect.objectContaining({ embeds: expect.any(Array) }) })
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" }));
|
||||
});
|
||||
|
||||
it("handles undefined thrown values in sendMessage", async () => {
|
||||
mockGotPost.mockRejectedValue(undefined);
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
|
||||
});
|
||||
|
||||
it("includes threshold breaches in embed fields", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
|
||||
const payload = mockGotPost.mock.calls[0][1].json;
|
||||
const fields = payload.embeds[0].fields;
|
||||
expect(fields.some((f: any) => f.name === "Threshold Breaches")).toBe(true);
|
||||
});
|
||||
|
||||
it("includes details in embed fields", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage());
|
||||
const fields = mockGotPost.mock.calls[0][1].json.embeds[0].fields;
|
||||
expect(fields.some((f: any) => f.name === "Details")).toBe(true);
|
||||
});
|
||||
|
||||
it("maps all severity levels to colors", async () => {
|
||||
const { provider } = createProvider();
|
||||
for (const severity of ["critical", "warning", "success", "info"] as const) {
|
||||
mockGotPost.mockClear();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ severity }));
|
||||
const color = mockGotPost.mock.calls[0][1].json.embeds[0].color;
|
||||
expect(color).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses info color for unknown severity", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ severity: "unknown" as any }));
|
||||
const color = mockGotPost.mock.calls[0][1].json.embeds[0].color;
|
||||
expect(color).toBe(0x3b82f6);
|
||||
});
|
||||
|
||||
it("omits threshold fields when not present", async () => {
|
||||
const { provider } = createProvider();
|
||||
const msg = makeMessage();
|
||||
msg.content.thresholds = undefined;
|
||||
msg.content.details = undefined;
|
||||
await provider.sendMessage(makeNotification() as any, msg);
|
||||
const fields = mockGotPost.mock.calls[0][1].json.embeds[0].fields;
|
||||
expect(fields.some((f: any) => f.name === "Threshold Breaches")).toBe(false);
|
||||
expect(fields.some((f: any) => f.name === "Details")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { EmailProvider } from "../../../../src/service/infrastructure/notificationProviders/email.ts";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import { makeNotification, makeMessage } from "../../../helpers/notificationMessage.ts";
|
||||
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
|
||||
import type { IEmailService } from "../../../../src/service/infrastructure/emailService.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const createMockEmailService = () =>
|
||||
({
|
||||
buildEmail: jest.fn().mockResolvedValue("<html>email</html>"),
|
||||
sendEmail: jest.fn().mockResolvedValue("msg-123"),
|
||||
}) as unknown as jest.Mocked<IEmailService>;
|
||||
|
||||
const createProvider = () => {
|
||||
const logger = createMockLogger();
|
||||
const emailService = createMockEmailService();
|
||||
const provider = new EmailProvider(emailService, logger as any);
|
||||
return { provider, logger, emailService };
|
||||
};
|
||||
|
||||
testNotificationProviderContract("EmailProvider", {
|
||||
create: () => createProvider().provider,
|
||||
makeNotification: () => makeNotification(),
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("EmailProvider", () => {
|
||||
describe("sendTestAlert", () => {
|
||||
it("builds test email and sends it", async () => {
|
||||
const { provider, emailService } = createProvider();
|
||||
const result = await provider.sendTestAlert(makeNotification());
|
||||
expect(result).toBe(true);
|
||||
expect(emailService.buildEmail).toHaveBeenCalledWith("testEmailTemplate", { testName: "Monitoring System" });
|
||||
expect(emailService.sendEmail).toHaveBeenCalledWith("https://hooks.example.com/webhook", "Test notification", "<html>email</html>");
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Missing address" }));
|
||||
});
|
||||
|
||||
it("returns false when buildEmail returns undefined", async () => {
|
||||
const { provider, emailService, logger } = createProvider();
|
||||
(emailService.buildEmail as jest.Mock).mockResolvedValue(undefined);
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build test email content" }));
|
||||
});
|
||||
|
||||
it("returns false when sendEmail returns falsy", async () => {
|
||||
const { provider, emailService, logger } = createProvider();
|
||||
(emailService.sendEmail as jest.Mock).mockResolvedValue(false);
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Email test alert failed" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("builds email from message and sends it", async () => {
|
||||
const { provider, emailService } = createProvider();
|
||||
const result = await provider.sendMessage(makeNotification() as any, makeMessage());
|
||||
expect(result).toBe(true);
|
||||
expect(emailService.buildEmail).toHaveBeenCalledWith(
|
||||
"unifiedNotificationTemplate",
|
||||
expect.objectContaining({ title: "Monitor Down: Test Monitor" })
|
||||
);
|
||||
expect(emailService.sendEmail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when buildEmail returns undefined", async () => {
|
||||
const { provider, emailService, logger } = createProvider();
|
||||
(emailService.buildEmail as jest.Mock).mockResolvedValue(undefined);
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build email content" }));
|
||||
});
|
||||
|
||||
it("returns false when sendEmail returns falsy", async () => {
|
||||
const { provider, emailService, logger } = createProvider();
|
||||
(emailService.sendEmail as jest.Mock).mockResolvedValue(undefined);
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Email notification failed" }));
|
||||
});
|
||||
|
||||
it("builds correct subject for monitor_down", async () => {
|
||||
const { provider, emailService } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "monitor_down" }));
|
||||
expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Monitor Test Monitor is down", expect.anything());
|
||||
});
|
||||
|
||||
it("builds correct subject for monitor_up", async () => {
|
||||
const { provider, emailService } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "monitor_up" }));
|
||||
expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Monitor Test Monitor is back up", expect.anything());
|
||||
});
|
||||
|
||||
it("builds correct subject for threshold_breach", async () => {
|
||||
const { provider, emailService } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "threshold_breach" }));
|
||||
expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Monitor Test Monitor threshold exceeded", expect.anything());
|
||||
});
|
||||
|
||||
it("builds correct subject for threshold_resolved", async () => {
|
||||
const { provider, emailService } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "threshold_resolved" }));
|
||||
expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Monitor Test Monitor thresholds resolved", expect.anything());
|
||||
});
|
||||
|
||||
it("builds default subject for unknown type", async () => {
|
||||
const { provider, emailService } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "test" }));
|
||||
expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Alert: Test Monitor", expect.anything());
|
||||
});
|
||||
|
||||
it("passes incident url in context when present", async () => {
|
||||
const { provider, emailService } = createProvider();
|
||||
const msg = makeMessage();
|
||||
msg.content.incident = { id: "inc-1", url: "https://app.example.com/incidents/inc-1", createdAt: new Date() };
|
||||
await provider.sendMessage(makeNotification() as any, msg);
|
||||
expect(emailService.buildEmail).toHaveBeenCalledWith(
|
||||
"unifiedNotificationTemplate",
|
||||
expect.objectContaining({ incidentUrl: "https://app.example.com/incidents/inc-1" })
|
||||
);
|
||||
});
|
||||
|
||||
it("maps all severity levels to colors", async () => {
|
||||
const { provider, emailService } = createProvider();
|
||||
const severityColors: Record<string, string> = { critical: "red", warning: "#f59e0b", info: "#3b82f6", success: "green" };
|
||||
for (const [severity, color] of Object.entries(severityColors)) {
|
||||
(emailService.buildEmail as jest.Mock).mockClear();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ severity: severity as any }));
|
||||
expect(emailService.buildEmail).toHaveBeenCalledWith("unifiedNotificationTemplate", expect.objectContaining({ headerColor: color }));
|
||||
}
|
||||
});
|
||||
|
||||
it("uses default color for unknown severity", async () => {
|
||||
const { provider, emailService } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ severity: "unknown" as any }));
|
||||
expect(emailService.buildEmail).toHaveBeenCalledWith("unifiedNotificationTemplate", expect.objectContaining({ headerColor: "#3b82f6" }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
|
||||
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
|
||||
|
||||
const mockGotPut = jest.fn().mockResolvedValue({});
|
||||
jest.unstable_mockModule("got", () => ({ default: { put: mockGotPut } }));
|
||||
jest.unstable_mockModule("crypto", () => ({ randomUUID: () => "test-uuid-1234" }));
|
||||
|
||||
const { MatrixProvider } = await import("../../../../src/service/infrastructure/notificationProviders/matrix.ts");
|
||||
|
||||
const createProvider = () => {
|
||||
const logger = createMockLogger();
|
||||
return { provider: new MatrixProvider(logger as any), logger };
|
||||
};
|
||||
|
||||
testNotificationProviderContract("MatrixProvider", {
|
||||
create: () => {
|
||||
mockGotPut.mockResolvedValue({});
|
||||
return createProvider().provider;
|
||||
},
|
||||
makeNotification: () => makeNotification(),
|
||||
});
|
||||
|
||||
describe("MatrixProvider", () => {
|
||||
beforeEach(() => mockGotPut.mockReset().mockResolvedValue({}));
|
||||
|
||||
describe("sendTestAlert", () => {
|
||||
it("sends to Matrix API and returns true", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
|
||||
expect(mockGotPut).toHaveBeenCalledWith(
|
||||
expect.stringContaining("matrix.example.com/_matrix/client/v3/rooms/!room:example.com/send/m.room.message/test-uuid-1234"),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: "Bearer token-abc" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when homeserverUrl is missing", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNotification({ homeserverUrl: undefined }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when accessToken is missing", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNotification({ accessToken: undefined }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when roomId is missing", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNotification({ roomId: undefined }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPut.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles non-Error thrown values", async () => {
|
||||
mockGotPut.mockRejectedValue({ message: "custom obj" });
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("sends formatted HTML and plain text", async () => {
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
|
||||
const body = mockGotPut.mock.calls[0][1].json;
|
||||
expect(body.msgtype).toBe("m.text");
|
||||
expect(body.format).toBe("org.matrix.custom.html");
|
||||
expect(body.body).toContain("Monitor Down");
|
||||
expect(body.formatted_body).toContain("<h2");
|
||||
});
|
||||
|
||||
it("returns false when homeserverUrl is missing", async () => {
|
||||
expect(await createProvider().provider.sendMessage(makeNotification({ homeserverUrl: undefined }) as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPut.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes threshold breaches", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
|
||||
const body = mockGotPut.mock.calls[0][1].json;
|
||||
expect(body.body).toContain("CPU");
|
||||
expect(body.formatted_body).toContain("CPU");
|
||||
});
|
||||
|
||||
it("includes incident link", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessageWithIncident());
|
||||
const body = mockGotPut.mock.calls[0][1].json;
|
||||
expect(body.body).toContain("View Incident");
|
||||
expect(body.formatted_body).toContain("View Incident");
|
||||
});
|
||||
|
||||
it("omits optional sections when not present", async () => {
|
||||
const { provider } = createProvider();
|
||||
const msg = makeMessage();
|
||||
msg.content.thresholds = undefined;
|
||||
msg.content.details = undefined;
|
||||
msg.content.incident = undefined;
|
||||
await provider.sendMessage(makeNotification() as any, msg);
|
||||
const body = mockGotPut.mock.calls[0][1].json;
|
||||
expect(body.body).not.toContain("Threshold");
|
||||
expect(body.body).not.toContain("Additional");
|
||||
expect(body.body).not.toContain("View Incident");
|
||||
});
|
||||
|
||||
it("escapes HTML special characters", async () => {
|
||||
const { provider } = createProvider();
|
||||
const msg = makeMessage();
|
||||
msg.monitor.name = '<script>alert("xss")</script>';
|
||||
await provider.sendMessage(makeNotification() as any, msg);
|
||||
const html = mockGotPut.mock.calls[0][1].json.formatted_body;
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).toContain("<script>");
|
||||
});
|
||||
|
||||
it("maps all severity levels to colors", async () => {
|
||||
const { provider } = createProvider();
|
||||
for (const severity of ["critical", "warning", "success", "info"] as const) {
|
||||
mockGotPut.mockClear();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ severity }));
|
||||
const html = mockGotPut.mock.calls[0][1].json.formatted_body;
|
||||
expect(html).toContain("color:");
|
||||
}
|
||||
});
|
||||
|
||||
it("uses default color for unknown severity", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ severity: "unknown" as any }));
|
||||
const html = mockGotPut.mock.calls[0][1].json.formatted_body;
|
||||
expect(html).toContain("#808080");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { buildTestEmail, getTestMessage } from "../../../../src/service/infrastructure/notificationProviders/utils.ts";
|
||||
import type { IEmailService } from "../../../../src/service/infrastructure/emailService.ts";
|
||||
|
||||
describe("notification utils", () => {
|
||||
describe("getTestMessage", () => {
|
||||
it("returns a non-empty string", () => {
|
||||
expect(getTestMessage()).toBe("This is a test notification from Checkmate");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTestEmail", () => {
|
||||
it("calls emailService.buildEmail with test template and context", async () => {
|
||||
const emailService = {
|
||||
buildEmail: jest.fn().mockResolvedValue("<html>test</html>"),
|
||||
} as unknown as IEmailService;
|
||||
|
||||
const result = await buildTestEmail(emailService);
|
||||
|
||||
expect(emailService.buildEmail).toHaveBeenCalledWith("testEmailTemplate", { testName: "Monitoring System" });
|
||||
expect(result).toBe("<html>test</html>");
|
||||
});
|
||||
|
||||
it("returns undefined when buildEmail returns undefined", async () => {
|
||||
const emailService = {
|
||||
buildEmail: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as IEmailService;
|
||||
|
||||
const result = await buildTestEmail(emailService);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import { makeNotification, makeMessage, makeMessageWithThresholds } from "../../../helpers/notificationMessage.ts";
|
||||
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
|
||||
|
||||
const mockGotPost = jest.fn().mockResolvedValue({});
|
||||
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
|
||||
|
||||
const { PagerDutyProvider } = await import("../../../../src/service/infrastructure/notificationProviders/pagerduty.ts");
|
||||
|
||||
const createProvider = () => {
|
||||
const logger = createMockLogger();
|
||||
return { provider: new PagerDutyProvider(logger as any), logger };
|
||||
};
|
||||
|
||||
testNotificationProviderContract("PagerDutyProvider", {
|
||||
create: () => {
|
||||
mockGotPost.mockResolvedValue({});
|
||||
return createProvider().provider;
|
||||
},
|
||||
makeNotification: () => makeNotification(),
|
||||
});
|
||||
|
||||
describe("PagerDutyProvider", () => {
|
||||
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
|
||||
|
||||
describe("sendTestAlert", () => {
|
||||
it("sends to PagerDuty API and returns true", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
|
||||
expect(mockGotPost).toHaveBeenCalledWith(
|
||||
"https://events.pagerduty.com/v2/enqueue",
|
||||
expect.objectContaining({
|
||||
json: expect.objectContaining({ routing_key: "https://hooks.example.com/webhook", event_action: "trigger" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles non-Error thrown values", async () => {
|
||||
mockGotPost.mockRejectedValue(null);
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("sends payload and returns true", async () => {
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
|
||||
const payload = mockGotPost.mock.calls[0][1].json;
|
||||
expect(payload.routing_key).toBe("https://hooks.example.com/webhook");
|
||||
expect(payload.event_action).toBe("trigger");
|
||||
expect(payload.dedup_key).toBe("checkmate-mon-1");
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles non-Error thrown values in sendMessage", async () => {
|
||||
mockGotPost.mockRejectedValue(null);
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("uses 'resolve' event_action for monitor_up", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "monitor_up" }));
|
||||
expect(mockGotPost.mock.calls[0][1].json.event_action).toBe("resolve");
|
||||
});
|
||||
|
||||
it("uses 'resolve' event_action for threshold_resolved", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "threshold_resolved" }));
|
||||
expect(mockGotPost.mock.calls[0][1].json.event_action).toBe("resolve");
|
||||
});
|
||||
|
||||
it("includes threshold info in summary and custom_details", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
|
||||
const payload = mockGotPost.mock.calls[0][1].json;
|
||||
expect(payload.payload.summary).toContain("CPU");
|
||||
expect(payload.payload.custom_details.threshold_breaches).toBeDefined();
|
||||
});
|
||||
|
||||
it("includes details in custom_details", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage());
|
||||
expect(mockGotPost.mock.calls[0][1].json.payload.custom_details.details).toBeDefined();
|
||||
});
|
||||
|
||||
it("maps all severity levels", async () => {
|
||||
const { provider } = createProvider();
|
||||
for (const severity of ["critical", "warning", "info", "success"] as const) {
|
||||
mockGotPost.mockClear();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ severity }));
|
||||
expect(mockGotPost.mock.calls[0][1].json.payload.severity).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses 'error' for unknown severity", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ severity: "unknown" as any }));
|
||||
expect(mockGotPost.mock.calls[0][1].json.payload.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("omits threshold and details from custom_details when not present", async () => {
|
||||
const { provider } = createProvider();
|
||||
const msg = makeMessage();
|
||||
msg.content.thresholds = undefined;
|
||||
msg.content.details = undefined;
|
||||
await provider.sendMessage(makeNotification() as any, msg);
|
||||
const cd = mockGotPost.mock.calls[0][1].json.payload.custom_details;
|
||||
expect(cd.threshold_breaches).toBeUndefined();
|
||||
expect(cd.details).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
|
||||
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
|
||||
|
||||
const mockGotPost = jest.fn().mockResolvedValue({});
|
||||
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost }, HTTPError: class extends Error {} }));
|
||||
|
||||
const { SlackProvider } = await import("../../../../src/service/infrastructure/notificationProviders/slack.ts");
|
||||
|
||||
const createProvider = () => {
|
||||
const logger = createMockLogger();
|
||||
return { provider: new SlackProvider(logger as any), logger };
|
||||
};
|
||||
|
||||
testNotificationProviderContract("SlackProvider", {
|
||||
create: () => {
|
||||
mockGotPost.mockResolvedValue({});
|
||||
return createProvider().provider;
|
||||
},
|
||||
makeNotification: () => makeNotification(),
|
||||
});
|
||||
|
||||
describe("SlackProvider", () => {
|
||||
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
|
||||
|
||||
describe("sendTestAlert", () => {
|
||||
it("returns true on success", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles undefined thrown values", async () => {
|
||||
mockGotPost.mockRejectedValue(undefined);
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("sends Block Kit payload and returns true", async () => {
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
|
||||
const payload = mockGotPost.mock.calls[0][1].json;
|
||||
expect(payload.blocks).toBeDefined();
|
||||
expect(payload.attachments).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
expect(await createProvider().provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles undefined thrown values in sendMessage", async () => {
|
||||
mockGotPost.mockRejectedValue(undefined);
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("includes threshold section when thresholds present", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
|
||||
const blocks = mockGotPost.mock.calls[0][1].json.blocks;
|
||||
const text = JSON.stringify(blocks);
|
||||
expect(text).toContain("Threshold Breaches");
|
||||
});
|
||||
|
||||
it("includes incident button when incident present", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessageWithIncident());
|
||||
const blocks = mockGotPost.mock.calls[0][1].json.blocks;
|
||||
expect(blocks.some((b: any) => b.type === "actions")).toBe(true);
|
||||
});
|
||||
|
||||
it("includes details section when details are present", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage());
|
||||
const blocks = mockGotPost.mock.calls[0][1].json.blocks;
|
||||
const text = JSON.stringify(blocks);
|
||||
expect(text).toContain("Additional Information");
|
||||
});
|
||||
|
||||
it("omits threshold, details, and incident sections when not present", async () => {
|
||||
const { provider } = createProvider();
|
||||
const msg = makeMessage();
|
||||
msg.content.thresholds = undefined;
|
||||
msg.content.details = undefined;
|
||||
msg.content.incident = undefined;
|
||||
await provider.sendMessage(makeNotification() as any, msg);
|
||||
const blocks = mockGotPost.mock.calls[0][1].json.blocks;
|
||||
const text = JSON.stringify(blocks);
|
||||
expect(text).not.toContain("Additional Information");
|
||||
expect(blocks.some((b: any) => b.type === "actions")).toBe(false);
|
||||
});
|
||||
|
||||
it("maps all severity levels to colors", async () => {
|
||||
const { provider } = createProvider();
|
||||
for (const severity of ["critical", "warning", "success", "info"] as const) {
|
||||
mockGotPost.mockClear();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ severity }));
|
||||
expect(mockGotPost.mock.calls[0][1].json.attachments[0].color).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses default color for unknown severity", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessage({ severity: "unknown" as any }));
|
||||
expect(mockGotPost.mock.calls[0][1].json.attachments[0].color).toBe("#808080");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
|
||||
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
|
||||
|
||||
const mockGotPost = jest.fn().mockResolvedValue({});
|
||||
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
|
||||
|
||||
const { TelegramProvider } = await import("../../../../src/service/infrastructure/notificationProviders/telegram.ts");
|
||||
|
||||
const createProvider = () => {
|
||||
const logger = createMockLogger();
|
||||
return { provider: new TelegramProvider(logger as any), logger };
|
||||
};
|
||||
|
||||
testNotificationProviderContract("TelegramProvider", {
|
||||
create: () => {
|
||||
mockGotPost.mockResolvedValue({});
|
||||
return createProvider().provider;
|
||||
},
|
||||
makeNotification: () => makeNotification(),
|
||||
});
|
||||
|
||||
describe("TelegramProvider", () => {
|
||||
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
|
||||
|
||||
describe("sendTestAlert", () => {
|
||||
it("sends to Telegram API and returns true", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
|
||||
expect(mockGotPost).toHaveBeenCalledWith(
|
||||
expect.stringContaining("api.telegram.org/bottoken-abc/sendMessage"),
|
||||
expect.objectContaining({ json: expect.objectContaining({ chat_id: "https://hooks.example.com/webhook" }) })
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when accessToken is missing", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNotification({ accessToken: undefined }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert", details: { error: "fail" } }));
|
||||
});
|
||||
|
||||
it("handles non-Error thrown values in sendTestAlert", async () => {
|
||||
mockGotPost.mockRejectedValue("string error");
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined, details: { error: "unknown error" } }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("sends HTML-formatted text and returns true", async () => {
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
|
||||
const json = mockGotPost.mock.calls[0][1].json;
|
||||
expect(json.parse_mode).toBe("HTML");
|
||||
expect(json.text).toContain("<b>Monitor Down");
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
expect(await createProvider().provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when accessToken is missing", async () => {
|
||||
expect(await createProvider().provider.sendMessage(makeNotification({ accessToken: undefined }) as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" }));
|
||||
});
|
||||
|
||||
it("handles non-Error thrown values in sendMessage", async () => {
|
||||
mockGotPost.mockRejectedValue(42);
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ details: { error: "unknown error" } }));
|
||||
});
|
||||
|
||||
it("includes thresholds in text", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
|
||||
expect(mockGotPost.mock.calls[0][1].json.text).toContain("CPU");
|
||||
});
|
||||
|
||||
it("includes incident link in text", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessageWithIncident());
|
||||
expect(mockGotPost.mock.calls[0][1].json.text).toContain("View Incident");
|
||||
});
|
||||
|
||||
it("omits optional sections when not present", async () => {
|
||||
const { provider } = createProvider();
|
||||
const msg = makeMessage();
|
||||
msg.content.thresholds = undefined;
|
||||
msg.content.details = undefined;
|
||||
msg.content.incident = undefined;
|
||||
await provider.sendMessage(makeNotification() as any, msg);
|
||||
const text = mockGotPost.mock.calls[0][1].json.text;
|
||||
expect(text).not.toContain("Threshold");
|
||||
expect(text).not.toContain("Additional");
|
||||
expect(text).not.toContain("View Incident");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
|
||||
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
|
||||
|
||||
const mockGotPost = jest.fn().mockResolvedValue({});
|
||||
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
|
||||
|
||||
const { WebhookProvider } = await import("../../../../src/service/infrastructure/notificationProviders/webhook.ts");
|
||||
|
||||
const createProvider = () => {
|
||||
const logger = createMockLogger();
|
||||
return { provider: new WebhookProvider(logger as any), logger };
|
||||
};
|
||||
|
||||
testNotificationProviderContract("WebhookProvider", {
|
||||
create: () => {
|
||||
mockGotPost.mockResolvedValue({});
|
||||
return createProvider().provider;
|
||||
},
|
||||
makeNotification: () => makeNotification(),
|
||||
});
|
||||
|
||||
describe("WebhookProvider", () => {
|
||||
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
|
||||
|
||||
describe("sendTestAlert", () => {
|
||||
it("returns true on success", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles non-Error thrown values", async () => {
|
||||
mockGotPost.mockRejectedValue(null);
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("sends payload with text and structured data", async () => {
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
|
||||
const payload = mockGotPost.mock.calls[0][1].json;
|
||||
expect(payload.text).toContain("Monitor Down");
|
||||
expect(payload.severity).toBe("critical");
|
||||
expect(payload.monitor.id).toBe("mon-1");
|
||||
});
|
||||
|
||||
it("returns false when address is missing", async () => {
|
||||
expect(await createProvider().provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles non-Error thrown values in sendMessage", async () => {
|
||||
mockGotPost.mockRejectedValue(null);
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("includes threshold breaches in text", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
|
||||
expect(mockGotPost.mock.calls[0][1].json.text).toContain("CPU");
|
||||
});
|
||||
|
||||
it("includes incident link in text", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeNotification() as any, makeMessageWithIncident());
|
||||
expect(mockGotPost.mock.calls[0][1].json.text).toContain("View Incident");
|
||||
});
|
||||
|
||||
it("omits threshold and incident sections when not present", async () => {
|
||||
const { provider } = createProvider();
|
||||
const msg = makeMessage();
|
||||
msg.content.thresholds = undefined;
|
||||
msg.content.details = undefined;
|
||||
msg.content.incident = undefined;
|
||||
await provider.sendMessage(makeNotification() as any, msg);
|
||||
const text = mockGotPost.mock.calls[0][1].json.text;
|
||||
expect(text).not.toContain("Threshold");
|
||||
expect(text).not.toContain("Additional Information");
|
||||
expect(text).not.toContain("View Incident");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,454 +0,0 @@
|
||||
import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals";
|
||||
import { BufferService } from "../../../src/service/infrastructure/bufferService.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
import type { ICheckService } from "../../../src/service/business/checkService.ts";
|
||||
import type { IGeoChecksService } from "../../../src/service/business/geoChecksService.ts";
|
||||
import type { ISettingsService } from "../../../src/service/system/settingsService.ts";
|
||||
import type { Check, GeoCheck } from "../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const createMockCheckService = () =>
|
||||
({
|
||||
createChecks: jest.fn().mockResolvedValue([]),
|
||||
}) as unknown as jest.Mocked<ICheckService>;
|
||||
|
||||
const createMockGeoChecksService = () =>
|
||||
({
|
||||
createGeoChecks: jest.fn().mockResolvedValue([]),
|
||||
}) as unknown as jest.Mocked<IGeoChecksService>;
|
||||
|
||||
const createMockSettingsService = (nodeEnv: string = "development") =>
|
||||
({
|
||||
getSettings: jest.fn().mockReturnValue({ nodeEnv }),
|
||||
}) as unknown as jest.Mocked<ISettingsService>;
|
||||
|
||||
const makeCheck = (overrides?: Partial<Check>): Check =>
|
||||
({
|
||||
id: "check-1",
|
||||
metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" },
|
||||
status: true,
|
||||
statusCode: 200,
|
||||
responseTime: 100,
|
||||
message: "OK",
|
||||
...overrides,
|
||||
}) as Check;
|
||||
|
||||
const makeGeoCheck = (overrides?: Partial<GeoCheck>): GeoCheck =>
|
||||
({
|
||||
id: "geo-1",
|
||||
monitorId: "mon-1",
|
||||
...overrides,
|
||||
}) as GeoCheck;
|
||||
|
||||
const createService = (nodeEnv: string = "development") => {
|
||||
const logger = createMockLogger();
|
||||
const checkService = createMockCheckService();
|
||||
const geoChecksService = createMockGeoChecksService();
|
||||
const settingsService = createMockSettingsService(nodeEnv);
|
||||
const service = new BufferService(logger as any, checkService, geoChecksService, settingsService);
|
||||
return { service, logger, checkService, geoChecksService };
|
||||
};
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("BufferService", () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Static / instance properties ─────────────────────────────────────
|
||||
|
||||
describe("serviceName", () => {
|
||||
it("returns BufferService from static property", () => {
|
||||
expect(BufferService.SERVICE_NAME).toBe("BufferService");
|
||||
});
|
||||
|
||||
it("returns BufferService from instance getter", () => {
|
||||
const { service } = createService();
|
||||
expect(service.serviceName).toBe("BufferService");
|
||||
});
|
||||
});
|
||||
|
||||
// ── constructor ──────────────────────────────────────────────────────
|
||||
|
||||
describe("constructor", () => {
|
||||
it("logs initialization with development timeout", () => {
|
||||
const { logger } = createService("development");
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("0.01s"),
|
||||
service: "BufferService",
|
||||
method: "constructor",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("uses 60s timeout in non-development environment", () => {
|
||||
const { logger } = createService("production");
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("60s"),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("schedules a flush on construction", () => {
|
||||
createService();
|
||||
expect(jest.getTimerCount()).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── addToBuffer ──────────────────────────────────────────────────────
|
||||
|
||||
describe("addToBuffer", () => {
|
||||
it("adds a check to the buffer", async () => {
|
||||
const { service, checkService } = createService();
|
||||
const check = makeCheck();
|
||||
|
||||
service.addToBuffer(check);
|
||||
await service.flushBuffer();
|
||||
|
||||
expect(checkService.createChecks).toHaveBeenCalledWith([check]);
|
||||
});
|
||||
|
||||
it("adds multiple checks to the buffer", async () => {
|
||||
const { service, checkService } = createService();
|
||||
const check1 = makeCheck({ id: "c1" });
|
||||
const check2 = makeCheck({ id: "c2" });
|
||||
|
||||
service.addToBuffer(check1);
|
||||
service.addToBuffer(check2);
|
||||
await service.flushBuffer();
|
||||
|
||||
expect(checkService.createChecks).toHaveBeenCalledWith([check1, check2]);
|
||||
});
|
||||
|
||||
it("logs error if push throws", () => {
|
||||
const { service, logger } = createService();
|
||||
// Force buffer.push to throw by making buffer non-extensible
|
||||
Object.defineProperty(service, "buffer", { value: Object.freeze([]) });
|
||||
|
||||
service.addToBuffer(makeCheck());
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
service: "BufferService",
|
||||
method: "addToBuffer",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error thrown values", () => {
|
||||
const { service, logger } = createService();
|
||||
Object.defineProperty(service, "buffer", {
|
||||
value: {
|
||||
push: () => {
|
||||
throw "string error";
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
service.addToBuffer(makeCheck());
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Unknown error",
|
||||
stack: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── addGeoCheckToBuffer ──────────────────────────────────────────────
|
||||
|
||||
describe("addGeoCheckToBuffer", () => {
|
||||
it("adds a geo check to the buffer", async () => {
|
||||
const { service, geoChecksService } = createService();
|
||||
const geoCheck = makeGeoCheck();
|
||||
|
||||
service.addGeoCheckToBuffer(geoCheck);
|
||||
await service.flushGeoBuffer();
|
||||
|
||||
expect(geoChecksService.createGeoChecks).toHaveBeenCalledWith([geoCheck]);
|
||||
});
|
||||
|
||||
it("logs error if push throws", () => {
|
||||
const { service, logger } = createService();
|
||||
Object.defineProperty(service, "geoBuffer", { value: Object.freeze([]) });
|
||||
|
||||
service.addGeoCheckToBuffer(makeGeoCheck());
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
service: "BufferService",
|
||||
method: "addGeoCheckToBuffer",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error thrown values", () => {
|
||||
const { service, logger } = createService();
|
||||
Object.defineProperty(service, "geoBuffer", {
|
||||
value: {
|
||||
push: () => {
|
||||
throw 42;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
service.addGeoCheckToBuffer(makeGeoCheck());
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Unknown error",
|
||||
stack: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── scheduleNextFlush ────────────────────────────────────────────────
|
||||
|
||||
describe("scheduleNextFlush", () => {
|
||||
it("clears existing timer and sets a new one", () => {
|
||||
const { service } = createService();
|
||||
// Constructor already scheduled one flush
|
||||
const initialTimerCount = jest.getTimerCount();
|
||||
|
||||
service.scheduleNextFlush();
|
||||
|
||||
// Should still have timers (cleared old, set new)
|
||||
expect(jest.getTimerCount()).toBe(initialTimerCount);
|
||||
});
|
||||
|
||||
it("flushes buffer and geo buffer when timer fires", async () => {
|
||||
const { service, checkService, geoChecksService } = createService();
|
||||
service.addToBuffer(makeCheck());
|
||||
service.addGeoCheckToBuffer(makeGeoCheck());
|
||||
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(checkService.createChecks).toHaveBeenCalled();
|
||||
expect(geoChecksService.createGeoChecks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reschedules after flush completes", async () => {
|
||||
const { service, checkService } = createService();
|
||||
(checkService.createChecks as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
service.addToBuffer(makeCheck());
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Add another check and advance again to confirm rescheduling
|
||||
service.addToBuffer(makeCheck({ id: "c2" }));
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(checkService.createChecks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reschedules even when flush throws", async () => {
|
||||
const { service, checkService, logger } = createService();
|
||||
(checkService.createChecks as jest.Mock).mockRejectedValueOnce(new Error("DB down"));
|
||||
service.addToBuffer(makeCheck());
|
||||
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "DB down",
|
||||
method: "flushBuffer",
|
||||
})
|
||||
);
|
||||
|
||||
// Should still reschedule — add another check and flush
|
||||
(checkService.createChecks as jest.Mock).mockResolvedValue([]);
|
||||
service.addToBuffer(makeCheck({ id: "c2" }));
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(checkService.createChecks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("logs error and reschedules when flush throws past its own catch", async () => {
|
||||
const { service, logger } = createService();
|
||||
// Override flushBuffer to throw past its own try/catch
|
||||
service.flushBuffer = jest.fn<() => Promise<void>>().mockRejectedValueOnce(new Error("unexpected"));
|
||||
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "unexpected",
|
||||
method: "scheduleNextFlush",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' when flush throws non-Error past its own catch", async () => {
|
||||
const { service, logger } = createService();
|
||||
service.flushBuffer = jest.fn<() => Promise<void>>().mockRejectedValueOnce("string error");
|
||||
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Unknown error",
|
||||
method: "scheduleNextFlush",
|
||||
stack: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── flushBuffer ──────────────────────────────────────────────────────
|
||||
|
||||
describe("flushBuffer", () => {
|
||||
it("does nothing when buffer is empty", async () => {
|
||||
const { service, checkService } = createService();
|
||||
|
||||
await service.flushBuffer();
|
||||
|
||||
expect(checkService.createChecks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("flushes checks to checksService and clears buffer", async () => {
|
||||
const { service, checkService } = createService();
|
||||
const check = makeCheck();
|
||||
service.addToBuffer(check);
|
||||
|
||||
await service.flushBuffer();
|
||||
|
||||
expect(checkService.createChecks).toHaveBeenCalledWith([check]);
|
||||
// Buffer should be empty now
|
||||
await service.flushBuffer();
|
||||
expect(checkService.createChecks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("logs debug message before flushing", async () => {
|
||||
const { service, logger } = createService();
|
||||
service.addToBuffer(makeCheck());
|
||||
|
||||
await service.flushBuffer();
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Flushing 1 checks to database",
|
||||
service: "BufferService",
|
||||
method: "flushBuffer",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("clears buffer even on error to prevent infinite retries", async () => {
|
||||
const { service, checkService, logger } = createService();
|
||||
(checkService.createChecks as jest.Mock).mockRejectedValue(new Error("DB write failed"));
|
||||
service.addToBuffer(makeCheck());
|
||||
|
||||
await service.flushBuffer();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "DB write failed",
|
||||
method: "flushBuffer",
|
||||
})
|
||||
);
|
||||
// Buffer should be cleared
|
||||
await service.flushBuffer();
|
||||
expect(checkService.createChecks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error thrown values", async () => {
|
||||
const { service, checkService, logger } = createService();
|
||||
(checkService.createChecks as jest.Mock).mockRejectedValue(null);
|
||||
service.addToBuffer(makeCheck());
|
||||
|
||||
await service.flushBuffer();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Unknown error",
|
||||
method: "flushBuffer",
|
||||
stack: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── flushGeoBuffer ───────────────────────────────────────────────────
|
||||
|
||||
describe("flushGeoBuffer", () => {
|
||||
it("does nothing when geo buffer is empty", async () => {
|
||||
const { service, geoChecksService } = createService();
|
||||
|
||||
await service.flushGeoBuffer();
|
||||
|
||||
expect(geoChecksService.createGeoChecks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("flushes geo checks and clears buffer", async () => {
|
||||
const { service, geoChecksService } = createService();
|
||||
const geoCheck = makeGeoCheck();
|
||||
service.addGeoCheckToBuffer(geoCheck);
|
||||
|
||||
await service.flushGeoBuffer();
|
||||
|
||||
expect(geoChecksService.createGeoChecks).toHaveBeenCalledWith([geoCheck]);
|
||||
// Buffer should be empty now
|
||||
await service.flushGeoBuffer();
|
||||
expect(geoChecksService.createGeoChecks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("logs debug message before flushing", async () => {
|
||||
const { service, logger } = createService();
|
||||
service.addGeoCheckToBuffer(makeGeoCheck());
|
||||
|
||||
await service.flushGeoBuffer();
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Flushing 1 geo checks to database",
|
||||
service: "BufferService",
|
||||
method: "flushGeoBuffer",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("clears geo buffer even on error", async () => {
|
||||
const { service, geoChecksService, logger } = createService();
|
||||
(geoChecksService.createGeoChecks as jest.Mock).mockRejectedValue(new Error("DB error"));
|
||||
service.addGeoCheckToBuffer(makeGeoCheck());
|
||||
|
||||
await service.flushGeoBuffer();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "DB error",
|
||||
method: "flushGeoBuffer",
|
||||
})
|
||||
);
|
||||
// Buffer should be cleared
|
||||
await service.flushGeoBuffer();
|
||||
expect(geoChecksService.createGeoChecks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error thrown values", async () => {
|
||||
const { service, geoChecksService, logger } = createService();
|
||||
(geoChecksService.createGeoChecks as jest.Mock).mockRejectedValue(undefined);
|
||||
service.addGeoCheckToBuffer(makeGeoCheck());
|
||||
|
||||
await service.flushGeoBuffer();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Unknown error",
|
||||
method: "flushGeoBuffer",
|
||||
stack: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,427 +0,0 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { CheckService } from "../../../src/service/business/checkService.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
import type { IChecksRepository, IMonitorsRepository } from "../../../src/repositories/index.ts";
|
||||
import type { MonitorStatusResponse, HardwareStatusPayload, PageSpeedStatusPayload } from "../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const createMonitorsRepo = () =>
|
||||
({
|
||||
findById: jest.fn().mockResolvedValue({}),
|
||||
}) as unknown as jest.Mocked<IMonitorsRepository>;
|
||||
|
||||
const createChecksRepo = () =>
|
||||
({
|
||||
createChecks: jest.fn().mockImplementation((checks: unknown) => Promise.resolve(checks)),
|
||||
findByMonitorId: jest.fn().mockResolvedValue({ checks: [], count: 0 }),
|
||||
findByTeamId: jest.fn().mockResolvedValue({ checks: [], count: 0 }),
|
||||
findSummaryByTeamId: jest.fn().mockResolvedValue({}),
|
||||
deleteByMonitorId: jest.fn().mockResolvedValue(5),
|
||||
deleteByTeamId: jest.fn().mockResolvedValue(10),
|
||||
deleteOlderThan: jest.fn().mockResolvedValue(3),
|
||||
}) as unknown as jest.Mocked<IChecksRepository>;
|
||||
|
||||
const createService = () => {
|
||||
const logger = createMockLogger();
|
||||
const monitorsRepository = createMonitorsRepo();
|
||||
const checksRepository = createChecksRepo();
|
||||
const service = new CheckService(monitorsRepository, logger as any, checksRepository);
|
||||
return { service, logger, monitorsRepository, checksRepository };
|
||||
};
|
||||
|
||||
const makeStatusResponse = (overrides?: Partial<MonitorStatusResponse>): MonitorStatusResponse =>
|
||||
({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "http",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "OK",
|
||||
responseTime: 100,
|
||||
...overrides,
|
||||
}) as MonitorStatusResponse;
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("CheckService", () => {
|
||||
describe("serviceName", () => {
|
||||
it("returns checkService", () => {
|
||||
const { service } = createService();
|
||||
expect(service.serviceName).toBe("checkService");
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildCheck ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("buildCheck", () => {
|
||||
it("builds a basic HTTP check with correct fields", () => {
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse());
|
||||
|
||||
expect(check).toBeDefined();
|
||||
expect(check!.metadata).toEqual({ monitorId: "mon-1", teamId: "team-1", type: "http" });
|
||||
expect(check!.status).toBe(true);
|
||||
expect(check!.statusCode).toBe(200);
|
||||
expect(check!.responseTime).toBe(100);
|
||||
expect(check!.message).toBe("OK");
|
||||
expect(check!.id).toBeDefined();
|
||||
expect(check!.createdAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("defaults responseTime to 0 when falsy", () => {
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ responseTime: undefined }));
|
||||
expect(check!.responseTime).toBe(0);
|
||||
});
|
||||
|
||||
it("includes timings when provided", () => {
|
||||
const timings = { start: 0, socket: 10, lookup: 20 };
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ timings } as any));
|
||||
expect(check!.timings).toBe(timings);
|
||||
});
|
||||
|
||||
// ── PageSpeed ────────────────────────────────────────────────────────
|
||||
|
||||
describe("pagespeed type", () => {
|
||||
it("extracts category scores and audits from lighthouse result", () => {
|
||||
const payload: PageSpeedStatusPayload = {
|
||||
lighthouseResult: {
|
||||
categories: {
|
||||
accessibility: { score: 0.9 },
|
||||
"best-practices": { score: 0.8 },
|
||||
seo: { score: 0.95 },
|
||||
performance: { score: 0.7 },
|
||||
},
|
||||
audits: {
|
||||
"cumulative-layout-shift": { id: "cls", title: "CLS", score: 0.1, displayValue: "0.1", numericValue: 0.1, numericUnit: "unitless" },
|
||||
"speed-index": { id: "si", title: "SI", score: 0.5, displayValue: "3.0s", numericValue: 3000, numericUnit: "millisecond" },
|
||||
"first-contentful-paint": { id: "fcp", title: "FCP", score: 0.8 },
|
||||
"largest-contentful-paint": { id: "lcp", title: "LCP", score: 0.6 },
|
||||
"total-blocking-time": { id: "tbt", title: "TBT", score: 0.9 },
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
|
||||
|
||||
expect(check!.accessibility).toBe(90);
|
||||
expect(check!.bestPractices).toBe(80);
|
||||
expect(check!.seo).toBe(95);
|
||||
expect(check!.performance).toBe(70);
|
||||
expect(check!.audits!.cls).toEqual(expect.objectContaining({ id: "cls", score: 0.1, numericValue: 0.1 }));
|
||||
expect(check!.audits!.si).toEqual(expect.objectContaining({ id: "si", numericValue: 3000 }));
|
||||
expect(check!.audits!.fcp).toEqual(expect.objectContaining({ id: "fcp" }));
|
||||
});
|
||||
|
||||
it("returns undefined when pagespeed payload is missing", () => {
|
||||
const { service, logger } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload: undefined } as any));
|
||||
|
||||
expect(check).toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build check" }));
|
||||
});
|
||||
|
||||
it("defaults categories and audits to empty when lighthouseResult is missing", () => {
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload: {} } as any));
|
||||
|
||||
expect(check!.accessibility).toBe(0);
|
||||
expect(check!.bestPractices).toBe(0);
|
||||
expect(check!.seo).toBe(0);
|
||||
expect(check!.performance).toBe(0);
|
||||
expect(check!.audits!.cls).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles audit with non-number score (string)", () => {
|
||||
const payload = {
|
||||
lighthouseResult: {
|
||||
categories: {},
|
||||
audits: {
|
||||
"cumulative-layout-shift": { id: "cls", title: "CLS", score: "informative" },
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
|
||||
|
||||
expect(check!.audits!.cls!.score).toBe("informative");
|
||||
});
|
||||
|
||||
it("handles audit with null score", () => {
|
||||
const payload = {
|
||||
lighthouseResult: {
|
||||
categories: {},
|
||||
audits: {
|
||||
"cumulative-layout-shift": { id: "cls", title: "CLS", score: null },
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
|
||||
|
||||
expect(check!.audits!.cls!.score).toBeNull();
|
||||
});
|
||||
|
||||
it("handles audit with non-number numericValue", () => {
|
||||
const payload = {
|
||||
lighthouseResult: {
|
||||
categories: {},
|
||||
audits: {
|
||||
"speed-index": { id: "si", title: "SI", score: 0.5, numericValue: "n/a" },
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
|
||||
|
||||
expect(check!.audits!.si!.numericValue).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for non-object audit", () => {
|
||||
const payload = {
|
||||
lighthouseResult: {
|
||||
categories: {},
|
||||
audits: {
|
||||
"cumulative-layout-shift": null,
|
||||
"speed-index": "not an object",
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
|
||||
|
||||
expect(check!.audits!.cls).toBeUndefined();
|
||||
expect(check!.audits!.si).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Hardware ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("hardware type", () => {
|
||||
it("extracts cpu, memory, disk, host, net, and capture from payload", () => {
|
||||
const payload: HardwareStatusPayload = {
|
||||
data: {
|
||||
cpu: { usage_percent: 0.5 },
|
||||
memory: { usage_percent: 0.6 },
|
||||
disk: [{ device: "/dev/sda", usage_percent: 0.7 }],
|
||||
host: { os: "linux" },
|
||||
net: [{ name: "eth0" }],
|
||||
},
|
||||
capture: { screenshot: "base64data" },
|
||||
} as any;
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any));
|
||||
|
||||
expect(check!.cpu).toEqual({ usage_percent: 0.5 });
|
||||
expect(check!.memory).toEqual({ usage_percent: 0.6 });
|
||||
expect(check!.disk).toHaveLength(1);
|
||||
expect(check!.host).toEqual({ os: "linux" });
|
||||
expect(check!.net).toHaveLength(1);
|
||||
expect(check!.capture).toEqual({ screenshot: "base64data" });
|
||||
});
|
||||
|
||||
it("extracts errors from array format", () => {
|
||||
const payload = {
|
||||
data: {},
|
||||
errors: [{ message: "timeout" }],
|
||||
} as any;
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any));
|
||||
|
||||
expect(check!.errors).toEqual([{ message: "timeout" }]);
|
||||
});
|
||||
|
||||
it("extracts errors from nested object format", () => {
|
||||
const payload = {
|
||||
data: {},
|
||||
errors: { errors: [{ message: "nested error" }] },
|
||||
} as any;
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any));
|
||||
|
||||
expect(check!.errors).toEqual([{ message: "nested error" }]);
|
||||
});
|
||||
|
||||
it("handles undefined payload gracefully", () => {
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload: undefined } as any));
|
||||
|
||||
expect(check).toBeDefined();
|
||||
expect(check!.cpu).toBeUndefined();
|
||||
expect(check!.memory).toBeUndefined();
|
||||
expect(check!.disk).toBeUndefined();
|
||||
expect(check!.errors).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── createChecks ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("createChecks", () => {
|
||||
it("delegates to repository", async () => {
|
||||
const { service, checksRepository } = createService();
|
||||
const checks = [{ id: "c1" }] as any;
|
||||
await service.createChecks(checks);
|
||||
expect(checksRepository.createChecks).toHaveBeenCalledWith(checks);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getChecksByMonitor ───────────────────────────────────────────────────
|
||||
|
||||
describe("getChecksByMonitor", () => {
|
||||
it("returns checks from repository", async () => {
|
||||
const expected = { checks: [{ id: "c1" }], count: 1 };
|
||||
const { service, checksRepository } = createService();
|
||||
(checksRepository.findByMonitorId as jest.Mock).mockResolvedValue(expected);
|
||||
|
||||
const result = await service.getChecksByMonitor({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
sortOrder: "desc",
|
||||
dateRange: "day",
|
||||
page: 0,
|
||||
rowsPerPage: 10,
|
||||
});
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it("defaults page to 0 and rowsPerPage to 5 when nullish", async () => {
|
||||
const { service, checksRepository } = createService();
|
||||
|
||||
await service.getChecksByMonitor({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
sortOrder: "desc",
|
||||
dateRange: "day",
|
||||
page: null as any,
|
||||
rowsPerPage: null as any,
|
||||
});
|
||||
|
||||
expect(checksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "desc", "day", undefined, 0, 5, undefined);
|
||||
});
|
||||
|
||||
it("passes filter and status parameters", async () => {
|
||||
const { service, checksRepository } = createService();
|
||||
|
||||
await service.getChecksByMonitor({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
sortOrder: "asc",
|
||||
dateRange: "week",
|
||||
page: 2,
|
||||
rowsPerPage: 20,
|
||||
filter: "error",
|
||||
status: false,
|
||||
});
|
||||
|
||||
expect(checksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "asc", "week", "error", 2, 20, false);
|
||||
});
|
||||
|
||||
it("throws when monitorId is missing", async () => {
|
||||
const { service } = createService();
|
||||
await expect(
|
||||
service.getChecksByMonitor({ monitorId: "", teamId: "team-1", sortOrder: "desc", dateRange: "day", page: 0, rowsPerPage: 10 })
|
||||
).rejects.toThrow("No monitor ID in request");
|
||||
});
|
||||
|
||||
it("throws when teamId is missing", async () => {
|
||||
const { service } = createService();
|
||||
await expect(
|
||||
service.getChecksByMonitor({ monitorId: "mon-1", teamId: "", sortOrder: "desc", dateRange: "day", page: 0, rowsPerPage: 10 })
|
||||
).rejects.toThrow("No team ID in request");
|
||||
});
|
||||
|
||||
it("verifies monitor belongs to team via repository", async () => {
|
||||
const { service, monitorsRepository } = createService();
|
||||
|
||||
await service.getChecksByMonitor({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
sortOrder: "desc",
|
||||
dateRange: "day",
|
||||
page: 0,
|
||||
rowsPerPage: 10,
|
||||
});
|
||||
|
||||
expect(monitorsRepository.findById).toHaveBeenCalledWith("mon-1", "team-1");
|
||||
});
|
||||
});
|
||||
|
||||
// ── getChecksByTeam ──────────────────────────────────────────────────────
|
||||
|
||||
describe("getChecksByTeam", () => {
|
||||
it("returns checks from repository", async () => {
|
||||
const expected = { checks: [], count: 0 };
|
||||
const { service, checksRepository } = createService();
|
||||
(checksRepository.findByTeamId as jest.Mock).mockResolvedValue(expected);
|
||||
|
||||
const result = await service.getChecksByTeam({ teamId: "team-1", sortOrder: "desc", dateRange: "day", page: 0, rowsPerPage: 10 });
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it("defaults page to 0 and rowsPerPage to 5 when nullish", async () => {
|
||||
const { service, checksRepository } = createService();
|
||||
|
||||
await service.getChecksByTeam({ teamId: "team-1", sortOrder: "desc", dateRange: "day", page: null as any, rowsPerPage: null as any });
|
||||
|
||||
expect(checksRepository.findByTeamId).toHaveBeenCalledWith("desc", "day", undefined, 0, 5, "team-1");
|
||||
});
|
||||
});
|
||||
|
||||
// ── getChecksSummaryByTeamId ──────────────────────────────────────────────
|
||||
|
||||
describe("getChecksSummaryByTeamId", () => {
|
||||
it("delegates to repository", async () => {
|
||||
const summary = { total: 5, up: 4, down: 1 };
|
||||
const { service, checksRepository } = createService();
|
||||
(checksRepository.findSummaryByTeamId as jest.Mock).mockResolvedValue(summary);
|
||||
|
||||
const result = await service.getChecksSummaryByTeamId({ teamId: "team-1", dateRange: "day" });
|
||||
|
||||
expect(result).toBe(summary);
|
||||
expect(checksRepository.findSummaryByTeamId).toHaveBeenCalledWith("team-1", "day");
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteChecks ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("deleteChecks", () => {
|
||||
it("verifies monitor ownership and deletes", async () => {
|
||||
const { service, monitorsRepository, checksRepository } = createService();
|
||||
|
||||
const result = await service.deleteChecks({ monitorId: "mon-1", teamId: "team-1" });
|
||||
|
||||
expect(monitorsRepository.findById).toHaveBeenCalledWith("mon-1", "team-1");
|
||||
expect(checksRepository.deleteByMonitorId).toHaveBeenCalledWith("mon-1");
|
||||
expect(result).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteChecksByTeamId", () => {
|
||||
it("delegates to repository", async () => {
|
||||
const { service, checksRepository } = createService();
|
||||
|
||||
const result = await service.deleteChecksByTeamId({ teamId: "team-1" });
|
||||
|
||||
expect(checksRepository.deleteByTeamId).toHaveBeenCalledWith("team-1");
|
||||
expect(result).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteOlderThan", () => {
|
||||
it("delegates to repository", async () => {
|
||||
const date = new Date();
|
||||
const { service, checksRepository } = createService();
|
||||
|
||||
const result = await service.deleteOlderThan(date);
|
||||
|
||||
expect(checksRepository.deleteOlderThan).toHaveBeenCalledWith(date);
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals";
|
||||
import { DiagnosticService } from "../../../src/service/business/diagnosticService.ts";
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("DiagnosticService", () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe("serviceName", () => {
|
||||
it("returns diagnosticService from static property", () => {
|
||||
expect(DiagnosticService.SERVICE_NAME).toBe("diagnosticService");
|
||||
});
|
||||
|
||||
it("returns diagnosticService from instance getter", () => {
|
||||
const service = new DiagnosticService();
|
||||
expect(service.serviceName).toBe("diagnosticService");
|
||||
});
|
||||
});
|
||||
|
||||
// ── constructor ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("constructor", () => {
|
||||
it("sets up PerformanceObserver that clears marks on callback", () => {
|
||||
let capturedCallback: ((list: PerformanceObserverEntryList) => void) | null = null;
|
||||
const originalObserver = globalThis.PerformanceObserver;
|
||||
const mockObserve = jest.fn();
|
||||
|
||||
globalThis.PerformanceObserver = jest.fn().mockImplementation((cb: any) => {
|
||||
capturedCallback = cb;
|
||||
return { observe: mockObserve };
|
||||
}) as any;
|
||||
|
||||
const clearMarksSpy = jest.spyOn(performance, "clearMarks");
|
||||
const service = new DiagnosticService();
|
||||
|
||||
expect(mockObserve).toHaveBeenCalledWith({ entryTypes: ["measure"] });
|
||||
expect(capturedCallback).not.toBeNull();
|
||||
|
||||
// Invoke the callback manually
|
||||
const mockEntryList = { getEntries: jest.fn().mockReturnValue([]) } as unknown as PerformanceObserverEntryList;
|
||||
capturedCallback!(mockEntryList);
|
||||
|
||||
expect(mockEntryList.getEntries).toHaveBeenCalled();
|
||||
expect(clearMarksSpy).toHaveBeenCalled();
|
||||
|
||||
clearMarksSpy.mockRestore();
|
||||
globalThis.PerformanceObserver = originalObserver;
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── getCPUUsage ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("getCPUUsage", () => {
|
||||
it("returns cpu usage metrics after timing period", async () => {
|
||||
const service = new DiagnosticService();
|
||||
const promise = service.getCPUUsage();
|
||||
jest.advanceTimersByTime(1000);
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toHaveProperty("userUsageMs");
|
||||
expect(result).toHaveProperty("systemUsageMs");
|
||||
expect(result).toHaveProperty("usagePercentage");
|
||||
expect(typeof result.userUsageMs).toBe("number");
|
||||
expect(typeof result.systemUsageMs).toBe("number");
|
||||
expect(typeof result.usagePercentage).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
// ── getSystemStats ──────────────────────────────────────────────────────
|
||||
|
||||
describe("getSystemStats", () => {
|
||||
it("returns all diagnostic sections", async () => {
|
||||
const service = new DiagnosticService();
|
||||
const promise = service.getSystemStats();
|
||||
// getCPUUsage has 1s timeout, then event loop delay has 0ms timeout
|
||||
jest.advanceTimersByTime(1000);
|
||||
// Need to flush the microtask queue and advance again for the nested setTimeout(0)
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toHaveProperty("osStats");
|
||||
expect(result.osStats).toHaveProperty("freeMemoryBytes");
|
||||
expect(result.osStats).toHaveProperty("totalMemoryBytes");
|
||||
|
||||
expect(result).toHaveProperty("memoryUsage");
|
||||
expect(result.memoryUsage).toHaveProperty("rss");
|
||||
expect(result.memoryUsage).toHaveProperty("heapTotal");
|
||||
expect(result.memoryUsage).toHaveProperty("heapUsed");
|
||||
expect(result.memoryUsage).toHaveProperty("external");
|
||||
expect(result.memoryUsage).toHaveProperty("arrayBuffers");
|
||||
|
||||
expect(result).toHaveProperty("cpuUsage");
|
||||
expect(result.cpuUsage).toHaveProperty("userUsageMs");
|
||||
|
||||
expect(result).toHaveProperty("v8HeapStats");
|
||||
expect(result.v8HeapStats).toHaveProperty("totalHeapSizeBytes");
|
||||
expect(result.v8HeapStats).toHaveProperty("usedHeapSizeBytes");
|
||||
expect(result.v8HeapStats).toHaveProperty("heapSizeLimitBytes");
|
||||
|
||||
expect(typeof result.eventLoopDelayMs).toBe("number");
|
||||
expect(result.eventLoopDelayMs).toBeGreaterThanOrEqual(0);
|
||||
expect(typeof result.uptimeMs).toBe("number");
|
||||
expect(result.uptimeMs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("reads eventLoopDelay from real performance entries", async () => {
|
||||
jest.useRealTimers();
|
||||
const service = new DiagnosticService();
|
||||
|
||||
// Call getSystemStats with real timers — slow but guarantees real perf entries
|
||||
const result = await service.getSystemStats();
|
||||
|
||||
expect(typeof result.eventLoopDelayMs).toBe("number");
|
||||
expect(result.eventLoopDelayMs).toBeGreaterThanOrEqual(0);
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "empty entries array", entries: [] },
|
||||
{ label: "undefined entry in array", entries: [undefined] },
|
||||
])("defaults eventLoopDelayMs to 0 when $label", async ({ entries }) => {
|
||||
const service = new DiagnosticService();
|
||||
const originalGetEntries = performance.getEntriesByName;
|
||||
const originalMeasure = performance.measure;
|
||||
const originalMark = performance.mark;
|
||||
|
||||
performance.mark = jest.fn() as any;
|
||||
performance.measure = jest.fn() as any;
|
||||
performance.getEntriesByName = jest.fn().mockReturnValue(entries) as any;
|
||||
|
||||
const promise = service.getSystemStats();
|
||||
jest.advanceTimersByTime(1000);
|
||||
await jest.advanceTimersByTimeAsync(10);
|
||||
const result = await promise;
|
||||
|
||||
expect(result.eventLoopDelayMs).toBe(0);
|
||||
expect(performance.getEntriesByName).toHaveBeenCalledWith("eventLoopDelay");
|
||||
|
||||
performance.getEntriesByName = originalGetEntries;
|
||||
performance.measure = originalMeasure;
|
||||
performance.mark = originalMark;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,400 +0,0 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { EmailService } from "../../../src/service/infrastructure/emailService.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
import type { ISettingsService } from "../../../src/service/system/settingsService.ts";
|
||||
import type { EmailTransportConfig } from "../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeTransportConfig = (overrides?: Partial<EmailTransportConfig>): EmailTransportConfig =>
|
||||
({
|
||||
systemEmailHost: "smtp.example.com",
|
||||
systemEmailPort: 587,
|
||||
systemEmailSecure: false,
|
||||
systemEmailPool: false,
|
||||
systemEmailUser: "user@example.com",
|
||||
systemEmailAddress: "noreply@example.com",
|
||||
systemEmailPassword: "password123",
|
||||
systemEmailConnectionHost: "mail.example.com",
|
||||
systemEmailTLSServername: "smtp.example.com",
|
||||
systemEmailIgnoreTLS: false,
|
||||
systemEmailRequireTLS: true,
|
||||
systemEmailRejectUnauthorized: true,
|
||||
...overrides,
|
||||
}) as EmailTransportConfig;
|
||||
|
||||
const createMockSettingsService = () =>
|
||||
({
|
||||
getDBSettings: jest.fn().mockResolvedValue(makeTransportConfig()),
|
||||
}) as unknown as jest.Mocked<ISettingsService>;
|
||||
|
||||
const createMockFs = (templateContent: string = "<mjml><mj-body></mj-body></mjml>") =>
|
||||
({
|
||||
readFileSync: jest.fn().mockReturnValue(templateContent),
|
||||
}) as any;
|
||||
|
||||
const createMockPath = () =>
|
||||
({
|
||||
join: jest.fn((...args: string[]) => args.join("/")),
|
||||
dirname: jest.fn().mockReturnValue("/mock/dir"),
|
||||
}) as any;
|
||||
|
||||
const createMockCompile = (result: string = "<mjml>compiled</mjml>") => {
|
||||
const compiledFn = jest.fn().mockReturnValue(result);
|
||||
const compile = jest.fn().mockReturnValue(compiledFn) as any;
|
||||
compile.__compiledFn = compiledFn;
|
||||
return compile;
|
||||
};
|
||||
|
||||
const createMockMjml = (html: string = "<html>rendered</html>") => jest.fn().mockReturnValue({ html }) as any;
|
||||
|
||||
const createMockTransporter = () => ({
|
||||
verify: jest.fn().mockResolvedValue(true),
|
||||
sendMail: jest.fn().mockResolvedValue({ messageId: "msg-123" }),
|
||||
});
|
||||
|
||||
const createMockNodemailer = (transporter?: ReturnType<typeof createMockTransporter>) => {
|
||||
const t = transporter ?? createMockTransporter();
|
||||
return {
|
||||
createTransport: jest.fn().mockReturnValue(t),
|
||||
__transporter: t,
|
||||
} as any;
|
||||
};
|
||||
|
||||
const createService = (overrides?: { settingsService?: any; fs?: any; path?: any; compile?: any; mjml?: any; nodemailer?: any }) => {
|
||||
const logger = createMockLogger();
|
||||
const settingsService = overrides?.settingsService ?? createMockSettingsService();
|
||||
const mockFs = overrides?.fs ?? createMockFs();
|
||||
const mockPath = overrides?.path ?? createMockPath();
|
||||
const compile = overrides?.compile ?? createMockCompile();
|
||||
const mjml = overrides?.mjml ?? createMockMjml();
|
||||
const mockNodemailer = overrides?.nodemailer ?? createMockNodemailer();
|
||||
|
||||
const service = new EmailService(settingsService, mockFs, mockPath, compile, mjml, mockNodemailer, logger as any);
|
||||
|
||||
return { service, logger, settingsService, mockFs, mockPath, compile, mjml, mockNodemailer };
|
||||
};
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("EmailService", () => {
|
||||
describe("SERVICE_NAME", () => {
|
||||
it("returns EmailService from static property", () => {
|
||||
expect(EmailService.SERVICE_NAME).toBe("EmailService");
|
||||
});
|
||||
|
||||
it("returns EmailService from instance getter", () => {
|
||||
const { service } = createService();
|
||||
expect(service.serviceName).toBe("EmailService");
|
||||
});
|
||||
});
|
||||
|
||||
// ── init / loadTemplate ──────────────────────────────────────────────
|
||||
|
||||
describe("init", () => {
|
||||
it("loads all expected templates on construction", () => {
|
||||
const { mockFs } = createService();
|
||||
|
||||
// 6 templates loaded
|
||||
expect(mockFs.readFileSync).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it("compiles each loaded template", () => {
|
||||
const { compile } = createService();
|
||||
|
||||
expect(compile).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it("logs error when a template file is not found", () => {
|
||||
const failFs = {
|
||||
readFileSync: jest.fn().mockImplementation(() => {
|
||||
throw new Error("ENOENT: no such file");
|
||||
}),
|
||||
};
|
||||
|
||||
const { logger } = createService({ fs: failFs });
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "ENOENT: no such file",
|
||||
service: "EmailService",
|
||||
method: "loadTemplate",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error thrown values in loadTemplate", () => {
|
||||
const failFs = {
|
||||
readFileSync: jest.fn().mockImplementation(() => {
|
||||
throw "string error";
|
||||
}),
|
||||
};
|
||||
|
||||
const { logger } = createService({ fs: failFs });
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Unknown error",
|
||||
stack: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("can be called again to reinitialize templates", () => {
|
||||
const { service, mockFs } = createService();
|
||||
const initialCalls = mockFs.readFileSync.mock.calls.length;
|
||||
|
||||
service.init();
|
||||
|
||||
expect(mockFs.readFileSync).toHaveBeenCalledTimes(initialCalls + 6);
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildEmail ───────────────────────────────────────────────────────
|
||||
|
||||
describe("buildEmail", () => {
|
||||
it("compiles template with context and returns rendered HTML", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
const result = await service.buildEmail("welcomeEmailTemplate", { name: "Test" });
|
||||
|
||||
expect(result).toBe("<html>rendered</html>");
|
||||
});
|
||||
|
||||
it("passes context to the compiled template function", async () => {
|
||||
const compile = createMockCompile();
|
||||
const { service } = createService({ compile });
|
||||
const context = { name: "Alex", url: "https://example.com" };
|
||||
|
||||
await service.buildEmail("welcomeEmailTemplate", context);
|
||||
|
||||
expect(compile.__compiledFn).toHaveBeenCalledWith(context);
|
||||
});
|
||||
|
||||
it("returns undefined and logs error when template is not found", async () => {
|
||||
const { service, logger } = createService();
|
||||
|
||||
const result = await service.buildEmail("nonExistentTemplate", {});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Template nonExistentTemplate not found",
|
||||
service: "EmailService",
|
||||
method: "buildEmail",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined and logs error when template function returns falsy", async () => {
|
||||
const compile = jest.fn().mockReturnValue(jest.fn().mockReturnValue(undefined)) as any;
|
||||
const { service, logger } = createService({ compile });
|
||||
|
||||
const result = await service.buildEmail("welcomeEmailTemplate", {});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Template welcomeEmailTemplate not found",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined and logs error when mjml throws", async () => {
|
||||
const mjml = jest.fn().mockImplementation(() => {
|
||||
throw new Error("MJML parse error");
|
||||
}) as any;
|
||||
const { service, logger } = createService({ mjml });
|
||||
|
||||
const result = await service.buildEmail("welcomeEmailTemplate", {});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "MJML parse error",
|
||||
method: "buildEmail",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error thrown values in buildEmail", async () => {
|
||||
const mjml = jest.fn().mockImplementation(() => {
|
||||
throw null;
|
||||
}) as any;
|
||||
const { service, logger } = createService({ mjml });
|
||||
|
||||
const result = await service.buildEmail("welcomeEmailTemplate", {});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Unknown error",
|
||||
method: "buildEmail",
|
||||
stack: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── sendEmail ────────────────────────────────────────────────────────
|
||||
|
||||
describe("sendEmail", () => {
|
||||
it("sends email with provided transport config and returns messageId", async () => {
|
||||
const transporter = createMockTransporter();
|
||||
const nodemailer = createMockNodemailer(transporter);
|
||||
const { service } = createService({ nodemailer });
|
||||
const config = makeTransportConfig();
|
||||
|
||||
const result = await service.sendEmail("to@example.com", "Subject", "<p>body</p>", config);
|
||||
|
||||
expect(result).toBe("msg-123");
|
||||
expect(transporter.verify).toHaveBeenCalled();
|
||||
expect(transporter.sendMail).toHaveBeenCalledWith({
|
||||
to: "to@example.com",
|
||||
from: "noreply@example.com",
|
||||
subject: "Subject",
|
||||
html: "<p>body</p>",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to DB settings when no transport config is provided", async () => {
|
||||
const settingsService = createMockSettingsService();
|
||||
const transporter = createMockTransporter();
|
||||
const nodemailer = createMockNodemailer(transporter);
|
||||
const { service } = createService({ settingsService, nodemailer });
|
||||
|
||||
await service.sendEmail("to@example.com", "Subject", "<p>body</p>");
|
||||
|
||||
expect(settingsService.getDBSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates transporter with correct email config", async () => {
|
||||
const transporter = createMockTransporter();
|
||||
const nodemailer = createMockNodemailer(transporter);
|
||||
const { service } = createService({ nodemailer });
|
||||
const config = makeTransportConfig({
|
||||
systemEmailHost: "smtp.test.com",
|
||||
systemEmailPort: 465,
|
||||
systemEmailSecure: true,
|
||||
systemEmailPool: true,
|
||||
systemEmailUser: "testuser",
|
||||
systemEmailPassword: "testpass",
|
||||
systemEmailConnectionHost: "conn.test.com",
|
||||
systemEmailRejectUnauthorized: false,
|
||||
systemEmailIgnoreTLS: true,
|
||||
systemEmailRequireTLS: false,
|
||||
systemEmailTLSServername: "tls.test.com",
|
||||
});
|
||||
|
||||
await service.sendEmail("to@example.com", "Subject", "<p>body</p>", config);
|
||||
|
||||
expect(nodemailer.createTransport).toHaveBeenCalledWith({
|
||||
host: "smtp.test.com",
|
||||
port: 465,
|
||||
secure: true,
|
||||
auth: { user: "testuser", pass: "testpass" },
|
||||
name: "conn.test.com",
|
||||
connectionTimeout: 5000,
|
||||
pool: true,
|
||||
tls: {
|
||||
rejectUnauthorized: false,
|
||||
ignoreTLS: true,
|
||||
requireTLS: false,
|
||||
servername: "tls.test.com",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses systemEmailAddress as auth user when systemEmailUser is falsy", async () => {
|
||||
const transporter = createMockTransporter();
|
||||
const nodemailer = createMockNodemailer(transporter);
|
||||
const { service } = createService({ nodemailer });
|
||||
const config = makeTransportConfig({ systemEmailUser: "", systemEmailAddress: "fallback@example.com" });
|
||||
|
||||
await service.sendEmail("to@example.com", "Subject", "<p>body</p>", config);
|
||||
|
||||
expect(nodemailer.createTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
auth: expect.objectContaining({ user: "fallback@example.com" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("uses 'localhost' as name when systemEmailConnectionHost is falsy", async () => {
|
||||
const transporter = createMockTransporter();
|
||||
const nodemailer = createMockNodemailer(transporter);
|
||||
const { service } = createService({ nodemailer });
|
||||
const config = makeTransportConfig({ systemEmailConnectionHost: "" });
|
||||
|
||||
await service.sendEmail("to@example.com", "Subject", "<p>body</p>", config);
|
||||
|
||||
expect(nodemailer.createTransport).toHaveBeenCalledWith(expect.objectContaining({ name: "localhost" }));
|
||||
});
|
||||
|
||||
it("returns false when transporter verification fails", async () => {
|
||||
const transporter = createMockTransporter();
|
||||
(transporter.verify as jest.Mock).mockRejectedValue(new Error("SMTP auth failed"));
|
||||
const nodemailer = createMockNodemailer(transporter);
|
||||
const { service, logger } = createService({ nodemailer });
|
||||
|
||||
const result = await service.sendEmail("to@example.com", "Subject", "<p>body</p>", makeTransportConfig());
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Email transporter verification failed",
|
||||
service: "EmailService",
|
||||
method: "verifyTransporter",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs stack as undefined when verification fails with non-Error", async () => {
|
||||
const transporter = createMockTransporter();
|
||||
(transporter.verify as jest.Mock).mockRejectedValue("connection refused");
|
||||
const nodemailer = createMockNodemailer(transporter);
|
||||
const { service, logger } = createService({ nodemailer });
|
||||
|
||||
const result = await service.sendEmail("to@example.com", "Subject", "<p>body</p>", makeTransportConfig());
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
|
||||
});
|
||||
|
||||
it("returns undefined and logs error when sendMail throws", async () => {
|
||||
const transporter = createMockTransporter();
|
||||
(transporter.sendMail as jest.Mock).mockRejectedValue(new Error("Recipient rejected"));
|
||||
const nodemailer = createMockNodemailer(transporter);
|
||||
const { service, logger } = createService({ nodemailer });
|
||||
|
||||
const result = await service.sendEmail("to@example.com", "Subject", "<p>body</p>", makeTransportConfig());
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Recipient rejected",
|
||||
service: "EmailService",
|
||||
method: "sendEmail",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error thrown values in sendMail", async () => {
|
||||
const transporter = createMockTransporter();
|
||||
(transporter.sendMail as jest.Mock).mockRejectedValue(42);
|
||||
const nodemailer = createMockNodemailer(transporter);
|
||||
const { service, logger } = createService({ nodemailer });
|
||||
|
||||
const result = await service.sendEmail("to@example.com", "Subject", "<p>body</p>", makeTransportConfig());
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Unknown error",
|
||||
method: "sendEmail",
|
||||
stack: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,269 +0,0 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { GeoChecksService } from "../../../src/service/business/geoChecksService.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
import type { Monitor } from "../../../src/types/index.ts";
|
||||
import type { GeoCheckResult } from "../../../src/types/geoCheck.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "http",
|
||||
url: "https://example.com",
|
||||
geoCheckEnabled: true,
|
||||
geoCheckLocations: ["NA", "EU"],
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const makeGeoResult = (overrides?: Partial<GeoCheckResult>): GeoCheckResult => ({
|
||||
continent: "NA",
|
||||
country: "US",
|
||||
city: "New York",
|
||||
status: "up",
|
||||
responseTime: 50,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createService = (overrides?: Record<string, unknown>) => {
|
||||
const logger = createMockLogger();
|
||||
const geoChecksRepository = {
|
||||
createGeoChecks: jest.fn().mockResolvedValue([]),
|
||||
findByMonitorId: jest.fn().mockResolvedValue({ geoChecksCount: 0, geoChecks: [] }),
|
||||
};
|
||||
const globalPingService = {
|
||||
createMeasurement: jest.fn().mockResolvedValue("measurement-123"),
|
||||
pollForResults: jest.fn().mockResolvedValue([makeGeoResult()]),
|
||||
};
|
||||
const monitorsRepository = {
|
||||
findById: jest.fn().mockResolvedValue(makeMonitor()),
|
||||
};
|
||||
|
||||
const defaults = { logger, geoChecksRepository, globalPingService, monitorsRepository, ...overrides };
|
||||
const service = new GeoChecksService(defaults as any);
|
||||
return { service, ...defaults };
|
||||
};
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GeoChecksService", () => {
|
||||
describe("serviceName", () => {
|
||||
it("returns GeoChecksService from static property", () => {
|
||||
expect(GeoChecksService.SERVICE_NAME).toBe("GeoChecksService");
|
||||
});
|
||||
|
||||
it("returns GeoChecksService from instance getter", () => {
|
||||
const { service } = createService();
|
||||
expect(service.serviceName).toBe("GeoChecksService");
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildGeoCheck ───────────────────────────────────────────────────────
|
||||
|
||||
describe("buildGeoCheck", () => {
|
||||
it("returns a geo check document on success", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
const result = await service.buildGeoCheck(makeMonitor());
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.metadata).toEqual({ monitorId: "mon-1", teamId: "team-1", type: "http" });
|
||||
expect(result!.results).toHaveLength(1);
|
||||
expect(result!.results[0].continent).toBe("NA");
|
||||
expect(result!.id).toBeDefined();
|
||||
expect(result!.expiry).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns null and warns when monitor has no URL", async () => {
|
||||
const { service, logger } = createService();
|
||||
|
||||
const result = await service.buildGeoCheck(makeMonitor({ url: "" }));
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Monitor missing URL for geo check" }));
|
||||
});
|
||||
|
||||
it("returns null and warns when geoCheckLocations is empty", async () => {
|
||||
const { service, logger } = createService();
|
||||
|
||||
const result = await service.buildGeoCheck(makeMonitor({ geoCheckLocations: [] }));
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Monitor missing geo check locations" }));
|
||||
});
|
||||
|
||||
it("returns null and warns when geoCheckLocations is undefined", async () => {
|
||||
const { service, logger } = createService();
|
||||
|
||||
const result = await service.buildGeoCheck(makeMonitor({ geoCheckLocations: undefined as any }));
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Monitor missing geo check locations" }));
|
||||
});
|
||||
|
||||
it("returns null when createMeasurement returns null (API unavailable)", async () => {
|
||||
const { service, logger } = createService({
|
||||
globalPingService: {
|
||||
createMeasurement: jest.fn().mockResolvedValue(null),
|
||||
pollForResults: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.buildGeoCheck(makeMonitor());
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Skipping geo check due to API unavailability" }));
|
||||
});
|
||||
|
||||
it("returns null when pollForResults returns empty array", async () => {
|
||||
const { service, logger } = createService({
|
||||
globalPingService: {
|
||||
createMeasurement: jest.fn().mockResolvedValue("measurement-123"),
|
||||
pollForResults: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.buildGeoCheck(makeMonitor());
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "No successful geo check results" }));
|
||||
});
|
||||
|
||||
it("returns null and logs error when an exception is thrown", async () => {
|
||||
const { service, logger } = createService({
|
||||
globalPingService: {
|
||||
createMeasurement: jest.fn().mockRejectedValue(new Error("API error")),
|
||||
pollForResults: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.buildGeoCheck(makeMonitor());
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Error executing geo check",
|
||||
details: expect.objectContaining({ error: "API error" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error exceptions", async () => {
|
||||
const { service, logger } = createService({
|
||||
globalPingService: {
|
||||
createMeasurement: jest.fn().mockRejectedValue("string error"),
|
||||
pollForResults: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.buildGeoCheck(makeMonitor());
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ details: expect.objectContaining({ error: "Unknown error" }) }));
|
||||
});
|
||||
});
|
||||
|
||||
// ── createGeoChecks ─────────────────────────────────────────────────────
|
||||
|
||||
describe("createGeoChecks", () => {
|
||||
it("delegates to repository", async () => {
|
||||
const { service, geoChecksRepository } = createService();
|
||||
const geoChecks = [{ id: "gc-1" }] as any;
|
||||
|
||||
await service.createGeoChecks(geoChecks);
|
||||
|
||||
expect(geoChecksRepository.createGeoChecks).toHaveBeenCalledWith(geoChecks);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getGeoChecksByMonitor ────────────────────────────────────────────────
|
||||
|
||||
describe("getGeoChecksByMonitor", () => {
|
||||
it("returns geo checks from repository", async () => {
|
||||
const expected = { geoChecksCount: 1, geoChecks: [{ id: "gc-1" }] };
|
||||
const { service, geoChecksRepository } = createService();
|
||||
(geoChecksRepository.findByMonitorId as jest.Mock).mockResolvedValue(expected);
|
||||
|
||||
const result = await service.getGeoChecksByMonitor({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
sortOrder: "desc",
|
||||
dateRange: "day",
|
||||
continent: "NA" as any,
|
||||
});
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
expect(geoChecksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "desc", "day", 0, 5, ["NA"]);
|
||||
});
|
||||
|
||||
it("passes array of continents through", async () => {
|
||||
const { service, geoChecksRepository } = createService();
|
||||
|
||||
await service.getGeoChecksByMonitor({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
sortOrder: "desc",
|
||||
dateRange: "day",
|
||||
continent: ["NA", "EU"] as any,
|
||||
});
|
||||
|
||||
expect(geoChecksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "desc", "day", 0, 5, ["NA", "EU"]);
|
||||
});
|
||||
|
||||
it("passes undefined continents when not provided", async () => {
|
||||
const { service, geoChecksRepository } = createService();
|
||||
|
||||
await service.getGeoChecksByMonitor({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
sortOrder: "desc",
|
||||
dateRange: "day",
|
||||
continent: undefined as any,
|
||||
});
|
||||
|
||||
expect(geoChecksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "desc", "day", 0, 5, undefined);
|
||||
});
|
||||
|
||||
it("uses provided page and rowsPerPage", async () => {
|
||||
const { service, geoChecksRepository } = createService();
|
||||
|
||||
await service.getGeoChecksByMonitor({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
sortOrder: "asc",
|
||||
dateRange: "week",
|
||||
page: 3,
|
||||
rowsPerPage: 20,
|
||||
continent: "EU" as any,
|
||||
});
|
||||
|
||||
expect(geoChecksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "asc", "week", 3, 20, ["EU"]);
|
||||
});
|
||||
|
||||
it("throws when monitorId is missing", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
await expect(
|
||||
service.getGeoChecksByMonitor({ monitorId: "", teamId: "team-1", sortOrder: "desc", dateRange: "day", continent: "NA" as any })
|
||||
).rejects.toThrow("No monitor ID in request");
|
||||
});
|
||||
|
||||
it("throws when teamId is missing", async () => {
|
||||
const { service } = createService();
|
||||
|
||||
await expect(
|
||||
service.getGeoChecksByMonitor({ monitorId: "mon-1", teamId: "", sortOrder: "desc", dateRange: "day", continent: "NA" as any })
|
||||
).rejects.toThrow("No team ID in request");
|
||||
});
|
||||
|
||||
it("throws 404 when monitor is not found", async () => {
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.getGeoChecksByMonitor({ monitorId: "mon-1", teamId: "team-1", sortOrder: "desc", dateRange: "day", continent: "NA" as any })
|
||||
).rejects.toThrow("Monitor with ID mon-1 not found.");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,499 +0,0 @@
|
||||
import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
import type { GeoContinent } from "../../../src/types/geoCheck.ts";
|
||||
|
||||
// ── got mock ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockGotPost = jest.fn();
|
||||
const mockGotGet = jest.fn();
|
||||
|
||||
jest.unstable_mockModule("got", () => ({
|
||||
default: {
|
||||
post: mockGotPost,
|
||||
get: mockGotGet,
|
||||
},
|
||||
}));
|
||||
|
||||
// Dynamic import AFTER mock registration
|
||||
const { GlobalPingService } = await import("../../../src/service/infrastructure/globalPingService.ts");
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const createService = () => {
|
||||
const logger = createMockLogger();
|
||||
const service = new GlobalPingService(logger as any);
|
||||
return { service, logger };
|
||||
};
|
||||
|
||||
const makeProbeResult = (overrides?: Record<string, any>) => ({
|
||||
probe: {
|
||||
continent: "NA" as GeoContinent,
|
||||
region: "Northern America",
|
||||
country: "US",
|
||||
state: "CA",
|
||||
city: "San Francisco",
|
||||
longitude: -122.4,
|
||||
latitude: 37.77,
|
||||
},
|
||||
result: {
|
||||
status: "finished",
|
||||
statusCode: 200,
|
||||
statusCodeName: "OK",
|
||||
timings: {
|
||||
total: 150,
|
||||
dns: 10,
|
||||
tcp: 20,
|
||||
tls: 30,
|
||||
firstByte: 50,
|
||||
download: 40,
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GlobalPingService", () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockGotPost.mockReset();
|
||||
mockGotGet.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Static / instance properties ─────────────────────────────────────
|
||||
|
||||
describe("serviceName", () => {
|
||||
it("returns GlobalPingService from static property", () => {
|
||||
expect(GlobalPingService.SERVICE_NAME).toBe("GlobalPingService");
|
||||
});
|
||||
|
||||
it("returns GlobalPingService from instance getter", () => {
|
||||
const { service } = createService();
|
||||
expect(service.serviceName).toBe("GlobalPingService");
|
||||
});
|
||||
});
|
||||
|
||||
// ── createMeasurement ────────────────────────────────────────────────
|
||||
|
||||
describe("createMeasurement", () => {
|
||||
it("creates a measurement and returns the id", async () => {
|
||||
const { service, logger } = createService();
|
||||
mockGotPost.mockResolvedValue({ body: { id: "meas-123" } });
|
||||
|
||||
const result = await service.createMeasurement("http", "https://example.com", ["NA", "EU"] as GeoContinent[]);
|
||||
|
||||
expect(result).toBe("meas-123");
|
||||
expect(mockGotPost).toHaveBeenCalledWith(
|
||||
"https://api.globalping.io/v1/measurements",
|
||||
expect.objectContaining({
|
||||
json: {
|
||||
type: "http",
|
||||
target: "example.com",
|
||||
locations: [{ continent: "NA" }, { continent: "EU" }],
|
||||
limit: 2,
|
||||
},
|
||||
responseType: "json",
|
||||
timeout: { request: 10000 },
|
||||
})
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("meas-123"),
|
||||
method: "createMeasurement",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("strips http:// protocol from target URL", async () => {
|
||||
const { service } = createService();
|
||||
mockGotPost.mockResolvedValue({ body: { id: "meas-1" } });
|
||||
|
||||
await service.createMeasurement("http", "http://example.com", ["NA"] as GeoContinent[]);
|
||||
|
||||
expect(mockGotPost).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
json: expect.objectContaining({ target: "example.com" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("strips https:// protocol from target URL", async () => {
|
||||
const { service } = createService();
|
||||
mockGotPost.mockResolvedValue({ body: { id: "meas-1" } });
|
||||
|
||||
await service.createMeasurement("http", "https://example.com/path", ["EU"] as GeoContinent[]);
|
||||
|
||||
expect(mockGotPost).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
json: expect.objectContaining({ target: "example.com/path" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null and logs error for unsupported monitor type", async () => {
|
||||
const { service, logger } = createService();
|
||||
|
||||
const result = await service.createMeasurement("hardware" as any, "https://example.com", ["NA"] as GeoContinent[]);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "GlobalPing API unavailable, skipping geo check",
|
||||
method: "createMeasurement",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null and logs error when API call fails", async () => {
|
||||
const { service, logger } = createService();
|
||||
mockGotPost.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const result = await service.createMeasurement("http", "https://example.com", ["NA"] as GeoContinent[]);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "GlobalPing API unavailable, skipping geo check",
|
||||
method: "createMeasurement",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs stack as undefined for non-Error thrown values", async () => {
|
||||
const { service, logger } = createService();
|
||||
mockGotPost.mockRejectedValue("string error");
|
||||
|
||||
await service.createMeasurement("http", "https://example.com", ["NA"] as GeoContinent[]);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
|
||||
});
|
||||
|
||||
it("supports ping monitor type", async () => {
|
||||
const { service } = createService();
|
||||
mockGotPost.mockResolvedValue({ body: { id: "meas-ping" } });
|
||||
|
||||
const result = await service.createMeasurement("ping", "example.com", ["NA"] as GeoContinent[]);
|
||||
|
||||
expect(result).toBe("meas-ping");
|
||||
});
|
||||
});
|
||||
|
||||
// ── pollForResults ───────────────────────────────────────────────────
|
||||
|
||||
describe("pollForResults", () => {
|
||||
it("returns transformed results when measurement is finished", async () => {
|
||||
const { service, logger } = createService();
|
||||
mockGotGet.mockResolvedValue({
|
||||
body: {
|
||||
status: "finished",
|
||||
results: [makeProbeResult()],
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.pollForResults("meas-123");
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
location: expect.objectContaining({
|
||||
continent: "NA",
|
||||
city: "San Francisco",
|
||||
}),
|
||||
status: true,
|
||||
statusCode: 200,
|
||||
timings: expect.objectContaining({
|
||||
total: 150,
|
||||
dns: 10,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("meas-123"),
|
||||
method: "pollForResults",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults results to empty array when finished with no results", async () => {
|
||||
const { service } = createService();
|
||||
mockGotGet.mockResolvedValue({
|
||||
body: { status: "finished", results: undefined },
|
||||
});
|
||||
|
||||
const results = await service.pollForResults("meas-123");
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when measurement has failed", async () => {
|
||||
const { service, logger } = createService();
|
||||
mockGotGet.mockResolvedValue({
|
||||
body: { status: "failed" },
|
||||
});
|
||||
|
||||
const results = await service.pollForResults("meas-123");
|
||||
|
||||
expect(results).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("failed"),
|
||||
method: "pollForResults",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("polls again when status is in-progress, then returns on finished", async () => {
|
||||
const { service } = createService();
|
||||
mockGotGet.mockResolvedValueOnce({ body: { status: "in-progress" } }).mockResolvedValueOnce({
|
||||
body: {
|
||||
status: "finished",
|
||||
results: [makeProbeResult()],
|
||||
},
|
||||
});
|
||||
|
||||
const promise = service.pollForResults("meas-123");
|
||||
|
||||
// Advance past the sleep(2000)
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
|
||||
const results = await promise;
|
||||
|
||||
expect(mockGotGet).toHaveBeenCalledTimes(2);
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns empty array and logs error when API call throws", async () => {
|
||||
const { service, logger } = createService();
|
||||
mockGotGet.mockRejectedValue(new Error("Connection refused"));
|
||||
|
||||
const results = await service.pollForResults("meas-123");
|
||||
|
||||
expect(results).toEqual([]);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Error polling GlobalPing API",
|
||||
method: "pollForResults",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs stack as undefined for non-Error thrown values in poll", async () => {
|
||||
const { service, logger } = createService();
|
||||
mockGotGet.mockRejectedValue(42);
|
||||
|
||||
await service.pollForResults("meas-123");
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
|
||||
});
|
||||
|
||||
it("returns empty array and logs warning on timeout", async () => {
|
||||
const { service, logger } = createService();
|
||||
// Always return in-progress so we hit the timeout
|
||||
mockGotGet.mockImplementation(async () => ({ body: { status: "in-progress" } }));
|
||||
|
||||
const promise = service.pollForResults("meas-123", 100);
|
||||
|
||||
// Advance time well past the timeout
|
||||
await jest.advanceTimersByTimeAsync(35000);
|
||||
|
||||
const results = await promise;
|
||||
|
||||
expect(results).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("timeout"),
|
||||
method: "pollForResults",
|
||||
details: expect.objectContaining({ measurementId: "meas-123", timeoutMs: 100 }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── transformResults (via pollForResults) ────────────────────────────
|
||||
|
||||
describe("transformResults", () => {
|
||||
it("skips probes with non-finished status", async () => {
|
||||
const { service } = createService();
|
||||
mockGotGet.mockResolvedValue({
|
||||
body: {
|
||||
status: "finished",
|
||||
results: [makeProbeResult({ result: { status: "failed" } }), makeProbeResult({ result: { status: "timeout" } })],
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.pollForResults("meas-123");
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("transforms HTTP results with statusCode and timings", async () => {
|
||||
const { service } = createService();
|
||||
mockGotGet.mockResolvedValue({
|
||||
body: {
|
||||
status: "finished",
|
||||
results: [makeProbeResult()],
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.pollForResults("meas-123");
|
||||
|
||||
expect(results[0].status).toBe(true);
|
||||
expect(results[0].statusCode).toBe(200);
|
||||
expect(results[0].timings.total).toBe(150);
|
||||
});
|
||||
|
||||
it("marks HTTP result as failed for non-2xx status codes", async () => {
|
||||
const { service } = createService();
|
||||
mockGotGet.mockResolvedValue({
|
||||
body: {
|
||||
status: "finished",
|
||||
results: [
|
||||
makeProbeResult({
|
||||
result: {
|
||||
status: "finished",
|
||||
statusCode: 500,
|
||||
timings: { total: 100, dns: 5, tcp: 10, tls: 15, firstByte: 30, download: 40 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.pollForResults("meas-123");
|
||||
|
||||
expect(results[0].status).toBe(false);
|
||||
expect(results[0].statusCode).toBe(500);
|
||||
});
|
||||
|
||||
it("transforms ping results with stats (no loss)", async () => {
|
||||
const { service } = createService();
|
||||
mockGotGet.mockResolvedValue({
|
||||
body: {
|
||||
status: "finished",
|
||||
results: [
|
||||
makeProbeResult({
|
||||
result: {
|
||||
status: "finished",
|
||||
stats: { min: 10, max: 30, avg: 20, total: 3, loss: 0, rcv: 3, drop: 0 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.pollForResults("meas-123");
|
||||
|
||||
expect(results[0].status).toBe(true);
|
||||
expect(results[0].statusCode).toBe(200);
|
||||
expect(results[0].timings.total).toBe(20);
|
||||
expect(results[0].timings.dns).toBe(0);
|
||||
});
|
||||
|
||||
it("transforms ping results with loss as failed", async () => {
|
||||
const { service } = createService();
|
||||
mockGotGet.mockResolvedValue({
|
||||
body: {
|
||||
status: "finished",
|
||||
results: [
|
||||
makeProbeResult({
|
||||
result: {
|
||||
status: "finished",
|
||||
stats: { min: 10, max: 30, avg: 20, total: 3, loss: 2, rcv: 1, drop: 2 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.pollForResults("meas-123");
|
||||
|
||||
expect(results[0].status).toBe(false);
|
||||
expect(results[0].statusCode).toBe(5000);
|
||||
});
|
||||
|
||||
it("uses empty string for null state in location", async () => {
|
||||
const { service } = createService();
|
||||
mockGotGet.mockResolvedValue({
|
||||
body: {
|
||||
status: "finished",
|
||||
results: [
|
||||
makeProbeResult({
|
||||
probe: {
|
||||
continent: "EU",
|
||||
region: "Western Europe",
|
||||
country: "DE",
|
||||
state: null,
|
||||
city: "Berlin",
|
||||
longitude: 13.4,
|
||||
latitude: 52.5,
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.pollForResults("meas-123");
|
||||
|
||||
expect(results[0].location.state).toBe("");
|
||||
});
|
||||
|
||||
it("skips probes with no statusCode/timings and no stats", async () => {
|
||||
const { service } = createService();
|
||||
mockGotGet.mockResolvedValue({
|
||||
body: {
|
||||
status: "finished",
|
||||
results: [
|
||||
makeProbeResult({
|
||||
result: { status: "finished" },
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.pollForResults("meas-123");
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("transforms multiple probes from different locations", async () => {
|
||||
const { service } = createService();
|
||||
mockGotGet.mockResolvedValue({
|
||||
body: {
|
||||
status: "finished",
|
||||
results: [
|
||||
makeProbeResult(),
|
||||
makeProbeResult({
|
||||
probe: {
|
||||
continent: "EU",
|
||||
region: "Western Europe",
|
||||
country: "GB",
|
||||
state: null,
|
||||
city: "London",
|
||||
longitude: -0.12,
|
||||
latitude: 51.5,
|
||||
},
|
||||
result: {
|
||||
status: "finished",
|
||||
statusCode: 200,
|
||||
timings: { total: 80, dns: 5, tcp: 10, tls: 15, firstByte: 30, download: 20 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.pollForResults("meas-123");
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].location.continent).toBe("NA");
|
||||
expect(results[1].location.continent).toBe("EU");
|
||||
});
|
||||
});
|
||||
});
|
||||
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user