Compare commits

..
Author SHA1 Message Date
Alex Holliday dc7688bc13 initialize timeOfLastFailure 2026-04-13 10:58:58 -07:00
Alexander Holliday ff114b3ab2 Merge pull request #3494 from bluewave-labs/feat/integration-tests
feat: integration tests
2026-04-11 15:03:22 -07:00
Alex Holliday 233966b73d add heartbeat tests 2026-04-11 14:45:36 -07:00
Alex Holliday 45d4779b45 fix race condition in heartbeat job 2026-04-11 13:57:57 -07:00
Alexander Holliday d608b5ea24 Merge pull request #3492 from bluewave-labs/feat/service-tests
feat/service tests
2026-04-10 12:43:54 -07:00
Alex Holliday 1ad750e2ef fix ternary condiiton 2026-04-10 12:41:46 -07:00
Alex Holliday 4d1af5e2b4 fix TS errors, add missing tests 2026-04-10 12:33:08 -07:00
Alex Holliday b515d688f4 add geo check service tests 2026-04-10 11:43:02 -07:00
Alex Holliday ff92b307bb add diagnosticService tests 2026-04-10 11:39:26 -07:00
Alex Holliday e4ffe0e267 inviteSerice tests 2026-04-10 11:30:31 -07:00
Alex Holliday 2b121355c8 status page service tests 2026-04-10 11:25:54 -07:00
Alex Holliday e7db938baa import monitors bug fix 2026-04-10 11:15:28 -07:00
Alex Holliday 1b746d0f0a remvoe dead code from monitor service 2026-04-10 11:13:12 -07:00
Alex Holliday 6886fc75de monitorService tests 2026-04-10 11:07:48 -07:00
Alex Holliday 5c5f84a5b2 user service tests 2026-04-10 10:59:48 -07:00
Alex Holliday a12154523f settingsService tests 2026-04-10 10:50:37 -07:00
Alex Holliday 6c8adf8594 add tests for maintenancewindowsercie 2026-04-10 10:39:04 -07:00
Alex Holliday a0f73e4e8d add notificaiton provider tests 2026-04-09 11:05:21 -07:00
Alex Holliday 63efef65c7 add provider tests 2026-04-09 10:36:59 -07:00
Alex Holliday dca09daf35 add tests for globalPingService 2026-04-09 10:03:23 -07:00
Alex Holliday db833b441a add tests for emailService 2026-04-09 09:49:12 -07:00
Alex Holliday bb55f3b3eb add tests for notificaitonMessageBuilder 2026-04-09 09:41:17 -07:00
Alex Holliday 88e0660762 add tests for buffer service 2026-04-09 09:36:51 -07:00
Alex Holliday a8c172c77f add tests for network service 2026-04-09 09:27:38 -07:00
Alex Holliday b355da4aed Merge branch 'develop' into feat/server-testing 2026-04-09 09:15:55 -07:00
Alexander Holliday 77a4bf9c36 Merge pull request #3479 from ad2ien/fix/matrix-notif
Fix matrix notifications
2026-04-09 09:15:15 -07:00
Adrien Touzouli 6a0f4372c6 fix(notifications): fix matrix notifications
Respect Matrix spec, factorize api call and improve error log
2026-04-09 06:08:05 +02:00
Alex Holliday db378120be add queue tests 2026-04-08 12:03:26 -07:00
Alex Holliday ba5368f942 add notificationsService tests 2026-04-08 11:32:08 -07:00
Alex Holliday 7329306ccf add incident serivce tests 2026-04-08 10:43:47 -07:00
Alex Holliday 365987c287 relocate tests, add statusService tests 2026-04-08 10:31:24 -07:00
Gorkem Cetin 09d6147f75 Remove Translations section from README
Removed the Translations section from the README.
2026-04-08 01:26:16 -04:00
Gorkem Cetin 86ab1f2146 Add Coolify and Cloudzy 2026-04-07 18:34:28 -04:00
Alexander Holliday 934f85f505 Merge pull request #3460 from bluewave-labs/fix/server-test-suite-failures
fix: align server test suite with current source implementations
2026-04-07 11:11:51 -07:00
Alex Holliday 96beda5c47 update mock logger and tests 2026-04-07 11:10:24 -07:00
Alexander Holliday 504d6157e5 Merge pull request #3473 from bluewave-labs/fix/actions
fix: disable poeditor workflow
2026-04-07 09:58:41 -07:00
Alex Holliday 628bca3e29 disable poeditor workflow 2026-04-07 09:56:11 -07:00
Alexander Holliday 2c76484bad Merge pull request #3471 from bluewave-labs/feat/zh-tw
feat: add remaining zh-tw strings
2026-04-07 09:53:58 -07:00
Alex Holliday 22498756cd add remaining zh-tw strings 2026-04-07 09:46:07 -07:00
Alexander Holliday 7c61ca490c Merge pull request #3461 from bluewave-labs/fix/i18n-locale-migration
fix: restructure and translate all i18n locale files
2026-04-03 20:46:13 -07:00
gorkem-bwl 96f9360280 style: format migration script with prettier 2026-04-03 23:43:34 -04:00
gorkem-bwl eb4a92c84f fix: restructure and translate all i18n locale files
All 15 non-English locale files had a structural mismatch with en.json:
- en.json used nested keys (common.buttons.cancel)
- Other locales used flat keys (cancel, DeleteAccountButton)
- Result: 0.3% effective translation coverage at runtime

## Changes

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

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

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

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

Closes #3457
2026-04-03 21:56:14 -04:00
Alexander Holliday 42e82af9bb Merge pull request #3458 from bluewave-labs/feat/scheduler/1.9.1
feat: bump scheduler, consume scheduler events
2026-04-03 15:38:12 -07:00
Alex 7636d2d090 error -> warn on job fail, register listeners before starting scheduler 2026-04-03 15:36:37 -07:00
Alex Holliday 72728748f8 bump scheduler, consume scheduler events 2026-04-03 13:21:16 -07:00
113 changed files with 33601 additions and 16932 deletions

No files matched your search

+105 -105
View File
@@ -1,120 +1,120 @@
name: POEditor Translation Synchronization # name: POEditor Translation Synchronization
on: # on:
# For manual triggering # # For manual triggering
workflow_dispatch: # workflow_dispatch:
inputs: # inputs:
languages: # languages:
description: "Languages to synchronize (comma separated, e.g.: tr,en,es)" # description: "Languages to synchronize (comma separated, e.g.: tr,en,es)"
required: false # required: false
default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th" # default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th"
format: # format:
description: "Export format (key_value_json or json)" # description: "Export format (key_value_json or json)"
required: false # required: false
default: "key_value_json" # default: "key_value_json"
permissions: # permissions:
contents: write # contents: write
pull-requests: write # pull-requests: write
jobs: # jobs:
sync-translations: # sync-translations:
runs-on: ubuntu-latest # runs-on: ubuntu-latest
permissions: # permissions:
contents: write # contents: write
issues: write # issues: write
pull-requests: write # pull-requests: write
steps: # steps:
- name: Checkout code # - name: Checkout code
uses: actions/checkout@v3 # uses: actions/checkout@v3
with: # with:
token: ${{ secrets.GITHUB_TOKEN }} # token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 # fetch-depth: 0
- name: Setup Node.js # - name: Setup Node.js
uses: actions/setup-node@v3 # uses: actions/setup-node@v3
with: # with:
node-version: "22" # node-version: "22"
- name: Create package.json for scripts # - name: Create package.json for scripts
run: | # run: |
mkdir -p .github/scripts # mkdir -p .github/scripts
cat > .github/scripts/package.json << EOF # cat > .github/scripts/package.json << EOF
{ # {
"name": "poeditor-scripts", # "name": "poeditor-scripts",
"version": "1.0.0", # "version": "1.0.0",
"private": true, # "private": true,
"type": "module", # "type": "module",
"dependencies": { # "dependencies": {
"axios": "^1.6.0", # "axios": "^1.6.0",
"fs-extra": "^11.1.1" # "fs-extra": "^11.1.1"
} # }
} # }
EOF # EOF
- name: Install dependencies # - name: Install dependencies
run: | # run: |
cd .github/scripts # cd .github/scripts
npm install # npm install
- name: Download translations from POEditor # - name: Download translations from POEditor
env: # env:
POEDITOR_API: ${{ secrets.POEDITOR_API }} # POEDITOR_API: ${{ secrets.POEDITOR_API }}
POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }} # POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }} # LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }}
EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }} # EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }}
run: | # run: |
mkdir -p temp # mkdir -p temp
node .github/scripts/download-translations.js # node .github/scripts/download-translations.js
- name: Verify translation files # - name: Verify translation files
run: | # run: |
echo "Verifying translation files..." # echo "Verifying translation files..."
for file in temp/*.json; do # for file in temp/*.json; do
echo "Checking $file" # echo "Checking $file"
if [ ! -s "$file" ]; then # if [ ! -s "$file" ]; then
echo "Error: $file is empty or does not exist" # echo "Error: $file is empty or does not exist"
exit 1 # exit 1
fi # fi
# Validate JSON format # # Validate JSON format
cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; } # cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; }
done # done
echo "All translation files are valid" # echo "All translation files are valid"
- name: Copy translations to project # - name: Copy translations to project
run: | # run: |
mkdir -p client/src/locales # mkdir -p client/src/locales
cp -r temp/* client/src/locales/ # cp -r temp/* client/src/locales/
echo "Translation files copied to client/src/locales/" # echo "Translation files copied to client/src/locales/"
- name: Get current date # - name: Get current date
id: date # id: date
run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT # run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
- name: Format client code # - name: Format client code
run: | # run: |
cd client # cd client
npm ci # npm ci
npm run format # npm run format
- name: Create Pull Request # - name: Create Pull Request
uses: peter-evans/create-pull-request@v7 # uses: peter-evans/create-pull-request@v7
with: # with:
token: ${{ secrets.GITHUB_TOKEN }} # token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "feat: translations updated from POEditor" # commit-message: "feat: translations updated from POEditor"
title: "🌐 Updated Translations from POEditor" # title: "🌐 Updated Translations from POEditor"
body: | # body: |
This PR contains the latest translations from POEditor. # This PR contains the latest translations from POEditor.
📅 Update Date: ${{ steps.date.outputs.date }} # 📅 Update Date: ${{ steps.date.outputs.date }}
🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }} # 🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }}
⚠️ Please review the translations and approve the PR if everything looks correct. # ⚠️ Please review the translations and approve the PR if everything looks correct.
branch: translation-update-${{ github.run_number }} # branch: translation-update-${{ github.run_number }}
delete-branch: true # delete-branch: true
base: develop # base: develop
add-paths: | # add-paths: |
client/src/locales/*.json # client/src/locales/*.json
committer: GitHub Action <github-actions[bot]@users.noreply.github.com> # committer: GitHub Action <github-actions[bot]@users.noreply.github.com>
author: GitHub Action <github-actions[bot]@users.noreply.github.com> # author: GitHub Action <github-actions[bot]@users.noreply.github.com>
+1 -7
View File
@@ -29,7 +29,6 @@ Checkmate has been stress-tested with 1000+ active monitors without any particul
- [📦 Demo](#demo) - [📦 Demo](#demo)
- [🔗 User's guide](#users-guide) - [🔗 User's guide](#users-guide)
- [🛠️ Installation](#installation) - [🛠️ Installation](#installation)
- [🏁 Translations](#translations)
- [🚀 Performance](#performance) - [🚀 Performance](#performance)
- [💚 Questions & Ideas](#questions--ideas) - [💚 Questions & Ideas](#questions--ideas)
- [🧩 Features](#features) - [🧩 Features](#features)
@@ -56,7 +55,7 @@ Usage instructions can be found [here](https://checkmate.so/docs).
See installation instructions in [Checkmate documentation portal](https://checkmate.so/docs). See installation instructions in [Checkmate documentation portal](https://checkmate.so/docs).
Alternatively, you can also use [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions. Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
### Using a Custom CA ### Using a Custom CA
@@ -64,11 +63,6 @@ If you need to monitor internal HTTPS endpoints with certificates from private C
For more documentation, see the [docs directory](./docs/). 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 ## 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: Thanks to extensive optimizations, Checkmate operates with an exceptionally small memory footprint, requiring minimal memory and CPU resources. Here’s the memory usage of a Node.js instance running on a server that monitors 323 servers every minute:
+258
View File
@@ -0,0 +1,258 @@
/**
* Locale Migration Script
*
* Restructures all non-English locale files to match en.json's nested key structure.
* Recovers translations via safe key-matching strategies, uses English placeholders
* for unmatched keys.
*
* Usage: npx tsx client/scripts/migrate-locales.ts
*
* SAFETY: en.json is NEVER modified. Originals are backed up before overwriting.
*/
import fs from "fs";
import path from "path";
const LOCALES_DIR = path.resolve(import.meta.dirname, "../src/locales");
const BACKUP_DIR = path.join(LOCALES_DIR, "backup");
const REPORT_PATH = path.join(LOCALES_DIR, "migration-report.json");
// ── Helpers ──────────────────────────────────────────────────────────────────
/** Flatten a nested object into { "a.b.c": value } pairs */
function flattenKeys(obj: Record<string, unknown>, prefix = ""): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(obj)) {
const fullPath = prefix ? `${prefix}.${key}` : key;
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
Object.assign(result, flattenKeys(value as Record<string, unknown>, fullPath));
} else {
result[fullPath] = String(value);
}
}
return result;
}
/** Set a value at a dot-separated path in a nested object */
function setNestedValue(
obj: Record<string, unknown>,
keyPath: string,
value: string
): void {
const parts = keyPath.split(".");
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!(part in current) || typeof current[part] !== "object") {
current[part] = {};
}
current = current[part] as Record<string, unknown>;
}
current[parts[parts.length - 1]] = value;
}
// ── Build mapping indexes from en.json ───────────────────────────────────────
function buildIndexes(enFlat: Record<string, string>) {
const enKeys = Object.keys(enFlat);
// last segment -> en key paths
const lastSegIndex: Record<string, string[]> = {};
// last 2 segments -> en key paths
const suffixIndex: Record<string, string[]> = {};
for (const keyPath of enKeys) {
const parts = keyPath.split(".");
const lastSeg = parts[parts.length - 1].toLowerCase();
if (!lastSegIndex[lastSeg]) lastSegIndex[lastSeg] = [];
lastSegIndex[lastSeg].push(keyPath);
if (parts.length >= 2) {
const suffix = parts.slice(-2).join(".").toLowerCase();
if (!suffixIndex[suffix]) suffixIndex[suffix] = [];
suffixIndex[suffix].push(keyPath);
}
}
return { lastSegIndex, suffixIndex };
}
// ── Mapping logic ────────────────────────────────────────────────────────────
type MatchStrategy = "exact" | "suffix-2" | "unique-name" | "english-placeholder";
interface KeyMatch {
enKey: string;
value: string;
strategy: MatchStrategy;
}
function buildMigratedLocale(
enFlat: Record<string, string>,
langFlat: Record<string, string>,
indexes: ReturnType<typeof buildIndexes>
): { matches: KeyMatch[]; result: Record<string, unknown> } {
const { lastSegIndex, suffixIndex } = indexes;
const matches: KeyMatch[] = [];
const result: Record<string, unknown> = {};
// Build reverse lookup: for each orphan key in lang, index by last segment and suffix
const langByLastSeg: Record<string, { key: string; value: string }[]> = {};
const langBySuffix: Record<string, { key: string; value: string }[]> = {};
for (const [langKey, langValue] of Object.entries(langFlat)) {
const parts = langKey.split(".");
const lastSeg = parts[parts.length - 1].toLowerCase();
if (!langByLastSeg[lastSeg]) langByLastSeg[lastSeg] = [];
langByLastSeg[lastSeg].push({ key: langKey, value: langValue });
if (parts.length >= 2) {
const suffix = parts.slice(-2).join(".").toLowerCase();
if (!langBySuffix[suffix]) langBySuffix[suffix] = [];
langBySuffix[suffix].push({ key: langKey, value: langValue });
}
}
for (const enKey of Object.keys(enFlat)) {
const enValue = enFlat[enKey];
const parts = enKey.split(".");
const lastSeg = parts[parts.length - 1].toLowerCase();
const suffix = parts.length >= 2 ? parts.slice(-2).join(".").toLowerCase() : null;
// Strategy 1: Exact path match
if (langFlat[enKey] !== undefined) {
const value = langFlat[enKey];
setNestedValue(result, enKey, value);
matches.push({ enKey, value, strategy: "exact" });
continue;
}
// Strategy 2: 2-segment suffix match (lang key suffix matches en key suffix, both unique)
if (suffix) {
const enCandidates = suffixIndex[suffix];
const langCandidates = langBySuffix[suffix];
if (
enCandidates &&
enCandidates.length === 1 &&
langCandidates &&
langCandidates.length === 1
) {
const value = langCandidates[0].value;
setNestedValue(result, enKey, value);
matches.push({ enKey, value, strategy: "suffix-2" });
continue;
}
}
// Strategy 3: Unique last-segment match
const enCandidates = lastSegIndex[lastSeg];
const langCandidates = langByLastSeg[lastSeg];
if (
enCandidates &&
enCandidates.length === 1 &&
langCandidates &&
langCandidates.length === 1
) {
const value = langCandidates[0].value;
setNestedValue(result, enKey, value);
matches.push({ enKey, value, strategy: "unique-name" });
continue;
}
// No match — use English placeholder
setNestedValue(result, enKey, enValue);
matches.push({ enKey, value: enValue, strategy: "english-placeholder" });
}
return { matches, result };
}
// ── Main ─────────────────────────────────────────────────────────────────────
function main() {
// Load en.json
const enPath = path.join(LOCALES_DIR, "en.json");
const en = JSON.parse(fs.readFileSync(enPath, "utf8"));
const enFlat = flattenKeys(en);
const enKeyCount = Object.keys(enFlat).length;
const indexes = buildIndexes(enFlat);
console.log(`English: ${enKeyCount} keys (source of truth — NOT modified)\n`);
// Find all non-English locale files
const localeFiles = fs
.readdirSync(LOCALES_DIR)
.filter(
(f: string) =>
f.endsWith(".json") && f !== "en.json" && f !== "migration-report.json"
);
if (localeFiles.length === 0) {
console.log("No locale files to migrate.");
return;
}
// Create backup directory
if (!fs.existsSync(BACKUP_DIR)) {
fs.mkdirSync(BACKUP_DIR, { recursive: true });
}
const report: Record<
string,
{
total: number;
exact: number;
suffix: number;
uniqueName: number;
placeholder: number;
}
> = {};
for (const file of localeFiles) {
const lang = file.replace(".json", "");
const filePath = path.join(LOCALES_DIR, file);
// Backup original
const backupPath = path.join(BACKUP_DIR, file);
fs.copyFileSync(filePath, backupPath);
// Load and flatten
const langData = JSON.parse(fs.readFileSync(filePath, "utf8"));
const langFlat = flattenKeys(langData);
// Migrate
const { matches, result } = buildMigratedLocale(enFlat, langFlat, indexes);
// Write migrated file
fs.writeFileSync(filePath, JSON.stringify(result, null, "\t") + "\n", "utf8");
// Compute stats
const stats = {
total: enKeyCount,
exact: matches.filter((m) => m.strategy === "exact").length,
suffix: matches.filter((m) => m.strategy === "suffix-2").length,
uniqueName: matches.filter((m) => m.strategy === "unique-name").length,
placeholder: matches.filter((m) => m.strategy === "english-placeholder").length,
};
report[lang] = stats;
const translated = stats.exact + stats.suffix + stats.uniqueName;
const pct = ((translated / enKeyCount) * 100).toFixed(1);
console.log(
`${lang.padEnd(8)} ${String(translated).padStart(4)} translated (${pct}%) | ` +
`exact: ${stats.exact} suffix: ${stats.suffix} name: ${stats.uniqueName} ` +
`placeholder: ${stats.placeholder}`
);
}
// Write report
fs.writeFileSync(REPORT_PATH, JSON.stringify(report, null, "\t") + "\n", "utf8");
const totalLangs = localeFiles.length;
console.log(
`\nMigrated ${totalLangs} locale files. Originals backed up to locales/backup/`
);
console.log(`Report written to ${REPORT_PATH}`);
}
main();
-1
View File
@@ -40,7 +40,6 @@ export const useMonitorForm = ({
matchMethod: data?.matchMethod || "", matchMethod: data?.matchMethod || "",
expectedValue: data?.expectedValue || "", expectedValue: data?.expectedValue || "",
jsonPath: data?.jsonPath || "", jsonPath: data?.jsonPath || "",
customUserAgent: data?.customUserAgent || "",
}; };
break; break;
case "ping": case "ping":
-1
View File
@@ -40,7 +40,6 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
: 80, : 80,
}, },
checkTTL: data?.checkTTL ?? 30, checkTTL: data?.checkTTL ?? 30,
defaultUserAgent: data?.defaultUserAgent || "",
pagespeedApiKey: "", pagespeedApiKey: "",
systemEmailPassword: "", systemEmailPassword: "",
}; };
-34
View File
@@ -795,40 +795,6 @@ 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" && ( {watchedType === "http" && (
<ConfigBox <ConfigBox
title={t("pages.createMonitor.form.advanced.title")} title={t("pages.createMonitor.form.advanced.title")}
-33
View File
@@ -425,39 +425,6 @@ 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 */} {/* Clear All Stats */}
{isAdmin && ( {isAdmin && (
<ConfigBox <ConfigBox
-1
View File
@@ -73,7 +73,6 @@ export interface Monitor {
gameId?: string; gameId?: string;
grpcServiceName?: string; grpcServiceName?: string;
group: string | null; group: string | null;
customUserAgent?: string | null;
geoCheckEnabled?: boolean; geoCheckEnabled?: boolean;
geoCheckLocations?: GeoContinent[]; geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number; geoCheckInterval?: number;
-1
View File
@@ -21,7 +21,6 @@ export interface Settings {
systemEmailRequireTLS: boolean; systemEmailRequireTLS: boolean;
systemEmailRejectUnauthorized: boolean; systemEmailRejectUnauthorized: boolean;
showURL: boolean; showURL: boolean;
defaultUserAgent?: string;
singleton: boolean; singleton: boolean;
globalThresholds?: SettingsThresholds; globalThresholds?: SettingsThresholds;
createdAt: string; createdAt: string;
-10
View File
@@ -38,16 +38,6 @@ const httpSchema = baseSchema.extend({
matchMethod: z.enum(["equal", "include", "regex", ""]).optional(), matchMethod: z.enum(["equal", "include", "regex", ""]).optional(),
expectedValue: z.string().optional(), expectedValue: z.string().optional(),
jsonPath: 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 // Ping monitor schema
-9
View File
@@ -12,15 +12,6 @@ export const settingsSchema = z.object({
systemEmailSecure: z.boolean().optional(), systemEmailSecure: z.boolean().optional(),
systemEmailPool: z.boolean().optional(), systemEmailPool: z.boolean().optional(),
showURL: 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 checkTTL: z
.number() .number()
.int() .int()
+1281 -1084
View File
File diff suppressed because it is too large. Load diff
+1281 -1084
View File
File diff suppressed because it is too large. Load diff
+1271 -1106
View File
File diff suppressed because it is too large. Load diff
-16
View File
@@ -527,14 +527,6 @@
}, },
"title": "TLS/SSL settings" "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": { "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.", "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": { "option": {
@@ -1059,14 +1051,6 @@
} }
} }
}, },
"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": { "stats": {
"title": "Monitor history", "title": "Monitor history",
"description": "Clear all monitoring history and statistics for your team. This action is irreversible.", "description": "Clear all monitoring history and statistics for your team. This action is irreversible.",
+1281 -1084
View File
File diff suppressed because it is too large. Load diff
+1281 -1084
View File
File diff suppressed because it is too large. Load diff
+1271 -1106
View File
File diff suppressed because it is too large. Load diff
+1271 -1085
View File
File diff suppressed because it is too large. Load diff
+1271 -1106
View File
File diff suppressed because it is too large. Load diff
+1271 -1106
View File
File diff suppressed because it is too large. Load diff
+1271 -1106
View File
File diff suppressed because it is too large. Load diff
+1281 -1084
View File
File diff suppressed because it is too large. Load diff
+1268 -801
View File
File diff suppressed because it is too large. Load diff
+1268 -801
View File
File diff suppressed because it is too large. Load diff
+1271 -1098
View File
File diff suppressed because it is too large. Load diff
+1299 -1088
View File
File diff suppressed because it is too large. Load diff
+1 -2
View File
@@ -9,8 +9,7 @@ const config: Config = {
}, },
moduleNameMapper: { moduleNameMapper: {
"^@/validation/(.*)\\.js$": "<rootDir>/src/validation/$1.js", "^@/validation/(.*)\\.js$": "<rootDir>/src/validation/$1.js",
"^@/utils/(AppError)\\.js$": "<rootDir>/src/utils/$1.ts", "^@/utils/(.*)\\.js$": "<rootDir>/src/utils/$1.ts",
"^@/utils/(.*)\\.js$": "<rootDir>/src/utils/$1.js",
"^@/(.*)\\.ts$": "<rootDir>/src/$1.ts", "^@/(.*)\\.ts$": "<rootDir>/src/$1.ts",
"^@/(.*)\\.js$": "<rootDir>/src/$1.ts", "^@/(.*)\\.js$": "<rootDir>/src/$1.ts",
"^@/(.*)$": "<rootDir>/src/$1", "^@/(.*)$": "<rootDir>/src/$1",
+99 -200
View File
@@ -41,7 +41,7 @@
"ping": "0.4.4", "ping": "0.4.4",
"sharp": "0.33.5", "sharp": "0.33.5",
"ssl-checker": "2.0.10", "ssl-checker": "2.0.10",
"super-simple-scheduler": "1.4.5", "super-simple-scheduler": "1.9.1",
"swagger-ui-express": "5.0.1", "swagger-ui-express": "5.0.1",
"winston": "^3.13.0", "winston": "^3.13.0",
"ws": "^8.19.0", "ws": "^8.19.0",
@@ -69,7 +69,6 @@
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1", "@typescript-eslint/parser": "^8.56.1",
"c8": "10.1.3",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"eslint-plugin-mocha": "^10.5.0", "eslint-plugin-mocha": "^10.5.0",
"esm": "3.2.25", "esm": "3.2.25",
@@ -801,40 +800,20 @@
} }
}, },
"node_modules/@aws-sdk/xml-builder": { "node_modules/@aws-sdk/xml-builder": {
"version": "3.972.10", "version": "3.972.16",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.10.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.16.tgz",
"integrity": "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA==", "integrity": "sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@smithy/types": "^4.13.0", "@smithy/types": "^4.13.1",
"fast-xml-parser": "5.4.1", "fast-xml-parser": "5.5.8",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "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": { "node_modules/@aws/lambda-invoke-store": {
"version": "0.2.3", "version": "0.2.3",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz",
@@ -1378,16 +1357,6 @@
"integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==",
"license": "Apache-2.0" "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": { "node_modules/@colors/colors": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
@@ -3855,9 +3824,9 @@
} }
}, },
"node_modules/@smithy/types": { "node_modules/@smithy/types": {
"version": "4.13.0", "version": "4.13.1",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz", "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz",
"integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==", "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -4962,9 +4931,9 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.4", "version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -5905,9 +5874,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -6073,40 +6042,6 @@
"node": ">= 0.8" "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": { "node_modules/cacheable-lookup": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
@@ -8152,9 +8087,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-xml-builder": { "node_modules/fast-xml-builder": {
"version": "1.0.0", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
"integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -8162,7 +8097,31 @@
"url": "https://github.com/sponsors/NaturalIntelligence" "url": "https://github.com/sponsors/NaturalIntelligence"
} }
], ],
"license": "MIT" "license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.1.3"
}
},
"node_modules/fast-xml-parser": {
"version": "5.5.8",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz",
"integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.1.4",
"path-expression-matcher": "^1.2.0",
"strnum": "^2.2.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
}, },
"node_modules/fastq": { "node_modules/fastq": {
"version": "1.20.1", "version": "1.20.1",
@@ -8303,9 +8262,9 @@
} }
}, },
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.3.3", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -8752,9 +8711,9 @@
} }
}, },
"node_modules/glob/node_modules/brace-expansion": { "node_modules/glob/node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
@@ -8854,9 +8813,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/handlebars": { "node_modules/handlebars": {
"version": "4.7.8", "version": "4.7.9",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"minimist": "^1.2.5", "minimist": "^1.2.5",
@@ -10156,9 +10115,9 @@
} }
}, },
"node_modules/jest-util/node_modules/picomatch": { "node_modules/jest-util/node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -10693,9 +10652,9 @@
} }
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.23", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.camelcase": { "node_modules/lodash.camelcase": {
@@ -11237,9 +11196,9 @@
} }
}, },
"node_modules/mjml-cli/node_modules/brace-expansion": { "node_modules/mjml-cli/node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
@@ -12333,6 +12292,22 @@
"node": ">=8" "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": { "node_modules/path-is-absolute": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -12369,9 +12344,9 @@
} }
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "0.1.12", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-type": { "node_modules/path-type": {
@@ -12481,9 +12456,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.1", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
@@ -14509,9 +14484,9 @@
} }
}, },
"node_modules/strnum": { "node_modules/strnum": {
"version": "2.2.0", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
"integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -14538,25 +14513,12 @@
} }
}, },
"node_modules/super-simple-scheduler": { "node_modules/super-simple-scheduler": {
"version": "1.4.5", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.4.5.tgz", "resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.9.1.tgz",
"integrity": "sha512-xHR3a+pLywY7HtwjWj/+OaANdEwAieQhckC/i0s6OoHtVJQ4+VLO1VfkKUbxp+oSROGXcaZet8PTGONVdKNUUw==", "integrity": "sha512-JSiEQLk7MsnlSkJ87uoyFKIseG2N27ikB0FSxlh25nnQHWC7R5Lrd0VcoLpwSBanyLzlAslhK4W2rQ0mCU8jCQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "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": { "node_modules/super-simple-scheduler/node_modules/uuid": {
@@ -14572,28 +14534,6 @@
"uuid": "dist/esm/bin/uuid" "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": { "node_modules/supports-color": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -14681,47 +14621,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/test-exclude": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
"integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^10.4.1",
"minimatch": "^9.0.4"
},
"engines": {
"node": ">=18"
}
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/test-exclude/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/text-hex": { "node_modules/text-hex": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
@@ -14764,9 +14663,9 @@
} }
}, },
"node_modules/tinyglobby/node_modules/picomatch": { "node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
@@ -15155,9 +15054,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "6.23.0", "version": "6.24.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.17" "node": ">=18.17"
@@ -15825,9 +15724,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.8.2", "version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
+5 -3
View File
@@ -5,7 +5,10 @@
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "NODE_OPTIONS=--experimental-vm-modules c8 jest --runInBand", "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",
"dev": "nodemon --exec tsx src/index.js", "dev": "nodemon --exec tsx src/index.js",
"start": "node --watch ./dist/index.js", "start": "node --watch ./dist/index.js",
"build": "tsc && tsc-alias && cp -r src/templates dist/templates", "build": "tsc && tsc-alias && cp -r src/templates dist/templates",
@@ -56,7 +59,7 @@
"ping": "0.4.4", "ping": "0.4.4",
"sharp": "0.33.5", "sharp": "0.33.5",
"ssl-checker": "2.0.10", "ssl-checker": "2.0.10",
"super-simple-scheduler": "1.4.5", "super-simple-scheduler": "1.9.1",
"swagger-ui-express": "5.0.1", "swagger-ui-express": "5.0.1",
"winston": "^3.13.0", "winston": "^3.13.0",
"ws": "^8.19.0", "ws": "^8.19.0",
@@ -84,7 +87,6 @@
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1", "@typescript-eslint/parser": "^8.56.1",
"c8": "10.1.3",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"eslint-plugin-mocha": "^10.5.0", "eslint-plugin-mocha": "^10.5.0",
"esm": "3.2.25", "esm": "3.2.25",
-1
View File
@@ -364,7 +364,6 @@ export const initializeServices = async ({
}); });
const monitorService = new MonitorService({ const monitorService = new MonitorService({
jobQueue: superSimpleQueue, jobQueue: superSimpleQueue,
emailService,
logger, logger,
games, games,
monitorsRepository, monitorsRepository,
@@ -1,21 +0,0 @@
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,7 +21,6 @@ import { createStatusPages, dropStatusPages } from "./0018_create_status_pages.j
import { createAppSettings, dropAppSettings } from "./0019_create_app_settings.js"; import { createAppSettings, dropAppSettings } from "./0019_create_app_settings.js";
import { createContinuousAggregates, dropContinuousAggregates } from "./0020_create_continuous_aggregates.js"; import { createContinuousAggregates, dropContinuousAggregates } from "./0020_create_continuous_aggregates.js";
import { createRetentionCompression, dropRetentionCompression } from "./0021_create_retention_compression.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"; const SERVICE_NAME = "TimescaleDB Migrations";
@@ -53,7 +52,6 @@ const migrations: MigrationEntry[] = [
{ name: "0019_create_app_settings", up: createAppSettings, down: dropAppSettings }, { name: "0019_create_app_settings", up: createAppSettings, down: dropAppSettings },
{ name: "0020_create_continuous_aggregates", up: createContinuousAggregates, down: dropContinuousAggregates }, { name: "0020_create_continuous_aggregates", up: createContinuousAggregates, down: dropContinuousAggregates },
{ name: "0021_create_retention_compression", up: createRetentionCompression, down: dropRetentionCompression }, { name: "0021_create_retention_compression", up: createRetentionCompression, down: dropRetentionCompression },
{ name: "0022_add_user_agent_fields", up: addUserAgentFields, down: dropUserAgentFields },
]; ];
const ensureMigrationsTable = async (pool: Pool) => { const ensureMigrationsTable = async (pool: Pool) => {
-1
View File
@@ -36,7 +36,6 @@ const AppSettingsSchema = new Schema<AppSettingsDocument>(
systemEmailRequireTLS: { type: Boolean, default: false }, systemEmailRequireTLS: { type: Boolean, default: false },
systemEmailRejectUnauthorized: { type: Boolean, default: true }, systemEmailRejectUnauthorized: { type: Boolean, default: true },
showURL: { type: Boolean, default: false }, showURL: { type: Boolean, default: false },
defaultUserAgent: { type: String },
singleton: { type: Boolean, required: true, unique: true, default: true }, singleton: { type: Boolean, required: true, unique: true, default: true },
version: { type: Number, default: 1 }, version: { type: Number, default: 1 },
globalThresholds: { type: thresholdsSchema }, globalThresholds: { type: thresholdsSchema },
-3
View File
@@ -339,9 +339,6 @@ const MonitorSchema = new Schema<MonitorDocument>(
return value && value.trim() ? value.trim() : null; return value && value.trim() ? value.trim() : null;
}, },
}, },
customUserAgent: {
type: String,
},
geoCheckEnabled: { geoCheckEnabled: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -387,7 +387,6 @@ class MongoMonitorsRepository implements IMonitorsRepository {
gameId: doc.gameId ?? undefined, gameId: doc.gameId ?? undefined,
grpcServiceName: doc.grpcServiceName ?? undefined, grpcServiceName: doc.grpcServiceName ?? undefined,
group: doc.group ?? null, group: doc.group ?? null,
customUserAgent: doc.customUserAgent ?? undefined,
recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)), recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)),
geoCheckEnabled: doc.geoCheckEnabled ?? false, geoCheckEnabled: doc.geoCheckEnabled ?? false,
geoCheckLocations: doc.geoCheckLocations ?? [], geoCheckLocations: doc.geoCheckLocations ?? [],
@@ -447,7 +446,6 @@ class MongoMonitorsRepository implements IMonitorsRepository {
gameId: doc.gameId ?? undefined, gameId: doc.gameId ?? undefined,
grpcServiceName: doc.grpcServiceName ?? undefined, grpcServiceName: doc.grpcServiceName ?? undefined,
group: doc.group ?? null, group: doc.group ?? null,
customUserAgent: doc.customUserAgent ?? undefined,
recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)), recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)),
geoCheckEnabled: doc.geoCheckEnabled ?? false, geoCheckEnabled: doc.geoCheckEnabled ?? false,
geoCheckLocations: doc.geoCheckLocations ?? [], geoCheckLocations: doc.geoCheckLocations ?? [],
@@ -37,7 +37,6 @@ interface MonitorRow {
game_id: string | null; game_id: string | null;
grpc_service_name: string | null; grpc_service_name: string | null;
monitor_group: string | null; monitor_group: string | null;
custom_user_agent: string | null;
geo_check_enabled: boolean; geo_check_enabled: boolean;
geo_check_locations: GeoContinent[] | null; geo_check_locations: GeoContinent[] | null;
geo_check_interval_ms: number; geo_check_interval_ms: number;
@@ -50,7 +49,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, 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, 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, disk_alert_threshold, disk_alert_counter, temp_alert_threshold, temp_alert_counter, selected_disks,
game_id, grpc_service_name, monitor_group, custom_user_agent, geo_check_enabled, geo_check_locations, geo_check_interval_ms, game_id, grpc_service_name, monitor_group, geo_check_enabled, geo_check_locations, geo_check_interval_ms,
created_at, updated_at`; created_at, updated_at`;
export class TimescaleMonitorsRepository implements IMonitorsRepository { export class TimescaleMonitorsRepository implements IMonitorsRepository {
@@ -63,8 +62,8 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository {
interval_ms, is_active, status_window, status_window_size, status_window_threshold, interval_ms, is_active, status_window, status_window_size, status_window_threshold,
cpu_alert_threshold, cpu_alert_counter, memory_alert_threshold, memory_alert_counter, 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, disk_alert_threshold, disk_alert_counter, temp_alert_threshold, temp_alert_counter, selected_disks,
game_id, grpc_service_name, monitor_group, custom_user_agent, geo_check_enabled, geo_check_locations, geo_check_interval_ms) 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,$35) 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)
RETURNING ${MONITOR_COLUMNS}`, RETURNING ${MONITOR_COLUMNS}`,
[ [
userId, userId,
@@ -98,7 +97,6 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository {
monitor.gameId ?? null, monitor.gameId ?? null,
monitor.grpcServiceName ?? null, monitor.grpcServiceName ?? null,
monitor.group ?? null, monitor.group ?? null,
monitor.customUserAgent ?? null,
monitor.geoCheckEnabled ?? false, monitor.geoCheckEnabled ?? false,
monitor.geoCheckLocations ?? [], monitor.geoCheckLocations ?? [],
monitor.geoCheckInterval ?? 300000, monitor.geoCheckInterval ?? 300000,
@@ -597,7 +595,6 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository {
["gameId", "game_id"], ["gameId", "game_id"],
["grpcServiceName", "grpc_service_name"], ["grpcServiceName", "grpc_service_name"],
["group", "monitor_group"], ["group", "monitor_group"],
["customUserAgent", "custom_user_agent"],
["geoCheckEnabled", "geo_check_enabled"], ["geoCheckEnabled", "geo_check_enabled"],
["geoCheckLocations", "geo_check_locations"], ["geoCheckLocations", "geo_check_locations"],
["geoCheckInterval", "geo_check_interval_ms"], ["geoCheckInterval", "geo_check_interval_ms"],
@@ -1037,7 +1034,6 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository {
gameId: row.game_id ?? undefined, gameId: row.game_id ?? undefined,
grpcServiceName: row.grpc_service_name ?? undefined, grpcServiceName: row.grpc_service_name ?? undefined,
group: row.monitor_group, group: row.monitor_group,
customUserAgent: row.custom_user_agent ?? undefined,
geoCheckEnabled: row.geo_check_enabled, geoCheckEnabled: row.geo_check_enabled,
geoCheckLocations: row.geo_check_locations ?? [], geoCheckLocations: row.geo_check_locations ?? [],
geoCheckInterval: row.geo_check_interval_ms, geoCheckInterval: row.geo_check_interval_ms,
@@ -38,7 +38,6 @@ class MongoSettingsRepository implements ISettingsRepository {
systemEmailRequireTLS: doc.systemEmailRequireTLS ?? false, systemEmailRequireTLS: doc.systemEmailRequireTLS ?? false,
systemEmailRejectUnauthorized: doc.systemEmailRejectUnauthorized ?? true, systemEmailRejectUnauthorized: doc.systemEmailRejectUnauthorized ?? true,
showURL: doc.showURL ?? false, showURL: doc.showURL ?? false,
defaultUserAgent: doc.defaultUserAgent ?? undefined,
singleton: doc.singleton, singleton: doc.singleton,
version: doc.version ?? 1, version: doc.version ?? 1,
globalThresholds: doc.globalThresholds ?? undefined, globalThresholds: doc.globalThresholds ?? undefined,
@@ -21,7 +21,6 @@ interface SettingsRow {
system_email_require_tls: boolean | null; system_email_require_tls: boolean | null;
system_email_reject_unauthorized: boolean | null; system_email_reject_unauthorized: boolean | null;
show_url: boolean | null; show_url: boolean | null;
default_user_agent: string | null;
version: string | null; version: string | null;
threshold_cpu_usage: number | null; threshold_cpu_usage: number | null;
threshold_memory_usage: number | null; threshold_memory_usage: number | null;
@@ -35,7 +34,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_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_connection_host, system_email_tls_servername, system_email_secure, system_email_pool,
system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized, system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized,
show_url, default_user_agent, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature, show_url, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature,
created_at, updated_at`; created_at, updated_at`;
export class TimescaleSettingsRepository implements ISettingsRepository { export class TimescaleSettingsRepository implements ISettingsRepository {
@@ -47,8 +46,8 @@ export class TimescaleSettingsRepository implements ISettingsRepository {
system_email_host, system_email_port, system_email_address, system_email_password, system_email_user, 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_connection_host, system_email_tls_servername, system_email_secure, system_email_pool,
system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized, system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized,
show_url, default_user_agent, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature) 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,$23) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)
RETURNING ${COLUMNS}`, RETURNING ${COLUMNS}`,
[ [
settings.checkTTL ?? 30, settings.checkTTL ?? 30,
@@ -68,7 +67,6 @@ export class TimescaleSettingsRepository implements ISettingsRepository {
settings.systemEmailRequireTLS ?? false, settings.systemEmailRequireTLS ?? false,
settings.systemEmailRejectUnauthorized ?? true, settings.systemEmailRejectUnauthorized ?? true,
settings.showURL ?? false, settings.showURL ?? false,
settings.defaultUserAgent ?? null,
settings.version ?? 1, settings.version ?? 1,
settings.globalThresholds?.cpu ?? null, settings.globalThresholds?.cpu ?? null,
settings.globalThresholds?.memory ?? null, settings.globalThresholds?.memory ?? null,
@@ -115,7 +113,6 @@ export class TimescaleSettingsRepository implements ISettingsRepository {
["systemEmailRequireTLS", "system_email_require_tls"], ["systemEmailRequireTLS", "system_email_require_tls"],
["systemEmailRejectUnauthorized", "system_email_reject_unauthorized"], ["systemEmailRejectUnauthorized", "system_email_reject_unauthorized"],
["showURL", "show_url"], ["showURL", "show_url"],
["defaultUserAgent", "default_user_agent"],
["version", "version"], ["version", "version"],
]; ];
@@ -200,7 +197,6 @@ export class TimescaleSettingsRepository implements ISettingsRepository {
systemEmailRequireTLS: row.system_email_require_tls ?? false, systemEmailRequireTLS: row.system_email_require_tls ?? false,
systemEmailRejectUnauthorized: row.system_email_reject_unauthorized ?? true, systemEmailRejectUnauthorized: row.system_email_reject_unauthorized ?? true,
showURL: row.show_url ?? false, showURL: row.show_url ?? false,
defaultUserAgent: row.default_user_agent ?? undefined,
singleton: true, singleton: true,
version: Number(row.version ?? 1), version: Number(row.version ?? 1),
globalThresholds: globalThresholds:
+10 -10
View File
@@ -107,16 +107,16 @@ export class CheckService implements ICheckService {
numericUnit: audit.numericUnit, numericUnit: audit.numericUnit,
}; };
}; };
check.accessibility = (categories?.accessibility?.score || 0) * 100; check.accessibility = (categories.accessibility?.score || 0) * 100;
check.bestPractices = (categories?.["best-practices"]?.score || 0) * 100; check.bestPractices = (categories["best-practices"]?.score || 0) * 100;
check.seo = (categories?.seo?.score || 0) * 100; check.seo = (categories.seo?.score || 0) * 100;
check.performance = (categories?.performance?.score || 0) * 100; check.performance = (categories.performance?.score || 0) * 100;
check.audits = { check.audits = {
cls: mapAudit(audits?.["cumulative-layout-shift"]), cls: mapAudit(audits["cumulative-layout-shift"]),
si: mapAudit(audits?.["speed-index"]), si: mapAudit(audits["speed-index"]),
fcp: mapAudit(audits?.["first-contentful-paint"]), fcp: mapAudit(audits["first-contentful-paint"]),
lcp: mapAudit(audits?.["largest-contentful-paint"]), lcp: mapAudit(audits["largest-contentful-paint"]),
tbt: mapAudit(audits?.["total-blocking-time"]), tbt: mapAudit(audits["total-blocking-time"]),
}; };
} }
@@ -124,7 +124,7 @@ export class CheckService implements ICheckService {
const hardwarePayload = payload as HardwareStatusPayload | undefined; const hardwarePayload = payload as HardwareStatusPayload | undefined;
const { cpu, memory, disk, host, net } = hardwarePayload?.data ?? {}; const { cpu, memory, disk, host, net } = hardwarePayload?.data ?? {};
const errorsSource = Array.isArray(hardwarePayload?.errors) const errorsSource = Array.isArray(hardwarePayload?.errors)
? hardwarePayload?.errors ? hardwarePayload.errors
: (hardwarePayload?.errors as { errors?: CheckErrorInfo[] } | undefined)?.errors; : (hardwarePayload?.errors as { errors?: CheckErrorInfo[] } | undefined)?.errors;
check.cpu = cpu; check.cpu = cpu;
check.memory = memory; check.memory = memory;
@@ -95,17 +95,14 @@ export class IncidentService implements IIncidentService {
} }
} }
if (decision.shouldResolveIncident) { if (!decision.shouldResolveIncident || !activeIncident) {
if (!activeIncident) { return null;
return null;
}
activeIncident.status = false;
activeIncident.endTime = Date.now().toString();
activeIncident.resolutionType = "automatic";
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
} }
return null; activeIncident.status = false;
activeIncident.endTime = Date.now().toString();
activeIncident.resolutionType = "automatic";
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
}; };
private buildThresholdBreachMessage(monitor: Monitor, monitorStatusResponse?: MonitorStatusResponse): string { private buildThresholdBreachMessage(monitor: Monitor, monitorStatusResponse?: MonitorStatusResponse): string {
+10 -31
View File
@@ -23,7 +23,6 @@ import demoMonitorsData from "@/utils/demoMonitors.json" with { type: "json" };
import { AppError } from "@/utils/AppError.js"; import { AppError } from "@/utils/AppError.js";
import type { ImportedMonitor } from "@/validation/monitorValidation.js"; import type { ImportedMonitor } from "@/validation/monitorValidation.js";
import { ISuperSimpleQueue } from "../infrastructure/SuperSimpleQueue/SuperSimpleQueue.js"; import { ISuperSimpleQueue } from "../infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
import { IEmailService } from "../infrastructure/emailService.js";
import { ILogger } from "@/utils/logger.js"; import { ILogger } from "@/utils/logger.js";
const SERVICE_NAME = "MonitorService"; const SERVICE_NAME = "MonitorService";
@@ -91,7 +90,6 @@ export class MonitorService implements IMonitorService {
static SERVICE_NAME = SERVICE_NAME; static SERVICE_NAME = SERVICE_NAME;
private jobQueue: ISuperSimpleQueue; private jobQueue: ISuperSimpleQueue;
private emailService: IEmailService;
private logger: ILogger; private logger: ILogger;
private games: GamesMap; private games: GamesMap;
private monitorsRepository: IMonitorsRepository; private monitorsRepository: IMonitorsRepository;
@@ -103,7 +101,6 @@ export class MonitorService implements IMonitorService {
constructor({ constructor({
jobQueue, jobQueue,
emailService,
logger, logger,
games, games,
monitorsRepository, monitorsRepository,
@@ -114,7 +111,6 @@ export class MonitorService implements IMonitorService {
incidentsRepository, incidentsRepository,
}: { }: {
jobQueue: ISuperSimpleQueue; jobQueue: ISuperSimpleQueue;
emailService: IEmailService;
logger: ILogger; logger: ILogger;
games: GamesMap; games: GamesMap;
monitorsRepository: IMonitorsRepository; monitorsRepository: IMonitorsRepository;
@@ -125,7 +121,6 @@ export class MonitorService implements IMonitorService {
incidentsRepository: IIncidentsRepository; incidentsRepository: IIncidentsRepository;
}) { }) {
this.jobQueue = jobQueue; this.jobQueue = jobQueue;
this.emailService = emailService;
this.logger = logger; this.logger = logger;
this.games = games; this.games = games;
this.monitorsRepository = monitorsRepository; this.monitorsRepository = monitorsRepository;
@@ -176,7 +171,7 @@ export class MonitorService implements IMonitorService {
createMonitors = async (monitors: Array<Monitor>): Promise<Monitor[] | null> => { createMonitors = async (monitors: Array<Monitor>): Promise<Monitor[] | null> => {
const createdMonitors = await this.monitorsRepository.createMonitors(monitors); const createdMonitors = await this.monitorsRepository.createMonitors(monitors);
if (!monitors || monitors.length === 0) { if (!createdMonitors || createdMonitors.length === 0) {
throw new AppError({ message: "Failed to create monitors", status: 500, service: SERVICE_NAME, method: "createMonitors" }); throw new AppError({ message: "Failed to create monitors", status: 500, service: SERVICE_NAME, method: "createMonitors" });
} }
@@ -185,8 +180,7 @@ export class MonitorService implements IMonitorService {
}; };
addDemoMonitors = async ({ userId, teamId }: { userId: string; teamId: string }): Promise<Monitor[]> => { addDemoMonitors = async ({ userId, teamId }: { userId: string; teamId: string }): Promise<Monitor[]> => {
const demoData = demoMonitorsData; const monitors = demoMonitorsData.map((monitor) => ({
const monitors = demoData.map((monitor) => ({
userId, userId,
teamId, teamId,
name: monitor.name, name: monitor.name,
@@ -214,7 +208,7 @@ export class MonitorService implements IMonitorService {
if (!monitor) { if (!monitor) {
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 }); throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
} }
const rangeKey = (dateRange as DateRangeKey) ?? "recent"; const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey); const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), { const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
type: monitor.type, type: monitor.type,
@@ -263,7 +257,7 @@ export class MonitorService implements IMonitorService {
throw new AppError({ message: `${monitor.type} monitors are not supported for hardware details`, status: 400 }); throw new AppError({ message: `${monitor.type} monitors are not supported for hardware details`, status: 400 });
} }
const rangeKey = (dateRange as DateRangeKey) ?? "recent"; const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey); const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), { const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
type: monitor.type, type: monitor.type,
@@ -305,7 +299,7 @@ export class MonitorService implements IMonitorService {
throw new AppError({ message: `${monitor.type} monitors are not supported for pagespeed details`, status: 400 }); throw new AppError({ message: `${monitor.type} monitors are not supported for pagespeed details`, status: 400 });
} }
const rangeKey = (dateRange as DateRangeKey) ?? "recent"; const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey); const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), { const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
type: monitor.type, type: monitor.type,
@@ -346,7 +340,7 @@ export class MonitorService implements IMonitorService {
return { groupedGeoChecks: [] }; return { groupedGeoChecks: [] };
} }
const rangeKey = (dateRange as DateRangeKey) ?? "recent"; const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey); const { start, end } = this.getDateRange(rangeKey);
const groupedGeoChecks = await this.geoChecksRepository.findGroupedByMonitorIdAndDateRange( const groupedGeoChecks = await this.geoChecksRepository.findGroupedByMonitorIdAndDateRange(
monitor.id, monitor.id,
@@ -360,8 +354,7 @@ export class MonitorService implements IMonitorService {
}; };
getMonitorById = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => { getMonitorById = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId); return await this.monitorsRepository.findById(monitorId, teamId);
return monitor;
}; };
getMonitorsByTeamId = async ({ getMonitorsByTeamId = async ({
@@ -373,11 +366,7 @@ export class MonitorService implements IMonitorService {
type?: MonitorType | MonitorType[]; type?: MonitorType | MonitorType[];
filter?: string; filter?: string;
}): Promise<Monitor[] | null> => { }): Promise<Monitor[] | null> => {
const monitors = await this.monitorsRepository.findByTeamId(teamId, { return await this.monitorsRepository.findByTeamId(teamId, { type, filter });
type,
filter,
});
return monitors;
}; };
getMonitorsWithChecksByTeamId = async ({ getMonitorsWithChecksByTeamId = async ({
@@ -432,8 +421,7 @@ export class MonitorService implements IMonitorService {
}; };
getGroupsByTeamId = async ({ teamId }: { teamId: string }): Promise<string[]> => { getGroupsByTeamId = async ({ teamId }: { teamId: string }): Promise<string[]> => {
const groups = await this.monitorsRepository.findGroupsByTeamId(teamId); return await this.monitorsRepository.findGroupsByTeamId(teamId);
return groups;
}; };
editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: Partial<Monitor> }) => { editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: Partial<Monitor> }) => {
@@ -574,15 +562,6 @@ export class MonitorService implements IMonitorService {
const createdMonitors = await this.createMonitors(cleanedMonitors); const createdMonitors = await this.createMonitors(cleanedMonitors);
if (!createdMonitors || createdMonitors.length === 0) { return { imported: createdMonitors!.length, errors };
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 };
}; };
} }
+8 -24
View File
@@ -110,10 +110,7 @@ export class UserService implements IUserService {
} }
issueToken = (payload: Partial<User>, appSettings: EnvConfig) => { issueToken = (payload: Partial<User>, appSettings: EnvConfig) => {
const tokenTTL = appSettings?.jwtTTL ?? "2h"; return this.jwt.sign(payload, appSettings.jwtSecret, { expiresIn: appSettings.jwtTTL });
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) => { registerUser = async (user: Partial<User>, inviteToken: string, file: Express.Multer.File | null) => {
@@ -253,14 +250,10 @@ export class UserService implements IUserService {
currentUserEmail: string currentUserEmail: string
) => { ) => {
// Change Password check // 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; updates.email = currentUserEmail;
// Get user
const user = await this.usersRepository.findByEmail(currentUserEmail); const user = await this.usersRepository.findByEmail(currentUserEmail);
// Compare passwords const match = await bcrypt.compare(updates.password, user.password);
const match = await bcrypt.compare(updates?.password, user.password);
// If not a match, throw a 403 // If not a match, throw a 403
// 403 instead of 401 to avoid triggering axios interceptor // 403 instead of 401 to avoid triggering axios interceptor
if (!match) { if (!match) {
@@ -286,7 +279,7 @@ export class UserService implements IUserService {
const recoveryToken = await this.recoveryTokensRepository.create(email); const recoveryToken = await this.recoveryTokensRepository.create(email);
const name = user.firstName; const name = user.firstName;
const settings = this.settingsService.getSettings(); 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", { const html = await this.emailService.buildEmail("passwordResetTemplate", {
name, name,
@@ -336,17 +329,10 @@ export class UserService implements IUserService {
throw new AppError({ message: "Demo user cannot be deleted", service: SERVICE_NAME, method: "deleteUser", status: 400 }); 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")) { if (roles.includes("superadmin")) {
// 2. Remove all jobs, delete checks and alerts const monitors = await this.monitorsRepository.findByTeamId(teamId, {});
if (res && res.length > 0) { if (monitors) {
await Promise.all( await Promise.all(monitors.map((monitor) => this.jobQueue.deleteJob(monitor)));
res.map(async (monitor) => {
await this.jobQueue.deleteJob(monitor);
})
);
} }
} }
// 6. Delete the user by id // 6. Delete the user by id
@@ -410,9 +396,7 @@ export class UserService implements IUserService {
if (!roles.includes("superadmin") && !roles.includes("admin")) { if (!roles.includes("superadmin") && !roles.includes("admin")) {
throw new AppError({ message: "Insufficient permissions", service: SERVICE_NAME, status: 403 }); throw new AppError({ message: "Insufficient permissions", service: SERVICE_NAME, status: 403 });
} }
const user = await this.usersRepository.findById(userId); return await this.usersRepository.findById(userId);
return user;
}; };
editUserById = async (userId: string, patch: Partial<User>) => { editUserById = async (userId: string, patch: Partial<User>) => {
@@ -51,7 +51,6 @@ export interface ISuperSimpleQueue {
getMetrics(): Promise<QueueMetrics>; getMetrics(): Promise<QueueMetrics>;
getJobs(): Promise<QueueJobSummary[]>; getJobs(): Promise<QueueJobSummary[]>;
flushQueues(): Promise<{ success: boolean }>; flushQueues(): Promise<{ success: boolean }>;
obliterate(): Promise<void>;
} }
export class SuperSimpleQueue implements ISuperSimpleQueue { export class SuperSimpleQueue implements ISuperSimpleQueue {
@@ -73,22 +72,91 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
return SuperSimpleQueue.SERVICE_NAME; 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) { static async create(logger: ILogger, helper: ISuperSimpleQueueHelper, monitorsRepository: IMonitorsRepository) {
const scheduler = new Scheduler({ const scheduler = new Scheduler({
// storeType: "mongo", // storeType: "mongo",
// storeType: "redis", // storeType: "redis",
logLevel: "debug",
// dbUri: envSettings.dbConnectionString, // dbUri: envSettings.dbConnectionString,
}); });
const instance = new SuperSimpleQueue(logger, helper, monitorsRepository, scheduler); const instance = new SuperSimpleQueue(logger, helper, monitorsRepository, scheduler);
await instance.init(); await instance.init();
return instance; return instance;
} }
init = async () => { init = async () => {
try { try {
this.scheduler.start(); this.registerListeners();
this.scheduler.start();
this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob()); this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob());
this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob()); this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob());
this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob()); this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob());
@@ -237,8 +305,8 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
if (failCount > 0) { if (failCount > 0) {
acc.jobsWithFailures.push({ acc.jobsWithFailures.push({
monitorId: job.id, monitorId: job.id,
monitorUrl: job?.data?.url || null, monitorUrl: job.data?.url || null,
monitorType: job?.data?.type || null, monitorType: job.data?.type || null,
failedAt: job.lastFailedAt ?? null, failedAt: job.lastFailedAt ?? null,
failCount, failCount,
failReason: job.lastFailReason ?? null, failReason: job.lastFailReason ?? null,
@@ -270,9 +338,9 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
return jobs.map((job) => { return jobs.map((job) => {
return { return {
monitorId: job.id, monitorId: job.id,
monitorUrl: job?.data?.url || null, monitorUrl: job.data?.url || null,
monitorType: job?.data?.type || null, monitorType: job.data?.type || null,
monitorInterval: job?.data?.interval || null, monitorInterval: job.data?.interval || null,
active: job.active, active: job.active,
lockedAt: job.lockedAt ?? null, lockedAt: job.lockedAt ?? null,
runCount: job.runCount ?? 0, runCount: job.runCount ?? 0,
@@ -294,12 +362,4 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
success: Boolean(stopRes && flushRes && initRes), success: Boolean(stopRes && flushRes && initRes),
}; };
}; };
obliterate = async () => {
this.logger.warn({
message: "obliterate method not implemented",
service: SERVICE_NAME,
method: "obliterate",
});
};
} }
@@ -132,15 +132,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
} }
// Step 2. Request monitor status // Step 2. Request monitor status
// Resolve default user agent for HTTP monitors (cached, invalidated on settings update) const status = await this.networkService.requestStatus(monitor);
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) { if (!status) {
throw new Error("No network response"); throw new Error("No network response");
} }
@@ -176,15 +168,17 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
}); });
} }
// Step 7. Handle incidents (best effort, don't wait) // Step 7. Handle incidents
this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status).catch((error: unknown) => { try {
await this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status);
} catch (error: unknown) {
this.logger.warn({ this.logger.warn({
message: `Error handling incident for job ${monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`, message: `Error handling incident for job ${monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
service: SERVICE_NAME, service: SERVICE_NAME,
method: "getMonitorJob", method: "getMonitorJob",
stack: error instanceof Error ? error.stack : undefined, stack: error instanceof Error ? error.stack : undefined,
}); });
}); }
} catch (error: unknown) { } catch (error: unknown) {
this.logger.warn({ this.logger.warn({
message: error instanceof Error ? error.message : "Unknown error", message: error instanceof Error ? error.message : "Unknown error",
@@ -385,7 +379,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
return true; return true;
} }
} }
return false; return acc;
} }
return acc; return acc;
}, false); }, false);
@@ -9,7 +9,6 @@ const SERVICE_NAME = "BufferService";
export interface IBufferService { export interface IBufferService {
addToBuffer(check: Check): void; addToBuffer(check: Check): void;
addGeoCheckToBuffer(geoCheck: GeoCheck): void; addGeoCheckToBuffer(geoCheck: GeoCheck): void;
removeCheckFromBuffer(check: Check): boolean;
scheduleNextFlush(): void; scheduleNextFlush(): void;
flushBuffer(): Promise<void>; flushBuffer(): Promise<void>;
flushGeoBuffer(): Promise<void>; flushGeoBuffer(): Promise<void>;
@@ -72,44 +71,6 @@ export class BufferService implements IBufferService {
} }
} }
removeCheckFromBuffer(checkToRemove: Check) {
try {
if (!checkToRemove) {
return false;
}
const index = this.buffer.findIndex((check) => {
if (checkToRemove.id && check.id) {
return check.id.toString() === checkToRemove.id.toString();
}
return (
check.metadata.monitorId?.toString() === checkToRemove.metadata.monitorId &&
check.metadata.teamId?.toString() === checkToRemove.metadata.teamId &&
check.metadata.type === checkToRemove.metadata.type &&
check.status === checkToRemove.status &&
check.statusCode === checkToRemove.statusCode &&
check.responseTime === checkToRemove.responseTime &&
check.message === checkToRemove.message
);
});
if (index !== -1) {
this.buffer.splice(index, 1);
return true;
}
return false;
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: this.SERVICE_NAME,
method: "removeCheckFromBuffer",
stack: error instanceof Error ? error.stack : undefined,
});
return false;
}
}
scheduleNextFlush() { scheduleNextFlush() {
if (this.bufferTimer) { if (this.bufferTimer) {
clearTimeout(this.bufferTimer); clearTimeout(this.bufferTimer);
@@ -35,7 +35,6 @@ export class EmailService implements IEmailService {
private logger: ILogger; private logger: ILogger;
private transporter: ReturnType<typeof import("nodemailer").createTransport> | null = null; private transporter: ReturnType<typeof import("nodemailer").createTransport> | null = null;
private templateLookup: Record<string, ((context: Record<string, unknown>) => string) | undefined>; private templateLookup: Record<string, ((context: Record<string, unknown>) => string) | undefined>;
private loadTemplate: (templateName: string) => ((context: Record<string, unknown>) => string) | undefined;
constructor( constructor(
settingsService: ISettingsService, settingsService: ISettingsService,
@@ -54,7 +53,6 @@ export class EmailService implements IEmailService {
this.nodemailer = nodemailer; this.nodemailer = nodemailer;
this.logger = logger; this.logger = logger;
this.templateLookup = {}; this.templateLookup = {};
this.loadTemplate = () => undefined;
this.init(); this.init();
} }
@@ -63,7 +61,7 @@ export class EmailService implements IEmailService {
} }
init = () => { init = () => {
this.loadTemplate = (templateName) => { const loadTemplate = (templateName: string) => {
try { try {
const templatePath = this.path.join(__dirname, `../../templates/${templateName}.mjml`); const templatePath = this.path.join(__dirname, `../../templates/${templateName}.mjml`);
const templateContent = this.fs.readFileSync(templatePath, "utf8"); const templateContent = this.fs.readFileSync(templatePath, "utf8");
@@ -79,12 +77,12 @@ export class EmailService implements IEmailService {
}; };
this.templateLookup = { this.templateLookup = {
welcomeEmailTemplate: this.loadTemplate("welcomeEmail"), welcomeEmailTemplate: loadTemplate("welcomeEmail"),
employeeActivationTemplate: this.loadTemplate("employeeActivation"), employeeActivationTemplate: loadTemplate("employeeActivation"),
noIncidentsThisWeekTemplate: this.loadTemplate("noIncidentsThisWeek"), noIncidentsThisWeekTemplate: loadTemplate("noIncidentsThisWeek"),
passwordResetTemplate: this.loadTemplate("passwordReset"), passwordResetTemplate: loadTemplate("passwordReset"),
testEmailTemplate: this.loadTemplate("testEmailTemplate"), testEmailTemplate: loadTemplate("testEmailTemplate"),
unifiedNotificationTemplate: this.loadTemplate("unifiedNotification"), unifiedNotificationTemplate: loadTemplate("unifiedNotification"),
}; };
}; };
@@ -167,7 +165,7 @@ export class EmailService implements IEmailService {
subject: subject, subject: subject,
html: html, html: html,
}); });
return info?.messageId; return info.messageId;
} catch (error: unknown) { } catch (error: unknown) {
this.logger.error({ this.logger.error({
message: error instanceof Error ? error.message : "Unknown error", message: error instanceof Error ? error.message : "Unknown error",
@@ -90,7 +90,8 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
if (partialIdMatch && !exactIdMatch) matchTypes.push("partial ID"); if (partialIdMatch && !exactIdMatch) matchTypes.push("partial ID");
if (matchTypes.length > 1) { if (matchTypes.length > 1) {
const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${exactIdMatch ? "exact ID" : exactNameMatch ? "exact name" : "partial ID"} match.`; const matchUsed = exactIdMatch ? "exact ID" : "exact name";
const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${matchUsed} match.`;
this.logger.warn({ this.logger.warn({
message, message,
@@ -138,11 +139,24 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
}; };
} }
if (!response) {
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code: NETWORK_ERROR,
message: "No response from Docker container inspect",
responseTime,
payload: null,
};
}
return { return {
monitorId: monitor.id, monitorId: monitor.id,
teamId: monitor.teamId, teamId: monitor.teamId,
type: monitor.type, type: monitor.type,
status: response?.State?.Status === "running", status: response.State?.Status === "running",
code: 200, code: 200,
message: "Docker container status fetched successfully", message: "Docker container status fetched successfully",
responseTime, responseTime,
@@ -60,7 +60,7 @@ export class GrpcProvider implements IStatusProvider<GrpcStatusPayload> {
throw new AppError({ message: "Monitor port is required", service: SERVICE_NAME, method: "handle" }); 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 target = `${host}:${port}`;
const currentFilePath = fileURLToPath(import.meta.url); const currentFilePath = fileURLToPath(import.meta.url);
@@ -56,30 +56,15 @@ 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>> { async handle<T>(monitor: Monitor): Promise<MonitorStatusResponse<T>> {
const { url, secret, jsonPath, ignoreTlsErrors, customUserAgent } = monitor; const { url, secret, jsonPath, ignoreTlsErrors } = monitor;
if (!url) { if (!url) {
throw new Error("URL is required for HTTP monitor"); 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> = { const options: Record<string, unknown> = {
headers: Object.keys(headers).length > 0 ? headers : undefined, headers: monitor.secret ? { Authorization: `Bearer ${secret}` } : undefined,
}; };
options.agent = { 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; return true;
} catch (err: unknown) { } catch (err: unknown) {
const originalMessage = err instanceof Error ? err.message : String(err); const originalMessage = err instanceof Error ? err.message : String(err);
@@ -5,6 +5,7 @@ import type { AlertMatrixPayload, Notification } from "@/types/index.js";
import type { NotificationMessage } from "@/types/notificationMessage.js"; import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import { ILogger } from "@/utils/logger.js"; import { ILogger } from "@/utils/logger.js";
import { randomUUID } from "crypto";
export class MatrixProvider implements INotificationProvider { export class MatrixProvider implements INotificationProvider {
private logger: ILogger; private logger: ILogger;
@@ -18,63 +19,51 @@ export class MatrixProvider implements INotificationProvider {
if (!homeserverUrl || !accessToken || !roomId) { if (!homeserverUrl || !accessToken || !roomId) {
return false; return false;
} }
const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message?access_token=${accessToken}`;
const body = { const body = {
msgtype: "m.text", msgtype: "m.text",
body: getTestMessage(), body: getTestMessage(),
}; };
try { return await this.sendMessageWithBody(notification, body);
await got.post(url, {
json: body,
headers: {
"Content-Type": "application/json",
},
});
return true;
} catch (error) {
const err = error as Error;
this.logger.warn({
message: "Matrix test alert failed",
service: SERVICE_NAME,
method: "sendTestAlert",
stack: err?.stack,
});
return false;
}
}; };
sendMessage = async (notification: Notification, message: NotificationMessage): Promise<boolean> => { sendMessage = async (notification: Notification, message: NotificationMessage): Promise<boolean> => {
const { homeserverUrl, accessToken, roomId } = notification; const { homeserverUrl, accessToken, roomId } = notification;
if (!homeserverUrl || !accessToken || !roomId) { if (!homeserverUrl || !accessToken || !roomId) {
return false; return false;
} }
const { plainText, htmlText } = this.buildMatrixMessage(message); const { plainText, htmlText } = this.buildMatrixMessage(message);
const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message?access_token=${accessToken}`;
const body = { const body = {
msgtype: "m.text", msgtype: "m.text",
body: plainText, body: plainText,
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: htmlText, 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 { try {
await got.post(url, { await got.put(url, {
json: body, json: body,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
}, },
}); });
return true; return true;
} catch (error) { } catch (error) {
const err = error as Error; const err = error as Error;
this.logger.warn({ this.logger.warn({
message: "Matrix notification failed", message: `Matrix notification failed : ${err.message}`,
service: SERVICE_NAME, service: SERVICE_NAME,
method: "sendMessage", method: "sendMessage",
stack: err?.stack, stack: err.stack,
}); });
return false; return false;
} }
@@ -96,6 +96,7 @@ export class StatusService implements IStatusService {
uptimePercentage: 0, uptimePercentage: 0,
lastResponseTime: 0, lastResponseTime: 0,
lastCheckTimestamp: 0, lastCheckTimestamp: 0,
timeOfLastFailure: 0,
}; };
} else { } else {
// Use existing stats (omit id, monitorId, createdAt, updatedAt) // Use existing stats (omit id, monitorId, createdAt, updatedAt)
@@ -147,13 +148,7 @@ export class StatusService implements IStatusService {
} }
// Calculate uptime percentage // Calculate uptime percentage
let uptimePercentage; stats.uptimePercentage = stats.totalUpChecks / stats.totalChecks;
if (stats.totalChecks > 0) {
uptimePercentage = stats.totalUpChecks / stats.totalChecks;
} else {
uptimePercentage = status === true ? 100 : 0;
}
stats.uptimePercentage = uptimePercentage;
// latest check // latest check
stats.lastCheckTimestamp = new Date().getTime(); stats.lastCheckTimestamp = new Date().getTime();
@@ -268,7 +263,7 @@ export class StatusService implements IStatusService {
let thresholdBreaches: { cpu: boolean; memory: boolean; disk: boolean; temp: boolean } | undefined; let thresholdBreaches: { cpu: boolean; memory: boolean; disk: boolean; temp: boolean } | undefined;
if (monitor.type === "hardware" && statusResponse.payload) { if (monitor.type === "hardware" && statusResponse.payload) {
const payload = statusResponse.payload as HardwareStatusPayload; const payload = statusResponse.payload as HardwareStatusPayload;
const metrics = payload?.data; const metrics = payload.data;
if (metrics) { if (metrics) {
// Evaluate threshold breaches // Evaluate threshold breaches
@@ -278,9 +273,11 @@ export class StatusService implements IStatusService {
const memoryUsage = metrics.memory?.usage_percent ?? -1; const memoryUsage = metrics.memory?.usage_percent ?? -1;
const memoryBreach = memoryUsage !== -1 && memoryUsage > monitor.memoryAlertThreshold / 100; const memoryBreach = memoryUsage !== -1 && memoryUsage > monitor.memoryAlertThreshold / 100;
const diskBreach = const diskBreach = metrics.disk
metrics.disk?.some((d: CheckDiskInfo) => typeof d?.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100) ?? ? metrics.disk.some(
false; (d: CheckDiskInfo) => d != null && typeof d.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100
)
: false;
const temps = metrics.cpu?.temperature ?? []; const temps = metrics.cpu?.temperature ?? [];
const tempBreach = temps.some((temp: number) => temp > monitor.tempAlertThreshold); const tempBreach = temps.some((temp: number) => temp > monitor.tempAlertThreshold);
@@ -366,27 +363,4 @@ export class StatusService implements IStatusService {
}); });
} }
}; };
insertCheck = async (check: Check) => {
try {
if (typeof check === "undefined") {
this.logger.warn({
message: "Failed to build check",
service: SERVICE_NAME,
method: "insertCheck",
});
return false;
}
this.buffer.addToBuffer(check);
return true;
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "insertCheck",
details: { msg: `Error inserting check for monitor: ${check?.metadata.monitorId}` },
stack: error instanceof Error ? error.stack : undefined,
});
}
};
} }
+1 -19
View File
@@ -20,7 +20,6 @@ export interface ISettingsService {
loadSettings(): EnvConfig; loadSettings(): EnvConfig;
getSettings(): EnvConfig; getSettings(): EnvConfig;
getDBSettings(): Promise<Settings>; getDBSettings(): Promise<Settings>;
getDefaultUserAgent(): Promise<string | undefined>;
updateDbSettings(newSettings: SettingsUpdate): Promise<Settings>; updateDbSettings(newSettings: SettingsUpdate): Promise<Settings>;
} }
@@ -28,7 +27,6 @@ export class SettingsService implements ISettingsService {
static SERVICE_NAME = SERVICE_NAME; static SERVICE_NAME = SERVICE_NAME;
private settings: EnvConfig; private settings: EnvConfig;
private settingsRepository: ISettingsRepository | null = null; private settingsRepository: ISettingsRepository | null = null;
private cachedDefaultUserAgent: string | null | undefined = undefined;
constructor(env: ValidatedEnv) { constructor(env: ValidatedEnv) {
this.settings = { this.settings = {
@@ -55,9 +53,6 @@ export class SettingsService implements ISettingsService {
} }
getSettings() { getSettings() {
if (!this.settings) {
throw new Error("Settings have not been loaded");
}
return this.settings; return this.settings;
} }
@@ -72,21 +67,8 @@ export class SettingsService implements ISettingsService {
return this.settingsRepository; 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) => { updateDbSettings = async (newSettings: SettingsUpdate) => {
const updated = await this.getRepository().update(newSettings); return await this.getRepository().update(newSettings);
if ("defaultUserAgent" in newSettings) {
this.cachedDefaultUserAgent = newSettings.defaultUserAgent ?? null;
}
return updated;
}; };
getDBSettings = async () => { getDBSettings = async () => {
-1
View File
@@ -50,7 +50,6 @@ export interface Monitor {
gameId?: string; gameId?: string;
grpcServiceName?: string; grpcServiceName?: string;
group: string | null; group: string | null;
customUserAgent?: string | null;
geoCheckEnabled?: boolean; geoCheckEnabled?: boolean;
geoCheckLocations?: GeoContinent[]; geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number; geoCheckInterval?: number;
-1
View File
@@ -31,7 +31,6 @@ export interface Settings {
systemEmailRequireTLS: boolean; systemEmailRequireTLS: boolean;
systemEmailRejectUnauthorized: boolean; systemEmailRejectUnauthorized: boolean;
showURL: boolean; showURL: boolean;
defaultUserAgent?: string;
singleton: boolean; singleton: boolean;
version: number; version: number;
globalThresholds?: SettingsThresholds; globalThresholds?: SettingsThresholds;
+1 -4
View File
@@ -1,5 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { booleanCoercion, httpHeaderValueSchema } from "./shared.js"; import { booleanCoercion } from "./shared.js";
import { GeoContinents } from "@/types/geoCheck.js"; import { GeoContinents } from "@/types/geoCheck.js";
import { MonitorMatchMethods, MonitorTypes } from "@/types/monitor.js"; import { MonitorMatchMethods, MonitorTypes } from "@/types/monitor.js";
@@ -75,7 +75,6 @@ export const createMonitorBodyValidation = z.object({
grpcServiceName: z.union([z.string(), z.literal("")]).default(""), grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
selectedDisks: z.array(z.string()).optional(), selectedDisks: z.array(z.string()).optional(),
group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(),
customUserAgent: httpHeaderValueSchema.nullable().optional(),
geoCheckEnabled: z.boolean().optional(), geoCheckEnabled: z.boolean().optional(),
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z.number().min(300000).optional(), geoCheckInterval: z.number().min(300000).optional(),
@@ -105,7 +104,6 @@ export const editMonitorBodyValidation = z.object({
grpcServiceName: z.union([z.string(), z.literal("")]).optional(), grpcServiceName: z.union([z.string(), z.literal("")]).optional(),
selectedDisks: z.array(z.string()).optional(), selectedDisks: z.array(z.string()).optional(),
group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(),
customUserAgent: httpHeaderValueSchema.nullable().optional(),
geoCheckEnabled: z.boolean().optional(), geoCheckEnabled: z.boolean().optional(),
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z.number().min(300000).optional(), geoCheckInterval: z.number().min(300000).optional(),
@@ -159,7 +157,6 @@ const importedMonitorSchema = z.object({
gameId: z.union([z.string(), z.literal("")]).optional(), gameId: z.union([z.string(), z.literal("")]).optional(),
grpcServiceName: z.union([z.string(), z.literal("")]).default(""), grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
group: z.union([z.string().max(50).trim(), z.null()]).default(null), group: z.union([z.string().max(50).trim(), z.null()]).default(null),
customUserAgent: httpHeaderValueSchema.nullable().optional(),
geoCheckEnabled: z.boolean().default(false), geoCheckEnabled: z.boolean().default(false),
geoCheckLocations: z.array(z.enum(GeoContinents)).default([]), geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
geoCheckInterval: z.number().min(300000).default(300000), geoCheckInterval: z.number().min(300000).default(300000),
@@ -1,6 +1,5 @@
import { CHECK_TTL_SENTINEL } from "@/types/check.js"; import { CHECK_TTL_SENTINEL } from "@/types/check.js";
import { z } from "zod"; import { z } from "zod";
import { httpHeaderValueSchema } from "@/validation/shared.js";
//**************************************** //****************************************
// Settings Validations // Settings Validations
@@ -21,7 +20,6 @@ export const updateAppSettingsBodyValidation = z
systemEmailTLSServername: z.string().nullable().optional(), systemEmailTLSServername: z.string().nullable().optional(),
showURL: z.boolean().optional(), showURL: z.boolean().optional(),
defaultUserAgent: httpHeaderValueSchema.nullable().optional(),
systemEmailSecure: z.boolean().optional(), systemEmailSecure: z.boolean().optional(),
systemEmailPool: z.boolean().optional(), systemEmailPool: z.boolean().optional(),
systemEmailIgnoreTLS: z.boolean().optional(), systemEmailIgnoreTLS: z.boolean().optional(),
-6
View File
@@ -21,12 +21,6 @@ export const booleanCoercion = z.preprocess((val) => {
return val; // Let Zod validation handle invalid values return val; // Let Zod validation handle invalid values
}, z.boolean()); }, 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[]) => { export const roleValidator = (allowedRoles: UserRole[]) => {
return z.array(z.custom<UserRole>()).refine((userRoles) => allowedRoles.some((role) => userRoles.includes(role)), { 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(", ")}`, message: `You do not have the required authorization. Required roles: ${allowedRoles.join(", ")}`,
@@ -0,0 +1,138 @@
import type { IIncidentsRepository } from "../../src/repositories/incidents/IIncidentsRepository.ts";
import type { Incident, IncidentSummary } from "../../src/types/index.ts";
import { randomUUID } from "crypto";
export class InMemoryIncidentsRepository implements IIncidentsRepository {
private incidents: Incident[] = [];
async create(incident: Partial<Incident>): Promise<Incident> {
const now = new Date().toISOString();
const full: Incident = {
id: randomUUID(),
monitorId: incident.monitorId ?? "",
teamId: incident.teamId ?? "",
startTime: incident.startTime ?? now,
endTime: incident.endTime ?? null,
status: incident.status ?? true,
message: incident.message ?? null,
statusCode: incident.statusCode ?? null,
resolutionType: incident.resolutionType ?? null,
resolvedBy: incident.resolvedBy ?? null,
resolvedByEmail: incident.resolvedByEmail ?? null,
comment: incident.comment ?? null,
createdAt: now,
updatedAt: now,
};
this.incidents.push(full);
return { ...full };
}
async findById(incidentId: string, teamId: string): Promise<Incident> {
const incident = this.incidents.find((i) => i.id === incidentId && i.teamId === teamId);
if (!incident) {
throw new Error(`Incident ${incidentId} not found`);
}
return { ...incident };
}
async findActiveByIncidentId(incidentId: string, teamId: string): Promise<Incident | null> {
const incident = this.incidents.find((i) => i.id === incidentId && i.teamId === teamId && i.status === true);
return incident ? { ...incident } : null;
}
async findActiveByMonitorId(monitorId: string, teamId: string): Promise<Incident | null> {
const incident = this.incidents.find((i) => i.monitorId === monitorId && i.teamId === teamId && i.status === true);
return incident ? { ...incident } : null;
}
async findByTeamId(
teamId: string,
startDate: Date | undefined,
page: number,
rowsPerPage: number,
sortOrder?: string,
status?: boolean,
monitorId?: string,
resolutionType?: string
): Promise<Incident[]> {
let results = this.incidents.filter((i) => i.teamId === teamId);
if (startDate) {
results = results.filter((i) => new Date(i.startTime) >= startDate);
}
if (status !== undefined) {
results = results.filter((i) => i.status === status);
}
if (monitorId) {
results = results.filter((i) => i.monitorId === monitorId);
}
if (resolutionType) {
results = results.filter((i) => i.resolutionType === resolutionType);
}
results.sort((a, b) => {
const cmp = new Date(a.startTime).getTime() - new Date(b.startTime).getTime();
return sortOrder === "desc" ? -cmp : cmp;
});
return results.slice(page * rowsPerPage, (page + 1) * rowsPerPage).map((i) => ({ ...i }));
}
async findSummaryByTeamId(_teamId: string, _limit?: number): Promise<IncidentSummary> {
throw new Error("Not implemented");
}
async countByTeamId(teamId: string, startDate: Date | undefined, status?: boolean, monitorId?: string, resolutionType?: string): Promise<number> {
let results = this.incidents.filter((i) => i.teamId === teamId);
if (startDate) {
results = results.filter((i) => new Date(i.startTime) >= startDate);
}
if (status !== undefined) {
results = results.filter((i) => i.status === status);
}
if (monitorId) {
results = results.filter((i) => i.monitorId === monitorId);
}
if (resolutionType) {
results = results.filter((i) => i.resolutionType === resolutionType);
}
return results.length;
}
async updateById(incidentId: string, teamId: string, updateData: Partial<Incident>): Promise<Incident> {
const index = this.incidents.findIndex((i) => i.id === incidentId && i.teamId === teamId);
if (index === -1) {
throw new Error(`Incident ${incidentId} not found`);
}
const updated: Incident = {
...this.incidents[index],
...updateData,
id: this.incidents[index].id,
monitorId: this.incidents[index].monitorId,
teamId: this.incidents[index].teamId,
createdAt: this.incidents[index].createdAt,
updatedAt: new Date().toISOString(),
};
this.incidents[index] = updated;
return { ...updated };
}
async deleteByMonitorId(monitorId: string, teamId: string): Promise<number> {
const before = this.incidents.length;
this.incidents = this.incidents.filter((i) => !(i.monitorId === monitorId && i.teamId === teamId));
return before - this.incidents.length;
}
async deleteByMonitorIdsNotIn(monitorIds: string[]): Promise<number> {
const before = this.incidents.length;
this.incidents = this.incidents.filter((i) => monitorIds.includes(i.monitorId));
return before - this.incidents.length;
}
// Test helpers — not part of the interface
getAll(): Incident[] {
return this.incidents.map((i) => ({ ...i }));
}
clear(): void {
this.incidents = [];
}
}
@@ -0,0 +1,116 @@
import type { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "../../src/repositories/monitors/IMonitorsRepository.ts";
import type { Monitor, MonitorsSummary } from "../../src/types/index.ts";
export class InMemoryMonitorsRepository implements IMonitorsRepository {
private monitors: Monitor[] = [];
async create(monitor: Monitor, _teamId: string, _userId: string): Promise<Monitor> {
this.monitors.push({ ...monitor });
return { ...monitor };
}
async createMonitors(monitors: Monitor[]): Promise<Monitor[]> {
const created = monitors.map((m) => ({ ...m }));
this.monitors.push(...created);
return created;
}
async findById(monitorId: string, teamId: string): Promise<Monitor> {
const monitor = this.monitors.find((m) => m.id === monitorId && m.teamId === teamId);
if (!monitor) {
throw new Error(`Monitor ${monitorId} not found`);
}
return { ...monitor };
}
async findAll(): Promise<Monitor[]> {
return this.monitors.map((m) => ({ ...m }));
}
async findByTeamId(_teamId: string, _config: TeamQueryConfig): Promise<Monitor[]> {
return this.monitors.filter((m) => m.teamId === _teamId).map((m) => ({ ...m }));
}
async findByIds(monitorIds: string[]): Promise<Monitor[]> {
return this.monitors.filter((m) => monitorIds.includes(m.id)).map((m) => ({ ...m }));
}
async findByIdsWithChecks(monitorIds: string[], _checksCount?: number): Promise<Monitor[]> {
return this.findByIds(monitorIds);
}
async updateById(monitorId: string, teamId: string, updates: Partial<Monitor>): Promise<Monitor> {
const index = this.monitors.findIndex((m) => m.id === monitorId && m.teamId === teamId);
if (index === -1) {
throw new Error(`Monitor ${monitorId} not found`);
}
const updated = { ...this.monitors[index], ...updates, id: this.monitors[index].id, teamId: this.monitors[index].teamId };
this.monitors[index] = updated;
return { ...updated };
}
async togglePauseById(monitorId: string, teamId: string): Promise<Monitor> {
const monitor = await this.findById(monitorId, teamId);
const newStatus = monitor.status === "paused" ? "up" : "paused";
return this.updateById(monitorId, teamId, { status: newStatus });
}
async deleteById(monitorId: string, teamId: string): Promise<Monitor> {
const index = this.monitors.findIndex((m) => m.id === monitorId && m.teamId === teamId);
if (index === -1) {
throw new Error(`Monitor ${monitorId} not found`);
}
const [deleted] = this.monitors.splice(index, 1);
return { ...deleted };
}
async deleteByTeamId(teamId: string): Promise<{ monitors: Monitor[]; deletedCount: number }> {
const toDelete = this.monitors.filter((m) => m.teamId === teamId);
this.monitors = this.monitors.filter((m) => m.teamId !== teamId);
return { monitors: toDelete, deletedCount: toDelete.length };
}
async findMonitorCountByTeamIdAndType(teamId: string, _config: TeamQueryConfig): Promise<number> {
return this.monitors.filter((m) => m.teamId === teamId).length;
}
async findMonitorsSummaryByTeamId(_teamId: string, _config?: SummaryConfig): Promise<MonitorsSummary> {
throw new Error("Not implemented");
}
async findGroupsByTeamId(_teamId: string): Promise<string[]> {
throw new Error("Not implemented");
}
async removeNotificationFromMonitors(_notificationId: string): Promise<void> {
throw new Error("Not implemented");
}
async updateNotifications(_teamId: string, _monitorIds: string[], _notificationIds: string[], _action: "add" | "remove" | "set"): Promise<number> {
throw new Error("Not implemented");
}
async deleteByTeamIdsNotIn(teamIds: string[]): Promise<number> {
const before = this.monitors.length;
this.monitors = this.monitors.filter((m) => teamIds.includes(m.teamId));
return before - this.monitors.length;
}
async findAllMonitorIds(): Promise<string[]> {
return this.monitors.map((m) => m.id);
}
// Test helpers
seed(monitor: Monitor): void {
this.monitors.push({ ...monitor });
}
getAll(): Monitor[] {
return this.monitors.map((m) => ({ ...m }));
}
clear(): void {
this.monitors = [];
}
}
+12
View File
@@ -0,0 +1,12 @@
import { jest } from "@jest/globals";
export const createMockLogger = () => ({
serviceName: "test",
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
cacheLog: jest.fn(),
getLogs: jest.fn().mockReturnValue([]),
buildLogEntry: jest.fn().mockReturnValue({ level: "info", timestamp: new Date().toISOString() }),
});
+179
View File
@@ -0,0 +1,179 @@
import { jest } from "@jest/globals";
import { SuperSimpleQueueHelper } from "../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
import { StatusService } from "../../src/service/infrastructure/statusService.ts";
import { IncidentService } from "../../src/service/business/incidentService.ts";
import { InMemoryMonitorsRepository } from "./InMemoryMonitorsRepository.ts";
import { InMemoryIncidentsRepository } from "./InMemoryIncidentsRepository.ts";
import { createMockLogger } from "./createMockLogger.ts";
import type { Monitor } from "../../src/types/monitor.ts";
import type { MonitorStatusResponse } from "../../src/types/network.ts";
import type { Check } from "../../src/types/check.ts";
import type { MaintenanceWindow } from "../../src/types/maintenanceWindow.ts";
let checkCounter = 0;
export const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
name: "Test Monitor",
type: "http",
url: "https://example.com",
status: "up",
// Pre-fill window so threshold evaluation runs immediately.
// Without this, the warm-up path sets monitor.status directly
// from each check, bypassing the threshold logic.
statusWindow: [true, true, true, true, true],
statusWindowSize: 5,
statusWindowThreshold: 60,
recentChecks: [],
notifications: [],
...overrides,
}) as Monitor;
export const makeStatusResponse = (status: boolean, code: number): MonitorStatusResponse => ({
monitorId: "mon-1",
teamId: "team-1",
type: "http",
status,
code,
message: status ? "OK" : "Service Unavailable",
responseTime: status ? 150 : 0,
});
export const makeCheck = (status: boolean, code: number): Check => {
const now = new Date().toISOString();
return {
id: `check-${++checkCounter}`,
metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" },
status,
statusCode: code,
responseTime: status ? 150 : 0,
message: status ? "OK" : "Service Unavailable",
createdAt: now,
updatedAt: now,
};
};
const createStubMonitorStatsRepo = () => ({
findByMonitorId: jest.fn().mockRejectedValue(new Error("no stats")),
create: jest.fn().mockResolvedValue({}),
updateByMonitorId: jest.fn().mockResolvedValue({}),
deleteByMonitorId: jest.fn(),
deleteByMonitorIds: jest.fn(),
deleteByMonitorIdsNotIn: jest.fn(),
});
const createStubChecksRepo = () => ({
create: jest.fn(),
createChecks: jest.fn(),
findByMonitorId: jest.fn(),
findByTeamId: jest.fn(),
findLatestByMonitorIds: jest.fn(),
findByDateRangeAndMonitorId: jest.fn(),
findSummaryByTeamId: jest.fn(),
deleteByMonitorId: jest.fn(),
deleteByTeamId: jest.fn(),
deleteByMonitorIdsNotIn: jest.fn(),
deleteOlderThan: jest.fn(),
});
export interface HeartbeatTestHarness {
monitorsRepo: InMemoryMonitorsRepository;
incidentsRepo: InMemoryIncidentsRepository;
statusService: StatusService;
incidentService: IncidentService;
notificationsService: { handleNotifications: jest.Mock };
networkService: { requestStatus: jest.Mock };
bufferStub: { addToBuffer: jest.Mock; addGeoCheckToBuffer: jest.Mock; scheduleNextFlush: jest.Mock };
maintenanceWindowsRepo: { findByMonitorId: jest.Mock };
messageBuilder: { extractThresholdBreaches: jest.Mock };
heartbeatJob: (monitor: Monitor) => Promise<void>;
setNextResponse: (status: boolean, code: number) => void;
setNextResponseFull: (response: MonitorStatusResponse) => void;
}
export function createHeartbeatTestHarness(): HeartbeatTestHarness {
checkCounter = 0;
const monitorsRepo = new InMemoryMonitorsRepository();
const incidentsRepo = new InMemoryIncidentsRepository();
const logger = createMockLogger() as any;
const bufferStub = { addToBuffer: jest.fn(), addGeoCheckToBuffer: jest.fn(), scheduleNextFlush: jest.fn() };
const statusService = new StatusService(
logger,
bufferStub as any,
monitorsRepo as any,
createStubMonitorStatsRepo() as any,
createStubChecksRepo() as any
);
const messageBuilder = { extractThresholdBreaches: jest.fn().mockReturnValue([]) };
const incidentService = new IncidentService(logger, incidentsRepo, monitorsRepo as any, { findById: jest.fn() } as any, messageBuilder as any);
const notificationsService = { handleNotifications: jest.fn().mockResolvedValue(true) };
let nextResponse: MonitorStatusResponse | null = null;
let nextStatus = true;
let nextCode = 200;
const networkService = {
requestStatus: jest.fn().mockImplementation(() => {
if (nextResponse) {
return Promise.resolve(nextResponse);
}
return Promise.resolve(makeStatusResponse(nextStatus, nextCode));
}),
};
const checkService = {
buildCheck: jest.fn().mockImplementation((response: MonitorStatusResponse) => {
return makeCheck(response.status, response.code);
}),
};
const setNextResponse = (status: boolean, code: number) => {
nextResponse = null;
nextStatus = status;
nextCode = code;
};
const setNextResponseFull = (response: MonitorStatusResponse) => {
nextResponse = response;
};
const maintenanceWindowsRepo = { findByMonitorId: jest.fn().mockResolvedValue([]) };
const helper = new SuperSimpleQueueHelper(
logger,
networkService as any,
statusService as any,
notificationsService as any,
checkService as any,
{ getDBSettings: jest.fn() } as any,
bufferStub as any,
incidentService as any,
maintenanceWindowsRepo as any,
monitorsRepo as any,
{ findAllTeamIds: jest.fn() } as any,
createStubMonitorStatsRepo() as any,
createStubChecksRepo() as any,
incidentsRepo as any,
{ buildGeoCheck: jest.fn() } as any,
{ deleteByMonitorIdsNotIn: jest.fn() } as any
);
return {
monitorsRepo,
incidentsRepo,
statusService,
incidentService,
notificationsService,
networkService,
bufferStub,
maintenanceWindowsRepo,
messageBuilder,
heartbeatJob: helper.getHeartbeatJob(),
setNextResponse,
setNextResponseFull,
};
}
@@ -0,0 +1,51 @@
import type { Notification } from "../../src/types/index.ts";
import type { NotificationMessage } from "../../src/types/notificationMessage.ts";
export const makeNotification = (overrides?: Partial<Notification>): Notification =>
({
address: "https://hooks.example.com/webhook",
accessToken: "token-abc",
homeserverUrl: "https://matrix.example.com",
roomId: "!room:example.com",
...overrides,
}) as Notification;
export const makeMessage = (overrides?: Partial<NotificationMessage>): NotificationMessage => ({
type: "monitor_down",
severity: "critical",
monitor: { id: "mon-1", name: "Test Monitor", url: "https://example.com", type: "http", status: "down" },
content: {
title: "Monitor Down: Test Monitor",
summary: 'Monitor "Test Monitor" is currently down.',
details: ["URL: https://example.com", "Status: Down"],
timestamp: new Date("2025-01-01T00:00:00Z"),
},
clientHost: "https://app.example.com",
metadata: { teamId: "team-1", notificationReason: "status_change" },
...overrides,
});
export const makeMessageWithThresholds = (): NotificationMessage =>
makeMessage({
type: "threshold_breach",
severity: "warning",
monitor: { id: "mon-1", name: "Infra Server", url: "https://infra.example.com", type: "hardware", status: "up" },
content: {
title: "Threshold Exceeded: Infra Server",
summary: "Thresholds exceeded.",
details: ["URL: https://infra.example.com"],
thresholds: [
{ metric: "cpu", currentValue: 90, threshold: 80, unit: "%", formattedValue: "90.0%" },
{ metric: "memory", currentValue: 85, threshold: 70, unit: "%", formattedValue: "85.0%" },
],
timestamp: new Date("2025-01-01T00:00:00Z"),
},
});
export const makeMessageWithIncident = (): NotificationMessage =>
makeMessage({
content: {
...makeMessage().content,
incident: { id: "inc-1", url: "https://app.example.com/incidents/inc-1", createdAt: new Date("2025-01-01T00:00:00Z") },
},
});
@@ -0,0 +1,40 @@
import { describe, expect, it } from "@jest/globals";
import type { INotificationProvider } from "../../src/service/infrastructure/notificationProviders/INotificationProvider.ts";
import type { Notification } from "../../src/types/index.ts";
import type { NotificationMessage } from "../../src/types/notificationMessage.ts";
const makeMessage = (): NotificationMessage => ({
type: "monitor_down",
severity: "critical",
monitor: { id: "mon-1", name: "Test", url: "https://example.com", type: "http", status: "down" },
content: {
title: "Monitor Down: Test",
summary: "Test is down",
details: ["URL: https://example.com"],
timestamp: new Date("2025-01-01T00:00:00Z"),
},
clientHost: "https://app.example.com",
metadata: { teamId: "team-1", notificationReason: "status_change" },
});
export const testNotificationProviderContract = (
name: string,
opts: {
create: () => INotificationProvider;
makeNotification: () => Partial<Notification>;
}
) => {
describe(`INotificationProvider contract: ${name}`, () => {
it("sendTestAlert returns a boolean", async () => {
const provider = opts.create();
const result = await provider.sendTestAlert(opts.makeNotification());
expect(typeof result).toBe("boolean");
});
it("sendMessage returns a boolean", async () => {
const provider = opts.create();
const result = await provider.sendMessage(opts.makeNotification() as Notification, makeMessage());
expect(typeof result).toBe("boolean");
});
});
};
@@ -0,0 +1,54 @@
import { describe, expect, it } from "@jest/globals";
import type { IStatusProvider } from "../../src/service/infrastructure/network/IStatusProvider.ts";
import type { Monitor } from "../../src/types/index.ts";
/**
* Shared contract tests for all IStatusProvider implementations.
* Tests the interface guarantees that every provider must satisfy.
*/
export const testStatusProviderContract = (
name: string,
opts: {
create: () => IStatusProvider<unknown>;
supportedType: string;
unsupportedType: string;
makeMonitor: () => Monitor;
}
) => {
describe(`IStatusProvider contract: ${name}`, () => {
it("has a readonly type property matching the supported type", () => {
const provider = opts.create();
expect(provider.type).toBe(opts.supportedType);
});
it("supports() returns true for its own type", () => {
const provider = opts.create();
expect(provider.supports(opts.supportedType as any)).toBe(true);
});
it("supports() returns false for a different type", () => {
const provider = opts.create();
expect(provider.supports(opts.unsupportedType as any)).toBe(false);
});
it("handle() returns an object with required MonitorStatusResponse fields", async () => {
const provider = opts.create();
try {
const result = await provider.handle(opts.makeMonitor());
expect(result).toEqual(
expect.objectContaining({
monitorId: expect.anything(),
teamId: expect.anything(),
type: expect.anything(),
status: expect.any(Boolean),
code: expect.any(Number),
message: expect.any(String),
})
);
} catch (err: any) {
// Some providers throw AppError on failure — that's also valid behavior
expect(err).toBeDefined();
}
});
});
};
@@ -0,0 +1,76 @@
import { describe, expect, it, beforeEach } from "@jest/globals";
import { createHeartbeatTestHarness, makeMonitor, type HeartbeatTestHarness } from "../helpers/heartbeatTestHarness.ts";
describe("Heartbeat job: down detection", () => {
let h: HeartbeatTestHarness;
beforeEach(() => {
h = createHeartbeatTestHarness();
});
it("transitions monitor to down and creates incident after threshold failures", async () => {
// Monitor starts with full passing window: [true, true, true, true, true]
const monitor = makeMonitor();
h.monitorsRepo.seed(monitor);
// 2 failures: window = [true, true, true, false, false] = 40% < 60%
h.setNextResponse(false, 503);
await h.heartbeatJob(monitor);
await h.heartbeatJob(monitor);
let storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
expect(storedMonitor.status).toBe("up");
expect(h.incidentsRepo.getAll()).toHaveLength(0);
// 3rd failure: window = [true, true, false, false, false] = 60% >= 60%
await h.heartbeatJob(monitor);
storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
expect(storedMonitor.status).toBe("down");
// Incident should have been created
const incidents = h.incidentsRepo.getAll();
expect(incidents).toHaveLength(1);
expect(incidents[0].monitorId).toBe("mon-1");
expect(incidents[0].status).toBe(true);
expect(incidents[0].statusCode).toBe(503);
// Notification should have been triggered
expect(h.notificationsService.handleNotifications).toHaveBeenCalled();
});
it("does not create duplicate incidents on continued failures", async () => {
const monitor = makeMonitor();
h.monitorsRepo.seed(monitor);
h.setNextResponse(false, 500);
// 3 failures trigger down, 4 more continue failing
for (let i = 0; i < 7; i++) {
await h.heartbeatJob(monitor);
}
// Only one incident despite 7 heartbeats
expect(h.incidentsRepo.getAll()).toHaveLength(1);
});
it("stays up when failures are below threshold", async () => {
const monitor = makeMonitor();
h.monitorsRepo.seed(monitor);
// 2 failures: window = [true, true, true, false, false] = 40% < 60%
h.setNextResponse(false, 500);
await h.heartbeatJob(monitor);
await h.heartbeatJob(monitor);
// 3 passes: window = [false, false, true, true, true] = 40% < 60%
h.setNextResponse(true, 200);
await h.heartbeatJob(monitor);
await h.heartbeatJob(monitor);
await h.heartbeatJob(monitor);
const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
expect(storedMonitor.status).toBe("up");
expect(h.incidentsRepo.getAll()).toHaveLength(0);
});
});
@@ -0,0 +1,129 @@
import { describe, expect, it, beforeEach } from "@jest/globals";
import { createHeartbeatTestHarness, makeMonitor, type HeartbeatTestHarness } from "../helpers/heartbeatTestHarness.ts";
import type { MaintenanceWindow } from "../../src/types/maintenanceWindow.ts";
const makeMaintenanceWindow = (overrides?: Partial<MaintenanceWindow>): MaintenanceWindow => {
const now = new Date();
const start = new Date(now.getTime() - 60 * 60 * 1000); // 1 hour ago
const end = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour from now
return {
id: "mw-1",
monitorId: "mon-1",
teamId: "team-1",
active: true,
name: "Test Maintenance",
duration: 2,
durationUnit: "hours",
repeat: 0,
start: start.toISOString(),
end: end.toISOString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
};
describe("Heartbeat job: maintenance windows", () => {
let h: HeartbeatTestHarness;
beforeEach(() => {
h = createHeartbeatTestHarness();
});
it("skips checks and sets monitor to maintenance when window is active", async () => {
const monitor = makeMonitor();
h.monitorsRepo.seed(monitor);
// Configure an active maintenance window covering the current time
h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([makeMaintenanceWindow()]);
await h.heartbeatJob(monitor);
// Monitor status should be set to maintenance
const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
expect(storedMonitor.status).toBe("maintenance");
// No network request should have been made
expect(h.networkService.requestStatus).not.toHaveBeenCalled();
// No check should have been buffered
expect(h.bufferStub.addToBuffer).not.toHaveBeenCalled();
// No incident should have been created
expect(h.incidentsRepo.getAll()).toHaveLength(0);
// No notification should have been sent
expect(h.notificationsService.handleNotifications).not.toHaveBeenCalled();
});
it("does not update status if monitor is already in maintenance", async () => {
const monitor = makeMonitor({ status: "maintenance" });
h.monitorsRepo.seed(monitor);
h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([makeMaintenanceWindow()]);
await h.heartbeatJob(monitor);
// findById reads the stored monitor; updateById should not have been called
// since status was already "maintenance"
const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
expect(storedMonitor.status).toBe("maintenance");
expect(h.networkService.requestStatus).not.toHaveBeenCalled();
});
it("resumes normal checks when maintenance window expires", async () => {
const monitor = makeMonitor();
h.monitorsRepo.seed(monitor);
// First heartbeat: inside maintenance window
h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([makeMaintenanceWindow()]);
await h.heartbeatJob(monitor);
let storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
expect(storedMonitor.status).toBe("maintenance");
// Second heartbeat: maintenance window expired (no windows returned)
h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([]);
h.setNextResponse(true, 200);
await h.heartbeatJob(monitor);
// Network request should have been made this time
expect(h.networkService.requestStatus).toHaveBeenCalledTimes(1);
// Check should have been buffered
expect(h.bufferStub.addToBuffer).toHaveBeenCalledTimes(1);
});
it("skips checks when window is inactive (active: false)", async () => {
const monitor = makeMonitor();
h.monitorsRepo.seed(monitor);
// Inactive maintenance window — should be ignored
h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([makeMaintenanceWindow({ active: false })]);
h.setNextResponse(true, 200);
await h.heartbeatJob(monitor);
// Normal check should proceed since window is inactive
expect(h.networkService.requestStatus).toHaveBeenCalledTimes(1);
expect(h.bufferStub.addToBuffer).toHaveBeenCalledTimes(1);
});
it("skips checks when window is in the past (expired)", async () => {
const monitor = makeMonitor();
h.monitorsRepo.seed(monitor);
// Window ended an hour ago
const pastEnd = new Date(Date.now() - 60 * 60 * 1000);
const pastStart = new Date(pastEnd.getTime() - 2 * 60 * 60 * 1000);
h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([
makeMaintenanceWindow({ start: pastStart.toISOString(), end: pastEnd.toISOString() }),
]);
h.setNextResponse(true, 200);
await h.heartbeatJob(monitor);
// Window is in the past — normal check should proceed
expect(h.networkService.requestStatus).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,61 @@
import { describe, expect, it, beforeEach } from "@jest/globals";
import { createHeartbeatTestHarness, makeMonitor, type HeartbeatTestHarness } from "../helpers/heartbeatTestHarness.ts";
describe("Heartbeat job: notification failure isolation", () => {
let h: HeartbeatTestHarness;
beforeEach(() => {
h = createHeartbeatTestHarness();
});
it("creates incident even when notification dispatch throws", async () => {
const monitor = makeMonitor();
h.monitorsRepo.seed(monitor);
// Make notifications throw
h.notificationsService.handleNotifications.mockRejectedValue(new Error("SMTP connection refused"));
// Drive monitor down
h.setNextResponse(false, 503);
for (let i = 0; i < 3; i++) {
await h.heartbeatJob(monitor);
}
// Incident should still have been created
const incidents = h.incidentsRepo.getAll();
expect(incidents).toHaveLength(1);
expect(incidents[0].status).toBe(true);
expect(incidents[0].statusCode).toBe(503);
// Monitor should still be marked down
const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
expect(storedMonitor.status).toBe("down");
});
it("auto-resolves incident even when recovery notification throws", async () => {
const monitor = makeMonitor();
h.monitorsRepo.seed(monitor);
// Drive monitor down (notifications succeed here)
h.setNextResponse(false, 503);
for (let i = 0; i < 3; i++) {
await h.heartbeatJob(monitor);
}
expect(h.incidentsRepo.getAll()[0].status).toBe(true);
// Now make notifications throw for the recovery
h.notificationsService.handleNotifications.mockRejectedValue(new Error("Slack API timeout"));
// Recover
h.setNextResponse(true, 200);
for (let i = 0; i < 5; i++) {
await h.heartbeatJob(monitor);
}
// Incident should still be resolved despite notification failure
const incidents = h.incidentsRepo.getAll();
expect(incidents).toHaveLength(1);
expect(incidents[0].status).toBe(false);
expect(incidents[0].resolutionType).toBe("automatic");
});
});
@@ -0,0 +1,77 @@
import { describe, expect, it, beforeEach } from "@jest/globals";
import { createHeartbeatTestHarness, makeMonitor, type HeartbeatTestHarness } from "../helpers/heartbeatTestHarness.ts";
describe("Heartbeat job: recovery", () => {
let h: HeartbeatTestHarness;
beforeEach(() => {
h = createHeartbeatTestHarness();
});
it("recovers monitor and auto-resolves incident when checks pass", async () => {
const monitor = makeMonitor();
h.monitorsRepo.seed(monitor);
// 3 failures to trigger down (60% threshold)
h.setNextResponse(false, 503);
for (let i = 0; i < 3; i++) {
await h.heartbeatJob(monitor);
}
expect(h.incidentsRepo.getAll()).toHaveLength(1);
expect(h.incidentsRepo.getAll()[0].status).toBe(true);
// Send passing checks to push failure rate below threshold
// Window after 3 fails: [false, false, false, false, false] (prior trues shifted out)
// Need enough passes to drop below 60%
h.setNextResponse(true, 200);
for (let i = 0; i < 5; i++) {
await h.heartbeatJob(monitor);
}
// Monitor should be back up
const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
expect(storedMonitor.status).toBe("up");
// Incident should be auto-resolved
const incidents = h.incidentsRepo.getAll();
expect(incidents).toHaveLength(1);
expect(incidents[0].status).toBe(false);
expect(incidents[0].resolutionType).toBe("automatic");
expect(incidents[0].endTime).not.toBeNull();
});
it("creates a new incident after recovery if monitor goes down again", async () => {
const monitor = makeMonitor();
h.monitorsRepo.seed(monitor);
// First outage: 3 failures to trigger (window shifts from all-true)
h.setNextResponse(false, 503);
for (let i = 0; i < 3; i++) {
await h.heartbeatJob(monitor);
}
// Recovery: enough passes to drop below threshold
h.setNextResponse(true, 200);
for (let i = 0; i < 5; i++) {
await h.heartbeatJob(monitor);
}
// Second outage: 3 more failures to trigger again
h.setNextResponse(false, 502);
for (let i = 0; i < 3; i++) {
await h.heartbeatJob(monitor);
}
const incidents = h.incidentsRepo.getAll();
expect(incidents).toHaveLength(2);
// First incident resolved
expect(incidents[0].status).toBe(false);
expect(incidents[0].resolutionType).toBe("automatic");
expect(incidents[0].statusCode).toBe(503);
// Second incident active
expect(incidents[1].status).toBe(true);
expect(incidents[1].statusCode).toBe(502);
});
});
@@ -0,0 +1,153 @@
import { describe, expect, it, beforeEach } from "@jest/globals";
import { createHeartbeatTestHarness, makeMonitor, type HeartbeatTestHarness } from "../helpers/heartbeatTestHarness.ts";
import type { MonitorStatusResponse } from "../../src/types/network.ts";
import type { HardwareStatusPayload } from "../../src/types/network.ts";
const makeHardwareResponse = (cpuUsage: number): MonitorStatusResponse<HardwareStatusPayload> => ({
monitorId: "mon-1",
teamId: "team-1",
type: "hardware",
status: true,
code: 200,
message: "OK",
responseTime: 50,
payload: {
data: {
cpu: { usage_percent: cpuUsage, temperature: [] },
memory: { usage_percent: 0.3 },
disk: [{ usage_percent: 0.2 }],
},
},
});
describe("Heartbeat job: hardware threshold breach", () => {
let h: HeartbeatTestHarness;
beforeEach(() => {
h = createHeartbeatTestHarness();
});
it("transitions to breached and creates incident after alert counter hits zero", async () => {
// Hardware monitor with CPU threshold at 80% (stored as 80, compared as 80/100 = 0.8)
// Alert counters start at 5, decrement each breach, trigger at 0
const monitor = makeMonitor({
type: "hardware",
status: "up",
cpuAlertThreshold: 80,
cpuAlertCounter: 5,
memoryAlertThreshold: 100,
memoryAlertCounter: 5,
diskAlertThreshold: 100,
diskAlertCounter: 5,
tempAlertThreshold: 100,
tempAlertCounter: 5,
selectedDisks: [],
});
h.monitorsRepo.seed(monitor);
// CPU at 90% (0.9) exceeds threshold of 80% (0.8)
h.setNextResponseFull(makeHardwareResponse(0.9));
// Configure message builder for when incident is created
h.messageBuilder.extractThresholdBreaches.mockReturnValue([{ metric: "cpu", formattedValue: "90%", threshold: 80, unit: "%" }]);
// 4 heartbeats: counter goes 5→4→3→2→1, status still "up"
for (let i = 0; i < 4; i++) {
await h.heartbeatJob(monitor);
}
let storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
expect(storedMonitor.status).toBe("up");
expect(storedMonitor.cpuAlertCounter).toBe(1);
expect(h.incidentsRepo.getAll()).toHaveLength(0);
// 5th heartbeat: counter hits 0, status transitions to "breached"
await h.heartbeatJob(monitor);
storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
expect(storedMonitor.status).toBe("breached");
expect(storedMonitor.cpuAlertCounter).toBe(0);
// Incident created with statusCode 9999 and threshold message
const incidents = h.incidentsRepo.getAll();
expect(incidents).toHaveLength(1);
expect(incidents[0].statusCode).toBe(9999);
expect(incidents[0].message).toBe("CPU: 90% (threshold: 80%)");
expect(incidents[0].status).toBe(true);
// Notification should have been triggered
expect(h.notificationsService.handleNotifications).toHaveBeenCalled();
});
it("recovers from breached to up when metrics return to normal", async () => {
// Start with a monitor already in breached state with counter at 0
const monitor = makeMonitor({
type: "hardware",
status: "breached",
cpuAlertThreshold: 80,
cpuAlertCounter: 0,
memoryAlertThreshold: 100,
memoryAlertCounter: 5,
diskAlertThreshold: 100,
diskAlertCounter: 5,
tempAlertThreshold: 100,
tempAlertCounter: 5,
selectedDisks: [],
});
h.monitorsRepo.seed(monitor);
// Also seed an active incident for this monitor
await h.incidentsRepo.create({
monitorId: "mon-1",
teamId: "team-1",
status: true,
statusCode: 9999,
message: "CPU: 90% (threshold: 80%)",
});
// CPU at 50% (0.5) — below threshold of 80% (0.8)
h.setNextResponseFull(makeHardwareResponse(0.5));
await h.heartbeatJob(monitor);
// Monitor should recover to "up"
const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
expect(storedMonitor.status).toBe("up");
// Counter resets to 5 when not breaching
expect(storedMonitor.cpuAlertCounter).toBe(5);
// Incident should be auto-resolved
const incidents = h.incidentsRepo.getAll();
expect(incidents).toHaveLength(1);
expect(incidents[0].status).toBe(false);
expect(incidents[0].resolutionType).toBe("automatic");
});
it("does not create duplicate incidents while remaining breached", async () => {
const monitor = makeMonitor({
type: "hardware",
status: "up",
cpuAlertThreshold: 80,
cpuAlertCounter: 5,
memoryAlertThreshold: 100,
memoryAlertCounter: 5,
diskAlertThreshold: 100,
diskAlertCounter: 5,
tempAlertThreshold: 100,
tempAlertCounter: 5,
selectedDisks: [],
});
h.monitorsRepo.seed(monitor);
h.setNextResponseFull(makeHardwareResponse(0.9));
h.messageBuilder.extractThresholdBreaches.mockReturnValue([{ metric: "cpu", formattedValue: "90%", threshold: 80, unit: "%" }]);
// 5 heartbeats to trigger breach, then 5 more while still breaching
for (let i = 0; i < 10; i++) {
await h.heartbeatJob(monitor);
}
// Only one incident despite 10 heartbeats
expect(h.incidentsRepo.getAll()).toHaveLength(1);
});
});
@@ -0,0 +1,216 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { IncidentService } from "../../src/service/business/incidentService.ts";
import { InMemoryIncidentsRepository } from "../helpers/InMemoryIncidentsRepository.ts";
import { createMockLogger } from "../helpers/createMockLogger.ts";
import type { IMonitorsRepository, IUsersRepository } from "../../src/repositories/index.ts";
import type { INotificationMessageBuilder } from "../../src/service/infrastructure/notificationMessageBuilder.ts";
import type { Monitor } from "../../src/types/monitor.ts";
import type { MonitorActionDecision } from "../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
name: "Test Monitor",
type: "http",
url: "https://example.com",
status: "down",
...overrides,
}) as Monitor;
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: false,
incidentReason: null,
notificationReason: null,
...overrides,
});
// ── Test suite ───────────────────────────────────────────────────────────────
describe("Incident lifecycle (integration)", () => {
let repo: InMemoryIncidentsRepository;
let service: IncidentService;
let monitorsRepo: jest.Mocked<IMonitorsRepository>;
let usersRepo: jest.Mocked<IUsersRepository>;
let messageBuilder: jest.Mocked<INotificationMessageBuilder>;
beforeEach(() => {
repo = new InMemoryIncidentsRepository();
monitorsRepo = { findById: jest.fn() } as unknown as jest.Mocked<IMonitorsRepository>;
usersRepo = { findById: jest.fn() } as unknown as jest.Mocked<IUsersRepository>;
messageBuilder = { extractThresholdBreaches: jest.fn() } as unknown as jest.Mocked<INotificationMessageBuilder>;
service = new IncidentService(createMockLogger() as any, repo, monitorsRepo, usersRepo, messageBuilder);
});
// ── Creation ─────────────────────────────────────────────────────────────
it("creates an incident when monitor goes down", async () => {
const monitor = makeMonitor({ status: "down" });
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
const incident = await service.handleIncident(monitor, 503, decision);
expect(incident).not.toBeNull();
expect(incident!.monitorId).toBe("mon-1");
expect(incident!.teamId).toBe("team-1");
expect(incident!.status).toBe(true);
expect(incident!.statusCode).toBe(503);
expect(incident!.endTime).toBeNull();
expect(incident!.resolutionType).toBeNull();
const stored = repo.getAll();
expect(stored).toHaveLength(1);
expect(stored[0].id).toBe(incident!.id);
});
// ── Idempotency ──────────────────────────────────────────────────────────
it("does not create a duplicate incident for the same monitor", async () => {
const monitor = makeMonitor({ status: "down" });
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
const first = await service.handleIncident(monitor, 503, decision);
const second = await service.handleIncident(monitor, 503, decision);
expect(second!.id).toBe(first!.id);
expect(repo.getAll()).toHaveLength(1);
});
// ── Auto-resolve ─────────────────────────────────────────────────────────
it("auto-resolves an incident when monitor recovers", async () => {
const monitor = makeMonitor({ status: "down" });
const createDecision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
const created = await service.handleIncident(monitor, 503, createDecision);
const resolveDecision = makeDecision({ shouldResolveIncident: true });
const resolved = await service.handleIncident(monitor, 200, resolveDecision);
expect(resolved).not.toBeNull();
expect(resolved!.id).toBe(created!.id);
expect(resolved!.status).toBe(false);
expect(resolved!.endTime).toBeDefined();
expect(resolved!.resolutionType).toBe("automatic");
});
// ── Resolve with nothing active ──────────────────────────────────────────
it("returns null when resolving with no active incident", async () => {
const monitor = makeMonitor({ status: "up" });
const decision = makeDecision({ shouldResolveIncident: true });
const result = await service.handleIncident(monitor, 200, decision);
expect(result).toBeNull();
});
// ── Manual resolution ────────────────────────────────────────────────────
it("manually resolves an active incident with comment", async () => {
const monitor = makeMonitor({ status: "down" });
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
const created = await service.handleIncident(monitor, 500, decision);
const resolved = await service.resolveIncident(created!.id, "user-1", "team-1", "Root cause identified", "user@test.com");
expect(resolved.status).toBe(false);
expect(resolved.resolutionType).toBe("manual");
expect(resolved.resolvedBy).toBe("user-1");
expect(resolved.resolvedByEmail).toBe("user@test.com");
expect(resolved.comment).toBe("Root cause identified");
expect(resolved.endTime).toBeDefined();
});
it("throws when manually resolving an already-resolved incident", async () => {
const monitor = makeMonitor({ status: "down" });
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
const created = await service.handleIncident(monitor, 500, decision);
await service.resolveIncident(created!.id, "user-1", "team-1");
await expect(service.resolveIncident(created!.id, "user-1", "team-1")).rejects.toThrow("Incident not found");
});
// ── Threshold breach ─────────────────────────────────────────────────────
it("creates a threshold breach incident with statusCode 9999 and descriptive message", async () => {
const monitor = makeMonitor({ status: "breached", type: "hardware" });
(messageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([
{ metric: "cpu", formattedValue: "92%", threshold: 80, unit: "%" },
{ metric: "memory", formattedValue: "88%", threshold: 85, unit: "%" },
]);
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
const incident = await service.handleIncident(monitor, 200, decision, { monitorId: "mon-1" } as any);
expect(incident!.statusCode).toBe(9999);
expect(incident!.message).toBe("CPU: 92% (threshold: 80%), MEMORY: 88% (threshold: 85%)");
});
// ── No action ────────────────────────────────────────────────────────────
it("returns null when no action is needed", async () => {
const monitor = makeMonitor({ status: "up" });
const decision = makeDecision();
const result = await service.handleIncident(monitor, 200, decision);
expect(result).toBeNull();
expect(repo.getAll()).toHaveLength(0);
});
// ── Full lifecycle ───────────────────────────────────────────────────────
it("handles a complete lifecycle: create -> manual resolve -> new incident -> auto-resolve", async () => {
const monitor = makeMonitor({ status: "down" });
// First outage
const first = await service.handleIncident(monitor, 503, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }));
expect(first!.status).toBe(true);
// Manually resolved
await service.resolveIncident(first!.id, "user-1", "team-1", "Restarted server");
// Second outage — new incident since previous was resolved
const second = await service.handleIncident(monitor, 502, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }));
expect(second!.id).not.toBe(first!.id);
expect(second!.statusCode).toBe(502);
// Auto-resolve
const resolved = await service.handleIncident(monitor, 200, makeDecision({ shouldResolveIncident: true }));
expect(resolved!.id).toBe(second!.id);
expect(resolved!.status).toBe(false);
expect(resolved!.resolutionType).toBe("automatic");
// Both incidents stored, both resolved
const all = repo.getAll();
expect(all).toHaveLength(2);
expect(all.every((i) => i.status === false)).toBe(true);
});
// ── Cross-monitor isolation ──────────────────────────────────────────────
it("incidents for different monitors do not interfere", async () => {
const monitorA = makeMonitor({ id: "mon-a", status: "down" });
const monitorB = makeMonitor({ id: "mon-b", status: "down" });
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" });
const incidentA = await service.handleIncident(monitorA, 500, decision);
const incidentB = await service.handleIncident(monitorB, 502, decision);
expect(incidentA!.id).not.toBe(incidentB!.id);
expect(repo.getAll()).toHaveLength(2);
// Resolving monitor A does not affect monitor B
const resolved = await service.handleIncident(monitorA, 200, makeDecision({ shouldResolveIncident: true }));
expect(resolved!.id).toBe(incidentA!.id);
const bStillActive = await repo.findActiveByMonitorId("mon-b", "team-1");
expect(bStillActive).not.toBeNull();
expect(bStillActive!.status).toBe(true);
});
});
-230
View File
@@ -1,230 +0,0 @@
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,
})
);
});
});
});
-101
View File
@@ -1,101 +0,0 @@
import { describe, expect, it, jest } from "@jest/globals";
import SuperSimpleQueueHelper from "../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
import type { Monitor } from "../src/types/monitor.ts";
const createLogger = () => ({ info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() });
const createHelper = (overrides?: Partial<ConstructorParameters<typeof SuperSimpleQueueHelper>[0]>) => {
const maintenanceWindowsRepository = {
findByMonitorId: jest.fn().mockResolvedValue([]),
};
const statusServiceMock = {
updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1" }, statusChanged: true, prevStatus: false }),
};
const helper = new SuperSimpleQueueHelper({
logger: createLogger(),
networkService: { requestStatus: jest.fn() },
statusService: statusServiceMock,
notificationsService: { handleNotifications: jest.fn().mockResolvedValue(undefined) },
checkService: { buildCheck: jest.fn().mockResolvedValue({}) },
buffer: { addToBuffer: jest.fn() },
incidentService: { handleIncident: jest.fn().mockResolvedValue(undefined) },
maintenanceWindowsRepository,
...overrides,
});
return { helper, maintenanceWindowsRepository };
};
describe("SuperSimpleQueueHelper", () => {
describe("getMonitorJob", () => {
it("skips execution when monitor is in maintenance window", async () => {
const { helper } = createHelper();
const spy = jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true);
const job = helper.getMonitorJob();
await job({ id: "m1", teamId: "team", interval: 60000 } as Monitor);
expect(helper["networkService"].requestStatus).not.toHaveBeenCalled();
expect(helper["logger"].debug).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("Monitor m1 is in maintenance window") })
);
spy.mockRestore();
});
it("processes monitor status and notifications when active", async () => {
const networkResponse = { monitor: { id: "m1" }, status: true };
const updatedMonitor = { id: "m1", status: true };
const { helper } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
statusService: {
updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: updatedMonitor, statusChanged: true, prevStatus: false, code: 200 }),
},
notificationsService: { handleNotifications: jest.fn().mockResolvedValue(undefined) },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getMonitorJob();
const monitor = { id: "m1", teamId: "team" } as Monitor;
await job(monitor);
expect(helper["networkService"].requestStatus).toHaveBeenCalledWith(monitor);
});
it("throws when monitor id is missing", async () => {
const { helper } = createHelper();
const job = helper.getMonitorJob();
await expect(job({} as Monitor)).rejects.toThrow("No monitor id");
expect(helper["logger"].warn).toHaveBeenCalled();
});
});
describe("isInMaintenanceWindow", () => {
it("returns true when an active window spans now", async () => {
const now = new Date();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now.getTime() - 1000).toISOString(),
end: new Date(now.getTime() + 1000).toISOString(),
repeat: 0,
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
});
it("returns true when repeat interval advances window into current time", async () => {
const now = Date.now();
const { helper, maintenanceWindowsRepository } = createHelper();
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now - 7200000).toISOString(),
end: new Date(now - 6600000).toISOString(),
repeat: 3600000,
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
});
it("returns false when no active windows exist", async () => {
const { helper } = createHelper();
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
});
});
});
@@ -0,0 +1,228 @@
import { describe, expect, it, jest } from "@jest/globals";
import { AdvancedMatcher } from "../../../../src/service/infrastructure/network/AdvancedMatcher.ts";
import type { Monitor } from "../../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createMockJmespath = () => ({
search: jest.fn((data: any, expr: string) => {
// Simple dot-path evaluation for tests
return expr.split(".").reduce((acc: any, key: string) => acc?.[key], data);
}),
});
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
useAdvancedMatching: false,
jsonPath: undefined,
matchMethod: undefined,
expectedValue: undefined,
...overrides,
}) as Monitor;
// ── Tests ────────────────────────────────────────────────────────────────────
describe("AdvancedMatcher", () => {
describe("validate", () => {
it("returns ok when advanced matching is disabled", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate({ data: "test" }, makeMonitor({ useAdvancedMatching: false }));
expect(result).toEqual({ ok: true, message: "Success" });
});
// ── jsonPath extraction ──────────────────────────────────────────
describe("jsonPath extraction", () => {
it("extracts value using jsonPath", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate({ status: "healthy" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "status" }));
expect(result).toEqual({ ok: true, message: "Success", extracted: "healthy" });
});
it("returns error when jsonPath evaluation throws", () => {
const jmespath = {
search: jest.fn().mockImplementation(() => {
throw new Error("bad expression");
}),
};
const matcher = new AdvancedMatcher(jmespath as any);
const result = matcher.validate({ data: "test" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "invalid[" }));
expect(result).toEqual({ ok: false, message: "Error evaluating JSON path" });
});
});
// ── expectedValue matching ───────────────────────────────────────
describe("expectedValue with matchMethod", () => {
it("matches with 'equal' method", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate(
{ status: "ok" },
makeMonitor({ useAdvancedMatching: true, jsonPath: "status", matchMethod: "equal", expectedValue: "ok" })
);
expect(result.ok).toBe(true);
});
it("fails with 'equal' method on mismatch", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate(
{ status: "error" },
makeMonitor({ useAdvancedMatching: true, jsonPath: "status", matchMethod: "equal", expectedValue: "ok" })
);
expect(result.ok).toBe(false);
expect(result.message).toBe("Expected value did not match");
});
it("matches with 'include' method", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate(
{ msg: "server is healthy" },
makeMonitor({ useAdvancedMatching: true, jsonPath: "msg", matchMethod: "include", expectedValue: "healthy" })
);
expect(result.ok).toBe(true);
});
it("fails with 'include' method when substring not found", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate(
{ msg: "server is down" },
makeMonitor({ useAdvancedMatching: true, jsonPath: "msg", matchMethod: "include", expectedValue: "healthy" })
);
expect(result.ok).toBe(false);
});
it("matches with 'regex' method", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate(
{ code: "200" },
makeMonitor({ useAdvancedMatching: true, jsonPath: "code", matchMethod: "regex", expectedValue: "^2\\d{2}$" })
);
expect(result.ok).toBe(true);
});
it("fails with 'regex' method on mismatch", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate(
{ code: "500" },
makeMonitor({ useAdvancedMatching: true, jsonPath: "code", matchMethod: "regex", expectedValue: "^2\\d{2}$" })
);
expect(result.ok).toBe(false);
});
it("defaults to 'equal' comparison when matchMethod is undefined", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate({ val: "exact" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "val", expectedValue: "exact" }));
expect(result.ok).toBe(true);
});
it("defaults to 'equal' comparison for unknown matchMethod", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate(
{ val: "exact" },
makeMonitor({ useAdvancedMatching: true, jsonPath: "val", matchMethod: "unknown" as any, expectedValue: "exact" })
);
expect(result.ok).toBe(true);
});
it("compares entire payload when jsonPath is not set", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate(
"hello",
makeMonitor({ useAdvancedMatching: true, jsonPath: undefined, matchMethod: "equal", expectedValue: "hello" })
);
expect(result.ok).toBe(true);
expect(result.extracted).toBe("hello");
});
it("includes extracted value in result", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate(
{ nested: { value: "42" } },
makeMonitor({ useAdvancedMatching: true, jsonPath: "nested.value", matchMethod: "equal", expectedValue: "42" })
);
expect(result.extracted).toBe("42");
});
});
// ── falsy value check (no expectedValue) ────────────────────────
describe("falsy value check (no expectedValue)", () => {
it("returns ok for truthy extracted value", () => {
const matcher = new AdvancedMatcher(createMockJmespath() as any);
const result = matcher.validate({ status: "up" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "status" }));
expect(result.ok).toBe(true);
expect(result.extracted).toBe("up");
});
it("returns not ok for false", () => {
const jmespath = { search: jest.fn().mockReturnValue(false) };
const matcher = new AdvancedMatcher(jmespath as any);
const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "missing" }));
expect(result.ok).toBe(false);
expect(result.message).toBe("Extracted value is falsy");
});
it("returns not ok for string 'false'", () => {
const jmespath = { search: jest.fn().mockReturnValue("false") };
const matcher = new AdvancedMatcher(jmespath as any);
const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" }));
expect(result.ok).toBe(false);
});
it("returns not ok for undefined", () => {
const jmespath = { search: jest.fn().mockReturnValue(undefined) };
const matcher = new AdvancedMatcher(jmespath as any);
const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" }));
expect(result.ok).toBe(false);
});
it("returns not ok for null", () => {
const jmespath = { search: jest.fn().mockReturnValue(null) };
const matcher = new AdvancedMatcher(jmespath as any);
const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" }));
expect(result.ok).toBe(false);
});
it("returns ok for zero (not in falsy list)", () => {
const jmespath = { search: jest.fn().mockReturnValue(0) };
const matcher = new AdvancedMatcher(jmespath as any);
const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" }));
expect(result.ok).toBe(true);
});
it("returns ok for empty string (not in falsy list)", () => {
const jmespath = { search: jest.fn().mockReturnValue("") };
const matcher = new AdvancedMatcher(jmespath as any);
const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" }));
expect(result.ok).toBe(true);
});
});
});
});
@@ -0,0 +1,343 @@
import { describe, expect, it, jest } from "@jest/globals";
import { DockerProvider } from "../../../../src/service/infrastructure/network/DockerProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
import type { Monitor } from "../../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "docker",
url: "my-container",
...overrides,
}) as Monitor;
const makeContainer = (overrides?: Record<string, any>) => ({
Id: "abc123def456abc123def456abc123def456abc123def456abc123def456abcd",
Names: ["/my-container"],
State: "running",
...overrides,
});
const createMockDocker = (containers: any[] = [makeContainer()], inspectResult?: any) => {
const inspect = jest.fn().mockResolvedValue(inspectResult ?? { State: { Status: "running" } });
const getContainer = jest.fn().mockReturnValue({ inspect });
const instance = {
listContainers: jest.fn().mockResolvedValue(containers),
getContainer,
};
const DockerLib = jest.fn().mockReturnValue(instance) as any;
return { DockerLib, instance, inspect, getContainer };
};
const createProvider = (opts?: { containers?: any[]; inspectResult?: any }) => {
const logger = createMockLogger();
const { DockerLib, instance, inspect, getContainer } = createMockDocker(opts?.containers, opts?.inspectResult);
const provider = new DockerProvider(logger as any, DockerLib);
return { provider, logger, docker: instance, inspect, getContainer };
};
// ── Contract ─────────────────────────────────────────────────────────────────
testStatusProviderContract("DockerProvider", {
create: () => createProvider().provider,
supportedType: "docker",
unsupportedType: "http",
makeMonitor: () => makeMonitor(),
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("DockerProvider", () => {
// ── Container matching ───────────────────────────────────────────────
describe("container matching", () => {
it("matches by exact container name", async () => {
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ url: "my-container" }));
expect(result.status).toBe(true);
expect(result.code).toBe(200);
});
it("matches by exact full ID (64 chars)", async () => {
const fullId = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd";
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ url: fullId }));
expect(result.status).toBe(true);
});
it("matches by partial ID prefix", async () => {
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ url: "abc123" }));
expect(result.status).toBe(true);
});
it("strips leading slashes from input", async () => {
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ url: "///my-container" }));
expect(result.status).toBe(true);
});
it("matches case-insensitively", async () => {
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ url: "MY-CONTAINER" }));
expect(result.status).toBe(true);
});
it("returns 404 when no container matches", async () => {
const { provider, logger } = createProvider();
const result = await provider.handle(makeMonitor({ url: "nonexistent" }));
expect(result.status).toBe(false);
expect(result.code).toBe(404);
expect(result.message).toBe("Docker container not found");
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("No container found"),
})
);
});
});
// ── Ambiguity detection ──────────────────────────────────────────────
describe("ambiguity detection", () => {
it("returns error when input matches both exact name and partial ID", async () => {
// Container whose name matches the input AND whose ID starts with the input
const container = makeContainer({
Id: "my-containerabc123def456abc123def456abc123def456abc123def456abcd",
Names: ["/my-container"],
});
const { provider, logger } = createProvider({ containers: [container] });
const result = await provider.handle(makeMonitor({ url: "my-container" }));
expect(result.status).toBe(false);
expect(result.code).toBe(NETWORK_ERROR);
expect(result.message).toContain("Ambiguous");
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("Ambiguous"),
})
);
});
it("reports 'exact ID' in ambiguity message when exact ID and name both match", async () => {
const fullId = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd";
const container = makeContainer({
Id: fullId,
Names: ["/" + fullId],
});
const { provider } = createProvider({ containers: [container] });
const result = await provider.handle(makeMonitor({ url: fullId }));
expect(result.message).toContain("Using exact ID");
});
it("reports 'exact name' in ambiguity message when name and partial ID match", async () => {
const container = makeContainer({
Id: "my-containerabc123def456abc123def456abc123def456abc123def456abcd",
Names: ["/my-container"],
});
const { provider } = createProvider({ containers: [container] });
const result = await provider.handle(makeMonitor({ url: "my-container" }));
expect(result.message).toContain("Using exact name");
});
});
// ── Container inspection ─────────────────────────────────────────────
describe("container inspection", () => {
it("returns running status for running container", async () => {
const { provider } = createProvider({ inspectResult: { State: { Status: "running" } } });
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(true);
expect(result.message).toBe("Docker container status fetched successfully");
});
it("returns not-running status for stopped container", async () => {
const { provider } = createProvider({ inspectResult: { State: { Status: "exited" } } });
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(false);
});
it("returns NETWORK_ERROR when inspect response is null", async () => {
const logger = createMockLogger();
const inspect = jest.fn().mockResolvedValue(null);
const getContainer = jest.fn().mockReturnValue({ inspect });
const instance = { listContainers: jest.fn().mockResolvedValue([makeContainer()]), getContainer };
const DockerLib = jest.fn().mockReturnValue(instance) as any;
const provider = new DockerProvider(logger as any, DockerLib);
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(false);
expect(result.code).toBe(NETWORK_ERROR);
expect(result.message).toBe("No response from Docker container inspect");
});
it("returns false status when inspect response has no State", async () => {
const { provider } = createProvider({ inspectResult: {} });
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(false);
});
it("returns false status when inspect response has State but no Status", async () => {
const { provider } = createProvider({ inspectResult: { State: {} } });
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(false);
});
it("handles inspect error with Docker-specific error", async () => {
const { provider } = createProvider();
// Make inspect throw a Docker error
const dockerErr = Object.assign(new Error("container not found"), {
statusCode: 404,
reason: "no such container",
json: { message: "Container not found" },
});
provider["docker"] = {
listContainers: jest.fn().mockResolvedValue([makeContainer()]),
getContainer: jest.fn().mockReturnValue({
inspect: jest.fn().mockRejectedValue(dockerErr),
}),
} as any;
// Need to re-assign; simpler to use the createProvider mock
const { provider: p2 } = createProvider();
// Override getContainer to return failing inspect
p2["docker"].getContainer = jest.fn().mockReturnValue({
inspect: jest.fn().mockRejectedValue(dockerErr),
});
const result = await p2.handle(makeMonitor());
expect(result.status).toBe(false);
expect(result.code).toBe(404);
expect(result.message).toBe("Container not found");
});
it("handles inspect error with Docker error using reason fallback", async () => {
const { provider } = createProvider();
const dockerErr = Object.assign(new Error("error"), {
statusCode: 500,
reason: "internal error",
json: {},
});
provider["docker"].getContainer = jest.fn().mockReturnValue({
inspect: jest.fn().mockRejectedValue(dockerErr),
});
const result = await provider.handle(makeMonitor());
expect(result.message).toBe("internal error");
});
it("handles inspect error with Docker error using Error message fallback", async () => {
const { provider } = createProvider();
const dockerErr = Object.assign(new Error("base error"), {
statusCode: 503,
reason: undefined,
json: undefined,
});
provider["docker"].getContainer = jest.fn().mockReturnValue({
inspect: jest.fn().mockRejectedValue(dockerErr),
});
const result = await provider.handle(makeMonitor());
expect(result.message).toBe("base error");
});
it("handles inspect error with NETWORK_ERROR when no statusCode", async () => {
const { provider } = createProvider();
const dockerErr = Object.assign(new Error("unknown"), {
statusCode: undefined,
});
provider["docker"].getContainer = jest.fn().mockReturnValue({
inspect: jest.fn().mockRejectedValue(dockerErr),
});
const result = await provider.handle(makeMonitor());
expect(result.code).toBe(NETWORK_ERROR);
});
it("handles inspect error with standard Error (not Docker error)", async () => {
const { provider } = createProvider();
provider["docker"].getContainer = jest.fn().mockReturnValue({
inspect: jest.fn().mockRejectedValue(new Error("ECONNREFUSED")),
});
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(false);
expect(result.message).toBe("ECONNREFUSED");
});
it("handles inspect error with non-Error non-Docker thrown value", async () => {
const { provider } = createProvider();
provider["docker"].getContainer = jest.fn().mockReturnValue({
inspect: jest.fn().mockRejectedValue("string error"),
});
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(false);
expect(result.message).toBe("Failed to fetch Docker container information");
});
});
// ── Outer catch ──────────────────────────────────────────────────────
describe("outer error handling", () => {
it("throws AppError when containerInput is missing", async () => {
const { provider } = createProvider();
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("Container name or ID is required for Docker monitor");
});
it("throws AppError when listContainers throws", async () => {
const { provider } = createProvider();
provider["docker"].listContainers = jest.fn().mockRejectedValue(new Error("Docker daemon unavailable"));
await expect(provider.handle(makeMonitor())).rejects.toThrow("Docker daemon unavailable");
});
it("throws AppError with default message for non-Error thrown values", async () => {
const { provider } = createProvider();
provider["docker"].listContainers = jest.fn().mockRejectedValue(null);
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing Docker request");
});
});
});
@@ -0,0 +1,191 @@
import { describe, expect, it, jest } from "@jest/globals";
import { GameProvider } from "../../../../src/service/infrastructure/network/GameProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
import type { Monitor } from "../../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "game",
url: "play.example.com",
port: 25565,
gameId: "minecraft",
...overrides,
}) as Monitor;
const createMockGameDig = (state?: Record<string, any> | null) => ({
query: jest.fn().mockResolvedValue(
state ?? {
name: "My Server",
map: "world",
players: [],
ping: 45,
}
),
});
const createProvider = (gameDig?: any) => {
const logger = createMockLogger();
const dig = gameDig ?? createMockGameDig();
const provider = new GameProvider(logger as any, dig as any);
return { provider, logger, gameDig: dig };
};
// ── Contract ─────────────────────────────────────────────────────────────────
testStatusProviderContract("GameProvider", {
create: () => createProvider().provider,
supportedType: "game",
unsupportedType: "http",
makeMonitor: () => makeMonitor(),
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("GameProvider", () => {
it("returns success with server state", async () => {
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
monitorId: "mon-1",
teamId: "team-1",
type: "game",
status: true,
code: 200,
message: "Success",
responseTime: 45,
})
);
});
it("queries with correct host, port, and gameId", async () => {
const { provider, gameDig } = createProvider();
await provider.handle(makeMonitor({ url: "https://play.example.com/path", port: 27015, gameId: "csgo" }));
expect(gameDig.query).toHaveBeenCalledWith({
type: "csgo",
host: "play.example.com",
port: 27015,
});
});
it("strips protocol and path from url for host", async () => {
const { provider, gameDig } = createProvider();
await provider.handle(makeMonitor({ url: "http://game.server.com:8080/info" }));
expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ host: "game.server.com" }));
});
it("defaults gameId to 'unknown' when not set", async () => {
const { provider, gameDig } = createProvider();
await provider.handle(makeMonitor({ gameId: undefined }));
expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ type: "unknown" }));
});
it("defaults port to 0 when not set", async () => {
const { provider, gameDig } = createProvider();
await provider.handle(makeMonitor({ port: undefined }));
expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ port: 0 }));
});
it("defaults responseTime to 0 when ping is undefined", async () => {
const { provider } = createProvider(createMockGameDig({ ping: undefined }));
const result = await provider.handle(makeMonitor());
expect(result.responseTime).toBe(0);
});
it("returns failure when query resolves to undefined (caught error)", async () => {
const gameDig = { query: jest.fn().mockRejectedValue(new Error("Server offline")) };
// GameProvider catches the error internally via .catch()
const { provider, logger } = createProvider(gameDig);
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
status: false,
code: NETWORK_ERROR,
message: "No response from game server",
responseTime: 0,
})
);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: "Server offline",
method: "handle",
})
);
});
it("logs non-Error thrown values from query", async () => {
const gameDig = { query: jest.fn().mockRejectedValue("string error") };
const { provider, logger } = createProvider(gameDig);
await provider.handle(makeMonitor());
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "string error" }));
});
it("uses empty string as host when url is undefined", async () => {
const { provider, gameDig } = createProvider();
await provider.handle(makeMonitor({ url: undefined }));
expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ host: "" }));
});
it("throws AppError with default message when error stringifies to empty", async () => {
const gameDig = {
query: jest.fn().mockReturnValue({
catch: () => {
throw new Error("");
},
}),
};
const { provider } = createProvider(gameDig);
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing game server check");
});
it("throws AppError with default message for non-Error that stringifies to empty", async () => {
const gameDig = {
query: jest.fn().mockReturnValue({
catch: () => {
throw "";
},
}),
};
const { provider } = createProvider(gameDig);
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing game server check");
});
it("throws AppError with Error message in outer catch", async () => {
const gameDig = {
query: jest.fn().mockReturnValue({
catch: () => {
throw new Error("unexpected");
},
}),
};
const { provider } = createProvider(gameDig);
await expect(provider.handle(makeMonitor())).rejects.toThrow("unexpected");
});
});
@@ -0,0 +1,378 @@
import { describe, expect, it, jest } from "@jest/globals";
import { GrpcProvider } from "../../../../src/service/infrastructure/network/GrpcProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import type { Monitor } from "../../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "grpc",
url: "grpc.example.com",
port: 50051,
grpcServiceName: "my.Service",
ignoreTlsErrors: false,
...overrides,
}) as Monitor;
const createMockClient = (behavior: "serving" | "not-serving" | "error" | "grpc-error" = "serving") => {
const close = jest.fn();
const Check = jest.fn((_req: any, _opts: any, cb: Function) => {
if (behavior === "serving") {
cb(null, { status: "SERVING" });
} else if (behavior === "not-serving") {
cb(null, { status: "NOT_SERVING" });
} else if (behavior === "error") {
cb({ code: 14, details: "Connection refused", message: "unavailable" });
} else if (behavior === "grpc-error") {
cb({ code: 2, message: "Unknown error" });
}
});
return { Check, close };
};
const createMockGrpc = (client?: ReturnType<typeof createMockClient>) => {
const c = client ?? createMockClient();
const Health = jest.fn().mockReturnValue(c);
return {
grpc: {
loadPackageDefinition: jest.fn().mockReturnValue({
grpc: { health: { v1: { Health } } },
}),
credentials: {
createInsecure: jest.fn().mockReturnValue("insecure-creds"),
createSsl: jest.fn().mockReturnValue("ssl-creds"),
},
},
protoLoader: {
loadSync: jest.fn().mockReturnValue({}),
},
Health,
client: c,
};
};
const createProvider = (opts?: { behavior?: "serving" | "not-serving" | "error" | "grpc-error" }) => {
const client = createMockClient(opts?.behavior ?? "serving");
const mocks = createMockGrpc(client);
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
return { provider, ...mocks, client };
};
// ── Contract ─────────────────────────────────────────────────────────────────
testStatusProviderContract("GrpcProvider", {
create: () => createProvider().provider,
supportedType: "grpc",
unsupportedType: "http",
makeMonitor: () => makeMonitor(),
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("GrpcProvider", () => {
// ── Success paths ────────────────────────────────────────────────────
describe("success responses", () => {
it("returns healthy status for SERVING response", async () => {
const { provider } = createProvider({ behavior: "serving" });
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
monitorId: "mon-1",
teamId: "team-1",
type: "grpc",
status: true,
code: 200,
message: expect.stringContaining("SERVING"),
})
);
expect(result.payload).toEqual(
expect.objectContaining({
grpcStatusCode: 0,
grpcStatusName: "OK",
serviceName: "my.Service",
servingStatus: "SERVING",
})
);
});
it("returns unhealthy status for NOT_SERVING response", async () => {
const { provider } = createProvider({ behavior: "not-serving" });
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(false);
expect(result.code).toBe(5000);
expect(result.message).toContain("NOT_SERVING");
});
it("defaults to UNKNOWN when response has no status", async () => {
const client = { Check: jest.fn((_r: any, _o: any, cb: Function) => cb(null, {})), close: jest.fn() };
const mocks = createMockGrpc(client as any);
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
const result = await provider.handle(makeMonitor());
expect(result.payload).toEqual(expect.objectContaining({ servingStatus: "UNKNOWN" }));
});
it("defaults to UNKNOWN when response is undefined", async () => {
const client = { Check: jest.fn((_r: any, _o: any, cb: Function) => cb(null, undefined)), close: jest.fn() };
const mocks = createMockGrpc(client as any);
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
const result = await provider.handle(makeMonitor());
expect(result.payload).toEqual(expect.objectContaining({ servingStatus: "UNKNOWN" }));
});
});
// ── gRPC errors ──────────────────────────────────────────────────────
describe("gRPC errors", () => {
it("returns failure with gRPC error details", async () => {
const { provider } = createProvider({ behavior: "error" });
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(false);
expect(result.code).toBe(14);
expect(result.message).toBe("Connection refused");
expect(result.payload).toEqual(
expect.objectContaining({
grpcStatusCode: 14,
grpcStatusName: "UNAVAILABLE",
servingStatus: "UNKNOWN",
})
);
});
it("falls back to message when details is missing", async () => {
const client = {
Check: jest.fn((_r: any, _o: any, cb: Function) => cb({ code: 2, message: "Unknown error" })),
close: jest.fn(),
};
const mocks = createMockGrpc(client as any);
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
const result = await provider.handle(makeMonitor());
expect(result.message).toBe("Unknown error");
});
it("uses default message and code when error has no code/details/message", async () => {
const client = {
Check: jest.fn((_r: any, _o: any, cb: Function) => cb({})),
close: jest.fn(),
};
const mocks = createMockGrpc(client as any);
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
const result = await provider.handle(makeMonitor());
expect(result.code).toBe(5000);
expect(result.message).toBe("gRPC error");
expect(result.payload).toEqual(expect.objectContaining({ grpcStatusCode: -1, grpcStatusName: "UNKNOWN" }));
});
it("defaults grpcCode to 5000 when error has no grpcCode", async () => {
const client = {
Check: jest.fn((_r: any, _o: any, cb: Function) => {
const err = new Error("test") as any;
// no grpcPayload or grpcCode
cb(err);
}),
close: jest.fn(),
};
const mocks = createMockGrpc(client as any);
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
const result = await provider.handle(makeMonitor());
expect(result.code).toBe(5000);
});
it("includes grpcPayload in error result", async () => {
const { provider } = createProvider({ behavior: "error" });
const result = await provider.handle(makeMonitor());
expect(result.payload).toEqual(
expect.objectContaining({
grpcStatusCode: 14,
grpcStatusName: "UNAVAILABLE",
})
);
});
it("defaults code, message, and payload when error lacks gRPC properties", async () => {
// client.Check throws synchronously — timeRequest catches a plain object with no gRPC props
const client = {
Check: jest.fn(() => {
throw { unexpected: true };
}),
close: jest.fn(),
};
const mocks = createMockGrpc(client as any);
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
const result = await provider.handle(makeMonitor());
expect(result.code).toBe(5000);
expect(result.message).toBe("gRPC health check failed");
expect(result.payload).toBeNull();
});
});
// ── Credentials ──────────────────────────────────────────────────────
describe("credentials", () => {
it("uses insecure credentials by default", async () => {
const { provider, grpc } = createProvider();
await provider.handle(makeMonitor({ ignoreTlsErrors: false }));
expect(grpc.credentials.createInsecure).toHaveBeenCalled();
});
it("uses SSL credentials with checkServerIdentity override when ignoreTlsErrors is true", async () => {
const { provider, grpc } = createProvider();
await provider.handle(makeMonitor({ ignoreTlsErrors: true }));
expect(grpc.credentials.createSsl).toHaveBeenCalledWith(null, null, null, {
checkServerIdentity: expect.any(Function),
});
// Verify checkServerIdentity returns undefined
const opts = (grpc.credentials.createSsl as jest.Mock).mock.calls[0][3];
expect(opts.checkServerIdentity()).toBeUndefined();
});
});
// ── URL parsing ──────────────────────────────────────────────────────
describe("URL and target construction", () => {
it("strips protocol from URL", async () => {
const { provider, Health } = createProvider();
await provider.handle(makeMonitor({ url: "https://grpc.example.com", port: 443 }));
expect(Health).toHaveBeenCalledWith("grpc.example.com:443", expect.anything());
});
it("uses grpcServiceName in Check request", async () => {
const { provider, client } = createProvider();
await provider.handle(makeMonitor({ grpcServiceName: "health.v1" }));
expect(client.Check).toHaveBeenCalledWith(
{ service: "health.v1" },
expect.objectContaining({ deadline: expect.any(Date) }),
expect.any(Function)
);
});
it("defaults grpcServiceName to empty string when not set", async () => {
const { provider, client } = createProvider();
await provider.handle(makeMonitor({ grpcServiceName: undefined }));
expect(client.Check).toHaveBeenCalledWith({ service: "" }, expect.anything(), expect.any(Function));
});
});
// ── Validation ───────────────────────────────────────────────────────
describe("validation", () => {
it("throws AppError when url is missing", async () => {
const { provider } = createProvider();
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("Monitor host is required");
});
it("throws AppError when port is missing", async () => {
const { provider } = createProvider();
await expect(provider.handle(makeMonitor({ port: undefined }))).rejects.toThrow("Monitor port is required");
});
});
// ── Outer catch ──────────────────────────────────────────────────────
describe("outer error handling", () => {
it("throws AppError when protoLoader.loadSync throws", async () => {
const mocks = createMockGrpc();
mocks.protoLoader.loadSync = jest.fn().mockImplementation(() => {
throw new Error("Proto file not found");
});
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
await expect(provider.handle(makeMonitor())).rejects.toThrow("Proto file not found");
});
it("throws AppError with default message when Error has empty message", async () => {
const mocks = createMockGrpc();
mocks.protoLoader.loadSync = jest.fn().mockImplementation(() => {
throw new Error("");
});
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing gRPC health check");
});
it("throws AppError with stringified message for non-Error thrown values", async () => {
const mocks = createMockGrpc();
mocks.protoLoader.loadSync = jest.fn().mockImplementation(() => {
throw "proto load failed";
});
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
await expect(provider.handle(makeMonitor())).rejects.toThrow("proto load failed");
});
});
// ── getGrpcStatusName ────────────────────────────────────────────────
describe("gRPC status name mapping", () => {
const statusCodes: [number, string][] = [
[0, "OK"],
[1, "CANCELLED"],
[2, "UNKNOWN"],
[3, "INVALID_ARGUMENT"],
[4, "DEADLINE_EXCEEDED"],
[5, "NOT_FOUND"],
[6, "ALREADY_EXISTS"],
[7, "PERMISSION_DENIED"],
[8, "RESOURCE_EXHAUSTED"],
[9, "FAILED_PRECONDITION"],
[10, "ABORTED"],
[11, "OUT_OF_RANGE"],
[12, "UNIMPLEMENTED"],
[13, "INTERNAL"],
[14, "UNAVAILABLE"],
[15, "DATA_LOSS"],
[16, "UNAUTHENTICATED"],
[99, "UNKNOWN"],
];
it.each(statusCodes)("maps gRPC code %i to %s", async (code, expectedName) => {
const client = {
Check: jest.fn((_r: any, _o: any, cb: Function) => cb({ code, details: "test" })),
close: jest.fn(),
};
const mocks = createMockGrpc(client as any);
const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any);
const result = await provider.handle(makeMonitor());
expect(result.payload).toEqual(expect.objectContaining({ grpcStatusName: expectedName }));
});
});
});
@@ -0,0 +1,91 @@
import { describe, expect, it, jest } from "@jest/globals";
import { HardwareProvider } from "../../../../src/service/infrastructure/network/HardwareProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import type { HttpProvider } from "../../../../src/service/infrastructure/network/HttpProvider.ts";
import type { Monitor } from "../../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "hardware",
url: "https://capture.example.com",
...overrides,
}) as Monitor;
const createMockHttpProvider = () =>
({
handle: jest.fn().mockResolvedValue({
monitorId: "mon-1",
teamId: "team-1",
type: "hardware",
status: true,
code: 200,
message: "OK",
responseTime: 50,
payload: { data: { cpu: { usage_percent: 0.5 } } },
}),
}) as unknown as jest.Mocked<HttpProvider>;
const createProvider = () => {
const httpProvider = createMockHttpProvider();
const provider = new HardwareProvider(httpProvider);
return { provider, httpProvider };
};
// ── Contract ─────────────────────────────────────────────────────────────────
testStatusProviderContract("HardwareProvider", {
create: () => createProvider().provider,
supportedType: "hardware",
unsupportedType: "http",
makeMonitor: () => makeMonitor(),
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("HardwareProvider", () => {
it("delegates to httpProvider.handle", async () => {
const { provider, httpProvider } = createProvider();
const monitor = makeMonitor();
await provider.handle(monitor);
expect(httpProvider.handle).toHaveBeenCalledWith(monitor);
});
it("returns the response from httpProvider", async () => {
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
monitorId: "mon-1",
status: true,
code: 200,
})
);
});
it("throws AppError when url is missing", async () => {
const { provider } = createProvider();
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for Hardware monitor");
});
it("throws AppError when httpProvider.handle throws", async () => {
const { provider, httpProvider } = createProvider();
(httpProvider.handle as jest.Mock).mockRejectedValue(new Error("connection failed"));
await expect(provider.handle(makeMonitor())).rejects.toThrow("connection failed");
});
it("throws AppError with default message for non-Error thrown values", async () => {
const { provider, httpProvider } = createProvider();
(httpProvider.handle as jest.Mock).mockRejectedValue("string error");
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing Hardware request");
});
});
@@ -0,0 +1,348 @@
import { describe, expect, it, jest } from "@jest/globals";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
import type { Monitor } from "../../../../src/types/index.ts";
// ── Mocks ────────────────────────────────────────────────────────────────────
jest.unstable_mockModule("cacheable-lookup", () => ({
default: jest.fn().mockImplementation(() => ({})),
}));
const mockGot = jest.fn();
// got instance returned by got.extend()
mockGot.mockImplementation(() => Promise.resolve());
(mockGot as any).extend = jest.fn().mockReturnValue(mockGot);
jest.unstable_mockModule("got", () => ({
type: { Got: {} },
HTTPError: class HTTPError extends Error {
response: any;
timings: any;
constructor(msg: string, response?: any, timings?: any) {
super(msg);
this.name = "HTTPError";
this.response = response;
this.timings = timings;
}
},
RequestError: class RequestError extends Error {
response: any;
timings: any;
constructor(msg: string, response?: any, timings?: any) {
super(msg);
this.name = "RequestError";
this.response = response;
this.timings = timings;
}
},
}));
const { HttpProvider } = await import("../../../../src/service/infrastructure/network/HttpProvider.ts");
const gotModule = await import("got");
const { HTTPError, RequestError } = gotModule;
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "http",
url: "https://example.com",
secret: undefined,
jsonPath: undefined,
ignoreTlsErrors: false,
useAdvancedMatching: false,
matchMethod: undefined,
expectedValue: undefined,
...overrides,
}) as Monitor;
const createMockMatcher = (result?: { ok: boolean; message: string; extracted?: unknown }) => ({
validate: jest.fn().mockReturnValue(result ?? { ok: true, message: "Success" }),
});
const makeGotResponse = (overrides?: Record<string, any>) => ({
ok: true,
statusCode: 200,
statusMessage: "OK",
headers: { "content-type": "text/html" },
body: "<html></html>",
timings: { phases: { total: 100 } },
...overrides,
});
const createProvider = (matcher?: any) => {
const advancedMatcher = matcher ?? createMockMatcher();
const provider = new HttpProvider(mockGot as any, advancedMatcher);
return { provider, advancedMatcher };
};
// ── Contract ─────────────────────────────────────────────────────────────────
testStatusProviderContract("HttpProvider", {
create: () => {
mockGot.mockResolvedValue(makeGotResponse());
return createProvider().provider;
},
supportedType: "http",
unsupportedType: "ping",
makeMonitor: () => makeMonitor(),
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("HttpProvider", () => {
// ── Success paths ────────────────────────────────────────────────────
describe("success responses", () => {
it("returns success for a standard HTML response", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
monitorId: "mon-1",
teamId: "team-1",
type: "http",
status: true,
code: 200,
message: "OK",
responseTime: 100,
})
);
});
it("parses JSON body when content-type is application/json", async () => {
mockGot.mockResolvedValue(
makeGotResponse({
headers: { "content-type": "application/json" },
body: '{"status":"ok"}',
})
);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.payload).toEqual({ status: "ok" });
});
it("returns raw body when JSON parsing fails", async () => {
mockGot.mockResolvedValue(
makeGotResponse({
headers: { "content-type": "application/json" },
body: "not-json",
})
);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.payload).toBe("not-json");
});
it("returns raw body for non-JSON content", async () => {
mockGot.mockResolvedValue(makeGotResponse({ body: "<html>test</html>" }));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.payload).toBe("<html>test</html>");
});
it("passes Authorization header when secret is set", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor({ secret: "my-token" }));
expect(mockGot).toHaveBeenCalledWith(
"https://example.com",
expect.objectContaining({
headers: { Authorization: "Bearer my-token" },
})
);
});
it("passes undefined headers when no secret", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor({ secret: undefined }));
expect(mockGot).toHaveBeenCalledWith(
"https://example.com",
expect.objectContaining({
headers: undefined,
})
);
});
it("defaults responseTime to 0 when timings.phases.total is undefined", async () => {
mockGot.mockResolvedValue(makeGotResponse({ timings: { phases: { total: undefined } } }));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.responseTime).toBe(0);
});
it("defaults statusMessage to 'OK' when undefined", async () => {
mockGot.mockResolvedValue(makeGotResponse({ statusMessage: undefined }));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.message).toBe("OK");
});
it("uses empty string for content-type when header is missing", async () => {
mockGot.mockResolvedValue(makeGotResponse({ headers: {} }));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
// Should treat as non-JSON
expect(result.payload).toBe("<html></html>");
});
});
// ── jsonPath + non-JSON response ─────────────────────────────────────
describe("jsonPath validation", () => {
it("returns failure when jsonPath is set but response is not JSON", async () => {
mockGot.mockResolvedValue(makeGotResponse({ headers: { "content-type": "text/html" } }));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ jsonPath: "status" }));
expect(result.status).toBe(false);
expect(result.message).toBe("Response is not JSON");
});
it("defaults responseTime to 0 in non-JSON jsonPath response when total is undefined", async () => {
mockGot.mockResolvedValue(
makeGotResponse({
headers: { "content-type": "text/html" },
timings: { phases: { total: undefined } },
})
);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor({ jsonPath: "status" }));
expect(result.responseTime).toBe(0);
});
});
// ── AdvancedMatcher integration ──────────────────────────────────────
describe("advanced matching", () => {
it("uses matcher result for status and message", async () => {
mockGot.mockResolvedValue(makeGotResponse());
const matcher = createMockMatcher({ ok: false, message: "Mismatch", extracted: "value" });
const { provider } = createProvider(matcher);
const result = await provider.handle(makeMonitor({ useAdvancedMatching: true }));
expect(result.status).toBe(false);
expect(result.message).toBe("Mismatch");
expect(result.extracted).toBe("value");
});
it("sets status to false when response.ok is false even if matcher passes", async () => {
mockGot.mockResolvedValue(makeGotResponse({ ok: false, statusCode: 301 }));
const matcher = createMockMatcher({ ok: true, message: "Success" });
const { provider } = createProvider(matcher);
const result = await provider.handle(makeMonitor());
// status = response.ok && matchResult.ok
expect(result.status).toBe(false);
});
});
// ── Error handling ───────────────────────────────────────────────────
describe("error handling", () => {
it("handles HTTPError with response and timings", async () => {
const err = new HTTPError("Not Found");
(err as any).response = { statusCode: 404 };
(err as any).timings = { phases: { total: 50 } };
mockGot.mockRejectedValue(err);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
status: false,
code: 404,
message: "Not Found",
responseTime: 50,
})
);
});
it("handles RequestError with response and timings", async () => {
const err = new RequestError("ECONNREFUSED");
(err as any).response = { statusCode: undefined };
(err as any).timings = { phases: { total: undefined } };
mockGot.mockRejectedValue(err);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.code).toBe(NETWORK_ERROR);
expect(result.responseTime).toBe(0);
});
it("handles HTTPError without response (defaults to NETWORK_ERROR)", async () => {
const err = new HTTPError("Timeout");
(err as any).response = undefined;
(err as any).timings = undefined;
mockGot.mockRejectedValue(err);
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.code).toBe(NETWORK_ERROR);
expect(result.responseTime).toBe(0);
});
it("handles generic Error (non-HTTPError/RequestError)", async () => {
mockGot.mockRejectedValue(new Error("DNS lookup failed"));
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
status: false,
code: NETWORK_ERROR,
message: "DNS lookup failed",
responseTime: 0,
})
);
});
it("handles non-Error thrown values", async () => {
mockGot.mockRejectedValue("string error");
const { provider } = createProvider();
const result = await provider.handle(makeMonitor());
expect(result.message).toBe("string error");
expect(result.code).toBe(NETWORK_ERROR);
});
it("throws when url is missing", async () => {
const { provider } = createProvider();
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for HTTP monitor");
});
});
});
@@ -0,0 +1,165 @@
import { describe, expect, it, jest } from "@jest/globals";
import { PageSpeedProvider } from "../../../../src/service/infrastructure/network/PageSpeedProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import type { HttpProvider } from "../../../../src/service/infrastructure/network/HttpProvider.ts";
import type { ISettingsService } from "../../../../src/service/system/settingsService.ts";
import type { Monitor } from "../../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "pagespeed",
url: "https://example.com",
...overrides,
}) as Monitor;
const createMockHttpProvider = () =>
({
handle: jest.fn().mockResolvedValue({
monitorId: "mon-1",
teamId: "team-1",
type: "pagespeed",
status: true,
code: 200,
message: "OK",
responseTime: 2000,
payload: { lighthouseResult: {} },
}),
}) as unknown as jest.Mocked<HttpProvider>;
const createMockSettingsService = (apiKey?: string) =>
({
getDBSettings: jest.fn().mockResolvedValue({ pagespeedApiKey: apiKey }),
}) as unknown as jest.Mocked<ISettingsService>;
const createProvider = (opts?: { apiKey?: string }) => {
const logger = createMockLogger();
const httpProvider = createMockHttpProvider();
const settingsService = createMockSettingsService(opts?.apiKey);
const provider = new PageSpeedProvider(httpProvider, settingsService, logger as any);
return { provider, httpProvider, settingsService, logger };
};
// ── Contract ─────────────────────────────────────────────────────────────────
testStatusProviderContract("PageSpeedProvider", {
create: () => createProvider({ apiKey: "test-key" }).provider,
supportedType: "pagespeed",
unsupportedType: "http",
makeMonitor: () => makeMonitor(),
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("PageSpeedProvider", () => {
it("delegates to httpProvider with PageSpeed API URL", async () => {
const { provider, httpProvider } = createProvider({ apiKey: "my-key" });
await provider.handle(makeMonitor({ url: "https://example.com" }));
expect(httpProvider.handle).toHaveBeenCalledWith(
expect.objectContaining({
url: expect.stringContaining("pagespeedonline.googleapis.com"),
})
);
});
it("includes API key in URL when available", async () => {
const { provider, httpProvider } = createProvider({ apiKey: "my-key" });
await provider.handle(makeMonitor());
const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url;
expect(calledUrl).toContain("key=my-key");
});
it("omits API key and logs warning when not configured", async () => {
const { provider, httpProvider, logger } = createProvider({ apiKey: undefined });
await provider.handle(makeMonitor());
const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url;
expect(calledUrl).not.toContain("key=");
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("API key not found"),
service: "PageSpeedProvider",
})
);
});
it("encodes the monitor URL in the PageSpeed API URL", async () => {
const { provider, httpProvider } = createProvider({ apiKey: "key" });
await provider.handle(makeMonitor({ url: "https://example.com/path?q=1" }));
const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url;
expect(calledUrl).toContain(encodeURIComponent("https://example.com/path?q=1"));
});
it("includes all PageSpeed categories in the URL", async () => {
const { provider, httpProvider } = createProvider({ apiKey: "key" });
await provider.handle(makeMonitor());
const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url;
expect(calledUrl).toContain("category=seo");
expect(calledUrl).toContain("category=accessibility");
expect(calledUrl).toContain("category=best-practices");
expect(calledUrl).toContain("category=performance");
});
it("passes all monitor properties through to httpProvider", async () => {
const { provider, httpProvider } = createProvider({ apiKey: "key" });
const monitor = makeMonitor({ id: "mon-5", teamId: "team-9" });
await provider.handle(monitor);
expect(httpProvider.handle).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-5", teamId: "team-9" }));
});
it("throws AppError when url is missing", async () => {
const { provider } = createProvider();
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for PageSpeed monitor");
});
it("throws AppError when httpProvider.handle throws", async () => {
const { provider, httpProvider } = createProvider({ apiKey: "key" });
(httpProvider.handle as jest.Mock).mockRejectedValue(new Error("timeout"));
await expect(provider.handle(makeMonitor())).rejects.toThrow("timeout");
});
it("throws AppError with default message for non-Error thrown values", async () => {
const { provider, httpProvider } = createProvider({ apiKey: "key" });
(httpProvider.handle as jest.Mock).mockRejectedValue("string error");
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing PageSpeed request");
});
it("handles null apiKey from DB settings", async () => {
const { provider, logger } = createProvider({ apiKey: null as any });
await provider.handle(makeMonitor());
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("API key not found") }));
});
it("handles null dbSettings response", async () => {
const logger = createMockLogger();
const httpProvider = createMockHttpProvider();
const settingsService = {
getDBSettings: jest.fn().mockResolvedValue(null),
} as unknown as jest.Mocked<ISettingsService>;
const provider = new PageSpeedProvider(httpProvider, settingsService, logger as any);
await provider.handle(makeMonitor());
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("API key not found") }));
});
});
@@ -0,0 +1,139 @@
import { describe, expect, it, jest } from "@jest/globals";
import { PingProvider } from "../../../../src/service/infrastructure/network/PingProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import type { Monitor } from "../../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "ping",
url: "example.com",
...overrides,
}) as Monitor;
const createMockPing = (result?: Partial<{ alive: boolean; time: number | string }>) => ({
promise: {
probe: jest.fn().mockResolvedValue({
alive: true,
time: 25,
...result,
}),
},
});
// ── Contract ─────────────────────────────────────────────────────────────────
testStatusProviderContract("PingProvider", {
create: () => new PingProvider(createMockPing() as any),
supportedType: "ping",
unsupportedType: "http",
makeMonitor: () => makeMonitor(),
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("PingProvider", () => {
it("returns success when host is alive", async () => {
const provider = new PingProvider(createMockPing({ alive: true, time: 30 }) as any);
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
monitorId: "mon-1",
teamId: "team-1",
type: "ping",
status: true,
code: 200,
message: "Success",
responseTime: 30,
})
);
});
it("returns failure when host is not alive", async () => {
const provider = new PingProvider(createMockPing({ alive: false, time: 0 }) as any);
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
status: false,
code: 5000,
message: "Ping failed",
})
);
});
it("parses string time values", async () => {
const provider = new PingProvider(createMockPing({ alive: true, time: "42.5" }) as any);
const result = await provider.handle(makeMonitor());
expect(result.responseTime).toBe(42.5);
});
it("defaults responseTime to 0 for non-numeric time", async () => {
const provider = new PingProvider(createMockPing({ alive: true, time: "unknown" }) as any);
const result = await provider.handle(makeMonitor());
expect(result.responseTime).toBe(0);
});
it("defaults status to false when alive is undefined", async () => {
const provider = new PingProvider(createMockPing({ alive: undefined as any }) as any);
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(false);
});
it("sanitizes URL by stripping protocol and path", async () => {
const mockPing = createMockPing();
const provider = new PingProvider(mockPing as any);
await provider.handle(makeMonitor({ url: "https://example.com/path?query=1" }));
expect(mockPing.promise.probe).toHaveBeenCalledWith("example.com");
});
it("sanitizes URL by stripping port", async () => {
const mockPing = createMockPing();
const provider = new PingProvider(mockPing as any);
await provider.handle(makeMonitor({ url: "example.com:8080" }));
expect(mockPing.promise.probe).toHaveBeenCalledWith("example.com");
});
it("throws AppError when url is missing", async () => {
const provider = new PingProvider(createMockPing() as any);
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for ping monitor");
});
it("throws AppError when ping.probe rejects", async () => {
const mockPing = { promise: { probe: jest.fn().mockRejectedValue(new Error("Host unreachable")) } };
const provider = new PingProvider(mockPing as any);
await expect(provider.handle(makeMonitor())).rejects.toThrow("Host unreachable");
});
it("throws AppError when probe returns no response", async () => {
const mockPing = { promise: { probe: jest.fn().mockResolvedValue(null) } };
const provider = new PingProvider(mockPing as any);
await expect(provider.handle(makeMonitor())).rejects.toThrow("No response from ping");
});
it("throws AppError with stringified message for non-Error throws", async () => {
const mockPing = { promise: { probe: jest.fn().mockRejectedValue(42) } };
const provider = new PingProvider(mockPing as any);
await expect(provider.handle(makeMonitor())).rejects.toThrow("42");
});
});
@@ -0,0 +1,270 @@
import { describe, expect, it, jest } from "@jest/globals";
import { PortProvider } from "../../../../src/service/infrastructure/network/PortProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
import type { Monitor } from "../../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "port",
url: "example.com",
port: 443,
...overrides,
}) as Monitor;
const createMockNet = (behavior: "connect" | "timeout" | "error" = "connect") => {
const socket = {
setTimeout: jest.fn(),
on: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
};
const createConnection = jest.fn((opts: any, onConnect: () => void) => {
if (behavior === "connect") {
// Simulate immediate connection
process.nextTick(onConnect);
} else if (behavior === "timeout") {
process.nextTick(() => {
const timeoutCb = socket.on.mock.calls.find((c: any) => c[0] === "timeout");
// Simulate via setTimeout handler
const setTimeoutCb = socket.setTimeout.mock.calls[0];
if (setTimeoutCb) {
// The timeout event handler is registered via socket.on("timeout", ...)
}
});
} else if (behavior === "error") {
process.nextTick(() => {
const errorCb = socket.on.mock.calls.find((c: any) => c[0] === "error");
if (errorCb) {
errorCb[1](new Error("ECONNREFUSED"));
}
});
}
return socket;
});
return { createConnection, __socket: socket } as any;
};
const createSuccessNet = () => {
const socket = {
setTimeout: jest.fn(),
on: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
};
return {
createConnection: jest.fn((_opts: any, onConnect: () => void) => {
process.nextTick(onConnect);
return socket;
}),
} as any;
};
const createErrorNet = (error: Error = new Error("ECONNREFUSED")) => {
const socket = {
setTimeout: jest.fn(),
on: jest.fn((event: string, cb: Function) => {
if (event === "error") {
process.nextTick(() => cb(error));
}
}),
end: jest.fn(),
destroy: jest.fn(),
};
return {
createConnection: jest.fn(() => socket),
} as any;
};
const createTimeoutNet = () => {
const socket = {
setTimeout: jest.fn(),
on: jest.fn((event: string, cb: Function) => {
if (event === "timeout") {
process.nextTick(() => cb());
}
}),
end: jest.fn(),
destroy: jest.fn(),
};
return {
createConnection: jest.fn(() => socket),
} as any;
};
// ── Contract ─────────────────────────────────────────────────────────────────
testStatusProviderContract("PortProvider", {
create: () => new PortProvider(createSuccessNet()),
supportedType: "port",
unsupportedType: "http",
makeMonitor: () => makeMonitor(),
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("PortProvider", () => {
it("returns success when port connection succeeds", async () => {
const provider = new PortProvider(createSuccessNet());
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
monitorId: "mon-1",
teamId: "team-1",
type: "port",
status: true,
code: 200,
message: "Port check successful",
payload: { success: true },
})
);
expect(result.responseTime).toBeGreaterThanOrEqual(0);
});
it("returns failure when connection errors", async () => {
const provider = new PortProvider(createErrorNet());
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
status: false,
code: NETWORK_ERROR,
message: "ECONNREFUSED",
payload: { success: false },
})
);
});
it("returns failure with generic message for non-Error connection error", async () => {
const net = createErrorNet();
// Override the on handler to throw a non-Error
const socket = {
setTimeout: jest.fn(),
on: jest.fn((event: string, cb: Function) => {
if (event === "error") {
process.nextTick(() => cb("string error"));
}
}),
end: jest.fn(),
destroy: jest.fn(),
};
net.createConnection = jest.fn(() => socket);
const provider = new PortProvider(net);
const result = await provider.handle(makeMonitor());
expect(result.message).toBe("Port check failed");
});
it("returns failure when connection times out", async () => {
const provider = new PortProvider(createTimeoutNet());
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
status: false,
code: NETWORK_ERROR,
message: "Connection timeout",
})
);
});
it("throws AppError when url is missing", async () => {
const provider = new PortProvider(createSuccessNet());
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL and port are required for port monitoring");
});
it("throws AppError when port is missing", async () => {
const provider = new PortProvider(createSuccessNet());
await expect(provider.handle(makeMonitor({ port: undefined }))).rejects.toThrow("URL and port are required for port monitoring");
});
it("returns failure with generic message when timeRequest catches non-Error", async () => {
const socket = {
setTimeout: jest.fn(),
on: jest.fn((event: string, cb: Function) => {
if (event === "error") {
process.nextTick(() => cb({ notAnError: true }));
}
}),
end: jest.fn(),
destroy: jest.fn(),
};
const net = { createConnection: jest.fn(() => socket) } as any;
const provider = new PortProvider(net);
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(false);
expect(result.message).toBe("Port check failed");
});
it("throws AppError wrapping the original Error message", async () => {
const provider = new PortProvider(createSuccessNet());
try {
await provider.handle(makeMonitor({ url: "" }));
expect.unreachable("should have thrown");
} catch (err: any) {
expect(err.service).toBe("PortProvider");
expect(err.method).toBe("handle");
}
});
it("throws AppError when an unexpected error occurs in outer try", async () => {
const provider = new PortProvider(createSuccessNet());
const monitor = makeMonitor();
let calls = 0;
Object.defineProperty(monitor, "url", {
get() {
calls++;
if (calls === 1) throw new Error("getter exploded");
return "example.com";
},
});
await expect(provider.handle(monitor)).rejects.toThrow("getter exploded");
});
it("throws AppError with fallback message when Error has empty message", async () => {
const provider = new PortProvider(createSuccessNet());
const monitor = makeMonitor();
let calls = 0;
Object.defineProperty(monitor, "url", {
get() {
calls++;
if (calls === 1) throw new Error("");
return "example.com";
},
});
await expect(provider.handle(monitor)).rejects.toThrow("Error performing port check");
});
it("throws AppError with stringified message for non-Error thrown values", async () => {
const provider = new PortProvider(createSuccessNet());
const monitor = makeMonitor();
let calls = 0;
Object.defineProperty(monitor, "url", {
get() {
calls++;
if (calls === 1) throw 42;
return "example.com";
},
});
await expect(provider.handle(monitor)).rejects.toThrow("42");
});
});
@@ -0,0 +1,44 @@
import { describe, expect, it } from "@jest/globals";
import { timeRequest, NETWORK_ERROR, PING_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
describe("network utils", () => {
describe("timeRequest", () => {
it("returns response and responseTime on success", async () => {
const result = await timeRequest(async () => "hello");
expect(result.response).toBe("hello");
expect(result.responseTime).toBeGreaterThanOrEqual(0);
expect(result.error).toBeNull();
});
it("returns error and responseTime on failure", async () => {
const err = new Error("boom");
const result = await timeRequest(async () => {
throw err;
});
expect(result.response).toBeNull();
expect(result.responseTime).toBeGreaterThanOrEqual(0);
expect(result.error).toBe(err);
});
it("measures elapsed time", async () => {
const result = await timeRequest(async () => {
await new Promise((r) => setTimeout(r, 50));
return "done";
});
expect(result.responseTime).toBeGreaterThanOrEqual(40);
});
});
describe("constants", () => {
it("NETWORK_ERROR is 5000", () => {
expect(NETWORK_ERROR).toBe(5000);
});
it("PING_ERROR is 5001", () => {
expect(PING_ERROR).toBe(5001);
});
});
});
@@ -0,0 +1,194 @@
import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals";
import { WebSocketProvider } from "../../../../src/service/infrastructure/network/WebSocketProvider.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
import type { Monitor } from "../../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "websocket",
url: "wss://example.com/ws",
ignoreTlsErrors: false,
...overrides,
}) as Monitor;
const createMockWS = (behavior: "open" | "error" = "open") => {
return jest.fn().mockImplementation((_url: string, _opts: any) => {
const handlers: Record<string, Function> = {};
const ws = {
on: jest.fn((event: string, cb: Function) => {
handlers[event] = cb;
if (behavior === "open" && event === "open") {
process.nextTick(() => cb());
}
if (behavior === "error" && event === "error") {
process.nextTick(() => cb(new Error("Connection failed")));
}
}),
close: jest.fn(),
};
return ws;
}) as any;
};
// ── Contract ─────────────────────────────────────────────────────────────────
testStatusProviderContract("WebSocketProvider", {
create: () => new WebSocketProvider(createMockWS("open")),
supportedType: "websocket",
unsupportedType: "http",
makeMonitor: () => makeMonitor(),
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("WebSocketProvider", () => {
it("returns success when connection opens", async () => {
const provider = new WebSocketProvider(createMockWS("open"));
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
monitorId: "mon-1",
teamId: "team-1",
type: "websocket",
status: true,
code: 200,
message: "WebSocket check successful",
payload: { connected: true },
})
);
});
it("returns failure when connection errors", async () => {
const provider = new WebSocketProvider(createMockWS("error"));
const result = await provider.handle(makeMonitor());
expect(result).toEqual(
expect.objectContaining({
status: false,
code: NETWORK_ERROR,
message: "Connection failed",
payload: { connected: false },
})
);
});
it("returns failure with generic message for non-Error connection error", async () => {
const WS = jest.fn().mockImplementation(() => ({
on: jest.fn((event: string, cb: Function) => {
if (event === "error") {
process.nextTick(() => cb("string error"));
}
}),
close: jest.fn(),
})) as any;
const provider = new WebSocketProvider(WS);
const result = await provider.handle(makeMonitor());
expect(result.message).toBe("WebSocket check failed");
});
it("passes rejectUnauthorized: false when ignoreTlsErrors is true", async () => {
const WS = jest.fn().mockImplementation((_url: string, opts: any) => {
expect(opts.rejectUnauthorized).toBe(false);
return {
on: jest.fn((event: string, cb: Function) => {
if (event === "open") process.nextTick(() => cb());
}),
close: jest.fn(),
};
}) as any;
const provider = new WebSocketProvider(WS);
await provider.handle(makeMonitor({ ignoreTlsErrors: true }));
expect(WS).toHaveBeenCalled();
});
it("throws AppError when url is missing", async () => {
const provider = new WebSocketProvider(createMockWS("open"));
await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for WebSocket monitoring");
});
it("throws AppError when an unexpected error occurs in outer try", async () => {
const provider = new WebSocketProvider(createMockWS("open"));
const monitor = makeMonitor();
let calls = 0;
Object.defineProperty(monitor, "url", {
get() {
calls++;
if (calls === 1) throw new Error("getter failed");
return "wss://example.com";
},
});
await expect(provider.handle(monitor)).rejects.toThrow("getter failed");
});
it("throws AppError with fallback message when Error has empty message", async () => {
const provider = new WebSocketProvider(createMockWS("open"));
const monitor = makeMonitor();
let calls = 0;
Object.defineProperty(monitor, "url", {
get() {
calls++;
if (calls === 1) throw new Error("");
return "wss://example.com";
},
});
await expect(provider.handle(monitor)).rejects.toThrow("Error performing WebSocket check");
});
it("throws AppError with stringified message for non-Error thrown values", async () => {
const provider = new WebSocketProvider(createMockWS("open"));
const monitor = makeMonitor();
let calls = 0;
Object.defineProperty(monitor, "url", {
get() {
calls++;
if (calls === 1) throw 99;
return "wss://example.com";
},
});
await expect(provider.handle(monitor)).rejects.toThrow("99");
});
describe("connection timeout", () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it("returns failure on connection timeout", async () => {
// WS that never fires open or error
const WS = jest.fn().mockImplementation(() => ({
on: jest.fn(),
close: jest.fn(),
})) as any;
const provider = new WebSocketProvider(WS);
const promise = provider.handle(makeMonitor());
await jest.advanceTimersByTimeAsync(10000);
const result = await promise;
expect(result).toEqual(
expect.objectContaining({
status: false,
code: NETWORK_ERROR,
message: "WebSocket connection timeout",
})
);
});
});
});
@@ -0,0 +1,132 @@
import { describe, expect, it, jest } from "@jest/globals";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
const mockGotPost = jest.fn().mockResolvedValue({});
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
const { DiscordProvider } = await import("../../../../src/service/infrastructure/notificationProviders/discord.ts");
const createProvider = () => {
const logger = createMockLogger();
const provider = new DiscordProvider(logger as any);
return { provider, logger };
};
testNotificationProviderContract("DiscordProvider", {
create: () => {
mockGotPost.mockResolvedValue({});
return createProvider().provider;
},
makeNotification: () => makeNotification(),
});
describe("DiscordProvider", () => {
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
describe("sendTestAlert", () => {
it("sends test message and returns true", async () => {
const { provider } = createProvider();
const result = await provider.sendTestAlert(makeNotification());
expect(result).toBe(true);
expect(mockGotPost).toHaveBeenCalledWith(
"https://hooks.example.com/webhook",
expect.objectContaining({ json: expect.objectContaining({ content: expect.any(String) }) })
);
});
it("returns false when address is missing", async () => {
const { provider } = createProvider();
expect(await provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("network"));
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert" }));
});
it("handles undefined thrown values", async () => {
mockGotPost.mockRejectedValue(undefined);
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
});
});
describe("sendMessage", () => {
it("sends embed and returns true", async () => {
const { provider } = createProvider();
const result = await provider.sendMessage(makeNotification() as any, makeMessage());
expect(result).toBe(true);
expect(mockGotPost).toHaveBeenCalledWith(
"https://hooks.example.com/webhook",
expect.objectContaining({ json: expect.objectContaining({ embeds: expect.any(Array) }) })
);
});
it("returns false when address is missing", async () => {
const { provider } = createProvider();
expect(await provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" }));
});
it("handles undefined thrown values in sendMessage", async () => {
mockGotPost.mockRejectedValue(undefined);
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
});
it("includes threshold breaches in embed fields", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
const payload = mockGotPost.mock.calls[0][1].json;
const fields = payload.embeds[0].fields;
expect(fields.some((f: any) => f.name === "Threshold Breaches")).toBe(true);
});
it("includes details in embed fields", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessage());
const fields = mockGotPost.mock.calls[0][1].json.embeds[0].fields;
expect(fields.some((f: any) => f.name === "Details")).toBe(true);
});
it("maps all severity levels to colors", async () => {
const { provider } = createProvider();
for (const severity of ["critical", "warning", "success", "info"] as const) {
mockGotPost.mockClear();
await provider.sendMessage(makeNotification() as any, makeMessage({ severity }));
const color = mockGotPost.mock.calls[0][1].json.embeds[0].color;
expect(color).toBeGreaterThan(0);
}
});
it("uses info color for unknown severity", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessage({ severity: "unknown" as any }));
const color = mockGotPost.mock.calls[0][1].json.embeds[0].color;
expect(color).toBe(0x3b82f6);
});
it("omits threshold fields when not present", async () => {
const { provider } = createProvider();
const msg = makeMessage();
msg.content.thresholds = undefined;
msg.content.details = undefined;
await provider.sendMessage(makeNotification() as any, msg);
const fields = mockGotPost.mock.calls[0][1].json.embeds[0].fields;
expect(fields.some((f: any) => f.name === "Threshold Breaches")).toBe(false);
expect(fields.some((f: any) => f.name === "Details")).toBe(false);
});
});
});
@@ -0,0 +1,149 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { EmailProvider } from "../../../../src/service/infrastructure/notificationProviders/email.ts";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { makeNotification, makeMessage } from "../../../helpers/notificationMessage.ts";
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
import type { IEmailService } from "../../../../src/service/infrastructure/emailService.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createMockEmailService = () =>
({
buildEmail: jest.fn().mockResolvedValue("<html>email</html>"),
sendEmail: jest.fn().mockResolvedValue("msg-123"),
}) as unknown as jest.Mocked<IEmailService>;
const createProvider = () => {
const logger = createMockLogger();
const emailService = createMockEmailService();
const provider = new EmailProvider(emailService, logger as any);
return { provider, logger, emailService };
};
testNotificationProviderContract("EmailProvider", {
create: () => createProvider().provider,
makeNotification: () => makeNotification(),
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("EmailProvider", () => {
describe("sendTestAlert", () => {
it("builds test email and sends it", async () => {
const { provider, emailService } = createProvider();
const result = await provider.sendTestAlert(makeNotification());
expect(result).toBe(true);
expect(emailService.buildEmail).toHaveBeenCalledWith("testEmailTemplate", { testName: "Monitoring System" });
expect(emailService.sendEmail).toHaveBeenCalledWith("https://hooks.example.com/webhook", "Test notification", "<html>email</html>");
});
it("returns false when address is missing", async () => {
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Missing address" }));
});
it("returns false when buildEmail returns undefined", async () => {
const { provider, emailService, logger } = createProvider();
(emailService.buildEmail as jest.Mock).mockResolvedValue(undefined);
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build test email content" }));
});
it("returns false when sendEmail returns falsy", async () => {
const { provider, emailService, logger } = createProvider();
(emailService.sendEmail as jest.Mock).mockResolvedValue(false);
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Email test alert failed" }));
});
});
describe("sendMessage", () => {
it("builds email from message and sends it", async () => {
const { provider, emailService } = createProvider();
const result = await provider.sendMessage(makeNotification() as any, makeMessage());
expect(result).toBe(true);
expect(emailService.buildEmail).toHaveBeenCalledWith(
"unifiedNotificationTemplate",
expect.objectContaining({ title: "Monitor Down: Test Monitor" })
);
expect(emailService.sendEmail).toHaveBeenCalled();
});
it("returns false when address is missing", async () => {
const { provider } = createProvider();
expect(await provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
});
it("returns false when buildEmail returns undefined", async () => {
const { provider, emailService, logger } = createProvider();
(emailService.buildEmail as jest.Mock).mockResolvedValue(undefined);
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build email content" }));
});
it("returns false when sendEmail returns falsy", async () => {
const { provider, emailService, logger } = createProvider();
(emailService.sendEmail as jest.Mock).mockResolvedValue(undefined);
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Email notification failed" }));
});
it("builds correct subject for monitor_down", async () => {
const { provider, emailService } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "monitor_down" }));
expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Monitor Test Monitor is down", expect.anything());
});
it("builds correct subject for monitor_up", async () => {
const { provider, emailService } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "monitor_up" }));
expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Monitor Test Monitor is back up", expect.anything());
});
it("builds correct subject for threshold_breach", async () => {
const { provider, emailService } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "threshold_breach" }));
expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Monitor Test Monitor threshold exceeded", expect.anything());
});
it("builds correct subject for threshold_resolved", async () => {
const { provider, emailService } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "threshold_resolved" }));
expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Monitor Test Monitor thresholds resolved", expect.anything());
});
it("builds default subject for unknown type", async () => {
const { provider, emailService } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "test" }));
expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Alert: Test Monitor", expect.anything());
});
it("passes incident url in context when present", async () => {
const { provider, emailService } = createProvider();
const msg = makeMessage();
msg.content.incident = { id: "inc-1", url: "https://app.example.com/incidents/inc-1", createdAt: new Date() };
await provider.sendMessage(makeNotification() as any, msg);
expect(emailService.buildEmail).toHaveBeenCalledWith(
"unifiedNotificationTemplate",
expect.objectContaining({ incidentUrl: "https://app.example.com/incidents/inc-1" })
);
});
it("maps all severity levels to colors", async () => {
const { provider, emailService } = createProvider();
const severityColors: Record<string, string> = { critical: "red", warning: "#f59e0b", info: "#3b82f6", success: "green" };
for (const [severity, color] of Object.entries(severityColors)) {
(emailService.buildEmail as jest.Mock).mockClear();
await provider.sendMessage(makeNotification() as any, makeMessage({ severity: severity as any }));
expect(emailService.buildEmail).toHaveBeenCalledWith("unifiedNotificationTemplate", expect.objectContaining({ headerColor: color }));
}
});
it("uses default color for unknown severity", async () => {
const { provider, emailService } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessage({ severity: "unknown" as any }));
expect(emailService.buildEmail).toHaveBeenCalledWith("unifiedNotificationTemplate", expect.objectContaining({ headerColor: "#3b82f6" }));
});
});
});
@@ -0,0 +1,144 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
const mockGotPut = jest.fn().mockResolvedValue({});
jest.unstable_mockModule("got", () => ({ default: { put: mockGotPut } }));
jest.unstable_mockModule("crypto", () => ({ randomUUID: () => "test-uuid-1234" }));
const { MatrixProvider } = await import("../../../../src/service/infrastructure/notificationProviders/matrix.ts");
const createProvider = () => {
const logger = createMockLogger();
return { provider: new MatrixProvider(logger as any), logger };
};
testNotificationProviderContract("MatrixProvider", {
create: () => {
mockGotPut.mockResolvedValue({});
return createProvider().provider;
},
makeNotification: () => makeNotification(),
});
describe("MatrixProvider", () => {
beforeEach(() => mockGotPut.mockReset().mockResolvedValue({}));
describe("sendTestAlert", () => {
it("sends to Matrix API and returns true", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
expect(mockGotPut).toHaveBeenCalledWith(
expect.stringContaining("matrix.example.com/_matrix/client/v3/rooms/!room:example.com/send/m.room.message/test-uuid-1234"),
expect.objectContaining({
headers: expect.objectContaining({ Authorization: "Bearer token-abc" }),
})
);
});
it("returns false when homeserverUrl is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification({ homeserverUrl: undefined }))).toBe(false);
});
it("returns false when accessToken is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification({ accessToken: undefined }))).toBe(false);
});
it("returns false when roomId is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification({ roomId: undefined }))).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPut.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalled();
});
it("handles non-Error thrown values", async () => {
mockGotPut.mockRejectedValue({ message: "custom obj" });
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
});
});
describe("sendMessage", () => {
it("sends formatted HTML and plain text", async () => {
const { provider } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
const body = mockGotPut.mock.calls[0][1].json;
expect(body.msgtype).toBe("m.text");
expect(body.format).toBe("org.matrix.custom.html");
expect(body.body).toContain("Monitor Down");
expect(body.formatted_body).toContain("<h2");
});
it("returns false when homeserverUrl is missing", async () => {
expect(await createProvider().provider.sendMessage(makeNotification({ homeserverUrl: undefined }) as any, makeMessage())).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPut.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalled();
});
it("includes threshold breaches", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
const body = mockGotPut.mock.calls[0][1].json;
expect(body.body).toContain("CPU");
expect(body.formatted_body).toContain("CPU");
});
it("includes incident link", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithIncident());
const body = mockGotPut.mock.calls[0][1].json;
expect(body.body).toContain("View Incident");
expect(body.formatted_body).toContain("View Incident");
});
it("omits optional sections when not present", async () => {
const { provider } = createProvider();
const msg = makeMessage();
msg.content.thresholds = undefined;
msg.content.details = undefined;
msg.content.incident = undefined;
await provider.sendMessage(makeNotification() as any, msg);
const body = mockGotPut.mock.calls[0][1].json;
expect(body.body).not.toContain("Threshold");
expect(body.body).not.toContain("Additional");
expect(body.body).not.toContain("View Incident");
});
it("escapes HTML special characters", async () => {
const { provider } = createProvider();
const msg = makeMessage();
msg.monitor.name = '<script>alert("xss")</script>';
await provider.sendMessage(makeNotification() as any, msg);
const html = mockGotPut.mock.calls[0][1].json.formatted_body;
expect(html).not.toContain("<script>");
expect(html).toContain("&lt;script&gt;");
});
it("maps all severity levels to colors", async () => {
const { provider } = createProvider();
for (const severity of ["critical", "warning", "success", "info"] as const) {
mockGotPut.mockClear();
await provider.sendMessage(makeNotification() as any, makeMessage({ severity }));
const html = mockGotPut.mock.calls[0][1].json.formatted_body;
expect(html).toContain("color:");
}
});
it("uses default color for unknown severity", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessage({ severity: "unknown" as any }));
const html = mockGotPut.mock.calls[0][1].json.formatted_body;
expect(html).toContain("#808080");
});
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it, jest } from "@jest/globals";
import { buildTestEmail, getTestMessage } from "../../../../src/service/infrastructure/notificationProviders/utils.ts";
import type { IEmailService } from "../../../../src/service/infrastructure/emailService.ts";
describe("notification utils", () => {
describe("getTestMessage", () => {
it("returns a non-empty string", () => {
expect(getTestMessage()).toBe("This is a test notification from Checkmate");
});
});
describe("buildTestEmail", () => {
it("calls emailService.buildEmail with test template and context", async () => {
const emailService = {
buildEmail: jest.fn().mockResolvedValue("<html>test</html>"),
} as unknown as IEmailService;
const result = await buildTestEmail(emailService);
expect(emailService.buildEmail).toHaveBeenCalledWith("testEmailTemplate", { testName: "Monitoring System" });
expect(result).toBe("<html>test</html>");
});
it("returns undefined when buildEmail returns undefined", async () => {
const emailService = {
buildEmail: jest.fn().mockResolvedValue(undefined),
} as unknown as IEmailService;
const result = await buildTestEmail(emailService);
expect(result).toBeUndefined();
});
});
});
@@ -0,0 +1,132 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { makeNotification, makeMessage, makeMessageWithThresholds } from "../../../helpers/notificationMessage.ts";
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
const mockGotPost = jest.fn().mockResolvedValue({});
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
const { PagerDutyProvider } = await import("../../../../src/service/infrastructure/notificationProviders/pagerduty.ts");
const createProvider = () => {
const logger = createMockLogger();
return { provider: new PagerDutyProvider(logger as any), logger };
};
testNotificationProviderContract("PagerDutyProvider", {
create: () => {
mockGotPost.mockResolvedValue({});
return createProvider().provider;
},
makeNotification: () => makeNotification(),
});
describe("PagerDutyProvider", () => {
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
describe("sendTestAlert", () => {
it("sends to PagerDuty API and returns true", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
expect(mockGotPost).toHaveBeenCalledWith(
"https://events.pagerduty.com/v2/enqueue",
expect.objectContaining({
json: expect.objectContaining({ routing_key: "https://hooks.example.com/webhook", event_action: "trigger" }),
})
);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalled();
});
it("handles non-Error thrown values", async () => {
mockGotPost.mockRejectedValue(null);
const { provider } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
});
});
describe("sendMessage", () => {
it("sends payload and returns true", async () => {
const { provider } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
const payload = mockGotPost.mock.calls[0][1].json;
expect(payload.routing_key).toBe("https://hooks.example.com/webhook");
expect(payload.event_action).toBe("trigger");
expect(payload.dedup_key).toBe("checkmate-mon-1");
});
it("returns false when address is missing", async () => {
const { provider } = createProvider();
expect(await provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalled();
});
it("handles non-Error thrown values in sendMessage", async () => {
mockGotPost.mockRejectedValue(null);
const { provider } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
});
it("uses 'resolve' event_action for monitor_up", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "monitor_up" }));
expect(mockGotPost.mock.calls[0][1].json.event_action).toBe("resolve");
});
it("uses 'resolve' event_action for threshold_resolved", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessage({ type: "threshold_resolved" }));
expect(mockGotPost.mock.calls[0][1].json.event_action).toBe("resolve");
});
it("includes threshold info in summary and custom_details", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
const payload = mockGotPost.mock.calls[0][1].json;
expect(payload.payload.summary).toContain("CPU");
expect(payload.payload.custom_details.threshold_breaches).toBeDefined();
});
it("includes details in custom_details", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessage());
expect(mockGotPost.mock.calls[0][1].json.payload.custom_details.details).toBeDefined();
});
it("maps all severity levels", async () => {
const { provider } = createProvider();
for (const severity of ["critical", "warning", "info", "success"] as const) {
mockGotPost.mockClear();
await provider.sendMessage(makeNotification() as any, makeMessage({ severity }));
expect(mockGotPost.mock.calls[0][1].json.payload.severity).toBeTruthy();
}
});
it("uses 'error' for unknown severity", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessage({ severity: "unknown" as any }));
expect(mockGotPost.mock.calls[0][1].json.payload.severity).toBe("error");
});
it("omits threshold and details from custom_details when not present", async () => {
const { provider } = createProvider();
const msg = makeMessage();
msg.content.thresholds = undefined;
msg.content.details = undefined;
await provider.sendMessage(makeNotification() as any, msg);
const cd = mockGotPost.mock.calls[0][1].json.payload.custom_details;
expect(cd.threshold_breaches).toBeUndefined();
expect(cd.details).toBeUndefined();
});
});
});
@@ -0,0 +1,127 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
const mockGotPost = jest.fn().mockResolvedValue({});
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost }, HTTPError: class extends Error {} }));
const { SlackProvider } = await import("../../../../src/service/infrastructure/notificationProviders/slack.ts");
const createProvider = () => {
const logger = createMockLogger();
return { provider: new SlackProvider(logger as any), logger };
};
testNotificationProviderContract("SlackProvider", {
create: () => {
mockGotPost.mockResolvedValue({});
return createProvider().provider;
},
makeNotification: () => makeNotification(),
});
describe("SlackProvider", () => {
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
describe("sendTestAlert", () => {
it("returns true on success", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
});
it("returns false when address is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalled();
});
it("handles undefined thrown values", async () => {
mockGotPost.mockRejectedValue(undefined);
const { provider } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
});
});
describe("sendMessage", () => {
it("sends Block Kit payload and returns true", async () => {
const { provider } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
const payload = mockGotPost.mock.calls[0][1].json;
expect(payload.blocks).toBeDefined();
expect(payload.attachments).toBeDefined();
});
it("returns false when address is missing", async () => {
expect(await createProvider().provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalled();
});
it("handles undefined thrown values in sendMessage", async () => {
mockGotPost.mockRejectedValue(undefined);
const { provider } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
});
it("includes threshold section when thresholds present", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
const blocks = mockGotPost.mock.calls[0][1].json.blocks;
const text = JSON.stringify(blocks);
expect(text).toContain("Threshold Breaches");
});
it("includes incident button when incident present", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithIncident());
const blocks = mockGotPost.mock.calls[0][1].json.blocks;
expect(blocks.some((b: any) => b.type === "actions")).toBe(true);
});
it("includes details section when details are present", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessage());
const blocks = mockGotPost.mock.calls[0][1].json.blocks;
const text = JSON.stringify(blocks);
expect(text).toContain("Additional Information");
});
it("omits threshold, details, and incident sections when not present", async () => {
const { provider } = createProvider();
const msg = makeMessage();
msg.content.thresholds = undefined;
msg.content.details = undefined;
msg.content.incident = undefined;
await provider.sendMessage(makeNotification() as any, msg);
const blocks = mockGotPost.mock.calls[0][1].json.blocks;
const text = JSON.stringify(blocks);
expect(text).not.toContain("Additional Information");
expect(blocks.some((b: any) => b.type === "actions")).toBe(false);
});
it("maps all severity levels to colors", async () => {
const { provider } = createProvider();
for (const severity of ["critical", "warning", "success", "info"] as const) {
mockGotPost.mockClear();
await provider.sendMessage(makeNotification() as any, makeMessage({ severity }));
expect(mockGotPost.mock.calls[0][1].json.attachments[0].color).toBeTruthy();
}
});
it("uses default color for unknown severity", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessage({ severity: "unknown" as any }));
expect(mockGotPost.mock.calls[0][1].json.attachments[0].color).toBe("#808080");
});
});
});
@@ -1,19 +1,15 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals"; import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { TeamsProvider } from "../src/service/infrastructure/notificationProviders/teams.ts"; import type { Notification } from "../../../../src/types/notification.ts";
import type { Notification } from "../src/types/notification.ts"; import type { NotificationMessage } from "../../../../src/types/notificationMessage.ts";
import type { NotificationMessage } from "../src/types/notificationMessage.ts"; import { createMockLogger } from "../../../helpers/createMockLogger.ts";
// Mock got const mockPost = jest.fn();
jest.unstable_mockModule("got", () => ({ jest.unstable_mockModule("got", () => ({
default: { post: jest.fn() }, default: { post: mockPost },
HTTPError: class HTTPError extends Error {},
})); }));
const createLogger = () => ({ const { TeamsProvider } = await import("../../../../src/service/infrastructure/notificationProviders/teams.ts");
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
});
const createNotification = (overrides?: Partial<Notification>): Notification => ({ const createNotification = (overrides?: Partial<Notification>): Notification => ({
id: "notif-1", id: "notif-1",
@@ -58,17 +54,14 @@ const createMessage = (overrides?: Partial<NotificationMessage>): NotificationMe
}); });
describe("TeamsProvider", () => { describe("TeamsProvider", () => {
let provider: TeamsProvider; let provider: InstanceType<typeof TeamsProvider>;
let logger: ReturnType<typeof createLogger>; let logger: ReturnType<typeof createMockLogger>;
let gotPost: jest.Mock;
beforeEach(async () => { beforeEach(() => {
logger = createLogger(); logger = createMockLogger();
provider = new TeamsProvider(logger); provider = new TeamsProvider(logger);
const got = await import("got"); mockPost.mockReset();
gotPost = got.default.post as jest.Mock; mockPost.mockResolvedValue({});
gotPost.mockReset();
gotPost.mockResolvedValue({});
}); });
describe("sendTestAlert", () => { describe("sendTestAlert", () => {
@@ -76,35 +69,33 @@ describe("TeamsProvider", () => {
const notification = createNotification({ address: undefined }); const notification = createNotification({ address: undefined });
const result = await provider.sendTestAlert(notification); const result = await provider.sendTestAlert(notification);
expect(result).toBe(false); expect(result).toBe(false);
expect(gotPost).not.toHaveBeenCalled(); expect(mockPost).not.toHaveBeenCalled();
}); });
it("sends an Adaptive Card test message and returns true on success", async () => { it("sends an Adaptive Card test message and returns true on success", async () => {
const notification = createNotification(); const notification = createNotification();
const result = await provider.sendTestAlert(notification); const result = await provider.sendTestAlert(notification);
expect(result).toBe(true); expect(result).toBe(true);
expect(gotPost).toHaveBeenCalledTimes(1); expect(mockPost).toHaveBeenCalledTimes(1);
const [url, options] = gotPost.mock.calls[0] as [string, { json: any; headers: any }]; const [url, options] = mockPost.mock.calls[0] as [string, { json: any; headers: any }];
expect(url).toBe(notification.address); expect(url).toBe(notification.address);
expect(options.headers["Content-Type"]).toBe("application/json"); expect(options.headers["Content-Type"]).toBe("application/json");
// Verify Teams webhook envelope
const payload = options.json; const payload = options.json;
expect(payload.type).toBe("message"); expect(payload.type).toBe("message");
expect(payload.attachments).toHaveLength(1); expect(payload.attachments).toHaveLength(1);
expect(payload.attachments[0].contentType).toBe("application/vnd.microsoft.card.adaptive"); expect(payload.attachments[0].contentType).toBe("application/vnd.microsoft.card.adaptive");
// Verify Adaptive Card structure
const card = payload.attachments[0].content; const card = payload.attachments[0].content;
expect(card.type).toBe("AdaptiveCard"); expect(card.type).toBe("AdaptiveCard");
expect(card.version).toBe("1.4"); expect(card.version).toBe("1.4");
expect(card.body[0].type).toBe("TextBlock"); expect(card.body[0].type).toBe("TextBlock");
expect(card.body[0].text).toContain("test notification"); expect(card.body[0].text).toBe("This is a test notification from Checkmate");
}); });
it("returns false and logs warning on HTTP error", async () => { it("returns false and logs warning on HTTP error", async () => {
gotPost.mockRejectedValue(new Error("Network error")); mockPost.mockRejectedValue(new Error("Network error"));
const notification = createNotification(); const notification = createNotification();
const result = await provider.sendTestAlert(notification); const result = await provider.sendTestAlert(notification);
expect(result).toBe(false); expect(result).toBe(false);
@@ -122,7 +113,7 @@ describe("TeamsProvider", () => {
const notification = createNotification({ address: undefined }); const notification = createNotification({ address: undefined });
const result = await provider.sendMessage(notification, createMessage()); const result = await provider.sendMessage(notification, createMessage());
expect(result).toBe(false); expect(result).toBe(false);
expect(gotPost).not.toHaveBeenCalled(); expect(mockPost).not.toHaveBeenCalled();
}); });
it("sends a well-formed Adaptive Card and returns true", async () => { it("sends a well-formed Adaptive Card and returns true", async () => {
@@ -131,9 +122,9 @@ describe("TeamsProvider", () => {
const result = await provider.sendMessage(notification, message); const result = await provider.sendMessage(notification, message);
expect(result).toBe(true); expect(result).toBe(true);
expect(gotPost).toHaveBeenCalledTimes(1); expect(mockPost).toHaveBeenCalledTimes(1);
const [url, options] = gotPost.mock.calls[0] as [string, { json: any }]; const [url, options] = mockPost.mock.calls[0] as [string, { json: any }];
expect(url).toBe(notification.address); expect(url).toBe(notification.address);
const payload = options.json; const payload = options.json;
@@ -143,18 +134,13 @@ describe("TeamsProvider", () => {
expect(card.type).toBe("AdaptiveCard"); expect(card.type).toBe("AdaptiveCard");
expect(card.version).toBe("1.4"); expect(card.version).toBe("1.4");
// Verify body contains expected elements
const textBlocks = card.body.filter((b: any) => b.type === "TextBlock"); const textBlocks = card.body.filter((b: any) => b.type === "TextBlock");
const factSets = card.body.filter((b: any) => b.type === "FactSet"); const factSets = card.body.filter((b: any) => b.type === "FactSet");
// Title block
expect(textBlocks[0].text).toBe(message.content.title); expect(textBlocks[0].text).toBe(message.content.title);
expect(textBlocks[0].color).toBe("attention"); // critical -> attention expect(textBlocks[0].color).toBe("attention");
// Summary block
expect(textBlocks[1].text).toBe(message.content.summary); expect(textBlocks[1].text).toBe(message.content.summary);
// FactSet with monitor details
expect(factSets).toHaveLength(1); expect(factSets).toHaveLength(1);
const facts = factSets[0].facts; const facts = factSets[0].facts;
expect(facts).toEqual( expect(facts).toEqual(
@@ -165,11 +151,10 @@ describe("TeamsProvider", () => {
]) ])
); );
// Incident action button
expect(card.actions).toHaveLength(1); expect(card.actions).toHaveLength(1);
expect(card.actions[0].type).toBe("Action.OpenUrl"); expect(card.actions[0].type).toBe("Action.OpenUrl");
expect(card.actions[0].title).toBe("View Incident"); expect(card.actions[0].title).toBe("View Incident");
expect(card.actions[0].url).toContain("/infrastructure/mon-1"); expect(card.actions[0].url).toContain("/incidents/inc-1");
}); });
it("omits actions when no incident is present", async () => { it("omits actions when no incident is present", async () => {
@@ -184,7 +169,7 @@ describe("TeamsProvider", () => {
const result = await provider.sendMessage(notification, message); const result = await provider.sendMessage(notification, message);
expect(result).toBe(true); expect(result).toBe(true);
const [, options] = gotPost.mock.calls[0] as [string, { json: any }]; const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
const card = options.json.attachments[0].content; const card = options.json.attachments[0].content;
expect(card.actions).toBeUndefined(); expect(card.actions).toBeUndefined();
}); });
@@ -212,7 +197,7 @@ describe("TeamsProvider", () => {
await provider.sendMessage(notification, message); await provider.sendMessage(notification, message);
const [, options] = gotPost.mock.calls[0] as [string, { json: any }]; const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
const card = options.json.attachments[0].content; const card = options.json.attachments[0].content;
// Title should use "warning" color // Title should use "warning" color
@@ -222,7 +207,7 @@ describe("TeamsProvider", () => {
const thresholdHeader = card.body.find((b: any) => b.type === "TextBlock" && b.text === "**Threshold Breaches**"); const thresholdHeader = card.body.find((b: any) => b.type === "TextBlock" && b.text === "**Threshold Breaches**");
expect(thresholdHeader).toBeDefined(); 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).toBeDefined();
expect(cpuBlock.text).toContain("95%"); expect(cpuBlock.text).toContain("95%");
expect(cpuBlock.text).toContain("threshold: 80%"); expect(cpuBlock.text).toContain("threshold: 80%");
@@ -239,20 +224,20 @@ describe("TeamsProvider", () => {
] as const; ] as const;
for (const { severity, expected } of severityMap) { for (const { severity, expected } of severityMap) {
gotPost.mockReset(); mockPost.mockReset();
gotPost.mockResolvedValue({}); mockPost.mockResolvedValue({});
const message = createMessage({ severity }); const message = createMessage({ severity });
await provider.sendMessage(notification, message); await provider.sendMessage(notification, message);
const [, options] = gotPost.mock.calls[0] as [string, { json: any }]; const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
const card = options.json.attachments[0].content; const card = options.json.attachments[0].content;
expect(card.body[0].color).toBe(expected); expect(card.body[0].color).toBe(expected);
} }
}); });
it("returns false and logs warning on HTTP error", async () => { it("returns false and logs warning on HTTP error", async () => {
gotPost.mockRejectedValue(new Error("502 Bad Gateway")); mockPost.mockRejectedValue(new Error("502 Bad Gateway"));
const notification = createNotification(); const notification = createNotification();
const result = await provider.sendMessage(notification, createMessage()); const result = await provider.sendMessage(notification, createMessage());
expect(result).toBe(false); expect(result).toBe(false);
@@ -263,5 +248,48 @@ describe("TeamsProvider", () => {
}) })
); );
}); });
it("handles non-Error thrown values in sendMessage", async () => {
mockPost.mockRejectedValue(null);
const result = await provider.sendMessage(createNotification(), createMessage());
expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
});
it("includes details section in Adaptive Card when details are present", async () => {
const notification = createNotification();
const message = createMessage({
content: {
title: "Test",
summary: "Summary",
details: ["URL: https://example.com", "Status: Down"],
timestamp: new Date(),
},
});
await provider.sendMessage(notification, message);
const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
const card = options.json.attachments[0].content;
const text = JSON.stringify(card.body);
expect(text).toContain("Additional Information");
expect(text).toContain("URL: https://example.com");
});
it("uses default color for unknown severity", async () => {
const notification = createNotification();
const message = createMessage({ severity: "unknown" as any });
await provider.sendMessage(notification, message);
const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
const card = options.json.attachments[0].content;
expect(card.body[0].color).toBe("default");
});
});
describe("sendTestAlert error handling", () => {
it("handles non-Error thrown values", async () => {
mockPost.mockRejectedValue(null);
const result = await provider.sendTestAlert(createNotification());
expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
});
}); });
}); });
@@ -0,0 +1,115 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
const mockGotPost = jest.fn().mockResolvedValue({});
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
const { TelegramProvider } = await import("../../../../src/service/infrastructure/notificationProviders/telegram.ts");
const createProvider = () => {
const logger = createMockLogger();
return { provider: new TelegramProvider(logger as any), logger };
};
testNotificationProviderContract("TelegramProvider", {
create: () => {
mockGotPost.mockResolvedValue({});
return createProvider().provider;
},
makeNotification: () => makeNotification(),
});
describe("TelegramProvider", () => {
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
describe("sendTestAlert", () => {
it("sends to Telegram API and returns true", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
expect(mockGotPost).toHaveBeenCalledWith(
expect.stringContaining("api.telegram.org/bottoken-abc/sendMessage"),
expect.objectContaining({ json: expect.objectContaining({ chat_id: "https://hooks.example.com/webhook" }) })
);
});
it("returns false when address is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
});
it("returns false when accessToken is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification({ accessToken: undefined }))).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert", details: { error: "fail" } }));
});
it("handles non-Error thrown values in sendTestAlert", async () => {
mockGotPost.mockRejectedValue("string error");
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined, details: { error: "unknown error" } }));
});
});
describe("sendMessage", () => {
it("sends HTML-formatted text and returns true", async () => {
const { provider } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
const json = mockGotPost.mock.calls[0][1].json;
expect(json.parse_mode).toBe("HTML");
expect(json.text).toContain("<b>Monitor Down");
});
it("returns false when address is missing", async () => {
expect(await createProvider().provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
});
it("returns false when accessToken is missing", async () => {
expect(await createProvider().provider.sendMessage(makeNotification({ accessToken: undefined }) as any, makeMessage())).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" }));
});
it("handles non-Error thrown values in sendMessage", async () => {
mockGotPost.mockRejectedValue(42);
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ details: { error: "unknown error" } }));
});
it("includes thresholds in text", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
expect(mockGotPost.mock.calls[0][1].json.text).toContain("CPU");
});
it("includes incident link in text", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithIncident());
expect(mockGotPost.mock.calls[0][1].json.text).toContain("View Incident");
});
it("omits optional sections when not present", async () => {
const { provider } = createProvider();
const msg = makeMessage();
msg.content.thresholds = undefined;
msg.content.details = undefined;
msg.content.incident = undefined;
await provider.sendMessage(makeNotification() as any, msg);
const text = mockGotPost.mock.calls[0][1].json.text;
expect(text).not.toContain("Threshold");
expect(text).not.toContain("Additional");
expect(text).not.toContain("View Incident");
});
});
});
@@ -0,0 +1,102 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
const mockGotPost = jest.fn().mockResolvedValue({});
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
const { WebhookProvider } = await import("../../../../src/service/infrastructure/notificationProviders/webhook.ts");
const createProvider = () => {
const logger = createMockLogger();
return { provider: new WebhookProvider(logger as any), logger };
};
testNotificationProviderContract("WebhookProvider", {
create: () => {
mockGotPost.mockResolvedValue({});
return createProvider().provider;
},
makeNotification: () => makeNotification(),
});
describe("WebhookProvider", () => {
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
describe("sendTestAlert", () => {
it("returns true on success", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
});
it("returns false when address is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalled();
});
it("handles non-Error thrown values", async () => {
mockGotPost.mockRejectedValue(null);
const { provider } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
});
});
describe("sendMessage", () => {
it("sends payload with text and structured data", async () => {
const { provider } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
const payload = mockGotPost.mock.calls[0][1].json;
expect(payload.text).toContain("Monitor Down");
expect(payload.severity).toBe("critical");
expect(payload.monitor.id).toBe("mon-1");
});
it("returns false when address is missing", async () => {
expect(await createProvider().provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalled();
});
it("handles non-Error thrown values in sendMessage", async () => {
mockGotPost.mockRejectedValue(null);
const { provider } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
});
it("includes threshold breaches in text", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
expect(mockGotPost.mock.calls[0][1].json.text).toContain("CPU");
});
it("includes incident link in text", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithIncident());
expect(mockGotPost.mock.calls[0][1].json.text).toContain("View Incident");
});
it("omits threshold and incident sections when not present", async () => {
const { provider } = createProvider();
const msg = makeMessage();
msg.content.thresholds = undefined;
msg.content.details = undefined;
msg.content.incident = undefined;
await provider.sendMessage(makeNotification() as any, msg);
const text = mockGotPost.mock.calls[0][1].json.text;
expect(text).not.toContain("Threshold");
expect(text).not.toContain("Additional Information");
expect(text).not.toContain("View Incident");
});
});
});
@@ -0,0 +1,454 @@
import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals";
import { BufferService } from "../../../src/service/infrastructure/bufferService.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { ICheckService } from "../../../src/service/business/checkService.ts";
import type { IGeoChecksService } from "../../../src/service/business/geoChecksService.ts";
import type { ISettingsService } from "../../../src/service/system/settingsService.ts";
import type { Check, GeoCheck } from "../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createMockCheckService = () =>
({
createChecks: jest.fn().mockResolvedValue([]),
}) as unknown as jest.Mocked<ICheckService>;
const createMockGeoChecksService = () =>
({
createGeoChecks: jest.fn().mockResolvedValue([]),
}) as unknown as jest.Mocked<IGeoChecksService>;
const createMockSettingsService = (nodeEnv: string = "development") =>
({
getSettings: jest.fn().mockReturnValue({ nodeEnv }),
}) as unknown as jest.Mocked<ISettingsService>;
const makeCheck = (overrides?: Partial<Check>): Check =>
({
id: "check-1",
metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" },
status: true,
statusCode: 200,
responseTime: 100,
message: "OK",
...overrides,
}) as Check;
const makeGeoCheck = (overrides?: Partial<GeoCheck>): GeoCheck =>
({
id: "geo-1",
monitorId: "mon-1",
...overrides,
}) as GeoCheck;
const createService = (nodeEnv: string = "development") => {
const logger = createMockLogger();
const checkService = createMockCheckService();
const geoChecksService = createMockGeoChecksService();
const settingsService = createMockSettingsService(nodeEnv);
const service = new BufferService(logger as any, checkService, geoChecksService, settingsService);
return { service, logger, checkService, geoChecksService };
};
// ── Tests ────────────────────────────────────────────────────────────────────
describe("BufferService", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
// ── Static / instance properties ─────────────────────────────────────
describe("serviceName", () => {
it("returns BufferService from static property", () => {
expect(BufferService.SERVICE_NAME).toBe("BufferService");
});
it("returns BufferService from instance getter", () => {
const { service } = createService();
expect(service.serviceName).toBe("BufferService");
});
});
// ── constructor ──────────────────────────────────────────────────────
describe("constructor", () => {
it("logs initialization with development timeout", () => {
const { logger } = createService("development");
expect(logger.info).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("0.01s"),
service: "BufferService",
method: "constructor",
})
);
});
it("uses 60s timeout in non-development environment", () => {
const { logger } = createService("production");
expect(logger.info).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("60s"),
})
);
});
it("schedules a flush on construction", () => {
createService();
expect(jest.getTimerCount()).toBeGreaterThanOrEqual(1);
});
});
// ── addToBuffer ──────────────────────────────────────────────────────
describe("addToBuffer", () => {
it("adds a check to the buffer", async () => {
const { service, checkService } = createService();
const check = makeCheck();
service.addToBuffer(check);
await service.flushBuffer();
expect(checkService.createChecks).toHaveBeenCalledWith([check]);
});
it("adds multiple checks to the buffer", async () => {
const { service, checkService } = createService();
const check1 = makeCheck({ id: "c1" });
const check2 = makeCheck({ id: "c2" });
service.addToBuffer(check1);
service.addToBuffer(check2);
await service.flushBuffer();
expect(checkService.createChecks).toHaveBeenCalledWith([check1, check2]);
});
it("logs error if push throws", () => {
const { service, logger } = createService();
// Force buffer.push to throw by making buffer non-extensible
Object.defineProperty(service, "buffer", { value: Object.freeze([]) });
service.addToBuffer(makeCheck());
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
service: "BufferService",
method: "addToBuffer",
})
);
});
it("logs 'Unknown error' for non-Error thrown values", () => {
const { service, logger } = createService();
Object.defineProperty(service, "buffer", {
value: {
push: () => {
throw "string error";
},
},
});
service.addToBuffer(makeCheck());
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Unknown error",
stack: undefined,
})
);
});
});
// ── addGeoCheckToBuffer ──────────────────────────────────────────────
describe("addGeoCheckToBuffer", () => {
it("adds a geo check to the buffer", async () => {
const { service, geoChecksService } = createService();
const geoCheck = makeGeoCheck();
service.addGeoCheckToBuffer(geoCheck);
await service.flushGeoBuffer();
expect(geoChecksService.createGeoChecks).toHaveBeenCalledWith([geoCheck]);
});
it("logs error if push throws", () => {
const { service, logger } = createService();
Object.defineProperty(service, "geoBuffer", { value: Object.freeze([]) });
service.addGeoCheckToBuffer(makeGeoCheck());
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
service: "BufferService",
method: "addGeoCheckToBuffer",
})
);
});
it("logs 'Unknown error' for non-Error thrown values", () => {
const { service, logger } = createService();
Object.defineProperty(service, "geoBuffer", {
value: {
push: () => {
throw 42;
},
},
});
service.addGeoCheckToBuffer(makeGeoCheck());
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Unknown error",
stack: undefined,
})
);
});
});
// ── scheduleNextFlush ────────────────────────────────────────────────
describe("scheduleNextFlush", () => {
it("clears existing timer and sets a new one", () => {
const { service } = createService();
// Constructor already scheduled one flush
const initialTimerCount = jest.getTimerCount();
service.scheduleNextFlush();
// Should still have timers (cleared old, set new)
expect(jest.getTimerCount()).toBe(initialTimerCount);
});
it("flushes buffer and geo buffer when timer fires", async () => {
const { service, checkService, geoChecksService } = createService();
service.addToBuffer(makeCheck());
service.addGeoCheckToBuffer(makeGeoCheck());
await jest.advanceTimersByTimeAsync(10);
expect(checkService.createChecks).toHaveBeenCalled();
expect(geoChecksService.createGeoChecks).toHaveBeenCalled();
});
it("reschedules after flush completes", async () => {
const { service, checkService } = createService();
(checkService.createChecks as jest.Mock).mockResolvedValue([]);
service.addToBuffer(makeCheck());
await jest.advanceTimersByTimeAsync(10);
// Add another check and advance again to confirm rescheduling
service.addToBuffer(makeCheck({ id: "c2" }));
await jest.advanceTimersByTimeAsync(10);
expect(checkService.createChecks).toHaveBeenCalledTimes(2);
});
it("reschedules even when flush throws", async () => {
const { service, checkService, logger } = createService();
(checkService.createChecks as jest.Mock).mockRejectedValueOnce(new Error("DB down"));
service.addToBuffer(makeCheck());
await jest.advanceTimersByTimeAsync(10);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "DB down",
method: "flushBuffer",
})
);
// Should still reschedule — add another check and flush
(checkService.createChecks as jest.Mock).mockResolvedValue([]);
service.addToBuffer(makeCheck({ id: "c2" }));
await jest.advanceTimersByTimeAsync(10);
expect(checkService.createChecks).toHaveBeenCalledTimes(2);
});
it("logs error and reschedules when flush throws past its own catch", async () => {
const { service, logger } = createService();
// Override flushBuffer to throw past its own try/catch
service.flushBuffer = jest.fn<() => Promise<void>>().mockRejectedValueOnce(new Error("unexpected"));
await jest.advanceTimersByTimeAsync(10);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "unexpected",
method: "scheduleNextFlush",
})
);
});
it("logs 'Unknown error' when flush throws non-Error past its own catch", async () => {
const { service, logger } = createService();
service.flushBuffer = jest.fn<() => Promise<void>>().mockRejectedValueOnce("string error");
await jest.advanceTimersByTimeAsync(10);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Unknown error",
method: "scheduleNextFlush",
stack: undefined,
})
);
});
});
// ── flushBuffer ──────────────────────────────────────────────────────
describe("flushBuffer", () => {
it("does nothing when buffer is empty", async () => {
const { service, checkService } = createService();
await service.flushBuffer();
expect(checkService.createChecks).not.toHaveBeenCalled();
});
it("flushes checks to checksService and clears buffer", async () => {
const { service, checkService } = createService();
const check = makeCheck();
service.addToBuffer(check);
await service.flushBuffer();
expect(checkService.createChecks).toHaveBeenCalledWith([check]);
// Buffer should be empty now
await service.flushBuffer();
expect(checkService.createChecks).toHaveBeenCalledTimes(1);
});
it("logs debug message before flushing", async () => {
const { service, logger } = createService();
service.addToBuffer(makeCheck());
await service.flushBuffer();
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({
message: "Flushing 1 checks to database",
service: "BufferService",
method: "flushBuffer",
})
);
});
it("clears buffer even on error to prevent infinite retries", async () => {
const { service, checkService, logger } = createService();
(checkService.createChecks as jest.Mock).mockRejectedValue(new Error("DB write failed"));
service.addToBuffer(makeCheck());
await service.flushBuffer();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "DB write failed",
method: "flushBuffer",
})
);
// Buffer should be cleared
await service.flushBuffer();
expect(checkService.createChecks).toHaveBeenCalledTimes(1);
});
it("logs 'Unknown error' for non-Error thrown values", async () => {
const { service, checkService, logger } = createService();
(checkService.createChecks as jest.Mock).mockRejectedValue(null);
service.addToBuffer(makeCheck());
await service.flushBuffer();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Unknown error",
method: "flushBuffer",
stack: undefined,
})
);
});
});
// ── flushGeoBuffer ───────────────────────────────────────────────────
describe("flushGeoBuffer", () => {
it("does nothing when geo buffer is empty", async () => {
const { service, geoChecksService } = createService();
await service.flushGeoBuffer();
expect(geoChecksService.createGeoChecks).not.toHaveBeenCalled();
});
it("flushes geo checks and clears buffer", async () => {
const { service, geoChecksService } = createService();
const geoCheck = makeGeoCheck();
service.addGeoCheckToBuffer(geoCheck);
await service.flushGeoBuffer();
expect(geoChecksService.createGeoChecks).toHaveBeenCalledWith([geoCheck]);
// Buffer should be empty now
await service.flushGeoBuffer();
expect(geoChecksService.createGeoChecks).toHaveBeenCalledTimes(1);
});
it("logs debug message before flushing", async () => {
const { service, logger } = createService();
service.addGeoCheckToBuffer(makeGeoCheck());
await service.flushGeoBuffer();
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({
message: "Flushing 1 geo checks to database",
service: "BufferService",
method: "flushGeoBuffer",
})
);
});
it("clears geo buffer even on error", async () => {
const { service, geoChecksService, logger } = createService();
(geoChecksService.createGeoChecks as jest.Mock).mockRejectedValue(new Error("DB error"));
service.addGeoCheckToBuffer(makeGeoCheck());
await service.flushGeoBuffer();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "DB error",
method: "flushGeoBuffer",
})
);
// Buffer should be cleared
await service.flushGeoBuffer();
expect(geoChecksService.createGeoChecks).toHaveBeenCalledTimes(1);
});
it("logs 'Unknown error' for non-Error thrown values", async () => {
const { service, geoChecksService, logger } = createService();
(geoChecksService.createGeoChecks as jest.Mock).mockRejectedValue(undefined);
service.addGeoCheckToBuffer(makeGeoCheck());
await service.flushGeoBuffer();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Unknown error",
method: "flushGeoBuffer",
stack: undefined,
})
);
});
});
});
@@ -0,0 +1,427 @@
import { describe, expect, it, jest } from "@jest/globals";
import { CheckService } from "../../../src/service/business/checkService.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { IChecksRepository, IMonitorsRepository } from "../../../src/repositories/index.ts";
import type { MonitorStatusResponse, HardwareStatusPayload, PageSpeedStatusPayload } from "../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createMonitorsRepo = () =>
({
findById: jest.fn().mockResolvedValue({}),
}) as unknown as jest.Mocked<IMonitorsRepository>;
const createChecksRepo = () =>
({
createChecks: jest.fn().mockImplementation((checks: unknown) => Promise.resolve(checks)),
findByMonitorId: jest.fn().mockResolvedValue({ checks: [], count: 0 }),
findByTeamId: jest.fn().mockResolvedValue({ checks: [], count: 0 }),
findSummaryByTeamId: jest.fn().mockResolvedValue({}),
deleteByMonitorId: jest.fn().mockResolvedValue(5),
deleteByTeamId: jest.fn().mockResolvedValue(10),
deleteOlderThan: jest.fn().mockResolvedValue(3),
}) as unknown as jest.Mocked<IChecksRepository>;
const createService = () => {
const logger = createMockLogger();
const monitorsRepository = createMonitorsRepo();
const checksRepository = createChecksRepo();
const service = new CheckService(monitorsRepository, logger as any, checksRepository);
return { service, logger, monitorsRepository, checksRepository };
};
const makeStatusResponse = (overrides?: Partial<MonitorStatusResponse>): MonitorStatusResponse =>
({
monitorId: "mon-1",
teamId: "team-1",
type: "http",
status: true,
code: 200,
message: "OK",
responseTime: 100,
...overrides,
}) as MonitorStatusResponse;
// ── Tests ────────────────────────────────────────────────────────────────────
describe("CheckService", () => {
describe("serviceName", () => {
it("returns checkService", () => {
const { service } = createService();
expect(service.serviceName).toBe("checkService");
});
});
// ── buildCheck ───────────────────────────────────────────────────────────
describe("buildCheck", () => {
it("builds a basic HTTP check with correct fields", () => {
const { service } = createService();
const check = service.buildCheck(makeStatusResponse());
expect(check).toBeDefined();
expect(check!.metadata).toEqual({ monitorId: "mon-1", teamId: "team-1", type: "http" });
expect(check!.status).toBe(true);
expect(check!.statusCode).toBe(200);
expect(check!.responseTime).toBe(100);
expect(check!.message).toBe("OK");
expect(check!.id).toBeDefined();
expect(check!.createdAt).toBeDefined();
});
it("defaults responseTime to 0 when falsy", () => {
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ responseTime: undefined }));
expect(check!.responseTime).toBe(0);
});
it("includes timings when provided", () => {
const timings = { start: 0, socket: 10, lookup: 20 };
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ timings } as any));
expect(check!.timings).toBe(timings);
});
// ── PageSpeed ────────────────────────────────────────────────────────
describe("pagespeed type", () => {
it("extracts category scores and audits from lighthouse result", () => {
const payload: PageSpeedStatusPayload = {
lighthouseResult: {
categories: {
accessibility: { score: 0.9 },
"best-practices": { score: 0.8 },
seo: { score: 0.95 },
performance: { score: 0.7 },
},
audits: {
"cumulative-layout-shift": { id: "cls", title: "CLS", score: 0.1, displayValue: "0.1", numericValue: 0.1, numericUnit: "unitless" },
"speed-index": { id: "si", title: "SI", score: 0.5, displayValue: "3.0s", numericValue: 3000, numericUnit: "millisecond" },
"first-contentful-paint": { id: "fcp", title: "FCP", score: 0.8 },
"largest-contentful-paint": { id: "lcp", title: "LCP", score: 0.6 },
"total-blocking-time": { id: "tbt", title: "TBT", score: 0.9 },
},
},
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.accessibility).toBe(90);
expect(check!.bestPractices).toBe(80);
expect(check!.seo).toBe(95);
expect(check!.performance).toBe(70);
expect(check!.audits!.cls).toEqual(expect.objectContaining({ id: "cls", score: 0.1, numericValue: 0.1 }));
expect(check!.audits!.si).toEqual(expect.objectContaining({ id: "si", numericValue: 3000 }));
expect(check!.audits!.fcp).toEqual(expect.objectContaining({ id: "fcp" }));
});
it("returns undefined when pagespeed payload is missing", () => {
const { service, logger } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload: undefined } as any));
expect(check).toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build check" }));
});
it("defaults categories and audits to empty when lighthouseResult is missing", () => {
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload: {} } as any));
expect(check!.accessibility).toBe(0);
expect(check!.bestPractices).toBe(0);
expect(check!.seo).toBe(0);
expect(check!.performance).toBe(0);
expect(check!.audits!.cls).toBeUndefined();
});
it("handles audit with non-number score (string)", () => {
const payload = {
lighthouseResult: {
categories: {},
audits: {
"cumulative-layout-shift": { id: "cls", title: "CLS", score: "informative" },
},
},
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.audits!.cls!.score).toBe("informative");
});
it("handles audit with null score", () => {
const payload = {
lighthouseResult: {
categories: {},
audits: {
"cumulative-layout-shift": { id: "cls", title: "CLS", score: null },
},
},
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.audits!.cls!.score).toBeNull();
});
it("handles audit with non-number numericValue", () => {
const payload = {
lighthouseResult: {
categories: {},
audits: {
"speed-index": { id: "si", title: "SI", score: 0.5, numericValue: "n/a" },
},
},
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.audits!.si!.numericValue).toBeUndefined();
});
it("returns undefined for non-object audit", () => {
const payload = {
lighthouseResult: {
categories: {},
audits: {
"cumulative-layout-shift": null,
"speed-index": "not an object",
},
},
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
expect(check!.audits!.cls).toBeUndefined();
expect(check!.audits!.si).toBeUndefined();
});
});
// ── Hardware ─────────────────────────────────────────────────────────
describe("hardware type", () => {
it("extracts cpu, memory, disk, host, net, and capture from payload", () => {
const payload: HardwareStatusPayload = {
data: {
cpu: { usage_percent: 0.5 },
memory: { usage_percent: 0.6 },
disk: [{ device: "/dev/sda", usage_percent: 0.7 }],
host: { os: "linux" },
net: [{ name: "eth0" }],
},
capture: { screenshot: "base64data" },
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any));
expect(check!.cpu).toEqual({ usage_percent: 0.5 });
expect(check!.memory).toEqual({ usage_percent: 0.6 });
expect(check!.disk).toHaveLength(1);
expect(check!.host).toEqual({ os: "linux" });
expect(check!.net).toHaveLength(1);
expect(check!.capture).toEqual({ screenshot: "base64data" });
});
it("extracts errors from array format", () => {
const payload = {
data: {},
errors: [{ message: "timeout" }],
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any));
expect(check!.errors).toEqual([{ message: "timeout" }]);
});
it("extracts errors from nested object format", () => {
const payload = {
data: {},
errors: { errors: [{ message: "nested error" }] },
} as any;
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any));
expect(check!.errors).toEqual([{ message: "nested error" }]);
});
it("handles undefined payload gracefully", () => {
const { service } = createService();
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload: undefined } as any));
expect(check).toBeDefined();
expect(check!.cpu).toBeUndefined();
expect(check!.memory).toBeUndefined();
expect(check!.disk).toBeUndefined();
expect(check!.errors).toBeUndefined();
});
});
});
// ── createChecks ─────────────────────────────────────────────────────────
describe("createChecks", () => {
it("delegates to repository", async () => {
const { service, checksRepository } = createService();
const checks = [{ id: "c1" }] as any;
await service.createChecks(checks);
expect(checksRepository.createChecks).toHaveBeenCalledWith(checks);
});
});
// ── getChecksByMonitor ───────────────────────────────────────────────────
describe("getChecksByMonitor", () => {
it("returns checks from repository", async () => {
const expected = { checks: [{ id: "c1" }], count: 1 };
const { service, checksRepository } = createService();
(checksRepository.findByMonitorId as jest.Mock).mockResolvedValue(expected);
const result = await service.getChecksByMonitor({
monitorId: "mon-1",
teamId: "team-1",
sortOrder: "desc",
dateRange: "day",
page: 0,
rowsPerPage: 10,
});
expect(result).toBe(expected);
});
it("defaults page to 0 and rowsPerPage to 5 when nullish", async () => {
const { service, checksRepository } = createService();
await service.getChecksByMonitor({
monitorId: "mon-1",
teamId: "team-1",
sortOrder: "desc",
dateRange: "day",
page: null as any,
rowsPerPage: null as any,
});
expect(checksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "desc", "day", undefined, 0, 5, undefined);
});
it("passes filter and status parameters", async () => {
const { service, checksRepository } = createService();
await service.getChecksByMonitor({
monitorId: "mon-1",
teamId: "team-1",
sortOrder: "asc",
dateRange: "week",
page: 2,
rowsPerPage: 20,
filter: "error",
status: false,
});
expect(checksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "asc", "week", "error", 2, 20, false);
});
it("throws when monitorId is missing", async () => {
const { service } = createService();
await expect(
service.getChecksByMonitor({ monitorId: "", teamId: "team-1", sortOrder: "desc", dateRange: "day", page: 0, rowsPerPage: 10 })
).rejects.toThrow("No monitor ID in request");
});
it("throws when teamId is missing", async () => {
const { service } = createService();
await expect(
service.getChecksByMonitor({ monitorId: "mon-1", teamId: "", sortOrder: "desc", dateRange: "day", page: 0, rowsPerPage: 10 })
).rejects.toThrow("No team ID in request");
});
it("verifies monitor belongs to team via repository", async () => {
const { service, monitorsRepository } = createService();
await service.getChecksByMonitor({
monitorId: "mon-1",
teamId: "team-1",
sortOrder: "desc",
dateRange: "day",
page: 0,
rowsPerPage: 10,
});
expect(monitorsRepository.findById).toHaveBeenCalledWith("mon-1", "team-1");
});
});
// ── getChecksByTeam ──────────────────────────────────────────────────────
describe("getChecksByTeam", () => {
it("returns checks from repository", async () => {
const expected = { checks: [], count: 0 };
const { service, checksRepository } = createService();
(checksRepository.findByTeamId as jest.Mock).mockResolvedValue(expected);
const result = await service.getChecksByTeam({ teamId: "team-1", sortOrder: "desc", dateRange: "day", page: 0, rowsPerPage: 10 });
expect(result).toBe(expected);
});
it("defaults page to 0 and rowsPerPage to 5 when nullish", async () => {
const { service, checksRepository } = createService();
await service.getChecksByTeam({ teamId: "team-1", sortOrder: "desc", dateRange: "day", page: null as any, rowsPerPage: null as any });
expect(checksRepository.findByTeamId).toHaveBeenCalledWith("desc", "day", undefined, 0, 5, "team-1");
});
});
// ── getChecksSummaryByTeamId ──────────────────────────────────────────────
describe("getChecksSummaryByTeamId", () => {
it("delegates to repository", async () => {
const summary = { total: 5, up: 4, down: 1 };
const { service, checksRepository } = createService();
(checksRepository.findSummaryByTeamId as jest.Mock).mockResolvedValue(summary);
const result = await service.getChecksSummaryByTeamId({ teamId: "team-1", dateRange: "day" });
expect(result).toBe(summary);
expect(checksRepository.findSummaryByTeamId).toHaveBeenCalledWith("team-1", "day");
});
});
// ── deleteChecks ─────────────────────────────────────────────────────────
describe("deleteChecks", () => {
it("verifies monitor ownership and deletes", async () => {
const { service, monitorsRepository, checksRepository } = createService();
const result = await service.deleteChecks({ monitorId: "mon-1", teamId: "team-1" });
expect(monitorsRepository.findById).toHaveBeenCalledWith("mon-1", "team-1");
expect(checksRepository.deleteByMonitorId).toHaveBeenCalledWith("mon-1");
expect(result).toBe(5);
});
});
describe("deleteChecksByTeamId", () => {
it("delegates to repository", async () => {
const { service, checksRepository } = createService();
const result = await service.deleteChecksByTeamId({ teamId: "team-1" });
expect(checksRepository.deleteByTeamId).toHaveBeenCalledWith("team-1");
expect(result).toBe(10);
});
});
describe("deleteOlderThan", () => {
it("delegates to repository", async () => {
const date = new Date();
const { service, checksRepository } = createService();
const result = await service.deleteOlderThan(date);
expect(checksRepository.deleteOlderThan).toHaveBeenCalledWith(date);
expect(result).toBe(3);
});
});
});
@@ -0,0 +1,151 @@
import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals";
import { DiagnosticService } from "../../../src/service/business/diagnosticService.ts";
// ── Tests ────────────────────────────────────────────────────────────────────
describe("DiagnosticService", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
describe("serviceName", () => {
it("returns diagnosticService from static property", () => {
expect(DiagnosticService.SERVICE_NAME).toBe("diagnosticService");
});
it("returns diagnosticService from instance getter", () => {
const service = new DiagnosticService();
expect(service.serviceName).toBe("diagnosticService");
});
});
// ── constructor ─────────────────────────────────────────────────────────
describe("constructor", () => {
it("sets up PerformanceObserver that clears marks on callback", () => {
let capturedCallback: ((list: PerformanceObserverEntryList) => void) | null = null;
const originalObserver = globalThis.PerformanceObserver;
const mockObserve = jest.fn();
globalThis.PerformanceObserver = jest.fn().mockImplementation((cb: any) => {
capturedCallback = cb;
return { observe: mockObserve };
}) as any;
const clearMarksSpy = jest.spyOn(performance, "clearMarks");
const service = new DiagnosticService();
expect(mockObserve).toHaveBeenCalledWith({ entryTypes: ["measure"] });
expect(capturedCallback).not.toBeNull();
// Invoke the callback manually
const mockEntryList = { getEntries: jest.fn().mockReturnValue([]) } as unknown as PerformanceObserverEntryList;
capturedCallback!(mockEntryList);
expect(mockEntryList.getEntries).toHaveBeenCalled();
expect(clearMarksSpy).toHaveBeenCalled();
clearMarksSpy.mockRestore();
globalThis.PerformanceObserver = originalObserver;
expect(service).toBeDefined();
});
});
// ── getCPUUsage ─────────────────────────────────────────────────────────
describe("getCPUUsage", () => {
it("returns cpu usage metrics after timing period", async () => {
const service = new DiagnosticService();
const promise = service.getCPUUsage();
jest.advanceTimersByTime(1000);
const result = await promise;
expect(result).toHaveProperty("userUsageMs");
expect(result).toHaveProperty("systemUsageMs");
expect(result).toHaveProperty("usagePercentage");
expect(typeof result.userUsageMs).toBe("number");
expect(typeof result.systemUsageMs).toBe("number");
expect(typeof result.usagePercentage).toBe("number");
});
});
// ── getSystemStats ──────────────────────────────────────────────────────
describe("getSystemStats", () => {
it("returns all diagnostic sections", async () => {
const service = new DiagnosticService();
const promise = service.getSystemStats();
// getCPUUsage has 1s timeout, then event loop delay has 0ms timeout
jest.advanceTimersByTime(1000);
// Need to flush the microtask queue and advance again for the nested setTimeout(0)
await jest.advanceTimersByTimeAsync(10);
const result = await promise;
expect(result).toHaveProperty("osStats");
expect(result.osStats).toHaveProperty("freeMemoryBytes");
expect(result.osStats).toHaveProperty("totalMemoryBytes");
expect(result).toHaveProperty("memoryUsage");
expect(result.memoryUsage).toHaveProperty("rss");
expect(result.memoryUsage).toHaveProperty("heapTotal");
expect(result.memoryUsage).toHaveProperty("heapUsed");
expect(result.memoryUsage).toHaveProperty("external");
expect(result.memoryUsage).toHaveProperty("arrayBuffers");
expect(result).toHaveProperty("cpuUsage");
expect(result.cpuUsage).toHaveProperty("userUsageMs");
expect(result).toHaveProperty("v8HeapStats");
expect(result.v8HeapStats).toHaveProperty("totalHeapSizeBytes");
expect(result.v8HeapStats).toHaveProperty("usedHeapSizeBytes");
expect(result.v8HeapStats).toHaveProperty("heapSizeLimitBytes");
expect(typeof result.eventLoopDelayMs).toBe("number");
expect(result.eventLoopDelayMs).toBeGreaterThanOrEqual(0);
expect(typeof result.uptimeMs).toBe("number");
expect(result.uptimeMs).toBeGreaterThan(0);
});
it("reads eventLoopDelay from real performance entries", async () => {
jest.useRealTimers();
const service = new DiagnosticService();
// Call getSystemStats with real timers — slow but guarantees real perf entries
const result = await service.getSystemStats();
expect(typeof result.eventLoopDelayMs).toBe("number");
expect(result.eventLoopDelayMs).toBeGreaterThanOrEqual(0);
jest.useFakeTimers();
});
it.each([
{ label: "empty entries array", entries: [] },
{ label: "undefined entry in array", entries: [undefined] },
])("defaults eventLoopDelayMs to 0 when $label", async ({ entries }) => {
const service = new DiagnosticService();
const originalGetEntries = performance.getEntriesByName;
const originalMeasure = performance.measure;
const originalMark = performance.mark;
performance.mark = jest.fn() as any;
performance.measure = jest.fn() as any;
performance.getEntriesByName = jest.fn().mockReturnValue(entries) as any;
const promise = service.getSystemStats();
jest.advanceTimersByTime(1000);
await jest.advanceTimersByTimeAsync(10);
const result = await promise;
expect(result.eventLoopDelayMs).toBe(0);
expect(performance.getEntriesByName).toHaveBeenCalledWith("eventLoopDelay");
performance.getEntriesByName = originalGetEntries;
performance.measure = originalMeasure;
performance.mark = originalMark;
});
});
});
@@ -0,0 +1,400 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { EmailService } from "../../../src/service/infrastructure/emailService.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { ISettingsService } from "../../../src/service/system/settingsService.ts";
import type { EmailTransportConfig } from "../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeTransportConfig = (overrides?: Partial<EmailTransportConfig>): EmailTransportConfig =>
({
systemEmailHost: "smtp.example.com",
systemEmailPort: 587,
systemEmailSecure: false,
systemEmailPool: false,
systemEmailUser: "user@example.com",
systemEmailAddress: "noreply@example.com",
systemEmailPassword: "password123",
systemEmailConnectionHost: "mail.example.com",
systemEmailTLSServername: "smtp.example.com",
systemEmailIgnoreTLS: false,
systemEmailRequireTLS: true,
systemEmailRejectUnauthorized: true,
...overrides,
}) as EmailTransportConfig;
const createMockSettingsService = () =>
({
getDBSettings: jest.fn().mockResolvedValue(makeTransportConfig()),
}) as unknown as jest.Mocked<ISettingsService>;
const createMockFs = (templateContent: string = "<mjml><mj-body></mj-body></mjml>") =>
({
readFileSync: jest.fn().mockReturnValue(templateContent),
}) as any;
const createMockPath = () =>
({
join: jest.fn((...args: string[]) => args.join("/")),
dirname: jest.fn().mockReturnValue("/mock/dir"),
}) as any;
const createMockCompile = (result: string = "<mjml>compiled</mjml>") => {
const compiledFn = jest.fn().mockReturnValue(result);
const compile = jest.fn().mockReturnValue(compiledFn) as any;
compile.__compiledFn = compiledFn;
return compile;
};
const createMockMjml = (html: string = "<html>rendered</html>") => jest.fn().mockReturnValue({ html }) as any;
const createMockTransporter = () => ({
verify: jest.fn().mockResolvedValue(true),
sendMail: jest.fn().mockResolvedValue({ messageId: "msg-123" }),
});
const createMockNodemailer = (transporter?: ReturnType<typeof createMockTransporter>) => {
const t = transporter ?? createMockTransporter();
return {
createTransport: jest.fn().mockReturnValue(t),
__transporter: t,
} as any;
};
const createService = (overrides?: { settingsService?: any; fs?: any; path?: any; compile?: any; mjml?: any; nodemailer?: any }) => {
const logger = createMockLogger();
const settingsService = overrides?.settingsService ?? createMockSettingsService();
const mockFs = overrides?.fs ?? createMockFs();
const mockPath = overrides?.path ?? createMockPath();
const compile = overrides?.compile ?? createMockCompile();
const mjml = overrides?.mjml ?? createMockMjml();
const mockNodemailer = overrides?.nodemailer ?? createMockNodemailer();
const service = new EmailService(settingsService, mockFs, mockPath, compile, mjml, mockNodemailer, logger as any);
return { service, logger, settingsService, mockFs, mockPath, compile, mjml, mockNodemailer };
};
// ── Tests ────────────────────────────────────────────────────────────────────
describe("EmailService", () => {
describe("SERVICE_NAME", () => {
it("returns EmailService from static property", () => {
expect(EmailService.SERVICE_NAME).toBe("EmailService");
});
it("returns EmailService from instance getter", () => {
const { service } = createService();
expect(service.serviceName).toBe("EmailService");
});
});
// ── init / loadTemplate ──────────────────────────────────────────────
describe("init", () => {
it("loads all expected templates on construction", () => {
const { mockFs } = createService();
// 6 templates loaded
expect(mockFs.readFileSync).toHaveBeenCalledTimes(6);
});
it("compiles each loaded template", () => {
const { compile } = createService();
expect(compile).toHaveBeenCalledTimes(6);
});
it("logs error when a template file is not found", () => {
const failFs = {
readFileSync: jest.fn().mockImplementation(() => {
throw new Error("ENOENT: no such file");
}),
};
const { logger } = createService({ fs: failFs });
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "ENOENT: no such file",
service: "EmailService",
method: "loadTemplate",
})
);
});
it("logs 'Unknown error' for non-Error thrown values in loadTemplate", () => {
const failFs = {
readFileSync: jest.fn().mockImplementation(() => {
throw "string error";
}),
};
const { logger } = createService({ fs: failFs });
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Unknown error",
stack: undefined,
})
);
});
it("can be called again to reinitialize templates", () => {
const { service, mockFs } = createService();
const initialCalls = mockFs.readFileSync.mock.calls.length;
service.init();
expect(mockFs.readFileSync).toHaveBeenCalledTimes(initialCalls + 6);
});
});
// ── buildEmail ───────────────────────────────────────────────────────
describe("buildEmail", () => {
it("compiles template with context and returns rendered HTML", async () => {
const { service } = createService();
const result = await service.buildEmail("welcomeEmailTemplate", { name: "Test" });
expect(result).toBe("<html>rendered</html>");
});
it("passes context to the compiled template function", async () => {
const compile = createMockCompile();
const { service } = createService({ compile });
const context = { name: "Alex", url: "https://example.com" };
await service.buildEmail("welcomeEmailTemplate", context);
expect(compile.__compiledFn).toHaveBeenCalledWith(context);
});
it("returns undefined and logs error when template is not found", async () => {
const { service, logger } = createService();
const result = await service.buildEmail("nonExistentTemplate", {});
expect(result).toBeUndefined();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Template nonExistentTemplate not found",
service: "EmailService",
method: "buildEmail",
})
);
});
it("returns undefined and logs error when template function returns falsy", async () => {
const compile = jest.fn().mockReturnValue(jest.fn().mockReturnValue(undefined)) as any;
const { service, logger } = createService({ compile });
const result = await service.buildEmail("welcomeEmailTemplate", {});
expect(result).toBeUndefined();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Template welcomeEmailTemplate not found",
})
);
});
it("returns undefined and logs error when mjml throws", async () => {
const mjml = jest.fn().mockImplementation(() => {
throw new Error("MJML parse error");
}) as any;
const { service, logger } = createService({ mjml });
const result = await service.buildEmail("welcomeEmailTemplate", {});
expect(result).toBeUndefined();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "MJML parse error",
method: "buildEmail",
})
);
});
it("logs 'Unknown error' for non-Error thrown values in buildEmail", async () => {
const mjml = jest.fn().mockImplementation(() => {
throw null;
}) as any;
const { service, logger } = createService({ mjml });
const result = await service.buildEmail("welcomeEmailTemplate", {});
expect(result).toBeUndefined();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Unknown error",
method: "buildEmail",
stack: undefined,
})
);
});
});
// ── sendEmail ────────────────────────────────────────────────────────
describe("sendEmail", () => {
it("sends email with provided transport config and returns messageId", async () => {
const transporter = createMockTransporter();
const nodemailer = createMockNodemailer(transporter);
const { service } = createService({ nodemailer });
const config = makeTransportConfig();
const result = await service.sendEmail("to@example.com", "Subject", "<p>body</p>", config);
expect(result).toBe("msg-123");
expect(transporter.verify).toHaveBeenCalled();
expect(transporter.sendMail).toHaveBeenCalledWith({
to: "to@example.com",
from: "noreply@example.com",
subject: "Subject",
html: "<p>body</p>",
});
});
it("falls back to DB settings when no transport config is provided", async () => {
const settingsService = createMockSettingsService();
const transporter = createMockTransporter();
const nodemailer = createMockNodemailer(transporter);
const { service } = createService({ settingsService, nodemailer });
await service.sendEmail("to@example.com", "Subject", "<p>body</p>");
expect(settingsService.getDBSettings).toHaveBeenCalled();
});
it("creates transporter with correct email config", async () => {
const transporter = createMockTransporter();
const nodemailer = createMockNodemailer(transporter);
const { service } = createService({ nodemailer });
const config = makeTransportConfig({
systemEmailHost: "smtp.test.com",
systemEmailPort: 465,
systemEmailSecure: true,
systemEmailPool: true,
systemEmailUser: "testuser",
systemEmailPassword: "testpass",
systemEmailConnectionHost: "conn.test.com",
systemEmailRejectUnauthorized: false,
systemEmailIgnoreTLS: true,
systemEmailRequireTLS: false,
systemEmailTLSServername: "tls.test.com",
});
await service.sendEmail("to@example.com", "Subject", "<p>body</p>", config);
expect(nodemailer.createTransport).toHaveBeenCalledWith({
host: "smtp.test.com",
port: 465,
secure: true,
auth: { user: "testuser", pass: "testpass" },
name: "conn.test.com",
connectionTimeout: 5000,
pool: true,
tls: {
rejectUnauthorized: false,
ignoreTLS: true,
requireTLS: false,
servername: "tls.test.com",
},
});
});
it("uses systemEmailAddress as auth user when systemEmailUser is falsy", async () => {
const transporter = createMockTransporter();
const nodemailer = createMockNodemailer(transporter);
const { service } = createService({ nodemailer });
const config = makeTransportConfig({ systemEmailUser: "", systemEmailAddress: "fallback@example.com" });
await service.sendEmail("to@example.com", "Subject", "<p>body</p>", config);
expect(nodemailer.createTransport).toHaveBeenCalledWith(
expect.objectContaining({
auth: expect.objectContaining({ user: "fallback@example.com" }),
})
);
});
it("uses 'localhost' as name when systemEmailConnectionHost is falsy", async () => {
const transporter = createMockTransporter();
const nodemailer = createMockNodemailer(transporter);
const { service } = createService({ nodemailer });
const config = makeTransportConfig({ systemEmailConnectionHost: "" });
await service.sendEmail("to@example.com", "Subject", "<p>body</p>", config);
expect(nodemailer.createTransport).toHaveBeenCalledWith(expect.objectContaining({ name: "localhost" }));
});
it("returns false when transporter verification fails", async () => {
const transporter = createMockTransporter();
(transporter.verify as jest.Mock).mockRejectedValue(new Error("SMTP auth failed"));
const nodemailer = createMockNodemailer(transporter);
const { service, logger } = createService({ nodemailer });
const result = await service.sendEmail("to@example.com", "Subject", "<p>body</p>", makeTransportConfig());
expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: "Email transporter verification failed",
service: "EmailService",
method: "verifyTransporter",
})
);
});
it("logs stack as undefined when verification fails with non-Error", async () => {
const transporter = createMockTransporter();
(transporter.verify as jest.Mock).mockRejectedValue("connection refused");
const nodemailer = createMockNodemailer(transporter);
const { service, logger } = createService({ nodemailer });
const result = await service.sendEmail("to@example.com", "Subject", "<p>body</p>", makeTransportConfig());
expect(result).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
});
it("returns undefined and logs error when sendMail throws", async () => {
const transporter = createMockTransporter();
(transporter.sendMail as jest.Mock).mockRejectedValue(new Error("Recipient rejected"));
const nodemailer = createMockNodemailer(transporter);
const { service, logger } = createService({ nodemailer });
const result = await service.sendEmail("to@example.com", "Subject", "<p>body</p>", makeTransportConfig());
expect(result).toBeUndefined();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Recipient rejected",
service: "EmailService",
method: "sendEmail",
})
);
});
it("logs 'Unknown error' for non-Error thrown values in sendMail", async () => {
const transporter = createMockTransporter();
(transporter.sendMail as jest.Mock).mockRejectedValue(42);
const nodemailer = createMockNodemailer(transporter);
const { service, logger } = createService({ nodemailer });
const result = await service.sendEmail("to@example.com", "Subject", "<p>body</p>", makeTransportConfig());
expect(result).toBeUndefined();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Unknown error",
method: "sendEmail",
stack: undefined,
})
);
});
});
});
@@ -0,0 +1,269 @@
import { describe, expect, it, jest } from "@jest/globals";
import { GeoChecksService } from "../../../src/service/business/geoChecksService.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { Monitor } from "../../../src/types/index.ts";
import type { GeoCheckResult } from "../../../src/types/geoCheck.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "http",
url: "https://example.com",
geoCheckEnabled: true,
geoCheckLocations: ["NA", "EU"],
...overrides,
}) as Monitor;
const makeGeoResult = (overrides?: Partial<GeoCheckResult>): GeoCheckResult => ({
continent: "NA",
country: "US",
city: "New York",
status: "up",
responseTime: 50,
...overrides,
});
const createService = (overrides?: Record<string, unknown>) => {
const logger = createMockLogger();
const geoChecksRepository = {
createGeoChecks: jest.fn().mockResolvedValue([]),
findByMonitorId: jest.fn().mockResolvedValue({ geoChecksCount: 0, geoChecks: [] }),
};
const globalPingService = {
createMeasurement: jest.fn().mockResolvedValue("measurement-123"),
pollForResults: jest.fn().mockResolvedValue([makeGeoResult()]),
};
const monitorsRepository = {
findById: jest.fn().mockResolvedValue(makeMonitor()),
};
const defaults = { logger, geoChecksRepository, globalPingService, monitorsRepository, ...overrides };
const service = new GeoChecksService(defaults as any);
return { service, ...defaults };
};
// ── Tests ────────────────────────────────────────────────────────────────────
describe("GeoChecksService", () => {
describe("serviceName", () => {
it("returns GeoChecksService from static property", () => {
expect(GeoChecksService.SERVICE_NAME).toBe("GeoChecksService");
});
it("returns GeoChecksService from instance getter", () => {
const { service } = createService();
expect(service.serviceName).toBe("GeoChecksService");
});
});
// ── buildGeoCheck ───────────────────────────────────────────────────────
describe("buildGeoCheck", () => {
it("returns a geo check document on success", async () => {
const { service } = createService();
const result = await service.buildGeoCheck(makeMonitor());
expect(result).not.toBeNull();
expect(result!.metadata).toEqual({ monitorId: "mon-1", teamId: "team-1", type: "http" });
expect(result!.results).toHaveLength(1);
expect(result!.results[0].continent).toBe("NA");
expect(result!.id).toBeDefined();
expect(result!.expiry).toBeDefined();
});
it("returns null and warns when monitor has no URL", async () => {
const { service, logger } = createService();
const result = await service.buildGeoCheck(makeMonitor({ url: "" }));
expect(result).toBeNull();
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Monitor missing URL for geo check" }));
});
it("returns null and warns when geoCheckLocations is empty", async () => {
const { service, logger } = createService();
const result = await service.buildGeoCheck(makeMonitor({ geoCheckLocations: [] }));
expect(result).toBeNull();
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Monitor missing geo check locations" }));
});
it("returns null and warns when geoCheckLocations is undefined", async () => {
const { service, logger } = createService();
const result = await service.buildGeoCheck(makeMonitor({ geoCheckLocations: undefined as any }));
expect(result).toBeNull();
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Monitor missing geo check locations" }));
});
it("returns null when createMeasurement returns null (API unavailable)", async () => {
const { service, logger } = createService({
globalPingService: {
createMeasurement: jest.fn().mockResolvedValue(null),
pollForResults: jest.fn(),
},
});
const result = await service.buildGeoCheck(makeMonitor());
expect(result).toBeNull();
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Skipping geo check due to API unavailability" }));
});
it("returns null when pollForResults returns empty array", async () => {
const { service, logger } = createService({
globalPingService: {
createMeasurement: jest.fn().mockResolvedValue("measurement-123"),
pollForResults: jest.fn().mockResolvedValue([]),
},
});
const result = await service.buildGeoCheck(makeMonitor());
expect(result).toBeNull();
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "No successful geo check results" }));
});
it("returns null and logs error when an exception is thrown", async () => {
const { service, logger } = createService({
globalPingService: {
createMeasurement: jest.fn().mockRejectedValue(new Error("API error")),
pollForResults: jest.fn(),
},
});
const result = await service.buildGeoCheck(makeMonitor());
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Error executing geo check",
details: expect.objectContaining({ error: "API error" }),
})
);
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { service, logger } = createService({
globalPingService: {
createMeasurement: jest.fn().mockRejectedValue("string error"),
pollForResults: jest.fn(),
},
});
const result = await service.buildGeoCheck(makeMonitor());
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ details: expect.objectContaining({ error: "Unknown error" }) }));
});
});
// ── createGeoChecks ─────────────────────────────────────────────────────
describe("createGeoChecks", () => {
it("delegates to repository", async () => {
const { service, geoChecksRepository } = createService();
const geoChecks = [{ id: "gc-1" }] as any;
await service.createGeoChecks(geoChecks);
expect(geoChecksRepository.createGeoChecks).toHaveBeenCalledWith(geoChecks);
});
});
// ── getGeoChecksByMonitor ────────────────────────────────────────────────
describe("getGeoChecksByMonitor", () => {
it("returns geo checks from repository", async () => {
const expected = { geoChecksCount: 1, geoChecks: [{ id: "gc-1" }] };
const { service, geoChecksRepository } = createService();
(geoChecksRepository.findByMonitorId as jest.Mock).mockResolvedValue(expected);
const result = await service.getGeoChecksByMonitor({
monitorId: "mon-1",
teamId: "team-1",
sortOrder: "desc",
dateRange: "day",
continent: "NA" as any,
});
expect(result).toEqual(expected);
expect(geoChecksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "desc", "day", 0, 5, ["NA"]);
});
it("passes array of continents through", async () => {
const { service, geoChecksRepository } = createService();
await service.getGeoChecksByMonitor({
monitorId: "mon-1",
teamId: "team-1",
sortOrder: "desc",
dateRange: "day",
continent: ["NA", "EU"] as any,
});
expect(geoChecksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "desc", "day", 0, 5, ["NA", "EU"]);
});
it("passes undefined continents when not provided", async () => {
const { service, geoChecksRepository } = createService();
await service.getGeoChecksByMonitor({
monitorId: "mon-1",
teamId: "team-1",
sortOrder: "desc",
dateRange: "day",
continent: undefined as any,
});
expect(geoChecksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "desc", "day", 0, 5, undefined);
});
it("uses provided page and rowsPerPage", async () => {
const { service, geoChecksRepository } = createService();
await service.getGeoChecksByMonitor({
monitorId: "mon-1",
teamId: "team-1",
sortOrder: "asc",
dateRange: "week",
page: 3,
rowsPerPage: 20,
continent: "EU" as any,
});
expect(geoChecksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "asc", "week", 3, 20, ["EU"]);
});
it("throws when monitorId is missing", async () => {
const { service } = createService();
await expect(
service.getGeoChecksByMonitor({ monitorId: "", teamId: "team-1", sortOrder: "desc", dateRange: "day", continent: "NA" as any })
).rejects.toThrow("No monitor ID in request");
});
it("throws when teamId is missing", async () => {
const { service } = createService();
await expect(
service.getGeoChecksByMonitor({ monitorId: "mon-1", teamId: "", sortOrder: "desc", dateRange: "day", continent: "NA" as any })
).rejects.toThrow("No team ID in request");
});
it("throws 404 when monitor is not found", async () => {
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(null);
await expect(
service.getGeoChecksByMonitor({ monitorId: "mon-1", teamId: "team-1", sortOrder: "desc", dateRange: "day", continent: "NA" as any })
).rejects.toThrow("Monitor with ID mon-1 not found.");
});
});
});
@@ -0,0 +1,499 @@
import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { GeoContinent } from "../../../src/types/geoCheck.ts";
// ── got mock ─────────────────────────────────────────────────────────────────
const mockGotPost = jest.fn();
const mockGotGet = jest.fn();
jest.unstable_mockModule("got", () => ({
default: {
post: mockGotPost,
get: mockGotGet,
},
}));
// Dynamic import AFTER mock registration
const { GlobalPingService } = await import("../../../src/service/infrastructure/globalPingService.ts");
// ── Helpers ──────────────────────────────────────────────────────────────────
const createService = () => {
const logger = createMockLogger();
const service = new GlobalPingService(logger as any);
return { service, logger };
};
const makeProbeResult = (overrides?: Record<string, any>) => ({
probe: {
continent: "NA" as GeoContinent,
region: "Northern America",
country: "US",
state: "CA",
city: "San Francisco",
longitude: -122.4,
latitude: 37.77,
},
result: {
status: "finished",
statusCode: 200,
statusCodeName: "OK",
timings: {
total: 150,
dns: 10,
tcp: 20,
tls: 30,
firstByte: 50,
download: 40,
},
},
...overrides,
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("GlobalPingService", () => {
beforeEach(() => {
jest.useFakeTimers();
mockGotPost.mockReset();
mockGotGet.mockReset();
});
afterEach(() => {
jest.useRealTimers();
});
// ── Static / instance properties ─────────────────────────────────────
describe("serviceName", () => {
it("returns GlobalPingService from static property", () => {
expect(GlobalPingService.SERVICE_NAME).toBe("GlobalPingService");
});
it("returns GlobalPingService from instance getter", () => {
const { service } = createService();
expect(service.serviceName).toBe("GlobalPingService");
});
});
// ── createMeasurement ────────────────────────────────────────────────
describe("createMeasurement", () => {
it("creates a measurement and returns the id", async () => {
const { service, logger } = createService();
mockGotPost.mockResolvedValue({ body: { id: "meas-123" } });
const result = await service.createMeasurement("http", "https://example.com", ["NA", "EU"] as GeoContinent[]);
expect(result).toBe("meas-123");
expect(mockGotPost).toHaveBeenCalledWith(
"https://api.globalping.io/v1/measurements",
expect.objectContaining({
json: {
type: "http",
target: "example.com",
locations: [{ continent: "NA" }, { continent: "EU" }],
limit: 2,
},
responseType: "json",
timeout: { request: 10000 },
})
);
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("meas-123"),
method: "createMeasurement",
})
);
});
it("strips http:// protocol from target URL", async () => {
const { service } = createService();
mockGotPost.mockResolvedValue({ body: { id: "meas-1" } });
await service.createMeasurement("http", "http://example.com", ["NA"] as GeoContinent[]);
expect(mockGotPost).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
json: expect.objectContaining({ target: "example.com" }),
})
);
});
it("strips https:// protocol from target URL", async () => {
const { service } = createService();
mockGotPost.mockResolvedValue({ body: { id: "meas-1" } });
await service.createMeasurement("http", "https://example.com/path", ["EU"] as GeoContinent[]);
expect(mockGotPost).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
json: expect.objectContaining({ target: "example.com/path" }),
})
);
});
it("returns null and logs error for unsupported monitor type", async () => {
const { service, logger } = createService();
const result = await service.createMeasurement("hardware" as any, "https://example.com", ["NA"] as GeoContinent[]);
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "GlobalPing API unavailable, skipping geo check",
method: "createMeasurement",
})
);
});
it("returns null and logs error when API call fails", async () => {
const { service, logger } = createService();
mockGotPost.mockRejectedValue(new Error("Network error"));
const result = await service.createMeasurement("http", "https://example.com", ["NA"] as GeoContinent[]);
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "GlobalPing API unavailable, skipping geo check",
method: "createMeasurement",
})
);
});
it("logs stack as undefined for non-Error thrown values", async () => {
const { service, logger } = createService();
mockGotPost.mockRejectedValue("string error");
await service.createMeasurement("http", "https://example.com", ["NA"] as GeoContinent[]);
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
});
it("supports ping monitor type", async () => {
const { service } = createService();
mockGotPost.mockResolvedValue({ body: { id: "meas-ping" } });
const result = await service.createMeasurement("ping", "example.com", ["NA"] as GeoContinent[]);
expect(result).toBe("meas-ping");
});
});
// ── pollForResults ───────────────────────────────────────────────────
describe("pollForResults", () => {
it("returns transformed results when measurement is finished", async () => {
const { service, logger } = createService();
mockGotGet.mockResolvedValue({
body: {
status: "finished",
results: [makeProbeResult()],
},
});
const results = await service.pollForResults("meas-123");
expect(results).toHaveLength(1);
expect(results[0]).toEqual(
expect.objectContaining({
location: expect.objectContaining({
continent: "NA",
city: "San Francisco",
}),
status: true,
statusCode: 200,
timings: expect.objectContaining({
total: 150,
dns: 10,
}),
})
);
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("meas-123"),
method: "pollForResults",
})
);
});
it("defaults results to empty array when finished with no results", async () => {
const { service } = createService();
mockGotGet.mockResolvedValue({
body: { status: "finished", results: undefined },
});
const results = await service.pollForResults("meas-123");
expect(results).toEqual([]);
});
it("returns empty array when measurement has failed", async () => {
const { service, logger } = createService();
mockGotGet.mockResolvedValue({
body: { status: "failed" },
});
const results = await service.pollForResults("meas-123");
expect(results).toEqual([]);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("failed"),
method: "pollForResults",
})
);
});
it("polls again when status is in-progress, then returns on finished", async () => {
const { service } = createService();
mockGotGet.mockResolvedValueOnce({ body: { status: "in-progress" } }).mockResolvedValueOnce({
body: {
status: "finished",
results: [makeProbeResult()],
},
});
const promise = service.pollForResults("meas-123");
// Advance past the sleep(2000)
await jest.advanceTimersByTimeAsync(2000);
const results = await promise;
expect(mockGotGet).toHaveBeenCalledTimes(2);
expect(results).toHaveLength(1);
});
it("returns empty array and logs error when API call throws", async () => {
const { service, logger } = createService();
mockGotGet.mockRejectedValue(new Error("Connection refused"));
const results = await service.pollForResults("meas-123");
expect(results).toEqual([]);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: "Error polling GlobalPing API",
method: "pollForResults",
})
);
});
it("logs stack as undefined for non-Error thrown values in poll", async () => {
const { service, logger } = createService();
mockGotGet.mockRejectedValue(42);
await service.pollForResults("meas-123");
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined }));
});
it("returns empty array and logs warning on timeout", async () => {
const { service, logger } = createService();
// Always return in-progress so we hit the timeout
mockGotGet.mockImplementation(async () => ({ body: { status: "in-progress" } }));
const promise = service.pollForResults("meas-123", 100);
// Advance time well past the timeout
await jest.advanceTimersByTimeAsync(35000);
const results = await promise;
expect(results).toEqual([]);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("timeout"),
method: "pollForResults",
details: expect.objectContaining({ measurementId: "meas-123", timeoutMs: 100 }),
})
);
});
});
// ── transformResults (via pollForResults) ────────────────────────────
describe("transformResults", () => {
it("skips probes with non-finished status", async () => {
const { service } = createService();
mockGotGet.mockResolvedValue({
body: {
status: "finished",
results: [makeProbeResult({ result: { status: "failed" } }), makeProbeResult({ result: { status: "timeout" } })],
},
});
const results = await service.pollForResults("meas-123");
expect(results).toEqual([]);
});
it("transforms HTTP results with statusCode and timings", async () => {
const { service } = createService();
mockGotGet.mockResolvedValue({
body: {
status: "finished",
results: [makeProbeResult()],
},
});
const results = await service.pollForResults("meas-123");
expect(results[0].status).toBe(true);
expect(results[0].statusCode).toBe(200);
expect(results[0].timings.total).toBe(150);
});
it("marks HTTP result as failed for non-2xx status codes", async () => {
const { service } = createService();
mockGotGet.mockResolvedValue({
body: {
status: "finished",
results: [
makeProbeResult({
result: {
status: "finished",
statusCode: 500,
timings: { total: 100, dns: 5, tcp: 10, tls: 15, firstByte: 30, download: 40 },
},
}),
],
},
});
const results = await service.pollForResults("meas-123");
expect(results[0].status).toBe(false);
expect(results[0].statusCode).toBe(500);
});
it("transforms ping results with stats (no loss)", async () => {
const { service } = createService();
mockGotGet.mockResolvedValue({
body: {
status: "finished",
results: [
makeProbeResult({
result: {
status: "finished",
stats: { min: 10, max: 30, avg: 20, total: 3, loss: 0, rcv: 3, drop: 0 },
},
}),
],
},
});
const results = await service.pollForResults("meas-123");
expect(results[0].status).toBe(true);
expect(results[0].statusCode).toBe(200);
expect(results[0].timings.total).toBe(20);
expect(results[0].timings.dns).toBe(0);
});
it("transforms ping results with loss as failed", async () => {
const { service } = createService();
mockGotGet.mockResolvedValue({
body: {
status: "finished",
results: [
makeProbeResult({
result: {
status: "finished",
stats: { min: 10, max: 30, avg: 20, total: 3, loss: 2, rcv: 1, drop: 2 },
},
}),
],
},
});
const results = await service.pollForResults("meas-123");
expect(results[0].status).toBe(false);
expect(results[0].statusCode).toBe(5000);
});
it("uses empty string for null state in location", async () => {
const { service } = createService();
mockGotGet.mockResolvedValue({
body: {
status: "finished",
results: [
makeProbeResult({
probe: {
continent: "EU",
region: "Western Europe",
country: "DE",
state: null,
city: "Berlin",
longitude: 13.4,
latitude: 52.5,
},
}),
],
},
});
const results = await service.pollForResults("meas-123");
expect(results[0].location.state).toBe("");
});
it("skips probes with no statusCode/timings and no stats", async () => {
const { service } = createService();
mockGotGet.mockResolvedValue({
body: {
status: "finished",
results: [
makeProbeResult({
result: { status: "finished" },
}),
],
},
});
const results = await service.pollForResults("meas-123");
expect(results).toEqual([]);
});
it("transforms multiple probes from different locations", async () => {
const { service } = createService();
mockGotGet.mockResolvedValue({
body: {
status: "finished",
results: [
makeProbeResult(),
makeProbeResult({
probe: {
continent: "EU",
region: "Western Europe",
country: "GB",
state: null,
city: "London",
longitude: -0.12,
latitude: 51.5,
},
result: {
status: "finished",
statusCode: 200,
timings: { total: 80, dns: 5, tcp: 10, tls: 15, firstByte: 30, download: 20 },
},
}),
],
},
});
const results = await service.pollForResults("meas-123");
expect(results).toHaveLength(2);
expect(results[0].location.continent).toBe("NA");
expect(results[1].location.continent).toBe("EU");
});
});
});
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more