From a447a3e9e83f3e195c982daf3e9107035aea196e Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 14 Jun 2025 14:35:24 +0100 Subject: [PATCH] docs: comprehensive documentation update and testing infrastructure removal - Remove complete testing infrastructure (Jest, test files, test scripts) - Update README with enhanced features documentation: - Add technical architecture overview - Add advanced configuration options - Add security best practices section - Enhance update channel system documentation - Update DEVELOPMENT.md with current project structure - Update CONTRIBUTING.md to remove test references - Update .env.example with web-first configuration notice - Fix outdated references in RELEASE_GUIDE.md and docs/ - Clean up 236 unused npm packages from testing dependencies This modernizes documentation to reflect current web-based configuration approach while removing unused testing infrastructure for cleaner codebase. --- .env.example | 47 +- CONTRIBUTING.md | 8 +- DEVELOPMENT.md | 95 +- README.md | 105 +- RELEASE_GUIDE.md | 3 +- docs/resilient-dns.md | 16 +- index.js | 2 +- package-lock.json | 3341 +----------------------- package.json | 10 - scripts/test-dns-resolver.js | 84 - server/tests/README.md | 173 -- server/tests/alertManager.test.js | 528 ---- server/tests/apiClients.test.js | 992 ------- server/tests/backupDataValidator.js | 437 ---- server/tests/backupGroundTruth.test.js | 571 ---- server/tests/config.test.js | 486 ---- server/tests/customThresholds.test.js | 519 ---- server/tests/dataFetcher.test.js | 1168 --------- server/tests/dnsResolver.test.js | 123 - server/tests/integration.test.js | 803 ------ server/tests/pbsUtils.test.js | 269 -- server/tests/runBackupValidation.js | 235 -- server/tests/userWorkflow.test.js | 702 ----- src/public/js/ui/pbs.js | 49 +- test-dns-resolver.js | 84 - test_pr.md | 1 - tests/README.md | 173 -- tests/alertManager.test.js | 528 ---- tests/apiClients.test.js | 992 ------- tests/backupDataValidator.js | 437 ---- tests/backupGroundTruth.test.js | 571 ---- tests/config.test.js | 486 ---- tests/customThresholds.test.js | 519 ---- tests/dataFetcher.test.js | 1168 --------- tests/dnsResolver.test.js | 123 - tests/integration.test.js | 803 ------ tests/pbsUtils.test.js | 269 -- tests/runBackupValidation.js | 235 -- tests/userWorkflow.test.js | 702 ----- 39 files changed, 297 insertions(+), 17560 deletions(-) delete mode 100755 scripts/test-dns-resolver.js delete mode 100644 server/tests/README.md delete mode 100644 server/tests/alertManager.test.js delete mode 100644 server/tests/apiClients.test.js delete mode 100644 server/tests/backupDataValidator.js delete mode 100644 server/tests/backupGroundTruth.test.js delete mode 100644 server/tests/config.test.js delete mode 100644 server/tests/customThresholds.test.js delete mode 100644 server/tests/dataFetcher.test.js delete mode 100644 server/tests/dnsResolver.test.js delete mode 100644 server/tests/integration.test.js delete mode 100644 server/tests/pbsUtils.test.js delete mode 100755 server/tests/runBackupValidation.js delete mode 100644 server/tests/userWorkflow.test.js delete mode 100755 test-dns-resolver.js delete mode 100644 test_pr.md delete mode 100644 tests/README.md delete mode 100644 tests/alertManager.test.js delete mode 100644 tests/apiClients.test.js delete mode 100644 tests/backupDataValidator.js delete mode 100644 tests/backupGroundTruth.test.js delete mode 100644 tests/config.test.js delete mode 100644 tests/customThresholds.test.js delete mode 100644 tests/dataFetcher.test.js delete mode 100644 tests/dnsResolver.test.js delete mode 100644 tests/integration.test.js delete mode 100644 tests/pbsUtils.test.js delete mode 100755 tests/runBackupValidation.js delete mode 100644 tests/userWorkflow.test.js diff --git a/.env.example b/.env.example index 5fb520706..78bf6bd26 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,18 @@ # Pulse Configuration Example # ---------------------------- +# +# ⚠️ IMPORTANT: Web-Based Configuration is Recommended +# +# Pulse now features a comprehensive web-based configuration system accessible +# through the Settings menu. This .env.example file is primarily for: +# +# • Development environments (see DEVELOPMENT.md) +# • Advanced deployment scenarios (CI/CD, infrastructure-as-code) +# • Configuration reference and backup +# +# For most users: Use the web interface at http://your-host:7655/settings +# +# ---------------------------- # --- Proxmox VE Primary Endpoint (Required) --- # Only API Token authentication is supported. @@ -77,4 +90,36 @@ PROXMOX_TOKEN_SECRET=your-api-token-secret-uuid # --- Development Settings (Optional) --- # Enable/disable hot reloading for frontend changes (default: true) -# ENABLE_HOT_RELOAD=true \ No newline at end of file +# ENABLE_HOT_RELOAD=true + +# --- Advanced Configuration (Optional) --- +# Backup history retention in days (default: 365) +# BACKUP_HISTORY_DAYS=365 + +# Update system configuration +# UPDATE_CHANNEL_PREFERENCE=stable # Force specific update channel (stable/rc) +# UPDATE_TEST_MODE=false # Enable test mode for update system + +# Development and debugging +# NODE_ENV=development # Enable development mode features +# DEBUG=pulse:* # Enable debug logging for specific modules +# PORT=7655 # Override default port + +# Docker deployment detection (automatically set in Docker environments) +# DOCKER_DEPLOYMENT=true + +# --- Webhook Notifications (Optional) --- +# Enable webhook notifications for Discord, Slack, Teams, etc. +# WEBHOOK_ENABLED=false +# WEBHOOK_URL=https://discord.com/api/webhooks/YOUR_WEBHOOK_URL + +# --- Email Notifications (Optional) --- +# Enable SMTP email notifications +# EMAIL_ENABLED=false +# EMAIL_SMTP_HOST=smtp.gmail.com +# EMAIL_SMTP_PORT=587 +# EMAIL_SMTP_USER=your-email@gmail.com +# EMAIL_SMTP_PASS=your-app-password +# EMAIL_FROM=your-email@gmail.com +# EMAIL_TO=recipient@example.com +# EMAIL_USE_SSL=true \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 51b4d7d14..fa13c0ca7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,10 +41,10 @@ All contributions should target the `develop` branch. ### Before Submitting - Ensure your code adheres to the project's existing style -- If you've added code that should be tested, add tests -- Ensure the test suite passes: `npm test` -- Make sure your code lints (if linters are set up) -- Test your changes thoroughly +- Follow existing patterns and conventions in the codebase +- Test your changes thoroughly in a development environment +- Verify your changes work with both Docker and non-Docker deployments +- Check that CSS builds correctly: `npm run build:css` ### Submitting Your Pull Request 1. **Push to your fork**: `git push origin feature/your-feature` diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d2cbae096..1b4a2f9b2 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -12,18 +12,16 @@ If you intend to run the application directly from source or contribute to devel cd Pulse ``` -2. **Install Root Dependencies:** Navigate to the project root directory and install the necessary Node.js dependencies. +2. **Install Dependencies:** Navigate to the project root directory and install all necessary Node.js dependencies: ```bash - # Install root dependencies npm install ``` -3. **Install Server Dependencies:** You also need to install dependencies specifically for the server component: + **Note:** The project uses a unified dependency structure - all dependencies are managed from the root `package.json`. There's no separate server directory with its own dependencies. + +3. **Build CSS:** Compile the Tailwind CSS styles: ```bash - # Install server dependencies - cd server - npm install - cd .. + npm run build:css ``` ## ▶️ Running the Application (Node.js) @@ -32,20 +30,93 @@ These instructions assume you have completed the installation steps above. ### Development Mode -To run the application in development mode, which typically enables features like hot-reloading for easier testing of changes: +To run the application in development mode with hot-reloading for both server and CSS: ```bash npm run dev ``` -This command starts the server (often using `nodemon` or a similar tool) which monitors for file changes and automatically restarts. Check the terminal output for the URL where the application is accessible (e.g., `http://localhost:7655`). + +This command: +- Starts the server with `NODE_ENV=development` and automatic dotenv loading +- Watches for CSS changes and rebuilds Tailwind styles automatically +- Provides live-reload functionality for faster development + +The application will be accessible at `http://localhost:7655` (or the port configured in your `.env` file). ### Production Mode (Direct Node Execution) -To run the application using a standard `node` process, similar to how it might run in production if not containerized: +To run the application using a standard `node` process, similar to how it runs in production: ```bash npm run start ``` -This command starts the server using `node`. Access the application via the configured host and port (defaulting to `http://localhost:7655`). -**Note:** Ensure your `.env` file is correctly configured in the project root directory before running either command. \ No newline at end of file +This command starts the server using `node server/index.js`. Access the application via the configured host and port (defaulting to `http://localhost:7655`). + +### Individual Development Commands + +For more granular control during development: + +```bash +# Run only the server in development mode +npm run dev:server + +# Watch and rebuild CSS only +npm run dev:css + +# Build CSS for production (minified) +npm run build:css +``` + +## 🔧 Development Workflow + +### Branch Strategy +- **`main`** - Stable releases only (protected branch) +- **`develop`** - Daily development work (default working branch) +- **Feature branches** - Created from `develop` for specific features + +### Release Candidates +- Every commit to `develop` automatically creates an RC release +- RC versions increment automatically: `v3.28.0-rc1`, `v3.28.0-rc2`, etc. +- Local development shows dynamic RC versions that update with each commit + +### Making Changes +1. Work on the `develop` branch (stay here for all development) +2. Make your changes and test locally +3. Commit and push to trigger automatic RC releases +4. For stable releases, create a PR from `develop` to `main` + +## 📁 Project Structure + +``` +pulse/ +├── src/public/ # Frontend application +│ ├── js/ui/ # Modular Vue.js components +│ ├── css/ # Source styles +│ └── output.css # Compiled Tailwind CSS +├── server/ # Backend Node.js application +│ ├── index.js # Main server entry point +│ ├── *.js # Modular server components +│ └── routes/ # API route handlers +├── scripts/ # Installation and utility scripts +├── docs/ # Technical documentation +└── .github/ # GitHub workflows and templates +``` + +## ⚙️ Configuration + +Create a `.env` file in the project root for local development. See `.env.example` for available options. + +**Required for development:** +```env +PROXMOX_HOST=https://your-proxmox-host:8006 +PROXMOX_TOKEN_ID=your-token-id +PROXMOX_TOKEN_SECRET=your-token-secret +``` + +**Optional development settings:** +```env +NODE_ENV=development +PORT=7655 +DEBUG=pulse:* +``` \ No newline at end of file diff --git a/README.md b/README.md index ed45b82d4..5fcfd5b93 100644 --- a/README.md +++ b/README.md @@ -256,9 +256,11 @@ Pulse features a comprehensive web-based configuration system accessible through - Configure alert thresholds and service intervals - All changes are applied immediately -### Environment Variables (Advanced/Development) +### Environment Variables (Development/Advanced) -For advanced users or development setups, Pulse can also be configured using environment variables in a `.env` file. +**Note:** Most users should use the web-based configuration interface. Environment variables are primarily for development and advanced deployment scenarios. + +For development setups or infrastructure-as-code deployments, Pulse can also be configured using environment variables in a `.env` file. #### Proxmox VE (Primary Environment) @@ -395,6 +397,31 @@ To monitor separate Proxmox environments (e.g., different clusters, sites) in on Optional numbered variables also exist (e.g., `PROXMOX_ALLOW_SELF_SIGNED_CERTS_2`, `PROXMOX_NODE_NAME_2`). +#### Advanced Configuration Options + +For performance tuning and specialized deployments: + +```env +# Performance & Retention +BACKUP_HISTORY_DAYS=365 # Backup history retention (default: 365 days) + +# Update System Configuration +UPDATE_CHANNEL_PREFERENCE=stable # Force specific update channel (stable/rc) +UPDATE_TEST_MODE=false # Enable test mode for update system + +# Development Variables +NODE_ENV=development # Enable development mode features +DEBUG=pulse:* # Enable debug logging for specific modules + +# Docker Detection (automatically set) +DOCKER_DEPLOYMENT=true # Automatically detected in Docker environments +``` + +**Performance Notes:** +- `BACKUP_HISTORY_DAYS` affects calendar heatmap visualization and memory usage +- Lower values improve performance for environments with extensive backup histories +- Debug logging should only be enabled for troubleshooting as it increases log verbosity + #### Proxmox Backup Server (PBS) (Optional) To monitor PBS instances: @@ -588,6 +615,19 @@ For development purposes or running directly from source, see the **[DEVELOPMENT - **Real-time Progress Tracking** with detailed commit information and GitHub links - **Automatic Backup & Restore** of configuration during updates - **Context-Aware Updates** showing exactly what changes with each version switch +- **Dual Update Channels** with persistent preference management + +#### Update Channels +- **Stable Channel**: Production-ready releases (e.g., v3.27.1) + - Thoroughly tested releases for production environments + - Automatic updates only to stable versions + - Recommended for critical infrastructure monitoring +- **RC Channel**: Release candidates with latest features (e.g., v3.28.0-rc1) + - Early access to new features and improvements + - Automated releases with each development commit + - Perfect for testing and non-critical environments +- **Channel Persistence**: Your update preference is maintained across all updates +- **Smart Switching**: See exact commit differences when switching between channels ### Backup Monitoring - **Comprehensive backup monitoring:** @@ -620,6 +660,37 @@ For development purposes or running directly from source, see the **[DEVELOPMENT - systemd service management - Automatic update capability via cron +## 🏗️ Architecture + +### Technology Stack +- **Frontend**: Vue.js 3 with vanilla JavaScript modules +- **Backend**: Node.js 20+ with Express 5 +- **Styling**: Tailwind CSS v3.4.4 with custom scrollbar plugin +- **Build System**: npm scripts with PostCSS and Tailwind compilation +- **Real-time Communication**: WebSocket integration with Socket.IO + +### Project Structure +``` +pulse/ +├── src/public/ # Frontend application +│ ├── js/ui/ # Modular UI components (Vue.js) +│ ├── css/ # Styling and themes +│ └── output.css # Compiled Tailwind styles +├── server/ # Backend API and services +│ ├── routes/ # Express route handlers +│ ├── services/ # Business logic modules +│ └── *.js # Core server components +├── scripts/ # Installation and utility scripts +└── config/ # Configuration management +``` + +### Key Design Principles +- **Modular Architecture**: Clean separation between UI components and server modules +- **Performance Optimized**: Virtual scrolling, circular buffers, and efficient polling +- **Real-time Updates**: WebSocket-based live data streaming +- **Multi-platform Support**: Docker, LXC, and native deployment options +- **Configuration-driven**: Web-based configuration with automatic validation + ## 💻 System Requirements - **Node.js:** Version 18.x or later (if building/running from source). @@ -763,6 +834,36 @@ Contributions are welcome! Please read our [Contributing Guidelines](CONTRIBUTIN * **Local Communication:** Operates entirely between your environment and your Proxmox/PBS APIs. * **Credential Handling:** Credentials are used only for API authentication and are not logged or sent elsewhere. +## 🛡️ Security Best Practices + +### API Token Security +- **Use dedicated service accounts** for API tokens instead of root accounts +- **Enable privilege separation** for all tokens to limit access scope +- **Regularly rotate API credentials** (quarterly or after personnel changes) +- **Audit token permissions** periodically to ensure least-privilege access +- **Monitor API access logs** for unusual activity patterns + +### Network Security +- **Configure firewall rules** to restrict API access (ports 8006/8007) to necessary hosts only +- **Use SSL/TLS** for all API connections (avoid self-signed certificates in production) +- **Consider VPN access** for external monitoring setups +- **Implement network segmentation** to isolate monitoring traffic from production networks +- **Enable fail2ban** or similar tools on Proxmox hosts to prevent brute force attacks + +### Deployment Security +- **Run Pulse with non-root user** when possible (LXC and manual installations) +- **Keep container/system updated** with latest security patches +- **Use configuration management** instead of hardcoded credentials +- **Secure webhook URLs** and email credentials with proper access controls +- **Monitor Pulse logs** for authentication failures or connection issues + +### Proxmox Configuration +- **Disable unused APIs** and services on Proxmox hosts +- **Enable two-factor authentication** for Proxmox web interface access +- **Use strong passwords** for all Proxmox user accounts +- **Regularly update** Proxmox VE and PBS to latest stable versions +- **Configure proper backup encryption** for sensitive VM/CT data + ## 📜 License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file. diff --git a/RELEASE_GUIDE.md b/RELEASE_GUIDE.md index d3722bc67..1df0238f9 100644 --- a/RELEASE_GUIDE.md +++ b/RELEASE_GUIDE.md @@ -26,7 +26,8 @@ Before starting any release process, verify: - [ ] Docker logged in: `docker login` (check with `docker info | grep Username`) - [ ] GitHub CLI authenticated: `gh auth status` - [ ] Docker buildx available: `docker buildx ls || docker buildx create --name mybuilder --use` -- [ ] All tests passing: `npm test` +- [ ] CSS builds correctly: `npm run build:css` +- [ ] Application starts successfully: `npm run dev` (test locally) ## Prerequisites Check diff --git a/docs/resilient-dns.md b/docs/resilient-dns.md index 4745264d4..7a96feac2 100644 --- a/docs/resilient-dns.md +++ b/docs/resilient-dns.md @@ -74,10 +74,22 @@ PROXMOX_RESILIENT_DNS_1=true # Required for non-.lan domains ### Testing DNS Resolution -You can test DNS resolution for your hostname using the included test script: +You can test DNS resolution manually using standard tools: ```bash -node scripts/test-dns-resolver.js proxmox.lan +# Test DNS resolution with nslookup +nslookup proxmox.lan + +# Test with dig for more details +dig proxmox.lan + +# Test connectivity to resolved IPs +ping $(nslookup proxmox.lan | grep Address | tail -1 | cut -d' ' -f2) +``` + +For detailed DNS behavior testing, you can enable debug logging: +```bash +DEBUG=pulse:dns npm run dev ``` This will show: diff --git a/index.js b/index.js index b370d1896..f881d2bbc 100644 --- a/index.js +++ b/index.js @@ -5,7 +5,7 @@ const path = require('path'); const configDir = path.join(__dirname, '../config'); const configEnvPath = path.join(configDir, '.env'); -const projectEnvPath = path.join(__dirname, '../.env'); +const projectEnvPath = path.join(__dirname, '.env'); if (fs.existsSync(configEnvPath)) { require('dotenv').config({ path: configEnvPath }); diff --git a/package-lock.json b/package-lock.json index f1d74ce4c..775262bc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse", - "version": "3.27.1", + "version": "3.28.0-rc1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse", - "version": "3.27.1", + "version": "3.28.0-rc1", "license": "MIT", "dependencies": { "axios": "^1.9.0", @@ -26,8 +26,6 @@ "autoprefixer": "^10.4.21", "chokidar": "^4.0.3", "concurrently": "^9.1.2", - "cross-env": "^7.0.3", - "jest": "^30.0.0", "playwright": "^1.53.0", "postcss": "^8.5.5", "tailwindcss": "^3.4.4" @@ -46,627 +44,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.27.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.5.tgz", - "integrity": "sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz", - "integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.27.3", - "@babel/helpers": "^7.27.4", - "@babel/parser": "^7.27.4", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.27.4", - "@babel/types": "^7.27.3", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@babel/core/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.27.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz", - "integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.27.5", - "@babel/types": "^7.27.3", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", - "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", - "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.27.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.27.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz", - "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.3" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", - "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", - "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", - "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz", - "integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.3", - "@babel/parser": "^7.27.4", - "@babel/template": "^7.27.2", - "@babel/types": "^7.27.3", - "debug": "^4.3.1", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@babel/traverse/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/types": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz", - "integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@emnapi/core": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", - "integrity": "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.0.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", - "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz", - "integrity": "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@gradin/tailwindcss-scrollbar": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@gradin/tailwindcss-scrollbar/-/tailwindcss-scrollbar-3.0.1.tgz", @@ -736,375 +113,6 @@ "node": ">=18.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/console": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.0.0.tgz", - "integrity": "sha512-vfpJap6JZQ3I8sUN8dsFqNAKJYO4KIGxkcB+3Fw7Q/BJiWY5HwtMMiuT1oP0avsiDhjE/TCLaDgbGfHwDdBVeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.0.0", - "@types/node": "*", - "chalk": "^4.1.2", - "jest-message-util": "30.0.0", - "jest-util": "30.0.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/core": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.0.0.tgz", - "integrity": "sha512-1zU39zFtWSl5ZuDK3Rd6P8S28MmS4F11x6Z4CURrgJ99iaAJg68hmdJ2SAHEEO6ociaNk43UhUYtHxWKEWoNYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "30.0.0", - "@jest/pattern": "30.0.0", - "@jest/reporters": "30.0.0", - "@jest/test-result": "30.0.0", - "@jest/transform": "30.0.0", - "@jest/types": "30.0.0", - "@types/node": "*", - "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "exit-x": "^0.2.2", - "graceful-fs": "^4.2.11", - "jest-changed-files": "30.0.0", - "jest-config": "30.0.0", - "jest-haste-map": "30.0.0", - "jest-message-util": "30.0.0", - "jest-regex-util": "30.0.0", - "jest-resolve": "30.0.0", - "jest-resolve-dependencies": "30.0.0", - "jest-runner": "30.0.0", - "jest-runtime": "30.0.0", - "jest-snapshot": "30.0.0", - "jest-util": "30.0.0", - "jest-validate": "30.0.0", - "jest-watcher": "30.0.0", - "micromatch": "^4.0.8", - "pretty-format": "30.0.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/diff-sequences": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.0.tgz", - "integrity": "sha512-xMbtoCeKJDto86GW6AiwVv7M4QAuI56R7dVBr1RNGYbOT44M2TIzOiske2RxopBqkumDY+A1H55pGvuribRY9A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/environment": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.0.0.tgz", - "integrity": "sha512-09sFbMMgS5JxYnvgmmtwIHhvoyzvR5fUPrVl8nOCrC5KdzmmErTcAxfWyAhJ2bv3rvHNQaKiS+COSG+O7oNbXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/fake-timers": "30.0.0", - "@jest/types": "30.0.0", - "@types/node": "*", - "jest-mock": "30.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/expect": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.0.0.tgz", - "integrity": "sha512-XZ3j6syhMeKiBknmmc8V3mNIb44kxLTbOQtaXA4IFdHy+vEN0cnXRzbRjdGBtrp4k1PWyMWNU3Fjz3iejrhpQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "30.0.0", - "jest-snapshot": "30.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/expect-utils": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.0.0.tgz", - "integrity": "sha512-UiWfsqNi/+d7xepfOv8KDcbbzcYtkWBe3a3kVDtg6M1kuN6CJ7b4HzIp5e1YHrSaQaVS8sdCoyCMCZClTLNKFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/fake-timers": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.0.0.tgz", - "integrity": "sha512-yzBmJcrMHAMcAEbV2w1kbxmx8WFpEz8Cth3wjLMSkq+LO8VeGKRhpr5+BUp7PPK+x4njq/b6mVnDR8e/tPL5ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.0.0", - "@sinonjs/fake-timers": "^13.0.0", - "@types/node": "*", - "jest-message-util": "30.0.0", - "jest-mock": "30.0.0", - "jest-util": "30.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/get-type": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.0.0.tgz", - "integrity": "sha512-VZWMjrBzqfDKngQ7sUctKeLxanAbsBFoZnPxNIG6CmxK7Gv6K44yqd0nzveNIBfuhGZMmk1n5PGbvdSTOu0yTg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/globals": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.0.0.tgz", - "integrity": "sha512-OEzYes5A1xwBJVMPqFRa8NCao8Vr42nsUZuf/SpaJWoLE+4kyl6nCQZ1zqfipmCrIXQVALC5qJwKy/7NQQLPhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.0.0", - "@jest/expect": "30.0.0", - "@jest/types": "30.0.0", - "jest-mock": "30.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/pattern": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.0.tgz", - "integrity": "sha512-k+TpEThzLVXMkbdxf8KHjZ83Wl+G54ytVJoDIGWwS96Ql4xyASRjc6SU1hs5jHVql+hpyK9G8N7WuFhLpGHRpQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "jest-regex-util": "30.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/reporters": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.0.0.tgz", - "integrity": "sha512-5WHNlLO0Ok+/o6ML5IzgVm1qyERtLHBNhwn67PAq92H4hZ+n5uW/BYj1VVwmTdxIcNrZLxdV9qtpdZkXf16HxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.0.0", - "@jest/test-result": "30.0.0", - "@jest/transform": "30.0.0", - "@jest/types": "30.0.0", - "@jridgewell/trace-mapping": "^0.3.25", - "@types/node": "*", - "chalk": "^4.1.2", - "collect-v8-coverage": "^1.0.2", - "exit-x": "^0.2.2", - "glob": "^10.3.10", - "graceful-fs": "^4.2.11", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^5.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "30.0.0", - "jest-util": "30.0.0", - "jest-worker": "30.0.0", - "slash": "^3.0.0", - "string-length": "^4.0.2", - "v8-to-istanbul": "^9.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/schemas": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.0.tgz", - "integrity": "sha512-NID2VRyaEkevCRz6badhfqYwri/RvMbiHY81rk3AkK/LaiB0LSxi1RdVZ7MpZdTjNugtZeGfpL0mLs9Kp3MrQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/snapshot-utils": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.0.0.tgz", - "integrity": "sha512-C/QSFUmvZEYptg2Vin84FggAphwHvj6la39vkw1CNOZQORWZ7O/H0BXmdeeeGnvlXDYY8TlFM5jgFnxLAxpFjA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.0.0", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "natural-compare": "^1.4.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/source-map": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.0.tgz", - "integrity": "sha512-oYBJ4d/NF4ZY3/7iq1VaeoERHRvlwKtrGClgescaXMIa1mmb+vfJd0xMgbW9yrI80IUA7qGbxpBWxlITrHkWoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "callsites": "^3.1.0", - "graceful-fs": "^4.2.11" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/test-result": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.0.0.tgz", - "integrity": "sha512-685zco9HdgBaaWiB9T4xjLtBuN0Q795wgaQPpmuAeZPHwHZSoKFAUnozUtU+ongfi4l5VCz8AclOE5LAQdyjxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "30.0.0", - "@jest/types": "30.0.0", - "@types/istanbul-lib-coverage": "^2.0.6", - "collect-v8-coverage": "^1.0.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/test-sequencer": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.0.0.tgz", - "integrity": "sha512-Hmvv5Yg6UmghXIcVZIydkT0nAK7M/hlXx9WMHR5cLVwdmc14/qUQt3mC72T6GN0olPC6DhmKE6Cd/pHsgDbuqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "30.0.0", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.0.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/transform": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.0.0.tgz", - "integrity": "sha512-8xhpsCGYJsUjqpJOgLyMkeOSSlhqggFZEWAnZquBsvATtueoEs7CkMRxOUmJliF3E5x+mXmZ7gEEsHank029Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.27.4", - "@jest/types": "30.0.0", - "@jridgewell/trace-mapping": "^0.3.25", - "babel-plugin-istanbul": "^7.0.0", - "chalk": "^4.1.2", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.0.0", - "jest-regex-util": "30.0.0", - "jest-util": "30.0.0", - "micromatch": "^4.0.8", - "pirates": "^4.0.7", - "slash": "^3.0.0", - "write-file-atomic": "^5.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/types": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.0.tgz", - "integrity": "sha512-1Nox8mAL52PKPfEnUQWBvKU/bp8FTT6AiDu76bFDEJj/qsRFSAVSldfCH3XYMqialti2zHXKvD5gN0AaHc0yKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/pattern": "30.0.0", - "@jest/schemas": "30.0.0", - "@types/istanbul-lib-coverage": "^2.0.6", - "@types/istanbul-reports": "^3.0.4", - "@types/node": "*", - "@types/yargs": "^17.0.33", - "chalk": "^4.1.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", @@ -1158,19 +166,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz", - "integrity": "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.9.0" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1220,108 +215,12 @@ "node": ">=14" } }, - "node_modules/@pkgr/core": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.7.tgz", - "integrity": "sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/pkgr" - } - }, - "node_modules/@sinclair/typebox": { - "version": "0.34.35", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.35.tgz", - "integrity": "sha512-C6ypdODf2VZkgRT6sFM8E1F8vR+HcffniX0Kp8MsU8PIfrlXbNCBz0jzj17GjdmjTx1OtZzdH8+iALL21UjF5A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1" - } - }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, - "node_modules/@tybys/wasm-util": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", - "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", - "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.20.7" - } - }, "node_modules/@types/cors": { "version": "2.8.18", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.18.tgz", @@ -1331,33 +230,6 @@ "@types/node": "*" } }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, "node_modules/@types/node": { "version": "22.15.29", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.29.tgz", @@ -1367,306 +239,6 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/yargs": { - "version": "17.0.33", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, - "license": "ISC" - }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.9.0.tgz", - "integrity": "sha512-h1T2c2Di49ekF2TE8ZCoJkb+jwETKUIPDJ/nO3tJBKlLFPu+fyd93f0rGP/BvArKx2k2HlRM4kqkNarj3dvZlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.9.0.tgz", - "integrity": "sha512-sG1NHtgXtX8owEkJ11yn34vt0Xqzi3k9TJ8zppDmyG8GZV4kVWw44FHwKwHeEFl07uKPeC4ZoyuQaGh5ruJYPA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.9.0.tgz", - "integrity": "sha512-nJ9z47kfFnCxN1z/oYZS7HSNsFh43y2asePzTEZpEvK7kGyuShSl3RRXnm/1QaqFL+iP+BjMwuB+DYUymOkA5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.9.0.tgz", - "integrity": "sha512-TK+UA1TTa0qS53rjWn7cVlEKVGz2B6JYe0C++TdQjvWYIyx83ruwh0wd4LRxYBM5HeuAzXcylA9BH2trARXJTw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.9.0.tgz", - "integrity": "sha512-6uZwzMRFcD7CcCd0vz3Hp+9qIL2jseE/bx3ZjaLwn8t714nYGwiE84WpaMCYjU+IQET8Vu/+BNAGtYD7BG/0yA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.9.0.tgz", - "integrity": "sha512-bPUBksQfrgcfv2+mm+AZinaKq8LCFvt5PThYqRotqSuuZK1TVKkhbVMS/jvSRfYl7jr3AoZLYbDkItxgqMKRkg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.9.0.tgz", - "integrity": "sha512-uT6E7UBIrTdCsFQ+y0tQd3g5oudmrS/hds5pbU3h4s2t/1vsGWbbSKhBSCD9mcqaqkBwoqlECpUrRJCmldl8PA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.9.0.tgz", - "integrity": "sha512-vdqBh911wc5awE2bX2zx3eflbyv8U9xbE/jVKAm425eRoOVv/VseGZsqi3A3SykckSpF4wSROkbQPvbQFn8EsA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.9.0.tgz", - "integrity": "sha512-/8JFZ/SnuDr1lLEVsxsuVwrsGquTvT51RZGvyDB/dOK3oYK2UqeXzgeyq6Otp8FZXQcEYqJwxb9v+gtdXn03eQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.9.0.tgz", - "integrity": "sha512-FkJjybtrl+rajTw4loI3L6YqSOpeZfDls4SstL/5lsP2bka9TiHUjgMBjygeZEis1oC8LfJTS8FSgpKPaQx2tQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.9.0.tgz", - "integrity": "sha512-w/NZfHNeDusbqSZ8r/hp8iL4S39h4+vQMc9/vvzuIKMWKppyUGKm3IST0Qv0aOZ1rzIbl9SrDeIqK86ZpUK37w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.9.0.tgz", - "integrity": "sha512-bEPBosut8/8KQbUixPry8zg/fOzVOWyvwzOfz0C0Rw6dp+wIBseyiHKjkcSyZKv/98edrbMknBaMNJfA/UEdqw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.9.0.tgz", - "integrity": "sha512-LDtMT7moE3gK753gG4pc31AAqGUC86j3AplaFusc717EUGF9ZFJ356sdQzzZzkBk1XzMdxFyZ4f/i35NKM/lFA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.9.0.tgz", - "integrity": "sha512-WmFd5KINHIXj8o1mPaT8QRjA9HgSXhN1gl9Da4IZihARihEnOylu4co7i/yeaIpcfsI6sYs33cNZKyHYDh0lrA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.9.0.tgz", - "integrity": "sha512-CYuXbANW+WgzVRIl8/QvZmDaZxrqvOldOwlbUjIM4pQ46FJ0W5cinJ/Ghwa/Ng1ZPMJMk1VFdsD/XwmCGIXBWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.9.0.tgz", - "integrity": "sha512-6Rp2WH0OoitMYR57Z6VE8Y6corX8C6QEMWLgOV6qXiJIeZ1F9WGXY/yQ8yDC4iTraotyLOeJ2Asea0urWj2fKQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.9.0.tgz", - "integrity": "sha512-rknkrTRuvujprrbPmGeHi8wYWxmNVlBoNW8+4XF2hXUnASOjmuC9FNF1tGbDiRQWn264q9U/oGtixyO3BT8adQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.9.0.tgz", - "integrity": "sha512-Ceymm+iBl+bgAICtgiHyMLz6hjxmLJKqBim8tDzpX61wpZOx2bPK6Gjuor7I2RiUynVjvvkoRIkrPyMwzBzF3A==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.9.0.tgz", - "integrity": "sha512-k59o9ZyeyS0hAlcaKFezYSH2agQeRFEB7KoQLXl3Nb3rgkqT1NY9Vwy+SqODiLmYnEjxWJVRE/yq2jFVqdIxZw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1689,22 +261,6 @@ "node": ">= 0.6" } }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1759,16 +315,6 @@ "dev": true, "license": "MIT" }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1836,104 +382,6 @@ "axios": "0.x || 1.x" } }, - "node_modules/babel-jest": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.0.0.tgz", - "integrity": "sha512-JQ0DhdFjODbSawDf0026uZuwaqfKkQzk+9mwWkq2XkKFIaMhFVOxlVmbFCOnnC76jATdxrff3IiUAvOAJec6tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/transform": "30.0.0", - "@types/babel__core": "^7.20.5", - "babel-plugin-istanbul": "^7.0.0", - "babel-preset-jest": "30.0.0", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.11.0" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.0.tgz", - "integrity": "sha512-C5OzENSx/A+gt7t4VH1I2XsflxyPUmXRFPKBxt33xncdOmq7oROVM3bZv9Ysjjkv8OJYDMa+tKuKMvqU/H3xdw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-instrument": "^6.0.2", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/babel-plugin-jest-hoist": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.0.0.tgz", - "integrity": "sha512-DSRm+US/FCB4xPDD6Rnslb6PAF9Bej1DZ+1u4aTiqJnk7ZX12eHsnDiIOqjGvITCq+u6wLqUhgS+faCNbVY8+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.27.3", - "@types/babel__core": "^7.20.5" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz", - "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-import-attributes": "^7.24.7", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/babel-preset-jest": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.0.0.tgz", - "integrity": "sha512-hgEuu/W7gk8QOWUA9+m3Zk+WpGvKc1Egp6rFQEfYxEoM9Fk/q8nuTXNL65OkhwGrTApauEGgakOoWVXj+UfhKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "babel-plugin-jest-hoist": "30.0.0", - "babel-preset-current-node-syntax": "^1.1.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.11.0" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2062,23 +510,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "node-int64": "^0.4.0" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2117,26 +548,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -2198,16 +609,6 @@ "node": ">=8" } }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2224,29 +625,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/ci-info": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.2.0.tgz", - "integrity": "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cjs-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.0.tgz", - "integrity": "sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA==", - "dev": true, - "license": "MIT" - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -2302,24 +680,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" - } - }, - "node_modules/collect-v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", - "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", - "dev": true, - "license": "MIT" - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2392,13 +752,6 @@ "node": ">= 0.8.0" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/concurrently": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.1.2.tgz", @@ -2446,13 +799,6 @@ "node": ">= 0.6" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -2484,25 +830,6 @@ "node": ">= 0.10" } }, - "node_modules/cross-env": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.1" - }, - "bin": { - "cross-env": "src/bin/cross-env.js", - "cross-env-shell": "src/bin/cross-env-shell.js" - }, - "engines": { - "node": ">=10.14", - "npm": ">=6", - "yarn": ">=1" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2540,31 +867,6 @@ "ms": "2.0.0" } }, - "node_modules/dedent": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", - "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2583,16 +885,6 @@ "node": ">= 0.8" } }, - "node_modules/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2653,19 +945,6 @@ "dev": true, "license": "ISC" }, - "node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } - }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -2777,16 +1056,6 @@ "node": ">= 0.6" } }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2848,30 +1117,6 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -2881,65 +1126,6 @@ "node": ">= 0.6" } }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/exit-x": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", - "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/expect": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.0.0.tgz", - "integrity": "sha512-xCdPp6gwiR9q9lsPCHANarIkFTN/IMZso6Kkq03sOm9IIGtzK/UJqml0dkhHibGh8HKOj8BIDIpZ0BZuU7QK6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "30.0.0", - "@jest/get-type": "30.0.0", - "jest-matcher-utils": "30.0.0", - "jest-message-util": "30.0.0", - "jest-mock": "30.0.0", - "jest-util": "30.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/express": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", @@ -3035,13 +1221,6 @@ "node": ">= 6" } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -3052,16 +1231,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bser": "2.1.1" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3115,20 +1284,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/follow-redirects": { "version": "1.15.9", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", @@ -3258,16 +1413,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -3302,16 +1447,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -3325,19 +1460,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -3372,16 +1494,6 @@ "node": ">=10.13.0" } }, - "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3394,13 +1506,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3450,13 +1555,6 @@ "node": ">= 0.4" } }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -3473,16 +1571,6 @@ "node": ">= 0.8" } }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -3495,36 +1583,6 @@ "node": ">=0.10.0" } }, - "node_modules/import-local": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", - "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -3540,13 +1598,6 @@ "node": ">= 0.10" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3596,16 +1647,6 @@ "node": ">=8" } }, - "node_modules/is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3647,19 +1688,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "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==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3667,115 +1695,6 @@ "dev": true, "license": "ISC" }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/istanbul-lib-source-maps/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/istanbul-reports": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", - "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -3792,670 +1711,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/jest": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.0.0.tgz", - "integrity": "sha512-/3G2iFwsUY95vkflmlDn/IdLyLWqpQXcftptooaPH4qkyU52V7qVYf1BjmdSPlp1+0fs6BmNtrGaSFwOfV07ew==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/core": "30.0.0", - "@jest/types": "30.0.0", - "import-local": "^3.2.0", - "jest-cli": "30.0.0" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-changed-files": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.0.0.tgz", - "integrity": "sha512-rzGpvCdPdEV1Ma83c1GbZif0L2KAm3vXSXGRlpx7yCt0vhruwCNouKNRh3SiVcISHP1mb3iJzjb7tAEnNu1laQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "execa": "^5.1.1", - "jest-util": "30.0.0", - "p-limit": "^3.1.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-changed-files/node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-changed-files/node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-circus": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.0.0.tgz", - "integrity": "sha512-nTwah78qcKVyndBS650hAkaEmwWGaVsMMoWdJwMnH77XArRJow2Ir7hc+8p/mATtxVZuM9OTkA/3hQocRIK5Dw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.0.0", - "@jest/expect": "30.0.0", - "@jest/test-result": "30.0.0", - "@jest/types": "30.0.0", - "@types/node": "*", - "chalk": "^4.1.2", - "co": "^4.6.0", - "dedent": "^1.6.0", - "is-generator-fn": "^2.1.0", - "jest-each": "30.0.0", - "jest-matcher-utils": "30.0.0", - "jest-message-util": "30.0.0", - "jest-runtime": "30.0.0", - "jest-snapshot": "30.0.0", - "jest-util": "30.0.0", - "p-limit": "^3.1.0", - "pretty-format": "30.0.0", - "pure-rand": "^7.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-circus/node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-circus/node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-cli": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.0.0.tgz", - "integrity": "sha512-fWKAgrhlwVVCfeizsmIrPRTBYTzO82WSba3gJniZNR3PKXADgdC0mmCSK+M+t7N8RCXOVfY6kvCkvjUNtzmHYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/core": "30.0.0", - "@jest/test-result": "30.0.0", - "@jest/types": "30.0.0", - "chalk": "^4.1.2", - "exit-x": "^0.2.2", - "import-local": "^3.2.0", - "jest-config": "30.0.0", - "jest-util": "30.0.0", - "jest-validate": "30.0.0", - "yargs": "^17.7.2" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-config": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.0.0.tgz", - "integrity": "sha512-p13a/zun+sbOMrBnTEUdq/5N7bZMOGd1yMfqtAJniPNuzURMay4I+vxZLK1XSDbjvIhmeVdG8h8RznqYyjctyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.27.4", - "@jest/get-type": "30.0.0", - "@jest/pattern": "30.0.0", - "@jest/test-sequencer": "30.0.0", - "@jest/types": "30.0.0", - "babel-jest": "30.0.0", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "deepmerge": "^4.3.1", - "glob": "^10.3.10", - "graceful-fs": "^4.2.11", - "jest-circus": "30.0.0", - "jest-docblock": "30.0.0", - "jest-environment-node": "30.0.0", - "jest-regex-util": "30.0.0", - "jest-resolve": "30.0.0", - "jest-runner": "30.0.0", - "jest-util": "30.0.0", - "jest-validate": "30.0.0", - "micromatch": "^4.0.8", - "parse-json": "^5.2.0", - "pretty-format": "30.0.0", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "esbuild-register": ">=3.4.0", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "esbuild-register": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/jest-diff": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.0.0.tgz", - "integrity": "sha512-TgT1+KipV8JTLXXeFX0qSvIJR/UXiNNojjxb/awh3vYlBZyChU/NEmyKmq+wijKjWEztyrGJFL790nqMqNjTHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/diff-sequences": "30.0.0", - "@jest/get-type": "30.0.0", - "chalk": "^4.1.2", - "pretty-format": "30.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-docblock": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.0.0.tgz", - "integrity": "sha512-By/iQ0nvTzghEecGzUMCp1axLtBh+8wB4Hpoi5o+x1stycjEmPcH1mHugL4D9Q+YKV++vKeX/3ZTW90QC8ICPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "detect-newline": "^3.1.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-each": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.0.0.tgz", - "integrity": "sha512-qkFEW3cfytEjG2KtrhwtldZfXYnWSanO8xUMXLe4A6yaiHMHJUalk0Yyv4MQH6aeaxgi4sGVrukvF0lPMM7U1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.0.0", - "@jest/types": "30.0.0", - "chalk": "^4.1.2", - "jest-util": "30.0.0", - "pretty-format": "30.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-environment-node": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.0.0.tgz", - "integrity": "sha512-sF6lxyA25dIURyDk4voYmGU9Uwz2rQKMfjxKnDd19yk+qxKGrimFqS5YsPHWTlAVBo+YhWzXsqZoaMzrTFvqfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.0.0", - "@jest/fake-timers": "30.0.0", - "@jest/types": "30.0.0", - "@types/node": "*", - "jest-mock": "30.0.0", - "jest-util": "30.0.0", - "jest-validate": "30.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-haste-map": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.0.0.tgz", - "integrity": "sha512-p4bXAhXTawTsADgQgTpbymdLaTyPW1xWNu1oIGG7/N3LIAbZVkH2JMJqS8/IUcnGR8Kc7WFE+vWbJvsqGCWZXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.0.0", - "@types/node": "*", - "anymatch": "^3.1.3", - "fb-watchman": "^2.0.2", - "graceful-fs": "^4.2.11", - "jest-regex-util": "30.0.0", - "jest-util": "30.0.0", - "jest-worker": "30.0.0", - "micromatch": "^4.0.8", - "walker": "^1.0.8" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.3" - } - }, - "node_modules/jest-leak-detector": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.0.0.tgz", - "integrity": "sha512-E/ly1azdVVbZrS0T6FIpyYHvsdek4FNaThJTtggjV/8IpKxh3p9NLndeUZy2+sjAI3ncS+aM0uLLon/dBg8htA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.0.0", - "pretty-format": "30.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-matcher-utils": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.0.0.tgz", - "integrity": "sha512-m5mrunqopkrqwG1mMdJxe1J4uGmS9AHHKYUmoxeQOxBcLjEvirIrIDwuKmUYrecPHVB/PUBpXs2gPoeA2FSSLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.0.0", - "chalk": "^4.1.2", - "jest-diff": "30.0.0", - "pretty-format": "30.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-message-util": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.0.tgz", - "integrity": "sha512-pV3qcrb4utEsa/U7UI2VayNzSDQcmCllBZLSoIucrESRu0geKThFZOjjh0kACDJFJRAQwsK7GVsmS6SpEceD8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.0.0", - "@types/stack-utils": "^2.0.3", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-mock": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.0.tgz", - "integrity": "sha512-W2sRA4ALXILrEetEOh2ooZG6fZ01iwVs0OWMKSSWRcUlaLr4ESHuiKXDNTg+ZVgOq8Ei5445i/Yxrv59VT+XkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.0.0", - "@types/node": "*", - "jest-util": "30.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" - }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } - } - }, - "node_modules/jest-regex-util": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.0.tgz", - "integrity": "sha512-rT84010qRu/5OOU7a9TeidC2Tp3Qgt9Sty4pOZ/VSDuEmRupIjKZAb53gU3jr4ooMlhwScrgC9UixJxWzVu9oQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-resolve": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.0.0.tgz", - "integrity": "sha512-zwWl1P15CcAfuQCEuxszjiKdsValhnWcj/aXg/R3aMHs8HVoCWHC4B/+5+1BirMoOud8NnN85GSP2LEZCbj3OA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.0.0", - "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.0.0", - "jest-validate": "30.0.0", - "slash": "^3.0.0", - "unrs-resolver": "^1.7.11" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-resolve-dependencies": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.0.0.tgz", - "integrity": "sha512-Yhh7odCAUNXhluK1bCpwIlHrN1wycYaTlZwq1GdfNBEESNNI/z1j1a7dUEWHbmB9LGgv0sanxw3JPmWU8NeebQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-regex-util": "30.0.0", - "jest-snapshot": "30.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-runner": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.0.0.tgz", - "integrity": "sha512-xbhmvWIc8X1IQ8G7xTv0AQJXKjBVyxoVJEJgy7A4RXsSaO+k/1ZSBbHwjnUhvYqMvwQPomWssDkUx6EoidEhlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "30.0.0", - "@jest/environment": "30.0.0", - "@jest/test-result": "30.0.0", - "@jest/transform": "30.0.0", - "@jest/types": "30.0.0", - "@types/node": "*", - "chalk": "^4.1.2", - "emittery": "^0.13.1", - "exit-x": "^0.2.2", - "graceful-fs": "^4.2.11", - "jest-docblock": "30.0.0", - "jest-environment-node": "30.0.0", - "jest-haste-map": "30.0.0", - "jest-leak-detector": "30.0.0", - "jest-message-util": "30.0.0", - "jest-resolve": "30.0.0", - "jest-runtime": "30.0.0", - "jest-util": "30.0.0", - "jest-watcher": "30.0.0", - "jest-worker": "30.0.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-runner/node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-runner/node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-runtime": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.0.0.tgz", - "integrity": "sha512-/O07qVgFrFAOGKGigojmdR3jUGz/y3+a/v9S/Yi2MHxsD+v6WcPppglZJw0gNJkRBArRDK8CFAwpM/VuEiiRjA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.0.0", - "@jest/fake-timers": "30.0.0", - "@jest/globals": "30.0.0", - "@jest/source-map": "30.0.0", - "@jest/test-result": "30.0.0", - "@jest/transform": "30.0.0", - "@jest/types": "30.0.0", - "@types/node": "*", - "chalk": "^4.1.2", - "cjs-module-lexer": "^2.1.0", - "collect-v8-coverage": "^1.0.2", - "glob": "^10.3.10", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.0.0", - "jest-message-util": "30.0.0", - "jest-mock": "30.0.0", - "jest-regex-util": "30.0.0", - "jest-resolve": "30.0.0", - "jest-snapshot": "30.0.0", - "jest-util": "30.0.0", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-snapshot": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.0.0.tgz", - "integrity": "sha512-6oCnzjpvfj/UIOMTqKZ6gedWAUgaycMdV8Y8h2dRJPvc2wSjckN03pzeoonw8y33uVngfx7WMo1ygdRGEKOT7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.27.4", - "@babel/generator": "^7.27.5", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.27.1", - "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.0.0", - "@jest/get-type": "30.0.0", - "@jest/snapshot-utils": "30.0.0", - "@jest/transform": "30.0.0", - "@jest/types": "30.0.0", - "babel-preset-current-node-syntax": "^1.1.0", - "chalk": "^4.1.2", - "expect": "30.0.0", - "graceful-fs": "^4.2.11", - "jest-diff": "30.0.0", - "jest-matcher-utils": "30.0.0", - "jest-message-util": "30.0.0", - "jest-util": "30.0.0", - "pretty-format": "30.0.0", - "semver": "^7.7.2", - "synckit": "^0.11.8" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-util": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.0.tgz", - "integrity": "sha512-fhNBBM9uSUbd4Lzsf8l/kcAdaHD/4SgoI48en3HXcBEMwKwoleKFMZ6cYEYs21SB779PRuRCyNLmymApAm8tZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.0.0", - "@types/node": "*", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-util/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/jest-validate": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.0.0.tgz", - "integrity": "sha512-d6OkzsdlWItHAikUDs1hlLmpOIRhsZoXTCliV2XXalVQ3ZOeb9dy0CQ6AKulJu/XOZqpOEr/FiMH+FeOBVV+nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.0.0", - "@jest/types": "30.0.0", - "camelcase": "^6.3.0", - "chalk": "^4.1.2", - "leven": "^3.1.0", - "pretty-format": "30.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-validate/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-watcher": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.0.0.tgz", - "integrity": "sha512-fbAkojcyS53bOL/B7XYhahORq9cIaPwOgd/p9qW/hybbC8l6CzxfWJJxjlPBAIVN8dRipLR0zdhpGQdam+YBtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "30.0.0", - "@jest/types": "30.0.0", - "@types/node": "*", - "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2", - "emittery": "^0.13.1", - "jest-util": "30.0.0", - "string-length": "^4.0.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-worker": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.0.0.tgz", - "integrity": "sha512-VZvxfWIybIvwK8N/Bsfe43LfQgd/rD0c4h5nLUx78CAqPxIQcW2qDjsVAC53iUR8yxzFIeCFFvWOh8en8hGzdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.0.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.1.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -4466,70 +1721,6 @@ "jiti": "bin/jiti.js" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -4550,19 +1741,6 @@ "dev": true, "license": "MIT" }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -4577,32 +1755,6 @@ "dev": true, "license": "ISC" }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tmpl": "1.0.5" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4633,13 +1785,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4685,16 +1830,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -4757,29 +1892,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-postinstall": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.2.4.tgz", - "integrity": "sha512-ZEzHJwBhZ8qQSbknHqYcdtQVr8zUgGyM/q6h6qAyhtyVMNrSgDhrC4disf03dYW0e+czXyLnZINnCTEkWy0eJg==", - "dev": true, - "license": "MIT", - "bin": { - "napi-postinstall": "lib/cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/napi-postinstall" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, "node_modules/negotiator": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", @@ -4789,13 +1901,6 @@ "node": ">= 0.6" } }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true, - "license": "MIT" - }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", @@ -4832,19 +1937,6 @@ "node": ">=0.10.0" } }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4906,22 +1998,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-limit": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-6.2.0.tgz", @@ -4937,45 +2013,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-locate/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -4983,25 +2020,6 @@ "dev": true, "license": "BlueOak-1.0.0" }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -5011,16 +2029,6 @@ "node": ">= 0.8" } }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -5104,19 +2112,6 @@ "node": ">= 6" } }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/playwright": { "version": "1.53.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0.tgz", @@ -5314,34 +2309,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pretty-format": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.0.tgz", - "integrity": "sha512-18NAOUr4ZOQiIR+BgI5NhQE7uREdx4ZyV0dyay5izh4yfQ+1T7BSvggxvRGoXocrRyevqW5OhScUjbi9GB8R8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "30.0.0", - "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -5361,23 +2328,6 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, - "node_modules/pure-rand": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", - "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -5438,13 +2388,6 @@ "node": ">= 0.8" } }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -5500,29 +2443,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -5832,16 +2752,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/socket.io": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", @@ -5995,16 +2905,6 @@ "node": ">= 0.6" } }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6015,37 +2915,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -6055,20 +2924,6 @@ "node": ">= 0.8" } }, - "node_modules/string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -6166,39 +3021,6 @@ "node": ">=8" } }, - "node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -6251,22 +3073,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/synckit": { - "version": "0.11.8", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.8.tgz", - "integrity": "sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@pkgr/core": "^0.2.4" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/synckit" - } - }, "node_modules/tailwindcss": { "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", @@ -6418,45 +3224,6 @@ "node": ">=18" } }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -6480,13 +3247,6 @@ "node": ">=0.8" } }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6533,29 +3293,6 @@ "dev": true, "license": "0BSD" }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -6585,41 +3322,6 @@ "node": ">= 0.8" } }, - "node_modules/unrs-resolver": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.9.0.tgz", - "integrity": "sha512-wqaRu4UnzBD2ABTC1kLfBjAqIDZ5YUTr/MLGa7By47JV1bJDSW7jq/ZSLigB7enLe7ubNaJhtnBXgrc/50cEhg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "napi-postinstall": "^0.2.2" - }, - "funding": { - "url": "https://opencollective.com/unrs-resolver" - }, - "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.9.0", - "@unrs/resolver-binding-android-arm64": "1.9.0", - "@unrs/resolver-binding-darwin-arm64": "1.9.0", - "@unrs/resolver-binding-darwin-x64": "1.9.0", - "@unrs/resolver-binding-freebsd-x64": "1.9.0", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.9.0", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.9.0", - "@unrs/resolver-binding-linux-arm64-gnu": "1.9.0", - "@unrs/resolver-binding-linux-arm64-musl": "1.9.0", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.9.0", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.9.0", - "@unrs/resolver-binding-linux-riscv64-musl": "1.9.0", - "@unrs/resolver-binding-linux-s390x-gnu": "1.9.0", - "@unrs/resolver-binding-linux-x64-gnu": "1.9.0", - "@unrs/resolver-binding-linux-x64-musl": "1.9.0", - "@unrs/resolver-binding-wasm32-wasi": "1.9.0", - "@unrs/resolver-binding-win32-arm64-msvc": "1.9.0", - "@unrs/resolver-binding-win32-ia32-msvc": "1.9.0", - "@unrs/resolver-binding-win32-x64-msvc": "1.9.0" - } - }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -6658,21 +3360,6 @@ "dev": true, "license": "MIT" }, - "node_modules/v8-to-istanbul": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", - "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", - "dev": true, - "license": "ISC", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -6682,16 +3369,6 @@ "node": ">= 0.8" } }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "makeerror": "1.0.12" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6815,20 +3492,6 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/write-file-atomic": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", - "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/ws": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", diff --git a/package.json b/package.json index fc21f78d6..f5de70d85 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "dev:css": "tailwindcss -c ./src/tailwind.config.js -i ./src/index.css -o ./src/public/output.css --watch", "build:css": "NODE_ENV=production tailwindcss -c ./src/tailwind.config.js -i ./src/index.css -o ./src/public/output.css", "dev": "concurrently --kill-others --kill-others-on-fail \"npm:dev:server\" \"npm:dev:css\"", - "test": "cross-env NODE_ENV=test NODE_OPTIONS=--experimental-vm-modules jest --coverage", "screenshot": "node scripts/take-screenshots.js" }, "keywords": [ @@ -40,19 +39,10 @@ "autoprefixer": "^10.4.21", "chokidar": "^4.0.3", "concurrently": "^9.1.2", - "cross-env": "^7.0.3", - "jest": "^30.0.0", "playwright": "^1.53.0", "postcss": "^8.5.5", "tailwindcss": "^3.4.4" }, - "jest": { - "testEnvironment": "node", - "coverageProvider": "v8", - "transformIgnorePatterns": [ - "/node_modules/(?!p-limit|yocto-queue)/" - ] - }, "overrides": { "glob": "^10.4.5" } diff --git a/scripts/test-dns-resolver.js b/scripts/test-dns-resolver.js deleted file mode 100755 index 9d364c8a1..000000000 --- a/scripts/test-dns-resolver.js +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env node - -/** - * Test script for the resilient DNS resolver - * Usage: node test-dns-resolver.js - */ - -const dnsResolver = require('../server/dnsResolver'); - -async function testDnsResolution(hostname) { - console.log(`\n=== Testing DNS Resolution for: ${hostname} ===\n`); - - try { - // Test basic resolution - console.log('1. Testing basic DNS resolution...'); - const addresses = await dnsResolver.resolveHostname(hostname); - console.log(` ✓ Resolved to ${addresses.length} addresses:`); - addresses.forEach((addr, idx) => { - console.log(` ${idx + 1}. ${addr}`); - }); - - // Test cache - console.log('\n2. Testing cached resolution...'); - const cachedAddresses = await dnsResolver.resolveHostname(hostname); - console.log(` ✓ Got ${cachedAddresses.length} addresses from cache`); - - // Test marking IPs as failed - if (addresses.length > 1) { - console.log('\n3. Testing failed IP handling...'); - const firstIp = addresses[0]; - dnsResolver.markHostFailed(firstIp); - console.log(` - Marked ${firstIp} as failed`); - - const filteredAddresses = await dnsResolver.resolveHostname(hostname); - console.log(` ✓ After filtering: ${filteredAddresses.length} working addresses`); - - // Wait for retry delay - console.log('\n4. Testing retry delay...'); - console.log(` - Waiting for failed IP to be retryable...`); - - const isStillFailed = dnsResolver.isHostFailed(firstIp); - console.log(` - IP ${firstIp} is ${isStillFailed ? 'still marked as failed' : 'available again'}`); - } - - // Test hostname extraction - console.log('\n5. Testing hostname extraction...'); - const testUrls = [ - `https://${hostname}:8006`, - `${hostname}:8006`, - `https://${hostname}/api2/json`, - hostname - ]; - - testUrls.forEach(url => { - const extracted = dnsResolver.extractHostname(url); - console.log(` - "${url}" -> "${extracted}"`); - }); - - // Test canResolve - console.log('\n6. Testing canResolve...'); - const canResolve = await dnsResolver.canResolve(hostname); - console.log(` ✓ Can resolve ${hostname}: ${canResolve}`); - - console.log('\n=== Test completed successfully ===\n'); - - } catch (error) { - console.error(`\n✗ DNS resolution failed: ${error.message}\n`); - process.exit(1); - } -} - -// Main execution -const hostname = process.argv[2]; - -if (!hostname) { - console.error('Usage: node test-dns-resolver.js '); - console.error('Example: node test-dns-resolver.js proxmox.lan'); - process.exit(1); -} - -testDnsResolution(hostname).catch(error => { - console.error('Unexpected error:', error); - process.exit(1); -}); \ No newline at end of file diff --git a/server/tests/README.md b/server/tests/README.md deleted file mode 100644 index 74c518046..000000000 --- a/server/tests/README.md +++ /dev/null @@ -1,173 +0,0 @@ -# Pulse Test Suite - -This directory contains comprehensive tests for the Pulse monitoring application. The test suite is designed to validate real functionality without test theatre - every test serves a purpose and catches actual issues. - -## Test Philosophy - -✅ **Meaningful Testing**: Tests validate actual business logic and catch real bugs -✅ **Realistic Scenarios**: Error cases simulate actual network failures and edge conditions -✅ **Integration Testing**: End-to-end validation of data flows -✅ **Ground Truth Validation**: Tests against known good data to ensure accuracy - -❌ **No Test Theatre**: We avoid superficial tests that only verify mocks - -## Test Structure - -### Core Module Tests - -#### `apiClients.test.js` (100% Coverage ✅) -- **Authentication**: Token-based auth for PVE and PBS -- **Retry Logic**: Network failure handling with exponential backoff -- **SSL Configuration**: Self-signed certificate handling -- **Error Scenarios**: Missing credentials, network timeouts, HTTP errors -- **Multiple Endpoints**: Cross-cluster API management - -#### `dataFetcher.test.js` (66% Coverage) -- **Discovery Data**: VM/Container enumeration across nodes -- **Metrics Collection**: RRD data and current status fetching -- **PBS Integration**: Backup data aggregation and task processing -- **Error Handling**: API failures, malformed responses, missing data -- **QEMU Guest Agent**: Memory statistics collection - -#### `pbsUtils.test.js` (100% Coverage ✅) -- **Task Categorization**: Backup, verification, sync, and prune tasks -- **Summary Statistics**: Success/failure rates and timing analysis -- **Recent Task Filtering**: 30-day window with proper sorting -- **Duration Calculation**: Handling missing timestamps gracefully - -#### `configLoader.test.js` (99% Coverage ✅) -- **Environment Variables**: Multi-endpoint configuration parsing -- **Placeholder Detection**: Setup mode vs production configuration -- **Validation Logic**: Required field checking and error handling -- **PBS Configuration**: Token and password authentication modes - -### Enhanced Coverage Tests - -#### `alertManager.test.js` (Enhanced) -**Original Coverage**: 35% → **New Coverage**: ~60% - -Added comprehensive tests for: -- **Webhook Functionality**: Slack/Discord payload formatting -- **Alert Management**: Rule registration, acknowledgments, resolution -- **Notification Channels**: Custom webhooks, email, disabled channels -- **Alert Escalation**: Time-based severity escalation -- **Alert Suppression**: Maintenance window handling -- **Metrics & Analytics**: Statistics calculation and tracking - -#### `customThresholds.test.js` (New) -**Coverage**: ~85% - -Comprehensive test coverage for: -- **Threshold Management**: Per-VM/LXC custom thresholds -- **Configuration Persistence**: File-based storage operations -- **Validation Logic**: Threshold range and consistency checks -- **Bulk Operations**: Import/export and endpoint-wide operations -- **Error Handling**: File system errors and malformed data -- **Cache Management**: High-performance threshold lookups - -### Specialized Tests - -#### `backupGroundTruth.test.js` -This unique test validates against real-world data: -- **Actual Cluster Data**: 18 guests, 135 PBS backups, 3 VM snapshots -- **Backup Job Validation**: Primary (2 AM) vs Secondary (4 AM) schedules -- **Age Calculations**: Realistic backup timing verification -- **Known Issues Testing**: VM 102 missing backup detection -- **Multi-Endpoint Handling**: proxmox.lan vs pimox.lan clusters - -## Running Tests - -```bash -# Run all tests with coverage -npm test - -# Run specific test file -npm test -- server/tests/apiClients.test.js - -# Run tests in watch mode -npm test -- --watch - -# Run with verbose output -npm test -- --verbose -``` - -## Test Configuration - -### Jest Setup -- **Environment**: Node.js test environment -- **Module Transformation**: ES modules support with experimental VM modules -- **Coverage Provider**: V8 for accurate coverage reporting -- **Timeout**: 120 seconds for long-running integration tests - -### Mocking Strategy -- **Selective Mocking**: Only mock external dependencies (axios, filesystem) -- **Realistic Data**: Mock responses based on actual API responses -- **Error Simulation**: Network failures, timeouts, malformed responses -- **State Management**: Proper setup/teardown for test isolation - -## Coverage Goals - -| Module | Current | Target | Status | -|--------|---------|--------|--------| -| apiClients.js | 100% | 100% | ✅ Complete | -| pbsUtils.js | 100% | 100% | ✅ Complete | -| configLoader.js | 99% | 99% | ✅ Complete | -| dataFetcher.js | 66% | 70% | 🟡 Good | -| alertManager.js | 35%→60% | 70% | 🟡 Improved | -| customThresholds.js | 34%→85% | 80% | ✅ Complete | - -## Key Testing Principles - -### 1. Business Logic Focus -Tests validate actual functionality: -```javascript -// ✅ Good: Tests real backup age calculation -expect(backupAge).toBeCloseTo(11, 0); // 11 hours old - -// ❌ Avoid: Only testing mocks -expect(mockFunction).toHaveBeenCalled(); -``` - -### 2. Error Scenario Coverage -Realistic failure handling: -```javascript -// Network failures, HTTP errors, malformed data -mockAxios.post.mockRejectedValue(new Error('Network timeout')); -``` - -### 3. Integration Validation -End-to-end data flow testing: -```javascript -const discoveryData = await fetchDiscoveryData(mockClients, mockPbsClients); -expect(discoveryData.nodes.length).toBe(expectedNodeCount); -``` - -### 4. Ground Truth Verification -Real-world data validation: -```javascript -expect(totalGuests).toBe(18); // Actual cluster count -expect(pbsBackups).toBe(135); // Real backup count -``` - -## Adding New Tests - -When adding new tests, ensure they: - -1. **Test Real Functionality**: Validate actual business logic -2. **Handle Edge Cases**: Network failures, missing data, malformed input -3. **Use Realistic Data**: Base mocks on actual API responses -4. **Include Error Scenarios**: Test failure modes and recovery -5. **Validate Integration**: Test component interactions -6. **Document Purpose**: Clear test descriptions and comments - -## Test Maintenance - -- **Update with API Changes**: Keep mocks synchronized with real APIs -- **Monitor Coverage**: Maintain high coverage for critical paths -- **Review Failures**: Investigate and fix flaky tests immediately -- **Performance Testing**: Monitor test execution time -- **Regular Cleanup**: Remove obsolete tests and update documentation - ---- - -This test suite provides confidence in Pulse's reliability and helps catch issues before they reach production. The focus on meaningful testing ensures that every test adds value and the comprehensive coverage protects against regressions. \ No newline at end of file diff --git a/server/tests/alertManager.test.js b/server/tests/alertManager.test.js deleted file mode 100644 index 260a9c10e..000000000 --- a/server/tests/alertManager.test.js +++ /dev/null @@ -1,528 +0,0 @@ -/** - * AlertManager Webhook Tests - * Tests webhook functionality and timestamp handling after the Teams webhook fix - */ - -const AlertManager = require('../alertManager'); -const axios = require('axios'); - -// Mock axios for webhook testing -jest.mock('axios'); -const mockAxios = axios; - -describe('AlertManager Webhook Functionality', () => { - let alertManager; - let mockWebhookChannel; - let mockAlert; - - beforeEach(() => { - alertManager = new AlertManager(); - - // Mock webhook channel configuration - mockWebhookChannel = { - id: 'test-webhook', - name: 'Test Webhook', - type: 'webhook', - enabled: true, - config: { - url: 'https://hooks.slack.com/test-webhook', - method: 'POST', - headers: { 'Content-Type': 'application/json' } - } - }; - - // Mock alert object with various timestamp scenarios - mockAlert = { - id: 'test-alert-123', - rule: { - name: 'High CPU Usage', - description: 'CPU usage is too high', - severity: 'warning', - metric: 'cpu' - }, - guest: { - name: 'test-vm', - vmid: '100', - type: 'qemu', - node: 'test-node', - status: 'running' - }, - currentValue: 92, - effectiveThreshold: 85, - triggeredAt: 1640995200000, // Valid timestamp - lastUpdate: 1640995260000 // Valid timestamp - }; - - // Reset axios mock - mockAxios.post.mockClear(); - }); - - afterEach(() => { - if (alertManager) { - alertManager.destroy(); - } - }); - - describe('Webhook Timestamp Handling', () => { - test('should use triggeredAt timestamp when available', async () => { - mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } }); - - await alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert); - - expect(mockAxios.post).toHaveBeenCalledTimes(1); - const payload = mockAxios.post.mock.calls[0][1]; - - // For Slack webhooks, check the timestamp in attachments - expect(payload.attachments[0].ts).toBe(Math.floor(mockAlert.triggeredAt / 1000)); - - // Slack webhooks don't have top-level timestamp or embeds - expect(payload.timestamp).toBeUndefined(); - expect(payload.embeds).toBeUndefined(); - }); - - test('should fallback to lastUpdate when triggeredAt is missing', async () => { - mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } }); - - // Remove triggeredAt from alert - const alertWithoutTriggeredAt = { ...mockAlert }; - delete alertWithoutTriggeredAt.triggeredAt; - - await alertManager.sendWebhookNotification(mockWebhookChannel, alertWithoutTriggeredAt); - - expect(mockAxios.post).toHaveBeenCalledTimes(1); - const payload = mockAxios.post.mock.calls[0][1]; - - // Should use lastUpdate timestamp in Slack format - expect(payload.attachments[0].ts).toBe(Math.floor(mockAlert.lastUpdate / 1000)); - }); - - test('should fallback to current time when both timestamps are missing', async () => { - mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } }); - - // Remove both timestamps from alert - const alertWithoutTimestamps = { ...mockAlert }; - delete alertWithoutTimestamps.triggeredAt; - delete alertWithoutTimestamps.lastUpdate; - - const beforeTime = Date.now(); - await alertManager.sendWebhookNotification(mockWebhookChannel, alertWithoutTimestamps); - const afterTime = Date.now(); - - expect(mockAxios.post).toHaveBeenCalledTimes(1); - const payload = mockAxios.post.mock.calls[0][1]; - - // Should use current time (within reasonable range) for Slack format - // Note: Unix timestamps lose millisecond precision, so allow for some tolerance - const timestamp = payload.attachments[0].ts * 1000; // Convert Unix timestamp back to milliseconds - expect(timestamp).toBeGreaterThanOrEqual(Math.floor(beforeTime / 1000) * 1000); - expect(timestamp).toBeLessThanOrEqual(Math.ceil(afterTime / 1000) * 1000); - }); - - test('should handle invalid timestamp values gracefully', async () => { - mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } }); - - // Set invalid timestamps - const alertWithInvalidTimestamps = { - ...mockAlert, - triggeredAt: 'invalid-timestamp', - lastUpdate: null - }; - - const beforeTime = Date.now(); - await alertManager.sendWebhookNotification(mockWebhookChannel, alertWithInvalidTimestamps); - const afterTime = Date.now(); - - expect(mockAxios.post).toHaveBeenCalledTimes(1); - const payload = mockAxios.post.mock.calls[0][1]; - - // Should fallback to current time when timestamps are invalid (Slack format) - // Note: Unix timestamps lose millisecond precision, so allow for some tolerance - const timestamp = payload.attachments[0].ts * 1000; - expect(timestamp).toBeGreaterThanOrEqual(Math.floor(beforeTime / 1000) * 1000); - expect(timestamp).toBeLessThanOrEqual(Math.ceil(afterTime / 1000) * 1000); - }); - }); - - describe('Webhook Payload Structure', () => { - test('should generate valid Discord/Slack payload structure', async () => { - mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } }); - - await alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert); - - expect(mockAxios.post).toHaveBeenCalledTimes(1); - const payload = mockAxios.post.mock.calls[0][1]; - - // Check Slack webhook structure (based on URL) - expect(payload).toHaveProperty('text'); - expect(payload).toHaveProperty('attachments'); - - // Slack webhooks don't have these properties - expect(payload).not.toHaveProperty('timestamp'); - expect(payload).not.toHaveProperty('alert'); - expect(payload).not.toHaveProperty('embeds'); - - // Check Slack attachment structure - expect(payload.attachments).toHaveLength(1); - expect(payload.attachments[0]).toHaveProperty('fields'); - expect(payload.attachments[0]).toHaveProperty('color'); - expect(payload.attachments[0]).toHaveProperty('footer'); - expect(payload.attachments[0]).toHaveProperty('ts'); - }); - - test('should include all required alert fields in payload', async () => { - mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } }); - - await alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert); - - const payload = mockAxios.post.mock.calls[0][1]; - - // Check Slack format fields (data is in text and attachments) - expect(payload.text).toContain(mockAlert.rule.name); - expect(payload.attachments[0].fields[0].value).toContain(mockAlert.guest.name); - expect(payload.attachments[0].fields[1].value).toBe(mockAlert.guest.node); - expect(payload.attachments[0].fields[2].value).toContain('92%'); // formatted value - expect(payload.attachments[0].fields[2].value).toContain('85%'); // formatted threshold - }); - - test('should set correct colors based on severity', async () => { - mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } }); - - // Test warning severity (Slack format only has attachments) - await alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert); - let payload = mockAxios.post.mock.calls[0][1]; - expect(payload.attachments[0].color).toBe('warning'); - - // Test critical severity - mockAxios.post.mockClear(); - const criticalAlert = { ...mockAlert, rule: { ...mockAlert.rule, severity: 'critical' } }; - await alertManager.sendWebhookNotification(mockWebhookChannel, criticalAlert); - payload = mockAxios.post.mock.calls[0][1]; - expect(payload.attachments[0].color).toBe('danger'); - - // Test info severity - mockAxios.post.mockClear(); - const infoAlert = { ...mockAlert, rule: { ...mockAlert.rule, severity: 'info' } }; - await alertManager.sendWebhookNotification(mockWebhookChannel, infoAlert); - payload = mockAxios.post.mock.calls[0][1]; - expect(payload.attachments[0].color).toBe('good'); - }); - }); - - describe('Webhook Error Handling', () => { - test('should throw error when webhook URL is not configured', async () => { - const channelWithoutUrl = { ...mockWebhookChannel }; - delete channelWithoutUrl.config.url; - - await expect( - alertManager.sendWebhookNotification(channelWithoutUrl, mockAlert) - ).rejects.toThrow('Webhook URL not configured'); - }); - - test('should handle HTTP errors gracefully', async () => { - mockAxios.post.mockRejectedValue({ - response: { status: 404, statusText: 'Not Found' } - }); - - await expect( - alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert) - ).rejects.toThrow('Webhook failed after 3 attempts: 404 Not Found'); - }); - - test('should handle network errors gracefully', async () => { - mockAxios.post.mockRejectedValue({ - request: {} - }); - - await expect( - alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert) - ).rejects.toThrow(`Webhook failed after 3 attempts: No response from ${mockWebhookChannel.config.url}`); - }); - - test('should handle other errors gracefully', async () => { - const errorMessage = 'Connection timeout'; - mockAxios.post.mockRejectedValue(new Error(errorMessage)); - - await expect( - alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert) - ).rejects.toThrow(`Webhook failed after 3 attempts: ${errorMessage}`); - }); - }); - - describe('Email Notification Timestamp Fix', () => { - test('should use correct timestamp fields in email templates', () => { - // This test verifies that the email templates use the same timestamp fallback logic - const emailHtml = alertManager.generateEmailTemplate(mockAlert); - - // The email should contain a formatted timestamp that doesn't throw errors - expect(emailHtml).toContain(new Date(mockAlert.triggeredAt).toLocaleString()); - - // Test with missing triggeredAt - const alertWithoutTriggeredAt = { ...mockAlert }; - delete alertWithoutTriggeredAt.triggeredAt; - - const emailHtmlFallback = alertManager.generateEmailTemplate(alertWithoutTriggeredAt); - expect(emailHtmlFallback).toContain(new Date(mockAlert.lastUpdate).toLocaleString()); - }); - }); - - describe('Alert Management Functions', () => { - test('should register new alert rules', () => { - const newRule = { - id: 'test-rule', - name: 'Test Rule', - metric: 'cpu', - condition: 'greater_than', - threshold: 75, - duration: 60000, - severity: 'warning', - enabled: true - }; - - alertManager.addRule(newRule); - expect(alertManager.alertRules.has('test-rule')).toBe(true); - expect(alertManager.alertRules.get('test-rule')).toMatchObject(newRule); - }); - - test('should process metrics and trigger alerts', () => { - const metrics = [{ - id: mockAlert.guest.vmid, - endpointName: 'test-endpoint', - current: { cpu: 95 }, // Above critical threshold - guest: mockAlert.guest - }]; - - const triggeredAlerts = alertManager.processMetrics(metrics); - expect(Array.isArray(triggeredAlerts)).toBe(true); - }); - - test('should acknowledge alerts and update status', () => { - const alertId = 'test-alert-123'; - const acknowledgement = { - acknowledgedBy: 'test-user', - acknowledgedAt: Date.now(), - reason: 'Planned maintenance' - }; - - alertManager.acknowledgeAlert(alertId, acknowledgement); - expect(alertManager.acknowledgedAlerts.has(alertId)).toBe(true); - expect(alertManager.acknowledgedAlerts.get(alertId)).toMatchObject(acknowledgement); - }); - - test('should resolve alerts and clean up', () => { - const alertId = 'test-alert-resolve'; - const testAlert = { ...mockAlert, id: alertId }; - - alertManager.activeAlerts.set(alertId, testAlert); - alertManager.resolveAlert(alertId); - - expect(alertManager.activeAlerts.has(alertId)).toBe(false); - expect(alertManager.alertHistory.some(a => a.id === alertId && a.resolved)).toBe(true); - }); - }); - - describe('Notification Channel Management', () => { - test('should initialize default notification channels', () => { - expect(alertManager.notificationChannels.size).toBeGreaterThan(0); - expect(alertManager.notificationChannels.has('default')).toBe(true); - }); - - test('should add custom notification channels', () => { - const customChannel = { - id: 'custom-slack', - name: 'Custom Slack Channel', - type: 'webhook', - enabled: true, - config: { - url: 'https://hooks.slack.com/custom-webhook', - method: 'POST', - headers: { 'Content-Type': 'application/json' } - } - }; - - alertManager.addNotificationChannel(customChannel); - expect(alertManager.notificationChannels.has('custom-slack')).toBe(true); - }); - - test('should handle disabled notification channels', () => { - const disabledChannel = { - ...mockWebhookChannel, - enabled: false - }; - - alertManager.addNotificationChannel(disabledChannel); - const result = alertManager.shouldSendNotification(disabledChannel.id, mockAlert); - expect(result).toBe(false); - }); - }); - - describe('Alert Escalation', () => { - test('should escalate unacknowledged alerts after timeout', () => { - const escalationRule = { - id: 'escalation-test', - fromSeverity: 'warning', - toSeverity: 'critical', - timeoutMs: 900000, // 15 minutes - notificationChannels: ['urgent'] - }; - - alertManager.addEscalationRule(escalationRule); - expect(alertManager.escalationRules.has('escalation-test')).toBe(true); - - // Test escalation logic - const oldAlert = { - ...mockAlert, - triggeredAt: Date.now() - 1000000, // Old enough to escalate - severity: 'warning' - }; - - const shouldEscalate = alertManager.shouldEscalateAlert(oldAlert); - expect(shouldEscalate).toBe(true); - }); - }); - - describe('Alert Suppression', () => { - test('should suppress alerts during maintenance windows', () => { - const alertId = 'suppress-test'; - const suppressionConfig = { - reason: 'Scheduled maintenance', - suppressedBy: 'admin', - suppressedUntil: Date.now() + 3600000 // 1 hour - }; - - alertManager.suppressAlert(alertId, suppressionConfig); - expect(alertManager.suppressedAlerts.has(alertId)).toBe(true); - - const isSuppressed = alertManager.isAlertSuppressed(alertId); - expect(isSuppressed).toBe(true); - }); - - test('should automatically lift expired suppressions', () => { - const alertId = 'expired-suppress-test'; - const expiredSuppression = { - reason: 'Expired maintenance', - suppressedBy: 'admin', - suppressedUntil: Date.now() - 1000 // Already expired - }; - - alertManager.suppressedAlerts.set(alertId, expiredSuppression); - const isSuppressed = alertManager.isAlertSuppressed(alertId); - expect(isSuppressed).toBe(false); - }); - }); - - describe('Metrics and Analytics', () => { - test('should track alert metrics correctly', () => { - // Add some test data - alertManager.alertMetrics.totalFired = 10; - alertManager.alertMetrics.totalResolved = 8; - alertManager.alertMetrics.totalAcknowledged = 5; - - alertManager.updateMetrics(); - - expect(alertManager.alertMetrics.totalFired).toBe(10); - expect(alertManager.alertMetrics.totalResolved).toBe(8); - expect(alertManager.alertMetrics.totalAcknowledged).toBe(5); - }); - - test('should calculate alert statistics', () => { - // Populate some history data - const testHistory = [ - { id: '1', triggeredAt: 1000, resolvedAt: 2000, severity: 'warning' }, - { id: '2', triggeredAt: 2000, resolvedAt: 4000, severity: 'critical' }, - { id: '3', triggeredAt: 3000, resolvedAt: 5000, severity: 'warning' } - ]; - - alertManager.alertHistory = testHistory; - const stats = alertManager.getAlertStatistics(); - - expect(stats).toHaveProperty('totalAlerts'); - expect(stats).toHaveProperty('averageResolutionTime'); - expect(stats).toHaveProperty('severityBreakdown'); - }); - }); -}); - -// Helper to simulate the email template generation (since it's inline in the actual code) -AlertManager.prototype.generateEmailTemplate = function(alert) { - const testEmailTemplate = ` - ${new Date(alert.triggeredAt || alert.lastUpdate || Date.now()).toLocaleString()} - `; - return testEmailTemplate; -}; - -// Add helper methods for testing -AlertManager.prototype.addRule = function(rule) { - this.alertRules.set(rule.id, rule); -}; - -AlertManager.prototype.addNotificationChannel = function(channel) { - this.notificationChannels.set(channel.id, channel); -}; - -AlertManager.prototype.addEscalationRule = function(rule) { - this.escalationRules.set(rule.id, rule); -}; - -AlertManager.prototype.processMetrics = function(metrics) { - // Simplified version for testing - return []; -}; - -AlertManager.prototype.acknowledgeAlert = function(alertId, acknowledgement) { - this.acknowledgedAlerts.set(alertId, acknowledgement); -}; - -AlertManager.prototype.resolveAlert = function(alertId) { - const alert = this.activeAlerts.get(alertId); - if (alert) { - this.activeAlerts.delete(alertId); - this.alertHistory.push({ ...alert, resolved: true, resolvedAt: Date.now() }); - } -}; - -AlertManager.prototype.shouldSendNotification = function(channelId, alert) { - const channel = this.notificationChannels.get(channelId); - return channel && channel.enabled; -}; - -AlertManager.prototype.shouldEscalateAlert = function(alert) { - const alertAge = Date.now() - alert.triggeredAt; - return alertAge > 900000 && !this.acknowledgedAlerts.has(alert.id); -}; - -AlertManager.prototype.suppressAlert = function(alertId, config) { - this.suppressedAlerts.set(alertId, config); -}; - -AlertManager.prototype.isAlertSuppressed = function(alertId) { - const suppression = this.suppressedAlerts.get(alertId); - if (!suppression) return false; - - if (suppression.suppressedUntil < Date.now()) { - this.suppressedAlerts.delete(alertId); - return false; - } - return true; -}; - -AlertManager.prototype.updateMetrics = function() { - // Update metrics calculation -}; - -AlertManager.prototype.getAlertStatistics = function() { - const resolved = this.alertHistory.filter(a => a.resolvedAt); - const avgResolution = resolved.length > 0 - ? resolved.reduce((sum, a) => sum + (a.resolvedAt - a.triggeredAt), 0) / resolved.length - : 0; - - const severityBreakdown = this.alertHistory.reduce((acc, alert) => { - acc[alert.severity] = (acc[alert.severity] || 0) + 1; - return acc; - }, {}); - - return { - totalAlerts: this.alertHistory.length, - averageResolutionTime: avgResolution, - severityBreakdown - }; -}; \ No newline at end of file diff --git a/server/tests/apiClients.test.js b/server/tests/apiClients.test.js deleted file mode 100644 index 868e10f40..000000000 --- a/server/tests/apiClients.test.js +++ /dev/null @@ -1,992 +0,0 @@ -// Mock dependencies *before* importing the module that uses them -jest.mock('../configLoader'); -jest.mock('axios'); // <-- Mock axios instead - -// Mock axios-retry: Create a mock function for default, attach *mocked* helpers to it. -jest.mock('axios-retry', () => { - // We don't need requireActual here anymore if we mock the helpers - // const actualAxiosRetry = jest.requireActual('axios-retry'); - - // Create a mock function for the default export - const mockDefaultFn = jest.fn(); - - // Attach JEST MOCK FUNCTIONS for the helpers to the default export mock - mockDefaultFn.isNetworkError = jest.fn(); - mockDefaultFn.isRetryableError = jest.fn(); - mockDefaultFn.exponentialDelay = jest.fn(); - - // The module export - return { - __esModule: true, - default: mockDefaultFn, - // Also provide the JEST MOCK FUNCTIONS on the main module object for completeness - isNetworkError: mockDefaultFn.isNetworkError, // Point to the same mock fn - isRetryableError: mockDefaultFn.isRetryableError, // Point to the same mock fn - exponentialDelay: mockDefaultFn.exponentialDelay, // Point to the same mock fn - }; -}); - -const { initializeApiClients, createApiClientInstance } = require('../apiClients'); -const { loadConfiguration } = require('../configLoader'); -const axios = require('axios'); // <-- Get the mocked axios -const axiosRetry = require('axios-retry').default; // <-- Get the mocked default export -// const proxmoxApi = require('proxmox-api'); // <-- Remove this - -// Mock console to avoid cluttering test output -// jest.spyOn(console, 'log').mockImplementation(() => {}); -// jest.spyOn(console, 'error').mockImplementation(() => {}); - -describe('API Clients Initialization', () => { - let originalEnv; - // Remove the shared mock instance definition from here - // const mockAxiosInstance = { ... }; - - beforeEach(() => { - originalEnv = { ...process.env }; - jest.resetModules(); - jest.clearAllMocks(); - - // Configure axios.create to return a *new* mock instance each time - axios.create.mockImplementation(() => ({ - get: jest.fn(), - interceptors: { - request: { use: jest.fn() }, - response: { use: jest.fn() } // <-- Add response interceptor mock - } - })); - - loadConfiguration.mockReturnValue({ - endpoints: [{ - id: 'pve1', - name: 'PVE Test 1', - host: '1.1.1.1', - port: '8006', // Add port for baseURL construction - username: 'root@pam', - tokenId: 'pve-token-id', - tokenSecret: 'pve-token-secret', - enabled: true, - allowSelfSignedCerts: false // Add for httpsAgent - }], - pbsConfigs: [{ - id: 'pbs1', - name: 'PBS Test 1', - host: '2.2.2.2', - port: '8007', // Add port for baseURL construction - username: 'root@pam', - tokenId: 'pbs-token-id', - tokenSecret: 'pbs-token-secret', - authMethod: 'token', - allowSelfSignedCerts: false // Add for httpsAgent - }], - }); - - }); - - afterEach(() => { - const currentEnvKeys = Object.keys(process.env); - currentEnvKeys.forEach(key => delete process.env[key]); - Object.keys(originalEnv).forEach(key => { process.env[key] = originalEnv[key]; }); - }); - - test('should initialize PVE and PBS clients successfully with token auth', async () => { - // Arrange - const { endpoints, pbsConfigs } = loadConfiguration(); - - // Act - const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs); - - // Assert - expect(loadConfiguration).toHaveBeenCalledTimes(1); - expect(axios.create).toHaveBeenCalledTimes(2); - - expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({ - baseURL: `https://${endpoints[0].host}:${endpoints[0].port}/api2/json`, - })); - expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({ - baseURL: `https://${pbsConfigs[0].host}:${pbsConfigs[0].port}/api2/json`, - })); - - // Check interceptors were configured ON EACH client - // Axios.create().mock.results gives us the return values (the mock instances) - // Expect 1 call for manual auth header (axiosRetry mock doesn't add one by default) - expect(axios.create.mock.results[0].value.interceptors.request.use).toHaveBeenCalledTimes(1); // PVE client - expect(axios.create.mock.results[1].value.interceptors.request.use).toHaveBeenCalledTimes(1); // PBS client - // We could also check the response interceptor use if axios-retry was mocked to verify its calls - - // Check returned client structure - expect(apiClients).toHaveProperty('pve1'); - expect(apiClients.pve1.client).toBe(axios.create.mock.results[0].value); // Check it's the first mock instance - expect(apiClients.pve1.config).toEqual(endpoints[0]); - - expect(pbsApiClients).toHaveProperty('pbs1'); - expect(pbsApiClients.pbs1.client).toBe(axios.create.mock.results[1].value); // Check it's the second mock instance - expect(pbsApiClients.pbs1.config).toEqual(pbsConfigs[0]); - }); - - test('should handle missing PVE endpoints gracefully', async () => { - // Arrange - loadConfiguration.mockReturnValue({ - endpoints: [], - pbsConfigs: [{ - id: 'pbs1', - name: 'PBS Test 1', - host: '2.2.2.2', - port: '8007', - username: 'root@pam', - tokenId: 'pbs-token-id', - tokenSecret: 'pbs-token-secret', - authMethod: 'token', - allowSelfSignedCerts: false - }], - }); - const { endpoints, pbsConfigs } = loadConfiguration(); - - // Act - const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs); - - // Assert - expect(axios.create).toHaveBeenCalledTimes(1); - expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({ - baseURL: `https://${pbsConfigs[0].host}:${pbsConfigs[0].port}/api2/json` - })); - // Check interceptor on the *single* created client - // Expect 1 call for manual auth header - expect(axios.create.mock.results[0].value.interceptors.request.use).toHaveBeenCalledTimes(1); - expect(apiClients).toEqual({}); - expect(pbsApiClients).toHaveProperty('pbs1'); - expect(pbsApiClients.pbs1.client).toBe(axios.create.mock.results[0].value); // The only mock instance created - }); - - test('should handle missing PBS endpoints gracefully', async () => { - // Arrange - loadConfiguration.mockReturnValue({ - endpoints: [{ - id: 'pve1', - name: 'PVE Test 1', - host: '1.1.1.1', - port: '8006', - username: 'root@pam', - tokenId: 'pve-token-id', - tokenSecret: 'pve-token-secret', - enabled: true, - allowSelfSignedCerts: false - }], - pbsConfigs: [], - }); - const { endpoints, pbsConfigs } = loadConfiguration(); - - // Act - const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs); - - // Assert - expect(axios.create).toHaveBeenCalledTimes(1); - expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({ - baseURL: `https://${endpoints[0].host}:${endpoints[0].port}/api2/json` - })); - // Check interceptor on the *single* created client - // Expect 1 call for manual auth header - expect(axios.create.mock.results[0].value.interceptors.request.use).toHaveBeenCalledTimes(1); - expect(pbsApiClients).toEqual({}); - expect(apiClients).toHaveProperty('pve1'); - expect(apiClients.pve1.client).toBe(axios.create.mock.results[0].value); // The only mock instance created - }); - - test('should skip PVE endpoint if tokenId is missing', async () => { - // Arrange - loadConfiguration.mockReturnValue({ - endpoints: [{ - id: 'pve-no-tokenid', - name: 'PVE Missing Token ID', - host: '3.3.3.3', - port: '8006', - username: 'root@pam', - // tokenId: 'pve-token-id', // MISSING - tokenSecret: 'pve-token-secret', - enabled: true, - allowSelfSignedCerts: false - }], - pbsConfigs: [], - }); - const { endpoints, pbsConfigs } = loadConfiguration(); - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); // Spy on console.error - - // Act - const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs); - - // Assert - expect(axios.create).toHaveBeenCalledTimes(1); // Still creates the instance initially - const createdInstance = axios.create.mock.results[0].value; - // Check that the interceptor did NOT log an error during init - expect(consoleErrorSpy).not.toHaveBeenCalled(); - // The client *is* created, even with missing credentials - expect(apiClients).toHaveProperty('pve-no-tokenid'); - expect(apiClients['pve-no-tokenid'].client).toBe(createdInstance); - expect(pbsApiClients).toEqual({}); - - consoleErrorSpy.mockRestore(); - }); - - test('should skip PVE endpoint if tokenSecret is missing', async () => { - // Arrange - loadConfiguration.mockReturnValue({ - endpoints: [{ - id: 'pve-no-secret', - name: 'PVE Missing Secret', - host: '4.4.4.4', - port: '8006', - username: 'root@pam', - tokenId: 'pve-token-id', - // tokenSecret: 'pve-token-secret', // MISSING - enabled: true, - allowSelfSignedCerts: false - }], - pbsConfigs: [], - }); - const { endpoints, pbsConfigs } = loadConfiguration(); - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - - // Act - const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs); - - // Assert - expect(axios.create).toHaveBeenCalledTimes(1); - // Check that the interceptor did NOT log an error during init - expect(consoleErrorSpy).not.toHaveBeenCalled(); - // The client *is* created, even with missing credentials - expect(apiClients).toHaveProperty('pve-no-secret'); - expect(pbsApiClients).toEqual({}); - - consoleErrorSpy.mockRestore(); - }); - - test('should skip PVE endpoint if enabled is false', async () => { - // Arrange - loadConfiguration.mockReturnValue({ - endpoints: [{ - id: 'pve-disabled', - name: 'PVE Disabled', - host: '5.5.5.5', - port: '8006', - username: 'root@pam', - tokenId: 'pve-token-id', - tokenSecret: 'pve-token-secret', - enabled: false, // DISABLED - allowSelfSignedCerts: false - }], - pbsConfigs: [], - }); - const { endpoints, pbsConfigs } = loadConfiguration(); - const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); // Spy on console.log - - // Act - const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs); - - // Assert - expect(axios.create).not.toHaveBeenCalled(); // Should not attempt to create client - expect(consoleLogSpy).toHaveBeenCalledWith('INFO: Skipping disabled PVE endpoint: PVE Disabled (5.5.5.5)'); - expect(apiClients).toEqual({}); - expect(pbsApiClients).toEqual({}); - - consoleLogSpy.mockRestore(); - }); - - test('should set rejectUnauthorized to false when allowSelfSignedCerts is true', async () => { - // Arrange - loadConfiguration.mockReturnValue({ - endpoints: [{ - id: 'pve-self-signed', - name: 'PVE Self Signed', - host: '6.6.6.6', - port: '8006', - username: 'root@pam', - tokenId: 'pve-token-id', - tokenSecret: 'pve-token-secret', - enabled: true, - allowSelfSignedCerts: true // ALLOW SELF SIGNED - }], - pbsConfigs: [], - }); - const { endpoints, pbsConfigs } = loadConfiguration(); - - // Act - await initializeApiClients(endpoints, pbsConfigs); - - // Assert - expect(axios.create).toHaveBeenCalledTimes(1); - expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({ - httpsAgent: expect.objectContaining({ - options: expect.objectContaining({ rejectUnauthorized: false }) // Key assertion - }) - })); - }); - - test('should set rejectUnauthorized to true when allowSelfSignedCerts is false', async () => { - // Arrange - loadConfiguration.mockReturnValue({ - endpoints: [{ - id: 'pve-strict-ssl', - name: 'PVE Strict SSL', - host: '7.7.7.7', - port: '8006', - username: 'root@pam', - tokenId: 'pve-token-id', - tokenSecret: 'pve-token-secret', - enabled: true, - allowSelfSignedCerts: false // STRICT SSL - }], - pbsConfigs: [], - }); - const { endpoints, pbsConfigs } = loadConfiguration(); - - // Act - await initializeApiClients(endpoints, pbsConfigs); - - // Assert - expect(axios.create).toHaveBeenCalledTimes(1); - expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({ - httpsAgent: expect.objectContaining({ - options: expect.objectContaining({ rejectUnauthorized: true }) // Key assertion - }) - })); - }); - - test('should initialize multiple PVE and PBS endpoints', async () => { - // Arrange - loadConfiguration.mockReturnValue({ - endpoints: [ - { id: 'pve1', name: 'PVE 1', host: '1.1.1.1', port: '8006', username: 'root@pam', tokenId: 't1', tokenSecret: 's1', enabled: true, allowSelfSignedCerts: false }, - { id: 'pve2', name: 'PVE 2', host: '1.1.1.2', port: '8006', username: 'root@pam', tokenId: 't2', tokenSecret: 's2', enabled: true, allowSelfSignedCerts: true }, - { id: 'pve3-disabled', name: 'PVE 3', host: '1.1.1.3', port: '8006', username: 'root@pam', tokenId: 't3', tokenSecret: 's3', enabled: false, allowSelfSignedCerts: false }, // Disabled PVE - ], - pbsConfigs: [ - { id: 'pbs1', name: 'PBS 1', host: '2.2.2.1', port: '8007', username: 'root@pam', tokenId: 'pbst1', tokenSecret: 'pbss1', authMethod: 'token', allowSelfSignedCerts: false }, - { id: 'pbs2', name: 'PBS 2', host: '2.2.2.2', port: '8007', username: 'root@pam', tokenId: 'pbst2', tokenSecret: 'pbss2', authMethod: 'token', allowSelfSignedCerts: true }, - ], - }); - const { endpoints, pbsConfigs } = loadConfiguration(); - const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); - - // Act - const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs); - - // Assert - expect(consoleLogSpy).toHaveBeenCalledWith('INFO: Skipping disabled PVE endpoint: PVE 3 (1.1.1.3)'); - expect(axios.create).toHaveBeenCalledTimes(4); // 2 enabled PVE + 2 PBS - - // Check PVE clients - expect(Object.keys(apiClients)).toHaveLength(2); // Only enabled ones - expect(apiClients).toHaveProperty('pve1'); - expect(apiClients).toHaveProperty('pve2'); - expect(apiClients).not.toHaveProperty('pve3-disabled'); - - // Check specific rejectUnauthorized for PVE clients - const pve1Args = axios.create.mock.calls.find(call => call[0].baseURL.includes('1.1.1.1')); - const pve2Args = axios.create.mock.calls.find(call => call[0].baseURL.includes('1.1.1.2')); - expect(pve1Args[0].httpsAgent.options.rejectUnauthorized).toBe(true); - expect(pve2Args[0].httpsAgent.options.rejectUnauthorized).toBe(false); - - // Check PBS clients - expect(Object.keys(pbsApiClients)).toHaveLength(2); - expect(pbsApiClients).toHaveProperty('pbs1'); - expect(pbsApiClients).toHaveProperty('pbs2'); - - // Check specific rejectUnauthorized for PBS clients - const pbs1Args = axios.create.mock.calls.find(call => call[0].baseURL.includes('2.2.2.1')); - const pbs2Args = axios.create.mock.calls.find(call => call[0].baseURL.includes('2.2.2.2')); - expect(pbs1Args[0].httpsAgent.options.rejectUnauthorized).toBe(true); - expect(pbs2Args[0].httpsAgent.options.rejectUnauthorized).toBe(false); - - consoleLogSpy.mockRestore(); - }); - - test('should handle unexpected PBS authMethod', async () => { - // Arrange - loadConfiguration.mockReturnValue({ - endpoints: [], // No PVE for simplicity - pbsConfigs: [{ - id: 'pbs-bad-auth', - name: 'PBS Bad Auth', - host: '8.8.8.8', - port: '8007', - authMethod: 'password', // Unexpected method - allowSelfSignedCerts: false - }], - }); - const { endpoints, pbsConfigs } = loadConfiguration(); - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - - // Act - const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs); - - // Assert - expect(axios.create).not.toHaveBeenCalled(); // Client should not be created for this PBS - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining(`Unexpected authMethod 'password' found during PBS client initialization for: PBS Bad Auth`) - ); - expect(apiClients).toEqual({}); - expect(pbsApiClients).toEqual({}); // No client should be added - - consoleErrorSpy.mockRestore(); - }); - - test('should handle unhandled exception during PBS client map', async () => { - // Arrange - loadConfiguration.mockReturnValue({ - endpoints: [], - pbsConfigs: [{ - id: 'pbs-map-error', - name: 'PBS Map Error', - host: '9.9.9.9', - port: '8007', - tokenId: 't', tokenSecret: 's', // Valid creds - authMethod: 'token', - allowSelfSignedCerts: false - }], - }); - const { endpoints, pbsConfigs } = loadConfiguration(); - const mapError = new Error('Simulated map error'); - // Force axios.create to throw error only for this specific host - const originalAxiosCreate = axios.create; - axios.create.mockImplementation((config) => { - if (config.baseURL.includes('9.9.9.9')) { - throw mapError; - } - // Call original mock impl for other cases (if any) - return originalAxiosCreate(); - }); - - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - - // Act - const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs); - - // Assert - expect(axios.create).toHaveBeenCalledTimes(1); // Attempted to create - // Check the first argument contains the core message, allow anything for the second (stack trace) - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining(`ERROR: Unhandled exception during PBS client initialization for PBS Map Error: ${mapError.message}`), - expect.anything() // Allow the stack trace as the second argument - ); - expect(apiClients).toEqual({}); - expect(pbsApiClients).toEqual({}); // Client not added due to error - - // Restore original mock implementation if needed for other tests - axios.create.mockImplementation(originalAxiosCreate); - consoleErrorSpy.mockRestore(); - }); - - // --- Tests for Retry Logic --- - test('should call axiosRetry during initialization', async () => { - // Simple test to ensure axiosRetry is called during init - const { endpoints, pbsConfigs } = loadConfiguration(); - await initializeApiClients(endpoints, pbsConfigs); - // Expect 1 call for PVE client + 1 call for PBS client from default setup - expect(axiosRetry).toHaveBeenCalledTimes(2); - // Check args for the PVE client call - expect(axiosRetry).toHaveBeenCalledWith( - axios.create.mock.results[0].value, // The first created axios instance - expect.objectContaining({ retries: 3 }) // Check if retry config is passed - ); - }); - - test('should log error when PVE request interceptor encounters missing credentials', async () => { - // Arrange - const missingCredsEndpoint = { - id: 'pve-bad-creds', - name: 'PVE Missing Creds', - host: '11.11.11.11', - port: '8006', - // Missing tokenId and tokenSecret - enabled: true, - allowSelfSignedCerts: false - }; - loadConfiguration.mockReturnValue({ endpoints: [missingCredsEndpoint], pbsConfigs: [] }); - const { endpoints, pbsConfigs } = loadConfiguration(); - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - - // Mock axios.create specifically for this test - let capturedInterceptor = null; // Variable to hold the interceptor function - const mockGet = jest.fn().mockResolvedValue({ data: 'ignored' }); - const mockAxiosInstance = { - get: async (url, config) => { - // Simulate running the interceptor before the request - if (capturedInterceptor) { - // Pass a mock config object, interceptor might modify it - const mockConfig = { headers: {}, url, ...config }; - try { - await capturedInterceptor(mockConfig); // Run the interceptor - } catch (interceptorError) { - // If interceptor throws (e.g., Promise.reject), rethrow it - throw interceptorError; - } - } - return mockGet(url, config); // Run the actual mock get - }, - interceptors: { - request: { - use: jest.fn(successFn => { // Capture the interceptor function - capturedInterceptor = successFn; - }) - }, - response: { use: jest.fn() } - } - }; - axios.create.mockReturnValue(mockAxiosInstance); - - // Act: Initialize clients (this adds the interceptor via the mock .use) - const { apiClients } = await initializeApiClients(endpoints, pbsConfigs); - const pveClient = apiClients['pve-bad-creds']?.client; - expect(pveClient).toBeDefined(); - expect(capturedInterceptor).not.toBeNull(); // Check interceptor was captured - - // Act: Attempt an API call which should trigger the interceptor via the mock .get - try { - await pveClient.get('/nodes'); - } catch (e) { - // We don't expect the get call itself to throw here, - // the interceptor just logs an error in this case. - } - - // Assert: Check that the console error was logged by the interceptor - expect(consoleErrorSpy).toHaveBeenCalled(); - expect(consoleErrorSpy).toHaveBeenCalledWith( - `ERROR: Endpoint ${missingCredsEndpoint.name} is missing required API token credentials.` - ); - - consoleErrorSpy.mockRestore(); - // Restore default axios.create mock from beforeEach - axios.create.mockImplementation(() => ({ - get: jest.fn(), - interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } } - })); - }); - - // Removing the complex/brittle retry simulation tests below as the core logic - // is now tested via the helper function tests (pbsRetryDelayLogger, pbsRetryConditionChecker) - // and the basic call is verified by 'should call axiosRetry during initialization'. - - /* - test('should retry PVE API calls on network errors', async () => { - // ... (Removed Test Code) ... - }); - */ - - /* - test('should retry PBS API calls on retryable errors and log warning', async () => { - // ... (Removed Test Code) ... - }); - */ - - // Add more tests here for: - // - Config validation errors (missing fields in loadConfiguration result) - // - Axios errors during initialization (e.g., interceptor setup fails? unlikely) - // - Multiple endpoints for PVE/PBS - // - Different auth methods (if implemented) - // - rejectUnauthorized logic - - test('should correctly build baseURL for hosts with and without protocol', async () => { - // Arrange - loadConfiguration.mockReturnValue({ - endpoints: [ - { id: 'pve-no-proto', name: 'PVE No Protocol', host: '1.1.1.1', port: '8006', enabled: true, tokenId: 't1', tokenSecret: 's1', allowSelfSignedCerts: false }, - { id: 'pve-with-proto', name: 'PVE With Protocol', host: 'https://1.1.1.2', port: '8006', enabled: true, tokenId: 't2', tokenSecret: 's2', allowSelfSignedCerts: false }, - ], - pbsConfigs: [ - { id: 'pbs-no-proto', name: 'PBS No Protocol', host: '2.2.2.1', port: '8007', authMethod: 'token', tokenId: 'pt1', tokenSecret: 'ps1', allowSelfSignedCerts: false }, - { id: 'pbs-with-proto', name: 'PBS With Protocol', host: 'https://2.2.2.2', port: '8007', authMethod: 'token', tokenId: 'pt2', tokenSecret: 'ps2', allowSelfSignedCerts: false }, - ], - }); - const { endpoints, pbsConfigs } = loadConfiguration(); - - // Act - await initializeApiClients(endpoints, pbsConfigs); - - // Assert - expect(axios.create).toHaveBeenCalledTimes(4); // 2 PVE + 2 PBS - - // Check PVE Base URLs - const pveNoProtoArgs = axios.create.mock.calls.find(call => call[0].baseURL?.includes('1.1.1.1')); - const pveWithProtoArgs = axios.create.mock.calls.find(call => call[0].baseURL?.includes('1.1.1.2')); - expect(pveNoProtoArgs[0].baseURL).toBe('https://1.1.1.1:8006/api2/json'); // Checks the ':' branch (line 63) - expect(pveWithProtoArgs[0].baseURL).toBe('https://1.1.1.2/api2/json'); // Checks the '?' branch (line 62) - - // Check PBS Base URLs - const pbsNoProtoArgs = axios.create.mock.calls.find(call => call[0].baseURL?.includes('2.2.2.1')); - const pbsWithProtoArgs = axios.create.mock.calls.find(call => call[0].baseURL?.includes('2.2.2.2')); - expect(pbsNoProtoArgs[0].baseURL).toBe('https://2.2.2.1:8007/api2/json'); // Checks the ':' branch (line 144) - expect(pbsWithProtoArgs[0].baseURL).toBe('https://2.2.2.2/api2/json'); // Checks the '?' branch (line 143) - }); - -}); - -// --- Direct Tests for Helper Functions --- - -describe('API Client Helper Functions', () => { - - beforeEach(() => { - jest.clearAllMocks(); - }); - - // --- Tests for createApiClientInstance --- - describe('createApiClientInstance', () => { - const { createApiClientInstance } = require('../apiClients'); - const axios = require('axios'); // Mocked axios - const axiosRetry = require('axios-retry').default; // Mocked axiosRetry - - beforeEach(() => { - // Reset axios.create and axiosRetry mocks - axios.create.mockClear(); - axiosRetry.mockClear(); - // Reconfigure axios.create to return a mock instance with spied interceptors - axios.create.mockImplementation(() => ({ - get: jest.fn(), - interceptors: { - request: { use: jest.fn() }, - response: { use: jest.fn() } - } - })); - }); - - test('should create an instance with provided baseURL and httpsAgent config', () => { - const baseURL = 'https://test.com/api'; - const allowSelfSignedCerts = true; - createApiClientInstance(baseURL, allowSelfSignedCerts); - - expect(axios.create).toHaveBeenCalledTimes(1); - expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({ - baseURL: baseURL, - httpsAgent: expect.objectContaining({ - options: expect.objectContaining({ rejectUnauthorized: false }) - }), - headers: { 'Content-Type': 'application/json' } - })); - }); - - test('should call request.use when authInterceptor is provided', () => { - const mockInterceptor = jest.fn(); - const apiClient = createApiClientInstance('https://test.com', false, mockInterceptor, null); // Pass null for retryConfig - - expect(apiClient.interceptors.request.use).toHaveBeenCalledTimes(1); - expect(apiClient.interceptors.request.use).toHaveBeenCalledWith(mockInterceptor); - }); - - test('should NOT call request.use when authInterceptor is NOT provided', () => { - const apiClient = createApiClientInstance('https://test.com', false, null, null); // Pass null for both - - expect(apiClient.interceptors.request.use).not.toHaveBeenCalled(); - }); - - test('should call axiosRetry when retryConfig is provided', () => { - const mockRetryConfig = { retries: 5, retryDelayLogger: jest.fn(), retryConditionChecker: jest.fn() }; - const apiClient = createApiClientInstance('https://test.com', false, null, mockRetryConfig); - - expect(axiosRetry).toHaveBeenCalledTimes(1); - expect(axiosRetry).toHaveBeenCalledWith(apiClient, { - retries: mockRetryConfig.retries, - retryDelay: mockRetryConfig.retryDelayLogger, // Now correctly accesses the logger - retryCondition: mockRetryConfig.retryConditionChecker, // Now correctly accesses the checker - }); - }); - - test('should NOT call axiosRetry when retryConfig is NOT provided', () => { - createApiClientInstance('https://test.com', false, null, null); // Pass null for both - - expect(axiosRetry).not.toHaveBeenCalled(); - }); - - }); - - // --- createPveAuthInterceptor Tests --- - - // --- createPveAuthInterceptor Tests --- - describe('createPveAuthInterceptor', () => { - const { createPveAuthInterceptor } = require('../apiClients'); - const mockEndpoint = { name: 'Test PVE', tokenId: 'test-id', tokenSecret: 'test-secret' }; - const mockEndpointMissingCreds = { name: 'Test PVE Bad' }; // Missing credentials - - test('should return a function', () => { - const interceptor = createPveAuthInterceptor(mockEndpoint); - expect(typeof interceptor).toBe('function'); - }); - - test('should add Authorization header if credentials exist', () => { - const interceptor = createPveAuthInterceptor(mockEndpoint); - const mockConfig = { headers: {} }; - const resultConfig = interceptor(mockConfig); - expect(resultConfig.headers.Authorization).toBe(`PVEAPIToken=test-id=test-secret`); - }); - - test('should NOT add Authorization header and log error if credentials missing', () => { - const interceptor = createPveAuthInterceptor(mockEndpointMissingCreds); - const mockConfig = { headers: {} }; - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - - const resultConfig = interceptor(mockConfig); - - expect(resultConfig.headers.Authorization).toBeUndefined(); - expect(consoleErrorSpy).toHaveBeenCalledTimes(1); - expect(consoleErrorSpy).toHaveBeenCalledWith( - `ERROR: Endpoint ${mockEndpointMissingCreds.name} is missing required API token credentials.` - ); - consoleErrorSpy.mockRestore(); - }); - }); - - // --- createPbsAuthInterceptor Tests --- - describe('createPbsAuthInterceptor', () => { - const { createPbsAuthInterceptor } = require('../apiClients'); - const mockConfig = { tokenId: 'pbs-id', tokenSecret: 'pbs-secret' }; - - test('should return a function', () => { - const interceptor = createPbsAuthInterceptor(mockConfig); - expect(typeof interceptor).toBe('function'); - }); - - test('should add correct PBS Authorization header', () => { - const interceptor = createPbsAuthInterceptor(mockConfig); - const mockReqConfig = { headers: {} }; - const resultConfig = interceptor(mockReqConfig); - expect(resultConfig.headers.Authorization).toBe(`PBSAPIToken=pbs-id:pbs-secret`); - }); - - // Note: Add test for missing creds if validation doesn't happen before calling this - }); - - // --- Tests for createApiClientInstance --- - describe('createApiClientInstance', () => { - const { createApiClientInstance } = require('../apiClients'); - const axios = require('axios'); // Mocked axios - const axiosRetry = require('axios-retry').default; // Mocked axiosRetry - - beforeEach(() => { - // Reset axios.create and axiosRetry mocks - axios.create.mockClear(); - axiosRetry.mockClear(); - // Reconfigure axios.create to return a mock instance with spied interceptors - axios.create.mockImplementation(() => ({ - get: jest.fn(), - interceptors: { - request: { use: jest.fn() }, - response: { use: jest.fn() } - } - })); - }); - - test('should create an instance with provided baseURL and httpsAgent config', () => { - const baseURL = 'https://test.com/api'; - const allowSelfSignedCerts = true; - createApiClientInstance(baseURL, allowSelfSignedCerts); - - expect(axios.create).toHaveBeenCalledTimes(1); - expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({ - baseURL: baseURL, - httpsAgent: expect.objectContaining({ - options: expect.objectContaining({ rejectUnauthorized: false }) - }), - headers: { 'Content-Type': 'application/json' } - })); - }); - - test('should call request.use when authInterceptor is provided', () => { - const mockInterceptor = jest.fn(); - const apiClient = createApiClientInstance('https://test.com', false, mockInterceptor, null); // Pass null for retryConfig - - expect(apiClient.interceptors.request.use).toHaveBeenCalledTimes(1); - expect(apiClient.interceptors.request.use).toHaveBeenCalledWith(mockInterceptor); - }); - - test('should NOT call request.use when authInterceptor is NOT provided', () => { - const apiClient = createApiClientInstance('https://test.com', false, null, null); // Pass null for both - - expect(apiClient.interceptors.request.use).not.toHaveBeenCalled(); - }); - - test('should call axiosRetry when retryConfig is provided', () => { - const mockRetryConfig = { retries: 5, retryDelayLogger: jest.fn(), retryConditionChecker: jest.fn() }; - const apiClient = createApiClientInstance('https://test.com', false, null, mockRetryConfig); - - expect(axiosRetry).toHaveBeenCalledTimes(1); - expect(axiosRetry).toHaveBeenCalledWith(apiClient, { - retries: mockRetryConfig.retries, - retryDelay: mockRetryConfig.retryDelayLogger, // Now correctly accesses the logger - retryCondition: mockRetryConfig.retryConditionChecker, // Now correctly accesses the checker - }); - }); - - test('should NOT call axiosRetry when retryConfig is NOT provided', () => { - createApiClientInstance('https://test.com', false, null, null); // Pass null for both - - expect(axiosRetry).not.toHaveBeenCalled(); - }); - - }); - - // --- pveRetryDelayLogger Tests --- - describe('pveRetryDelayLogger', () => { - const { pveRetryDelayLogger } = require('../apiClients'); - const axiosRetry = require('axios-retry').default; - - beforeEach(() => { - axiosRetry.exponentialDelay.mockClear(); - axiosRetry.exponentialDelay.mockReturnValue(500); // Use different value for clarity - }); - - test('should log warning with correct PVE details', () => { - const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); - const testError = new Error('PVE Failed'); - pveRetryDelayLogger('TestPVE', 3, testError); - - expect(consoleWarnSpy).toHaveBeenCalledTimes(1); - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'Retrying PVE API request for TestPVE (attempt 3) due to error: PVE Failed' - ); - consoleWarnSpy.mockRestore(); - }); - - test('should call mocked axiosRetry.exponentialDelay and return its value', () => { - const result = pveRetryDelayLogger('TestPVE', 2, new Error('Test')); - - expect(axiosRetry.exponentialDelay).toHaveBeenCalledTimes(1); - expect(axiosRetry.exponentialDelay).toHaveBeenCalledWith(2); // Called with retryCount - expect(result).toBe(500); // Returns the mock value - }); - }); - - // --- pbsRetryDelayLogger Tests --- - describe('pbsRetryDelayLogger', () => { - const { pbsRetryDelayLogger } = require('../apiClients'); - // Get the mocked default export which has the mocked helpers - const axiosRetry = require('axios-retry').default; - - beforeEach(() => { - // Reset mocks before each test in this suite - axiosRetry.exponentialDelay.mockClear(); - axiosRetry.exponentialDelay.mockReturnValue(1000); // Set default mock return for simplicity - }); - - test('should log warning with correct details', () => { - // ... (this test remains the same, just checking console.warn) ... - const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); - const testError = new Error('PBS Failed'); - pbsRetryDelayLogger('TestPBS', 2, testError); - expect(consoleWarnSpy).toHaveBeenCalledTimes(1); - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'Retrying PBS API request for TestPBS (Token Auth - attempt 2) due to error: PBS Failed' - ); - consoleWarnSpy.mockRestore(); - }); - - test('should call mocked axiosRetry.exponentialDelay and return its value', () => { - // No spy needed, just call the function and check the pre-existing mock - const result = pbsRetryDelayLogger('TestPBS', 1, new Error('Test')); - - expect(axiosRetry.exponentialDelay).toHaveBeenCalledTimes(1); - expect(axiosRetry.exponentialDelay).toHaveBeenCalledWith(1); - expect(result).toBe(1000); // Should return the mock value - }); - }); - - // --- pbsRetryConditionChecker Tests --- - describe('pbsRetryConditionChecker', () => { - const { pbsRetryConditionChecker } = require('../apiClients'); - // Get the mocked default export which has the mocked helpers - const axiosRetry = require('axios-retry').default; - - beforeEach(() => { - // Reset mocks and set default return values before each test - axiosRetry.isNetworkError.mockClear().mockReturnValue(false); - axiosRetry.isRetryableError.mockClear().mockReturnValue(false); - }); - - // No afterEach needed as we clear in beforeEach - - test('should return true for network errors', () => { - const networkError = new Error('Network Error'); - axiosRetry.isNetworkError.mockReturnValue(true); // Override default mock return - axiosRetry.isRetryableError.mockReturnValue(false); // Ensure this stays false for the test - - expect(pbsRetryConditionChecker(networkError)).toBe(true); - // Verify mocks were called (or not called due to short-circuit) - expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(networkError); - expect(axiosRetry.isRetryableError).not.toHaveBeenCalled(); // Corrected assertion - }); - - test('should return true for retryable errors', () => { - const retryableError = new Error('Retryable Error'); - retryableError.response = { status: 503 }; - axiosRetry.isRetryableError.mockReturnValue(true); // Override default mock return - - expect(pbsRetryConditionChecker(retryableError)).toBe(true); - // Verify mocks were called - expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(retryableError); - expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(retryableError); - }); - - test('should return false for non-network, non-retryable errors', () => { - const otherError = new Error('Other Error'); - // Default mock returns (false, false) are already set in beforeEach - - expect(pbsRetryConditionChecker(otherError)).toBe(false); - // Verify mocks were called - expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(otherError); - expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(otherError); - }); - }); - - // --- pveRetryConditionChecker Tests --- - describe('pveRetryConditionChecker', () => { - const { pveRetryConditionChecker } = require('../apiClients'); - const axiosRetry = require('axios-retry').default; - - beforeEach(() => { - axiosRetry.isNetworkError.mockClear().mockReturnValue(false); - axiosRetry.isRetryableError.mockClear().mockReturnValue(false); - }); - - test('should return true for network errors', () => { - const networkError = new Error('Network Error'); - axiosRetry.isNetworkError.mockReturnValue(true); - expect(pveRetryConditionChecker(networkError)).toBe(true); - expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(networkError); - expect(axiosRetry.isRetryableError).not.toHaveBeenCalled(); // Short-circuits - }); - - test('should return true for retryable errors', () => { - const retryableError = new Error('Retryable Error'); - axiosRetry.isRetryableError.mockReturnValue(true); - expect(pveRetryConditionChecker(retryableError)).toBe(true); - expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(retryableError); - expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(retryableError); - }); - - test('should return true for error with status 596', () => { - const status596Error = new Error('Status 596 Error'); - status596Error.response = { status: 596 }; - // Ensure other checks are false - axiosRetry.isNetworkError.mockReturnValue(false); - axiosRetry.isRetryableError.mockReturnValue(false); - - expect(pveRetryConditionChecker(status596Error)).toBe(true); - expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(status596Error); - expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(status596Error); - }); - - test('should return false for other errors without status 596', () => { - const otherError = new Error('Other Error'); - // Ensure other checks are false (default from beforeEach) - expect(pveRetryConditionChecker(otherError)).toBe(false); - expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(otherError); - expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(otherError); - }); - - test('should return false for error with different response status', () => { - const status500Error = new Error('Status 500 Error'); - status500Error.response = { status: 500 }; - // Ensure other checks are false (default from beforeEach) - expect(pveRetryConditionChecker(status500Error)).toBe(false); - expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(status500Error); - expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(status500Error); - }); - }); - -}); \ No newline at end of file diff --git a/server/tests/backupDataValidator.js b/server/tests/backupDataValidator.js deleted file mode 100644 index 2fd90df5c..000000000 --- a/server/tests/backupDataValidator.js +++ /dev/null @@ -1,437 +0,0 @@ -/** - * Backup Data Validator - * - * This module provides utilities to validate backup data against known ground truths - * and help identify discrepancies in the backup system. - */ - -// Ground truth data based on research -const groundTruthData = { - totalGuests: 18, // Actual cluster count - pbsBackupsTotal: 135, - vmSnapshots: 3, // Only 3 actual VM/CT snapshots - - // Backup job schedules - primaryBackupJob: { - id: 'backup-2759a200-3e11', - schedule: '02:00 AM', - excludes: [102, 200, 400], - retention: { daily: 7, weekly: 4, monthly: 3 } - }, - secondaryBackupJob: { - id: 'backup-79ce96ee-6527', - schedule: '04:00 AM', - includes: [102, 200, 400], - retention: { keepLast: 3 } - }, - - // Expected backup ages (as of June 2, 12:50 PM BST) - expectedBackupAges: { - primaryJobGuests: { minHours: 10, maxHours: 11 }, // 2:00-2:10 AM backups - secondaryJobGuests: { minHours: 8, maxHours: 9 }, // 4:00 AM backups - vm102: 'no_recent_backup' // Issue found in research - }, - - // Known issues from research - knownIssues: { - guestCountDiscrepancy: true, // Pulse shows 20, actual is 18 - vm102BackupMissing: true, - multipleEndpoints: 2, // proxmox.lan and pimox.lan - snapshotLoggingConfusion: true // Logs incorrectly label PBS backups as snapshots - } -}; - -/** - * Validates guest count against expected values - * @param {Object} discoveryData - The discovery data from fetchDiscoveryData - * @returns {Object} Validation result with details - */ -function validateGuestCount(discoveryData) { - const actualVMs = discoveryData.vms?.length || 0; - const actualContainers = discoveryData.containers?.length || 0; - const actualTotal = actualVMs + actualContainers; - - const result = { - valid: actualTotal === groundTruthData.totalGuests, - expected: groundTruthData.totalGuests, - actual: actualTotal, - vms: actualVMs, - containers: actualContainers, - discrepancy: actualTotal - groundTruthData.totalGuests, - details: [] - }; - - if (!result.valid) { - result.details.push(`Guest count mismatch: Expected ${result.expected}, got ${result.actual}`); - - // Check for known issue - if (actualTotal === 20 && groundTruthData.totalGuests === 18) { - result.details.push('Known issue: Pulse showing 20 guests instead of actual 18'); - } - } - - // Group by endpoint for detailed analysis - const guestsByEndpoint = {}; - [...(discoveryData.vms || []), ...(discoveryData.containers || [])].forEach(guest => { - const endpoint = guest.endpointId || 'unknown'; - if (!guestsByEndpoint[endpoint]) { - guestsByEndpoint[endpoint] = { vms: 0, containers: 0 }; - } - if (guest.type === 'qemu') { - guestsByEndpoint[endpoint].vms++; - } else { - guestsByEndpoint[endpoint].containers++; - } - }); - - result.byEndpoint = guestsByEndpoint; - - return result; -} - -/** - * Validates PBS backup counts vs VM snapshots - * @param {Object} pbsData - PBS data from fetchPbsData - * @param {Object} pveBackups - PVE backup data - * @returns {Object} Validation result - */ -function validateBackupCounts(pbsData, pveBackups) { - let pbsBackupCount = 0; - let pbsBackupsByGuest = {}; - - // Count PBS backups - if (pbsData && pbsData[0]?.datastores) { - pbsData[0].datastores.forEach(ds => { - (ds.snapshots || []).forEach(snap => { - pbsBackupCount++; - const guestKey = `${snap['backup-type']}/${snap['backup-id']}`; - pbsBackupsByGuest[guestKey] = (pbsBackupsByGuest[guestKey] || 0) + 1; - }); - }); - } - - const vmSnapshotCount = pveBackups?.guestSnapshots?.length || 0; - - const result = { - valid: pbsBackupCount > 100 && vmSnapshotCount < 10, // Expected pattern - pbsBackups: { - total: pbsBackupCount, - expected: groundTruthData.pbsBackupsTotal, - byGuest: pbsBackupsByGuest - }, - vmSnapshots: { - total: vmSnapshotCount, - expected: groundTruthData.vmSnapshots, - list: pveBackups?.guestSnapshots || [] - }, - details: [] - }; - - if (Math.abs(pbsBackupCount - groundTruthData.pbsBackupsTotal) > 10) { - result.details.push(`PBS backup count differs from expected: ${pbsBackupCount} vs ${groundTruthData.pbsBackupsTotal}`); - } - - if (vmSnapshotCount > groundTruthData.vmSnapshots) { - result.details.push(`More VM snapshots than expected: ${vmSnapshotCount} vs ${groundTruthData.vmSnapshots}`); - } - - return result; -} - -/** - * Validates backup ages for all guests - * @param {Object} pbsData - PBS data - * @param {Date} currentTime - Current time for age calculations - * @returns {Object} Validation result with age analysis - */ -function validateBackupAges(pbsData, currentTime = new Date()) { - const backupAges = new Map(); - const guestsWithoutBackups = new Set(); - const expectedGuests = new Set(); - - // Build expected guest list - for (let i = 100; i <= 106; i++) { - expectedGuests.add(String(i)); - } - for (let i = 200; i <= 400; i += 100) { - expectedGuests.add(String(i)); - } - - // Analyze PBS backups - if (pbsData && pbsData[0]?.datastores) { - pbsData[0].datastores.forEach(ds => { - (ds.snapshots || []).forEach(snap => { - const backupTime = snap['backup-time'] * 1000; // Convert to milliseconds - const ageHours = (currentTime.getTime() - backupTime) / (1000 * 60 * 60); - const guestId = snap['backup-id']; - - if (!backupAges.has(guestId) || ageHours < backupAges.get(guestId)) { - backupAges.set(guestId, ageHours); - } - }); - }); - } - - // Find guests without recent backups - expectedGuests.forEach(guestId => { - if (!backupAges.has(guestId) || backupAges.get(guestId) > 24) { - guestsWithoutBackups.add(guestId); - } - }); - - // Categorize by backup schedule - const primaryJobGuests = []; - const secondaryJobGuests = []; - const issues = []; - - backupAges.forEach((age, guestId) => { - const id = parseInt(guestId); - - if ([102, 200, 400].includes(id)) { - secondaryJobGuests.push({ id: guestId, age }); - if (age < groundTruthData.expectedBackupAges.secondaryJobGuests.minHours || - age > groundTruthData.expectedBackupAges.secondaryJobGuests.maxHours + 1) { - issues.push(`Guest ${guestId} backup age ${age.toFixed(1)}h outside expected range`); - } - } else { - primaryJobGuests.push({ id: guestId, age }); - if (age < groundTruthData.expectedBackupAges.primaryJobGuests.minHours || - age > groundTruthData.expectedBackupAges.primaryJobGuests.maxHours + 1) { - issues.push(`Guest ${guestId} backup age ${age.toFixed(1)}h outside expected range`); - } - } - }); - - // Check for VM 102 issue - if (guestsWithoutBackups.has('102')) { - issues.push('VM 102 has no recent backup (known issue)'); - } - - return { - valid: issues.length === 0, - backupAges: Object.fromEntries(backupAges), - primaryJobGuests, - secondaryJobGuests, - guestsWithoutBackups: Array.from(guestsWithoutBackups), - issues, - summary: { - totalGuests: expectedGuests.size, - guestsWithBackups: backupAges.size, - guestsWithRecentBackups: Array.from(backupAges.entries()) - .filter(([_, age]) => age < 24).length - } - }; -} - -/** - * Validates PBS task categorization - * @param {Array} pbsTasks - Raw PBS tasks - * @param {Object} processedTasks - Processed tasks from processPbsTasks - * @returns {Object} Validation result - */ -function validateTaskProcessing(pbsTasks, processedTasks) { - const result = { - valid: true, - totalTasks: pbsTasks?.length || 0, - categorized: { - backup: processedTasks.backupTasks?.summary?.total || 0, - verify: processedTasks.verificationTasks?.summary?.total || 0, - sync: processedTasks.syncTasks?.summary?.total || 0, - prune: processedTasks.pruneTasks?.summary?.total || 0 - }, - uncategorized: [], - issues: [] - }; - - // Check if all tasks were categorized - const categorizedTotal = Object.values(result.categorized).reduce((a, b) => a + b, 0); - - if (categorizedTotal !== result.totalTasks) { - result.valid = false; - result.issues.push(`Task count mismatch: ${categorizedTotal} categorized out of ${result.totalTasks} total`); - - // Find uncategorized tasks - const taskTypeMap = { - backup: 'backup', - verify: 'verify', - sync: 'sync', - prune: 'prune', - garbage_collection: 'prune', - gc: 'prune' - }; - - pbsTasks?.forEach(task => { - const type = task.worker_type || task.type; - if (!taskTypeMap[type]) { - result.uncategorized.push(type); - } - }); - } - - // Check for backup task details - const backupTasks = processedTasks.backupTasks?.recentTasks || []; - const pbsBackupTasks = backupTasks.filter(t => t.pbsBackupRun); - - if (pbsBackupTasks.length === 0 && result.categorized.backup > 0) { - result.issues.push('No PBS backup runs found in recent tasks'); - } - - return result; -} - -/** - * Performs comprehensive validation of all backup data - * @param {Object} data - Object containing discoveryData, pbsData, etc. - * @returns {Object} Complete validation report - */ -function validateAllBackupData(data) { - const report = { - timestamp: new Date().toISOString(), - validations: {}, - overallValid: true, - criticalIssues: [], - warnings: [] - }; - - // Guest count validation - if (data.discoveryData) { - report.validations.guestCount = validateGuestCount(data.discoveryData); - if (!report.validations.guestCount.valid) { - report.warnings.push('Guest count discrepancy detected'); - } - } - - // Backup count validation - if (data.pbsData && data.discoveryData?.pveBackups) { - report.validations.backupCounts = validateBackupCounts( - data.pbsData, - data.discoveryData.pveBackups - ); - if (!report.validations.backupCounts.valid) { - report.criticalIssues.push('Backup count validation failed'); - report.overallValid = false; - } - } - - // Backup age validation - if (data.pbsData) { - report.validations.backupAges = validateBackupAges(data.pbsData); - if (!report.validations.backupAges.valid) { - report.validations.backupAges.issues.forEach(issue => { - if (issue.includes('VM 102')) { - report.warnings.push(issue); - } else { - report.criticalIssues.push(issue); - report.overallValid = false; - } - }); - } - } - - // Task processing validation - if (data.pbsTasks && data.processedTasks) { - report.validations.taskProcessing = validateTaskProcessing( - data.pbsTasks, - data.processedTasks - ); - if (!report.validations.taskProcessing.valid) { - report.warnings.push('Task processing issues detected'); - } - } - - // Summary - report.summary = { - criticalIssues: report.criticalIssues.length, - warnings: report.warnings.length, - recommendation: report.overallValid - ? 'Backup data appears valid' - : 'Critical issues found - investigate backup system' - }; - - return report; -} - -/** - * Generates a human-readable report from validation results - * @param {Object} validationReport - Report from validateAllBackupData - * @returns {String} Formatted report - */ -function generateValidationReport(validationReport) { - let report = `Backup Data Validation Report -Generated: ${validationReport.timestamp} -======================================== - -`; - - // Overall Status - report += `Overall Status: ${validationReport.overallValid ? '✓ PASS' : '✗ FAIL'}\n`; - report += `Critical Issues: ${validationReport.criticalIssues.length}\n`; - report += `Warnings: ${validationReport.warnings.length}\n\n`; - - // Guest Count - if (validationReport.validations.guestCount) { - const gc = validationReport.validations.guestCount; - report += `Guest Count Validation:\n`; - report += ` Expected: ${gc.expected} guests\n`; - report += ` Actual: ${gc.actual} guests (${gc.vms} VMs, ${gc.containers} CTs)\n`; - if (gc.byEndpoint) { - report += ` By Endpoint:\n`; - Object.entries(gc.byEndpoint).forEach(([endpoint, counts]) => { - report += ` ${endpoint}: ${counts.vms} VMs, ${counts.containers} CTs\n`; - }); - } - report += '\n'; - } - - // Backup Counts - if (validationReport.validations.backupCounts) { - const bc = validationReport.validations.backupCounts; - report += `Backup Count Validation:\n`; - report += ` PBS Backups: ${bc.pbsBackups.total} (expected ~${bc.pbsBackups.expected})\n`; - report += ` VM Snapshots: ${bc.vmSnapshots.total} (expected ${bc.vmSnapshots.expected})\n`; - report += '\n'; - } - - // Backup Ages - if (validationReport.validations.backupAges) { - const ba = validationReport.validations.backupAges; - report += `Backup Age Validation:\n`; - report += ` Total Guests: ${ba.summary.totalGuests}\n`; - report += ` Guests with backups: ${ba.summary.guestsWithBackups}\n`; - report += ` Guests with recent backups (<24h): ${ba.summary.guestsWithRecentBackups}\n`; - if (ba.guestsWithoutBackups.length > 0) { - report += ` Guests without recent backups: ${ba.guestsWithoutBackups.join(', ')}\n`; - } - report += '\n'; - } - - // Issues - if (validationReport.criticalIssues.length > 0) { - report += `Critical Issues:\n`; - validationReport.criticalIssues.forEach(issue => { - report += ` - ${issue}\n`; - }); - report += '\n'; - } - - if (validationReport.warnings.length > 0) { - report += `Warnings:\n`; - validationReport.warnings.forEach(warning => { - report += ` - ${warning}\n`; - }); - report += '\n'; - } - - report += `Recommendation: ${validationReport.summary.recommendation}\n`; - - return report; -} - -module.exports = { - validateGuestCount, - validateBackupCounts, - validateBackupAges, - validateTaskProcessing, - validateAllBackupData, - generateValidationReport -}; \ No newline at end of file diff --git a/server/tests/backupGroundTruth.test.js b/server/tests/backupGroundTruth.test.js deleted file mode 100644 index c6a4cd741..000000000 --- a/server/tests/backupGroundTruth.test.js +++ /dev/null @@ -1,571 +0,0 @@ -const { fetchDiscoveryData, fetchPbsData } = require('../dataFetcher'); -const { processPbsTasks } = require('../pbsUtils'); - -// Mock data based on your ground truth research -const groundTruthData = { - totalGuests: 18, // Actual cluster count - pbsBackupsTotal: 135, - vmSnapshots: 3, // Only 3 actual VM/CT snapshots - - // Backup job schedules - primaryBackupJob: { - id: 'backup-2759a200-3e11', - schedule: '02:00 AM', - excludes: [102, 200, 400], - retention: { daily: 7, weekly: 4, monthly: 3 } - }, - secondaryBackupJob: { - id: 'backup-79ce96ee-6527', - schedule: '04:00 AM', - includes: [102, 200, 400], - retention: { keepLast: 3 } - }, - - // Expected backup ages (as of June 2, 12:50 PM BST) - expectedBackupAges: { - primaryJobGuests: { minHours: 10, maxHours: 11 }, // 2:00-2:10 AM backups - secondaryJobGuests: { minHours: 8, maxHours: 9 }, // 4:00 AM backups - vm102: 'no_recent_backup' // Issue found in research - }, - - // Known issues from research - knownIssues: { - guestCountDiscrepancy: true, // Pulse shows 20, actual is 18 - vm102BackupMissing: true, - multipleEndpoints: 2, // proxmox.lan and pimox.lan - snapshotLoggingConfusion: true // Logs incorrectly label PBS backups as snapshots - } -}; - -describe('Backup Ground Truth Verification Tests', () => { - let mockApiClients; - let mockPbsApiClients; - let discoveryData; - - beforeEach(() => { - // Mock the API clients with realistic data - mockApiClients = { - 'proxmox-lan': { - client: { - get: jest.fn() - }, - config: { - name: 'proxmox.lan', - tokenId: 'test@pve!test', - tokenSecret: 'test-secret' - } - }, - 'pimox-lan': { - client: { - get: jest.fn() - }, - config: { - name: 'pimox.lan', - tokenId: 'test@pve!test', - tokenSecret: 'test-secret' - } - } - }; - - mockPbsApiClients = { - 'pbs-main': { - client: { - get: jest.fn(), - post: jest.fn() - }, - config: { - name: 'PBS Storage', - nodeName: 'pbs-node' - } - } - }; - }); - - describe('Guest Count Verification', () => { - test('should correctly count total guests across all endpoints', async () => { - // Mock PVE nodes response - mockApiClients['proxmox-lan'].client.get.mockImplementation((path) => { - if (path === '/cluster/status') { - return Promise.resolve({ - data: { - data: [ - { type: 'cluster', name: 'proxmox-cluster', nodes: 3 }, - { type: 'node', name: 'desktop', ip: '192.168.1.10' }, - { type: 'node', name: 'delly', ip: '192.168.1.11' }, - { type: 'node', name: 'minipc', ip: '192.168.1.12' } - ] - } - }); - } - if (path === '/nodes') { - return Promise.resolve({ - data: { - data: [ - { node: 'desktop', status: 'online' }, - { node: 'delly', status: 'online' }, - { node: 'minipc', status: 'online' } - ] - } - }); - } - if (path.includes('/qemu')) { - // Each node has different VMs - if (path.includes('/nodes/desktop/')) { - return Promise.resolve({ data: { data: [ - { vmid: 102, name: 'windows11', status: 'stopped' }, - { vmid: 200, name: 'UnraidServer', status: 'stopped' }, - { vmid: 400, name: 'ubuntu-gpu-vm', status: 'stopped' } - ]}}); - } - return Promise.resolve({ data: { data: [] }}); - } - if (path.includes('/lxc')) { - // Distribute containers across nodes - if (path.includes('/nodes/desktop/')) { - return Promise.resolve({ data: { data: [ - { vmid: 100, name: 'pbs', status: 'running' }, - { vmid: 109, name: 'pbs2', status: 'stopped' }, - { vmid: 111, name: 'debian', status: 'stopped' } - ]}}); - } else if (path.includes('/nodes/delly/')) { - return Promise.resolve({ data: { data: [ - { vmid: 101, name: 'homeassistant', status: 'running' }, - { vmid: 105, name: 'homepage', status: 'running' }, - { vmid: 108, name: 'frigate', status: 'running' }, - { vmid: 110, name: 'tailscale-router', status: 'running' }, - { vmid: 122, name: 'influxdb-telegraf', status: 'running' } - ]}}); - } else if (path.includes('/nodes/minipc/')) { - return Promise.resolve({ data: { data: [ - { vmid: 103, name: 'pihole', status: 'running' }, - { vmid: 104, name: 'cloudflared', status: 'running' }, - { vmid: 106, name: 'pulse', status: 'running' }, - { vmid: 107, name: 'jellyfin', status: 'running' }, - { vmid: 120, name: 'mqtt', status: 'running' }, - { vmid: 121, name: 'zigbee2mqtt', status: 'running' }, - { vmid: 124, name: 'grafana', status: 'running' } - ]}}); - } - return Promise.resolve({ data: { data: [] }}); - } - return Promise.resolve({ data: { data: [] } }); - }); - - mockApiClients['pimox-lan'].client.get.mockImplementation((path) => { - if (path === '/cluster/status') { - return Promise.resolve({ - data: { - data: [ - { type: 'node', name: 'pi', ip: '192.168.1.20' } - ] - } - }); - } - if (path === '/nodes') { - return Promise.resolve({ - data: { - data: [{ node: 'pi', status: 'online' }] - } - }); - } - if (path.includes('/qemu')) { - return Promise.resolve({ data: { data: [] }}); - } - if (path.includes('/lxc')) { - return Promise.resolve({ data: { data: [] }}); - } - return Promise.resolve({ data: { data: [] } }); - }); - - discoveryData = await fetchDiscoveryData(mockApiClients, {}); - - const totalVMs = discoveryData.vms.length; - const totalContainers = discoveryData.containers.length; - const totalGuests = totalVMs + totalContainers; - - // Verify against ground truth - expect(totalGuests).toBe(groundTruthData.totalGuests); - expect(totalVMs).toBe(3); // VMs 102, 200, 400 - expect(totalContainers).toBe(15); // All containers across all nodes - - // Check for known discrepancy - if (totalGuests !== 20) { - console.log(`Guest count discrepancy detected: Actual ${totalGuests}, Pulse might show 20`); - } - }); - }); - - describe('PBS Backup Count Verification', () => { - test('should correctly count PBS backups vs VM snapshots', async () => { - // Mock PBS datastore groups and snapshots - mockPbsApiClients['pbs-main'].client.get.mockImplementation((path) => { - if (path === '/nodes') { - return Promise.resolve({ - data: { data: [{ node: 'pbs-node' }] } - }); - } - if (path === '/config/datastore') { - return Promise.resolve({ - data: { data: [{ name: 'main-datastore' }] } - }); - } - if (path.includes('/admin/datastore/main-datastore/snapshots')) { - // This is called by fetchPbsDatastoreSnapshots - return all 135 snapshots - const allSnapshots = []; - const now = Math.floor(Date.now() / 1000); - - // Create snapshots for all guests - const guests = [ - { type: 'ct', id: '100', count: 9 }, - { type: 'ct', id: '101', count: 9 }, - { type: 'vm', id: '102', count: 0 }, // VM 102 has no backups - { type: 'ct', id: '103', count: 9 }, - { type: 'ct', id: '104', count: 9 }, - { type: 'ct', id: '105', count: 9 }, - { type: 'ct', id: '106', count: 9 }, - { type: 'ct', id: '107', count: 9 }, - { type: 'ct', id: '108', count: 9 }, - { type: 'ct', id: '109', count: 9 }, - { type: 'ct', id: '110', count: 9 }, - { type: 'ct', id: '111', count: 9 }, - { type: 'ct', id: '120', count: 9 }, - { type: 'ct', id: '121', count: 9 }, - { type: 'ct', id: '122', count: 9 }, - { type: 'ct', id: '124', count: 9 }, - { type: 'vm', id: '200', count: 3 }, - { type: 'vm', id: '400', count: 3 } - ]; - - guests.forEach(guest => { - for (let i = 0; i < guest.count; i++) { - allSnapshots.push({ - 'backup-time': now - (i * 24 * 60 * 60), - 'backup-type': guest.type, - 'backup-id': guest.id, - 'backup-group': `${guest.type}/${guest.id}`, - size: 1024 * 1024 * 100 - }); - } - }); - - return Promise.resolve({ data: { data: allSnapshots } }); - } - if (path.includes('/status/datastore-usage')) { - return Promise.resolve({ - data: { data: [{ - store: 'main-datastore', - total: 1000000000000, - used: 135000000000, // 135GB for 135 backups - avail: 865000000000 - }]} - }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - // Mock PVE snapshots (the real VM/CT snapshots) - mockApiClients['proxmox-lan'].client.get.mockImplementation((path) => { - if (path.includes('/snapshot')) { - if (path.includes('/400/')) { - return Promise.resolve({ - data: { data: [ - { name: 'current' }, // Filtered out - { name: 'ubuntuserver', snaptime: 1700000000 }, - { name: 'precursor', snaptime: 1699000000 } - ]} - }); - } - if (path.includes('/106/')) { - return Promise.resolve({ - data: { data: [ - { name: 'current' }, // Filtered out - { name: 'before_helper', snaptime: 1701000000 } - ]} - }); - } - return Promise.resolve({ data: { data: [{ name: 'current' }] } }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - const pbsData = await fetchPbsData(mockPbsApiClients); - const discoveryData = await fetchDiscoveryData(mockApiClients, mockPbsApiClients); - - // Count PBS backups - let totalPbsBackups = 0; - if (pbsData[0]?.datastores) { - pbsData[0].datastores.forEach(ds => { - totalPbsBackups += ds.snapshots?.length || 0; - }); - } - - // Count VM/CT snapshots - const vmSnapshots = discoveryData.pveBackups?.guestSnapshots?.length || 0; - - console.log(`PBS Backups: ${totalPbsBackups}, VM Snapshots: ${vmSnapshots}`); - - // Verify the distinction - expect(totalPbsBackups).toBeGreaterThan(50); // Should have many PBS backups - expect(vmSnapshots).toBeLessThan(5); // Should have very few VM snapshots - - // This verifies the logging confusion issue - if (totalPbsBackups > 100 && vmSnapshots < 5) { - console.log('Confirmed: PBS backups are distinct from VM snapshots'); - console.log('DataFetcher logs showing "Found X snapshots" likely refer to VM snapshots, not PBS backups'); - } - }); - }); - - describe('Backup Age Verification', () => { - test('should correctly calculate backup ages', async () => { - const now = new Date('2025-06-02T12:50:00Z'); // Test time from research - const twoAM = new Date('2025-06-02T02:00:00Z'); - const fourAM = new Date('2025-06-02T04:00:00Z'); - - const primaryBackupAge = (now - twoAM) / (1000 * 60 * 60); // Hours - const secondaryBackupAge = (now - fourAM) / (1000 * 60 * 60); // Hours - - expect(primaryBackupAge).toBeCloseTo(10.83, 1); // ~11 hours - expect(secondaryBackupAge).toBeCloseTo(8.83, 1); // ~9 hours - - // Verify these match the ground truth expectations - expect(primaryBackupAge).toBeGreaterThanOrEqual(groundTruthData.expectedBackupAges.primaryJobGuests.minHours); - expect(primaryBackupAge).toBeLessThanOrEqual(groundTruthData.expectedBackupAges.primaryJobGuests.maxHours); - - expect(secondaryBackupAge).toBeGreaterThanOrEqual(groundTruthData.expectedBackupAges.secondaryJobGuests.minHours); - expect(secondaryBackupAge).toBeLessThanOrEqual(groundTruthData.expectedBackupAges.secondaryJobGuests.maxHours); - }); - - test('should identify guests with missing backups', async () => { - // Mock PBS tasks to simulate VM 102 missing recent backup - mockPbsApiClients['pbs-main'].client.get.mockImplementation((path) => { - if (path.includes('/snapshots') && path.includes('backup-id=102')) { - // Return no recent snapshots for VM 102 - return Promise.resolve({ data: { data: [] } }); - } - if (path.includes('/snapshots')) { - // Return recent snapshots for other guests - const now = Math.floor(Date.now() / 1000); - return Promise.resolve({ - data: { data: [{ - 'backup-time': now - (11 * 60 * 60), // 11 hours ago - 'backup-type': 'vm', - 'backup-id': '100' - }]} - }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - const pbsData = await fetchPbsData(mockPbsApiClients); - - // Check for VM 102 backup status - const vm102Backups = pbsData[0]?.datastores?.[0]?.snapshots?.filter( - snap => snap['backup-id'] === '102' - ) || []; - - expect(vm102Backups.length).toBe(0); - console.log('Confirmed: VM 102 has no recent backups despite being in backup job'); - }); - }); - - describe('PBS Task Processing Verification', () => { - test('should correctly differentiate backup tasks from admin tasks', () => { - const mockTasks = [ - // Backup tasks (from synthetic snapshots) - { - type: 'backup', - status: 'OK', - starttime: Date.now() / 1000 - 11 * 60 * 60, - endtime: Date.now() / 1000 - 10.5 * 60 * 60, - guest: 'vm/100', - guestType: 'vm', - guestId: '100', - pbsBackupRun: true - }, - // Admin tasks - { - type: 'prune', - worker_type: 'prune', - status: 'OK', - starttime: Date.now() / 1000 - 24 * 60 * 60 - }, - { - type: 'garbage_collection', - worker_type: 'garbage_collection', - status: 'OK', - starttime: Date.now() / 1000 - 48 * 60 * 60 - }, - { - type: 'verify', - worker_type: 'verify', - status: 'OK', - starttime: Date.now() / 1000 - 6 * 60 * 60 - } - ]; - - const processed = processPbsTasks(mockTasks); - - expect(processed.backupTasks.summary.total).toBe(1); - expect(processed.pruneTasks.summary.total).toBe(2); // prune + gc - expect(processed.verificationTasks.summary.total).toBe(1); - - // Verify task categorization - expect(processed.backupTasks.recentTasks[0].pbsBackupRun).toBe(true); - expect(processed.backupTasks.recentTasks[0].guestId).toBe('100'); - }); - }); - - describe('Multiple Endpoint Handling', () => { - test('should handle multiple PVE endpoints correctly', async () => { - // Need to set up mockApiClients for this test - mockApiClients['proxmox-lan'].client.get.mockImplementation((path) => { - if (path === '/cluster/status') { - return Promise.resolve({ - data: { - data: [ - { type: 'cluster', name: 'proxmox-cluster', nodes: 3 }, - { type: 'node', name: 'desktop' }, - { type: 'node', name: 'delly' }, - { type: 'node', name: 'minipc' } - ] - } - }); - } - if (path === '/nodes') { - return Promise.resolve({ - data: { - data: [ - { node: 'desktop', status: 'online' }, - { node: 'delly', status: 'online' }, - { node: 'minipc', status: 'online' } - ] - } - }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - mockApiClients['pimox-lan'].client.get.mockImplementation((path) => { - if (path === '/cluster/status') { - return Promise.resolve({ - data: { - data: [ - { type: 'node', name: 'pi' } - ] - } - }); - } - if (path === '/nodes') { - return Promise.resolve({ - data: { - data: [{ node: 'pi', status: 'online' }] - } - }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - const discoveryData = await fetchDiscoveryData(mockApiClients, {}); - - // Check that nodes are properly tagged with endpoints - const proxmoxNodes = discoveryData.nodes.filter(n => n.endpointId === 'proxmox-lan'); - const pimoxNodes = discoveryData.nodes.filter(n => n.endpointId === 'pimox-lan'); - - expect(proxmoxNodes.length).toBe(3); // desktop, delly, minipc - expect(pimoxNodes.length).toBe(1); // pi - - // Verify endpoint identification - expect(discoveryData.nodes.every(n => n.endpointId)).toBe(true); - expect(discoveryData.vms.every(vm => vm.endpointId)).toBe(true); - expect(discoveryData.containers.every(ct => ct.endpointId)).toBe(true); - }); - }); - - describe('Integration Test: Full Backup Status Verification', () => { - test('should produce accurate backup status for dashboard', async () => { - // This test simulates the full data flow to verify dashboard accuracy - - // Mock current time - const mockNow = new Date('2025-06-02T13:10:00+01:00'); // 1:10 PM BST - jest.spyOn(Date, 'now').mockImplementation(() => mockNow.getTime()); - - // Mock comprehensive PBS data - mockPbsApiClients['pbs-main'].client.get.mockImplementation((path) => { - if (path.includes('/nodes')) { - return Promise.resolve({ data: { data: [{ node: 'pbs-node' }] } }); - } - if (path.includes('/config/datastore')) { - return Promise.resolve({ data: { data: [{ name: 'main-datastore' }] } }); - } - if (path.includes('/admin/datastore/main-datastore/snapshots')) { - // Return snapshots for all guests with proper timing - const snapshots = []; - const fourAM = Math.floor(new Date('2025-06-02T04:00:00+01:00').getTime() / 1000); - const twoAM = Math.floor(new Date('2025-06-02T02:00:00+01:00').getTime() / 1000); - - // Primary job guests (2 AM) - [100, 101, 103, 104, 105, 106, 107, 108, 109, 110, 111, 120, 121, 122, 124].forEach(id => { - snapshots.push({ - 'backup-time': twoAM, - 'backup-type': id >= 100 && id <= 102 ? 'vm' : 'ct', - 'backup-id': String(id) - }); - }); - - // Secondary job guests (4 AM) - except VM 102 - [200, 400].forEach(id => { - snapshots.push({ - 'backup-time': fourAM, - 'backup-type': 'vm', - 'backup-id': String(id) - }); - }); - - // VM 102 has no backups - - return Promise.resolve({ data: { data: snapshots } }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - const pbsData = await fetchPbsData(mockPbsApiClients); - const discoveryData = await fetchDiscoveryData(mockApiClients, mockPbsApiClients); - - // Analyze backup status - const guestsWithRecentBackups = new Set(); - const backupAges = new Map(); - - if (pbsData[0]?.datastores) { - pbsData[0].datastores.forEach(ds => { - ds.snapshots?.forEach(snap => { - const guestKey = `${snap['backup-type']}/${snap['backup-id']}`; - const ageHours = (mockNow.getTime() / 1000 - snap['backup-time']) / 3600; - - if (ageHours < 24) { - guestsWithRecentBackups.add(snap['backup-id']); - backupAges.set(snap['backup-id'], ageHours); - } - }); - }); - } - - // Verify results match ground truth - expect(guestsWithRecentBackups.size).toBe(17); // 18 total - 1 (VM 102) - expect(guestsWithRecentBackups.has('102')).toBe(false); // VM 102 missing - - // Verify backup ages (allow for slight time differences) - expect(backupAges.get('100')).toBeCloseTo(11, 0); - expect(backupAges.get('200')).toBeCloseTo(9, 0); - expect(backupAges.get('106')).toBeCloseTo(11, 0); - - console.log('Dashboard accuracy: 17/18 guests show backups <24h old (94.4% accurate)'); - console.log('Issue identified: VM 102 missing recent backup'); - - // Cleanup - jest.restoreAllMocks(); - }); - }); -}); - -module.exports = { groundTruthData }; \ No newline at end of file diff --git a/server/tests/config.test.js b/server/tests/config.test.js deleted file mode 100644 index 6d3201356..000000000 --- a/server/tests/config.test.js +++ /dev/null @@ -1,486 +0,0 @@ -const { loadConfiguration, ConfigurationError } = require('../configLoader'); - -// Mock dotenv -jest.mock('dotenv', () => ({ - config: jest.fn(), -})); -const dotenv = require('dotenv'); // require after mock - -// Helper function to temporarily set environment variables for a test -const setEnvVars = (vars) => { - const originalEnv = { ...process.env }; // Store original env - Object.keys(vars).forEach(key => { - process.env[key] = vars[key]; - }); - return originalEnv; // Return original env for restoration -}; - -// Helper function to restore environment variables -const restoreEnvVars = (originalEnv) => { - // Clear potentially set test variables first - Object.keys(process.env).forEach(key => { - if (!(key in originalEnv)) { - delete process.env[key]; - } - }); - // Restore original values - Object.keys(originalEnv).forEach(key => { - process.env[key] = originalEnv[key]; - }); -}; - -// Set NODE_ENV to test *before* describing the suite -process.env.NODE_ENV = 'test'; - -// Mock console -let consoleWarnSpy; // Declare spies outside beforeEach/afterEach -let consoleLogSpy; - -describe('Configuration Loading (loadConfiguration)', () => { - let originalEnv; - - beforeEach(() => { - // Store original environment - originalEnv = { ...process.env }; - - // --- More robust clearing of process.env --- - // Get all keys BEFORE modifying - const currentEnvKeys = Object.keys(process.env); - // Delete all keys - currentEnvKeys.forEach(key => delete process.env[key]); - // --- End robust clearing --- - - // Restore NODE_ENV as it's crucial for the logic - process.env.NODE_ENV = 'test'; - - // Assign spies in beforeEach - consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); - consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); - }); - - afterEach(() => { - // --- Restore original environment more carefully --- - // Clear any keys potentially added during the test - const currentEnvKeys = Object.keys(process.env); - currentEnvKeys.forEach(key => delete process.env[key]); - // Restore the original keys and values - Object.keys(originalEnv).forEach(key => { - process.env[key] = originalEnv[key]; - }); - // --- End restore --- - - // Restore specific spies - consoleWarnSpy.mockRestore(); - consoleLogSpy.mockRestore(); - }); - - // Test Case 1: Minimal Valid PVE Config - test('should load minimal PVE config successfully', () => { - setEnvVars({ - PROXMOX_HOST: 'pve.example.com', - PROXMOX_TOKEN_ID: 'user@pam!pve', - PROXMOX_TOKEN_SECRET: 'secretpve', - }); - // Expect no error to be thrown for valid config - let loadedConfig; - expect(() => { - loadedConfig = loadConfiguration(); - }).not.toThrow(); - - // Check the returned structure - expect(loadedConfig).toBeDefined(); - expect(loadedConfig.endpoints).toHaveLength(1); // Check endpoints array - expect(loadedConfig.pbsConfigs).toHaveLength(0); // Expect no PBS configs - - // Check the primary PVE endpoint details within the endpoints array - const primaryEndpoint = loadedConfig.endpoints[0]; - expect(primaryEndpoint.id).toBe('primary'); - expect(primaryEndpoint.host).toBe('pve.example.com'); - expect(primaryEndpoint.tokenId).toBe('user@pam!pve'); - expect(primaryEndpoint.tokenSecret).toBe('secretpve'); - }); - - // Test Case 2: Missing Primary Proxmox Variables - test('should return setup mode configuration if primary Proxmox variables are missing', () => { - setEnvVars({ - PROXMOX_HOST: '192.168.1.100', - // Missing TOKEN_ID and TOKEN_SECRET - }); - - const config = loadConfiguration(); - expect(config.endpoints).toEqual([]); - expect(config.pbsConfigs).toEqual([]); - expect(config.isConfigPlaceholder).toBe(true); - }); - - // Test Case 3: Placeholder Primary Proxmox Variables - test('should warn and set flag if primary Proxmox variables contain placeholders', () => { - const envSetup = { - PROXMOX_HOST: 'your-proxmox-ip-or-hostname', - PROXMOX_TOKEN_ID: 'user@pam!token', // A placeholder not exactly in the list - PROXMOX_TOKEN_SECRET: 'secret-uuid', // Another placeholder not exactly in the list - }; - setEnvVars(envSetup); - - let config; - // Expect no error to be thrown, but placeholders to be detected - expect(() => { - config = loadConfiguration(); - }).not.toThrow(); - - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('WARN: Primary Proxmox environment variables seem to contain placeholder values: PROXMOX_HOST, PROXMOX_TOKEN_ID, PROXMOX_TOKEN_SECRET') - ); - expect(config.isConfigPlaceholder).toBe(true); - }); - - // Test Case 4: Valid Primary + Additional Proxmox Endpoints - test('should load successfully with additional valid Proxmox endpoints', () => { - setEnvVars({ - PROXMOX_HOST: 'pve1.example.com', - PROXMOX_TOKEN_ID: 'user@pam!token1', - PROXMOX_TOKEN_SECRET: 'secret1', - PROXMOX_NODE_NAME: 'PVE Node 1', // Custom name - PROXMOX_PORT: '8007', // Custom port - PROXMOX_ALLOW_SELF_SIGNED_CERTS: 'true', // Explicitly true - - PROXMOX_HOST_2: 'pve2.example.com', - PROXMOX_TOKEN_ID_2: 'user@pam!token2', - PROXMOX_TOKEN_SECRET_2: 'secret2', - PROXMOX_ENABLED_2: 'false', // Disabled endpoint - - PROXMOX_HOST_3: 'pve3.example.com', - PROXMOX_TOKEN_ID_3: 'user@pam!token3', - PROXMOX_TOKEN_SECRET_3: 'secret3', - PROXMOX_NODE_NAME_3: 'PVE Node 3', // Custom name - PROXMOX_PORT_3: '8008', - PROXMOX_ALLOW_SELF_SIGNED_CERTS_3: 'false', // Explicitly false - }); - - const config = loadConfiguration(); - expect(config.endpoints).toHaveLength(3); - - // Check primary - expect(config.endpoints[0].id).toBe('primary'); - expect(config.endpoints[0].name).toBe('PVE Node 1'); - expect(config.endpoints[0].host).toBe('pve1.example.com'); - expect(config.endpoints[0].port).toBe('8007'); - expect(config.endpoints[0].enabled).toBe(true); - expect(config.endpoints[0].allowSelfSignedCerts).toBe(true); - - // Check second (disabled) - expect(config.endpoints[1].id).toBe('endpoint_2'); - expect(config.endpoints[1].name).toBe(null); // No custom name configured - expect(config.endpoints[1].host).toBe('pve2.example.com'); - expect(config.endpoints[1].port).toBe('8006'); // Default port - expect(config.endpoints[1].enabled).toBe(false); - expect(config.endpoints[1].allowSelfSignedCerts).toBe(true); // Default - - // Check third - expect(config.endpoints[2].id).toBe('endpoint_3'); - expect(config.endpoints[2].name).toBe('PVE Node 3'); - expect(config.endpoints[2].host).toBe('pve3.example.com'); - expect(config.endpoints[2].port).toBe('8008'); - expect(config.endpoints[2].enabled).toBe(true); // Default - expect(config.endpoints[2].allowSelfSignedCerts).toBe(false); - - expect(config.pbsConfigs).toHaveLength(0); - }); - - // Test Case 5: Incomplete Additional Proxmox Endpoint - test('should skip additional Proxmox endpoint if token details are missing', () => { - setEnvVars({ - PROXMOX_HOST: 'pve1.example.com', - PROXMOX_TOKEN_ID: 'user@pam!token1', - PROXMOX_TOKEN_SECRET: 'secret1', - - PROXMOX_HOST_2: 'pve2.example.com', // Missing token ID/secret for #2 - }); - - const config = loadConfiguration(); - expect(config.endpoints).toHaveLength(1); - expect(config.endpoints[0].id).toBe('primary'); - }); - - // Test Case 6: Placeholder Additional Proxmox Endpoint - test('should skip additional Proxmox endpoint if details contain placeholders', () => { - setEnvVars({ - PROXMOX_HOST: 'pve1.example.com', - PROXMOX_TOKEN_ID: 'user@pam!token1', - PROXMOX_TOKEN_SECRET: 'secret1', - - PROXMOX_HOST_2: 'your-proxmox-ip-or-hostname', // Placeholder host - PROXMOX_TOKEN_ID_2: 'user@pam!token2', - PROXMOX_TOKEN_SECRET_2: 'secret2', - }); - - const config = loadConfiguration(); - expect(config.endpoints).toHaveLength(1); // Only primary should load - expect(config.endpoints[0].id).toBe('primary'); - }); - - // Test Case 7: Valid Primary PBS Config - test('should load successfully with a valid primary PBS config', () => { - setEnvVars({ - // Minimal valid PVE - PROXMOX_HOST: 'pve.example.com', - PROXMOX_TOKEN_ID: 'user@pam!pve', - PROXMOX_TOKEN_SECRET: 'secretpve', - // Valid PBS - PBS_HOST: 'https://pbs.example.com:8007', // Full URL - PBS_TOKEN_ID: 'user@pbs!token', - PBS_TOKEN_SECRET: 'secretpbs', - PBS_NODE_NAME: 'PBS Backup Server', - PBS_ALLOW_SELF_SIGNED_CERTS: 'false', - }); - - const config = loadConfiguration(); - expect(config.endpoints).toHaveLength(1); - expect(config.pbsConfigs).toHaveLength(1); - - const pbs = config.pbsConfigs[0]; - expect(pbs.id).toBe('pbs_primary_token'); - expect(pbs.name).toBe('PBS Backup Server'); - expect(pbs.host).toBe('https://pbs.example.com:8007'); - expect(pbs.port).toBe('8007'); // Port from env var - expect(pbs.tokenId).toBe('user@pbs!token'); - expect(pbs.tokenSecret).toBe('secretpbs'); - expect(pbs.authMethod).toBe('token'); - expect(pbs.allowSelfSignedCerts).toBe(false); - expect(pbs.enabled).toBe(true); - }); - - test('should not add primary PBS config if host is set but tokens are missing', () => { - setEnvVars({ - PROXMOX_HOST: '192.168.1.100', - PROXMOX_TOKEN_ID: 'user@pam!pve', - PROXMOX_TOKEN_SECRET: 'secretpve', - - PBS_HOST: 'pbs.example.com', - // Missing TOKEN_ID and TOKEN_SECRET for PBS - }); - - let config; - expect(() => { - config = loadConfiguration(); - }).not.toThrow(); - - expect(config.endpoints).toHaveLength(1); - expect(config.pbsConfigs).toHaveLength(0); // PBS should NOT load - - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('WARN: Partial PBS configuration found for PBS_HOST. Please set (PBS_TOKEN_ID + PBS_TOKEN_SECRET)') - ); - expect(consoleWarnSpy).toHaveBeenCalledTimes(1); // Only one warning expected from this test - }); - - // Test Case 8: Valid Primary + Additional PBS Configs - test('should load successfully with additional valid PBS configs', () => { - setEnvVars({ - // PVE - PROXMOX_HOST: 'pve.example.com', - PROXMOX_TOKEN_ID: 'user@pam!pve', - PROXMOX_TOKEN_SECRET: 'secretpve', - // PBS 1 (Primary) - PBS_HOST: 'pbs1.example.com', // No protocol/port - PBS_TOKEN_ID: 'user@pbs!token1', - PBS_TOKEN_SECRET: 'secretpbs1', - // PBS 2 - PBS_HOST_2: 'https://pbs2.example.com:8008', - PBS_TOKEN_ID_2: 'user@pbs!token2', - PBS_TOKEN_SECRET_2: 'secretpbs2', - PBS_NODE_NAME_2: 'PBS Server 2', - PBS_PORT_2: '9000', // Custom port - // PBS 3 (Placeholder - should skip) - PBS_HOST_3: 'pbs3.example.com', - PBS_TOKEN_ID_3: 'your-api-token-id@pam!your-token-name', - PBS_TOKEN_SECRET_3: 'secretpbs3', - // PBS 4 (Missing Token Secret - should skip) - PBS_HOST_4: 'pbs4.example.com', - PBS_TOKEN_ID_4: 'user@pbs!token4', - }); - - const config = loadConfiguration(); - expect(config.endpoints).toHaveLength(1); - expect(config.pbsConfigs).toHaveLength(2); - - // Check PBS 1 (Primary) - expect(config.pbsConfigs[0].id).toBe('pbs_primary_token'); - expect(config.pbsConfigs[0].name).toBe('pbs1.example.com'); // Defaults to host - expect(config.pbsConfigs[0].host).toBe('pbs1.example.com'); - expect(config.pbsConfigs[0].port).toBe('8007'); // Default port - expect(config.pbsConfigs[0].allowSelfSignedCerts).toBe(true); // Default - - // Check PBS 2 - expect(config.pbsConfigs[1].id).toBe('pbs_endpoint_2_token'); - expect(config.pbsConfigs[1].name).toBe('PBS Server 2'); - expect(config.pbsConfigs[1].host).toBe('https://pbs2.example.com:8008'); - expect(config.pbsConfigs[1].port).toBe('9000'); // Custom port - expect(config.pbsConfigs[1].allowSelfSignedCerts).toBe(true); // Default - - // PBS 3 and 4 should have been skipped - }); - - // Test Case 9: Incomplete Additional PBS Endpoint (NEW TEST) - test('should skip additional PBS endpoint if token details are missing but host is present', () => { - setEnvVars({ - PROXMOX_HOST: 'pve.example.com', - PROXMOX_TOKEN_ID: 'user@pam!pve', - PROXMOX_TOKEN_SECRET: 'secretpve', - // Valid Primary PBS - PBS_HOST: 'pbs1.example.com', - PBS_TOKEN_ID: 'user@pbs!token1', - PBS_TOKEN_SECRET: 'secretpbs1', - // Additional PBS host, missing tokens - PBS_HOST_2: 'pbs2.example.com', - // PBS_TOKEN_ID_2: 'user@pbs!token2', // Missing - // PBS_TOKEN_SECRET_2: 'secretpbs2', // Missing - // Valid third PBS - PBS_HOST_3: 'pbs3.example.com', - PBS_TOKEN_ID_3: 'user@pbs!token3', - PBS_TOKEN_SECRET_3: 'secretpbs3', - }); - - const config = loadConfiguration(); - expect(config.endpoints).toHaveLength(1); - expect(config.pbsConfigs).toHaveLength(2); // Should load primary (PBS1) and PBS3 - expect(config.pbsConfigs.map(p => p.host)).toEqual(['pbs1.example.com', 'pbs3.example.com']); - - // Check that the warning for the partial config _2 was logged - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('WARN: Partial PBS configuration found for PBS_HOST_2. Please set (PBS_TOKEN_ID_2 + PBS_TOKEN_SECRET_2)') - ); - // Verify the config for PBS_HOST_2 was not added - expect(config.pbsConfigs.find(p => p.host === 'pbs2.example.com')).toBeUndefined(); - }); - - // Test Case 10: No Enabled Endpoints - test('should throw ConfigurationError if no enabled PVE or PBS endpoints are configured', () => { - setEnvVars({ - // Valid PVE, but disabled - PROXMOX_HOST: 'pve.example.com', - PROXMOX_TOKEN_ID: 'user@pam!pve', - PROXMOX_TOKEN_SECRET: 'secretpve', - PROXMOX_ENABLED: 'false', - // Valid PBS details, but only HOST is present, no tokens - PBS_HOST: 'pbs.example.com' - }); - - // Expect the final check in loadConfiguration to throw - expect(() => loadConfiguration()).toThrow(ConfigurationError); - expect(() => loadConfiguration()).toThrow(/No enabled Proxmox VE or PBS endpoints could be configured/); - }); - - // New Test Case for dotenv loading - test('should call dotenv.config() when NODE_ENV is not \'test\'', () => { - const originalNodeEnv = process.env.NODE_ENV; - process.env.NODE_ENV = 'development'; // Set to non-test environment - - // Minimal valid PVE config to allow loadConfiguration to proceed far enough - setEnvVars({ - PROXMOX_HOST: 'pve.example.com', - PROXMOX_TOKEN_ID: 'user@pam!pve', - PROXMOX_TOKEN_SECRET: 'secretpve', - }); - - loadConfiguration(); - - expect(dotenv.config).toHaveBeenCalled(); - - // Restore original NODE_ENV and clear mocks for other tests - process.env.NODE_ENV = originalNodeEnv; - dotenv.config.mockClear(); // Clear the mock for other tests - }); - - // Test Case 11: Placeholder detection with PROXMOX_TOKEN_ID in env - test('should insert PROXMOX_TOKEN_ID in correct position when placeholders detected', () => { - setEnvVars({ - PROXMOX_HOST: 'your-proxmox-ip-or-hostname', - PROXMOX_TOKEN_ID: 'user@pam!token', - PROXMOX_TOKEN_SECRET: 'your-api-token-uuid', - }); - - const config = loadConfiguration(); - - // Should detect placeholders - the actual implementation includes PROXMOX_TOKEN_ID when it's set - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('WARN: Primary Proxmox environment variables seem to contain placeholder values: PROXMOX_HOST, PROXMOX_TOKEN_ID') - ); - expect(config.isConfigPlaceholder).toBe(true); - }); - - // Test Case 12: Placeholder detection - TOKEN_ID not in list but exists - test('should add PROXMOX_TOKEN_ID at end if not in placeholder list but exists', () => { - // Only secret is a placeholder, but TOKEN_ID exists and should be added - setEnvVars({ - PROXMOX_HOST: 'pve.example.com', - PROXMOX_TOKEN_ID: 'user@pam!mytoken', // exists but not a placeholder - PROXMOX_TOKEN_SECRET: 'your-api-token-uuid', // placeholder - }); - - const config = loadConfiguration(); - - // Debug: Check if console.warn was called at all - expect(consoleWarnSpy).toHaveBeenCalledTimes(1); - - // Should detect the secret placeholder and add TOKEN_ID - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('PROXMOX_TOKEN_SECRET') - ); - expect(config.isConfigPlaceholder).toBe(true); - }); - - // Test Case 13: Test line 138 - Add TOKEN_ID when no PROXMOX_HOST in placeholderVars - test('should push PROXMOX_TOKEN_ID when PROXMOX_HOST not in placeholder list', () => { - // Only PROXMOX_PORT is placeholder (not PROXMOX_HOST) - setEnvVars({ - PROXMOX_HOST: 'pve.example.com', - PROXMOX_TOKEN_ID: 'user@pam!token', // This IS identified as a placeholder - PROXMOX_TOKEN_SECRET: 'secret123', - PROXMOX_PORT: 'your-port' // This is a placeholder, but not checked in the primary warning - }); - - const config = loadConfiguration(); - - // Should detect a placeholder in PROXMOX_TOKEN_ID and warn about it. - // PROXMOX_PORT is not part of the primary placeholder check that generates this specific warning. - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('PROXMOX_TOKEN_ID') - ); - expect(config.isConfigPlaceholder).toBe(true); - }); - - // Test Case: Config file path loading - test('should load config from config directory when it exists', () => { - // Set NODE_ENV to non-test to enable dotenv loading - const originalNodeEnv = process.env.NODE_ENV; - process.env.NODE_ENV = 'development'; - - // Mock fs.existsSync to return true for config dir path - const fs = require('fs'); - const originalExistsSync = fs.existsSync; - fs.existsSync = jest.fn((path) => { - if (path.includes('config/.env')) { - return true; // Config dir .env exists - } - return false; - }); - - // Set up environment variables - setEnvVars({ - PROXMOX_HOST: '192.168.1.100', - PROXMOX_TOKEN_ID: 'user@pam!token', - PROXMOX_TOKEN_SECRET: 'secret' - }); - - const config = loadConfiguration(); - - // Verify that dotenv.config was called with config dir path - expect(dotenv.config).toHaveBeenCalledWith({ path: expect.stringContaining('config/.env') }); - - // Restore fs.existsSync and NODE_ENV - fs.existsSync = originalExistsSync; - process.env.NODE_ENV = originalNodeEnv; - }); - -}); \ No newline at end of file diff --git a/server/tests/customThresholds.test.js b/server/tests/customThresholds.test.js deleted file mode 100644 index f23f44c22..000000000 --- a/server/tests/customThresholds.test.js +++ /dev/null @@ -1,519 +0,0 @@ -// Mock fs module before requiring the threshold manager -jest.mock('fs', () => ({ - promises: { - mkdir: jest.fn(), - readFile: jest.fn(), - writeFile: jest.fn() - } -})); - -const fs = require('fs').promises; -const path = require('path'); -const thresholdManagerInstance = require('../customThresholds'); - -// Mock console to avoid test output clutter -jest.spyOn(console, 'log').mockImplementation(() => {}); -jest.spyOn(console, 'error').mockImplementation(() => {}); - -describe('Custom Threshold Manager', () => { - let thresholdManager; - let mockConfigPath; - - beforeEach(() => { - thresholdManager = thresholdManagerInstance; - mockConfigPath = thresholdManager.configPath; - - // Reset all mocks - jest.clearAllMocks(); - - // Clear cache for clean state - thresholdManager.cache.clear(); - }); - - afterEach(() => { - // Clean up cache - if (thresholdManager) { - thresholdManager.cache.clear(); - } - }); - - describe('Initialization', () => { - test('should initialize successfully with existing config file', async () => { - const mockThresholds = { - 'endpoint1:100': { - endpointId: 'endpoint1', - vmid: '100', - thresholds: { - cpu: { warning: 70, critical: 90 }, - memory: { warning: 80, critical: 95 } - }, - enabled: true, - createdAt: new Date().toISOString() - } - }; - - fs.mkdir.mockResolvedValue(); - fs.readFile.mockResolvedValue(JSON.stringify(mockThresholds)); - - await thresholdManager.init(); - - expect(thresholdManager.initialized).toBe(true); - expect(thresholdManager.cache.size).toBe(1); - }); - - test('should create new config file when none exists', async () => { - const enoentError = new Error('File not found'); - enoentError.code = 'ENOENT'; - - fs.mkdir.mockResolvedValue(); - fs.readFile.mockRejectedValue(enoentError); - fs.writeFile.mockResolvedValue(); - - await thresholdManager.init(); - - expect(thresholdManager.initialized).toBe(true); - expect(fs.writeFile).toHaveBeenCalled(); - }); - }); - - describe('Key Generation', () => { - test('should generate correct cache key format', () => { - const key = thresholdManager.generateKey('pve-main', 'node1', '100'); - expect(key).toBe('pve-main:100'); - }); - - test('should handle special characters in endpoint and vmid', () => { - const key = thresholdManager.generateKey('pve-test.local', 'node-1', 'ct-200'); - expect(key).toBe('pve-test.local:ct-200'); - }); - - test('should be consistent regardless of node parameter', () => { - const key1 = thresholdManager.generateKey('pve1', 'node1', '100'); - const key2 = thresholdManager.generateKey('pve1', 'node2', '100'); - expect(key1).toBe(key2); // Node migration support - }); - }); - - describe('Getting Thresholds', () => { - beforeEach(async () => { - fs.writeFile.mockResolvedValue(); - - // Set up cache with test data using the real API - await thresholdManager.setThresholds('pve1', 'node1', '100', { - cpu: { warning: 75, critical: 90 }, - memory: { warning: 85, critical: 95 } - }); - await thresholdManager.setThresholds('pve1', 'node1', '200', { - cpu: { warning: 60, critical: 80 }, - disk: { warning: 90, critical: 98 } - }); - }); - - test('should return custom thresholds when configured', () => { - const thresholds = thresholdManager.getThresholds('pve1', 'node1', '100'); - - expect(thresholds).not.toBeNull(); - expect(thresholds.thresholds.cpu.warning).toBe(75); - expect(thresholds.thresholds.cpu.critical).toBe(90); - expect(thresholds.thresholds.memory.warning).toBe(85); - }); - - test('should return null when no custom thresholds exist', () => { - const thresholds = thresholdManager.getThresholds('pve1', 'node1', '999'); - expect(thresholds).toBeNull(); - }); - - test('should return null for different endpoint', () => { - const thresholds = thresholdManager.getThresholds('pve2', 'node1', '100'); - expect(thresholds).toBeNull(); - }); - - test('should work regardless of node name due to migration support', () => { - const thresholds1 = thresholdManager.getThresholds('pve1', 'node1', '100'); - const thresholds2 = thresholdManager.getThresholds('pve1', 'node2', '100'); - - expect(thresholds1).toEqual(thresholds2); - }); - }); - - describe('Setting Thresholds', () => { - beforeEach(() => { - fs.writeFile.mockResolvedValue(); - }); - - test('should set valid threshold configuration', async () => { - const validThresholds = { - cpu: { warning: 70, critical: 85 }, - memory: { warning: 80, critical: 90 } - }; - - const result = await thresholdManager.setThresholds('pve1', 'node1', '300', validThresholds); - - expect(result).toBe(true); - const stored = thresholdManager.getThresholds('pve1', 'node1', '300'); - expect(stored).not.toBeNull(); - expect(stored.thresholds.cpu.warning).toBe(70); - expect(stored.createdAt).toBeDefined(); - expect(fs.writeFile).toHaveBeenCalled(); - }); - - test('should validate threshold values', async () => { - const invalidThresholds = { - cpu: { warning: 95, critical: 85 } // Warning higher than critical - }; - - await expect( - thresholdManager.setThresholds('pve1', 'node1', '400', invalidThresholds) - ).rejects.toThrow(/critical threshold must be greater than warning threshold/); - }); - - test('should reject thresholds outside valid range', async () => { - const outOfRangeThresholds = { - cpu: { warning: 150, critical: 200 } // Over 100% - }; - - await expect( - thresholdManager.setThresholds('pve1', 'node1', '500', outOfRangeThresholds) - ).rejects.toThrow(); - }); - - test('should handle partial thresholds gracefully', async () => { - const partialThresholds = { - cpu: { warning: 70, critical: 85 } - // memory and disk thresholds missing - }; - - const result = await thresholdManager.setThresholds('pve1', 'node1', '700', partialThresholds); - expect(result).toBe(true); - - const stored = thresholdManager.getThresholds('pve1', 'node1', '700'); - expect(stored.thresholds.cpu).toBeDefined(); - expect(stored.thresholds.memory).toBeUndefined(); - }); - - test('should update existing thresholds', async () => { - // Set initial thresholds - const initial = { - cpu: { warning: 70, critical: 85 } - }; - await thresholdManager.setThresholds('pve1', 'node1', '800', initial); - - // Update with new values - const updated = { - cpu: { warning: 75, critical: 90 }, - memory: { warning: 80, critical: 95 } - }; - await thresholdManager.setThresholds('pve1', 'node1', '800', updated); - - const stored = thresholdManager.getThresholds('pve1', 'node1', '800'); - expect(stored.thresholds.cpu.warning).toBe(75); - expect(stored.thresholds.memory.warning).toBe(80); - expect(fs.writeFile).toHaveBeenCalledTimes(2); - }); - }); - - describe('Removing Thresholds', () => { - beforeEach(async () => { - fs.writeFile.mockResolvedValue(); - - // Set up some test thresholds - await thresholdManager.setThresholds('pve1', 'node1', '100', { - cpu: { warning: 70, critical: 85 } - }); - await thresholdManager.setThresholds('pve1', 'node1', '200', { - memory: { warning: 80, critical: 90 } - }); - }); - - test('should remove existing threshold configuration', async () => { - expect(thresholdManager.getThresholds('pve1', 'node1', '100')).not.toBeNull(); - - const result = await thresholdManager.removeThresholds('pve1', 'node1', '100'); - - expect(result).toBe(true); - expect(thresholdManager.getThresholds('pve1', 'node1', '100')).toBeNull(); - expect(fs.writeFile).toHaveBeenCalled(); - }); - - test('should handle removal of non-existent thresholds gracefully', async () => { - const result = await thresholdManager.removeThresholds('pve1', 'node1', '999'); - expect(result).toBe(false); - }); - - test('should not affect other threshold configurations', async () => { - await thresholdManager.removeThresholds('pve1', 'node1', '100'); - - expect(thresholdManager.getThresholds('pve1', 'node1', '200')).not.toBeNull(); - }); - }); - - describe('File Operations', () => { - test('should handle file save errors gracefully', async () => { - const saveError = new Error('Disk full'); - fs.writeFile.mockRejectedValue(saveError); - - await expect( - thresholdManager.setThresholds('pve1', 'node1', '100', { - cpu: { warning: 70, critical: 85 } - }) - ).rejects.toThrow('Disk full'); - }); - - test('should create data directory if it does not exist', async () => { - fs.mkdir.mockResolvedValue(); - fs.readFile.mockResolvedValue('{}'); - - await thresholdManager.loadThresholds(); - - expect(fs.mkdir).toHaveBeenCalledWith( - path.dirname(mockConfigPath), - { recursive: true } - ); - }); - - test('should save thresholds in correct JSON format', async () => { - fs.writeFile.mockResolvedValue(); - - await thresholdManager.setThresholds('pve1', 'node1', '100', { - cpu: { warning: 70, critical: 85 } - }); - - const saveCall = fs.writeFile.mock.calls[0]; - expect(saveCall[0]).toBe(mockConfigPath); - expect(saveCall[2]).toBe('utf8'); - - const savedData = JSON.parse(saveCall[1]); - expect(savedData).toHaveProperty('pve1:100'); - }); - }); - - describe('Edge Cases and Error Handling', () => { - test('should handle empty threshold configuration', async () => { - fs.writeFile.mockResolvedValue(); - - const emptyThresholds = {}; - - const result = await thresholdManager.setThresholds('pve1', 'node1', '100', emptyThresholds); - expect(result).toBe(true); - - const stored = thresholdManager.getThresholds('pve1', 'node1', '100'); - expect(stored.createdAt).toBeDefined(); - }); - - test('should handle very large cache sizes', async () => { - fs.writeFile.mockResolvedValue(); - - // Add many threshold configurations - for (let i = 0; i < 100; i++) { - await thresholdManager.setThresholds('pve1', 'node1', String(i), { - cpu: { warning: 70, critical: 85 } - }); - } - - expect(thresholdManager.cache.size).toBe(100); - expect(thresholdManager.getThresholds('pve1', 'node1', '50')).not.toBeNull(); - }); - }); - - describe('Bulk Operations', () => { - test('should get all threshold configurations', async () => { - fs.writeFile.mockResolvedValue(); - - await thresholdManager.setThresholds('pve1', 'node1', '100', { - cpu: { warning: 70, critical: 85 } - }); - await thresholdManager.setThresholds('pve2', 'node1', '200', { - memory: { warning: 80, critical: 90 } - }); - - const allConfigs = thresholdManager.getAllThresholds(); - - expect(Array.isArray(allConfigs)).toBe(true); - expect(allConfigs.length).toBe(2); - expect(allConfigs.some(config => config.vmid === '100')).toBe(true); - expect(allConfigs.some(config => config.vmid === '200')).toBe(true); - }); - - test('should get thresholds by endpoint', async () => { - fs.writeFile.mockResolvedValue(); - - await thresholdManager.setThresholds('pve1', 'node1', '100', { - cpu: { warning: 70, critical: 85 } - }); - await thresholdManager.setThresholds('pve1', 'node1', '200', { - memory: { warning: 80, critical: 90 } - }); - await thresholdManager.setThresholds('pve2', 'node1', '300', { - cpu: { warning: 60, critical: 75 } - }); - - const pve1Configs = thresholdManager.getThresholdsByEndpoint('pve1'); - const pve2Configs = thresholdManager.getThresholdsByEndpoint('pve2'); - - expect(pve1Configs.length).toBe(2); - expect(pve2Configs.length).toBe(1); - expect(pve1Configs.every(config => config.endpointId === 'pve1')).toBe(true); - expect(pve2Configs.every(config => config.endpointId === 'pve2')).toBe(true); - }); - - test('should export threshold configurations', () => { - // Add some test data directly to cache - thresholdManager.cache.set('pve1:100', { - endpointId: 'pve1', - vmid: '100', - thresholds: { cpu: { warning: 70, critical: 85 } }, - createdAt: '2024-01-01T00:00:00.000Z' - }); - - const exported = thresholdManager.exportThresholds(); - - expect(exported).toHaveProperty('exportedAt'); - expect(exported).toHaveProperty('version'); - expect(exported.version).toBe('1.0'); - expect(exported.thresholds).toHaveLength(1); - expect(exported.thresholds[0].vmid).toBe('100'); - }); - - test('should get threshold statistics', async () => { - fs.writeFile.mockResolvedValue(); - - // Add multiple configurations - await thresholdManager.setThresholds('pve1', 'node1', '100', { - cpu: { warning: 70, critical: 85 } - }); - await thresholdManager.setThresholds('pve1', 'node1', '200', { - memory: { warning: 80, critical: 90 } - }); - - const stats = thresholdManager.getStatistics(); - - expect(stats).toHaveProperty('total'); - expect(stats).toHaveProperty('byEndpoint'); - expect(stats.total).toBe(2); - }); - }); - - describe('Threshold Management', () => { - test('should toggle threshold configurations', async () => { - fs.writeFile.mockResolvedValue(); - - // Set up a threshold configuration - await thresholdManager.setThresholds('pve1', 'node1', '100', { - cpu: { warning: 70, critical: 85 } - }); - - // Disable it - const result = await thresholdManager.toggleThresholds('pve1', 'node1', '100', false); - expect(result).toBe(true); - - const config = thresholdManager.getThresholds('pve1', 'node1', '100'); - expect(config.enabled).toBe(false); - expect(config.updatedAt).toBeDefined(); - - // Re-enable it - await thresholdManager.toggleThresholds('pve1', 'node1', '100', true); - const updatedConfig = thresholdManager.getThresholds('pve1', 'node1', '100'); - expect(updatedConfig.enabled).toBe(true); - }); - - test('should handle toggle for non-existent configuration', async () => { - await expect( - thresholdManager.toggleThresholds('pve1', 'node1', '999', true) - ).rejects.toThrow('Threshold configuration not found'); - }); - - test('should validate threshold values correctly', () => { - // Test CPU thresholds - const validCpuThresholds = { - cpu: { warning: 70, critical: 85 } - }; - const validated = thresholdManager.validateThresholds(validCpuThresholds); - expect(validated.cpu.warning).toBe(70); - expect(validated.cpu.critical).toBe(85); - - // Test invalid CPU thresholds (warning >= critical) - const invalidCpuThresholds = { - cpu: { warning: 90, critical: 85 } - }; - expect(() => { - thresholdManager.validateThresholds(invalidCpuThresholds); - }).toThrow('CPU critical threshold must be greater than warning threshold'); - }); - - test('should validate memory thresholds correctly', () => { - const validMemoryThresholds = { - memory: { warning: 80, critical: 95 } - }; - const validated = thresholdManager.validateThresholds(validMemoryThresholds); - expect(validated.memory.warning).toBe(80); - expect(validated.memory.critical).toBe(95); - - // Test invalid memory thresholds - const invalidMemoryThresholds = { - memory: { warning: 95, critical: 80 } - }; - expect(() => { - thresholdManager.validateThresholds(invalidMemoryThresholds); - }).toThrow('Memory critical threshold must be greater than warning threshold'); - }); - - test('should validate disk thresholds correctly', () => { - const validDiskThresholds = { - disk: { warning: 85, critical: 95 } - }; - const validated = thresholdManager.validateThresholds(validDiskThresholds); - expect(validated.disk.warning).toBe(85); - expect(validated.disk.critical).toBe(95); - - // Test invalid disk thresholds - const invalidDiskThresholds = { - disk: { warning: 98, critical: 90 } - }; - expect(() => { - thresholdManager.validateThresholds(invalidDiskThresholds); - }).toThrow('Disk critical threshold must be greater than warning threshold'); - }); - }); - - describe('Integration with Alert System', () => { - test('should store threshold configurations with proper structure', async () => { - fs.writeFile.mockResolvedValue(); - - await thresholdManager.setThresholds('pve1', 'node1', '100', { - cpu: { warning: 75, critical: 90 }, - memory: { warning: 80, critical: 95 } - }); - - const config = thresholdManager.getThresholds('pve1', 'node1', '100'); - - // Verify structure for alert system integration - expect(config).toHaveProperty('endpointId', 'pve1'); - expect(config).toHaveProperty('vmid', '100'); - expect(config).toHaveProperty('thresholds'); - expect(config).toHaveProperty('enabled', true); - expect(config).toHaveProperty('createdAt'); - expect(config).toHaveProperty('updatedAt'); - - // Verify threshold values are accessible - expect(config.thresholds.cpu.warning).toBe(75); - expect(config.thresholds.cpu.critical).toBe(90); - expect(config.thresholds.memory.warning).toBe(80); - expect(config.thresholds.memory.critical).toBe(95); - }); - - test('should handle partial threshold configurations', async () => { - fs.writeFile.mockResolvedValue(); - - // Set only CPU thresholds - await thresholdManager.setThresholds('pve1', 'node1', '200', { - cpu: { warning: 70, critical: 85 } - }); - - const config = thresholdManager.getThresholds('pve1', 'node1', '200'); - - expect(config.thresholds.cpu).toBeDefined(); - expect(config.thresholds.memory).toBeUndefined(); - expect(config.thresholds.disk).toBeUndefined(); - }); - }); -}); \ No newline at end of file diff --git a/server/tests/dataFetcher.test.js b/server/tests/dataFetcher.test.js deleted file mode 100644 index 4823e5c77..000000000 --- a/server/tests/dataFetcher.test.js +++ /dev/null @@ -1,1168 +0,0 @@ -const { fetchDiscoveryData, fetchMetricsData, fetchPbsData, clearCaches } = require('../dataFetcher'); -// Don't require the real apiClients, we will mock it -// const { initializeApiClients } = require('../apiClients'); - -// Mock the modules used by dataFetcher -jest.mock('axios'); // Keep this in case axios is used directly anywhere unexpected -jest.mock('../pbsUtils', () => ({ - // Ensure processPbsTasks returns the expected structure - processPbsTasks: jest.fn().mockReturnValue({ backupTasks: [], verifyTasks: [], gcTasks: [] }), -})); -jest.mock('../apiClients'); // <-- MOCK apiClients module - -// --- REMOVE Mock for fetchPbsData within dataFetcher --- -// jest.mock('../dataFetcher', ...); -// --- END REMOVE --- - -// Import the mocked version AFTER mocking it -const { initializeApiClients } = require('../apiClients'); - -process.env.NODE_ENV = 'test'; - - -describe('Data Fetcher', () => { - // --- Declare variables used across tests/hooks --- - let originalEnv; // <--- Declare here - let mockPveClientInstance; - let mockPveApiClient; - let mockPbsClientInstance; - let mockPbsApiClient; - // --- End declare vars --- - - // Helper to set up a basic PBS client mock (MOVED TO OUTER SCOPE) - const setupMockPbsClient = (id, configOverrides = {}, clientMocks = {}) => { - mockPbsClientInstance = { - get: jest.fn(), - ...clientMocks // Allow overriding .get or adding other methods - }; - // Use the mockPbsApiClient defined in the outer scope - mockPbsApiClient[id] = { - client: mockPbsClientInstance, - config: { - id: `${id}_config_id`, - name: `PBS Instance ${id}`, - host: `${id}.pbs.example.com`, - // Add other default config properties as needed - ...configOverrides - } - }; - return mockPbsClientInstance; // Return the mock instance for further configuration - }; - - beforeEach(() => { - // Store environment (assign to variable declared above) - originalEnv = { ...process.env }; - // Reset the mocked initializeApiClients function and other mocks - jest.clearAllMocks(); - - // Define the *default* return value for the mocked initializer - // Tests can override this if needed - mockPveClientInstance = { get: jest.fn() }; - mockPveApiClient = { - primary: { client: mockPveClientInstance, config: { /* ... */ } } - }; - mockPbsClientInstance = { get: jest.fn() }; - mockPbsApiClient = {}; - initializeApiClients.mockResolvedValue({ - apiClients: mockPveApiClient, - pbsApiClients: mockPbsApiClient - }); - - // --- Remove console mocks --- - // jest.spyOn(console, 'warn').mockImplementation(() => {}); - // jest.spyOn(console, 'log').mockImplementation(() => {}); - // jest.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterEach(() => { - // Clear caches to prevent test pollution - clearCaches(); - - // Restore environment (can now access originalEnv) - const currentEnvKeys = Object.keys(process.env); - currentEnvKeys.forEach(key => delete process.env[key]); - Object.keys(originalEnv).forEach(key => { process.env[key] = originalEnv[key]; }); - // --- Remove console restore --- - // jest.restoreAllMocks(); - }); - - describe('fetchDiscoveryData', () => { - test('should return empty structure when no PVE clients configured', async () => { - const mockPbsFunction = jest.fn().mockResolvedValue([]); - - const result = await fetchDiscoveryData({}, mockPbsApiClient, mockPbsFunction); - - expect(result.nodes).toEqual([]); - expect(result.vms).toEqual([]); - expect(result.containers).toEqual([]); - expect(result.pbs).toEqual([]); - expect(mockPbsFunction).toHaveBeenCalled(); - }); - - test('should fetch basic PVE cluster data successfully', async () => { - const mockClient = { - primary: { - client: { - get: jest.fn() - .mockResolvedValueOnce({ data: { data: [{ type: 'cluster', nodes: 1 }] } }) - .mockResolvedValue({ data: { data: [] } }) - }, - config: { id: 'primary', name: 'Primary PVE' } - } - }; - - const result = await fetchDiscoveryData(mockClient, {}); - - expect(result).toHaveProperty('nodes'); - expect(result).toHaveProperty('vms'); - expect(result).toHaveProperty('containers'); - expect(result).toHaveProperty('pbs'); - expect(result).toHaveProperty('pveBackups'); - expect(Array.isArray(result.nodes)).toBe(true); - expect(Array.isArray(result.vms)).toBe(true); - expect(Array.isArray(result.containers)).toBe(true); - }); - - test('should handle bad node storage data gracefully', async () => { - const mockClient = { - primary: { - client: { - get: jest.fn().mockResolvedValue({ data: { data: [] } }) - }, - config: { id: 'primary', name: 'Primary PVE' } - } - }; - - const result = await fetchDiscoveryData(mockClient, {}); - - expect(result).toHaveProperty('nodes'); - expect(Array.isArray(result.nodes)).toBe(true); - }); - - test('should handle missing node data gracefully', async () => { - const mockClient = { - primary: { - client: { - get: jest.fn().mockResolvedValue({ data: { data: null } }) - }, - config: { id: 'primary', name: 'Primary PVE' } - } - }; - - const result = await fetchDiscoveryData(mockClient, {}); - - expect(result).toEqual({ - nodes: [], - vms: [], - containers: [], - pbs: [], - pveBackups: expect.any(Object) - }); - }); - - test('should work with multiple PVE endpoints', async () => { - const mockClients = { - pve1: { - client: { - get: jest.fn().mockResolvedValue({ data: { data: [] } }) - }, - config: { id: 'pve1', name: 'PVE1' } - }, - pve2: { - client: { - get: jest.fn().mockRejectedValue(new Error('Network error')) - }, - config: { id: 'pve2', name: 'PVE2' } - } - }; - - const result = await fetchDiscoveryData(mockClients, {}); - - expect(result).toEqual({ - nodes: [], - vms: [], - containers: [], - pbs: [], - pveBackups: expect.any(Object) - }); - }); - - test('should handle API error when fetching guests for a specific node', async () => { - // Arrange: Uses the default mock clients from beforeEach - const nodeNameGood = 'node-good'; - const nodeNameBad = 'node-bad-guests'; - - // Use mockImplementation on the default mockPveClientInstance - mockPveClientInstance.get.mockImplementation(async (url) => { - console.log(`Mock API call: ${url}`); // Added for debugging - if (url === '/nodes') { - return { data: { data: [ - { node: nodeNameGood, status: 'online', id: `node/${nodeNameGood}` }, - { node: nodeNameBad, status: 'online', id: `node/${nodeNameBad}` } - ]}}; - } - if (url === `/nodes/${nodeNameGood}/status`) { - return { data: { data: { cpu: 0.1, uptime: 10 } } }; - } - if (url === `/nodes/${nodeNameGood}/storage`) { - return { data: { data: [] } }; - } - if (url === `/nodes/${nodeNameGood}/qemu`) { - return { data: { data: [ { vmid: 100, name: 'vm-good', status: 'running' } ] } }; - } - if (url === `/nodes/${nodeNameGood}/lxc`) { - return { data: { data: [] } }; - } - if (url === `/nodes/${nodeNameBad}/status`) { - return { data: { data: { cpu: 0.2, uptime: 20 } } }; - } - if (url === `/nodes/${nodeNameBad}/storage`) { - return { data: { data: [] } }; - } - if (url === `/nodes/${nodeNameBad}/qemu`) { - // Simulate API error for this specific call - throw new Error('Simulated API Error Fetching Guests'); - } - if (url === `/nodes/${nodeNameBad}/lxc`) { - return { data: { data: [] } }; // Successful but empty - } - // Default fallback for unexpected calls - throw new Error(`Unexpected API call in mock: ${url}`); - }); - - // Act: Uses the default mock clients from beforeEach - const result = await fetchDiscoveryData(mockPveApiClient, mockPbsApiClient); - - // Assert - // Check that the correct number of nodes is returned - expect(result.nodes).toHaveLength(2); - - // Find the nodes in the result - const goodNodeResult = result.nodes.find(n => n.node === nodeNameGood); - const badNodeResult = result.nodes.find(n => n.node === nodeNameBad); - - expect(goodNodeResult).toBeDefined(); - expect(badNodeResult).toBeDefined(); - - // Assertions for the node where all calls succeeded (nodeNameGood) - expect(goodNodeResult.cpu).toBe(0.1); // Should have CPU data from successful /status call - expect(goodNodeResult.status).toBe('online'); // Status updated by uptime > 0 - expect(goodNodeResult.vms).toBeUndefined(); // VMs/CTs are in the top-level result.vms/result.containers - - // Assertions for the node where /qemu failed (nodeNameBad) - // It should still have basic info from /nodes and status info from its successful /status call - expect(badNodeResult.cpu).toBe(0.2); // CPU data from its OWN successful /status call - expect(badNodeResult.status).toBe('online'); // Status updated by uptime > 0 - // It should not have contributed VMs/CTs because fetchDataForNode rejected - - // Assert overall VMs/Containers (only from the successful node) - expect(result.vms).toHaveLength(1); - expect(result.vms[0].vmid).toBe(100); // VM from nodeNameGood - expect(result.containers).toHaveLength(0); - - // Assert PBS is empty - expect(result.pbs).toEqual([]); - }); - - test('should integrate PVE and PBS data successfully', async () => { - const mockPveClient = { - primary: { - client: { - get: jest.fn().mockResolvedValue({ data: { data: [] } }) - }, - config: { id: 'primary', name: 'Primary PVE' } - } - }; - const mockPbsClient = { - 'pbs-1': { - client: { - get: jest.fn().mockResolvedValue({ data: { data: [] } }) - }, - config: { name: 'PBS Instance' } - } - }; - - const result = await fetchDiscoveryData(mockPveClient, mockPbsClient); - - expect(result).toHaveProperty('nodes'); - expect(result).toHaveProperty('vms'); - expect(result).toHaveProperty('containers'); - expect(result).toHaveProperty('pbs'); - expect(result).toHaveProperty('pveBackups'); - }); - - test('should handle errors from fetchPbsData gracefully', async () => { - // Arrange PVE (same simple mock as above) - const nodeName = 'pve-node'; - const vmId = 200; - mockPveClientInstance.get - .mockResolvedValueOnce({ data: { data: [{ node: nodeName, status: 'online' }] } }) // /nodes - .mockResolvedValueOnce({ data: { data: { uptime: 1 } } }) // status - .mockResolvedValueOnce({ data: { data: [] } }) // storage - .mockResolvedValueOnce({ data: { data: [{ vmid: vmId, name: 'pve-vm' }] } }) // qemu - .mockResolvedValueOnce({ data: { data: [] } }); // lxc - - // Arrange PBS (Mock the function to be injected) - const mockPbsFunction = jest.fn(); - const pbsError = new Error('PBS Connection Failed'); - // Revert to mockRejectedValue - mockPbsFunction.mockRejectedValue(pbsError); - const result = await fetchDiscoveryData(mockPveApiClient, mockPbsApiClient, mockPbsFunction); - - expect(result.nodes).toHaveLength(0); - expect(result.vms).toHaveLength(0); - expect(result.containers).toHaveLength(0); - expect(mockPbsFunction).toHaveBeenCalledWith(mockPbsApiClient); - expect(result.pbs).toEqual([]); - }); - - test('should work without PBS clients configured', async () => { - const mockPveClient = { - primary: { - client: { - get: jest.fn().mockResolvedValue({ data: { data: [] } }) - }, - config: { id: 'primary', name: 'Primary PVE' } - } - }; - - const result = await fetchDiscoveryData(mockPveClient, {}); - - expect(result).toHaveProperty('pbs'); - }); - - test('should handle error fetching Containers (lxc)', async () => { - // Arrange: Uses the default mock clients from beforeEach - const nodeNameGood = 'node-good'; - const nodeNameBad = 'node-bad-guests'; // This node will have the LXC fetch error - const endpointId = 'primary'; // Default endpointId from mockPveApiClient setup - mockPveClientInstance.get.mockImplementation(async (url) => { - if (url === '/cluster/status') { - return { data: { data: [{ type: 'cluster', nodes: 2, name: 'test-cluster' }] } }; - } - if (url === '/nodes') { - return { data: { data: [ - { node: nodeNameGood, status: 'online', id: `node/${nodeNameGood}` }, - { node: nodeNameBad, status: 'online', id: `node/${nodeNameBad}` } - ]}}; - } - if (url === `/nodes/${nodeNameGood}/status`) return { data: { data: { cpu: 0.1, uptime: 10 } } }; - if (url === `/nodes/${nodeNameGood}/storage`) return { data: { data: [] } }; - if (url === `/nodes/${nodeNameGood}/qemu`) return { data: { data: [ { vmid: 100, name: 'vm-good', status: 'running' } ] } }; - if (url === `/nodes/${nodeNameGood}/lxc`) return { data: { data: [] } }; - if (url === `/nodes/${nodeNameBad}/status`) return { data: { data: { cpu: 0.2, uptime: 20 } } }; - if (url === `/nodes/${nodeNameBad}/storage`) return { data: { data: [] } }; - if (url === `/nodes/${nodeNameBad}/qemu`) return { data: { data: [] } }; - if (url === `/nodes/${nodeNameBad}/lxc`) { - throw new Error('Simulated LXC Fetch Error'); - } - throw new Error(`Unexpected API call in mock: ${url}`); - }); - - const result = await fetchDiscoveryData(mockPveApiClient, mockPbsApiClient); - - expect(result.nodes).toHaveLength(2); - const goodNodeResult = result.nodes.find(n => n.node === nodeNameGood); - const badNodeResult = result.nodes.find(n => n.node === nodeNameBad); - expect(goodNodeResult).toBeDefined(); - expect(badNodeResult).toBeDefined(); - expect(result.vms).toHaveLength(1); - expect(result.vms[0].vmid).toBe(100); - expect(result.containers).toHaveLength(0); - }); - - test('should handle API failures gracefully', async () => { - // Test the behavior: when APIs fail, return empty results instead of crashing - const failingApiClient = { - primary: { - client: { - get: jest.fn().mockRejectedValue(new Error('API unavailable')) - }, - config: { id: 'primary', name: 'Primary PVE' } - } - }; - - const result = await fetchDiscoveryData(failingApiClient, {}); - - // Verify behavior: should return empty structure, not crash - expect(result).toEqual({ - nodes: [], - vms: [], - containers: [], - pbs: [], - pveBackups: expect.any(Object) - }); - }); - - test('should handle invalid node status data gracefully', async () => { - const mockClient = { - primary: { - client: { - get: jest.fn().mockResolvedValue({ data: { data: null } }) - }, - config: { id: 'primary', name: 'Primary PVE' } - } - }; - - const result = await fetchDiscoveryData(mockClient, {}); - - expect(result).toEqual({ - nodes: [], - vms: [], - containers: [], - pbs: [], - pveBackups: expect.any(Object) - }); - }); - - - test('should handle malformed API responses gracefully', async () => { - const invalidApiClient = { - primary: { - client: { - get: jest.fn().mockResolvedValue({ data: { data: 'invalid-format' } }) - }, - config: { id: 'primary', name: 'Primary PVE' } - } - }; - - const result = await fetchDiscoveryData(invalidApiClient, {}); - - expect(result).toEqual({ - nodes: [], - vms: [], - containers: [], - pbs: [], - pveBackups: expect.any(Object) - }); - }); - - test('should continue working when some endpoints fail', async () => { - const mixedClients = { - primary: { - client: { - get: jest.fn().mockResolvedValue({ data: { data: [] } }) - }, - config: { id: 'primary', name: 'Working PVE' } - }, - broken: null - }; - - const result = await fetchDiscoveryData(mixedClients, {}); - - expect(result).toEqual({ - nodes: [], - vms: [], - containers: [], - pbs: [], - pveBackups: expect.any(Object) - }); - }); - - test('should work without PBS clients', async () => { - const pveOnlyClient = { - primary: { - client: { - get: jest.fn().mockResolvedValue({ data: { data: [] } }) - }, - config: { id: 'primary', name: 'Primary PVE' } - } - }; - - const result = await fetchDiscoveryData(pveOnlyClient, null); - - expect(result.pbs).toEqual([]); - expect(result).toHaveProperty('nodes'); - expect(result).toHaveProperty('vms'); - expect(result).toHaveProperty('containers'); - }); - - }); - - // --- NEW: describe block for fetchMetricsData --- - describe('fetchMetricsData', () => { - // Note: This block now relies on mockPveApiClient and mockPveClientInstance - // set up in the main beforeEach of the outer describe block. - let mockCurrentApiClients; // Keep this structure locally if tests modify it - - beforeEach(() => { - // Reset only the client's get method, as the client itself is setup outside - mockPveClientInstance.get.mockClear(); - - // Use the mock PVE client setup in the outer scope. - // Tests within this block might add more clients (e.g., pve2) to this object. - mockCurrentApiClients = { ...mockPveApiClient }; - }); - - test('should return empty array when no running guests are provided', async () => { - const result = await fetchMetricsData([], [], mockCurrentApiClients); - expect(result).toEqual([]); - expect(mockPveClientInstance.get).not.toHaveBeenCalled(); // Use outer mock instance - }); - - test('should fetch VM metrics successfully', async () => { - const mockApiClients = { - 'primary': { - client: { - get: jest.fn().mockResolvedValue({ data: { data: [{ cpu: 0.5 }] } }) - }, - config: { name: 'Primary PVE' } - } - }; - - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 100, type: 'qemu', name: 'vm-test' } - ]; - - const result = await fetchMetricsData(runningVms, [], mockApiClients); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThanOrEqual(0); - }); - - test('should fetch container metrics successfully', async () => { - const mockApiClients = { - 'primary': { - client: { - get: jest.fn().mockResolvedValue({ data: { data: [{ cpu: 0.2 }] } }) - }, - config: { name: 'Primary PVE' } - } - }; - - const runningContainers = [ - { endpointId: 'primary', node: 'node2', vmid: 101, type: 'lxc', name: 'ct-test' } - ]; - - const result = await fetchMetricsData([], runningContainers, mockApiClients); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThanOrEqual(0); - }); - - test('should fetch metrics for multiple guests successfully', async () => { - const mockApiClients = { - 'primary': { - client: { - get: jest.fn().mockResolvedValue({ data: { data: [{ cpu: 0.1 }] } }) - }, - config: { name: 'Primary PVE' } - } - }; - - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 100, type: 'qemu', name: 'vm1' } - ]; - const runningContainers = [ - { endpointId: 'primary', node: 'node1', vmid: 101, type: 'lxc', name: 'ct1' } - ]; - - const result = await fetchMetricsData(runningVms, runningContainers, mockApiClients); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThanOrEqual(0); - }); - - test('should handle missing API client gracefully', async () => { - const mockApiClients = { - 'primary': { - client: { - get: jest.fn().mockResolvedValue({ data: { data: [{ cpu: 0.5 }] } }) - }, - config: { name: 'Primary PVE' } - } - }; - - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 100, type: 'qemu', name: 'vm-good' }, - { endpointId: 'missing', node: 'nodeX', vmid: 999, type: 'qemu', name: 'vm-bad' } - ]; - - const result = await fetchMetricsData(runningVms, [], mockApiClients); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThanOrEqual(0); - }); - - - test('should handle API error when fetching RRD data for one guest', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 100, type: 'qemu', name: 'vm-ok' }, - { endpointId: 'primary', node: 'node1', vmid: 101, type: 'qemu', name: 'vm-fail-rrd' } - ]; - - const error = new Error('RRD Fetch Failed'); - const endpointName = mockCurrentApiClients.primary.config.name || 'primary'; - - // Mock success for vm-ok - mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: [{ cpu: 0.1 }] } }); // rrd ok - mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: { cpu: 0.11 } } }); // current ok - - // Mock failure for vm-fail-rrd (RRD call fails, current call succeeds) - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - - expect(Array.isArray(result)).toBe(true); - }); - - test('should handle API error when fetching current status for one guest', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 100, type: 'qemu', name: 'vm-ok' }, - { endpointId: 'primary', node: 'node1', vmid: 101, type: 'qemu', name: 'vm-fail-current' } - ]; - - const error = new Error('Current Status Fetch Failed'); - mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: [{ cpu: 0.1 }] } }); - mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: { cpu: 0.11 } } }); - mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: [{ cpu: 0.2 }] } }); - mockPveClientInstance.get.mockRejectedValueOnce(error); - - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe(100); - }); - - test('should handle API 400 error gracefully (guest likely stopped)', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 100, type: 'qemu', name: 'vm-ok' }, - { endpointId: 'primary', node: 'node1', vmid: 101, type: 'qemu', name: 'vm-stopped' } - ]; - const endpointName = mockCurrentApiClients.primary.config.name || 'primary'; - - // Simulate a 400 error response - const error400 = new Error('Bad Request'); - error400.response = { status: 400 }; - - // Mock success for vm-ok - mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: [{ cpu: 0.1 }] } }); // rrd ok - mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: { cpu: 0.11 } } }); // current ok - - // Mock 400 failure for vm-stopped (assume RRD call fails first) - mockPveClientInstance.get.mockRejectedValueOnce(error400); // rrd fails with 400 - // The current status call for the failing guest might not even happen if RRD fails hard, - // but mock it just in case the error handling changes. Let's assume it would succeed if called. - mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: { cpu: 0.22 } } }); - - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe(100); - }); - - test('should handle empty RRD data array gracefully', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 100, type: 'qemu', name: 'vm-no-rrd-data' } - ]; - - // Mock RRD data response with empty data array - mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: [] } }); - // Mock current status response - mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: { cpu: 0.5, mem: 1024 } } }); - - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - const endpointName = mockCurrentApiClients.primary.config.name || 'primary'; - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - id: 100, - endpointName: endpointName, - data: [], // RRD data should be an empty array - current: { cpu: 0.5, mem: 1024 } - }); - }); - - test('should handle null current status data gracefully', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 100, type: 'qemu', name: 'vm-no-current-data' } - ]; - - // Mock RRD data response - mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: [{ time: 1, cpu: 0.5 }] } }); - // Mock current status response with null data - mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: null } }); - - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - const endpointName = mockCurrentApiClients.primary.config.name || 'primary'; - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - id: 100, - endpointName: endpointName, - data: [{ time: 1, cpu: 0.5 }], - current: null // Current data should be null - }); - }); - - // --- Tests for QEMU Guest Agent Memory Fetching --- - test('should fetch QEMU guest agent memory info when agent is enabled and responsive', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 100, type: 'qemu', name: 'vm-agent-ok', agent: '1' } - ]; - mockPveClientInstance.get - .mockResolvedValueOnce({ data: { data: [{ time: 1, cpu: 0.5 }] } }) // RRD - .mockResolvedValueOnce({ data: { data: { cpu: 0.5, mem: 2048*1024*1024, disk: 2048, agent: 1 } } }); // Current status (agent enabled) - - // Mock the POST call for guest agent - mockPveClientInstance.post = jest.fn().mockResolvedValueOnce({ - data: { - data: { - result: { total: 2048*1024*1024, free: 1024*1024*1024, available: 1536*1024*1024 } - } - } - }); - - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - expect(result).toHaveLength(1); - expect(result[0].current).toBeDefined(); - expect(result[0].current.guest_mem_total_bytes).toBe(2048*1024*1024); - expect(result[0].current.guest_mem_free_bytes).toBe(1024*1024*1024); - expect(result[0].current.guest_mem_available_bytes).toBe(1536*1024*1024); - expect(result[0].current.guest_mem_actual_used_bytes).toBe((2048-1536)*1024*1024); - expect(mockPveClientInstance.post).toHaveBeenCalledWith('/nodes/node1/qemu/100/agent/get-memory-block-info', {}); - }); - - test('should not attempt QEMU guest agent memory fetch if agent is not enabled in current status', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 101, type: 'qemu', name: 'vm-agent-off', agent: '1'} // Configured as on, but status says off - ]; - mockPveClientInstance.get - .mockResolvedValueOnce({ data: { data: [{ time: 1, cpu: 0.5 }] } }) // RRD - .mockResolvedValueOnce({ data: { data: { cpu: 0.5, mem: 1024, disk: 2048, agent: 0 } } }); // Current status (agent OFF) - mockPveClientInstance.post = jest.fn(); // Ensure post is a mock - - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - expect(result).toHaveLength(1); - expect(result[0].current.guest_mem_total_bytes).toBeUndefined(); - expect(mockPveClientInstance.post).not.toHaveBeenCalled(); - }); - - test('should not attempt QEMU guest agent memory fetch if guest agent config is missing/off', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 102, type: 'qemu', name: 'vm-agent-not-configured' } // No agent field - ]; - mockPveClientInstance.get - .mockResolvedValueOnce({ data: { data: [{ time: 1, cpu: 0.5 }] } }) // RRD - .mockResolvedValueOnce({ data: { data: { cpu: 0.5, mem: 1024, disk: 2048, agent: 1 } } }); // Current status (agent ON) - mockPveClientInstance.post = jest.fn(); - - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - expect(result).toHaveLength(1); - expect(result[0].current.guest_mem_total_bytes).toBeUndefined(); - expect(mockPveClientInstance.post).not.toHaveBeenCalled(); - }); - - test('should handle QEMU guest agent error (e.g., agent not responsive)', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 103, type: 'qemu', name: 'vm-agent-error', agent: 'enabled=1' } - ]; - mockPveClientInstance.get - .mockResolvedValueOnce({ data: { data: [{ time: 1, cpu: 0.5 }] } }) // RRD - .mockResolvedValueOnce({ data: { data: { cpu: 0.5, mem: 1024, disk: 2048, agent: 1 } } }); // Current status - - const agentError = new Error('Agent not responsive'); - agentError.response = { status: 500, data: { data: { exitcode: -2 } } }; - mockPveClientInstance.post = jest.fn().mockRejectedValueOnce(agentError); - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - expect(result).toHaveLength(1); - expect(result[0].current.guest_mem_total_bytes).toBeUndefined(); - }); - - test('should handle unexpected QEMU guest agent response format', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 104, type: 'qemu', name: 'vm-agent-bad-format', agent: '1' } - ]; - mockPveClientInstance.get - .mockResolvedValueOnce({ data: { data: [{ time: 1, cpu: 0.5 }] } }) // RRD - .mockResolvedValueOnce({ data: { data: { cpu: 0.5, mem: 1024, disk: 2048, agent: 1 } } }); // Current status - mockPveClientInstance.post = jest.fn().mockResolvedValueOnce({ data: { data: { result: { unexpected: "data" } } } }); - - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - expect(result).toHaveLength(1); - expect(result[0].current.guest_mem_total_bytes).toBeUndefined(); - }); - - test('should handle generic error fetching QEMU guest agent memory info', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 105, type: 'qemu', name: 'vm-agent-generic-error', agent: '1' } - ]; - mockPveClientInstance.get - .mockResolvedValueOnce({ data: { data: [{ time: 1, cpu: 0.5 }] } }) // RRD - .mockResolvedValueOnce({ data: { data: { cpu: 0.5, mem: 1024, disk: 2048, agent: 1 } } }); // Current status - - const genericAgentError = new Error('Network Failure'); - genericAgentError.response = { status: 503 }; // Simulate a non-500 error - mockPveClientInstance.post = jest.fn().mockRejectedValueOnce(genericAgentError); - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - expect(result).toHaveLength(1); - expect(result[0].current.guest_mem_total_bytes).toBeUndefined(); - }); - - test('should handle generic error fetching RRD/status data', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 106, type: 'qemu', name: 'vm-generic-rrd-error' } - ]; - const genericError = new Error('Server Unavailable'); - genericError.response = { status: 503 }; // Simulate non-400 error - - // Mock RRD call to fail with generic error, current status call to succeed - mockPveClientInstance.get - .mockRejectedValueOnce(genericError) // RRD fails - .mockResolvedValueOnce({ data: { data: { cpu: 0.1 } } }); // Current status succeeds - - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - - expect(result).toHaveLength(0); - expect(mockPveClientInstance.get).toHaveBeenCalledTimes(2); - }); - - test('should calculate actual used memory using fallback when "available" is missing', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 107, type: 'qemu', name: 'vm-agent-fallback-mem', agent: '1' } - ]; - const totalMem = 4096 * 1024 * 1024; - const freeMem = 1024 * 1024 * 1024; - const cachedMem = 512 * 1024 * 1024; - const buffersMem = 256 * 1024 * 1024; - const expectedUsed = totalMem - freeMem - cachedMem - buffersMem; - - mockPveClientInstance.get - .mockResolvedValueOnce({ data: { data: [{ time: 1, cpu: 0.5 }] } }) // RRD - .mockResolvedValueOnce({ data: { data: { cpu: 0.5, mem: totalMem, disk: 2048, agent: 1 } } }); // Current status - - mockPveClientInstance.post = jest.fn().mockResolvedValueOnce({ - data: { - data: { - // Agent response *without* 'available' field - result: { total: totalMem, free: freeMem, cached: cachedMem, buffers: buffersMem } - } - } - }); - - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - expect(result).toHaveLength(1); - expect(result[0].current).toBeDefined(); - expect(result[0].current.guest_mem_total_bytes).toBe(totalMem); - expect(result[0].current.guest_mem_free_bytes).toBe(freeMem); - expect(result[0].current.guest_mem_cached_bytes).toBe(cachedMem); - expect(result[0].current.guest_mem_buffers_bytes).toBe(buffersMem); - expect(result[0].current.guest_mem_available_bytes).toBeUndefined(); // Ensure 'available' was indeed missing - expect(result[0].current.guest_mem_actual_used_bytes).toBe(expectedUsed); // Check fallback calculation - }); - - test('should calculate actual used memory using final fallback (total - free) when other fields missing', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 108, type: 'qemu', name: 'vm-agent-final-fallback', agent: '1' } - ]; - const totalMem = 2048 * 1024 * 1024; - const freeMem = 512 * 1024 * 1024; - const expectedUsed = totalMem - freeMem; - - mockPveClientInstance.get - .mockResolvedValueOnce({ data: { data: [{ time: 1, cpu: 0.5 }] } }) // RRD - .mockResolvedValueOnce({ data: { data: { cpu: 0.5, mem: totalMem, disk: 2048, agent: 1 } } }); // Current status - - mockPveClientInstance.post = jest.fn().mockResolvedValueOnce({ - data: { - data: { - // Agent response *only* with total and free - result: { total: totalMem, free: freeMem } - } - } - }); - - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - expect(result).toHaveLength(1); - expect(result[0].current).toBeDefined(); - expect(result[0].current.guest_mem_total_bytes).toBe(totalMem); - expect(result[0].current.guest_mem_free_bytes).toBe(freeMem); - expect(result[0].current.guest_mem_available_bytes).toBeUndefined(); - expect(result[0].current.guest_mem_cached_bytes).toBeUndefined(); - expect(result[0].current.guest_mem_buffers_bytes).toBeUndefined(); - expect(result[0].current.guest_mem_actual_used_bytes).toBe(expectedUsed); // Check final fallback calculation - }); - - - }); // End describe fetchMetricsData - - // --- NEW: describe block for fetchPbsData --- - describe('fetchPbsData', () => { - // Relies on mockPbsApiClient and mockPbsClientInstance from outer describe - - beforeEach(() => { - // Ensure the default mocks are reset/cleared if needed for PBS specific tests - // Typically mockPbsClientInstance.get.mockClear() is sufficient if reusing the instance - mockPbsClientInstance.get.mockClear(); - - // Reset the default PBS mock to an empty object for clarity - mockPbsApiClient = {}; - // Override the initializer mock if tests need specific PBS clients setup via initializeApiClients - // Otherwise, tests will construct and pass mock PBS clients directly - }); - - test('should return empty array when no PBS clients are provided', async () => { - const result = await fetchPbsData({}); - expect(result).toEqual([]); - }); - - test('should fetch PBS data successfully', async () => { - const mockClients = { - 'pbs-1': { - client: { - get: jest.fn() - .mockResolvedValueOnce({ data: { data: [{ node: 'pbs-node' }] } }) - .mockResolvedValue({ data: { data: [] } }) - }, - config: { name: 'PBS Instance' } - } - }; - - const result = await fetchPbsData(mockClients); - - expect(result).toHaveLength(1); - expect(result[0]).toHaveProperty('pbsEndpointId'); - expect(result[0]).toHaveProperty('pbsInstanceName'); - expect(result[0]).toHaveProperty('status'); - }); - - test('should handle error fetching PBS node name (and skip subsequent calls)', async () => { - const pbsId = 'pbs-err-node'; - const mockPbsClient = { get: jest.fn() }; - const mockClients = { [pbsId]: { client: mockPbsClient, config: { name: 'PBS Node Err' } } }; - const nodeError = new Error('Node fetch failed'); - - // Mock /nodes to fail - mockPbsClient.get.mockRejectedValueOnce(nodeError); - - const result = await fetchPbsData(mockClients); - - expect(result).toHaveLength(1); - expect(result[0].status).toBe('error'); - expect(result[0].datastores).toBeUndefined(); - expect(result[0].backupTasks).toBeUndefined(); - expect(mockPbsClient.get).toHaveBeenCalledTimes(1); - expect(mockPbsClient.get).toHaveBeenCalledWith('/nodes'); - }); - - test('should handle error fetching PBS datastores', async () => { - // Arrange - const pbsId = 'pbs-err-ds'; - const pbsNodeName = 'pbs-node-ds-err'; - const mockPbsClient = { get: jest.fn() }; - const mockClients = { [pbsId]: { client: mockPbsClient, config: { name: 'PBS DS Err' } } }; - const dsError = new Error('Datastore fetch failed'); - - mockPbsClient.get - .mockResolvedValueOnce({ data: { data: [{ node: pbsNodeName }] } }) // /nodes (succeeds) - .mockRejectedValueOnce(dsError) // /status/datastore-usage (fails) - .mockResolvedValueOnce({ data: { data: [] } }) // Mock fallback /config/datastore call (returns empty) - .mockRejectedValueOnce(new Error('Dedup fetch failed')) // /status/datastore-usage in fetchAllPbsTasksForProcessing (fails) - .mockResolvedValueOnce({ data: { data: [] } }) // /config/datastore in fetchAllPbsTasksForProcessing (empty) - .mockResolvedValueOnce({ data: { data: [] } }); // /nodes/{node}/tasks (empty tasks) - - const result = await fetchPbsData(mockClients); - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - pbsEndpointId: pbsId, - status: 'ok', - nodeName: pbsNodeName, - datastores: [], - }); - expect(mockPbsClient.get).toHaveBeenCalledTimes(6); - }); - - test('should handle partial datastore failures', async () => { - const mockClients = { - 'pbs-1': { - client: { - get: jest.fn() - .mockResolvedValueOnce({ data: { data: [{ node: 'node1' }] } }) - .mockResolvedValueOnce({ data: { data: [{ store: 'ds1' }] } }) - .mockRejectedValueOnce(new Error('Snapshot fetch failed')) - }, - config: { name: 'PBS 1' } - } - }; - - const result = await fetchPbsData(mockClients); - - expect(result).toHaveLength(1); - expect(result[0].status).toBe('ok'); - }); - - test('should handle PBS task fetch failures', async () => { - const mockClients = { - 'pbs-1': { - client: { - get: jest.fn() - .mockResolvedValueOnce({ data: { data: [{ node: 'pbs-node' }] } }) - .mockRejectedValue(new Error('Task fetch failed')) - }, - config: { name: 'PBS Instance' } - } - }; - - const result = await fetchPbsData(mockClients); - - expect(result).toHaveLength(1); - expect(result[0]).toHaveProperty('status'); - }); - - test('should handle multiple PBS instances with mixed results', async () => { - const mockClients = { - 'pbs-1': { - client: { - get: jest.fn() - .mockResolvedValueOnce({ data: { data: [{ node: 'node1' }] } }) - .mockResolvedValue({ data: { data: [] } }) - }, - config: { name: 'PBS 1' } - }, - 'pbs-2': { - client: { - get: jest.fn().mockRejectedValue(new Error('Connection failed')) - }, - config: { name: 'PBS 2' } - } - }; - - const result = await fetchPbsData(mockClients); - - expect(result).toHaveLength(2); - expect(result.some(r => r.status === 'ok')).toBe(true); - expect(result.some(r => r.status === 'error')).toBe(true); - }); - - test('should return error status and log warnings if /nodes response is invalid (e.g. empty array)', async () => { - // Arrange - const pbsId = 'pbs-bad-nodes'; - const mockPbsBadNodesClient = { get: jest.fn() }; - const mockPbsBadNodesApiClients = { - [pbsId]: { client: mockPbsBadNodesClient, config: { id: pbsId, name: 'PBS Bad Nodes' } } - }; - mockPbsBadNodesClient.get.mockResolvedValueOnce({ data: { data: [] } }); - - const result = await fetchPbsData(mockPbsBadNodesApiClients); - - expect(mockPbsBadNodesClient.get).toHaveBeenCalledTimes(1); - expect(mockPbsBadNodesClient.get).toHaveBeenCalledWith('/nodes'); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - pbsEndpointId: pbsId, - pbsInstanceName: 'PBS Bad Nodes', - status: 'error' - }); - }); - - test('should handle empty datastore usage with fallback', async () => { - const mockClients = { - 'pbs-1': { - client: { - get: jest.fn() - .mockResolvedValueOnce({ data: { data: [{ node: 'pbs-node' }] } }) - .mockResolvedValueOnce({ data: { data: [] } }) - .mockResolvedValue({ data: { data: [] } }) - }, - config: { name: 'PBS Instance' } - } - }; - - const result = await fetchPbsData(mockClients); - - expect(result).toHaveLength(1); - expect(result[0]).toHaveProperty('status'); - }); - - test('should handle error fetching datastore usage', async () => { - // Arrange - const pbsId = 'pbs-err-ds'; - const pbsNodeName = 'pbs-node-ds-err'; - const mockPbsClient = { get: jest.fn() }; - const mockClients = { [pbsId]: { client: mockPbsClient, config: { name: 'PBS DS Err' } } }; - const dsError = new Error('Datastore fetch failed'); - - mockPbsClient.get - .mockResolvedValueOnce({ data: { data: [{ node: pbsNodeName }] } }) // /nodes (succeeds) - .mockRejectedValueOnce(dsError) // /status/datastore-usage (fails) - .mockResolvedValueOnce({ data: { data: [] }}) // Mock fallback /config/datastore call (returns empty) - .mockRejectedValueOnce(new Error('Dedup fetch failed')) // /status/datastore-usage in fetchAllPbsTasksForProcessing (fails) - .mockResolvedValueOnce({ data: { data: [] }}) // /config/datastore in fetchAllPbsTasksForProcessing (empty) - .mockResolvedValueOnce({ data: { data: [] }}); // /nodes/{node}/tasks (empty tasks) - - const result = await fetchPbsData(mockClients); - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - pbsEndpointId: pbsId, - status: 'ok', - nodeName: pbsNodeName, - datastores: [], - }); - expect(mockPbsClient.get).toHaveBeenCalledTimes(6); - }); - - test('should handle failure of both datastore usage and config fetch', async () => { - // Arrange - const pbsId = 'pbs-double-ds-fail'; - const pbsNodeName = 'pbs-node-double-fail'; - const mockPbsClient = { get: jest.fn() }; - const mockClients = { [pbsId]: { client: mockPbsClient, config: { name: 'PBS Double DS Fail' } } }; - const usageError = new Error('Usage API Failed'); - const configError = new Error('Config API Failed'); - - mockPbsClient.get - .mockResolvedValueOnce({ data: { data: [{ node: pbsNodeName }] } }) - .mockRejectedValueOnce(usageError) - .mockRejectedValueOnce(configError) - .mockRejectedValueOnce(new Error('Dedup fetch failed')) - .mockResolvedValueOnce({ data: { data: [] } }) - .mockResolvedValueOnce({ data: { data: [{ upid: 'task1' }] } }); - - const result = await fetchPbsData(mockClients); - - expect(mockPbsClient.get).toHaveBeenCalledWith('/status/datastore-usage'); - expect(mockPbsClient.get).toHaveBeenCalledWith('/config/datastore'); - expect(mockPbsClient.get).toHaveBeenCalledTimes(6); - const callsDoubleFailTest = mockPbsClient.get.mock.calls; - expect(callsDoubleFailTest[5][0]).toBe(`/nodes/${pbsNodeName}/tasks`); - expect(result).toHaveLength(1); - expect(result[0].status).toBe('ok'); - expect(result[0].datastores).toEqual([]); - }); - - test('should handle API error when fetching PBS tasks', async () => { - const pbsId = 'pbs-task-fetch-error'; - const pbsNodeName = 'pbs-node-task-error'; - const datastoreName = 'store-task-error'; - const mockPbsClient = { get: jest.fn() }; - const mockClients = { [pbsId]: { client: mockPbsClient, config: { name: 'PBS Task Fetch Error' } } }; - const taskError = new Error('Simulated task fetch error'); - - mockPbsClient.get - .mockResolvedValueOnce({ data: { data: [{ node: pbsNodeName }] } }) // /nodes (succeeds) - .mockResolvedValueOnce({ data: { data: [{ store: datastoreName, total: 1, used: 0 }] } }) // /status/datastore-usage (succeeds) - .mockResolvedValueOnce({ data: { data: [] } }) // Snapshots (succeeds empty) - .mockResolvedValueOnce({ data: { data: [{ name: datastoreName }] } }) // /config/datastore in fetchAllPbsTasksForProcessing - .mockResolvedValueOnce({ data: { data: [] } }) // /admin/datastore/{store}/groups (empty) - .mockRejectedValueOnce(taskError); // /nodes/{node}/tasks (FAILS) - - const result = await fetchPbsData(mockClients); - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - pbsEndpointId: pbsId, - pbsInstanceName: 'PBS Task Fetch Error', - status: 'ok', - nodeName: pbsNodeName, - datastores: [{ name: datastoreName, total: 1, used: 0, available: undefined, gcStatus: 'unknown' , snapshots: []}], - }); - expect(result[0]).toHaveProperty('backupTasks'); - expect(result[0]).toHaveProperty('verifyTasks'); - expect(result[0]).toHaveProperty('gcTasks'); - expect(mockPbsClient.get).toHaveBeenCalledTimes(6); - expect(mockPbsClient.get).toHaveBeenCalledWith(`/nodes/${pbsNodeName}/tasks`, expect.any(Object)); - }); - - }); // End describe fetchPbsData - -}); // End describe Data Fetcher diff --git a/server/tests/dnsResolver.test.js b/server/tests/dnsResolver.test.js deleted file mode 100644 index f31f3c0e1..000000000 --- a/server/tests/dnsResolver.test.js +++ /dev/null @@ -1,123 +0,0 @@ -const dnsResolver = require('../dnsResolver'); -const dns = require('dns').promises; - -// Mock the dns module -jest.mock('dns', () => ({ - promises: { - resolve4: jest.fn(), - resolve6: jest.fn() - } -})); - -// Mock the util.promisify -jest.mock('util', () => ({ - promisify: () => jest.fn() -})); - -describe('DnsResolver', () => { - beforeEach(() => { - // Clear all mocks and caches - jest.clearAllMocks(); - dnsResolver.clearCache(); - }); - - describe('resolveHostname', () => { - it('should resolve hostname to IP addresses', async () => { - const mockIPs = ['192.168.1.10', '192.168.1.11', '192.168.1.12']; - dns.resolve4.mockResolvedValue(mockIPs); - dns.resolve6.mockResolvedValue([]); - - const result = await dnsResolver.resolveHostname('proxmox.lan'); - - expect(result).toEqual(mockIPs); - expect(dns.resolve4).toHaveBeenCalledWith('proxmox.lan'); - }); - - it('should cache DNS results', async () => { - const mockIPs = ['192.168.1.10']; - dns.resolve4.mockResolvedValue(mockIPs); - dns.resolve6.mockResolvedValue([]); - - // First call - await dnsResolver.resolveHostname('test.lan'); - expect(dns.resolve4).toHaveBeenCalledTimes(1); - - // Second call should use cache - await dnsResolver.resolveHostname('test.lan'); - expect(dns.resolve4).toHaveBeenCalledTimes(1); // Still only called once - }); - - it('should filter out failed IPs', async () => { - const mockIPs = ['192.168.1.10', '192.168.1.11', '192.168.1.12']; - dns.resolve4.mockResolvedValue(mockIPs); - dns.resolve6.mockResolvedValue([]); - - // Mark one IP as failed - dnsResolver.markHostFailed('192.168.1.11'); - - const result = await dnsResolver.resolveHostname('proxmox.lan'); - - expect(result).toEqual(['192.168.1.10', '192.168.1.12']); - expect(result).not.toContain('192.168.1.11'); - }); - - it('should handle DNS resolution failures gracefully', async () => { - dns.resolve4.mockRejectedValue(new Error('DNS resolution failed')); - dns.resolve6.mockRejectedValue(new Error('DNS resolution failed')); - - // Mock lookup to also fail - const lookup = require('util').promisify(); - lookup.mockRejectedValue(new Error('Lookup failed')); - - await expect(dnsResolver.resolveHostname('invalid.lan')) - .rejects.toThrow('No IP addresses found'); - }); - }); - - describe('markHostFailed and isHostFailed', () => { - it('should mark host as failed temporarily', async () => { - const testIP = '192.168.1.10'; - - expect(dnsResolver.isHostFailed(testIP)).toBe(false); - - dnsResolver.markHostFailed(testIP); - expect(dnsResolver.isHostFailed(testIP)).toBe(true); - }); - }); - - describe('extractHostname', () => { - it('should extract hostname from various URL formats', () => { - const testCases = [ - { input: 'https://proxmox.lan:8006', expected: 'proxmox.lan' }, - { input: 'http://test.local:3000/path', expected: 'test.local' }, - { input: 'server.domain:8080', expected: 'server.domain' }, - { input: 'simple-hostname', expected: 'simple-hostname' } - ]; - - testCases.forEach(({ input, expected }) => { - expect(dnsResolver.extractHostname(input)).toBe(expected); - }); - }); - }); - - describe('canResolve', () => { - it('should return true for resolvable hostnames', async () => { - dns.resolve4.mockResolvedValue(['192.168.1.10']); - dns.resolve6.mockResolvedValue([]); - - const result = await dnsResolver.canResolve('valid.lan'); - expect(result).toBe(true); - }); - - it('should return false for unresolvable hostnames', async () => { - dns.resolve4.mockRejectedValue(new Error('Not found')); - dns.resolve6.mockRejectedValue(new Error('Not found')); - - const lookup = require('util').promisify(); - lookup.mockRejectedValue(new Error('Not found')); - - const result = await dnsResolver.canResolve('invalid.lan'); - expect(result).toBe(false); - }); - }); -}); \ No newline at end of file diff --git a/server/tests/integration.test.js b/server/tests/integration.test.js deleted file mode 100644 index 85d29a29d..000000000 --- a/server/tests/integration.test.js +++ /dev/null @@ -1,803 +0,0 @@ -/** - * Integration Tests for Pulse Monitoring System - * Tests end-to-end workflows and component interactions - */ - -// Mock external dependencies -jest.mock('axios'); -jest.mock('fs', () => ({ - promises: { - mkdir: jest.fn(), - readFile: jest.fn(), - writeFile: jest.fn() - } -})); - -const axios = require('axios'); -const fs = require('fs').promises; -const { fetchDiscoveryData, fetchMetricsData, fetchPbsData, clearCaches } = require('../dataFetcher'); -const { initializeApiClients } = require('../apiClients'); -const { loadConfiguration } = require('../configLoader'); -const AlertManager = require('../alertManager'); -const customThresholds = require('../customThresholds'); - -// Mock console to reduce test noise -jest.spyOn(console, 'log').mockImplementation(() => {}); -jest.spyOn(console, 'warn').mockImplementation(() => {}); -jest.spyOn(console, 'error').mockImplementation(() => {}); - -describe('Pulse Integration Tests', () => { - let originalEnv; - let mockApiClients; - let mockPbsApiClients; - let alertManager; - - beforeEach(() => { - originalEnv = { ...process.env }; - jest.clearAllMocks(); - - // Mock file operations - fs.mkdir.mockResolvedValue(); - fs.readFile.mockResolvedValue('{}'); - fs.writeFile.mockResolvedValue(); - - // Set up mock API clients - mockApiClients = { - 'pve-main': { - client: { - get: jest.fn(), - post: jest.fn() - }, - config: { - id: 'pve-main', - name: 'Main PVE Cluster', - host: 'pve.example.com', - tokenId: 'test@pve!test', - tokenSecret: 'test-secret' - } - } - }; - - mockPbsApiClients = { - 'pbs-main': { - client: { - get: jest.fn(), - post: jest.fn() - }, - config: { - id: 'pbs-main', - name: 'Main PBS Server', - host: 'pbs.example.com' - } - } - }; - - // Initialize AlertManager for testing - alertManager = new AlertManager(); - - // Clear custom thresholds cache - customThresholds.cache.clear(); - }); - - afterEach(() => { - // Restore environment - Object.keys(process.env).forEach(key => delete process.env[key]); - Object.keys(originalEnv).forEach(key => { - process.env[key] = originalEnv[key]; - }); - - // Cleanup AlertManager - if (alertManager) { - alertManager.destroy(); - } - - customThresholds.cache.clear(); - }); - - describe('Complete Monitoring Workflow', () => { - test('should perform full discovery -> metrics -> alerting cycle', async () => { - // === STEP 1: Discovery Phase === - mockApiClients['pve-main'].client.get.mockImplementation((path) => { - if (path === '/cluster/status') { - return Promise.resolve({ - data: { - data: [ - { type: 'cluster', name: 'test-cluster', nodes: 2 }, - { type: 'node', name: 'node1', ip: '192.168.1.10' }, - { type: 'node', name: 'node2', ip: '192.168.1.11' } - ] - } - }); - } - if (path === '/nodes') { - return Promise.resolve({ - data: { - data: [ - { node: 'node1', status: 'online' }, - { node: 'node2', status: 'online' } - ] - } - }); - } - if (path.includes('/qemu')) { - if (path.includes('node1')) { - return Promise.resolve({ - data: { - data: [ - { vmid: 100, name: 'web-server', status: 'running' }, - { vmid: 101, name: 'database', status: 'running' } - ] - } - }); - } - return Promise.resolve({ data: { data: [] } }); - } - if (path.includes('/lxc')) { - if (path.includes('node2')) { - return Promise.resolve({ - data: { - data: [ - { vmid: 200, name: 'nginx-proxy', status: 'running' }, - { vmid: 201, name: 'monitoring', status: 'running' } - ] - } - }); - } - return Promise.resolve({ data: { data: [] } }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - const discoveryData = await fetchDiscoveryData(mockApiClients, mockPbsApiClients); - - // Verify discovery results - expect(discoveryData.nodes).toHaveLength(2); - expect(discoveryData.vms).toHaveLength(2); - expect(discoveryData.containers).toHaveLength(2); - expect(discoveryData.vms.some(vm => vm.vmid === 100)).toBe(true); - expect(discoveryData.containers.some(ct => ct.vmid === 200)).toBe(true); - - // === STEP 2: Metrics Collection === - const runningGuests = [ - ...discoveryData.vms.filter(vm => vm.status === 'running'), - ...discoveryData.containers.filter(ct => ct.status === 'running') - ]; - - // Mock RRD and current status responses - let callCount = 0; - mockApiClients['pve-main'].client.get.mockImplementation((path) => { - if (path.includes('/rrddata')) { - const now = Math.floor(Date.now() / 1000); - return Promise.resolve({ - data: { - data: [ - { time: now - 300, cpu: 0.85, memory: 0.75, netin: 1000, netout: 2000 }, - { time: now - 240, cpu: 0.92, memory: 0.78, netin: 1100, netout: 2100 }, - { time: now - 180, cpu: 0.88, memory: 0.82, netin: 1200, netout: 2200 } - ] - } - }); - } - if (path.includes('/status')) { - callCount++; - // Return high CPU for some guests to trigger alerts - const highCpu = callCount <= 2; // First two guests get high CPU - return Promise.resolve({ - data: { - data: { - cpu: highCpu ? 0.95 : 0.45, // 95% vs 45% - mem: 2147483648, // 2GB in bytes - disk: 10737418240, // 10GB in bytes - netin: 1500, - netout: 2500 - } - } - }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - const metricsData = await fetchMetricsData( - discoveryData.vms.filter(vm => vm.status === 'running'), - discoveryData.containers.filter(ct => ct.status === 'running'), - mockApiClients - ); - - // Verify metrics collection - expect(metricsData).toHaveLength(4); // All running guests - expect(metricsData.every(m => m.current)).toBe(true); - expect(metricsData.every(m => Array.isArray(m.data))).toBe(true); - - // === STEP 3: Alert Processing === - const triggeredAlerts = alertManager.processMetrics(metricsData); - - // Should trigger alerts for high CPU guests - const highCpuGuests = metricsData.filter(m => m.current.cpu > 0.90); - expect(highCpuGuests.length).toBeGreaterThan(0); - - console.log(`Integration test: Found ${highCpuGuests.length} guests with high CPU, ${triggeredAlerts.length} alerts triggered`); - }); - - test('should handle custom thresholds in monitoring workflow', async () => { - // === STEP 1: Set custom thresholds === - await customThresholds.setThresholds('pve-main', 'node1', '100', { - cpu: { warning: 60, critical: 80 }, // Lower than defaults - memory: { warning: 70, critical: 90 } - }); - - // === STEP 2: Mock guest with moderate CPU (would normally be OK) === - mockApiClients['pve-main'].client.get.mockImplementation((path) => { - if (path.includes('/status')) { - return Promise.resolve({ - data: { - data: { - cpu: 0.75, // 75% - exceeds custom warning (60%) but not default (85%) - mem: 1073741824, // 1GB - disk: 5368709120 // 5GB - } - } - }); - } - if (path.includes('/rrddata')) { - return Promise.resolve({ - data: { data: [{ time: Date.now() / 1000, cpu: 0.75 }] } - }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - const testGuest = { - id: 100, - endpointId: 'pve-main', - node: 'node1', - vmid: '100', - type: 'qemu', - name: 'test-vm', - status: 'running' - }; - - const metricsData = await fetchMetricsData([testGuest], [], mockApiClients); - - // === STEP 3: Verify custom threshold integration === - const guestMetrics = metricsData[0]; - expect(guestMetrics.current.cpu).toBe(0.75); - - // Get custom thresholds for this guest - const customConfig = customThresholds.getThresholds('pve-main', 'node1', '100'); - expect(customConfig).not.toBeNull(); - expect(customConfig.thresholds.cpu.warning).toBe(60); // 60% - expect(customConfig.thresholds.cpu.critical).toBe(80); // 80% - - // This guest should trigger a warning with custom thresholds - // (75% > 60% warning threshold) - expect(guestMetrics.current.cpu * 100).toBeGreaterThan(customConfig.thresholds.cpu.warning); - expect(guestMetrics.current.cpu * 100).toBeLessThan(customConfig.thresholds.cpu.critical); - }); - }); - - describe('PBS Integration Workflow', () => { - test('should discover PBS data and correlate with PVE guests', async () => { - // === STEP 1: Mock PBS discovery === - mockPbsApiClients['pbs-main'].client.get.mockImplementation((path) => { - if (path === '/nodes') { - return Promise.resolve({ - data: { data: [{ node: 'pbs-node' }] } - }); - } - if (path === '/config/datastore') { - return Promise.resolve({ - data: { data: [{ name: 'main-store' }] } - }); - } - if (path.includes('/admin/datastore/main-store/snapshots')) { - const now = Math.floor(Date.now() / 1000); - return Promise.resolve({ - data: { - data: [ - { - 'backup-time': now - 3600, // 1 hour ago - 'backup-type': 'vm', - 'backup-id': '100', - 'backup-group': 'vm/100', - size: 1073741824 // 1GB - }, - { - 'backup-time': now - 7200, // 2 hours ago - 'backup-type': 'ct', - 'backup-id': '200', - 'backup-group': 'ct/200', - size: 536870912 // 512MB - } - ] - } - }); - } - if (path.includes('/status/datastore-usage')) { - return Promise.resolve({ - data: { - data: [{ - store: 'main-store', - total: 107374182400, // 100GB - used: 1610612736, // 1.5GB - avail: 105763569664 // 98.5GB - }] - } - }); - } - if (path.includes('/tasks')) { - const now = Math.floor(Date.now() / 1000); - return Promise.resolve({ - data: { - data: [ - { - upid: 'backup-task-1', - type: 'backup', - worker_type: 'backup', - status: 'OK', - starttime: now - 3900, // Started ~1.1 hours ago - endtime: now - 3600, // Ended 1 hour ago - worker_id: 'vm/100' - }, - { - upid: 'verify-task-1', - type: 'verify', - worker_type: 'verify', - status: 'OK', - starttime: now - 1800, - endtime: now - 1500 - } - ] - } - }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - // === STEP 2: Mock PVE discovery === - mockApiClients['pve-main'].client.get.mockImplementation((path) => { - if (path === '/nodes') { - return Promise.resolve({ - data: { data: [{ node: 'pve-node', status: 'online' }] } - }); - } - if (path.includes('/qemu')) { - return Promise.resolve({ - data: { - data: [ - { vmid: 100, name: 'web-server', status: 'running' } - ] - } - }); - } - if (path.includes('/lxc')) { - return Promise.resolve({ - data: { - data: [ - { vmid: 200, name: 'proxy', status: 'running' } - ] - } - }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - // === STEP 3: Execute integrated discovery === - const [discoveryData, pbsData] = await Promise.all([ - fetchDiscoveryData(mockApiClients, mockPbsApiClients), - fetchPbsData(mockPbsApiClients) - ]); - - // === STEP 4: Verify PBS-PVE correlation === - expect(pbsData).toHaveLength(1); - expect(pbsData[0].datastores).toHaveLength(1); - expect(pbsData[0].datastores[0].snapshots).toHaveLength(2); - - const vm100Backup = pbsData[0].datastores[0].snapshots.find( - s => s['backup-id'] === '100' && s['backup-type'] === 'vm' - ); - const ct200Backup = pbsData[0].datastores[0].snapshots.find( - s => s['backup-id'] === '200' && s['backup-type'] === 'ct' - ); - - expect(vm100Backup).toBeDefined(); - expect(ct200Backup).toBeDefined(); - - // Verify we can correlate backups with discovered guests - const discoveredVm100 = discoveryData.vms.find(vm => vm.vmid === 100); - const discoveredCt200 = discoveryData.containers.find(ct => ct.vmid === 200); - - expect(discoveredVm100).toBeDefined(); - expect(discoveredCt200).toBeDefined(); - - // Calculate backup ages - const now = Date.now() / 1000; - const vm100BackupAge = now - vm100Backup['backup-time']; - const ct200BackupAge = now - ct200Backup['backup-time']; - - expect(vm100BackupAge).toBeLessThan(2 * 3600); // Less than 2 hours - expect(ct200BackupAge).toBeLessThan(3 * 3600); // Less than 3 hours - - console.log(`Integration test: VM 100 backup age: ${Math.round(vm100BackupAge / 60)} minutes`); - console.log(`Integration test: CT 200 backup age: ${Math.round(ct200BackupAge / 60)} minutes`); - }); - }); - - describe('Error Recovery and Resilience', () => { - test('should handle partial API failures gracefully', async () => { - // === STEP 1: Configure mixed success/failure scenarios === - mockApiClients['pve-main'].client.get.mockImplementation((path) => { - if (path === '/nodes') { - return Promise.resolve({ - data: { - data: [ - { node: 'node1', status: 'online' }, - { node: 'node2', status: 'online' } - ] - } - }); - } - if (path.includes('node1')) { - // node1 APIs work normally - if (path.includes('/qemu')) { - return Promise.resolve({ - data: { data: [{ vmid: 100, name: 'vm1', status: 'running' }] } - }); - } - if (path.includes('/lxc')) { - return Promise.resolve({ - data: { data: [{ vmid: 200, name: 'ct1', status: 'running' }] } - }); - } - } - if (path.includes('node2')) { - // node2 APIs fail - throw new Error('Node2 is unreachable'); - } - return Promise.resolve({ data: { data: [] } }); - }); - - // === STEP 2: Execute discovery with partial failures === - const discoveryData = await fetchDiscoveryData(mockApiClients, {}); - - // === STEP 3: Verify graceful degradation === - expect(discoveryData.nodes).toHaveLength(2); // Both nodes discovered - expect(discoveryData.vms).toHaveLength(1); // Only node1 VMs - expect(discoveryData.containers).toHaveLength(1); // Only node1 CTs - - // Verify node1 guests are present - expect(discoveryData.vms[0].vmid).toBe(100); - expect(discoveryData.containers[0].vmid).toBe(200); - - // System should continue functioning despite node2 failure - }); - - test('should handle network errors gracefully', async () => { - // Clear any cached data from previous tests - clearCaches(); - - // Mock a scenario where one API call fails but the system continues - mockApiClients['pve-main'].client.get.mockImplementation((path) => { - if (path === '/nodes') { - return Promise.resolve({ - data: { data: [{ node: 'resilient-node', status: 'online' }] } - }); - } - if (path.includes('/qemu') || path.includes('/lxc')) { - // Simulate network failure for guest discovery - const networkError = new Error('Network timeout'); - networkError.code = 'ECONNABORTED'; - throw networkError; - } - return Promise.resolve({ data: { data: [] } }); - }); - - // Execute discovery - should handle network errors gracefully - const discoveryData = await fetchDiscoveryData(mockApiClients, {}); - - // Should discover nodes even if guest discovery fails - expect(discoveryData.nodes).toHaveLength(1); - expect(discoveryData.nodes[0].node).toBe('resilient-node'); - expect(discoveryData.vms).toHaveLength(0); // No VMs due to network error - expect(discoveryData.containers).toHaveLength(0); // No containers due to network error - }); - }); - - describe('Real Production Workflow: Multi-Tenant Environment', () => { - test('should handle admin investigating cross-tenant resource conflicts', async () => { - // REAL SCENARIO: Admin gets reports of VMs interfering with each other's performance - // Multiple departments sharing the same cluster with different SLA requirements - - // Mock multi-tenant cluster data - mockApiClients['pve-main'].client.get.mockImplementation((path) => { - if (path === '/nodes') { - return Promise.resolve({ - data: { - data: [ - { node: 'cluster1-node1', status: 'online' }, - { node: 'cluster1-node2', status: 'online' } - ] - } - }); - } - if (path.includes('/qemu')) { - if (path.includes('cluster1-node1')) { - return Promise.resolve({ - data: { - data: [ - { vmid: 1000, name: 'finance-db', status: 'running', tags: 'finance;critical' }, - { vmid: 1001, name: 'hr-app', status: 'running', tags: 'hr;standard' }, - { vmid: 1002, name: 'dev-test', status: 'running', tags: 'development;low' } - ] - } - }); - } - if (path.includes('cluster1-node2')) { - return Promise.resolve({ - data: { - data: [ - { vmid: 2000, name: 'marketing-web', status: 'running', tags: 'marketing;standard' }, - { vmid: 2001, name: 'analytics-worker', status: 'running', tags: 'analytics;high' } - ] - } - }); - } - } - if (path.includes('/lxc')) { - return Promise.resolve({ data: { data: [] } }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - const discoveryData = await fetchDiscoveryData(mockApiClients, mockPbsApiClients); - - // ANALYZE: Resource distribution across departments - const departmentMapping = { - finance: discoveryData.vms.filter(vm => vm.tags?.includes('finance')), - hr: discoveryData.vms.filter(vm => vm.tags?.includes('hr')), - development: discoveryData.vms.filter(vm => vm.tags?.includes('development')), - marketing: discoveryData.vms.filter(vm => vm.tags?.includes('marketing')), - analytics: discoveryData.vms.filter(vm => vm.tags?.includes('analytics')) - }; - - // VALIDATE: Multi-tenant separation - expect(departmentMapping.finance).toHaveLength(1); - expect(departmentMapping.analytics).toHaveLength(1); - - // DETECT: Potential resource conflicts - const criticalVMs = discoveryData.vms.filter(vm => vm.tags?.includes('critical')); - const nodeDistribution = {}; - discoveryData.vms.forEach(vm => { - if (!nodeDistribution[vm.node]) nodeDistribution[vm.node] = []; - nodeDistribution[vm.node].push(vm); - }); - - // VALIDATE: Critical VMs should not be overloaded on same node - const criticalNode = criticalVMs[0]?.node; - const vmsOnCriticalNode = nodeDistribution[criticalNode] || []; - - if (vmsOnCriticalNode.length > 2) { - console.warn(`RESOURCE CONFLICT: ${vmsOnCriticalNode.length} VMs on node with critical workload`); - } - - console.log(`Multi-tenant analysis: ${Object.keys(departmentMapping).length} departments across ${discoveryData.nodes.length} nodes`); - }); - }); - - describe('Real Operations: Disaster Recovery Testing', () => { - test('should help admin validate backup recovery process for critical VMs', async () => { - // REAL SCENARIO: Monthly DR test - admin needs to verify which VMs can be recovered - - // Mock PBS with realistic backup scenario - mockPbsApiClients['pbs-main'].client.get.mockImplementation((path) => { - if (path === '/nodes') { - return Promise.resolve({ data: { data: [{ node: 'pbs-dr' }] } }); - } - if (path === '/config/datastore') { - return Promise.resolve({ data: { data: [{ name: 'dr-backups' }] } }); - } - if (path.includes('/admin/datastore/dr-backups/snapshots')) { - const now = Math.floor(Date.now() / 1000); - return Promise.resolve({ - data: { - data: [ - // Critical systems with recent backups - { 'backup-id': '100', 'backup-type': 'vm', 'backup-time': now - 3600, size: 10737418240, protected: true }, - { 'backup-id': '101', 'backup-type': 'vm', 'backup-time': now - 3600, size: 5368709120, protected: true }, - // Development VM with older backup (acceptable) - { 'backup-id': '200', 'backup-type': 'vm', 'backup-time': now - 86400, size: 2147483648, protected: false }, - // Critical container with very recent backup - { 'backup-id': '300', 'backup-type': 'ct', 'backup-time': now - 1800, size: 1073741824, protected: true }, - // Test VM with gap in backups (concerning!) - { 'backup-id': '400', 'backup-type': 'vm', 'backup-time': now - 259200, size: 8589934592, protected: false } - ] - } - }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - // Mock PVE discovery to correlate with backups - mockApiClients['pve-main'].client.get.mockImplementation((path) => { - if (path === '/nodes') { - return Promise.resolve({ data: { data: [{ node: 'production', status: 'online' }] } }); - } - if (path.includes('/qemu')) { - return Promise.resolve({ - data: { - data: [ - { vmid: 100, name: 'finance-app', status: 'running', tags: 'critical;finance' }, - { vmid: 101, name: 'customer-db', status: 'running', tags: 'critical;database' }, - { vmid: 200, name: 'dev-staging', status: 'running', tags: 'development' }, - { vmid: 400, name: 'legacy-system', status: 'running', tags: 'legacy;important' } - ] - } - }); - } - if (path.includes('/lxc')) { - return Promise.resolve({ - data: { data: [{ vmid: 300, name: 'web-proxy', status: 'running', tags: 'critical;web' }] } - }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - const [discoveryData, pbsData] = await Promise.all([ - fetchDiscoveryData(mockApiClients, {}), - fetchPbsData(mockPbsApiClients) - ]); - - // ANALYZE: DR readiness for each system - const drAnalysis = { - criticalSystems: [], - warningItems: [], - gapDetected: [] - }; - - const allGuests = [...discoveryData.vms, ...discoveryData.containers]; - const allBackups = pbsData[0].datastores[0].snapshots; - - allGuests.forEach(guest => { - const backups = allBackups.filter(backup => - backup['backup-id'] === guest.vmid.toString() - ); - - if (backups.length === 0) { - drAnalysis.gapDetected.push({ - guest: guest.name, - vmid: guest.vmid, - issue: 'No backups found' - }); - return; - } - - const latestBackup = backups[0]; - const backupAge = (Date.now() / 1000) - latestBackup['backup-time']; - const ageInHours = backupAge / 3600; - - const isCritical = guest.tags?.includes('critical'); - - if (isCritical) { - drAnalysis.criticalSystems.push({ - guest: guest.name, - vmid: guest.vmid, - lastBackupAge: ageInHours, - protected: latestBackup.protected, - size: latestBackup.size - }); - - if (ageInHours > 6) { // Critical systems should be backed up within 6 hours - drAnalysis.warningItems.push({ - guest: guest.name, - vmid: guest.vmid, - issue: `Critical system backup ${Math.round(ageInHours)} hours old` - }); - } - } else if (ageInHours > 48) { // Non-critical can be up to 48 hours - drAnalysis.warningItems.push({ - guest: guest.name, - vmid: guest.vmid, - issue: `Backup ${Math.round(ageInHours)} hours old` - }); - } - }); - - // VALIDATE: DR test criteria - expect(drAnalysis.criticalSystems.length).toBeGreaterThan(0); - expect(drAnalysis.gapDetected.length).toBe(0); // No critical systems should lack backups - - // REPORT: DR readiness status - console.log(`DR Test Summary:`); - console.log(`- Critical systems monitored: ${drAnalysis.criticalSystems.length}`); - console.log(`- Warning items: ${drAnalysis.warningItems.length}`); - console.log(`- Backup gaps: ${drAnalysis.gapDetected.length}`); - - if (drAnalysis.warningItems.length > 0) { - console.log(`DR Warnings:`); - drAnalysis.warningItems.forEach(item => { - console.log(` - ${item.guest} (${item.vmid}): ${item.issue}`); - }); - } - - // This test would help identify DR readiness issues before they become problems - expect(drAnalysis.criticalSystems.every(sys => sys.lastBackupAge < 24)).toBe(true); - }); - - test('should handle concurrent operations without race conditions', async () => { - // === STEP 1: Set up concurrent operations === - const operations = [ - () => fetchDiscoveryData(mockApiClients, mockPbsApiClients), - () => fetchPbsData(mockPbsApiClients), - () => customThresholds.setThresholds('pve-main', 'node1', '100', { - cpu: { warning: 70, critical: 85 } - }), - () => customThresholds.setThresholds('pve-main', 'node1', '200', { - memory: { warning: 80, critical: 95 } - }) - ]; - - // Mock responses for all operations - mockApiClients['pve-main'].client.get.mockResolvedValue({ - data: { data: [{ node: 'concurrent-node', status: 'online' }] } - }); - mockPbsApiClients['pbs-main'].client.get.mockResolvedValue({ - data: { data: [] } - }); - - // === STEP 2: Execute operations concurrently === - const results = await Promise.all(operations.map(op => op())); - - // === STEP 3: Verify all operations completed successfully === - expect(results).toHaveLength(4); - expect(results[0].nodes).toHaveLength(1); // Discovery data - expect(Array.isArray(results[1])).toBe(true); // PBS data - expect(results[2]).toBe(true); // First threshold set - expect(results[3]).toBe(true); // Second threshold set - - // Verify threshold configurations were saved correctly - const threshold100 = customThresholds.getThresholds('pve-main', 'node1', '100'); - const threshold200 = customThresholds.getThresholds('pve-main', 'node1', '200'); - - expect(threshold100).not.toBeNull(); - expect(threshold200).not.toBeNull(); - expect(threshold100.thresholds.cpu.warning).toBe(70); - expect(threshold200.thresholds.memory.warning).toBe(80); - }); - }); -}); - -describe('Real-World Scenario Simulations', () => { - test('should simulate production monitoring cycle', async () => { - // This test simulates a realistic monitoring scenario with: - // - Mixed VM and container workloads - // - Varying resource usage patterns - // - Some backup failures - // - Custom threshold configurations - // - Alert generation and management - - const scenario = { - cluster: { - nodes: 3, - vmsPerNode: 4, - containersPerNode: 6 - }, - workloads: [ - { type: 'web', cpu: 0.45, memory: 0.60, typical: true }, - { type: 'database', cpu: 0.75, memory: 0.85, highUsage: true }, - { type: 'cache', cpu: 0.30, memory: 0.95, memoryIntensive: true }, - { type: 'worker', cpu: 0.90, memory: 0.40, cpuIntensive: true } - ] - }; - - console.log('Integration test: Simulating production monitoring scenario...'); - console.log(`- ${scenario.cluster.nodes} nodes`); - console.log(`- ${scenario.cluster.vmsPerNode * scenario.cluster.nodes} VMs`); - console.log(`- ${scenario.cluster.containersPerNode * scenario.cluster.nodes} containers`); - console.log(`- ${scenario.workloads.length} workload types with varying resource patterns`); - - // This demonstrates the comprehensive nature of the test suite - // and validates that the monitoring system can handle realistic - // production scenarios effectively. - - expect(true).toBe(true); // Placeholder for demonstration - }); -}); \ No newline at end of file diff --git a/server/tests/pbsUtils.test.js b/server/tests/pbsUtils.test.js deleted file mode 100644 index c479ad9f5..000000000 --- a/server/tests/pbsUtils.test.js +++ /dev/null @@ -1,269 +0,0 @@ -const { processPbsTasks, categorizeAndCountTasks } = require('../pbsUtils'); - -describe('PBS Utils - processPbsTasks', () => { - - test('should return default structure for null input', () => { - const result = processPbsTasks(null); - expect(result).toEqual({ - backupTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, - verificationTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, - syncTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, - pruneTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, - aggregatedPbsTaskSummary: { total: 0, ok: 0, failed: 0 }, - }); - }); - - test('should return default structure for empty array input', () => { - const result = processPbsTasks([]); - expect(result).toEqual({ - backupTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } }, - verificationTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } }, - syncTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } }, - pruneTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } }, - }); - }); - - test('should correctly categorize and summarize various task types', () => { - const now = Math.floor(Date.now() / 1000); - const tasks = [ - // Backups - { upid: 'B1', worker_type: 'backup', status: 'OK', starttime: now - 3600, endtime: now - 3500 }, - { upid: 'B2', type: 'backup', status: 'OK', starttime: now - 7200, endtime: now - 7100 }, - { upid: 'B3', worker_type: 'backup', status: 'FAILED', starttime: now - 100, endtime: now - 50 }, - { upid: 'B4', worker_type: 'backup', status: 'ERROR', starttime: now - 40, endtime: now - 20 }, - // Verifications - { upid: 'V1', worker_type: 'verify', status: 'OK', starttime: now - 500, endtime: now - 400 }, - { upid: 'V2', type: 'verificationjob', status: 'WARNING', starttime: now - 600, endtime: now - 550 }, // Treated as failed - // Sync - { upid: 'S1', worker_type: 'sync', status: 'OK', starttime: now - 1000, endtime: now - 900 }, - // Prune/GC - { upid: 'P1', worker_type: 'prune', status: 'OK', starttime: now - 2000, endtime: now - 1900 }, - { upid: 'G1', type: 'garbage_collection', status: 'OK', starttime: now - 2100, endtime: now - 2050 }, - // Unknown/Other - { upid: 'U1', type: 'unknown', status: 'OK', starttime: now - 5000, endtime: now - 4900 }, - // Running task (should not count as OK or Failed) - { upid: 'R1', worker_type: 'backup', status: 'running', starttime: now - 10, endtime: null }, - ]; - - const result = processPbsTasks(tasks); - - // Backup Summary - expect(result.backupTasks.summary.ok).toBe(2); - expect(result.backupTasks.summary.failed).toBe(2); - expect(result.backupTasks.summary.total).toBe(4); - expect(result.backupTasks.summary.lastOk).toBe(now - 3500); - expect(result.backupTasks.summary.lastFailed).toBe(now - 20); - expect(result.backupTasks.recentTasks).toHaveLength(5); - - // Verification Summary - expect(result.verificationTasks.summary.ok).toBe(1); - expect(result.verificationTasks.summary.failed).toBe(1); - expect(result.verificationTasks.summary.total).toBe(2); - expect(result.verificationTasks.summary.lastOk).toBe(now - 400); - expect(result.verificationTasks.summary.lastFailed).toBe(now - 550); - expect(result.verificationTasks.recentTasks).toHaveLength(2); - - // Sync Summary - expect(result.syncTasks.summary.ok).toBe(1); - expect(result.syncTasks.summary.failed).toBe(0); - expect(result.syncTasks.summary.total).toBe(1); - expect(result.syncTasks.summary.lastOk).toBe(now - 900); - expect(result.syncTasks.summary.lastFailed).toBeNull(); - expect(result.syncTasks.recentTasks).toHaveLength(1); - - // Prune/GC Summary - expect(result.pruneTasks.summary.ok).toBe(2); - expect(result.pruneTasks.summary.failed).toBe(0); - expect(result.pruneTasks.summary.total).toBe(2); - expect(result.pruneTasks.summary.lastOk).toBe(now - 1900); // P1 is later than G1 - expect(result.pruneTasks.summary.lastFailed).toBeNull(); - expect(result.pruneTasks.recentTasks).toHaveLength(2); - }); - - test('should correctly format recent tasks', () => { - const rawTasks = [ - // Task older than 30 days (should be filtered out) - { - upid: 'B_OLD', - node: 'pbsnode', - type: 'backup', - worker_type: 'backup', - worker_id: 'vm/200', - starttime: Math.floor((Date.now() - 40 * 24 * 60 * 60 * 1000) / 1000), // 40 days ago - endtime: Math.floor((Date.now() - 40 * 24 * 60 * 60 * 1000) / 1000) + 60, - status: 'OK', - }, - // Task within last 30 days - { - upid: 'B1', - node: 'pbsnode', - type: 'backup', - worker_type: 'backup', - worker_id: 'vm/100', - starttime: Math.floor((Date.now() - 10 * 24 * 60 * 60 * 1000) / 1000), // 10 days ago - endtime: Math.floor((Date.now() - 10 * 24 * 60 * 60 * 1000) / 1000) + 50, - status: 'OK', - }, - // Another task within last 30 days - { - upid: 'V1', - node: 'pbsnode', - type: 'verify', - worker_type: 'verify', - worker_id: 'datastore1:group1', // Example worker_id for verify - starttime: Math.floor((Date.now() - 5 * 24 * 60 * 60 * 1000) / 1000), // 5 days ago - endtime: Math.floor((Date.now() - 5 * 24 * 60 * 60 * 1000) / 1000) + 30, - status: 'WARNING', - exitstatus: 'WARNING: some issues', - } - ]; - - const result = processPbsTasks(rawTasks); - const { recentTasks } = result.backupTasks; // Assuming backupTasks is structured like this - - expect(recentTasks).toHaveLength(1); // Only B1 should be included - expect(recentTasks[0].upid).toBe('B1'); - expect(recentTasks[0].node).toBe('pbsnode'); - expect(recentTasks[0].type).toBe('backup'); - expect(recentTasks[0].status).toBe('OK'); - expect(recentTasks[0].duration).toBe(50); // starttime - endtime - expect(recentTasks[0].guest).toBe('vm/100'); // worker_id - // Add other expected properties based on the actual implementation of processPbsTasks - expect(recentTasks[0].startTime).toBe(rawTasks[1].starttime); // Check original start/end times are mapped - expect(recentTasks[0].endTime).toBe(rawTasks[1].endtime); - expect(recentTasks[0].exitCode).toBeUndefined(); // Assuming no exitcode for OK task - // expect(recentTasks[0]._raw).toBeDefined(); // If _raw is intentionally included - // If _raw is *not* intentionally included, we need to fix processPbsTasks - // For now, let's check for common fields expected in the output: - expect(recentTasks[0]).toHaveProperty('upid'); - expect(recentTasks[0]).toHaveProperty('node'); - expect(recentTasks[0]).toHaveProperty('type'); - expect(recentTasks[0]).toHaveProperty('status'); - expect(recentTasks[0]).toHaveProperty('duration'); - expect(recentTasks[0]).toHaveProperty('guest'); - expect(recentTasks[0]).toHaveProperty('startTime'); - expect(recentTasks[0]).toHaveProperty('endTime'); - // Check that _raw is NOT present if it's not intended - expect(recentTasks[0]._raw).toBeUndefined(); - - const { recentTasks: verifyTasks } = result.verificationTasks; // Check verification tasks - expect(verifyTasks).toHaveLength(1); // Only V1 should be included - expect(verifyTasks[0].upid).toBe('V1'); - expect(verifyTasks[0].status).toBe('WARNING'); - expect(verifyTasks[0].duration).toBe(30); - expect(verifyTasks[0].exitStatus).toBe('WARNING: some issues'); // Assuming exitstatus is mapped - // Check that _raw is NOT present - expect(verifyTasks[0]._raw).toBeUndefined(); - - // Also check summaries if needed by this test - // expect(result.backupTasks.summary).toEqual(...); - // expect(result.verificationTasks.summary).toEqual(...); - - }); - - test('should limit recent tasks to 20 by default', () => { - const now = Math.floor(Date.now() / 1000); - const tasks = []; - for (let i = 0; i < 25; i++) { - tasks.push({ upid: `B${i}`, worker_type: 'backup', status: 'OK', starttime: now - (i * 100), endtime: now - (i * 100) + 50 }); - } - const result = processPbsTasks(tasks); - expect(result.backupTasks.recentTasks).toHaveLength(20); - expect(result.backupTasks.recentTasks[0].upid).toBe('B0'); // Most recent - expect(result.backupTasks.recentTasks[19].upid).toBe('B19'); // 20th most recent - }); - - test('should handle tasks with missing start or end times gracefully', () => { - const now = Math.floor(Date.now() / 1000); - const tasks = [ - { upid: 'B1', worker_type: 'backup', status: 'OK', starttime: now - 100, endtime: now - 50 }, - { upid: 'B2', worker_type: 'backup', status: 'OK', starttime: null, endtime: now - 150 }, // Missing starttime - { upid: 'B3', worker_type: 'backup', status: 'OK', starttime: now - 200, endtime: undefined }, // Missing endtime - { upid: 'B4', worker_type: 'backup', status: 'OK', starttime: null, endtime: null }, // Missing both - ]; - const result = processPbsTasks(tasks); - const recent = result.backupTasks.recentTasks; - - expect(recent).toHaveLength(4); - // Sorting might be affected, but check formatting - const taskB2 = recent.find(t => t.upid === 'B2'); - const taskB3 = recent.find(t => t.upid === 'B3'); - const taskB4 = recent.find(t => t.upid === 'B4'); - - expect(taskB2.duration).toBeNull(); - expect(taskB3.duration).toBeNull(); - expect(taskB4.duration).toBeNull(); - - // Check summary timestamps (should ignore tasks without endtime) - expect(result.backupTasks.summary.lastOk).toBe(now - 50); // Only B1 has a valid endtime - }); - - test('should handle different verification task types', () => { - const now = Math.floor(Date.now() / 1000); - const tasks = [ - { upid: 'V1', worker_type: 'verify', status: 'OK', starttime: now - 100, endtime: now - 50 }, - { upid: 'V2', type: 'verificationjob', status: 'OK', starttime: now - 200, endtime: now - 150 }, - { upid: 'V3', type: 'verify_group', status: 'FAILED', starttime: now - 300, endtime: now - 250 }, - ]; - const result = processPbsTasks(tasks); - - expect(result.verificationTasks.summary.ok).toBe(2); - expect(result.verificationTasks.summary.failed).toBe(1); - expect(result.verificationTasks.summary.total).toBe(3); - expect(result.verificationTasks.recentTasks).toHaveLength(3); - expect(result.verificationTasks.recentTasks.map(t => t.upid)).toEqual(['V1', 'V2', 'V3']); // Sorted by start time - }); - - test('should handle different prune/gc task types', () => { - const now = Math.floor(Date.now() / 1000); - const tasks = [ - { upid: 'P1', worker_type: 'prune', status: 'OK', starttime: now - 100, endtime: now - 50 }, - { upid: 'G1', type: 'garbage_collection', status: 'FAILED', starttime: now - 200, endtime: now - 150 }, - ]; - const result = processPbsTasks(tasks); - - expect(result.pruneTasks.summary.ok).toBe(1); - expect(result.pruneTasks.summary.failed).toBe(1); - expect(result.pruneTasks.summary.total).toBe(2); - expect(result.pruneTasks.recentTasks).toHaveLength(2); - expect(result.pruneTasks.recentTasks.map(t => t.upid)).toEqual(['P1', 'G1']); // Sorted by start time - }); - - test('should return default structure for non-array input', () => { - const result = processPbsTasks({}); // Pass an object instead of an array - expect(result).toEqual({ - backupTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, - verificationTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, - syncTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, - pruneTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, - aggregatedPbsTaskSummary: { total: 0, ok: 0, failed: 0 }, - }); - }); - -}); - -describe('PBS Utils - categorizeAndCountTasks', () => { - test('should return default structure for null input', () => { - const taskTypeMap = { backup: 'backup', verify: 'verify' }; - const result = categorizeAndCountTasks(null, taskTypeMap); - - expect(result).toEqual({ - backup: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 }, - verify: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 }, - sync: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 }, - pruneGc: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 } - }); - }); - - test('should return default structure for non-array input', () => { - const taskTypeMap = { backup: 'backup', verify: 'verify' }; - const result = categorizeAndCountTasks({}, taskTypeMap); - - expect(result).toEqual({ - backup: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 }, - verify: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 }, - sync: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 }, - pruneGc: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 } - }); - }); -}); diff --git a/server/tests/runBackupValidation.js b/server/tests/runBackupValidation.js deleted file mode 100755 index 835a134f7..000000000 --- a/server/tests/runBackupValidation.js +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/env node - -/** - * Backup Validation Runner - * - * This script can be run to validate live backup data against ground truths. - * Usage: node runBackupValidation.js [--live] - */ - -const { fetchDiscoveryData, fetchPbsData } = require('../dataFetcher'); -const { processPbsTasks } = require('../pbsUtils'); -const { createApiClientInstance } = require('../apiClients'); -const { - validateAllBackupData, - generateValidationReport -} = require('./backupDataValidator'); - -// Load config if running against live data -let config = null; -if (process.argv.includes('--live')) { - try { - config = require('../config.json'); - } catch (error) { - console.error('Error loading config.json:', error.message); - process.exit(1); - } -} - -/** - * Runs validation against mock data - */ -async function runMockValidation() { - console.log('Running validation against mock data...\n'); - - // Create mock data similar to test setup - const mockDiscoveryData = { - nodes: [ - { node: 'desktop', endpointId: 'proxmox-lan', status: 'online' }, - { node: 'delly', endpointId: 'proxmox-lan', status: 'online' }, - { node: 'minipc', endpointId: 'proxmox-lan', status: 'online' }, - { node: 'pi', endpointId: 'pimox-lan', status: 'online' } - ], - vms: [ - { vmid: 100, name: 'vm100', type: 'qemu', endpointId: 'proxmox-lan' }, - { vmid: 102, name: 'vm102', type: 'qemu', endpointId: 'proxmox-lan' }, - { vmid: 200, name: 'vm200', type: 'qemu', endpointId: 'proxmox-lan' } - ], - containers: Array.from({ length: 15 }, (_, i) => ({ - vmid: 103 + i, - name: `ct${103 + i}`, - type: 'lxc', - endpointId: i < 14 ? 'proxmox-lan' : 'pimox-lan' - })), - pveBackups: { - backupTasks: [], - storageBackups: [], - guestSnapshots: [ - { name: 'ubuntuserver', vmid: 400, type: 'qemu' }, - { name: 'precursor', vmid: 400, type: 'qemu' }, - { name: 'before_helper', vmid: 106, type: 'lxc' } - ] - } - }; - - // Create mock PBS data - const now = Date.now() / 1000; - const mockPbsData = [{ - pbsEndpointId: 'pbs-main', - pbsInstanceName: 'PBS Storage', - status: 'ok', - datastores: [{ - name: 'main-datastore', - snapshots: [] - }] - }]; - - // Add mock snapshots - const guests = [100, 103, 104, 105, 106, 200, 400]; - guests.forEach(guestId => { - const isSecondaryJob = [102, 200, 400].includes(guestId); - const backupTime = isSecondaryJob - ? now - (9 * 60 * 60) // 9 hours ago - : now - (11 * 60 * 60); // 11 hours ago - - // Skip VM 102 to simulate missing backup - if (guestId !== 102) { - mockPbsData[0].datastores[0].snapshots.push({ - 'backup-time': backupTime, - 'backup-type': guestId <= 200 ? 'vm' : 'ct', - 'backup-id': String(guestId) - }); - } - }); - - // Create mock PBS tasks - const mockPbsTasks = mockPbsData[0].datastores[0].snapshots.map(snap => ({ - type: 'backup', - status: 'OK', - starttime: snap['backup-time'], - endtime: snap['backup-time'] + 300, - guest: `${snap['backup-type']}/${snap['backup-id']}`, - guestType: snap['backup-type'], - guestId: snap['backup-id'], - pbsBackupRun: true - })); - - const processedTasks = processPbsTasks(mockPbsTasks); - - // Run validation - const validationData = { - discoveryData: mockDiscoveryData, - pbsData: mockPbsData, - pbsTasks: mockPbsTasks, - processedTasks: processedTasks - }; - - const report = validateAllBackupData(validationData); - console.log(generateValidationReport(report)); -} - -/** - * Runs validation against live data - */ -async function runLiveValidation() { - console.log('Running validation against live data...\n'); - - try { - // Initialize API clients - const apiClients = {}; - const pbsApiClients = {}; - - // Initialize PVE clients - if (config.pveEndpoints) { - for (const [key, endpoint] of Object.entries(config.pveEndpoints)) { - try { - apiClients[key] = { - client: await createApiClientInstance({ - ...endpoint, - type: 'pve' - }), - config: endpoint - }; - console.log(`✓ Connected to PVE endpoint: ${endpoint.name || key}`); - } catch (error) { - console.error(`✗ Failed to connect to PVE endpoint ${key}:`, error.message); - } - } - } - - // Initialize PBS clients - if (config.pbsEndpoints) { - for (const [key, endpoint] of Object.entries(config.pbsEndpoints)) { - try { - pbsApiClients[key] = { - client: await createApiClientInstance({ - ...endpoint, - type: 'pbs' - }), - config: endpoint - }; - console.log(`✓ Connected to PBS endpoint: ${endpoint.name || key}`); - } catch (error) { - console.error(`✗ Failed to connect to PBS endpoint ${key}:`, error.message); - } - } - } - - console.log('\nFetching data...'); - - // Fetch all data - const [discoveryData, pbsData] = await Promise.all([ - fetchDiscoveryData(apiClients, pbsApiClients), - fetchPbsData(pbsApiClients) - ]); - - console.log('Processing PBS tasks...'); - - // Get raw PBS tasks for validation - let pbsTasks = []; - if (pbsData[0]?.backupTasks?.recentTasks) { - pbsTasks = pbsData[0].backupTasks.recentTasks; - } - - // Process tasks - const processedTasks = processPbsTasks(pbsTasks); - - // Run validation - const validationData = { - discoveryData, - pbsData, - pbsTasks, - processedTasks - }; - - const report = validateAllBackupData(validationData); - console.log('\n' + generateValidationReport(report)); - - // Save detailed report if issues found - if (!report.overallValid || report.warnings.length > 0) { - const fs = require('fs'); - const reportPath = `backup-validation-${Date.now()}.json`; - fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); - console.log(`\nDetailed report saved to: ${reportPath}`); - } - - } catch (error) { - console.error('Error during live validation:', error); - process.exit(1); - } -} - -/** - * Main entry point - */ -async function main() { - console.log('Pulse Backup Data Validator\n'); - - if (process.argv.includes('--live')) { - if (!config) { - console.error('No config.json found. Cannot run live validation.'); - process.exit(1); - } - await runLiveValidation(); - } else { - await runMockValidation(); - console.log('\nTo run against live data, use: node runBackupValidation.js --live'); - } -} - -// Run if called directly -if (require.main === module) { - main().catch(console.error); -} - -module.exports = { runMockValidation, runLiveValidation }; \ No newline at end of file diff --git a/server/tests/userWorkflow.test.js b/server/tests/userWorkflow.test.js deleted file mode 100644 index 919e1cfd7..000000000 --- a/server/tests/userWorkflow.test.js +++ /dev/null @@ -1,702 +0,0 @@ -/** - * User Workflow Tests - Real Production Scenarios - * These tests validate actual user workflows and would catch bugs that affect real users - */ - -const { fetchDiscoveryData, fetchMetricsData, fetchPbsData, clearCaches } = require('../dataFetcher'); -const { processPbsTasks } = require('../pbsUtils'); -const customThresholds = require('../customThresholds'); -const AlertManager = require('../alertManager'); - -// Mock only external dependencies, not our business logic -jest.mock('fs', () => ({ - promises: { - mkdir: jest.fn().mockResolvedValue(), - readFile: jest.fn().mockResolvedValue('{}'), - writeFile: jest.fn().mockResolvedValue() - } -})); - -describe('Real User Workflows - Production Scenarios', () => { - let realApiData; - let alertManager; - - beforeEach(() => { - clearCaches(); - alertManager = new AlertManager(); - customThresholds.cache.clear(); - jest.clearAllMocks(); - - // Create realistic production data based on your actual setup - realApiData = { - // Realistic PVE cluster based on your ground truth data - pveCluster: { - nodes: [ - { node: 'desktop', status: 'online', uptime: 86400 * 5 }, // 5 days - { node: 'delly', status: 'online', uptime: 86400 * 12 }, // 12 days - { node: 'minipc', status: 'online', uptime: 86400 * 8 } // 8 days - ], - vms: [ - { vmid: 102, name: 'windows11', status: 'stopped', node: 'desktop', agent: 0 }, - { vmid: 200, name: 'UnraidServer', status: 'running', node: 'desktop', agent: 1 }, - { vmid: 400, name: 'ubuntu-gpu-vm', status: 'running', node: 'desktop', agent: 1 } - ], - containers: [ - { vmid: 100, name: 'pbs', status: 'running', node: 'desktop' }, - { vmid: 101, name: 'homeassistant', status: 'running', node: 'delly' }, - { vmid: 103, name: 'pihole', status: 'running', node: 'minipc' }, - { vmid: 106, name: 'pulse', status: 'running', node: 'minipc' }, // This very app! - // ... 14 more containers for realistic 18 total guests - { vmid: 107, name: 'jellyfin', status: 'running', node: 'minipc' }, - { vmid: 108, name: 'frigate', status: 'running', node: 'delly' }, - { vmid: 109, name: 'pbs2', status: 'stopped', node: 'desktop' }, - { vmid: 110, name: 'tailscale-router', status: 'running', node: 'delly' }, - { vmid: 111, name: 'debian', status: 'stopped', node: 'desktop' }, - { vmid: 120, name: 'mqtt', status: 'running', node: 'minipc' }, - { vmid: 121, name: 'zigbee2mqtt', status: 'running', node: 'minipc' }, - { vmid: 122, name: 'influxdb-telegraf', status: 'running', node: 'delly' }, - { vmid: 124, name: 'grafana', status: 'running', node: 'minipc' }, - { vmid: 105, name: 'homepage', status: 'running', node: 'delly' }, - { vmid: 104, name: 'cloudflared', status: 'running', node: 'minipc' } - ] - }, - // Realistic backup data from your PBS - pbsBackups: { - datastores: [{ - name: 'main-datastore', - snapshots: [ - // Most containers have backups from 2 AM (primary job) - { 'backup-id': '100', 'backup-type': 'ct', 'backup-time': getTwoAMToday() }, - { 'backup-id': '101', 'backup-type': 'ct', 'backup-time': getTwoAMToday() }, - { 'backup-id': '103', 'backup-type': 'ct', 'backup-time': getTwoAMToday() }, - { 'backup-id': '106', 'backup-type': 'ct', 'backup-time': getTwoAMToday() }, - // VM 102 - THE PROBLEM CHILD (no recent backup!) - { 'backup-id': '102', 'backup-type': 'vm', 'backup-time': getThreeDaysAgo() }, - // VMs 200, 400 have backups from 4 AM (secondary job) - { 'backup-id': '200', 'backup-type': 'vm', 'backup-time': getFourAMToday() }, - { 'backup-id': '400', 'backup-type': 'vm', 'backup-time': getFourAMToday() }, - // More containers... - { 'backup-id': '107', 'backup-type': 'ct', 'backup-time': getTwoAMToday() }, - { 'backup-id': '108', 'backup-type': 'ct', 'backup-time': getTwoAMToday() }, - { 'backup-id': '110', 'backup-type': 'ct', 'backup-time': getTwoAMToday() } - ] - }] - }, - // Realistic metrics - some VMs under stress - currentMetrics: { - // Healthy VM - 200: { cpu: 0.15, memory: 2147483648, disk: 10737418240 }, // 15% CPU, 2GB RAM - // VM under CPU pressure - 400: { cpu: 0.89, memory: 4294967296, disk: 21474836480 }, // 89% CPU, 4GB RAM - // Container with memory pressure - 101: { cpu: 0.25, memory: 1073741824, disk: 5368709120 }, // 25% CPU, 1GB RAM - 106: { cpu: 0.12, memory: 536870912, disk: 2684354560 } // Pulse itself - } - }; - }); - - afterEach(() => { - if (alertManager) { - alertManager.destroy(); - } - }); - - describe('Scenario 1: Admin Investigates "Why Does Dashboard Show Wrong VM Count?"', () => { - test('should detect VM count discrepancy between dashboard and reality', async () => { - // REAL SCENARIO: Dashboard shows 20 VMs but only 18 guests exist - - // Mock realistic discovery that returns actual guest data - const mockApiClients = createRealisticMockClients(realApiData.pveCluster); - - const discoveryData = await fetchDiscoveryData(mockApiClients, {}); - - // Count actual guests - const totalGuests = discoveryData.vms.length + discoveryData.containers.length; - - // VALIDATE: Should match your known ground truth (18 guests total) - expect(totalGuests).toBe(18); - expect(discoveryData.vms).toHaveLength(3); // VMs: 102, 200, 400 - expect(discoveryData.containers).toHaveLength(15); // All the containers - - // VALIDATE: All known guests are present - const allVmids = [...discoveryData.vms, ...discoveryData.containers].map(g => g.vmid); - expect(allVmids).toContain(102); // windows11 - expect(allVmids).toContain(106); // pulse (this app!) - expect(allVmids).toContain(200); // UnraidServer - - // DETECT: If count was wrong, this would help debug - if (totalGuests !== 18) { - console.error(`DISCREPANCY: Expected 18 guests, found ${totalGuests}`); - console.error('Missing guests:', [100,101,102,103,104,105,106,107,108,109,110,111,120,121,122,124,200,400].filter(id => !allVmids.includes(id))); - console.error('Extra guests:', allVmids.filter(id => ![100,101,102,103,104,105,106,107,108,109,110,111,120,121,122,124,200,400].includes(id))); - } - }); - }); - - describe('Scenario 2: Admin Investigates "VM 102 Backup Issue"', () => { - test('should detect that VM 102 backup is dangerously old', async () => { - // REAL SCENARIO: VM 102 should be in backup job but backup is 3 days old - - const mockPbsClients = createRealisticPbsClients(realApiData.pbsBackups); - const pbsData = await fetchPbsData(mockPbsClients); - - // Find VM 102 backup - const vm102Backups = pbsData[0].datastores[0].snapshots.filter( - snap => snap['backup-id'] === '102' && snap['backup-type'] === 'vm' - ); - - expect(vm102Backups).toHaveLength(1); - - const vm102LastBackup = vm102Backups[0]; - const backupAge = (Date.now() / 1000) - vm102LastBackup['backup-time']; - const ageInHours = backupAge / 3600; - - // VALIDATE: This should detect the problem - expect(ageInHours).toBeGreaterThan(48); // More than 2 days old! - - // ALERT: This should trigger a critical alert - if (ageInHours > 24) { - console.warn(`CRITICAL: VM 102 backup is ${Math.round(ageInHours)} hours old!`); - } - - // COMPARE: Other VMs should have recent backups - const vm200Backups = pbsData[0].datastores[0].snapshots.filter( - snap => snap['backup-id'] === '200' && snap['backup-type'] === 'vm' - ); - const vm200Age = (Date.now() / 1000) - vm200Backups[0]['backup-time']; - expect(vm200Age / 3600).toBeLessThan(24); // Should be recent - }); - - test('should identify backup job configuration issue', async () => { - // REAL SCENARIO: VM 102 might be excluded from backup jobs or job failed - - const mockPbsClients = createRealisticPbsClients(realApiData.pbsBackups); - const pbsData = await fetchPbsData(mockPbsClients); - - // Analyze backup patterns to detect issues - const backupsByGuest = {}; - pbsData[0].datastores[0].snapshots.forEach(snap => { - const guestId = snap['backup-id']; - if (!backupsByGuest[guestId]) { - backupsByGuest[guestId] = []; - } - backupsByGuest[guestId].push(snap); - }); - - // Check backup frequency patterns - const recentBackups = Object.keys(backupsByGuest).filter(guestId => { - const latestBackup = backupsByGuest[guestId][0]; - const ageHours = (Date.now() / 1000 - latestBackup['backup-time']) / 3600; - return ageHours < 24; - }); - - // VALIDATE: Most guests should have recent backups - expect(recentBackups.length).toBeGreaterThan(5); - - // DETECT: VM 102 should be flagged as problematic - expect(recentBackups).not.toContain('102'); - - // IDENTIFY: Pattern analysis - const guestsWithoutRecentBackups = Object.keys(backupsByGuest).filter(id => !recentBackups.includes(id)); - if (guestsWithoutRecentBackups.length > 0) { - console.warn(`Guests with old backups: ${guestsWithoutRecentBackups.join(', ')}`); - } - }); - }); - - describe('Scenario 3: Admin Responds to "High CPU Alert Storm"', () => { - test('should detect which VMs are actually problematic vs false alarms', async () => { - // REAL SCENARIO: Multiple CPU alerts, admin needs to prioritize - - const mockApiClients = createRealisticMockClientsWithMetrics(realApiData.currentMetrics); - const discoveryData = await fetchDiscoveryData(mockApiClients, {}); - - const runningGuests = [ - ...discoveryData.vms.filter(vm => vm.status === 'running'), - ...discoveryData.containers.filter(ct => ct.status === 'running') - ]; - - const metricsData = await fetchMetricsData( - discoveryData.vms.filter(vm => vm.status === 'running'), - discoveryData.containers.filter(ct => ct.status === 'running'), - mockApiClients - ); - - // ANALYZE: Which guests actually have high CPU - const highCpuGuests = metricsData.filter(metrics => metrics.current.cpu > 0.8); - const moderateCpuGuests = metricsData.filter(metrics => metrics.current.cpu > 0.5 && metrics.current.cpu <= 0.8); - - // VALIDATE: Should detect VM 400 as high CPU (89%) - expect(highCpuGuests).toHaveLength(1); - expect(highCpuGuests[0].id).toBe(400); - expect(highCpuGuests[0].current.cpu).toBeCloseTo(0.89, 2); - - // PRIORITIZE: Admin can focus on real issues - console.log(`HIGH PRIORITY: ${highCpuGuests.length} guests with CPU >80%`); - console.log(`MEDIUM PRIORITY: ${moderateCpuGuests.length} guests with CPU 50-80%`); - - highCpuGuests.forEach(guest => { - const guestInfo = runningGuests.find(g => g.vmid === guest.id); - console.log(` - ${guestInfo.name} (${guestInfo.type} ${guest.id}): ${Math.round(guest.current.cpu * 100)}% CPU`); - }); - }); - - test('should validate alert suppression during maintenance', async () => { - // REAL SCENARIO: Admin puts VM 400 in maintenance, alerts should stop - - // Set custom thresholds to ensure alerts would normally fire - await customThresholds.setThresholds('primary', 'desktop', '400', { - cpu: { warning: 70, critical: 85 } - }); - - const mockApiClients = createRealisticMockClientsWithMetrics(realApiData.currentMetrics); - const metricsData = await fetchMetricsData([], [ - { vmid: 400, name: 'ubuntu-gpu-vm', status: 'running', endpointId: 'primary', node: 'desktop', type: 'qemu' } - ], mockApiClients); - - // Process alerts normally - should fire - const triggeredAlerts = alertManager.processMetrics(metricsData); - expect(triggeredAlerts.length).toBeGreaterThan(0); - - // Suppress alerts for maintenance - alertManager.suppressAlert('cpu_high', { vmid: 400 }, 3600000, 'Maintenance window'); - - // Process again - should be suppressed - const suppressedAlerts = alertManager.processMetrics(metricsData); - const vm400Alerts = suppressedAlerts.filter(alert => alert.guest.vmid === '400'); - expect(vm400Alerts).toHaveLength(0); - }); - }); - - describe('Scenario 4: Admin Validates "Backup Job Health"', () => { - test('should validate backup job scheduling is working correctly', async () => { - // REAL SCENARIO: Admin checks if backup jobs ran on schedule - - const mockPbsClients = createRealisticPbsClients(realApiData.pbsBackups); - const pbsData = await fetchPbsData(mockPbsClients); - - // Group backups by time to detect job patterns - const backupTimes = {}; - pbsData[0].datastores[0].snapshots.forEach(snap => { - const backupHour = new Date(snap['backup-time'] * 1000).getHours(); - if (!backupTimes[backupHour]) { - backupTimes[backupHour] = []; - } - backupTimes[backupHour].push(snap); - }); - - // VALIDATE: Should see backups at 2 AM and 4 AM (your backup schedule) - expect(backupTimes[2]).toBeDefined(); // Primary job at 2 AM - expect(backupTimes[4]).toBeDefined(); // Secondary job at 4 AM - - // VALIDATE: 2 AM job should have most containers - const twoAMBackups = backupTimes[2] || []; - const fourAMBackups = backupTimes[4] || []; - - expect(twoAMBackups.length).toBeGreaterThan(fourAMBackups.length); - - // VALIDATE: Specific VMs should be in correct jobs - const twoAMVmids = twoAMBackups.map(b => b['backup-id']); - const fourAMVmids = fourAMBackups.map(b => b['backup-id']); - - // Based on your ground truth: VMs 200, 400 in secondary job (4 AM) - expect(fourAMVmids).toContain('200'); - expect(fourAMVmids).toContain('400'); - - // Most containers in primary job (2 AM) - excluding VMs 102, 200, 400 - expect(twoAMVmids).toContain('100'); // pbs container - expect(twoAMVmids).toContain('106'); // pulse container - - console.log(`Primary job (2 AM): ${twoAMBackups.length} backups`); - console.log(`Secondary job (4 AM): ${fourAMBackups.length} backups`); - }); - }); - - describe('Scenario 5: Performance Under Load', () => { - test('should handle realistic cluster size without performance degradation', async () => { - // REAL SCENARIO: System should stay responsive with full cluster - - const startTime = Date.now(); - const startMemory = process.memoryUsage().heapUsed; - - // Create full realistic cluster - const mockApiClients = createLargeRealisticCluster(); - - const discoveryData = await fetchDiscoveryData(mockApiClients, {}); - const discoveryTime = Date.now() - startTime; - - // VALIDATE: Performance should be acceptable - expect(discoveryTime).toBeLessThan(10000); // 10 seconds max for discovery - expect(discoveryData.nodes.length).toBeGreaterThan(2); - expect(discoveryData.vms.length + discoveryData.containers.length).toBeGreaterThan(15); - - // VALIDATE: Memory usage should be reasonable - const endMemory = process.memoryUsage().heapUsed; - const memoryIncrease = endMemory - startMemory; - expect(memoryIncrease).toBeLessThan(100 * 1024 * 1024); // Less than 100MB increase - - console.log(`Discovery took ${discoveryTime}ms for ${discoveryData.vms.length + discoveryData.containers.length} guests`); - console.log(`Memory increase: ${Math.round(memoryIncrease / 1024 / 1024)}MB`); - }); - }); - - describe('Scenario 6: Admin Debugs "Slow Dashboard Loading"', () => { - test('should identify performance bottlenecks in data fetching', async () => { - // REAL SCENARIO: Dashboard taking 30+ seconds to load, admin needs to find why - - const mockApiClients = createRealisticMockClients(realApiData.pveCluster); - const performanceMetrics = { - discoveryStart: Date.now(), - nodeCallTimes: [], - totalApiCalls: 0 - }; - - // Monitor API call performance - const originalGet = mockApiClients.primary.client.get; - mockApiClients.primary.client.get = jest.fn().mockImplementation(async (path) => { - const callStart = Date.now(); - performanceMetrics.totalApiCalls++; - - // Simulate realistic response times for different endpoints - let delay = 100; // Default delay - if (path.includes('/qemu') || path.includes('/lxc')) { - delay = 500; // Guest endpoints are slower - } - if (path.includes('node3')) { - delay = 2000; // One node is slow (network issue) - } - - await new Promise(resolve => setTimeout(resolve, delay)); - const result = await originalGet.call(this, path); - - const callTime = Date.now() - callStart; - performanceMetrics.nodeCallTimes.push({ path, time: callTime }); - - return result; - }); - - const discoveryData = await fetchDiscoveryData(mockApiClients, {}); - const totalTime = Date.now() - performanceMetrics.discoveryStart; - - // ANALYZE: Performance bottlenecks - const slowCalls = performanceMetrics.nodeCallTimes.filter(call => call.time > 1000); - const avgCallTime = performanceMetrics.nodeCallTimes.reduce((sum, call) => sum + call.time, 0) / performanceMetrics.nodeCallTimes.length; - - // VALIDATE: Should identify the slow node - expect(slowCalls.length).toBeGreaterThan(0); - expect(slowCalls.some(call => call.path.includes('node3'))).toBe(true); - - // DETECT: Performance recommendations - if (avgCallTime > 500) { - console.log(`PERFORMANCE ISSUE: Average API call time ${Math.round(avgCallTime)}ms`); - } - if (totalTime > 5000) { - console.log(`PERFORMANCE ISSUE: Total discovery time ${totalTime}ms`); - } - - console.log(`Performance analysis: ${performanceMetrics.totalApiCalls} API calls, ${slowCalls.length} slow calls`); - slowCalls.forEach(call => { - console.log(` SLOW: ${call.path} took ${call.time}ms`); - }); - }); - }); - - describe('Scenario 7: Admin Investigates "Missing Backup Alerts"', () => { - test('should detect when backup monitoring is not working correctly', async () => { - // REAL SCENARIO: VM 102 hasn't been backed up in 3 days but no alerts fired - - const mockPbsClients = createRealisticPbsClients(realApiData.pbsBackups); - const pbsData = await fetchPbsData(mockPbsClients); - - // ANALYZE: Backup monitoring effectiveness - const allBackups = pbsData[0].datastores[0].snapshots; - const vm102Backups = allBackups.filter(snap => - snap['backup-id'] === '102' && snap['backup-type'] === 'vm' - ); - - expect(vm102Backups).toHaveLength(1); - - const vm102LastBackup = vm102Backups[0]; - const backupAge = (Date.now() / 1000) - vm102LastBackup['backup-time']; - const ageInDays = backupAge / (24 * 3600); - - // VALIDATE: Should detect old backup - expect(ageInDays).toBeGreaterThan(2); // More than 2 days old - - // SIMULATE: Alert system check - const mockAlertThreshold = 24 * 3600; // 24 hours - const shouldHaveAlerted = backupAge > mockAlertThreshold; - - // DETECT: Alert system gap - if (shouldHaveAlerted) { - console.log(`MONITORING GAP: VM 102 backup is ${Math.round(ageInDays * 10) / 10} days old, should have triggered alert`); - console.log(`Backup age: ${Math.round(backupAge / 3600)} hours (threshold: ${mockAlertThreshold / 3600} hours)`); - } - - // VALIDATE: This test helps identify why backup alerts aren't working - expect(shouldHaveAlerted).toBe(true); - - // RECOMMEND: Compare with other VMs to see pattern - const recentBackups = allBackups.filter(snap => { - const snapAge = (Date.now() / 1000) - snap['backup-time']; - return snapAge < (24 * 3600); // Less than 24 hours old - }); - - console.log(`Found ${recentBackups.length} recent backups vs ${allBackups.length} total`); - }); - }); - - describe('Scenario 8: Data Integrity Validation', () => { - test('should validate that all running VMs have corresponding metrics', async () => { - // REAL SCENARIO: Admin notices some VMs missing from metrics dashboard - - const mockApiClients = createRealisticMockClients(realApiData.pveCluster); - const discoveryData = await fetchDiscoveryData(mockApiClients, {}); - - const runningGuests = [ - ...discoveryData.vms.filter(vm => vm.status === 'running'), - ...discoveryData.containers.filter(ct => ct.status === 'running') - ]; - - // Mock metrics that might miss some guests - const mockMetricsApiClients = createRealisticMockClientsWithMetrics(realApiData.currentMetrics); - const metricsData = await fetchMetricsData( - discoveryData.vms.filter(vm => vm.status === 'running'), - discoveryData.containers.filter(ct => ct.status === 'running'), - mockMetricsApiClients - ); - - // DATA INTEGRITY CHECK: Every running guest should have metrics - const runningGuestIds = runningGuests.map(g => g.vmid); - const metricsGuestIds = metricsData.map(m => m.id); - - const missingMetrics = runningGuestIds.filter(id => !metricsGuestIds.includes(id)); - const extraMetrics = metricsGuestIds.filter(id => !runningGuestIds.includes(id)); - - // VALIDATE: Data consistency - expect(missingMetrics).toHaveLength(0); // No running guests should be missing metrics - expect(extraMetrics).toHaveLength(0); // No metrics for non-existent guests - - if (missingMetrics.length > 0) { - console.error(`DATA INTEGRITY ISSUE: ${missingMetrics.length} running guests missing metrics:`, missingMetrics); - } - if (extraMetrics.length > 0) { - console.error(`DATA INTEGRITY ISSUE: ${extraMetrics.length} metrics for non-running guests:`, extraMetrics); - } - - // VALIDATE: Metrics data quality - metricsData.forEach(metrics => { - expect(metrics.current).toBeDefined(); - expect(typeof metrics.current.cpu).toBe('number'); - expect(metrics.current.cpu).toBeGreaterThanOrEqual(0); - expect(metrics.current.cpu).toBeLessThanOrEqual(1); // Assuming decimal format - }); - - console.log(`Data integrity check: ${runningGuests.length} running guests, ${metricsData.length} metrics records`); - }); - }); - - describe('Scenario 9: Admin Responds to "Disk Space Critical" Alert', () => { - test('should help admin prioritize disk cleanup actions', async () => { - // REAL SCENARIO: Multiple disk space alerts, admin needs to know where to focus cleanup - - // Mock guests with varying disk usage - const diskPressureGuests = { - 106: { cpu: 0.12, memory: 536870912, disk: 0.92 }, // Pulse - 92% full - 200: { cpu: 0.15, memory: 2147483648, disk: 0.88 }, // UnraidServer - 88% full - 107: { cpu: 0.08, memory: 268435456, disk: 0.95 }, // Jellyfin - 95% full (critical!) - 108: { cpu: 0.22, memory: 1073741824, disk: 0.85 } // Frigate - 85% full - }; - - const mockApiClients = createRealisticMockClientsWithMetrics(diskPressureGuests); - const metricsData = await fetchMetricsData([], [ - { vmid: 106, name: 'pulse', status: 'running', endpointId: 'primary', node: 'minipc', type: 'lxc' }, - { vmid: 200, name: 'UnraidServer', status: 'running', endpointId: 'primary', node: 'desktop', type: 'qemu' }, - { vmid: 107, name: 'jellyfin', status: 'running', endpointId: 'primary', node: 'minipc', type: 'lxc' }, - { vmid: 108, name: 'frigate', status: 'running', endpointId: 'primary', node: 'delly', type: 'lxc' } - ], mockApiClients); - - // ANALYZE: Disk usage patterns - const diskMetrics = metricsData.map(m => ({ - id: m.id, - name: m.guestName, - diskUsage: m.current.disk * 100, - type: m.type - })).sort((a, b) => b.diskUsage - a.diskUsage); - - // PRIORITIZE: Critical vs warning levels - const criticalDisk = diskMetrics.filter(g => g.diskUsage > 90); // >90% - const warningDisk = diskMetrics.filter(g => g.diskUsage > 85 && g.diskUsage <= 90); // 85-90% - - // VALIDATE: Should identify jellyfin as highest priority - expect(criticalDisk).toHaveLength(2); // Jellyfin (95%) and Pulse (92%) - expect(criticalDisk[0].name).toBe('jellyfin'); - expect(criticalDisk[0].diskUsage).toBe(95); - - // RECOMMEND: Actions based on service type - const mediaServices = criticalDisk.filter(g => - ['jellyfin', 'plex', 'frigate'].includes(g.name.toLowerCase()) - ); - const systemServices = criticalDisk.filter(g => - ['pulse', 'pihole', 'homeassistant'].includes(g.name.toLowerCase()) - ); - - console.log('DISK CLEANUP PRIORITIES:'); - console.log(`CRITICAL (>90%): ${criticalDisk.length} services`); - criticalDisk.forEach(g => { - console.log(` - ${g.name}: ${g.diskUsage}% full`); - }); - - console.log(`WARNING (85-90%): ${warningDisk.length} services`); - - // GUIDANCE: Specific cleanup recommendations - if (mediaServices.length > 0) { - console.log('RECOMMENDATION: Check media files for cleanup (jellyfin, frigate)'); - } - if (systemServices.length > 0) { - console.log('RECOMMENDATION: Check logs and temporary files (pulse, system services)'); - } - - expect(criticalDisk.length).toBeGreaterThan(0); - }); - }); -}); - -// Helper functions for realistic test data -function getTwoAMToday() { - const now = new Date(); - const twoAM = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 2, 0, 0); - if (twoAM > now) { - twoAM.setDate(twoAM.getDate() - 1); // Yesterday's 2 AM - } - return Math.floor(twoAM.getTime() / 1000); -} - -function getFourAMToday() { - const now = new Date(); - const fourAM = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 4, 0, 0); - if (fourAM > now) { - fourAM.setDate(fourAM.getDate() - 1); // Yesterday's 4 AM - } - return Math.floor(fourAM.getTime() / 1000); -} - -function getThreeDaysAgo() { - const threeDaysAgo = new Date(); - threeDaysAgo.setDate(threeDaysAgo.getDate() - 3); - threeDaysAgo.setHours(2, 0, 0, 0); // 2 AM three days ago - return Math.floor(threeDaysAgo.getTime() / 1000); -} - -function createRealisticMockClients(pveCluster) { - return { - 'primary': { - client: { - get: jest.fn().mockImplementation((path) => { - if (path === '/nodes') { - return Promise.resolve({ data: { data: pveCluster.nodes } }); - } - if (path.includes('/qemu')) { - const node = path.split('/')[2]; - const nodeVms = pveCluster.vms.filter(vm => vm.node === node); - return Promise.resolve({ data: { data: nodeVms } }); - } - if (path.includes('/lxc')) { - const node = path.split('/')[2]; - const nodeContainers = pveCluster.containers.filter(ct => ct.node === node); - return Promise.resolve({ data: { data: nodeContainers } }); - } - return Promise.resolve({ data: { data: [] } }); - }) - }, - config: { id: 'primary', name: 'Test Cluster' } - } - }; -} - -function createRealisticPbsClients(pbsBackups) { - return { - 'pbs-main': { - client: { - get: jest.fn().mockImplementation((path) => { - if (path === '/nodes') { - return Promise.resolve({ data: { data: [{ node: 'pbs-node' }] } }); - } - if (path === '/config/datastore') { - return Promise.resolve({ data: { data: [{ name: 'main-datastore' }] } }); - } - if (path.includes('/admin/datastore/main-datastore/snapshots')) { - return Promise.resolve({ data: { data: pbsBackups.datastores[0].snapshots } }); - } - if (path.includes('/status/datastore-usage')) { - return Promise.resolve({ data: { data: [{ store: 'main-datastore', total: 1000000000, used: 500000000 }] } }); - } - return Promise.resolve({ data: { data: [] } }); - }) - }, - config: { id: 'pbs-main', name: 'Test PBS' } - } - }; -} - -function createRealisticMockClientsWithMetrics(currentMetrics) { - return { - 'primary': { - client: { - get: jest.fn().mockImplementation((path) => { - if (path.includes('/status')) { - const vmidMatch = path.match(/\/(qemu|lxc)\/(\d+)\/status/); - if (vmidMatch) { - const vmid = parseInt(vmidMatch[2]); - const metrics = currentMetrics[vmid]; - if (metrics) { - return Promise.resolve({ data: { data: metrics } }); - } - } - return Promise.resolve({ data: { data: { cpu: 0.1, memory: 1073741824, disk: 5368709120 } } }); - } - if (path.includes('/rrddata')) { - return Promise.resolve({ data: { data: [{ time: Date.now() / 1000, cpu: 0.1 }] } }); - } - return Promise.resolve({ data: { data: [] } }); - }) - }, - config: { id: 'primary', name: 'Test Cluster' } - } - }; -} - -function createLargeRealisticCluster() { - // Create a larger but still realistic cluster - const nodes = ['desktop', 'delly', 'minipc', 'server1', 'server2']; - const largeCluster = { - nodes: nodes.map(name => ({ node: name, status: 'online', uptime: 86400 })), - vms: [], - containers: [] - }; - - // Add realistic VMs and containers distributed across nodes - let vmid = 100; - nodes.forEach((node, nodeIndex) => { - // Add some VMs per node - for (let i = 0; i < 3; i++) { - largeCluster.vms.push({ - vmid: vmid++, - name: `vm-${node}-${i}`, - status: Math.random() > 0.1 ? 'running' : 'stopped', - node: node - }); - } - // Add some containers per node - for (let i = 0; i < 8; i++) { - largeCluster.containers.push({ - vmid: vmid++, - name: `ct-${node}-${i}`, - status: Math.random() > 0.05 ? 'running' : 'stopped', - node: node - }); - } - }); - - return createRealisticMockClients(largeCluster); -} \ No newline at end of file diff --git a/src/public/js/ui/pbs.js b/src/public/js/ui/pbs.js index 0d6fd60b5..2944addc2 100644 --- a/src/public/js/ui/pbs.js +++ b/src/public/js/ui/pbs.js @@ -5,6 +5,7 @@ PulseApp.ui.pbs = (() => { // Global state tracker for expanded PBS tasks let expandedTaskState = new Set(); let expandedShowMoreState = new Set(); + let expandedMobileShowMoreState = new Set(); // Track mobile show more state let selectedPbsTabIndex = 0; // Track selected PBS tab index globally const CSS_CLASSES = { @@ -1698,7 +1699,9 @@ PulseApp.ui.pbs = (() => { heading.textContent = statusText; taskSection.appendChild(heading); - const taskContainer = _createMobileTaskContainer(recentTasks); + // Create unique key for this task section + const sectionKey = `${instanceId}-${taskType.type}`; + const taskContainer = _createMobileTaskContainer(recentTasks, sectionKey); taskSection.appendChild(taskContainer); section.appendChild(taskSection); @@ -1708,7 +1711,7 @@ PulseApp.ui.pbs = (() => { return section; }; - const _createMobileTaskContainer = (tasks) => { + const _createMobileTaskContainer = (tasks, sectionKey) => { const container = document.createElement('div'); container.className = 'mobile-task-container space-y-2'; @@ -1717,26 +1720,58 @@ PulseApp.ui.pbs = (() => { const otherTasks = tasks.filter(task => !task.status || task.status === 'OK' || task.status.toLowerCase().includes('running')); const prioritizedTasks = [...failedTasks, ...otherTasks]; - // Limit to 5 tasks on mobile for better performance - const displayTasks = prioritizedTasks.slice(0, 5); + // Check if this section has been expanded + const isExpanded = expandedMobileShowMoreState.has(sectionKey); + + // Show all tasks if expanded, otherwise limit to 5 + const displayTasks = isExpanded ? prioritizedTasks : prioritizedTasks.slice(0, 5); displayTasks.forEach(task => { const taskCard = _createMobileTaskCard(task); container.appendChild(taskCard); }); - if (prioritizedTasks.length > 5) { + // Show button if there are more than 5 tasks and not expanded + if (prioritizedTasks.length > 5 && !isExpanded) { const moreButton = document.createElement('button'); moreButton.className = 'w-full py-2 px-3 text-xs text-blue-600 dark:text-blue-400 border border-blue-200 dark:border-blue-600 rounded bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors'; moreButton.textContent = `Show ${prioritizedTasks.length - 5} More Tasks`; - moreButton.addEventListener('click', () => { + moreButton.addEventListener('click', (event) => { + // Prevent any event bubbling that might cause issues + event.stopPropagation(); + event.preventDefault(); + + // Mark this section as expanded + expandedMobileShowMoreState.add(sectionKey); + + // Add remaining tasks const remainingTasks = prioritizedTasks.slice(5); remainingTasks.forEach(task => { const taskCard = _createMobileTaskCard(task); container.insertBefore(taskCard, moreButton); }); - moreButton.remove(); + + // Replace button with "Show Less" button + const showLessButton = document.createElement('button'); + showLessButton.className = 'w-full py-2 px-3 text-xs text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-600 rounded bg-gray-50 dark:bg-gray-900/20 hover:bg-gray-100 dark:hover:bg-gray-900/30 transition-colors'; + showLessButton.textContent = 'Show Less Tasks'; + + showLessButton.addEventListener('click', (event) => { + event.stopPropagation(); + event.preventDefault(); + + // Remove expanded state + expandedMobileShowMoreState.delete(sectionKey); + + // Trigger a refresh to show collapsed state + // This will cause the function to be called again with the collapsed state + if (typeof updatePbsInfo === 'function') { + updatePbsInfo(); + } + }); + + moreButton.replaceWith(showLessButton); }); container.appendChild(moreButton); diff --git a/test-dns-resolver.js b/test-dns-resolver.js deleted file mode 100755 index 9d364c8a1..000000000 --- a/test-dns-resolver.js +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env node - -/** - * Test script for the resilient DNS resolver - * Usage: node test-dns-resolver.js - */ - -const dnsResolver = require('../server/dnsResolver'); - -async function testDnsResolution(hostname) { - console.log(`\n=== Testing DNS Resolution for: ${hostname} ===\n`); - - try { - // Test basic resolution - console.log('1. Testing basic DNS resolution...'); - const addresses = await dnsResolver.resolveHostname(hostname); - console.log(` ✓ Resolved to ${addresses.length} addresses:`); - addresses.forEach((addr, idx) => { - console.log(` ${idx + 1}. ${addr}`); - }); - - // Test cache - console.log('\n2. Testing cached resolution...'); - const cachedAddresses = await dnsResolver.resolveHostname(hostname); - console.log(` ✓ Got ${cachedAddresses.length} addresses from cache`); - - // Test marking IPs as failed - if (addresses.length > 1) { - console.log('\n3. Testing failed IP handling...'); - const firstIp = addresses[0]; - dnsResolver.markHostFailed(firstIp); - console.log(` - Marked ${firstIp} as failed`); - - const filteredAddresses = await dnsResolver.resolveHostname(hostname); - console.log(` ✓ After filtering: ${filteredAddresses.length} working addresses`); - - // Wait for retry delay - console.log('\n4. Testing retry delay...'); - console.log(` - Waiting for failed IP to be retryable...`); - - const isStillFailed = dnsResolver.isHostFailed(firstIp); - console.log(` - IP ${firstIp} is ${isStillFailed ? 'still marked as failed' : 'available again'}`); - } - - // Test hostname extraction - console.log('\n5. Testing hostname extraction...'); - const testUrls = [ - `https://${hostname}:8006`, - `${hostname}:8006`, - `https://${hostname}/api2/json`, - hostname - ]; - - testUrls.forEach(url => { - const extracted = dnsResolver.extractHostname(url); - console.log(` - "${url}" -> "${extracted}"`); - }); - - // Test canResolve - console.log('\n6. Testing canResolve...'); - const canResolve = await dnsResolver.canResolve(hostname); - console.log(` ✓ Can resolve ${hostname}: ${canResolve}`); - - console.log('\n=== Test completed successfully ===\n'); - - } catch (error) { - console.error(`\n✗ DNS resolution failed: ${error.message}\n`); - process.exit(1); - } -} - -// Main execution -const hostname = process.argv[2]; - -if (!hostname) { - console.error('Usage: node test-dns-resolver.js '); - console.error('Example: node test-dns-resolver.js proxmox.lan'); - process.exit(1); -} - -testDnsResolution(hostname).catch(error => { - console.error('Unexpected error:', error); - process.exit(1); -}); \ No newline at end of file diff --git a/test_pr.md b/test_pr.md deleted file mode 100644 index cc72e9527..000000000 --- a/test_pr.md +++ /dev/null @@ -1 +0,0 @@ -# Test PR merge diff --git a/tests/README.md b/tests/README.md deleted file mode 100644 index 74c518046..000000000 --- a/tests/README.md +++ /dev/null @@ -1,173 +0,0 @@ -# Pulse Test Suite - -This directory contains comprehensive tests for the Pulse monitoring application. The test suite is designed to validate real functionality without test theatre - every test serves a purpose and catches actual issues. - -## Test Philosophy - -✅ **Meaningful Testing**: Tests validate actual business logic and catch real bugs -✅ **Realistic Scenarios**: Error cases simulate actual network failures and edge conditions -✅ **Integration Testing**: End-to-end validation of data flows -✅ **Ground Truth Validation**: Tests against known good data to ensure accuracy - -❌ **No Test Theatre**: We avoid superficial tests that only verify mocks - -## Test Structure - -### Core Module Tests - -#### `apiClients.test.js` (100% Coverage ✅) -- **Authentication**: Token-based auth for PVE and PBS -- **Retry Logic**: Network failure handling with exponential backoff -- **SSL Configuration**: Self-signed certificate handling -- **Error Scenarios**: Missing credentials, network timeouts, HTTP errors -- **Multiple Endpoints**: Cross-cluster API management - -#### `dataFetcher.test.js` (66% Coverage) -- **Discovery Data**: VM/Container enumeration across nodes -- **Metrics Collection**: RRD data and current status fetching -- **PBS Integration**: Backup data aggregation and task processing -- **Error Handling**: API failures, malformed responses, missing data -- **QEMU Guest Agent**: Memory statistics collection - -#### `pbsUtils.test.js` (100% Coverage ✅) -- **Task Categorization**: Backup, verification, sync, and prune tasks -- **Summary Statistics**: Success/failure rates and timing analysis -- **Recent Task Filtering**: 30-day window with proper sorting -- **Duration Calculation**: Handling missing timestamps gracefully - -#### `configLoader.test.js` (99% Coverage ✅) -- **Environment Variables**: Multi-endpoint configuration parsing -- **Placeholder Detection**: Setup mode vs production configuration -- **Validation Logic**: Required field checking and error handling -- **PBS Configuration**: Token and password authentication modes - -### Enhanced Coverage Tests - -#### `alertManager.test.js` (Enhanced) -**Original Coverage**: 35% → **New Coverage**: ~60% - -Added comprehensive tests for: -- **Webhook Functionality**: Slack/Discord payload formatting -- **Alert Management**: Rule registration, acknowledgments, resolution -- **Notification Channels**: Custom webhooks, email, disabled channels -- **Alert Escalation**: Time-based severity escalation -- **Alert Suppression**: Maintenance window handling -- **Metrics & Analytics**: Statistics calculation and tracking - -#### `customThresholds.test.js` (New) -**Coverage**: ~85% - -Comprehensive test coverage for: -- **Threshold Management**: Per-VM/LXC custom thresholds -- **Configuration Persistence**: File-based storage operations -- **Validation Logic**: Threshold range and consistency checks -- **Bulk Operations**: Import/export and endpoint-wide operations -- **Error Handling**: File system errors and malformed data -- **Cache Management**: High-performance threshold lookups - -### Specialized Tests - -#### `backupGroundTruth.test.js` -This unique test validates against real-world data: -- **Actual Cluster Data**: 18 guests, 135 PBS backups, 3 VM snapshots -- **Backup Job Validation**: Primary (2 AM) vs Secondary (4 AM) schedules -- **Age Calculations**: Realistic backup timing verification -- **Known Issues Testing**: VM 102 missing backup detection -- **Multi-Endpoint Handling**: proxmox.lan vs pimox.lan clusters - -## Running Tests - -```bash -# Run all tests with coverage -npm test - -# Run specific test file -npm test -- server/tests/apiClients.test.js - -# Run tests in watch mode -npm test -- --watch - -# Run with verbose output -npm test -- --verbose -``` - -## Test Configuration - -### Jest Setup -- **Environment**: Node.js test environment -- **Module Transformation**: ES modules support with experimental VM modules -- **Coverage Provider**: V8 for accurate coverage reporting -- **Timeout**: 120 seconds for long-running integration tests - -### Mocking Strategy -- **Selective Mocking**: Only mock external dependencies (axios, filesystem) -- **Realistic Data**: Mock responses based on actual API responses -- **Error Simulation**: Network failures, timeouts, malformed responses -- **State Management**: Proper setup/teardown for test isolation - -## Coverage Goals - -| Module | Current | Target | Status | -|--------|---------|--------|--------| -| apiClients.js | 100% | 100% | ✅ Complete | -| pbsUtils.js | 100% | 100% | ✅ Complete | -| configLoader.js | 99% | 99% | ✅ Complete | -| dataFetcher.js | 66% | 70% | 🟡 Good | -| alertManager.js | 35%→60% | 70% | 🟡 Improved | -| customThresholds.js | 34%→85% | 80% | ✅ Complete | - -## Key Testing Principles - -### 1. Business Logic Focus -Tests validate actual functionality: -```javascript -// ✅ Good: Tests real backup age calculation -expect(backupAge).toBeCloseTo(11, 0); // 11 hours old - -// ❌ Avoid: Only testing mocks -expect(mockFunction).toHaveBeenCalled(); -``` - -### 2. Error Scenario Coverage -Realistic failure handling: -```javascript -// Network failures, HTTP errors, malformed data -mockAxios.post.mockRejectedValue(new Error('Network timeout')); -``` - -### 3. Integration Validation -End-to-end data flow testing: -```javascript -const discoveryData = await fetchDiscoveryData(mockClients, mockPbsClients); -expect(discoveryData.nodes.length).toBe(expectedNodeCount); -``` - -### 4. Ground Truth Verification -Real-world data validation: -```javascript -expect(totalGuests).toBe(18); // Actual cluster count -expect(pbsBackups).toBe(135); // Real backup count -``` - -## Adding New Tests - -When adding new tests, ensure they: - -1. **Test Real Functionality**: Validate actual business logic -2. **Handle Edge Cases**: Network failures, missing data, malformed input -3. **Use Realistic Data**: Base mocks on actual API responses -4. **Include Error Scenarios**: Test failure modes and recovery -5. **Validate Integration**: Test component interactions -6. **Document Purpose**: Clear test descriptions and comments - -## Test Maintenance - -- **Update with API Changes**: Keep mocks synchronized with real APIs -- **Monitor Coverage**: Maintain high coverage for critical paths -- **Review Failures**: Investigate and fix flaky tests immediately -- **Performance Testing**: Monitor test execution time -- **Regular Cleanup**: Remove obsolete tests and update documentation - ---- - -This test suite provides confidence in Pulse's reliability and helps catch issues before they reach production. The focus on meaningful testing ensures that every test adds value and the comprehensive coverage protects against regressions. \ No newline at end of file diff --git a/tests/alertManager.test.js b/tests/alertManager.test.js deleted file mode 100644 index 260a9c10e..000000000 --- a/tests/alertManager.test.js +++ /dev/null @@ -1,528 +0,0 @@ -/** - * AlertManager Webhook Tests - * Tests webhook functionality and timestamp handling after the Teams webhook fix - */ - -const AlertManager = require('../alertManager'); -const axios = require('axios'); - -// Mock axios for webhook testing -jest.mock('axios'); -const mockAxios = axios; - -describe('AlertManager Webhook Functionality', () => { - let alertManager; - let mockWebhookChannel; - let mockAlert; - - beforeEach(() => { - alertManager = new AlertManager(); - - // Mock webhook channel configuration - mockWebhookChannel = { - id: 'test-webhook', - name: 'Test Webhook', - type: 'webhook', - enabled: true, - config: { - url: 'https://hooks.slack.com/test-webhook', - method: 'POST', - headers: { 'Content-Type': 'application/json' } - } - }; - - // Mock alert object with various timestamp scenarios - mockAlert = { - id: 'test-alert-123', - rule: { - name: 'High CPU Usage', - description: 'CPU usage is too high', - severity: 'warning', - metric: 'cpu' - }, - guest: { - name: 'test-vm', - vmid: '100', - type: 'qemu', - node: 'test-node', - status: 'running' - }, - currentValue: 92, - effectiveThreshold: 85, - triggeredAt: 1640995200000, // Valid timestamp - lastUpdate: 1640995260000 // Valid timestamp - }; - - // Reset axios mock - mockAxios.post.mockClear(); - }); - - afterEach(() => { - if (alertManager) { - alertManager.destroy(); - } - }); - - describe('Webhook Timestamp Handling', () => { - test('should use triggeredAt timestamp when available', async () => { - mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } }); - - await alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert); - - expect(mockAxios.post).toHaveBeenCalledTimes(1); - const payload = mockAxios.post.mock.calls[0][1]; - - // For Slack webhooks, check the timestamp in attachments - expect(payload.attachments[0].ts).toBe(Math.floor(mockAlert.triggeredAt / 1000)); - - // Slack webhooks don't have top-level timestamp or embeds - expect(payload.timestamp).toBeUndefined(); - expect(payload.embeds).toBeUndefined(); - }); - - test('should fallback to lastUpdate when triggeredAt is missing', async () => { - mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } }); - - // Remove triggeredAt from alert - const alertWithoutTriggeredAt = { ...mockAlert }; - delete alertWithoutTriggeredAt.triggeredAt; - - await alertManager.sendWebhookNotification(mockWebhookChannel, alertWithoutTriggeredAt); - - expect(mockAxios.post).toHaveBeenCalledTimes(1); - const payload = mockAxios.post.mock.calls[0][1]; - - // Should use lastUpdate timestamp in Slack format - expect(payload.attachments[0].ts).toBe(Math.floor(mockAlert.lastUpdate / 1000)); - }); - - test('should fallback to current time when both timestamps are missing', async () => { - mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } }); - - // Remove both timestamps from alert - const alertWithoutTimestamps = { ...mockAlert }; - delete alertWithoutTimestamps.triggeredAt; - delete alertWithoutTimestamps.lastUpdate; - - const beforeTime = Date.now(); - await alertManager.sendWebhookNotification(mockWebhookChannel, alertWithoutTimestamps); - const afterTime = Date.now(); - - expect(mockAxios.post).toHaveBeenCalledTimes(1); - const payload = mockAxios.post.mock.calls[0][1]; - - // Should use current time (within reasonable range) for Slack format - // Note: Unix timestamps lose millisecond precision, so allow for some tolerance - const timestamp = payload.attachments[0].ts * 1000; // Convert Unix timestamp back to milliseconds - expect(timestamp).toBeGreaterThanOrEqual(Math.floor(beforeTime / 1000) * 1000); - expect(timestamp).toBeLessThanOrEqual(Math.ceil(afterTime / 1000) * 1000); - }); - - test('should handle invalid timestamp values gracefully', async () => { - mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } }); - - // Set invalid timestamps - const alertWithInvalidTimestamps = { - ...mockAlert, - triggeredAt: 'invalid-timestamp', - lastUpdate: null - }; - - const beforeTime = Date.now(); - await alertManager.sendWebhookNotification(mockWebhookChannel, alertWithInvalidTimestamps); - const afterTime = Date.now(); - - expect(mockAxios.post).toHaveBeenCalledTimes(1); - const payload = mockAxios.post.mock.calls[0][1]; - - // Should fallback to current time when timestamps are invalid (Slack format) - // Note: Unix timestamps lose millisecond precision, so allow for some tolerance - const timestamp = payload.attachments[0].ts * 1000; - expect(timestamp).toBeGreaterThanOrEqual(Math.floor(beforeTime / 1000) * 1000); - expect(timestamp).toBeLessThanOrEqual(Math.ceil(afterTime / 1000) * 1000); - }); - }); - - describe('Webhook Payload Structure', () => { - test('should generate valid Discord/Slack payload structure', async () => { - mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } }); - - await alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert); - - expect(mockAxios.post).toHaveBeenCalledTimes(1); - const payload = mockAxios.post.mock.calls[0][1]; - - // Check Slack webhook structure (based on URL) - expect(payload).toHaveProperty('text'); - expect(payload).toHaveProperty('attachments'); - - // Slack webhooks don't have these properties - expect(payload).not.toHaveProperty('timestamp'); - expect(payload).not.toHaveProperty('alert'); - expect(payload).not.toHaveProperty('embeds'); - - // Check Slack attachment structure - expect(payload.attachments).toHaveLength(1); - expect(payload.attachments[0]).toHaveProperty('fields'); - expect(payload.attachments[0]).toHaveProperty('color'); - expect(payload.attachments[0]).toHaveProperty('footer'); - expect(payload.attachments[0]).toHaveProperty('ts'); - }); - - test('should include all required alert fields in payload', async () => { - mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } }); - - await alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert); - - const payload = mockAxios.post.mock.calls[0][1]; - - // Check Slack format fields (data is in text and attachments) - expect(payload.text).toContain(mockAlert.rule.name); - expect(payload.attachments[0].fields[0].value).toContain(mockAlert.guest.name); - expect(payload.attachments[0].fields[1].value).toBe(mockAlert.guest.node); - expect(payload.attachments[0].fields[2].value).toContain('92%'); // formatted value - expect(payload.attachments[0].fields[2].value).toContain('85%'); // formatted threshold - }); - - test('should set correct colors based on severity', async () => { - mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } }); - - // Test warning severity (Slack format only has attachments) - await alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert); - let payload = mockAxios.post.mock.calls[0][1]; - expect(payload.attachments[0].color).toBe('warning'); - - // Test critical severity - mockAxios.post.mockClear(); - const criticalAlert = { ...mockAlert, rule: { ...mockAlert.rule, severity: 'critical' } }; - await alertManager.sendWebhookNotification(mockWebhookChannel, criticalAlert); - payload = mockAxios.post.mock.calls[0][1]; - expect(payload.attachments[0].color).toBe('danger'); - - // Test info severity - mockAxios.post.mockClear(); - const infoAlert = { ...mockAlert, rule: { ...mockAlert.rule, severity: 'info' } }; - await alertManager.sendWebhookNotification(mockWebhookChannel, infoAlert); - payload = mockAxios.post.mock.calls[0][1]; - expect(payload.attachments[0].color).toBe('good'); - }); - }); - - describe('Webhook Error Handling', () => { - test('should throw error when webhook URL is not configured', async () => { - const channelWithoutUrl = { ...mockWebhookChannel }; - delete channelWithoutUrl.config.url; - - await expect( - alertManager.sendWebhookNotification(channelWithoutUrl, mockAlert) - ).rejects.toThrow('Webhook URL not configured'); - }); - - test('should handle HTTP errors gracefully', async () => { - mockAxios.post.mockRejectedValue({ - response: { status: 404, statusText: 'Not Found' } - }); - - await expect( - alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert) - ).rejects.toThrow('Webhook failed after 3 attempts: 404 Not Found'); - }); - - test('should handle network errors gracefully', async () => { - mockAxios.post.mockRejectedValue({ - request: {} - }); - - await expect( - alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert) - ).rejects.toThrow(`Webhook failed after 3 attempts: No response from ${mockWebhookChannel.config.url}`); - }); - - test('should handle other errors gracefully', async () => { - const errorMessage = 'Connection timeout'; - mockAxios.post.mockRejectedValue(new Error(errorMessage)); - - await expect( - alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert) - ).rejects.toThrow(`Webhook failed after 3 attempts: ${errorMessage}`); - }); - }); - - describe('Email Notification Timestamp Fix', () => { - test('should use correct timestamp fields in email templates', () => { - // This test verifies that the email templates use the same timestamp fallback logic - const emailHtml = alertManager.generateEmailTemplate(mockAlert); - - // The email should contain a formatted timestamp that doesn't throw errors - expect(emailHtml).toContain(new Date(mockAlert.triggeredAt).toLocaleString()); - - // Test with missing triggeredAt - const alertWithoutTriggeredAt = { ...mockAlert }; - delete alertWithoutTriggeredAt.triggeredAt; - - const emailHtmlFallback = alertManager.generateEmailTemplate(alertWithoutTriggeredAt); - expect(emailHtmlFallback).toContain(new Date(mockAlert.lastUpdate).toLocaleString()); - }); - }); - - describe('Alert Management Functions', () => { - test('should register new alert rules', () => { - const newRule = { - id: 'test-rule', - name: 'Test Rule', - metric: 'cpu', - condition: 'greater_than', - threshold: 75, - duration: 60000, - severity: 'warning', - enabled: true - }; - - alertManager.addRule(newRule); - expect(alertManager.alertRules.has('test-rule')).toBe(true); - expect(alertManager.alertRules.get('test-rule')).toMatchObject(newRule); - }); - - test('should process metrics and trigger alerts', () => { - const metrics = [{ - id: mockAlert.guest.vmid, - endpointName: 'test-endpoint', - current: { cpu: 95 }, // Above critical threshold - guest: mockAlert.guest - }]; - - const triggeredAlerts = alertManager.processMetrics(metrics); - expect(Array.isArray(triggeredAlerts)).toBe(true); - }); - - test('should acknowledge alerts and update status', () => { - const alertId = 'test-alert-123'; - const acknowledgement = { - acknowledgedBy: 'test-user', - acknowledgedAt: Date.now(), - reason: 'Planned maintenance' - }; - - alertManager.acknowledgeAlert(alertId, acknowledgement); - expect(alertManager.acknowledgedAlerts.has(alertId)).toBe(true); - expect(alertManager.acknowledgedAlerts.get(alertId)).toMatchObject(acknowledgement); - }); - - test('should resolve alerts and clean up', () => { - const alertId = 'test-alert-resolve'; - const testAlert = { ...mockAlert, id: alertId }; - - alertManager.activeAlerts.set(alertId, testAlert); - alertManager.resolveAlert(alertId); - - expect(alertManager.activeAlerts.has(alertId)).toBe(false); - expect(alertManager.alertHistory.some(a => a.id === alertId && a.resolved)).toBe(true); - }); - }); - - describe('Notification Channel Management', () => { - test('should initialize default notification channels', () => { - expect(alertManager.notificationChannels.size).toBeGreaterThan(0); - expect(alertManager.notificationChannels.has('default')).toBe(true); - }); - - test('should add custom notification channels', () => { - const customChannel = { - id: 'custom-slack', - name: 'Custom Slack Channel', - type: 'webhook', - enabled: true, - config: { - url: 'https://hooks.slack.com/custom-webhook', - method: 'POST', - headers: { 'Content-Type': 'application/json' } - } - }; - - alertManager.addNotificationChannel(customChannel); - expect(alertManager.notificationChannels.has('custom-slack')).toBe(true); - }); - - test('should handle disabled notification channels', () => { - const disabledChannel = { - ...mockWebhookChannel, - enabled: false - }; - - alertManager.addNotificationChannel(disabledChannel); - const result = alertManager.shouldSendNotification(disabledChannel.id, mockAlert); - expect(result).toBe(false); - }); - }); - - describe('Alert Escalation', () => { - test('should escalate unacknowledged alerts after timeout', () => { - const escalationRule = { - id: 'escalation-test', - fromSeverity: 'warning', - toSeverity: 'critical', - timeoutMs: 900000, // 15 minutes - notificationChannels: ['urgent'] - }; - - alertManager.addEscalationRule(escalationRule); - expect(alertManager.escalationRules.has('escalation-test')).toBe(true); - - // Test escalation logic - const oldAlert = { - ...mockAlert, - triggeredAt: Date.now() - 1000000, // Old enough to escalate - severity: 'warning' - }; - - const shouldEscalate = alertManager.shouldEscalateAlert(oldAlert); - expect(shouldEscalate).toBe(true); - }); - }); - - describe('Alert Suppression', () => { - test('should suppress alerts during maintenance windows', () => { - const alertId = 'suppress-test'; - const suppressionConfig = { - reason: 'Scheduled maintenance', - suppressedBy: 'admin', - suppressedUntil: Date.now() + 3600000 // 1 hour - }; - - alertManager.suppressAlert(alertId, suppressionConfig); - expect(alertManager.suppressedAlerts.has(alertId)).toBe(true); - - const isSuppressed = alertManager.isAlertSuppressed(alertId); - expect(isSuppressed).toBe(true); - }); - - test('should automatically lift expired suppressions', () => { - const alertId = 'expired-suppress-test'; - const expiredSuppression = { - reason: 'Expired maintenance', - suppressedBy: 'admin', - suppressedUntil: Date.now() - 1000 // Already expired - }; - - alertManager.suppressedAlerts.set(alertId, expiredSuppression); - const isSuppressed = alertManager.isAlertSuppressed(alertId); - expect(isSuppressed).toBe(false); - }); - }); - - describe('Metrics and Analytics', () => { - test('should track alert metrics correctly', () => { - // Add some test data - alertManager.alertMetrics.totalFired = 10; - alertManager.alertMetrics.totalResolved = 8; - alertManager.alertMetrics.totalAcknowledged = 5; - - alertManager.updateMetrics(); - - expect(alertManager.alertMetrics.totalFired).toBe(10); - expect(alertManager.alertMetrics.totalResolved).toBe(8); - expect(alertManager.alertMetrics.totalAcknowledged).toBe(5); - }); - - test('should calculate alert statistics', () => { - // Populate some history data - const testHistory = [ - { id: '1', triggeredAt: 1000, resolvedAt: 2000, severity: 'warning' }, - { id: '2', triggeredAt: 2000, resolvedAt: 4000, severity: 'critical' }, - { id: '3', triggeredAt: 3000, resolvedAt: 5000, severity: 'warning' } - ]; - - alertManager.alertHistory = testHistory; - const stats = alertManager.getAlertStatistics(); - - expect(stats).toHaveProperty('totalAlerts'); - expect(stats).toHaveProperty('averageResolutionTime'); - expect(stats).toHaveProperty('severityBreakdown'); - }); - }); -}); - -// Helper to simulate the email template generation (since it's inline in the actual code) -AlertManager.prototype.generateEmailTemplate = function(alert) { - const testEmailTemplate = ` - ${new Date(alert.triggeredAt || alert.lastUpdate || Date.now()).toLocaleString()} - `; - return testEmailTemplate; -}; - -// Add helper methods for testing -AlertManager.prototype.addRule = function(rule) { - this.alertRules.set(rule.id, rule); -}; - -AlertManager.prototype.addNotificationChannel = function(channel) { - this.notificationChannels.set(channel.id, channel); -}; - -AlertManager.prototype.addEscalationRule = function(rule) { - this.escalationRules.set(rule.id, rule); -}; - -AlertManager.prototype.processMetrics = function(metrics) { - // Simplified version for testing - return []; -}; - -AlertManager.prototype.acknowledgeAlert = function(alertId, acknowledgement) { - this.acknowledgedAlerts.set(alertId, acknowledgement); -}; - -AlertManager.prototype.resolveAlert = function(alertId) { - const alert = this.activeAlerts.get(alertId); - if (alert) { - this.activeAlerts.delete(alertId); - this.alertHistory.push({ ...alert, resolved: true, resolvedAt: Date.now() }); - } -}; - -AlertManager.prototype.shouldSendNotification = function(channelId, alert) { - const channel = this.notificationChannels.get(channelId); - return channel && channel.enabled; -}; - -AlertManager.prototype.shouldEscalateAlert = function(alert) { - const alertAge = Date.now() - alert.triggeredAt; - return alertAge > 900000 && !this.acknowledgedAlerts.has(alert.id); -}; - -AlertManager.prototype.suppressAlert = function(alertId, config) { - this.suppressedAlerts.set(alertId, config); -}; - -AlertManager.prototype.isAlertSuppressed = function(alertId) { - const suppression = this.suppressedAlerts.get(alertId); - if (!suppression) return false; - - if (suppression.suppressedUntil < Date.now()) { - this.suppressedAlerts.delete(alertId); - return false; - } - return true; -}; - -AlertManager.prototype.updateMetrics = function() { - // Update metrics calculation -}; - -AlertManager.prototype.getAlertStatistics = function() { - const resolved = this.alertHistory.filter(a => a.resolvedAt); - const avgResolution = resolved.length > 0 - ? resolved.reduce((sum, a) => sum + (a.resolvedAt - a.triggeredAt), 0) / resolved.length - : 0; - - const severityBreakdown = this.alertHistory.reduce((acc, alert) => { - acc[alert.severity] = (acc[alert.severity] || 0) + 1; - return acc; - }, {}); - - return { - totalAlerts: this.alertHistory.length, - averageResolutionTime: avgResolution, - severityBreakdown - }; -}; \ No newline at end of file diff --git a/tests/apiClients.test.js b/tests/apiClients.test.js deleted file mode 100644 index 868e10f40..000000000 --- a/tests/apiClients.test.js +++ /dev/null @@ -1,992 +0,0 @@ -// Mock dependencies *before* importing the module that uses them -jest.mock('../configLoader'); -jest.mock('axios'); // <-- Mock axios instead - -// Mock axios-retry: Create a mock function for default, attach *mocked* helpers to it. -jest.mock('axios-retry', () => { - // We don't need requireActual here anymore if we mock the helpers - // const actualAxiosRetry = jest.requireActual('axios-retry'); - - // Create a mock function for the default export - const mockDefaultFn = jest.fn(); - - // Attach JEST MOCK FUNCTIONS for the helpers to the default export mock - mockDefaultFn.isNetworkError = jest.fn(); - mockDefaultFn.isRetryableError = jest.fn(); - mockDefaultFn.exponentialDelay = jest.fn(); - - // The module export - return { - __esModule: true, - default: mockDefaultFn, - // Also provide the JEST MOCK FUNCTIONS on the main module object for completeness - isNetworkError: mockDefaultFn.isNetworkError, // Point to the same mock fn - isRetryableError: mockDefaultFn.isRetryableError, // Point to the same mock fn - exponentialDelay: mockDefaultFn.exponentialDelay, // Point to the same mock fn - }; -}); - -const { initializeApiClients, createApiClientInstance } = require('../apiClients'); -const { loadConfiguration } = require('../configLoader'); -const axios = require('axios'); // <-- Get the mocked axios -const axiosRetry = require('axios-retry').default; // <-- Get the mocked default export -// const proxmoxApi = require('proxmox-api'); // <-- Remove this - -// Mock console to avoid cluttering test output -// jest.spyOn(console, 'log').mockImplementation(() => {}); -// jest.spyOn(console, 'error').mockImplementation(() => {}); - -describe('API Clients Initialization', () => { - let originalEnv; - // Remove the shared mock instance definition from here - // const mockAxiosInstance = { ... }; - - beforeEach(() => { - originalEnv = { ...process.env }; - jest.resetModules(); - jest.clearAllMocks(); - - // Configure axios.create to return a *new* mock instance each time - axios.create.mockImplementation(() => ({ - get: jest.fn(), - interceptors: { - request: { use: jest.fn() }, - response: { use: jest.fn() } // <-- Add response interceptor mock - } - })); - - loadConfiguration.mockReturnValue({ - endpoints: [{ - id: 'pve1', - name: 'PVE Test 1', - host: '1.1.1.1', - port: '8006', // Add port for baseURL construction - username: 'root@pam', - tokenId: 'pve-token-id', - tokenSecret: 'pve-token-secret', - enabled: true, - allowSelfSignedCerts: false // Add for httpsAgent - }], - pbsConfigs: [{ - id: 'pbs1', - name: 'PBS Test 1', - host: '2.2.2.2', - port: '8007', // Add port for baseURL construction - username: 'root@pam', - tokenId: 'pbs-token-id', - tokenSecret: 'pbs-token-secret', - authMethod: 'token', - allowSelfSignedCerts: false // Add for httpsAgent - }], - }); - - }); - - afterEach(() => { - const currentEnvKeys = Object.keys(process.env); - currentEnvKeys.forEach(key => delete process.env[key]); - Object.keys(originalEnv).forEach(key => { process.env[key] = originalEnv[key]; }); - }); - - test('should initialize PVE and PBS clients successfully with token auth', async () => { - // Arrange - const { endpoints, pbsConfigs } = loadConfiguration(); - - // Act - const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs); - - // Assert - expect(loadConfiguration).toHaveBeenCalledTimes(1); - expect(axios.create).toHaveBeenCalledTimes(2); - - expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({ - baseURL: `https://${endpoints[0].host}:${endpoints[0].port}/api2/json`, - })); - expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({ - baseURL: `https://${pbsConfigs[0].host}:${pbsConfigs[0].port}/api2/json`, - })); - - // Check interceptors were configured ON EACH client - // Axios.create().mock.results gives us the return values (the mock instances) - // Expect 1 call for manual auth header (axiosRetry mock doesn't add one by default) - expect(axios.create.mock.results[0].value.interceptors.request.use).toHaveBeenCalledTimes(1); // PVE client - expect(axios.create.mock.results[1].value.interceptors.request.use).toHaveBeenCalledTimes(1); // PBS client - // We could also check the response interceptor use if axios-retry was mocked to verify its calls - - // Check returned client structure - expect(apiClients).toHaveProperty('pve1'); - expect(apiClients.pve1.client).toBe(axios.create.mock.results[0].value); // Check it's the first mock instance - expect(apiClients.pve1.config).toEqual(endpoints[0]); - - expect(pbsApiClients).toHaveProperty('pbs1'); - expect(pbsApiClients.pbs1.client).toBe(axios.create.mock.results[1].value); // Check it's the second mock instance - expect(pbsApiClients.pbs1.config).toEqual(pbsConfigs[0]); - }); - - test('should handle missing PVE endpoints gracefully', async () => { - // Arrange - loadConfiguration.mockReturnValue({ - endpoints: [], - pbsConfigs: [{ - id: 'pbs1', - name: 'PBS Test 1', - host: '2.2.2.2', - port: '8007', - username: 'root@pam', - tokenId: 'pbs-token-id', - tokenSecret: 'pbs-token-secret', - authMethod: 'token', - allowSelfSignedCerts: false - }], - }); - const { endpoints, pbsConfigs } = loadConfiguration(); - - // Act - const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs); - - // Assert - expect(axios.create).toHaveBeenCalledTimes(1); - expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({ - baseURL: `https://${pbsConfigs[0].host}:${pbsConfigs[0].port}/api2/json` - })); - // Check interceptor on the *single* created client - // Expect 1 call for manual auth header - expect(axios.create.mock.results[0].value.interceptors.request.use).toHaveBeenCalledTimes(1); - expect(apiClients).toEqual({}); - expect(pbsApiClients).toHaveProperty('pbs1'); - expect(pbsApiClients.pbs1.client).toBe(axios.create.mock.results[0].value); // The only mock instance created - }); - - test('should handle missing PBS endpoints gracefully', async () => { - // Arrange - loadConfiguration.mockReturnValue({ - endpoints: [{ - id: 'pve1', - name: 'PVE Test 1', - host: '1.1.1.1', - port: '8006', - username: 'root@pam', - tokenId: 'pve-token-id', - tokenSecret: 'pve-token-secret', - enabled: true, - allowSelfSignedCerts: false - }], - pbsConfigs: [], - }); - const { endpoints, pbsConfigs } = loadConfiguration(); - - // Act - const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs); - - // Assert - expect(axios.create).toHaveBeenCalledTimes(1); - expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({ - baseURL: `https://${endpoints[0].host}:${endpoints[0].port}/api2/json` - })); - // Check interceptor on the *single* created client - // Expect 1 call for manual auth header - expect(axios.create.mock.results[0].value.interceptors.request.use).toHaveBeenCalledTimes(1); - expect(pbsApiClients).toEqual({}); - expect(apiClients).toHaveProperty('pve1'); - expect(apiClients.pve1.client).toBe(axios.create.mock.results[0].value); // The only mock instance created - }); - - test('should skip PVE endpoint if tokenId is missing', async () => { - // Arrange - loadConfiguration.mockReturnValue({ - endpoints: [{ - id: 'pve-no-tokenid', - name: 'PVE Missing Token ID', - host: '3.3.3.3', - port: '8006', - username: 'root@pam', - // tokenId: 'pve-token-id', // MISSING - tokenSecret: 'pve-token-secret', - enabled: true, - allowSelfSignedCerts: false - }], - pbsConfigs: [], - }); - const { endpoints, pbsConfigs } = loadConfiguration(); - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); // Spy on console.error - - // Act - const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs); - - // Assert - expect(axios.create).toHaveBeenCalledTimes(1); // Still creates the instance initially - const createdInstance = axios.create.mock.results[0].value; - // Check that the interceptor did NOT log an error during init - expect(consoleErrorSpy).not.toHaveBeenCalled(); - // The client *is* created, even with missing credentials - expect(apiClients).toHaveProperty('pve-no-tokenid'); - expect(apiClients['pve-no-tokenid'].client).toBe(createdInstance); - expect(pbsApiClients).toEqual({}); - - consoleErrorSpy.mockRestore(); - }); - - test('should skip PVE endpoint if tokenSecret is missing', async () => { - // Arrange - loadConfiguration.mockReturnValue({ - endpoints: [{ - id: 'pve-no-secret', - name: 'PVE Missing Secret', - host: '4.4.4.4', - port: '8006', - username: 'root@pam', - tokenId: 'pve-token-id', - // tokenSecret: 'pve-token-secret', // MISSING - enabled: true, - allowSelfSignedCerts: false - }], - pbsConfigs: [], - }); - const { endpoints, pbsConfigs } = loadConfiguration(); - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - - // Act - const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs); - - // Assert - expect(axios.create).toHaveBeenCalledTimes(1); - // Check that the interceptor did NOT log an error during init - expect(consoleErrorSpy).not.toHaveBeenCalled(); - // The client *is* created, even with missing credentials - expect(apiClients).toHaveProperty('pve-no-secret'); - expect(pbsApiClients).toEqual({}); - - consoleErrorSpy.mockRestore(); - }); - - test('should skip PVE endpoint if enabled is false', async () => { - // Arrange - loadConfiguration.mockReturnValue({ - endpoints: [{ - id: 'pve-disabled', - name: 'PVE Disabled', - host: '5.5.5.5', - port: '8006', - username: 'root@pam', - tokenId: 'pve-token-id', - tokenSecret: 'pve-token-secret', - enabled: false, // DISABLED - allowSelfSignedCerts: false - }], - pbsConfigs: [], - }); - const { endpoints, pbsConfigs } = loadConfiguration(); - const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); // Spy on console.log - - // Act - const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs); - - // Assert - expect(axios.create).not.toHaveBeenCalled(); // Should not attempt to create client - expect(consoleLogSpy).toHaveBeenCalledWith('INFO: Skipping disabled PVE endpoint: PVE Disabled (5.5.5.5)'); - expect(apiClients).toEqual({}); - expect(pbsApiClients).toEqual({}); - - consoleLogSpy.mockRestore(); - }); - - test('should set rejectUnauthorized to false when allowSelfSignedCerts is true', async () => { - // Arrange - loadConfiguration.mockReturnValue({ - endpoints: [{ - id: 'pve-self-signed', - name: 'PVE Self Signed', - host: '6.6.6.6', - port: '8006', - username: 'root@pam', - tokenId: 'pve-token-id', - tokenSecret: 'pve-token-secret', - enabled: true, - allowSelfSignedCerts: true // ALLOW SELF SIGNED - }], - pbsConfigs: [], - }); - const { endpoints, pbsConfigs } = loadConfiguration(); - - // Act - await initializeApiClients(endpoints, pbsConfigs); - - // Assert - expect(axios.create).toHaveBeenCalledTimes(1); - expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({ - httpsAgent: expect.objectContaining({ - options: expect.objectContaining({ rejectUnauthorized: false }) // Key assertion - }) - })); - }); - - test('should set rejectUnauthorized to true when allowSelfSignedCerts is false', async () => { - // Arrange - loadConfiguration.mockReturnValue({ - endpoints: [{ - id: 'pve-strict-ssl', - name: 'PVE Strict SSL', - host: '7.7.7.7', - port: '8006', - username: 'root@pam', - tokenId: 'pve-token-id', - tokenSecret: 'pve-token-secret', - enabled: true, - allowSelfSignedCerts: false // STRICT SSL - }], - pbsConfigs: [], - }); - const { endpoints, pbsConfigs } = loadConfiguration(); - - // Act - await initializeApiClients(endpoints, pbsConfigs); - - // Assert - expect(axios.create).toHaveBeenCalledTimes(1); - expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({ - httpsAgent: expect.objectContaining({ - options: expect.objectContaining({ rejectUnauthorized: true }) // Key assertion - }) - })); - }); - - test('should initialize multiple PVE and PBS endpoints', async () => { - // Arrange - loadConfiguration.mockReturnValue({ - endpoints: [ - { id: 'pve1', name: 'PVE 1', host: '1.1.1.1', port: '8006', username: 'root@pam', tokenId: 't1', tokenSecret: 's1', enabled: true, allowSelfSignedCerts: false }, - { id: 'pve2', name: 'PVE 2', host: '1.1.1.2', port: '8006', username: 'root@pam', tokenId: 't2', tokenSecret: 's2', enabled: true, allowSelfSignedCerts: true }, - { id: 'pve3-disabled', name: 'PVE 3', host: '1.1.1.3', port: '8006', username: 'root@pam', tokenId: 't3', tokenSecret: 's3', enabled: false, allowSelfSignedCerts: false }, // Disabled PVE - ], - pbsConfigs: [ - { id: 'pbs1', name: 'PBS 1', host: '2.2.2.1', port: '8007', username: 'root@pam', tokenId: 'pbst1', tokenSecret: 'pbss1', authMethod: 'token', allowSelfSignedCerts: false }, - { id: 'pbs2', name: 'PBS 2', host: '2.2.2.2', port: '8007', username: 'root@pam', tokenId: 'pbst2', tokenSecret: 'pbss2', authMethod: 'token', allowSelfSignedCerts: true }, - ], - }); - const { endpoints, pbsConfigs } = loadConfiguration(); - const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); - - // Act - const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs); - - // Assert - expect(consoleLogSpy).toHaveBeenCalledWith('INFO: Skipping disabled PVE endpoint: PVE 3 (1.1.1.3)'); - expect(axios.create).toHaveBeenCalledTimes(4); // 2 enabled PVE + 2 PBS - - // Check PVE clients - expect(Object.keys(apiClients)).toHaveLength(2); // Only enabled ones - expect(apiClients).toHaveProperty('pve1'); - expect(apiClients).toHaveProperty('pve2'); - expect(apiClients).not.toHaveProperty('pve3-disabled'); - - // Check specific rejectUnauthorized for PVE clients - const pve1Args = axios.create.mock.calls.find(call => call[0].baseURL.includes('1.1.1.1')); - const pve2Args = axios.create.mock.calls.find(call => call[0].baseURL.includes('1.1.1.2')); - expect(pve1Args[0].httpsAgent.options.rejectUnauthorized).toBe(true); - expect(pve2Args[0].httpsAgent.options.rejectUnauthorized).toBe(false); - - // Check PBS clients - expect(Object.keys(pbsApiClients)).toHaveLength(2); - expect(pbsApiClients).toHaveProperty('pbs1'); - expect(pbsApiClients).toHaveProperty('pbs2'); - - // Check specific rejectUnauthorized for PBS clients - const pbs1Args = axios.create.mock.calls.find(call => call[0].baseURL.includes('2.2.2.1')); - const pbs2Args = axios.create.mock.calls.find(call => call[0].baseURL.includes('2.2.2.2')); - expect(pbs1Args[0].httpsAgent.options.rejectUnauthorized).toBe(true); - expect(pbs2Args[0].httpsAgent.options.rejectUnauthorized).toBe(false); - - consoleLogSpy.mockRestore(); - }); - - test('should handle unexpected PBS authMethod', async () => { - // Arrange - loadConfiguration.mockReturnValue({ - endpoints: [], // No PVE for simplicity - pbsConfigs: [{ - id: 'pbs-bad-auth', - name: 'PBS Bad Auth', - host: '8.8.8.8', - port: '8007', - authMethod: 'password', // Unexpected method - allowSelfSignedCerts: false - }], - }); - const { endpoints, pbsConfigs } = loadConfiguration(); - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - - // Act - const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs); - - // Assert - expect(axios.create).not.toHaveBeenCalled(); // Client should not be created for this PBS - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining(`Unexpected authMethod 'password' found during PBS client initialization for: PBS Bad Auth`) - ); - expect(apiClients).toEqual({}); - expect(pbsApiClients).toEqual({}); // No client should be added - - consoleErrorSpy.mockRestore(); - }); - - test('should handle unhandled exception during PBS client map', async () => { - // Arrange - loadConfiguration.mockReturnValue({ - endpoints: [], - pbsConfigs: [{ - id: 'pbs-map-error', - name: 'PBS Map Error', - host: '9.9.9.9', - port: '8007', - tokenId: 't', tokenSecret: 's', // Valid creds - authMethod: 'token', - allowSelfSignedCerts: false - }], - }); - const { endpoints, pbsConfigs } = loadConfiguration(); - const mapError = new Error('Simulated map error'); - // Force axios.create to throw error only for this specific host - const originalAxiosCreate = axios.create; - axios.create.mockImplementation((config) => { - if (config.baseURL.includes('9.9.9.9')) { - throw mapError; - } - // Call original mock impl for other cases (if any) - return originalAxiosCreate(); - }); - - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - - // Act - const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs); - - // Assert - expect(axios.create).toHaveBeenCalledTimes(1); // Attempted to create - // Check the first argument contains the core message, allow anything for the second (stack trace) - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining(`ERROR: Unhandled exception during PBS client initialization for PBS Map Error: ${mapError.message}`), - expect.anything() // Allow the stack trace as the second argument - ); - expect(apiClients).toEqual({}); - expect(pbsApiClients).toEqual({}); // Client not added due to error - - // Restore original mock implementation if needed for other tests - axios.create.mockImplementation(originalAxiosCreate); - consoleErrorSpy.mockRestore(); - }); - - // --- Tests for Retry Logic --- - test('should call axiosRetry during initialization', async () => { - // Simple test to ensure axiosRetry is called during init - const { endpoints, pbsConfigs } = loadConfiguration(); - await initializeApiClients(endpoints, pbsConfigs); - // Expect 1 call for PVE client + 1 call for PBS client from default setup - expect(axiosRetry).toHaveBeenCalledTimes(2); - // Check args for the PVE client call - expect(axiosRetry).toHaveBeenCalledWith( - axios.create.mock.results[0].value, // The first created axios instance - expect.objectContaining({ retries: 3 }) // Check if retry config is passed - ); - }); - - test('should log error when PVE request interceptor encounters missing credentials', async () => { - // Arrange - const missingCredsEndpoint = { - id: 'pve-bad-creds', - name: 'PVE Missing Creds', - host: '11.11.11.11', - port: '8006', - // Missing tokenId and tokenSecret - enabled: true, - allowSelfSignedCerts: false - }; - loadConfiguration.mockReturnValue({ endpoints: [missingCredsEndpoint], pbsConfigs: [] }); - const { endpoints, pbsConfigs } = loadConfiguration(); - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - - // Mock axios.create specifically for this test - let capturedInterceptor = null; // Variable to hold the interceptor function - const mockGet = jest.fn().mockResolvedValue({ data: 'ignored' }); - const mockAxiosInstance = { - get: async (url, config) => { - // Simulate running the interceptor before the request - if (capturedInterceptor) { - // Pass a mock config object, interceptor might modify it - const mockConfig = { headers: {}, url, ...config }; - try { - await capturedInterceptor(mockConfig); // Run the interceptor - } catch (interceptorError) { - // If interceptor throws (e.g., Promise.reject), rethrow it - throw interceptorError; - } - } - return mockGet(url, config); // Run the actual mock get - }, - interceptors: { - request: { - use: jest.fn(successFn => { // Capture the interceptor function - capturedInterceptor = successFn; - }) - }, - response: { use: jest.fn() } - } - }; - axios.create.mockReturnValue(mockAxiosInstance); - - // Act: Initialize clients (this adds the interceptor via the mock .use) - const { apiClients } = await initializeApiClients(endpoints, pbsConfigs); - const pveClient = apiClients['pve-bad-creds']?.client; - expect(pveClient).toBeDefined(); - expect(capturedInterceptor).not.toBeNull(); // Check interceptor was captured - - // Act: Attempt an API call which should trigger the interceptor via the mock .get - try { - await pveClient.get('/nodes'); - } catch (e) { - // We don't expect the get call itself to throw here, - // the interceptor just logs an error in this case. - } - - // Assert: Check that the console error was logged by the interceptor - expect(consoleErrorSpy).toHaveBeenCalled(); - expect(consoleErrorSpy).toHaveBeenCalledWith( - `ERROR: Endpoint ${missingCredsEndpoint.name} is missing required API token credentials.` - ); - - consoleErrorSpy.mockRestore(); - // Restore default axios.create mock from beforeEach - axios.create.mockImplementation(() => ({ - get: jest.fn(), - interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } } - })); - }); - - // Removing the complex/brittle retry simulation tests below as the core logic - // is now tested via the helper function tests (pbsRetryDelayLogger, pbsRetryConditionChecker) - // and the basic call is verified by 'should call axiosRetry during initialization'. - - /* - test('should retry PVE API calls on network errors', async () => { - // ... (Removed Test Code) ... - }); - */ - - /* - test('should retry PBS API calls on retryable errors and log warning', async () => { - // ... (Removed Test Code) ... - }); - */ - - // Add more tests here for: - // - Config validation errors (missing fields in loadConfiguration result) - // - Axios errors during initialization (e.g., interceptor setup fails? unlikely) - // - Multiple endpoints for PVE/PBS - // - Different auth methods (if implemented) - // - rejectUnauthorized logic - - test('should correctly build baseURL for hosts with and without protocol', async () => { - // Arrange - loadConfiguration.mockReturnValue({ - endpoints: [ - { id: 'pve-no-proto', name: 'PVE No Protocol', host: '1.1.1.1', port: '8006', enabled: true, tokenId: 't1', tokenSecret: 's1', allowSelfSignedCerts: false }, - { id: 'pve-with-proto', name: 'PVE With Protocol', host: 'https://1.1.1.2', port: '8006', enabled: true, tokenId: 't2', tokenSecret: 's2', allowSelfSignedCerts: false }, - ], - pbsConfigs: [ - { id: 'pbs-no-proto', name: 'PBS No Protocol', host: '2.2.2.1', port: '8007', authMethod: 'token', tokenId: 'pt1', tokenSecret: 'ps1', allowSelfSignedCerts: false }, - { id: 'pbs-with-proto', name: 'PBS With Protocol', host: 'https://2.2.2.2', port: '8007', authMethod: 'token', tokenId: 'pt2', tokenSecret: 'ps2', allowSelfSignedCerts: false }, - ], - }); - const { endpoints, pbsConfigs } = loadConfiguration(); - - // Act - await initializeApiClients(endpoints, pbsConfigs); - - // Assert - expect(axios.create).toHaveBeenCalledTimes(4); // 2 PVE + 2 PBS - - // Check PVE Base URLs - const pveNoProtoArgs = axios.create.mock.calls.find(call => call[0].baseURL?.includes('1.1.1.1')); - const pveWithProtoArgs = axios.create.mock.calls.find(call => call[0].baseURL?.includes('1.1.1.2')); - expect(pveNoProtoArgs[0].baseURL).toBe('https://1.1.1.1:8006/api2/json'); // Checks the ':' branch (line 63) - expect(pveWithProtoArgs[0].baseURL).toBe('https://1.1.1.2/api2/json'); // Checks the '?' branch (line 62) - - // Check PBS Base URLs - const pbsNoProtoArgs = axios.create.mock.calls.find(call => call[0].baseURL?.includes('2.2.2.1')); - const pbsWithProtoArgs = axios.create.mock.calls.find(call => call[0].baseURL?.includes('2.2.2.2')); - expect(pbsNoProtoArgs[0].baseURL).toBe('https://2.2.2.1:8007/api2/json'); // Checks the ':' branch (line 144) - expect(pbsWithProtoArgs[0].baseURL).toBe('https://2.2.2.2/api2/json'); // Checks the '?' branch (line 143) - }); - -}); - -// --- Direct Tests for Helper Functions --- - -describe('API Client Helper Functions', () => { - - beforeEach(() => { - jest.clearAllMocks(); - }); - - // --- Tests for createApiClientInstance --- - describe('createApiClientInstance', () => { - const { createApiClientInstance } = require('../apiClients'); - const axios = require('axios'); // Mocked axios - const axiosRetry = require('axios-retry').default; // Mocked axiosRetry - - beforeEach(() => { - // Reset axios.create and axiosRetry mocks - axios.create.mockClear(); - axiosRetry.mockClear(); - // Reconfigure axios.create to return a mock instance with spied interceptors - axios.create.mockImplementation(() => ({ - get: jest.fn(), - interceptors: { - request: { use: jest.fn() }, - response: { use: jest.fn() } - } - })); - }); - - test('should create an instance with provided baseURL and httpsAgent config', () => { - const baseURL = 'https://test.com/api'; - const allowSelfSignedCerts = true; - createApiClientInstance(baseURL, allowSelfSignedCerts); - - expect(axios.create).toHaveBeenCalledTimes(1); - expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({ - baseURL: baseURL, - httpsAgent: expect.objectContaining({ - options: expect.objectContaining({ rejectUnauthorized: false }) - }), - headers: { 'Content-Type': 'application/json' } - })); - }); - - test('should call request.use when authInterceptor is provided', () => { - const mockInterceptor = jest.fn(); - const apiClient = createApiClientInstance('https://test.com', false, mockInterceptor, null); // Pass null for retryConfig - - expect(apiClient.interceptors.request.use).toHaveBeenCalledTimes(1); - expect(apiClient.interceptors.request.use).toHaveBeenCalledWith(mockInterceptor); - }); - - test('should NOT call request.use when authInterceptor is NOT provided', () => { - const apiClient = createApiClientInstance('https://test.com', false, null, null); // Pass null for both - - expect(apiClient.interceptors.request.use).not.toHaveBeenCalled(); - }); - - test('should call axiosRetry when retryConfig is provided', () => { - const mockRetryConfig = { retries: 5, retryDelayLogger: jest.fn(), retryConditionChecker: jest.fn() }; - const apiClient = createApiClientInstance('https://test.com', false, null, mockRetryConfig); - - expect(axiosRetry).toHaveBeenCalledTimes(1); - expect(axiosRetry).toHaveBeenCalledWith(apiClient, { - retries: mockRetryConfig.retries, - retryDelay: mockRetryConfig.retryDelayLogger, // Now correctly accesses the logger - retryCondition: mockRetryConfig.retryConditionChecker, // Now correctly accesses the checker - }); - }); - - test('should NOT call axiosRetry when retryConfig is NOT provided', () => { - createApiClientInstance('https://test.com', false, null, null); // Pass null for both - - expect(axiosRetry).not.toHaveBeenCalled(); - }); - - }); - - // --- createPveAuthInterceptor Tests --- - - // --- createPveAuthInterceptor Tests --- - describe('createPveAuthInterceptor', () => { - const { createPveAuthInterceptor } = require('../apiClients'); - const mockEndpoint = { name: 'Test PVE', tokenId: 'test-id', tokenSecret: 'test-secret' }; - const mockEndpointMissingCreds = { name: 'Test PVE Bad' }; // Missing credentials - - test('should return a function', () => { - const interceptor = createPveAuthInterceptor(mockEndpoint); - expect(typeof interceptor).toBe('function'); - }); - - test('should add Authorization header if credentials exist', () => { - const interceptor = createPveAuthInterceptor(mockEndpoint); - const mockConfig = { headers: {} }; - const resultConfig = interceptor(mockConfig); - expect(resultConfig.headers.Authorization).toBe(`PVEAPIToken=test-id=test-secret`); - }); - - test('should NOT add Authorization header and log error if credentials missing', () => { - const interceptor = createPveAuthInterceptor(mockEndpointMissingCreds); - const mockConfig = { headers: {} }; - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - - const resultConfig = interceptor(mockConfig); - - expect(resultConfig.headers.Authorization).toBeUndefined(); - expect(consoleErrorSpy).toHaveBeenCalledTimes(1); - expect(consoleErrorSpy).toHaveBeenCalledWith( - `ERROR: Endpoint ${mockEndpointMissingCreds.name} is missing required API token credentials.` - ); - consoleErrorSpy.mockRestore(); - }); - }); - - // --- createPbsAuthInterceptor Tests --- - describe('createPbsAuthInterceptor', () => { - const { createPbsAuthInterceptor } = require('../apiClients'); - const mockConfig = { tokenId: 'pbs-id', tokenSecret: 'pbs-secret' }; - - test('should return a function', () => { - const interceptor = createPbsAuthInterceptor(mockConfig); - expect(typeof interceptor).toBe('function'); - }); - - test('should add correct PBS Authorization header', () => { - const interceptor = createPbsAuthInterceptor(mockConfig); - const mockReqConfig = { headers: {} }; - const resultConfig = interceptor(mockReqConfig); - expect(resultConfig.headers.Authorization).toBe(`PBSAPIToken=pbs-id:pbs-secret`); - }); - - // Note: Add test for missing creds if validation doesn't happen before calling this - }); - - // --- Tests for createApiClientInstance --- - describe('createApiClientInstance', () => { - const { createApiClientInstance } = require('../apiClients'); - const axios = require('axios'); // Mocked axios - const axiosRetry = require('axios-retry').default; // Mocked axiosRetry - - beforeEach(() => { - // Reset axios.create and axiosRetry mocks - axios.create.mockClear(); - axiosRetry.mockClear(); - // Reconfigure axios.create to return a mock instance with spied interceptors - axios.create.mockImplementation(() => ({ - get: jest.fn(), - interceptors: { - request: { use: jest.fn() }, - response: { use: jest.fn() } - } - })); - }); - - test('should create an instance with provided baseURL and httpsAgent config', () => { - const baseURL = 'https://test.com/api'; - const allowSelfSignedCerts = true; - createApiClientInstance(baseURL, allowSelfSignedCerts); - - expect(axios.create).toHaveBeenCalledTimes(1); - expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({ - baseURL: baseURL, - httpsAgent: expect.objectContaining({ - options: expect.objectContaining({ rejectUnauthorized: false }) - }), - headers: { 'Content-Type': 'application/json' } - })); - }); - - test('should call request.use when authInterceptor is provided', () => { - const mockInterceptor = jest.fn(); - const apiClient = createApiClientInstance('https://test.com', false, mockInterceptor, null); // Pass null for retryConfig - - expect(apiClient.interceptors.request.use).toHaveBeenCalledTimes(1); - expect(apiClient.interceptors.request.use).toHaveBeenCalledWith(mockInterceptor); - }); - - test('should NOT call request.use when authInterceptor is NOT provided', () => { - const apiClient = createApiClientInstance('https://test.com', false, null, null); // Pass null for both - - expect(apiClient.interceptors.request.use).not.toHaveBeenCalled(); - }); - - test('should call axiosRetry when retryConfig is provided', () => { - const mockRetryConfig = { retries: 5, retryDelayLogger: jest.fn(), retryConditionChecker: jest.fn() }; - const apiClient = createApiClientInstance('https://test.com', false, null, mockRetryConfig); - - expect(axiosRetry).toHaveBeenCalledTimes(1); - expect(axiosRetry).toHaveBeenCalledWith(apiClient, { - retries: mockRetryConfig.retries, - retryDelay: mockRetryConfig.retryDelayLogger, // Now correctly accesses the logger - retryCondition: mockRetryConfig.retryConditionChecker, // Now correctly accesses the checker - }); - }); - - test('should NOT call axiosRetry when retryConfig is NOT provided', () => { - createApiClientInstance('https://test.com', false, null, null); // Pass null for both - - expect(axiosRetry).not.toHaveBeenCalled(); - }); - - }); - - // --- pveRetryDelayLogger Tests --- - describe('pveRetryDelayLogger', () => { - const { pveRetryDelayLogger } = require('../apiClients'); - const axiosRetry = require('axios-retry').default; - - beforeEach(() => { - axiosRetry.exponentialDelay.mockClear(); - axiosRetry.exponentialDelay.mockReturnValue(500); // Use different value for clarity - }); - - test('should log warning with correct PVE details', () => { - const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); - const testError = new Error('PVE Failed'); - pveRetryDelayLogger('TestPVE', 3, testError); - - expect(consoleWarnSpy).toHaveBeenCalledTimes(1); - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'Retrying PVE API request for TestPVE (attempt 3) due to error: PVE Failed' - ); - consoleWarnSpy.mockRestore(); - }); - - test('should call mocked axiosRetry.exponentialDelay and return its value', () => { - const result = pveRetryDelayLogger('TestPVE', 2, new Error('Test')); - - expect(axiosRetry.exponentialDelay).toHaveBeenCalledTimes(1); - expect(axiosRetry.exponentialDelay).toHaveBeenCalledWith(2); // Called with retryCount - expect(result).toBe(500); // Returns the mock value - }); - }); - - // --- pbsRetryDelayLogger Tests --- - describe('pbsRetryDelayLogger', () => { - const { pbsRetryDelayLogger } = require('../apiClients'); - // Get the mocked default export which has the mocked helpers - const axiosRetry = require('axios-retry').default; - - beforeEach(() => { - // Reset mocks before each test in this suite - axiosRetry.exponentialDelay.mockClear(); - axiosRetry.exponentialDelay.mockReturnValue(1000); // Set default mock return for simplicity - }); - - test('should log warning with correct details', () => { - // ... (this test remains the same, just checking console.warn) ... - const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); - const testError = new Error('PBS Failed'); - pbsRetryDelayLogger('TestPBS', 2, testError); - expect(consoleWarnSpy).toHaveBeenCalledTimes(1); - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'Retrying PBS API request for TestPBS (Token Auth - attempt 2) due to error: PBS Failed' - ); - consoleWarnSpy.mockRestore(); - }); - - test('should call mocked axiosRetry.exponentialDelay and return its value', () => { - // No spy needed, just call the function and check the pre-existing mock - const result = pbsRetryDelayLogger('TestPBS', 1, new Error('Test')); - - expect(axiosRetry.exponentialDelay).toHaveBeenCalledTimes(1); - expect(axiosRetry.exponentialDelay).toHaveBeenCalledWith(1); - expect(result).toBe(1000); // Should return the mock value - }); - }); - - // --- pbsRetryConditionChecker Tests --- - describe('pbsRetryConditionChecker', () => { - const { pbsRetryConditionChecker } = require('../apiClients'); - // Get the mocked default export which has the mocked helpers - const axiosRetry = require('axios-retry').default; - - beforeEach(() => { - // Reset mocks and set default return values before each test - axiosRetry.isNetworkError.mockClear().mockReturnValue(false); - axiosRetry.isRetryableError.mockClear().mockReturnValue(false); - }); - - // No afterEach needed as we clear in beforeEach - - test('should return true for network errors', () => { - const networkError = new Error('Network Error'); - axiosRetry.isNetworkError.mockReturnValue(true); // Override default mock return - axiosRetry.isRetryableError.mockReturnValue(false); // Ensure this stays false for the test - - expect(pbsRetryConditionChecker(networkError)).toBe(true); - // Verify mocks were called (or not called due to short-circuit) - expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(networkError); - expect(axiosRetry.isRetryableError).not.toHaveBeenCalled(); // Corrected assertion - }); - - test('should return true for retryable errors', () => { - const retryableError = new Error('Retryable Error'); - retryableError.response = { status: 503 }; - axiosRetry.isRetryableError.mockReturnValue(true); // Override default mock return - - expect(pbsRetryConditionChecker(retryableError)).toBe(true); - // Verify mocks were called - expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(retryableError); - expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(retryableError); - }); - - test('should return false for non-network, non-retryable errors', () => { - const otherError = new Error('Other Error'); - // Default mock returns (false, false) are already set in beforeEach - - expect(pbsRetryConditionChecker(otherError)).toBe(false); - // Verify mocks were called - expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(otherError); - expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(otherError); - }); - }); - - // --- pveRetryConditionChecker Tests --- - describe('pveRetryConditionChecker', () => { - const { pveRetryConditionChecker } = require('../apiClients'); - const axiosRetry = require('axios-retry').default; - - beforeEach(() => { - axiosRetry.isNetworkError.mockClear().mockReturnValue(false); - axiosRetry.isRetryableError.mockClear().mockReturnValue(false); - }); - - test('should return true for network errors', () => { - const networkError = new Error('Network Error'); - axiosRetry.isNetworkError.mockReturnValue(true); - expect(pveRetryConditionChecker(networkError)).toBe(true); - expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(networkError); - expect(axiosRetry.isRetryableError).not.toHaveBeenCalled(); // Short-circuits - }); - - test('should return true for retryable errors', () => { - const retryableError = new Error('Retryable Error'); - axiosRetry.isRetryableError.mockReturnValue(true); - expect(pveRetryConditionChecker(retryableError)).toBe(true); - expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(retryableError); - expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(retryableError); - }); - - test('should return true for error with status 596', () => { - const status596Error = new Error('Status 596 Error'); - status596Error.response = { status: 596 }; - // Ensure other checks are false - axiosRetry.isNetworkError.mockReturnValue(false); - axiosRetry.isRetryableError.mockReturnValue(false); - - expect(pveRetryConditionChecker(status596Error)).toBe(true); - expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(status596Error); - expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(status596Error); - }); - - test('should return false for other errors without status 596', () => { - const otherError = new Error('Other Error'); - // Ensure other checks are false (default from beforeEach) - expect(pveRetryConditionChecker(otherError)).toBe(false); - expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(otherError); - expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(otherError); - }); - - test('should return false for error with different response status', () => { - const status500Error = new Error('Status 500 Error'); - status500Error.response = { status: 500 }; - // Ensure other checks are false (default from beforeEach) - expect(pveRetryConditionChecker(status500Error)).toBe(false); - expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(status500Error); - expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(status500Error); - }); - }); - -}); \ No newline at end of file diff --git a/tests/backupDataValidator.js b/tests/backupDataValidator.js deleted file mode 100644 index 2fd90df5c..000000000 --- a/tests/backupDataValidator.js +++ /dev/null @@ -1,437 +0,0 @@ -/** - * Backup Data Validator - * - * This module provides utilities to validate backup data against known ground truths - * and help identify discrepancies in the backup system. - */ - -// Ground truth data based on research -const groundTruthData = { - totalGuests: 18, // Actual cluster count - pbsBackupsTotal: 135, - vmSnapshots: 3, // Only 3 actual VM/CT snapshots - - // Backup job schedules - primaryBackupJob: { - id: 'backup-2759a200-3e11', - schedule: '02:00 AM', - excludes: [102, 200, 400], - retention: { daily: 7, weekly: 4, monthly: 3 } - }, - secondaryBackupJob: { - id: 'backup-79ce96ee-6527', - schedule: '04:00 AM', - includes: [102, 200, 400], - retention: { keepLast: 3 } - }, - - // Expected backup ages (as of June 2, 12:50 PM BST) - expectedBackupAges: { - primaryJobGuests: { minHours: 10, maxHours: 11 }, // 2:00-2:10 AM backups - secondaryJobGuests: { minHours: 8, maxHours: 9 }, // 4:00 AM backups - vm102: 'no_recent_backup' // Issue found in research - }, - - // Known issues from research - knownIssues: { - guestCountDiscrepancy: true, // Pulse shows 20, actual is 18 - vm102BackupMissing: true, - multipleEndpoints: 2, // proxmox.lan and pimox.lan - snapshotLoggingConfusion: true // Logs incorrectly label PBS backups as snapshots - } -}; - -/** - * Validates guest count against expected values - * @param {Object} discoveryData - The discovery data from fetchDiscoveryData - * @returns {Object} Validation result with details - */ -function validateGuestCount(discoveryData) { - const actualVMs = discoveryData.vms?.length || 0; - const actualContainers = discoveryData.containers?.length || 0; - const actualTotal = actualVMs + actualContainers; - - const result = { - valid: actualTotal === groundTruthData.totalGuests, - expected: groundTruthData.totalGuests, - actual: actualTotal, - vms: actualVMs, - containers: actualContainers, - discrepancy: actualTotal - groundTruthData.totalGuests, - details: [] - }; - - if (!result.valid) { - result.details.push(`Guest count mismatch: Expected ${result.expected}, got ${result.actual}`); - - // Check for known issue - if (actualTotal === 20 && groundTruthData.totalGuests === 18) { - result.details.push('Known issue: Pulse showing 20 guests instead of actual 18'); - } - } - - // Group by endpoint for detailed analysis - const guestsByEndpoint = {}; - [...(discoveryData.vms || []), ...(discoveryData.containers || [])].forEach(guest => { - const endpoint = guest.endpointId || 'unknown'; - if (!guestsByEndpoint[endpoint]) { - guestsByEndpoint[endpoint] = { vms: 0, containers: 0 }; - } - if (guest.type === 'qemu') { - guestsByEndpoint[endpoint].vms++; - } else { - guestsByEndpoint[endpoint].containers++; - } - }); - - result.byEndpoint = guestsByEndpoint; - - return result; -} - -/** - * Validates PBS backup counts vs VM snapshots - * @param {Object} pbsData - PBS data from fetchPbsData - * @param {Object} pveBackups - PVE backup data - * @returns {Object} Validation result - */ -function validateBackupCounts(pbsData, pveBackups) { - let pbsBackupCount = 0; - let pbsBackupsByGuest = {}; - - // Count PBS backups - if (pbsData && pbsData[0]?.datastores) { - pbsData[0].datastores.forEach(ds => { - (ds.snapshots || []).forEach(snap => { - pbsBackupCount++; - const guestKey = `${snap['backup-type']}/${snap['backup-id']}`; - pbsBackupsByGuest[guestKey] = (pbsBackupsByGuest[guestKey] || 0) + 1; - }); - }); - } - - const vmSnapshotCount = pveBackups?.guestSnapshots?.length || 0; - - const result = { - valid: pbsBackupCount > 100 && vmSnapshotCount < 10, // Expected pattern - pbsBackups: { - total: pbsBackupCount, - expected: groundTruthData.pbsBackupsTotal, - byGuest: pbsBackupsByGuest - }, - vmSnapshots: { - total: vmSnapshotCount, - expected: groundTruthData.vmSnapshots, - list: pveBackups?.guestSnapshots || [] - }, - details: [] - }; - - if (Math.abs(pbsBackupCount - groundTruthData.pbsBackupsTotal) > 10) { - result.details.push(`PBS backup count differs from expected: ${pbsBackupCount} vs ${groundTruthData.pbsBackupsTotal}`); - } - - if (vmSnapshotCount > groundTruthData.vmSnapshots) { - result.details.push(`More VM snapshots than expected: ${vmSnapshotCount} vs ${groundTruthData.vmSnapshots}`); - } - - return result; -} - -/** - * Validates backup ages for all guests - * @param {Object} pbsData - PBS data - * @param {Date} currentTime - Current time for age calculations - * @returns {Object} Validation result with age analysis - */ -function validateBackupAges(pbsData, currentTime = new Date()) { - const backupAges = new Map(); - const guestsWithoutBackups = new Set(); - const expectedGuests = new Set(); - - // Build expected guest list - for (let i = 100; i <= 106; i++) { - expectedGuests.add(String(i)); - } - for (let i = 200; i <= 400; i += 100) { - expectedGuests.add(String(i)); - } - - // Analyze PBS backups - if (pbsData && pbsData[0]?.datastores) { - pbsData[0].datastores.forEach(ds => { - (ds.snapshots || []).forEach(snap => { - const backupTime = snap['backup-time'] * 1000; // Convert to milliseconds - const ageHours = (currentTime.getTime() - backupTime) / (1000 * 60 * 60); - const guestId = snap['backup-id']; - - if (!backupAges.has(guestId) || ageHours < backupAges.get(guestId)) { - backupAges.set(guestId, ageHours); - } - }); - }); - } - - // Find guests without recent backups - expectedGuests.forEach(guestId => { - if (!backupAges.has(guestId) || backupAges.get(guestId) > 24) { - guestsWithoutBackups.add(guestId); - } - }); - - // Categorize by backup schedule - const primaryJobGuests = []; - const secondaryJobGuests = []; - const issues = []; - - backupAges.forEach((age, guestId) => { - const id = parseInt(guestId); - - if ([102, 200, 400].includes(id)) { - secondaryJobGuests.push({ id: guestId, age }); - if (age < groundTruthData.expectedBackupAges.secondaryJobGuests.minHours || - age > groundTruthData.expectedBackupAges.secondaryJobGuests.maxHours + 1) { - issues.push(`Guest ${guestId} backup age ${age.toFixed(1)}h outside expected range`); - } - } else { - primaryJobGuests.push({ id: guestId, age }); - if (age < groundTruthData.expectedBackupAges.primaryJobGuests.minHours || - age > groundTruthData.expectedBackupAges.primaryJobGuests.maxHours + 1) { - issues.push(`Guest ${guestId} backup age ${age.toFixed(1)}h outside expected range`); - } - } - }); - - // Check for VM 102 issue - if (guestsWithoutBackups.has('102')) { - issues.push('VM 102 has no recent backup (known issue)'); - } - - return { - valid: issues.length === 0, - backupAges: Object.fromEntries(backupAges), - primaryJobGuests, - secondaryJobGuests, - guestsWithoutBackups: Array.from(guestsWithoutBackups), - issues, - summary: { - totalGuests: expectedGuests.size, - guestsWithBackups: backupAges.size, - guestsWithRecentBackups: Array.from(backupAges.entries()) - .filter(([_, age]) => age < 24).length - } - }; -} - -/** - * Validates PBS task categorization - * @param {Array} pbsTasks - Raw PBS tasks - * @param {Object} processedTasks - Processed tasks from processPbsTasks - * @returns {Object} Validation result - */ -function validateTaskProcessing(pbsTasks, processedTasks) { - const result = { - valid: true, - totalTasks: pbsTasks?.length || 0, - categorized: { - backup: processedTasks.backupTasks?.summary?.total || 0, - verify: processedTasks.verificationTasks?.summary?.total || 0, - sync: processedTasks.syncTasks?.summary?.total || 0, - prune: processedTasks.pruneTasks?.summary?.total || 0 - }, - uncategorized: [], - issues: [] - }; - - // Check if all tasks were categorized - const categorizedTotal = Object.values(result.categorized).reduce((a, b) => a + b, 0); - - if (categorizedTotal !== result.totalTasks) { - result.valid = false; - result.issues.push(`Task count mismatch: ${categorizedTotal} categorized out of ${result.totalTasks} total`); - - // Find uncategorized tasks - const taskTypeMap = { - backup: 'backup', - verify: 'verify', - sync: 'sync', - prune: 'prune', - garbage_collection: 'prune', - gc: 'prune' - }; - - pbsTasks?.forEach(task => { - const type = task.worker_type || task.type; - if (!taskTypeMap[type]) { - result.uncategorized.push(type); - } - }); - } - - // Check for backup task details - const backupTasks = processedTasks.backupTasks?.recentTasks || []; - const pbsBackupTasks = backupTasks.filter(t => t.pbsBackupRun); - - if (pbsBackupTasks.length === 0 && result.categorized.backup > 0) { - result.issues.push('No PBS backup runs found in recent tasks'); - } - - return result; -} - -/** - * Performs comprehensive validation of all backup data - * @param {Object} data - Object containing discoveryData, pbsData, etc. - * @returns {Object} Complete validation report - */ -function validateAllBackupData(data) { - const report = { - timestamp: new Date().toISOString(), - validations: {}, - overallValid: true, - criticalIssues: [], - warnings: [] - }; - - // Guest count validation - if (data.discoveryData) { - report.validations.guestCount = validateGuestCount(data.discoveryData); - if (!report.validations.guestCount.valid) { - report.warnings.push('Guest count discrepancy detected'); - } - } - - // Backup count validation - if (data.pbsData && data.discoveryData?.pveBackups) { - report.validations.backupCounts = validateBackupCounts( - data.pbsData, - data.discoveryData.pveBackups - ); - if (!report.validations.backupCounts.valid) { - report.criticalIssues.push('Backup count validation failed'); - report.overallValid = false; - } - } - - // Backup age validation - if (data.pbsData) { - report.validations.backupAges = validateBackupAges(data.pbsData); - if (!report.validations.backupAges.valid) { - report.validations.backupAges.issues.forEach(issue => { - if (issue.includes('VM 102')) { - report.warnings.push(issue); - } else { - report.criticalIssues.push(issue); - report.overallValid = false; - } - }); - } - } - - // Task processing validation - if (data.pbsTasks && data.processedTasks) { - report.validations.taskProcessing = validateTaskProcessing( - data.pbsTasks, - data.processedTasks - ); - if (!report.validations.taskProcessing.valid) { - report.warnings.push('Task processing issues detected'); - } - } - - // Summary - report.summary = { - criticalIssues: report.criticalIssues.length, - warnings: report.warnings.length, - recommendation: report.overallValid - ? 'Backup data appears valid' - : 'Critical issues found - investigate backup system' - }; - - return report; -} - -/** - * Generates a human-readable report from validation results - * @param {Object} validationReport - Report from validateAllBackupData - * @returns {String} Formatted report - */ -function generateValidationReport(validationReport) { - let report = `Backup Data Validation Report -Generated: ${validationReport.timestamp} -======================================== - -`; - - // Overall Status - report += `Overall Status: ${validationReport.overallValid ? '✓ PASS' : '✗ FAIL'}\n`; - report += `Critical Issues: ${validationReport.criticalIssues.length}\n`; - report += `Warnings: ${validationReport.warnings.length}\n\n`; - - // Guest Count - if (validationReport.validations.guestCount) { - const gc = validationReport.validations.guestCount; - report += `Guest Count Validation:\n`; - report += ` Expected: ${gc.expected} guests\n`; - report += ` Actual: ${gc.actual} guests (${gc.vms} VMs, ${gc.containers} CTs)\n`; - if (gc.byEndpoint) { - report += ` By Endpoint:\n`; - Object.entries(gc.byEndpoint).forEach(([endpoint, counts]) => { - report += ` ${endpoint}: ${counts.vms} VMs, ${counts.containers} CTs\n`; - }); - } - report += '\n'; - } - - // Backup Counts - if (validationReport.validations.backupCounts) { - const bc = validationReport.validations.backupCounts; - report += `Backup Count Validation:\n`; - report += ` PBS Backups: ${bc.pbsBackups.total} (expected ~${bc.pbsBackups.expected})\n`; - report += ` VM Snapshots: ${bc.vmSnapshots.total} (expected ${bc.vmSnapshots.expected})\n`; - report += '\n'; - } - - // Backup Ages - if (validationReport.validations.backupAges) { - const ba = validationReport.validations.backupAges; - report += `Backup Age Validation:\n`; - report += ` Total Guests: ${ba.summary.totalGuests}\n`; - report += ` Guests with backups: ${ba.summary.guestsWithBackups}\n`; - report += ` Guests with recent backups (<24h): ${ba.summary.guestsWithRecentBackups}\n`; - if (ba.guestsWithoutBackups.length > 0) { - report += ` Guests without recent backups: ${ba.guestsWithoutBackups.join(', ')}\n`; - } - report += '\n'; - } - - // Issues - if (validationReport.criticalIssues.length > 0) { - report += `Critical Issues:\n`; - validationReport.criticalIssues.forEach(issue => { - report += ` - ${issue}\n`; - }); - report += '\n'; - } - - if (validationReport.warnings.length > 0) { - report += `Warnings:\n`; - validationReport.warnings.forEach(warning => { - report += ` - ${warning}\n`; - }); - report += '\n'; - } - - report += `Recommendation: ${validationReport.summary.recommendation}\n`; - - return report; -} - -module.exports = { - validateGuestCount, - validateBackupCounts, - validateBackupAges, - validateTaskProcessing, - validateAllBackupData, - generateValidationReport -}; \ No newline at end of file diff --git a/tests/backupGroundTruth.test.js b/tests/backupGroundTruth.test.js deleted file mode 100644 index c6a4cd741..000000000 --- a/tests/backupGroundTruth.test.js +++ /dev/null @@ -1,571 +0,0 @@ -const { fetchDiscoveryData, fetchPbsData } = require('../dataFetcher'); -const { processPbsTasks } = require('../pbsUtils'); - -// Mock data based on your ground truth research -const groundTruthData = { - totalGuests: 18, // Actual cluster count - pbsBackupsTotal: 135, - vmSnapshots: 3, // Only 3 actual VM/CT snapshots - - // Backup job schedules - primaryBackupJob: { - id: 'backup-2759a200-3e11', - schedule: '02:00 AM', - excludes: [102, 200, 400], - retention: { daily: 7, weekly: 4, monthly: 3 } - }, - secondaryBackupJob: { - id: 'backup-79ce96ee-6527', - schedule: '04:00 AM', - includes: [102, 200, 400], - retention: { keepLast: 3 } - }, - - // Expected backup ages (as of June 2, 12:50 PM BST) - expectedBackupAges: { - primaryJobGuests: { minHours: 10, maxHours: 11 }, // 2:00-2:10 AM backups - secondaryJobGuests: { minHours: 8, maxHours: 9 }, // 4:00 AM backups - vm102: 'no_recent_backup' // Issue found in research - }, - - // Known issues from research - knownIssues: { - guestCountDiscrepancy: true, // Pulse shows 20, actual is 18 - vm102BackupMissing: true, - multipleEndpoints: 2, // proxmox.lan and pimox.lan - snapshotLoggingConfusion: true // Logs incorrectly label PBS backups as snapshots - } -}; - -describe('Backup Ground Truth Verification Tests', () => { - let mockApiClients; - let mockPbsApiClients; - let discoveryData; - - beforeEach(() => { - // Mock the API clients with realistic data - mockApiClients = { - 'proxmox-lan': { - client: { - get: jest.fn() - }, - config: { - name: 'proxmox.lan', - tokenId: 'test@pve!test', - tokenSecret: 'test-secret' - } - }, - 'pimox-lan': { - client: { - get: jest.fn() - }, - config: { - name: 'pimox.lan', - tokenId: 'test@pve!test', - tokenSecret: 'test-secret' - } - } - }; - - mockPbsApiClients = { - 'pbs-main': { - client: { - get: jest.fn(), - post: jest.fn() - }, - config: { - name: 'PBS Storage', - nodeName: 'pbs-node' - } - } - }; - }); - - describe('Guest Count Verification', () => { - test('should correctly count total guests across all endpoints', async () => { - // Mock PVE nodes response - mockApiClients['proxmox-lan'].client.get.mockImplementation((path) => { - if (path === '/cluster/status') { - return Promise.resolve({ - data: { - data: [ - { type: 'cluster', name: 'proxmox-cluster', nodes: 3 }, - { type: 'node', name: 'desktop', ip: '192.168.1.10' }, - { type: 'node', name: 'delly', ip: '192.168.1.11' }, - { type: 'node', name: 'minipc', ip: '192.168.1.12' } - ] - } - }); - } - if (path === '/nodes') { - return Promise.resolve({ - data: { - data: [ - { node: 'desktop', status: 'online' }, - { node: 'delly', status: 'online' }, - { node: 'minipc', status: 'online' } - ] - } - }); - } - if (path.includes('/qemu')) { - // Each node has different VMs - if (path.includes('/nodes/desktop/')) { - return Promise.resolve({ data: { data: [ - { vmid: 102, name: 'windows11', status: 'stopped' }, - { vmid: 200, name: 'UnraidServer', status: 'stopped' }, - { vmid: 400, name: 'ubuntu-gpu-vm', status: 'stopped' } - ]}}); - } - return Promise.resolve({ data: { data: [] }}); - } - if (path.includes('/lxc')) { - // Distribute containers across nodes - if (path.includes('/nodes/desktop/')) { - return Promise.resolve({ data: { data: [ - { vmid: 100, name: 'pbs', status: 'running' }, - { vmid: 109, name: 'pbs2', status: 'stopped' }, - { vmid: 111, name: 'debian', status: 'stopped' } - ]}}); - } else if (path.includes('/nodes/delly/')) { - return Promise.resolve({ data: { data: [ - { vmid: 101, name: 'homeassistant', status: 'running' }, - { vmid: 105, name: 'homepage', status: 'running' }, - { vmid: 108, name: 'frigate', status: 'running' }, - { vmid: 110, name: 'tailscale-router', status: 'running' }, - { vmid: 122, name: 'influxdb-telegraf', status: 'running' } - ]}}); - } else if (path.includes('/nodes/minipc/')) { - return Promise.resolve({ data: { data: [ - { vmid: 103, name: 'pihole', status: 'running' }, - { vmid: 104, name: 'cloudflared', status: 'running' }, - { vmid: 106, name: 'pulse', status: 'running' }, - { vmid: 107, name: 'jellyfin', status: 'running' }, - { vmid: 120, name: 'mqtt', status: 'running' }, - { vmid: 121, name: 'zigbee2mqtt', status: 'running' }, - { vmid: 124, name: 'grafana', status: 'running' } - ]}}); - } - return Promise.resolve({ data: { data: [] }}); - } - return Promise.resolve({ data: { data: [] } }); - }); - - mockApiClients['pimox-lan'].client.get.mockImplementation((path) => { - if (path === '/cluster/status') { - return Promise.resolve({ - data: { - data: [ - { type: 'node', name: 'pi', ip: '192.168.1.20' } - ] - } - }); - } - if (path === '/nodes') { - return Promise.resolve({ - data: { - data: [{ node: 'pi', status: 'online' }] - } - }); - } - if (path.includes('/qemu')) { - return Promise.resolve({ data: { data: [] }}); - } - if (path.includes('/lxc')) { - return Promise.resolve({ data: { data: [] }}); - } - return Promise.resolve({ data: { data: [] } }); - }); - - discoveryData = await fetchDiscoveryData(mockApiClients, {}); - - const totalVMs = discoveryData.vms.length; - const totalContainers = discoveryData.containers.length; - const totalGuests = totalVMs + totalContainers; - - // Verify against ground truth - expect(totalGuests).toBe(groundTruthData.totalGuests); - expect(totalVMs).toBe(3); // VMs 102, 200, 400 - expect(totalContainers).toBe(15); // All containers across all nodes - - // Check for known discrepancy - if (totalGuests !== 20) { - console.log(`Guest count discrepancy detected: Actual ${totalGuests}, Pulse might show 20`); - } - }); - }); - - describe('PBS Backup Count Verification', () => { - test('should correctly count PBS backups vs VM snapshots', async () => { - // Mock PBS datastore groups and snapshots - mockPbsApiClients['pbs-main'].client.get.mockImplementation((path) => { - if (path === '/nodes') { - return Promise.resolve({ - data: { data: [{ node: 'pbs-node' }] } - }); - } - if (path === '/config/datastore') { - return Promise.resolve({ - data: { data: [{ name: 'main-datastore' }] } - }); - } - if (path.includes('/admin/datastore/main-datastore/snapshots')) { - // This is called by fetchPbsDatastoreSnapshots - return all 135 snapshots - const allSnapshots = []; - const now = Math.floor(Date.now() / 1000); - - // Create snapshots for all guests - const guests = [ - { type: 'ct', id: '100', count: 9 }, - { type: 'ct', id: '101', count: 9 }, - { type: 'vm', id: '102', count: 0 }, // VM 102 has no backups - { type: 'ct', id: '103', count: 9 }, - { type: 'ct', id: '104', count: 9 }, - { type: 'ct', id: '105', count: 9 }, - { type: 'ct', id: '106', count: 9 }, - { type: 'ct', id: '107', count: 9 }, - { type: 'ct', id: '108', count: 9 }, - { type: 'ct', id: '109', count: 9 }, - { type: 'ct', id: '110', count: 9 }, - { type: 'ct', id: '111', count: 9 }, - { type: 'ct', id: '120', count: 9 }, - { type: 'ct', id: '121', count: 9 }, - { type: 'ct', id: '122', count: 9 }, - { type: 'ct', id: '124', count: 9 }, - { type: 'vm', id: '200', count: 3 }, - { type: 'vm', id: '400', count: 3 } - ]; - - guests.forEach(guest => { - for (let i = 0; i < guest.count; i++) { - allSnapshots.push({ - 'backup-time': now - (i * 24 * 60 * 60), - 'backup-type': guest.type, - 'backup-id': guest.id, - 'backup-group': `${guest.type}/${guest.id}`, - size: 1024 * 1024 * 100 - }); - } - }); - - return Promise.resolve({ data: { data: allSnapshots } }); - } - if (path.includes('/status/datastore-usage')) { - return Promise.resolve({ - data: { data: [{ - store: 'main-datastore', - total: 1000000000000, - used: 135000000000, // 135GB for 135 backups - avail: 865000000000 - }]} - }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - // Mock PVE snapshots (the real VM/CT snapshots) - mockApiClients['proxmox-lan'].client.get.mockImplementation((path) => { - if (path.includes('/snapshot')) { - if (path.includes('/400/')) { - return Promise.resolve({ - data: { data: [ - { name: 'current' }, // Filtered out - { name: 'ubuntuserver', snaptime: 1700000000 }, - { name: 'precursor', snaptime: 1699000000 } - ]} - }); - } - if (path.includes('/106/')) { - return Promise.resolve({ - data: { data: [ - { name: 'current' }, // Filtered out - { name: 'before_helper', snaptime: 1701000000 } - ]} - }); - } - return Promise.resolve({ data: { data: [{ name: 'current' }] } }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - const pbsData = await fetchPbsData(mockPbsApiClients); - const discoveryData = await fetchDiscoveryData(mockApiClients, mockPbsApiClients); - - // Count PBS backups - let totalPbsBackups = 0; - if (pbsData[0]?.datastores) { - pbsData[0].datastores.forEach(ds => { - totalPbsBackups += ds.snapshots?.length || 0; - }); - } - - // Count VM/CT snapshots - const vmSnapshots = discoveryData.pveBackups?.guestSnapshots?.length || 0; - - console.log(`PBS Backups: ${totalPbsBackups}, VM Snapshots: ${vmSnapshots}`); - - // Verify the distinction - expect(totalPbsBackups).toBeGreaterThan(50); // Should have many PBS backups - expect(vmSnapshots).toBeLessThan(5); // Should have very few VM snapshots - - // This verifies the logging confusion issue - if (totalPbsBackups > 100 && vmSnapshots < 5) { - console.log('Confirmed: PBS backups are distinct from VM snapshots'); - console.log('DataFetcher logs showing "Found X snapshots" likely refer to VM snapshots, not PBS backups'); - } - }); - }); - - describe('Backup Age Verification', () => { - test('should correctly calculate backup ages', async () => { - const now = new Date('2025-06-02T12:50:00Z'); // Test time from research - const twoAM = new Date('2025-06-02T02:00:00Z'); - const fourAM = new Date('2025-06-02T04:00:00Z'); - - const primaryBackupAge = (now - twoAM) / (1000 * 60 * 60); // Hours - const secondaryBackupAge = (now - fourAM) / (1000 * 60 * 60); // Hours - - expect(primaryBackupAge).toBeCloseTo(10.83, 1); // ~11 hours - expect(secondaryBackupAge).toBeCloseTo(8.83, 1); // ~9 hours - - // Verify these match the ground truth expectations - expect(primaryBackupAge).toBeGreaterThanOrEqual(groundTruthData.expectedBackupAges.primaryJobGuests.minHours); - expect(primaryBackupAge).toBeLessThanOrEqual(groundTruthData.expectedBackupAges.primaryJobGuests.maxHours); - - expect(secondaryBackupAge).toBeGreaterThanOrEqual(groundTruthData.expectedBackupAges.secondaryJobGuests.minHours); - expect(secondaryBackupAge).toBeLessThanOrEqual(groundTruthData.expectedBackupAges.secondaryJobGuests.maxHours); - }); - - test('should identify guests with missing backups', async () => { - // Mock PBS tasks to simulate VM 102 missing recent backup - mockPbsApiClients['pbs-main'].client.get.mockImplementation((path) => { - if (path.includes('/snapshots') && path.includes('backup-id=102')) { - // Return no recent snapshots for VM 102 - return Promise.resolve({ data: { data: [] } }); - } - if (path.includes('/snapshots')) { - // Return recent snapshots for other guests - const now = Math.floor(Date.now() / 1000); - return Promise.resolve({ - data: { data: [{ - 'backup-time': now - (11 * 60 * 60), // 11 hours ago - 'backup-type': 'vm', - 'backup-id': '100' - }]} - }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - const pbsData = await fetchPbsData(mockPbsApiClients); - - // Check for VM 102 backup status - const vm102Backups = pbsData[0]?.datastores?.[0]?.snapshots?.filter( - snap => snap['backup-id'] === '102' - ) || []; - - expect(vm102Backups.length).toBe(0); - console.log('Confirmed: VM 102 has no recent backups despite being in backup job'); - }); - }); - - describe('PBS Task Processing Verification', () => { - test('should correctly differentiate backup tasks from admin tasks', () => { - const mockTasks = [ - // Backup tasks (from synthetic snapshots) - { - type: 'backup', - status: 'OK', - starttime: Date.now() / 1000 - 11 * 60 * 60, - endtime: Date.now() / 1000 - 10.5 * 60 * 60, - guest: 'vm/100', - guestType: 'vm', - guestId: '100', - pbsBackupRun: true - }, - // Admin tasks - { - type: 'prune', - worker_type: 'prune', - status: 'OK', - starttime: Date.now() / 1000 - 24 * 60 * 60 - }, - { - type: 'garbage_collection', - worker_type: 'garbage_collection', - status: 'OK', - starttime: Date.now() / 1000 - 48 * 60 * 60 - }, - { - type: 'verify', - worker_type: 'verify', - status: 'OK', - starttime: Date.now() / 1000 - 6 * 60 * 60 - } - ]; - - const processed = processPbsTasks(mockTasks); - - expect(processed.backupTasks.summary.total).toBe(1); - expect(processed.pruneTasks.summary.total).toBe(2); // prune + gc - expect(processed.verificationTasks.summary.total).toBe(1); - - // Verify task categorization - expect(processed.backupTasks.recentTasks[0].pbsBackupRun).toBe(true); - expect(processed.backupTasks.recentTasks[0].guestId).toBe('100'); - }); - }); - - describe('Multiple Endpoint Handling', () => { - test('should handle multiple PVE endpoints correctly', async () => { - // Need to set up mockApiClients for this test - mockApiClients['proxmox-lan'].client.get.mockImplementation((path) => { - if (path === '/cluster/status') { - return Promise.resolve({ - data: { - data: [ - { type: 'cluster', name: 'proxmox-cluster', nodes: 3 }, - { type: 'node', name: 'desktop' }, - { type: 'node', name: 'delly' }, - { type: 'node', name: 'minipc' } - ] - } - }); - } - if (path === '/nodes') { - return Promise.resolve({ - data: { - data: [ - { node: 'desktop', status: 'online' }, - { node: 'delly', status: 'online' }, - { node: 'minipc', status: 'online' } - ] - } - }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - mockApiClients['pimox-lan'].client.get.mockImplementation((path) => { - if (path === '/cluster/status') { - return Promise.resolve({ - data: { - data: [ - { type: 'node', name: 'pi' } - ] - } - }); - } - if (path === '/nodes') { - return Promise.resolve({ - data: { - data: [{ node: 'pi', status: 'online' }] - } - }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - const discoveryData = await fetchDiscoveryData(mockApiClients, {}); - - // Check that nodes are properly tagged with endpoints - const proxmoxNodes = discoveryData.nodes.filter(n => n.endpointId === 'proxmox-lan'); - const pimoxNodes = discoveryData.nodes.filter(n => n.endpointId === 'pimox-lan'); - - expect(proxmoxNodes.length).toBe(3); // desktop, delly, minipc - expect(pimoxNodes.length).toBe(1); // pi - - // Verify endpoint identification - expect(discoveryData.nodes.every(n => n.endpointId)).toBe(true); - expect(discoveryData.vms.every(vm => vm.endpointId)).toBe(true); - expect(discoveryData.containers.every(ct => ct.endpointId)).toBe(true); - }); - }); - - describe('Integration Test: Full Backup Status Verification', () => { - test('should produce accurate backup status for dashboard', async () => { - // This test simulates the full data flow to verify dashboard accuracy - - // Mock current time - const mockNow = new Date('2025-06-02T13:10:00+01:00'); // 1:10 PM BST - jest.spyOn(Date, 'now').mockImplementation(() => mockNow.getTime()); - - // Mock comprehensive PBS data - mockPbsApiClients['pbs-main'].client.get.mockImplementation((path) => { - if (path.includes('/nodes')) { - return Promise.resolve({ data: { data: [{ node: 'pbs-node' }] } }); - } - if (path.includes('/config/datastore')) { - return Promise.resolve({ data: { data: [{ name: 'main-datastore' }] } }); - } - if (path.includes('/admin/datastore/main-datastore/snapshots')) { - // Return snapshots for all guests with proper timing - const snapshots = []; - const fourAM = Math.floor(new Date('2025-06-02T04:00:00+01:00').getTime() / 1000); - const twoAM = Math.floor(new Date('2025-06-02T02:00:00+01:00').getTime() / 1000); - - // Primary job guests (2 AM) - [100, 101, 103, 104, 105, 106, 107, 108, 109, 110, 111, 120, 121, 122, 124].forEach(id => { - snapshots.push({ - 'backup-time': twoAM, - 'backup-type': id >= 100 && id <= 102 ? 'vm' : 'ct', - 'backup-id': String(id) - }); - }); - - // Secondary job guests (4 AM) - except VM 102 - [200, 400].forEach(id => { - snapshots.push({ - 'backup-time': fourAM, - 'backup-type': 'vm', - 'backup-id': String(id) - }); - }); - - // VM 102 has no backups - - return Promise.resolve({ data: { data: snapshots } }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - const pbsData = await fetchPbsData(mockPbsApiClients); - const discoveryData = await fetchDiscoveryData(mockApiClients, mockPbsApiClients); - - // Analyze backup status - const guestsWithRecentBackups = new Set(); - const backupAges = new Map(); - - if (pbsData[0]?.datastores) { - pbsData[0].datastores.forEach(ds => { - ds.snapshots?.forEach(snap => { - const guestKey = `${snap['backup-type']}/${snap['backup-id']}`; - const ageHours = (mockNow.getTime() / 1000 - snap['backup-time']) / 3600; - - if (ageHours < 24) { - guestsWithRecentBackups.add(snap['backup-id']); - backupAges.set(snap['backup-id'], ageHours); - } - }); - }); - } - - // Verify results match ground truth - expect(guestsWithRecentBackups.size).toBe(17); // 18 total - 1 (VM 102) - expect(guestsWithRecentBackups.has('102')).toBe(false); // VM 102 missing - - // Verify backup ages (allow for slight time differences) - expect(backupAges.get('100')).toBeCloseTo(11, 0); - expect(backupAges.get('200')).toBeCloseTo(9, 0); - expect(backupAges.get('106')).toBeCloseTo(11, 0); - - console.log('Dashboard accuracy: 17/18 guests show backups <24h old (94.4% accurate)'); - console.log('Issue identified: VM 102 missing recent backup'); - - // Cleanup - jest.restoreAllMocks(); - }); - }); -}); - -module.exports = { groundTruthData }; \ No newline at end of file diff --git a/tests/config.test.js b/tests/config.test.js deleted file mode 100644 index 6d3201356..000000000 --- a/tests/config.test.js +++ /dev/null @@ -1,486 +0,0 @@ -const { loadConfiguration, ConfigurationError } = require('../configLoader'); - -// Mock dotenv -jest.mock('dotenv', () => ({ - config: jest.fn(), -})); -const dotenv = require('dotenv'); // require after mock - -// Helper function to temporarily set environment variables for a test -const setEnvVars = (vars) => { - const originalEnv = { ...process.env }; // Store original env - Object.keys(vars).forEach(key => { - process.env[key] = vars[key]; - }); - return originalEnv; // Return original env for restoration -}; - -// Helper function to restore environment variables -const restoreEnvVars = (originalEnv) => { - // Clear potentially set test variables first - Object.keys(process.env).forEach(key => { - if (!(key in originalEnv)) { - delete process.env[key]; - } - }); - // Restore original values - Object.keys(originalEnv).forEach(key => { - process.env[key] = originalEnv[key]; - }); -}; - -// Set NODE_ENV to test *before* describing the suite -process.env.NODE_ENV = 'test'; - -// Mock console -let consoleWarnSpy; // Declare spies outside beforeEach/afterEach -let consoleLogSpy; - -describe('Configuration Loading (loadConfiguration)', () => { - let originalEnv; - - beforeEach(() => { - // Store original environment - originalEnv = { ...process.env }; - - // --- More robust clearing of process.env --- - // Get all keys BEFORE modifying - const currentEnvKeys = Object.keys(process.env); - // Delete all keys - currentEnvKeys.forEach(key => delete process.env[key]); - // --- End robust clearing --- - - // Restore NODE_ENV as it's crucial for the logic - process.env.NODE_ENV = 'test'; - - // Assign spies in beforeEach - consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); - consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); - }); - - afterEach(() => { - // --- Restore original environment more carefully --- - // Clear any keys potentially added during the test - const currentEnvKeys = Object.keys(process.env); - currentEnvKeys.forEach(key => delete process.env[key]); - // Restore the original keys and values - Object.keys(originalEnv).forEach(key => { - process.env[key] = originalEnv[key]; - }); - // --- End restore --- - - // Restore specific spies - consoleWarnSpy.mockRestore(); - consoleLogSpy.mockRestore(); - }); - - // Test Case 1: Minimal Valid PVE Config - test('should load minimal PVE config successfully', () => { - setEnvVars({ - PROXMOX_HOST: 'pve.example.com', - PROXMOX_TOKEN_ID: 'user@pam!pve', - PROXMOX_TOKEN_SECRET: 'secretpve', - }); - // Expect no error to be thrown for valid config - let loadedConfig; - expect(() => { - loadedConfig = loadConfiguration(); - }).not.toThrow(); - - // Check the returned structure - expect(loadedConfig).toBeDefined(); - expect(loadedConfig.endpoints).toHaveLength(1); // Check endpoints array - expect(loadedConfig.pbsConfigs).toHaveLength(0); // Expect no PBS configs - - // Check the primary PVE endpoint details within the endpoints array - const primaryEndpoint = loadedConfig.endpoints[0]; - expect(primaryEndpoint.id).toBe('primary'); - expect(primaryEndpoint.host).toBe('pve.example.com'); - expect(primaryEndpoint.tokenId).toBe('user@pam!pve'); - expect(primaryEndpoint.tokenSecret).toBe('secretpve'); - }); - - // Test Case 2: Missing Primary Proxmox Variables - test('should return setup mode configuration if primary Proxmox variables are missing', () => { - setEnvVars({ - PROXMOX_HOST: '192.168.1.100', - // Missing TOKEN_ID and TOKEN_SECRET - }); - - const config = loadConfiguration(); - expect(config.endpoints).toEqual([]); - expect(config.pbsConfigs).toEqual([]); - expect(config.isConfigPlaceholder).toBe(true); - }); - - // Test Case 3: Placeholder Primary Proxmox Variables - test('should warn and set flag if primary Proxmox variables contain placeholders', () => { - const envSetup = { - PROXMOX_HOST: 'your-proxmox-ip-or-hostname', - PROXMOX_TOKEN_ID: 'user@pam!token', // A placeholder not exactly in the list - PROXMOX_TOKEN_SECRET: 'secret-uuid', // Another placeholder not exactly in the list - }; - setEnvVars(envSetup); - - let config; - // Expect no error to be thrown, but placeholders to be detected - expect(() => { - config = loadConfiguration(); - }).not.toThrow(); - - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('WARN: Primary Proxmox environment variables seem to contain placeholder values: PROXMOX_HOST, PROXMOX_TOKEN_ID, PROXMOX_TOKEN_SECRET') - ); - expect(config.isConfigPlaceholder).toBe(true); - }); - - // Test Case 4: Valid Primary + Additional Proxmox Endpoints - test('should load successfully with additional valid Proxmox endpoints', () => { - setEnvVars({ - PROXMOX_HOST: 'pve1.example.com', - PROXMOX_TOKEN_ID: 'user@pam!token1', - PROXMOX_TOKEN_SECRET: 'secret1', - PROXMOX_NODE_NAME: 'PVE Node 1', // Custom name - PROXMOX_PORT: '8007', // Custom port - PROXMOX_ALLOW_SELF_SIGNED_CERTS: 'true', // Explicitly true - - PROXMOX_HOST_2: 'pve2.example.com', - PROXMOX_TOKEN_ID_2: 'user@pam!token2', - PROXMOX_TOKEN_SECRET_2: 'secret2', - PROXMOX_ENABLED_2: 'false', // Disabled endpoint - - PROXMOX_HOST_3: 'pve3.example.com', - PROXMOX_TOKEN_ID_3: 'user@pam!token3', - PROXMOX_TOKEN_SECRET_3: 'secret3', - PROXMOX_NODE_NAME_3: 'PVE Node 3', // Custom name - PROXMOX_PORT_3: '8008', - PROXMOX_ALLOW_SELF_SIGNED_CERTS_3: 'false', // Explicitly false - }); - - const config = loadConfiguration(); - expect(config.endpoints).toHaveLength(3); - - // Check primary - expect(config.endpoints[0].id).toBe('primary'); - expect(config.endpoints[0].name).toBe('PVE Node 1'); - expect(config.endpoints[0].host).toBe('pve1.example.com'); - expect(config.endpoints[0].port).toBe('8007'); - expect(config.endpoints[0].enabled).toBe(true); - expect(config.endpoints[0].allowSelfSignedCerts).toBe(true); - - // Check second (disabled) - expect(config.endpoints[1].id).toBe('endpoint_2'); - expect(config.endpoints[1].name).toBe(null); // No custom name configured - expect(config.endpoints[1].host).toBe('pve2.example.com'); - expect(config.endpoints[1].port).toBe('8006'); // Default port - expect(config.endpoints[1].enabled).toBe(false); - expect(config.endpoints[1].allowSelfSignedCerts).toBe(true); // Default - - // Check third - expect(config.endpoints[2].id).toBe('endpoint_3'); - expect(config.endpoints[2].name).toBe('PVE Node 3'); - expect(config.endpoints[2].host).toBe('pve3.example.com'); - expect(config.endpoints[2].port).toBe('8008'); - expect(config.endpoints[2].enabled).toBe(true); // Default - expect(config.endpoints[2].allowSelfSignedCerts).toBe(false); - - expect(config.pbsConfigs).toHaveLength(0); - }); - - // Test Case 5: Incomplete Additional Proxmox Endpoint - test('should skip additional Proxmox endpoint if token details are missing', () => { - setEnvVars({ - PROXMOX_HOST: 'pve1.example.com', - PROXMOX_TOKEN_ID: 'user@pam!token1', - PROXMOX_TOKEN_SECRET: 'secret1', - - PROXMOX_HOST_2: 'pve2.example.com', // Missing token ID/secret for #2 - }); - - const config = loadConfiguration(); - expect(config.endpoints).toHaveLength(1); - expect(config.endpoints[0].id).toBe('primary'); - }); - - // Test Case 6: Placeholder Additional Proxmox Endpoint - test('should skip additional Proxmox endpoint if details contain placeholders', () => { - setEnvVars({ - PROXMOX_HOST: 'pve1.example.com', - PROXMOX_TOKEN_ID: 'user@pam!token1', - PROXMOX_TOKEN_SECRET: 'secret1', - - PROXMOX_HOST_2: 'your-proxmox-ip-or-hostname', // Placeholder host - PROXMOX_TOKEN_ID_2: 'user@pam!token2', - PROXMOX_TOKEN_SECRET_2: 'secret2', - }); - - const config = loadConfiguration(); - expect(config.endpoints).toHaveLength(1); // Only primary should load - expect(config.endpoints[0].id).toBe('primary'); - }); - - // Test Case 7: Valid Primary PBS Config - test('should load successfully with a valid primary PBS config', () => { - setEnvVars({ - // Minimal valid PVE - PROXMOX_HOST: 'pve.example.com', - PROXMOX_TOKEN_ID: 'user@pam!pve', - PROXMOX_TOKEN_SECRET: 'secretpve', - // Valid PBS - PBS_HOST: 'https://pbs.example.com:8007', // Full URL - PBS_TOKEN_ID: 'user@pbs!token', - PBS_TOKEN_SECRET: 'secretpbs', - PBS_NODE_NAME: 'PBS Backup Server', - PBS_ALLOW_SELF_SIGNED_CERTS: 'false', - }); - - const config = loadConfiguration(); - expect(config.endpoints).toHaveLength(1); - expect(config.pbsConfigs).toHaveLength(1); - - const pbs = config.pbsConfigs[0]; - expect(pbs.id).toBe('pbs_primary_token'); - expect(pbs.name).toBe('PBS Backup Server'); - expect(pbs.host).toBe('https://pbs.example.com:8007'); - expect(pbs.port).toBe('8007'); // Port from env var - expect(pbs.tokenId).toBe('user@pbs!token'); - expect(pbs.tokenSecret).toBe('secretpbs'); - expect(pbs.authMethod).toBe('token'); - expect(pbs.allowSelfSignedCerts).toBe(false); - expect(pbs.enabled).toBe(true); - }); - - test('should not add primary PBS config if host is set but tokens are missing', () => { - setEnvVars({ - PROXMOX_HOST: '192.168.1.100', - PROXMOX_TOKEN_ID: 'user@pam!pve', - PROXMOX_TOKEN_SECRET: 'secretpve', - - PBS_HOST: 'pbs.example.com', - // Missing TOKEN_ID and TOKEN_SECRET for PBS - }); - - let config; - expect(() => { - config = loadConfiguration(); - }).not.toThrow(); - - expect(config.endpoints).toHaveLength(1); - expect(config.pbsConfigs).toHaveLength(0); // PBS should NOT load - - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('WARN: Partial PBS configuration found for PBS_HOST. Please set (PBS_TOKEN_ID + PBS_TOKEN_SECRET)') - ); - expect(consoleWarnSpy).toHaveBeenCalledTimes(1); // Only one warning expected from this test - }); - - // Test Case 8: Valid Primary + Additional PBS Configs - test('should load successfully with additional valid PBS configs', () => { - setEnvVars({ - // PVE - PROXMOX_HOST: 'pve.example.com', - PROXMOX_TOKEN_ID: 'user@pam!pve', - PROXMOX_TOKEN_SECRET: 'secretpve', - // PBS 1 (Primary) - PBS_HOST: 'pbs1.example.com', // No protocol/port - PBS_TOKEN_ID: 'user@pbs!token1', - PBS_TOKEN_SECRET: 'secretpbs1', - // PBS 2 - PBS_HOST_2: 'https://pbs2.example.com:8008', - PBS_TOKEN_ID_2: 'user@pbs!token2', - PBS_TOKEN_SECRET_2: 'secretpbs2', - PBS_NODE_NAME_2: 'PBS Server 2', - PBS_PORT_2: '9000', // Custom port - // PBS 3 (Placeholder - should skip) - PBS_HOST_3: 'pbs3.example.com', - PBS_TOKEN_ID_3: 'your-api-token-id@pam!your-token-name', - PBS_TOKEN_SECRET_3: 'secretpbs3', - // PBS 4 (Missing Token Secret - should skip) - PBS_HOST_4: 'pbs4.example.com', - PBS_TOKEN_ID_4: 'user@pbs!token4', - }); - - const config = loadConfiguration(); - expect(config.endpoints).toHaveLength(1); - expect(config.pbsConfigs).toHaveLength(2); - - // Check PBS 1 (Primary) - expect(config.pbsConfigs[0].id).toBe('pbs_primary_token'); - expect(config.pbsConfigs[0].name).toBe('pbs1.example.com'); // Defaults to host - expect(config.pbsConfigs[0].host).toBe('pbs1.example.com'); - expect(config.pbsConfigs[0].port).toBe('8007'); // Default port - expect(config.pbsConfigs[0].allowSelfSignedCerts).toBe(true); // Default - - // Check PBS 2 - expect(config.pbsConfigs[1].id).toBe('pbs_endpoint_2_token'); - expect(config.pbsConfigs[1].name).toBe('PBS Server 2'); - expect(config.pbsConfigs[1].host).toBe('https://pbs2.example.com:8008'); - expect(config.pbsConfigs[1].port).toBe('9000'); // Custom port - expect(config.pbsConfigs[1].allowSelfSignedCerts).toBe(true); // Default - - // PBS 3 and 4 should have been skipped - }); - - // Test Case 9: Incomplete Additional PBS Endpoint (NEW TEST) - test('should skip additional PBS endpoint if token details are missing but host is present', () => { - setEnvVars({ - PROXMOX_HOST: 'pve.example.com', - PROXMOX_TOKEN_ID: 'user@pam!pve', - PROXMOX_TOKEN_SECRET: 'secretpve', - // Valid Primary PBS - PBS_HOST: 'pbs1.example.com', - PBS_TOKEN_ID: 'user@pbs!token1', - PBS_TOKEN_SECRET: 'secretpbs1', - // Additional PBS host, missing tokens - PBS_HOST_2: 'pbs2.example.com', - // PBS_TOKEN_ID_2: 'user@pbs!token2', // Missing - // PBS_TOKEN_SECRET_2: 'secretpbs2', // Missing - // Valid third PBS - PBS_HOST_3: 'pbs3.example.com', - PBS_TOKEN_ID_3: 'user@pbs!token3', - PBS_TOKEN_SECRET_3: 'secretpbs3', - }); - - const config = loadConfiguration(); - expect(config.endpoints).toHaveLength(1); - expect(config.pbsConfigs).toHaveLength(2); // Should load primary (PBS1) and PBS3 - expect(config.pbsConfigs.map(p => p.host)).toEqual(['pbs1.example.com', 'pbs3.example.com']); - - // Check that the warning for the partial config _2 was logged - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('WARN: Partial PBS configuration found for PBS_HOST_2. Please set (PBS_TOKEN_ID_2 + PBS_TOKEN_SECRET_2)') - ); - // Verify the config for PBS_HOST_2 was not added - expect(config.pbsConfigs.find(p => p.host === 'pbs2.example.com')).toBeUndefined(); - }); - - // Test Case 10: No Enabled Endpoints - test('should throw ConfigurationError if no enabled PVE or PBS endpoints are configured', () => { - setEnvVars({ - // Valid PVE, but disabled - PROXMOX_HOST: 'pve.example.com', - PROXMOX_TOKEN_ID: 'user@pam!pve', - PROXMOX_TOKEN_SECRET: 'secretpve', - PROXMOX_ENABLED: 'false', - // Valid PBS details, but only HOST is present, no tokens - PBS_HOST: 'pbs.example.com' - }); - - // Expect the final check in loadConfiguration to throw - expect(() => loadConfiguration()).toThrow(ConfigurationError); - expect(() => loadConfiguration()).toThrow(/No enabled Proxmox VE or PBS endpoints could be configured/); - }); - - // New Test Case for dotenv loading - test('should call dotenv.config() when NODE_ENV is not \'test\'', () => { - const originalNodeEnv = process.env.NODE_ENV; - process.env.NODE_ENV = 'development'; // Set to non-test environment - - // Minimal valid PVE config to allow loadConfiguration to proceed far enough - setEnvVars({ - PROXMOX_HOST: 'pve.example.com', - PROXMOX_TOKEN_ID: 'user@pam!pve', - PROXMOX_TOKEN_SECRET: 'secretpve', - }); - - loadConfiguration(); - - expect(dotenv.config).toHaveBeenCalled(); - - // Restore original NODE_ENV and clear mocks for other tests - process.env.NODE_ENV = originalNodeEnv; - dotenv.config.mockClear(); // Clear the mock for other tests - }); - - // Test Case 11: Placeholder detection with PROXMOX_TOKEN_ID in env - test('should insert PROXMOX_TOKEN_ID in correct position when placeholders detected', () => { - setEnvVars({ - PROXMOX_HOST: 'your-proxmox-ip-or-hostname', - PROXMOX_TOKEN_ID: 'user@pam!token', - PROXMOX_TOKEN_SECRET: 'your-api-token-uuid', - }); - - const config = loadConfiguration(); - - // Should detect placeholders - the actual implementation includes PROXMOX_TOKEN_ID when it's set - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('WARN: Primary Proxmox environment variables seem to contain placeholder values: PROXMOX_HOST, PROXMOX_TOKEN_ID') - ); - expect(config.isConfigPlaceholder).toBe(true); - }); - - // Test Case 12: Placeholder detection - TOKEN_ID not in list but exists - test('should add PROXMOX_TOKEN_ID at end if not in placeholder list but exists', () => { - // Only secret is a placeholder, but TOKEN_ID exists and should be added - setEnvVars({ - PROXMOX_HOST: 'pve.example.com', - PROXMOX_TOKEN_ID: 'user@pam!mytoken', // exists but not a placeholder - PROXMOX_TOKEN_SECRET: 'your-api-token-uuid', // placeholder - }); - - const config = loadConfiguration(); - - // Debug: Check if console.warn was called at all - expect(consoleWarnSpy).toHaveBeenCalledTimes(1); - - // Should detect the secret placeholder and add TOKEN_ID - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('PROXMOX_TOKEN_SECRET') - ); - expect(config.isConfigPlaceholder).toBe(true); - }); - - // Test Case 13: Test line 138 - Add TOKEN_ID when no PROXMOX_HOST in placeholderVars - test('should push PROXMOX_TOKEN_ID when PROXMOX_HOST not in placeholder list', () => { - // Only PROXMOX_PORT is placeholder (not PROXMOX_HOST) - setEnvVars({ - PROXMOX_HOST: 'pve.example.com', - PROXMOX_TOKEN_ID: 'user@pam!token', // This IS identified as a placeholder - PROXMOX_TOKEN_SECRET: 'secret123', - PROXMOX_PORT: 'your-port' // This is a placeholder, but not checked in the primary warning - }); - - const config = loadConfiguration(); - - // Should detect a placeholder in PROXMOX_TOKEN_ID and warn about it. - // PROXMOX_PORT is not part of the primary placeholder check that generates this specific warning. - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('PROXMOX_TOKEN_ID') - ); - expect(config.isConfigPlaceholder).toBe(true); - }); - - // Test Case: Config file path loading - test('should load config from config directory when it exists', () => { - // Set NODE_ENV to non-test to enable dotenv loading - const originalNodeEnv = process.env.NODE_ENV; - process.env.NODE_ENV = 'development'; - - // Mock fs.existsSync to return true for config dir path - const fs = require('fs'); - const originalExistsSync = fs.existsSync; - fs.existsSync = jest.fn((path) => { - if (path.includes('config/.env')) { - return true; // Config dir .env exists - } - return false; - }); - - // Set up environment variables - setEnvVars({ - PROXMOX_HOST: '192.168.1.100', - PROXMOX_TOKEN_ID: 'user@pam!token', - PROXMOX_TOKEN_SECRET: 'secret' - }); - - const config = loadConfiguration(); - - // Verify that dotenv.config was called with config dir path - expect(dotenv.config).toHaveBeenCalledWith({ path: expect.stringContaining('config/.env') }); - - // Restore fs.existsSync and NODE_ENV - fs.existsSync = originalExistsSync; - process.env.NODE_ENV = originalNodeEnv; - }); - -}); \ No newline at end of file diff --git a/tests/customThresholds.test.js b/tests/customThresholds.test.js deleted file mode 100644 index f23f44c22..000000000 --- a/tests/customThresholds.test.js +++ /dev/null @@ -1,519 +0,0 @@ -// Mock fs module before requiring the threshold manager -jest.mock('fs', () => ({ - promises: { - mkdir: jest.fn(), - readFile: jest.fn(), - writeFile: jest.fn() - } -})); - -const fs = require('fs').promises; -const path = require('path'); -const thresholdManagerInstance = require('../customThresholds'); - -// Mock console to avoid test output clutter -jest.spyOn(console, 'log').mockImplementation(() => {}); -jest.spyOn(console, 'error').mockImplementation(() => {}); - -describe('Custom Threshold Manager', () => { - let thresholdManager; - let mockConfigPath; - - beforeEach(() => { - thresholdManager = thresholdManagerInstance; - mockConfigPath = thresholdManager.configPath; - - // Reset all mocks - jest.clearAllMocks(); - - // Clear cache for clean state - thresholdManager.cache.clear(); - }); - - afterEach(() => { - // Clean up cache - if (thresholdManager) { - thresholdManager.cache.clear(); - } - }); - - describe('Initialization', () => { - test('should initialize successfully with existing config file', async () => { - const mockThresholds = { - 'endpoint1:100': { - endpointId: 'endpoint1', - vmid: '100', - thresholds: { - cpu: { warning: 70, critical: 90 }, - memory: { warning: 80, critical: 95 } - }, - enabled: true, - createdAt: new Date().toISOString() - } - }; - - fs.mkdir.mockResolvedValue(); - fs.readFile.mockResolvedValue(JSON.stringify(mockThresholds)); - - await thresholdManager.init(); - - expect(thresholdManager.initialized).toBe(true); - expect(thresholdManager.cache.size).toBe(1); - }); - - test('should create new config file when none exists', async () => { - const enoentError = new Error('File not found'); - enoentError.code = 'ENOENT'; - - fs.mkdir.mockResolvedValue(); - fs.readFile.mockRejectedValue(enoentError); - fs.writeFile.mockResolvedValue(); - - await thresholdManager.init(); - - expect(thresholdManager.initialized).toBe(true); - expect(fs.writeFile).toHaveBeenCalled(); - }); - }); - - describe('Key Generation', () => { - test('should generate correct cache key format', () => { - const key = thresholdManager.generateKey('pve-main', 'node1', '100'); - expect(key).toBe('pve-main:100'); - }); - - test('should handle special characters in endpoint and vmid', () => { - const key = thresholdManager.generateKey('pve-test.local', 'node-1', 'ct-200'); - expect(key).toBe('pve-test.local:ct-200'); - }); - - test('should be consistent regardless of node parameter', () => { - const key1 = thresholdManager.generateKey('pve1', 'node1', '100'); - const key2 = thresholdManager.generateKey('pve1', 'node2', '100'); - expect(key1).toBe(key2); // Node migration support - }); - }); - - describe('Getting Thresholds', () => { - beforeEach(async () => { - fs.writeFile.mockResolvedValue(); - - // Set up cache with test data using the real API - await thresholdManager.setThresholds('pve1', 'node1', '100', { - cpu: { warning: 75, critical: 90 }, - memory: { warning: 85, critical: 95 } - }); - await thresholdManager.setThresholds('pve1', 'node1', '200', { - cpu: { warning: 60, critical: 80 }, - disk: { warning: 90, critical: 98 } - }); - }); - - test('should return custom thresholds when configured', () => { - const thresholds = thresholdManager.getThresholds('pve1', 'node1', '100'); - - expect(thresholds).not.toBeNull(); - expect(thresholds.thresholds.cpu.warning).toBe(75); - expect(thresholds.thresholds.cpu.critical).toBe(90); - expect(thresholds.thresholds.memory.warning).toBe(85); - }); - - test('should return null when no custom thresholds exist', () => { - const thresholds = thresholdManager.getThresholds('pve1', 'node1', '999'); - expect(thresholds).toBeNull(); - }); - - test('should return null for different endpoint', () => { - const thresholds = thresholdManager.getThresholds('pve2', 'node1', '100'); - expect(thresholds).toBeNull(); - }); - - test('should work regardless of node name due to migration support', () => { - const thresholds1 = thresholdManager.getThresholds('pve1', 'node1', '100'); - const thresholds2 = thresholdManager.getThresholds('pve1', 'node2', '100'); - - expect(thresholds1).toEqual(thresholds2); - }); - }); - - describe('Setting Thresholds', () => { - beforeEach(() => { - fs.writeFile.mockResolvedValue(); - }); - - test('should set valid threshold configuration', async () => { - const validThresholds = { - cpu: { warning: 70, critical: 85 }, - memory: { warning: 80, critical: 90 } - }; - - const result = await thresholdManager.setThresholds('pve1', 'node1', '300', validThresholds); - - expect(result).toBe(true); - const stored = thresholdManager.getThresholds('pve1', 'node1', '300'); - expect(stored).not.toBeNull(); - expect(stored.thresholds.cpu.warning).toBe(70); - expect(stored.createdAt).toBeDefined(); - expect(fs.writeFile).toHaveBeenCalled(); - }); - - test('should validate threshold values', async () => { - const invalidThresholds = { - cpu: { warning: 95, critical: 85 } // Warning higher than critical - }; - - await expect( - thresholdManager.setThresholds('pve1', 'node1', '400', invalidThresholds) - ).rejects.toThrow(/critical threshold must be greater than warning threshold/); - }); - - test('should reject thresholds outside valid range', async () => { - const outOfRangeThresholds = { - cpu: { warning: 150, critical: 200 } // Over 100% - }; - - await expect( - thresholdManager.setThresholds('pve1', 'node1', '500', outOfRangeThresholds) - ).rejects.toThrow(); - }); - - test('should handle partial thresholds gracefully', async () => { - const partialThresholds = { - cpu: { warning: 70, critical: 85 } - // memory and disk thresholds missing - }; - - const result = await thresholdManager.setThresholds('pve1', 'node1', '700', partialThresholds); - expect(result).toBe(true); - - const stored = thresholdManager.getThresholds('pve1', 'node1', '700'); - expect(stored.thresholds.cpu).toBeDefined(); - expect(stored.thresholds.memory).toBeUndefined(); - }); - - test('should update existing thresholds', async () => { - // Set initial thresholds - const initial = { - cpu: { warning: 70, critical: 85 } - }; - await thresholdManager.setThresholds('pve1', 'node1', '800', initial); - - // Update with new values - const updated = { - cpu: { warning: 75, critical: 90 }, - memory: { warning: 80, critical: 95 } - }; - await thresholdManager.setThresholds('pve1', 'node1', '800', updated); - - const stored = thresholdManager.getThresholds('pve1', 'node1', '800'); - expect(stored.thresholds.cpu.warning).toBe(75); - expect(stored.thresholds.memory.warning).toBe(80); - expect(fs.writeFile).toHaveBeenCalledTimes(2); - }); - }); - - describe('Removing Thresholds', () => { - beforeEach(async () => { - fs.writeFile.mockResolvedValue(); - - // Set up some test thresholds - await thresholdManager.setThresholds('pve1', 'node1', '100', { - cpu: { warning: 70, critical: 85 } - }); - await thresholdManager.setThresholds('pve1', 'node1', '200', { - memory: { warning: 80, critical: 90 } - }); - }); - - test('should remove existing threshold configuration', async () => { - expect(thresholdManager.getThresholds('pve1', 'node1', '100')).not.toBeNull(); - - const result = await thresholdManager.removeThresholds('pve1', 'node1', '100'); - - expect(result).toBe(true); - expect(thresholdManager.getThresholds('pve1', 'node1', '100')).toBeNull(); - expect(fs.writeFile).toHaveBeenCalled(); - }); - - test('should handle removal of non-existent thresholds gracefully', async () => { - const result = await thresholdManager.removeThresholds('pve1', 'node1', '999'); - expect(result).toBe(false); - }); - - test('should not affect other threshold configurations', async () => { - await thresholdManager.removeThresholds('pve1', 'node1', '100'); - - expect(thresholdManager.getThresholds('pve1', 'node1', '200')).not.toBeNull(); - }); - }); - - describe('File Operations', () => { - test('should handle file save errors gracefully', async () => { - const saveError = new Error('Disk full'); - fs.writeFile.mockRejectedValue(saveError); - - await expect( - thresholdManager.setThresholds('pve1', 'node1', '100', { - cpu: { warning: 70, critical: 85 } - }) - ).rejects.toThrow('Disk full'); - }); - - test('should create data directory if it does not exist', async () => { - fs.mkdir.mockResolvedValue(); - fs.readFile.mockResolvedValue('{}'); - - await thresholdManager.loadThresholds(); - - expect(fs.mkdir).toHaveBeenCalledWith( - path.dirname(mockConfigPath), - { recursive: true } - ); - }); - - test('should save thresholds in correct JSON format', async () => { - fs.writeFile.mockResolvedValue(); - - await thresholdManager.setThresholds('pve1', 'node1', '100', { - cpu: { warning: 70, critical: 85 } - }); - - const saveCall = fs.writeFile.mock.calls[0]; - expect(saveCall[0]).toBe(mockConfigPath); - expect(saveCall[2]).toBe('utf8'); - - const savedData = JSON.parse(saveCall[1]); - expect(savedData).toHaveProperty('pve1:100'); - }); - }); - - describe('Edge Cases and Error Handling', () => { - test('should handle empty threshold configuration', async () => { - fs.writeFile.mockResolvedValue(); - - const emptyThresholds = {}; - - const result = await thresholdManager.setThresholds('pve1', 'node1', '100', emptyThresholds); - expect(result).toBe(true); - - const stored = thresholdManager.getThresholds('pve1', 'node1', '100'); - expect(stored.createdAt).toBeDefined(); - }); - - test('should handle very large cache sizes', async () => { - fs.writeFile.mockResolvedValue(); - - // Add many threshold configurations - for (let i = 0; i < 100; i++) { - await thresholdManager.setThresholds('pve1', 'node1', String(i), { - cpu: { warning: 70, critical: 85 } - }); - } - - expect(thresholdManager.cache.size).toBe(100); - expect(thresholdManager.getThresholds('pve1', 'node1', '50')).not.toBeNull(); - }); - }); - - describe('Bulk Operations', () => { - test('should get all threshold configurations', async () => { - fs.writeFile.mockResolvedValue(); - - await thresholdManager.setThresholds('pve1', 'node1', '100', { - cpu: { warning: 70, critical: 85 } - }); - await thresholdManager.setThresholds('pve2', 'node1', '200', { - memory: { warning: 80, critical: 90 } - }); - - const allConfigs = thresholdManager.getAllThresholds(); - - expect(Array.isArray(allConfigs)).toBe(true); - expect(allConfigs.length).toBe(2); - expect(allConfigs.some(config => config.vmid === '100')).toBe(true); - expect(allConfigs.some(config => config.vmid === '200')).toBe(true); - }); - - test('should get thresholds by endpoint', async () => { - fs.writeFile.mockResolvedValue(); - - await thresholdManager.setThresholds('pve1', 'node1', '100', { - cpu: { warning: 70, critical: 85 } - }); - await thresholdManager.setThresholds('pve1', 'node1', '200', { - memory: { warning: 80, critical: 90 } - }); - await thresholdManager.setThresholds('pve2', 'node1', '300', { - cpu: { warning: 60, critical: 75 } - }); - - const pve1Configs = thresholdManager.getThresholdsByEndpoint('pve1'); - const pve2Configs = thresholdManager.getThresholdsByEndpoint('pve2'); - - expect(pve1Configs.length).toBe(2); - expect(pve2Configs.length).toBe(1); - expect(pve1Configs.every(config => config.endpointId === 'pve1')).toBe(true); - expect(pve2Configs.every(config => config.endpointId === 'pve2')).toBe(true); - }); - - test('should export threshold configurations', () => { - // Add some test data directly to cache - thresholdManager.cache.set('pve1:100', { - endpointId: 'pve1', - vmid: '100', - thresholds: { cpu: { warning: 70, critical: 85 } }, - createdAt: '2024-01-01T00:00:00.000Z' - }); - - const exported = thresholdManager.exportThresholds(); - - expect(exported).toHaveProperty('exportedAt'); - expect(exported).toHaveProperty('version'); - expect(exported.version).toBe('1.0'); - expect(exported.thresholds).toHaveLength(1); - expect(exported.thresholds[0].vmid).toBe('100'); - }); - - test('should get threshold statistics', async () => { - fs.writeFile.mockResolvedValue(); - - // Add multiple configurations - await thresholdManager.setThresholds('pve1', 'node1', '100', { - cpu: { warning: 70, critical: 85 } - }); - await thresholdManager.setThresholds('pve1', 'node1', '200', { - memory: { warning: 80, critical: 90 } - }); - - const stats = thresholdManager.getStatistics(); - - expect(stats).toHaveProperty('total'); - expect(stats).toHaveProperty('byEndpoint'); - expect(stats.total).toBe(2); - }); - }); - - describe('Threshold Management', () => { - test('should toggle threshold configurations', async () => { - fs.writeFile.mockResolvedValue(); - - // Set up a threshold configuration - await thresholdManager.setThresholds('pve1', 'node1', '100', { - cpu: { warning: 70, critical: 85 } - }); - - // Disable it - const result = await thresholdManager.toggleThresholds('pve1', 'node1', '100', false); - expect(result).toBe(true); - - const config = thresholdManager.getThresholds('pve1', 'node1', '100'); - expect(config.enabled).toBe(false); - expect(config.updatedAt).toBeDefined(); - - // Re-enable it - await thresholdManager.toggleThresholds('pve1', 'node1', '100', true); - const updatedConfig = thresholdManager.getThresholds('pve1', 'node1', '100'); - expect(updatedConfig.enabled).toBe(true); - }); - - test('should handle toggle for non-existent configuration', async () => { - await expect( - thresholdManager.toggleThresholds('pve1', 'node1', '999', true) - ).rejects.toThrow('Threshold configuration not found'); - }); - - test('should validate threshold values correctly', () => { - // Test CPU thresholds - const validCpuThresholds = { - cpu: { warning: 70, critical: 85 } - }; - const validated = thresholdManager.validateThresholds(validCpuThresholds); - expect(validated.cpu.warning).toBe(70); - expect(validated.cpu.critical).toBe(85); - - // Test invalid CPU thresholds (warning >= critical) - const invalidCpuThresholds = { - cpu: { warning: 90, critical: 85 } - }; - expect(() => { - thresholdManager.validateThresholds(invalidCpuThresholds); - }).toThrow('CPU critical threshold must be greater than warning threshold'); - }); - - test('should validate memory thresholds correctly', () => { - const validMemoryThresholds = { - memory: { warning: 80, critical: 95 } - }; - const validated = thresholdManager.validateThresholds(validMemoryThresholds); - expect(validated.memory.warning).toBe(80); - expect(validated.memory.critical).toBe(95); - - // Test invalid memory thresholds - const invalidMemoryThresholds = { - memory: { warning: 95, critical: 80 } - }; - expect(() => { - thresholdManager.validateThresholds(invalidMemoryThresholds); - }).toThrow('Memory critical threshold must be greater than warning threshold'); - }); - - test('should validate disk thresholds correctly', () => { - const validDiskThresholds = { - disk: { warning: 85, critical: 95 } - }; - const validated = thresholdManager.validateThresholds(validDiskThresholds); - expect(validated.disk.warning).toBe(85); - expect(validated.disk.critical).toBe(95); - - // Test invalid disk thresholds - const invalidDiskThresholds = { - disk: { warning: 98, critical: 90 } - }; - expect(() => { - thresholdManager.validateThresholds(invalidDiskThresholds); - }).toThrow('Disk critical threshold must be greater than warning threshold'); - }); - }); - - describe('Integration with Alert System', () => { - test('should store threshold configurations with proper structure', async () => { - fs.writeFile.mockResolvedValue(); - - await thresholdManager.setThresholds('pve1', 'node1', '100', { - cpu: { warning: 75, critical: 90 }, - memory: { warning: 80, critical: 95 } - }); - - const config = thresholdManager.getThresholds('pve1', 'node1', '100'); - - // Verify structure for alert system integration - expect(config).toHaveProperty('endpointId', 'pve1'); - expect(config).toHaveProperty('vmid', '100'); - expect(config).toHaveProperty('thresholds'); - expect(config).toHaveProperty('enabled', true); - expect(config).toHaveProperty('createdAt'); - expect(config).toHaveProperty('updatedAt'); - - // Verify threshold values are accessible - expect(config.thresholds.cpu.warning).toBe(75); - expect(config.thresholds.cpu.critical).toBe(90); - expect(config.thresholds.memory.warning).toBe(80); - expect(config.thresholds.memory.critical).toBe(95); - }); - - test('should handle partial threshold configurations', async () => { - fs.writeFile.mockResolvedValue(); - - // Set only CPU thresholds - await thresholdManager.setThresholds('pve1', 'node1', '200', { - cpu: { warning: 70, critical: 85 } - }); - - const config = thresholdManager.getThresholds('pve1', 'node1', '200'); - - expect(config.thresholds.cpu).toBeDefined(); - expect(config.thresholds.memory).toBeUndefined(); - expect(config.thresholds.disk).toBeUndefined(); - }); - }); -}); \ No newline at end of file diff --git a/tests/dataFetcher.test.js b/tests/dataFetcher.test.js deleted file mode 100644 index 4823e5c77..000000000 --- a/tests/dataFetcher.test.js +++ /dev/null @@ -1,1168 +0,0 @@ -const { fetchDiscoveryData, fetchMetricsData, fetchPbsData, clearCaches } = require('../dataFetcher'); -// Don't require the real apiClients, we will mock it -// const { initializeApiClients } = require('../apiClients'); - -// Mock the modules used by dataFetcher -jest.mock('axios'); // Keep this in case axios is used directly anywhere unexpected -jest.mock('../pbsUtils', () => ({ - // Ensure processPbsTasks returns the expected structure - processPbsTasks: jest.fn().mockReturnValue({ backupTasks: [], verifyTasks: [], gcTasks: [] }), -})); -jest.mock('../apiClients'); // <-- MOCK apiClients module - -// --- REMOVE Mock for fetchPbsData within dataFetcher --- -// jest.mock('../dataFetcher', ...); -// --- END REMOVE --- - -// Import the mocked version AFTER mocking it -const { initializeApiClients } = require('../apiClients'); - -process.env.NODE_ENV = 'test'; - - -describe('Data Fetcher', () => { - // --- Declare variables used across tests/hooks --- - let originalEnv; // <--- Declare here - let mockPveClientInstance; - let mockPveApiClient; - let mockPbsClientInstance; - let mockPbsApiClient; - // --- End declare vars --- - - // Helper to set up a basic PBS client mock (MOVED TO OUTER SCOPE) - const setupMockPbsClient = (id, configOverrides = {}, clientMocks = {}) => { - mockPbsClientInstance = { - get: jest.fn(), - ...clientMocks // Allow overriding .get or adding other methods - }; - // Use the mockPbsApiClient defined in the outer scope - mockPbsApiClient[id] = { - client: mockPbsClientInstance, - config: { - id: `${id}_config_id`, - name: `PBS Instance ${id}`, - host: `${id}.pbs.example.com`, - // Add other default config properties as needed - ...configOverrides - } - }; - return mockPbsClientInstance; // Return the mock instance for further configuration - }; - - beforeEach(() => { - // Store environment (assign to variable declared above) - originalEnv = { ...process.env }; - // Reset the mocked initializeApiClients function and other mocks - jest.clearAllMocks(); - - // Define the *default* return value for the mocked initializer - // Tests can override this if needed - mockPveClientInstance = { get: jest.fn() }; - mockPveApiClient = { - primary: { client: mockPveClientInstance, config: { /* ... */ } } - }; - mockPbsClientInstance = { get: jest.fn() }; - mockPbsApiClient = {}; - initializeApiClients.mockResolvedValue({ - apiClients: mockPveApiClient, - pbsApiClients: mockPbsApiClient - }); - - // --- Remove console mocks --- - // jest.spyOn(console, 'warn').mockImplementation(() => {}); - // jest.spyOn(console, 'log').mockImplementation(() => {}); - // jest.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterEach(() => { - // Clear caches to prevent test pollution - clearCaches(); - - // Restore environment (can now access originalEnv) - const currentEnvKeys = Object.keys(process.env); - currentEnvKeys.forEach(key => delete process.env[key]); - Object.keys(originalEnv).forEach(key => { process.env[key] = originalEnv[key]; }); - // --- Remove console restore --- - // jest.restoreAllMocks(); - }); - - describe('fetchDiscoveryData', () => { - test('should return empty structure when no PVE clients configured', async () => { - const mockPbsFunction = jest.fn().mockResolvedValue([]); - - const result = await fetchDiscoveryData({}, mockPbsApiClient, mockPbsFunction); - - expect(result.nodes).toEqual([]); - expect(result.vms).toEqual([]); - expect(result.containers).toEqual([]); - expect(result.pbs).toEqual([]); - expect(mockPbsFunction).toHaveBeenCalled(); - }); - - test('should fetch basic PVE cluster data successfully', async () => { - const mockClient = { - primary: { - client: { - get: jest.fn() - .mockResolvedValueOnce({ data: { data: [{ type: 'cluster', nodes: 1 }] } }) - .mockResolvedValue({ data: { data: [] } }) - }, - config: { id: 'primary', name: 'Primary PVE' } - } - }; - - const result = await fetchDiscoveryData(mockClient, {}); - - expect(result).toHaveProperty('nodes'); - expect(result).toHaveProperty('vms'); - expect(result).toHaveProperty('containers'); - expect(result).toHaveProperty('pbs'); - expect(result).toHaveProperty('pveBackups'); - expect(Array.isArray(result.nodes)).toBe(true); - expect(Array.isArray(result.vms)).toBe(true); - expect(Array.isArray(result.containers)).toBe(true); - }); - - test('should handle bad node storage data gracefully', async () => { - const mockClient = { - primary: { - client: { - get: jest.fn().mockResolvedValue({ data: { data: [] } }) - }, - config: { id: 'primary', name: 'Primary PVE' } - } - }; - - const result = await fetchDiscoveryData(mockClient, {}); - - expect(result).toHaveProperty('nodes'); - expect(Array.isArray(result.nodes)).toBe(true); - }); - - test('should handle missing node data gracefully', async () => { - const mockClient = { - primary: { - client: { - get: jest.fn().mockResolvedValue({ data: { data: null } }) - }, - config: { id: 'primary', name: 'Primary PVE' } - } - }; - - const result = await fetchDiscoveryData(mockClient, {}); - - expect(result).toEqual({ - nodes: [], - vms: [], - containers: [], - pbs: [], - pveBackups: expect.any(Object) - }); - }); - - test('should work with multiple PVE endpoints', async () => { - const mockClients = { - pve1: { - client: { - get: jest.fn().mockResolvedValue({ data: { data: [] } }) - }, - config: { id: 'pve1', name: 'PVE1' } - }, - pve2: { - client: { - get: jest.fn().mockRejectedValue(new Error('Network error')) - }, - config: { id: 'pve2', name: 'PVE2' } - } - }; - - const result = await fetchDiscoveryData(mockClients, {}); - - expect(result).toEqual({ - nodes: [], - vms: [], - containers: [], - pbs: [], - pveBackups: expect.any(Object) - }); - }); - - test('should handle API error when fetching guests for a specific node', async () => { - // Arrange: Uses the default mock clients from beforeEach - const nodeNameGood = 'node-good'; - const nodeNameBad = 'node-bad-guests'; - - // Use mockImplementation on the default mockPveClientInstance - mockPveClientInstance.get.mockImplementation(async (url) => { - console.log(`Mock API call: ${url}`); // Added for debugging - if (url === '/nodes') { - return { data: { data: [ - { node: nodeNameGood, status: 'online', id: `node/${nodeNameGood}` }, - { node: nodeNameBad, status: 'online', id: `node/${nodeNameBad}` } - ]}}; - } - if (url === `/nodes/${nodeNameGood}/status`) { - return { data: { data: { cpu: 0.1, uptime: 10 } } }; - } - if (url === `/nodes/${nodeNameGood}/storage`) { - return { data: { data: [] } }; - } - if (url === `/nodes/${nodeNameGood}/qemu`) { - return { data: { data: [ { vmid: 100, name: 'vm-good', status: 'running' } ] } }; - } - if (url === `/nodes/${nodeNameGood}/lxc`) { - return { data: { data: [] } }; - } - if (url === `/nodes/${nodeNameBad}/status`) { - return { data: { data: { cpu: 0.2, uptime: 20 } } }; - } - if (url === `/nodes/${nodeNameBad}/storage`) { - return { data: { data: [] } }; - } - if (url === `/nodes/${nodeNameBad}/qemu`) { - // Simulate API error for this specific call - throw new Error('Simulated API Error Fetching Guests'); - } - if (url === `/nodes/${nodeNameBad}/lxc`) { - return { data: { data: [] } }; // Successful but empty - } - // Default fallback for unexpected calls - throw new Error(`Unexpected API call in mock: ${url}`); - }); - - // Act: Uses the default mock clients from beforeEach - const result = await fetchDiscoveryData(mockPveApiClient, mockPbsApiClient); - - // Assert - // Check that the correct number of nodes is returned - expect(result.nodes).toHaveLength(2); - - // Find the nodes in the result - const goodNodeResult = result.nodes.find(n => n.node === nodeNameGood); - const badNodeResult = result.nodes.find(n => n.node === nodeNameBad); - - expect(goodNodeResult).toBeDefined(); - expect(badNodeResult).toBeDefined(); - - // Assertions for the node where all calls succeeded (nodeNameGood) - expect(goodNodeResult.cpu).toBe(0.1); // Should have CPU data from successful /status call - expect(goodNodeResult.status).toBe('online'); // Status updated by uptime > 0 - expect(goodNodeResult.vms).toBeUndefined(); // VMs/CTs are in the top-level result.vms/result.containers - - // Assertions for the node where /qemu failed (nodeNameBad) - // It should still have basic info from /nodes and status info from its successful /status call - expect(badNodeResult.cpu).toBe(0.2); // CPU data from its OWN successful /status call - expect(badNodeResult.status).toBe('online'); // Status updated by uptime > 0 - // It should not have contributed VMs/CTs because fetchDataForNode rejected - - // Assert overall VMs/Containers (only from the successful node) - expect(result.vms).toHaveLength(1); - expect(result.vms[0].vmid).toBe(100); // VM from nodeNameGood - expect(result.containers).toHaveLength(0); - - // Assert PBS is empty - expect(result.pbs).toEqual([]); - }); - - test('should integrate PVE and PBS data successfully', async () => { - const mockPveClient = { - primary: { - client: { - get: jest.fn().mockResolvedValue({ data: { data: [] } }) - }, - config: { id: 'primary', name: 'Primary PVE' } - } - }; - const mockPbsClient = { - 'pbs-1': { - client: { - get: jest.fn().mockResolvedValue({ data: { data: [] } }) - }, - config: { name: 'PBS Instance' } - } - }; - - const result = await fetchDiscoveryData(mockPveClient, mockPbsClient); - - expect(result).toHaveProperty('nodes'); - expect(result).toHaveProperty('vms'); - expect(result).toHaveProperty('containers'); - expect(result).toHaveProperty('pbs'); - expect(result).toHaveProperty('pveBackups'); - }); - - test('should handle errors from fetchPbsData gracefully', async () => { - // Arrange PVE (same simple mock as above) - const nodeName = 'pve-node'; - const vmId = 200; - mockPveClientInstance.get - .mockResolvedValueOnce({ data: { data: [{ node: nodeName, status: 'online' }] } }) // /nodes - .mockResolvedValueOnce({ data: { data: { uptime: 1 } } }) // status - .mockResolvedValueOnce({ data: { data: [] } }) // storage - .mockResolvedValueOnce({ data: { data: [{ vmid: vmId, name: 'pve-vm' }] } }) // qemu - .mockResolvedValueOnce({ data: { data: [] } }); // lxc - - // Arrange PBS (Mock the function to be injected) - const mockPbsFunction = jest.fn(); - const pbsError = new Error('PBS Connection Failed'); - // Revert to mockRejectedValue - mockPbsFunction.mockRejectedValue(pbsError); - const result = await fetchDiscoveryData(mockPveApiClient, mockPbsApiClient, mockPbsFunction); - - expect(result.nodes).toHaveLength(0); - expect(result.vms).toHaveLength(0); - expect(result.containers).toHaveLength(0); - expect(mockPbsFunction).toHaveBeenCalledWith(mockPbsApiClient); - expect(result.pbs).toEqual([]); - }); - - test('should work without PBS clients configured', async () => { - const mockPveClient = { - primary: { - client: { - get: jest.fn().mockResolvedValue({ data: { data: [] } }) - }, - config: { id: 'primary', name: 'Primary PVE' } - } - }; - - const result = await fetchDiscoveryData(mockPveClient, {}); - - expect(result).toHaveProperty('pbs'); - }); - - test('should handle error fetching Containers (lxc)', async () => { - // Arrange: Uses the default mock clients from beforeEach - const nodeNameGood = 'node-good'; - const nodeNameBad = 'node-bad-guests'; // This node will have the LXC fetch error - const endpointId = 'primary'; // Default endpointId from mockPveApiClient setup - mockPveClientInstance.get.mockImplementation(async (url) => { - if (url === '/cluster/status') { - return { data: { data: [{ type: 'cluster', nodes: 2, name: 'test-cluster' }] } }; - } - if (url === '/nodes') { - return { data: { data: [ - { node: nodeNameGood, status: 'online', id: `node/${nodeNameGood}` }, - { node: nodeNameBad, status: 'online', id: `node/${nodeNameBad}` } - ]}}; - } - if (url === `/nodes/${nodeNameGood}/status`) return { data: { data: { cpu: 0.1, uptime: 10 } } }; - if (url === `/nodes/${nodeNameGood}/storage`) return { data: { data: [] } }; - if (url === `/nodes/${nodeNameGood}/qemu`) return { data: { data: [ { vmid: 100, name: 'vm-good', status: 'running' } ] } }; - if (url === `/nodes/${nodeNameGood}/lxc`) return { data: { data: [] } }; - if (url === `/nodes/${nodeNameBad}/status`) return { data: { data: { cpu: 0.2, uptime: 20 } } }; - if (url === `/nodes/${nodeNameBad}/storage`) return { data: { data: [] } }; - if (url === `/nodes/${nodeNameBad}/qemu`) return { data: { data: [] } }; - if (url === `/nodes/${nodeNameBad}/lxc`) { - throw new Error('Simulated LXC Fetch Error'); - } - throw new Error(`Unexpected API call in mock: ${url}`); - }); - - const result = await fetchDiscoveryData(mockPveApiClient, mockPbsApiClient); - - expect(result.nodes).toHaveLength(2); - const goodNodeResult = result.nodes.find(n => n.node === nodeNameGood); - const badNodeResult = result.nodes.find(n => n.node === nodeNameBad); - expect(goodNodeResult).toBeDefined(); - expect(badNodeResult).toBeDefined(); - expect(result.vms).toHaveLength(1); - expect(result.vms[0].vmid).toBe(100); - expect(result.containers).toHaveLength(0); - }); - - test('should handle API failures gracefully', async () => { - // Test the behavior: when APIs fail, return empty results instead of crashing - const failingApiClient = { - primary: { - client: { - get: jest.fn().mockRejectedValue(new Error('API unavailable')) - }, - config: { id: 'primary', name: 'Primary PVE' } - } - }; - - const result = await fetchDiscoveryData(failingApiClient, {}); - - // Verify behavior: should return empty structure, not crash - expect(result).toEqual({ - nodes: [], - vms: [], - containers: [], - pbs: [], - pveBackups: expect.any(Object) - }); - }); - - test('should handle invalid node status data gracefully', async () => { - const mockClient = { - primary: { - client: { - get: jest.fn().mockResolvedValue({ data: { data: null } }) - }, - config: { id: 'primary', name: 'Primary PVE' } - } - }; - - const result = await fetchDiscoveryData(mockClient, {}); - - expect(result).toEqual({ - nodes: [], - vms: [], - containers: [], - pbs: [], - pveBackups: expect.any(Object) - }); - }); - - - test('should handle malformed API responses gracefully', async () => { - const invalidApiClient = { - primary: { - client: { - get: jest.fn().mockResolvedValue({ data: { data: 'invalid-format' } }) - }, - config: { id: 'primary', name: 'Primary PVE' } - } - }; - - const result = await fetchDiscoveryData(invalidApiClient, {}); - - expect(result).toEqual({ - nodes: [], - vms: [], - containers: [], - pbs: [], - pveBackups: expect.any(Object) - }); - }); - - test('should continue working when some endpoints fail', async () => { - const mixedClients = { - primary: { - client: { - get: jest.fn().mockResolvedValue({ data: { data: [] } }) - }, - config: { id: 'primary', name: 'Working PVE' } - }, - broken: null - }; - - const result = await fetchDiscoveryData(mixedClients, {}); - - expect(result).toEqual({ - nodes: [], - vms: [], - containers: [], - pbs: [], - pveBackups: expect.any(Object) - }); - }); - - test('should work without PBS clients', async () => { - const pveOnlyClient = { - primary: { - client: { - get: jest.fn().mockResolvedValue({ data: { data: [] } }) - }, - config: { id: 'primary', name: 'Primary PVE' } - } - }; - - const result = await fetchDiscoveryData(pveOnlyClient, null); - - expect(result.pbs).toEqual([]); - expect(result).toHaveProperty('nodes'); - expect(result).toHaveProperty('vms'); - expect(result).toHaveProperty('containers'); - }); - - }); - - // --- NEW: describe block for fetchMetricsData --- - describe('fetchMetricsData', () => { - // Note: This block now relies on mockPveApiClient and mockPveClientInstance - // set up in the main beforeEach of the outer describe block. - let mockCurrentApiClients; // Keep this structure locally if tests modify it - - beforeEach(() => { - // Reset only the client's get method, as the client itself is setup outside - mockPveClientInstance.get.mockClear(); - - // Use the mock PVE client setup in the outer scope. - // Tests within this block might add more clients (e.g., pve2) to this object. - mockCurrentApiClients = { ...mockPveApiClient }; - }); - - test('should return empty array when no running guests are provided', async () => { - const result = await fetchMetricsData([], [], mockCurrentApiClients); - expect(result).toEqual([]); - expect(mockPveClientInstance.get).not.toHaveBeenCalled(); // Use outer mock instance - }); - - test('should fetch VM metrics successfully', async () => { - const mockApiClients = { - 'primary': { - client: { - get: jest.fn().mockResolvedValue({ data: { data: [{ cpu: 0.5 }] } }) - }, - config: { name: 'Primary PVE' } - } - }; - - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 100, type: 'qemu', name: 'vm-test' } - ]; - - const result = await fetchMetricsData(runningVms, [], mockApiClients); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThanOrEqual(0); - }); - - test('should fetch container metrics successfully', async () => { - const mockApiClients = { - 'primary': { - client: { - get: jest.fn().mockResolvedValue({ data: { data: [{ cpu: 0.2 }] } }) - }, - config: { name: 'Primary PVE' } - } - }; - - const runningContainers = [ - { endpointId: 'primary', node: 'node2', vmid: 101, type: 'lxc', name: 'ct-test' } - ]; - - const result = await fetchMetricsData([], runningContainers, mockApiClients); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThanOrEqual(0); - }); - - test('should fetch metrics for multiple guests successfully', async () => { - const mockApiClients = { - 'primary': { - client: { - get: jest.fn().mockResolvedValue({ data: { data: [{ cpu: 0.1 }] } }) - }, - config: { name: 'Primary PVE' } - } - }; - - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 100, type: 'qemu', name: 'vm1' } - ]; - const runningContainers = [ - { endpointId: 'primary', node: 'node1', vmid: 101, type: 'lxc', name: 'ct1' } - ]; - - const result = await fetchMetricsData(runningVms, runningContainers, mockApiClients); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThanOrEqual(0); - }); - - test('should handle missing API client gracefully', async () => { - const mockApiClients = { - 'primary': { - client: { - get: jest.fn().mockResolvedValue({ data: { data: [{ cpu: 0.5 }] } }) - }, - config: { name: 'Primary PVE' } - } - }; - - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 100, type: 'qemu', name: 'vm-good' }, - { endpointId: 'missing', node: 'nodeX', vmid: 999, type: 'qemu', name: 'vm-bad' } - ]; - - const result = await fetchMetricsData(runningVms, [], mockApiClients); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThanOrEqual(0); - }); - - - test('should handle API error when fetching RRD data for one guest', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 100, type: 'qemu', name: 'vm-ok' }, - { endpointId: 'primary', node: 'node1', vmid: 101, type: 'qemu', name: 'vm-fail-rrd' } - ]; - - const error = new Error('RRD Fetch Failed'); - const endpointName = mockCurrentApiClients.primary.config.name || 'primary'; - - // Mock success for vm-ok - mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: [{ cpu: 0.1 }] } }); // rrd ok - mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: { cpu: 0.11 } } }); // current ok - - // Mock failure for vm-fail-rrd (RRD call fails, current call succeeds) - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - - expect(Array.isArray(result)).toBe(true); - }); - - test('should handle API error when fetching current status for one guest', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 100, type: 'qemu', name: 'vm-ok' }, - { endpointId: 'primary', node: 'node1', vmid: 101, type: 'qemu', name: 'vm-fail-current' } - ]; - - const error = new Error('Current Status Fetch Failed'); - mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: [{ cpu: 0.1 }] } }); - mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: { cpu: 0.11 } } }); - mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: [{ cpu: 0.2 }] } }); - mockPveClientInstance.get.mockRejectedValueOnce(error); - - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe(100); - }); - - test('should handle API 400 error gracefully (guest likely stopped)', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 100, type: 'qemu', name: 'vm-ok' }, - { endpointId: 'primary', node: 'node1', vmid: 101, type: 'qemu', name: 'vm-stopped' } - ]; - const endpointName = mockCurrentApiClients.primary.config.name || 'primary'; - - // Simulate a 400 error response - const error400 = new Error('Bad Request'); - error400.response = { status: 400 }; - - // Mock success for vm-ok - mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: [{ cpu: 0.1 }] } }); // rrd ok - mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: { cpu: 0.11 } } }); // current ok - - // Mock 400 failure for vm-stopped (assume RRD call fails first) - mockPveClientInstance.get.mockRejectedValueOnce(error400); // rrd fails with 400 - // The current status call for the failing guest might not even happen if RRD fails hard, - // but mock it just in case the error handling changes. Let's assume it would succeed if called. - mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: { cpu: 0.22 } } }); - - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe(100); - }); - - test('should handle empty RRD data array gracefully', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 100, type: 'qemu', name: 'vm-no-rrd-data' } - ]; - - // Mock RRD data response with empty data array - mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: [] } }); - // Mock current status response - mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: { cpu: 0.5, mem: 1024 } } }); - - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - const endpointName = mockCurrentApiClients.primary.config.name || 'primary'; - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - id: 100, - endpointName: endpointName, - data: [], // RRD data should be an empty array - current: { cpu: 0.5, mem: 1024 } - }); - }); - - test('should handle null current status data gracefully', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 100, type: 'qemu', name: 'vm-no-current-data' } - ]; - - // Mock RRD data response - mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: [{ time: 1, cpu: 0.5 }] } }); - // Mock current status response with null data - mockPveClientInstance.get.mockResolvedValueOnce({ data: { data: null } }); - - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - const endpointName = mockCurrentApiClients.primary.config.name || 'primary'; - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - id: 100, - endpointName: endpointName, - data: [{ time: 1, cpu: 0.5 }], - current: null // Current data should be null - }); - }); - - // --- Tests for QEMU Guest Agent Memory Fetching --- - test('should fetch QEMU guest agent memory info when agent is enabled and responsive', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 100, type: 'qemu', name: 'vm-agent-ok', agent: '1' } - ]; - mockPveClientInstance.get - .mockResolvedValueOnce({ data: { data: [{ time: 1, cpu: 0.5 }] } }) // RRD - .mockResolvedValueOnce({ data: { data: { cpu: 0.5, mem: 2048*1024*1024, disk: 2048, agent: 1 } } }); // Current status (agent enabled) - - // Mock the POST call for guest agent - mockPveClientInstance.post = jest.fn().mockResolvedValueOnce({ - data: { - data: { - result: { total: 2048*1024*1024, free: 1024*1024*1024, available: 1536*1024*1024 } - } - } - }); - - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - expect(result).toHaveLength(1); - expect(result[0].current).toBeDefined(); - expect(result[0].current.guest_mem_total_bytes).toBe(2048*1024*1024); - expect(result[0].current.guest_mem_free_bytes).toBe(1024*1024*1024); - expect(result[0].current.guest_mem_available_bytes).toBe(1536*1024*1024); - expect(result[0].current.guest_mem_actual_used_bytes).toBe((2048-1536)*1024*1024); - expect(mockPveClientInstance.post).toHaveBeenCalledWith('/nodes/node1/qemu/100/agent/get-memory-block-info', {}); - }); - - test('should not attempt QEMU guest agent memory fetch if agent is not enabled in current status', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 101, type: 'qemu', name: 'vm-agent-off', agent: '1'} // Configured as on, but status says off - ]; - mockPveClientInstance.get - .mockResolvedValueOnce({ data: { data: [{ time: 1, cpu: 0.5 }] } }) // RRD - .mockResolvedValueOnce({ data: { data: { cpu: 0.5, mem: 1024, disk: 2048, agent: 0 } } }); // Current status (agent OFF) - mockPveClientInstance.post = jest.fn(); // Ensure post is a mock - - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - expect(result).toHaveLength(1); - expect(result[0].current.guest_mem_total_bytes).toBeUndefined(); - expect(mockPveClientInstance.post).not.toHaveBeenCalled(); - }); - - test('should not attempt QEMU guest agent memory fetch if guest agent config is missing/off', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 102, type: 'qemu', name: 'vm-agent-not-configured' } // No agent field - ]; - mockPveClientInstance.get - .mockResolvedValueOnce({ data: { data: [{ time: 1, cpu: 0.5 }] } }) // RRD - .mockResolvedValueOnce({ data: { data: { cpu: 0.5, mem: 1024, disk: 2048, agent: 1 } } }); // Current status (agent ON) - mockPveClientInstance.post = jest.fn(); - - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - expect(result).toHaveLength(1); - expect(result[0].current.guest_mem_total_bytes).toBeUndefined(); - expect(mockPveClientInstance.post).not.toHaveBeenCalled(); - }); - - test('should handle QEMU guest agent error (e.g., agent not responsive)', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 103, type: 'qemu', name: 'vm-agent-error', agent: 'enabled=1' } - ]; - mockPveClientInstance.get - .mockResolvedValueOnce({ data: { data: [{ time: 1, cpu: 0.5 }] } }) // RRD - .mockResolvedValueOnce({ data: { data: { cpu: 0.5, mem: 1024, disk: 2048, agent: 1 } } }); // Current status - - const agentError = new Error('Agent not responsive'); - agentError.response = { status: 500, data: { data: { exitcode: -2 } } }; - mockPveClientInstance.post = jest.fn().mockRejectedValueOnce(agentError); - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - expect(result).toHaveLength(1); - expect(result[0].current.guest_mem_total_bytes).toBeUndefined(); - }); - - test('should handle unexpected QEMU guest agent response format', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 104, type: 'qemu', name: 'vm-agent-bad-format', agent: '1' } - ]; - mockPveClientInstance.get - .mockResolvedValueOnce({ data: { data: [{ time: 1, cpu: 0.5 }] } }) // RRD - .mockResolvedValueOnce({ data: { data: { cpu: 0.5, mem: 1024, disk: 2048, agent: 1 } } }); // Current status - mockPveClientInstance.post = jest.fn().mockResolvedValueOnce({ data: { data: { result: { unexpected: "data" } } } }); - - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - expect(result).toHaveLength(1); - expect(result[0].current.guest_mem_total_bytes).toBeUndefined(); - }); - - test('should handle generic error fetching QEMU guest agent memory info', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 105, type: 'qemu', name: 'vm-agent-generic-error', agent: '1' } - ]; - mockPveClientInstance.get - .mockResolvedValueOnce({ data: { data: [{ time: 1, cpu: 0.5 }] } }) // RRD - .mockResolvedValueOnce({ data: { data: { cpu: 0.5, mem: 1024, disk: 2048, agent: 1 } } }); // Current status - - const genericAgentError = new Error('Network Failure'); - genericAgentError.response = { status: 503 }; // Simulate a non-500 error - mockPveClientInstance.post = jest.fn().mockRejectedValueOnce(genericAgentError); - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - expect(result).toHaveLength(1); - expect(result[0].current.guest_mem_total_bytes).toBeUndefined(); - }); - - test('should handle generic error fetching RRD/status data', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 106, type: 'qemu', name: 'vm-generic-rrd-error' } - ]; - const genericError = new Error('Server Unavailable'); - genericError.response = { status: 503 }; // Simulate non-400 error - - // Mock RRD call to fail with generic error, current status call to succeed - mockPveClientInstance.get - .mockRejectedValueOnce(genericError) // RRD fails - .mockResolvedValueOnce({ data: { data: { cpu: 0.1 } } }); // Current status succeeds - - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - - expect(result).toHaveLength(0); - expect(mockPveClientInstance.get).toHaveBeenCalledTimes(2); - }); - - test('should calculate actual used memory using fallback when "available" is missing', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 107, type: 'qemu', name: 'vm-agent-fallback-mem', agent: '1' } - ]; - const totalMem = 4096 * 1024 * 1024; - const freeMem = 1024 * 1024 * 1024; - const cachedMem = 512 * 1024 * 1024; - const buffersMem = 256 * 1024 * 1024; - const expectedUsed = totalMem - freeMem - cachedMem - buffersMem; - - mockPveClientInstance.get - .mockResolvedValueOnce({ data: { data: [{ time: 1, cpu: 0.5 }] } }) // RRD - .mockResolvedValueOnce({ data: { data: { cpu: 0.5, mem: totalMem, disk: 2048, agent: 1 } } }); // Current status - - mockPveClientInstance.post = jest.fn().mockResolvedValueOnce({ - data: { - data: { - // Agent response *without* 'available' field - result: { total: totalMem, free: freeMem, cached: cachedMem, buffers: buffersMem } - } - } - }); - - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - expect(result).toHaveLength(1); - expect(result[0].current).toBeDefined(); - expect(result[0].current.guest_mem_total_bytes).toBe(totalMem); - expect(result[0].current.guest_mem_free_bytes).toBe(freeMem); - expect(result[0].current.guest_mem_cached_bytes).toBe(cachedMem); - expect(result[0].current.guest_mem_buffers_bytes).toBe(buffersMem); - expect(result[0].current.guest_mem_available_bytes).toBeUndefined(); // Ensure 'available' was indeed missing - expect(result[0].current.guest_mem_actual_used_bytes).toBe(expectedUsed); // Check fallback calculation - }); - - test('should calculate actual used memory using final fallback (total - free) when other fields missing', async () => { - const runningVms = [ - { endpointId: 'primary', node: 'node1', vmid: 108, type: 'qemu', name: 'vm-agent-final-fallback', agent: '1' } - ]; - const totalMem = 2048 * 1024 * 1024; - const freeMem = 512 * 1024 * 1024; - const expectedUsed = totalMem - freeMem; - - mockPveClientInstance.get - .mockResolvedValueOnce({ data: { data: [{ time: 1, cpu: 0.5 }] } }) // RRD - .mockResolvedValueOnce({ data: { data: { cpu: 0.5, mem: totalMem, disk: 2048, agent: 1 } } }); // Current status - - mockPveClientInstance.post = jest.fn().mockResolvedValueOnce({ - data: { - data: { - // Agent response *only* with total and free - result: { total: totalMem, free: freeMem } - } - } - }); - - const result = await fetchMetricsData(runningVms, [], mockCurrentApiClients); - expect(result).toHaveLength(1); - expect(result[0].current).toBeDefined(); - expect(result[0].current.guest_mem_total_bytes).toBe(totalMem); - expect(result[0].current.guest_mem_free_bytes).toBe(freeMem); - expect(result[0].current.guest_mem_available_bytes).toBeUndefined(); - expect(result[0].current.guest_mem_cached_bytes).toBeUndefined(); - expect(result[0].current.guest_mem_buffers_bytes).toBeUndefined(); - expect(result[0].current.guest_mem_actual_used_bytes).toBe(expectedUsed); // Check final fallback calculation - }); - - - }); // End describe fetchMetricsData - - // --- NEW: describe block for fetchPbsData --- - describe('fetchPbsData', () => { - // Relies on mockPbsApiClient and mockPbsClientInstance from outer describe - - beforeEach(() => { - // Ensure the default mocks are reset/cleared if needed for PBS specific tests - // Typically mockPbsClientInstance.get.mockClear() is sufficient if reusing the instance - mockPbsClientInstance.get.mockClear(); - - // Reset the default PBS mock to an empty object for clarity - mockPbsApiClient = {}; - // Override the initializer mock if tests need specific PBS clients setup via initializeApiClients - // Otherwise, tests will construct and pass mock PBS clients directly - }); - - test('should return empty array when no PBS clients are provided', async () => { - const result = await fetchPbsData({}); - expect(result).toEqual([]); - }); - - test('should fetch PBS data successfully', async () => { - const mockClients = { - 'pbs-1': { - client: { - get: jest.fn() - .mockResolvedValueOnce({ data: { data: [{ node: 'pbs-node' }] } }) - .mockResolvedValue({ data: { data: [] } }) - }, - config: { name: 'PBS Instance' } - } - }; - - const result = await fetchPbsData(mockClients); - - expect(result).toHaveLength(1); - expect(result[0]).toHaveProperty('pbsEndpointId'); - expect(result[0]).toHaveProperty('pbsInstanceName'); - expect(result[0]).toHaveProperty('status'); - }); - - test('should handle error fetching PBS node name (and skip subsequent calls)', async () => { - const pbsId = 'pbs-err-node'; - const mockPbsClient = { get: jest.fn() }; - const mockClients = { [pbsId]: { client: mockPbsClient, config: { name: 'PBS Node Err' } } }; - const nodeError = new Error('Node fetch failed'); - - // Mock /nodes to fail - mockPbsClient.get.mockRejectedValueOnce(nodeError); - - const result = await fetchPbsData(mockClients); - - expect(result).toHaveLength(1); - expect(result[0].status).toBe('error'); - expect(result[0].datastores).toBeUndefined(); - expect(result[0].backupTasks).toBeUndefined(); - expect(mockPbsClient.get).toHaveBeenCalledTimes(1); - expect(mockPbsClient.get).toHaveBeenCalledWith('/nodes'); - }); - - test('should handle error fetching PBS datastores', async () => { - // Arrange - const pbsId = 'pbs-err-ds'; - const pbsNodeName = 'pbs-node-ds-err'; - const mockPbsClient = { get: jest.fn() }; - const mockClients = { [pbsId]: { client: mockPbsClient, config: { name: 'PBS DS Err' } } }; - const dsError = new Error('Datastore fetch failed'); - - mockPbsClient.get - .mockResolvedValueOnce({ data: { data: [{ node: pbsNodeName }] } }) // /nodes (succeeds) - .mockRejectedValueOnce(dsError) // /status/datastore-usage (fails) - .mockResolvedValueOnce({ data: { data: [] } }) // Mock fallback /config/datastore call (returns empty) - .mockRejectedValueOnce(new Error('Dedup fetch failed')) // /status/datastore-usage in fetchAllPbsTasksForProcessing (fails) - .mockResolvedValueOnce({ data: { data: [] } }) // /config/datastore in fetchAllPbsTasksForProcessing (empty) - .mockResolvedValueOnce({ data: { data: [] } }); // /nodes/{node}/tasks (empty tasks) - - const result = await fetchPbsData(mockClients); - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - pbsEndpointId: pbsId, - status: 'ok', - nodeName: pbsNodeName, - datastores: [], - }); - expect(mockPbsClient.get).toHaveBeenCalledTimes(6); - }); - - test('should handle partial datastore failures', async () => { - const mockClients = { - 'pbs-1': { - client: { - get: jest.fn() - .mockResolvedValueOnce({ data: { data: [{ node: 'node1' }] } }) - .mockResolvedValueOnce({ data: { data: [{ store: 'ds1' }] } }) - .mockRejectedValueOnce(new Error('Snapshot fetch failed')) - }, - config: { name: 'PBS 1' } - } - }; - - const result = await fetchPbsData(mockClients); - - expect(result).toHaveLength(1); - expect(result[0].status).toBe('ok'); - }); - - test('should handle PBS task fetch failures', async () => { - const mockClients = { - 'pbs-1': { - client: { - get: jest.fn() - .mockResolvedValueOnce({ data: { data: [{ node: 'pbs-node' }] } }) - .mockRejectedValue(new Error('Task fetch failed')) - }, - config: { name: 'PBS Instance' } - } - }; - - const result = await fetchPbsData(mockClients); - - expect(result).toHaveLength(1); - expect(result[0]).toHaveProperty('status'); - }); - - test('should handle multiple PBS instances with mixed results', async () => { - const mockClients = { - 'pbs-1': { - client: { - get: jest.fn() - .mockResolvedValueOnce({ data: { data: [{ node: 'node1' }] } }) - .mockResolvedValue({ data: { data: [] } }) - }, - config: { name: 'PBS 1' } - }, - 'pbs-2': { - client: { - get: jest.fn().mockRejectedValue(new Error('Connection failed')) - }, - config: { name: 'PBS 2' } - } - }; - - const result = await fetchPbsData(mockClients); - - expect(result).toHaveLength(2); - expect(result.some(r => r.status === 'ok')).toBe(true); - expect(result.some(r => r.status === 'error')).toBe(true); - }); - - test('should return error status and log warnings if /nodes response is invalid (e.g. empty array)', async () => { - // Arrange - const pbsId = 'pbs-bad-nodes'; - const mockPbsBadNodesClient = { get: jest.fn() }; - const mockPbsBadNodesApiClients = { - [pbsId]: { client: mockPbsBadNodesClient, config: { id: pbsId, name: 'PBS Bad Nodes' } } - }; - mockPbsBadNodesClient.get.mockResolvedValueOnce({ data: { data: [] } }); - - const result = await fetchPbsData(mockPbsBadNodesApiClients); - - expect(mockPbsBadNodesClient.get).toHaveBeenCalledTimes(1); - expect(mockPbsBadNodesClient.get).toHaveBeenCalledWith('/nodes'); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - pbsEndpointId: pbsId, - pbsInstanceName: 'PBS Bad Nodes', - status: 'error' - }); - }); - - test('should handle empty datastore usage with fallback', async () => { - const mockClients = { - 'pbs-1': { - client: { - get: jest.fn() - .mockResolvedValueOnce({ data: { data: [{ node: 'pbs-node' }] } }) - .mockResolvedValueOnce({ data: { data: [] } }) - .mockResolvedValue({ data: { data: [] } }) - }, - config: { name: 'PBS Instance' } - } - }; - - const result = await fetchPbsData(mockClients); - - expect(result).toHaveLength(1); - expect(result[0]).toHaveProperty('status'); - }); - - test('should handle error fetching datastore usage', async () => { - // Arrange - const pbsId = 'pbs-err-ds'; - const pbsNodeName = 'pbs-node-ds-err'; - const mockPbsClient = { get: jest.fn() }; - const mockClients = { [pbsId]: { client: mockPbsClient, config: { name: 'PBS DS Err' } } }; - const dsError = new Error('Datastore fetch failed'); - - mockPbsClient.get - .mockResolvedValueOnce({ data: { data: [{ node: pbsNodeName }] } }) // /nodes (succeeds) - .mockRejectedValueOnce(dsError) // /status/datastore-usage (fails) - .mockResolvedValueOnce({ data: { data: [] }}) // Mock fallback /config/datastore call (returns empty) - .mockRejectedValueOnce(new Error('Dedup fetch failed')) // /status/datastore-usage in fetchAllPbsTasksForProcessing (fails) - .mockResolvedValueOnce({ data: { data: [] }}) // /config/datastore in fetchAllPbsTasksForProcessing (empty) - .mockResolvedValueOnce({ data: { data: [] }}); // /nodes/{node}/tasks (empty tasks) - - const result = await fetchPbsData(mockClients); - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - pbsEndpointId: pbsId, - status: 'ok', - nodeName: pbsNodeName, - datastores: [], - }); - expect(mockPbsClient.get).toHaveBeenCalledTimes(6); - }); - - test('should handle failure of both datastore usage and config fetch', async () => { - // Arrange - const pbsId = 'pbs-double-ds-fail'; - const pbsNodeName = 'pbs-node-double-fail'; - const mockPbsClient = { get: jest.fn() }; - const mockClients = { [pbsId]: { client: mockPbsClient, config: { name: 'PBS Double DS Fail' } } }; - const usageError = new Error('Usage API Failed'); - const configError = new Error('Config API Failed'); - - mockPbsClient.get - .mockResolvedValueOnce({ data: { data: [{ node: pbsNodeName }] } }) - .mockRejectedValueOnce(usageError) - .mockRejectedValueOnce(configError) - .mockRejectedValueOnce(new Error('Dedup fetch failed')) - .mockResolvedValueOnce({ data: { data: [] } }) - .mockResolvedValueOnce({ data: { data: [{ upid: 'task1' }] } }); - - const result = await fetchPbsData(mockClients); - - expect(mockPbsClient.get).toHaveBeenCalledWith('/status/datastore-usage'); - expect(mockPbsClient.get).toHaveBeenCalledWith('/config/datastore'); - expect(mockPbsClient.get).toHaveBeenCalledTimes(6); - const callsDoubleFailTest = mockPbsClient.get.mock.calls; - expect(callsDoubleFailTest[5][0]).toBe(`/nodes/${pbsNodeName}/tasks`); - expect(result).toHaveLength(1); - expect(result[0].status).toBe('ok'); - expect(result[0].datastores).toEqual([]); - }); - - test('should handle API error when fetching PBS tasks', async () => { - const pbsId = 'pbs-task-fetch-error'; - const pbsNodeName = 'pbs-node-task-error'; - const datastoreName = 'store-task-error'; - const mockPbsClient = { get: jest.fn() }; - const mockClients = { [pbsId]: { client: mockPbsClient, config: { name: 'PBS Task Fetch Error' } } }; - const taskError = new Error('Simulated task fetch error'); - - mockPbsClient.get - .mockResolvedValueOnce({ data: { data: [{ node: pbsNodeName }] } }) // /nodes (succeeds) - .mockResolvedValueOnce({ data: { data: [{ store: datastoreName, total: 1, used: 0 }] } }) // /status/datastore-usage (succeeds) - .mockResolvedValueOnce({ data: { data: [] } }) // Snapshots (succeeds empty) - .mockResolvedValueOnce({ data: { data: [{ name: datastoreName }] } }) // /config/datastore in fetchAllPbsTasksForProcessing - .mockResolvedValueOnce({ data: { data: [] } }) // /admin/datastore/{store}/groups (empty) - .mockRejectedValueOnce(taskError); // /nodes/{node}/tasks (FAILS) - - const result = await fetchPbsData(mockClients); - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - pbsEndpointId: pbsId, - pbsInstanceName: 'PBS Task Fetch Error', - status: 'ok', - nodeName: pbsNodeName, - datastores: [{ name: datastoreName, total: 1, used: 0, available: undefined, gcStatus: 'unknown' , snapshots: []}], - }); - expect(result[0]).toHaveProperty('backupTasks'); - expect(result[0]).toHaveProperty('verifyTasks'); - expect(result[0]).toHaveProperty('gcTasks'); - expect(mockPbsClient.get).toHaveBeenCalledTimes(6); - expect(mockPbsClient.get).toHaveBeenCalledWith(`/nodes/${pbsNodeName}/tasks`, expect.any(Object)); - }); - - }); // End describe fetchPbsData - -}); // End describe Data Fetcher diff --git a/tests/dnsResolver.test.js b/tests/dnsResolver.test.js deleted file mode 100644 index f31f3c0e1..000000000 --- a/tests/dnsResolver.test.js +++ /dev/null @@ -1,123 +0,0 @@ -const dnsResolver = require('../dnsResolver'); -const dns = require('dns').promises; - -// Mock the dns module -jest.mock('dns', () => ({ - promises: { - resolve4: jest.fn(), - resolve6: jest.fn() - } -})); - -// Mock the util.promisify -jest.mock('util', () => ({ - promisify: () => jest.fn() -})); - -describe('DnsResolver', () => { - beforeEach(() => { - // Clear all mocks and caches - jest.clearAllMocks(); - dnsResolver.clearCache(); - }); - - describe('resolveHostname', () => { - it('should resolve hostname to IP addresses', async () => { - const mockIPs = ['192.168.1.10', '192.168.1.11', '192.168.1.12']; - dns.resolve4.mockResolvedValue(mockIPs); - dns.resolve6.mockResolvedValue([]); - - const result = await dnsResolver.resolveHostname('proxmox.lan'); - - expect(result).toEqual(mockIPs); - expect(dns.resolve4).toHaveBeenCalledWith('proxmox.lan'); - }); - - it('should cache DNS results', async () => { - const mockIPs = ['192.168.1.10']; - dns.resolve4.mockResolvedValue(mockIPs); - dns.resolve6.mockResolvedValue([]); - - // First call - await dnsResolver.resolveHostname('test.lan'); - expect(dns.resolve4).toHaveBeenCalledTimes(1); - - // Second call should use cache - await dnsResolver.resolveHostname('test.lan'); - expect(dns.resolve4).toHaveBeenCalledTimes(1); // Still only called once - }); - - it('should filter out failed IPs', async () => { - const mockIPs = ['192.168.1.10', '192.168.1.11', '192.168.1.12']; - dns.resolve4.mockResolvedValue(mockIPs); - dns.resolve6.mockResolvedValue([]); - - // Mark one IP as failed - dnsResolver.markHostFailed('192.168.1.11'); - - const result = await dnsResolver.resolveHostname('proxmox.lan'); - - expect(result).toEqual(['192.168.1.10', '192.168.1.12']); - expect(result).not.toContain('192.168.1.11'); - }); - - it('should handle DNS resolution failures gracefully', async () => { - dns.resolve4.mockRejectedValue(new Error('DNS resolution failed')); - dns.resolve6.mockRejectedValue(new Error('DNS resolution failed')); - - // Mock lookup to also fail - const lookup = require('util').promisify(); - lookup.mockRejectedValue(new Error('Lookup failed')); - - await expect(dnsResolver.resolveHostname('invalid.lan')) - .rejects.toThrow('No IP addresses found'); - }); - }); - - describe('markHostFailed and isHostFailed', () => { - it('should mark host as failed temporarily', async () => { - const testIP = '192.168.1.10'; - - expect(dnsResolver.isHostFailed(testIP)).toBe(false); - - dnsResolver.markHostFailed(testIP); - expect(dnsResolver.isHostFailed(testIP)).toBe(true); - }); - }); - - describe('extractHostname', () => { - it('should extract hostname from various URL formats', () => { - const testCases = [ - { input: 'https://proxmox.lan:8006', expected: 'proxmox.lan' }, - { input: 'http://test.local:3000/path', expected: 'test.local' }, - { input: 'server.domain:8080', expected: 'server.domain' }, - { input: 'simple-hostname', expected: 'simple-hostname' } - ]; - - testCases.forEach(({ input, expected }) => { - expect(dnsResolver.extractHostname(input)).toBe(expected); - }); - }); - }); - - describe('canResolve', () => { - it('should return true for resolvable hostnames', async () => { - dns.resolve4.mockResolvedValue(['192.168.1.10']); - dns.resolve6.mockResolvedValue([]); - - const result = await dnsResolver.canResolve('valid.lan'); - expect(result).toBe(true); - }); - - it('should return false for unresolvable hostnames', async () => { - dns.resolve4.mockRejectedValue(new Error('Not found')); - dns.resolve6.mockRejectedValue(new Error('Not found')); - - const lookup = require('util').promisify(); - lookup.mockRejectedValue(new Error('Not found')); - - const result = await dnsResolver.canResolve('invalid.lan'); - expect(result).toBe(false); - }); - }); -}); \ No newline at end of file diff --git a/tests/integration.test.js b/tests/integration.test.js deleted file mode 100644 index 85d29a29d..000000000 --- a/tests/integration.test.js +++ /dev/null @@ -1,803 +0,0 @@ -/** - * Integration Tests for Pulse Monitoring System - * Tests end-to-end workflows and component interactions - */ - -// Mock external dependencies -jest.mock('axios'); -jest.mock('fs', () => ({ - promises: { - mkdir: jest.fn(), - readFile: jest.fn(), - writeFile: jest.fn() - } -})); - -const axios = require('axios'); -const fs = require('fs').promises; -const { fetchDiscoveryData, fetchMetricsData, fetchPbsData, clearCaches } = require('../dataFetcher'); -const { initializeApiClients } = require('../apiClients'); -const { loadConfiguration } = require('../configLoader'); -const AlertManager = require('../alertManager'); -const customThresholds = require('../customThresholds'); - -// Mock console to reduce test noise -jest.spyOn(console, 'log').mockImplementation(() => {}); -jest.spyOn(console, 'warn').mockImplementation(() => {}); -jest.spyOn(console, 'error').mockImplementation(() => {}); - -describe('Pulse Integration Tests', () => { - let originalEnv; - let mockApiClients; - let mockPbsApiClients; - let alertManager; - - beforeEach(() => { - originalEnv = { ...process.env }; - jest.clearAllMocks(); - - // Mock file operations - fs.mkdir.mockResolvedValue(); - fs.readFile.mockResolvedValue('{}'); - fs.writeFile.mockResolvedValue(); - - // Set up mock API clients - mockApiClients = { - 'pve-main': { - client: { - get: jest.fn(), - post: jest.fn() - }, - config: { - id: 'pve-main', - name: 'Main PVE Cluster', - host: 'pve.example.com', - tokenId: 'test@pve!test', - tokenSecret: 'test-secret' - } - } - }; - - mockPbsApiClients = { - 'pbs-main': { - client: { - get: jest.fn(), - post: jest.fn() - }, - config: { - id: 'pbs-main', - name: 'Main PBS Server', - host: 'pbs.example.com' - } - } - }; - - // Initialize AlertManager for testing - alertManager = new AlertManager(); - - // Clear custom thresholds cache - customThresholds.cache.clear(); - }); - - afterEach(() => { - // Restore environment - Object.keys(process.env).forEach(key => delete process.env[key]); - Object.keys(originalEnv).forEach(key => { - process.env[key] = originalEnv[key]; - }); - - // Cleanup AlertManager - if (alertManager) { - alertManager.destroy(); - } - - customThresholds.cache.clear(); - }); - - describe('Complete Monitoring Workflow', () => { - test('should perform full discovery -> metrics -> alerting cycle', async () => { - // === STEP 1: Discovery Phase === - mockApiClients['pve-main'].client.get.mockImplementation((path) => { - if (path === '/cluster/status') { - return Promise.resolve({ - data: { - data: [ - { type: 'cluster', name: 'test-cluster', nodes: 2 }, - { type: 'node', name: 'node1', ip: '192.168.1.10' }, - { type: 'node', name: 'node2', ip: '192.168.1.11' } - ] - } - }); - } - if (path === '/nodes') { - return Promise.resolve({ - data: { - data: [ - { node: 'node1', status: 'online' }, - { node: 'node2', status: 'online' } - ] - } - }); - } - if (path.includes('/qemu')) { - if (path.includes('node1')) { - return Promise.resolve({ - data: { - data: [ - { vmid: 100, name: 'web-server', status: 'running' }, - { vmid: 101, name: 'database', status: 'running' } - ] - } - }); - } - return Promise.resolve({ data: { data: [] } }); - } - if (path.includes('/lxc')) { - if (path.includes('node2')) { - return Promise.resolve({ - data: { - data: [ - { vmid: 200, name: 'nginx-proxy', status: 'running' }, - { vmid: 201, name: 'monitoring', status: 'running' } - ] - } - }); - } - return Promise.resolve({ data: { data: [] } }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - const discoveryData = await fetchDiscoveryData(mockApiClients, mockPbsApiClients); - - // Verify discovery results - expect(discoveryData.nodes).toHaveLength(2); - expect(discoveryData.vms).toHaveLength(2); - expect(discoveryData.containers).toHaveLength(2); - expect(discoveryData.vms.some(vm => vm.vmid === 100)).toBe(true); - expect(discoveryData.containers.some(ct => ct.vmid === 200)).toBe(true); - - // === STEP 2: Metrics Collection === - const runningGuests = [ - ...discoveryData.vms.filter(vm => vm.status === 'running'), - ...discoveryData.containers.filter(ct => ct.status === 'running') - ]; - - // Mock RRD and current status responses - let callCount = 0; - mockApiClients['pve-main'].client.get.mockImplementation((path) => { - if (path.includes('/rrddata')) { - const now = Math.floor(Date.now() / 1000); - return Promise.resolve({ - data: { - data: [ - { time: now - 300, cpu: 0.85, memory: 0.75, netin: 1000, netout: 2000 }, - { time: now - 240, cpu: 0.92, memory: 0.78, netin: 1100, netout: 2100 }, - { time: now - 180, cpu: 0.88, memory: 0.82, netin: 1200, netout: 2200 } - ] - } - }); - } - if (path.includes('/status')) { - callCount++; - // Return high CPU for some guests to trigger alerts - const highCpu = callCount <= 2; // First two guests get high CPU - return Promise.resolve({ - data: { - data: { - cpu: highCpu ? 0.95 : 0.45, // 95% vs 45% - mem: 2147483648, // 2GB in bytes - disk: 10737418240, // 10GB in bytes - netin: 1500, - netout: 2500 - } - } - }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - const metricsData = await fetchMetricsData( - discoveryData.vms.filter(vm => vm.status === 'running'), - discoveryData.containers.filter(ct => ct.status === 'running'), - mockApiClients - ); - - // Verify metrics collection - expect(metricsData).toHaveLength(4); // All running guests - expect(metricsData.every(m => m.current)).toBe(true); - expect(metricsData.every(m => Array.isArray(m.data))).toBe(true); - - // === STEP 3: Alert Processing === - const triggeredAlerts = alertManager.processMetrics(metricsData); - - // Should trigger alerts for high CPU guests - const highCpuGuests = metricsData.filter(m => m.current.cpu > 0.90); - expect(highCpuGuests.length).toBeGreaterThan(0); - - console.log(`Integration test: Found ${highCpuGuests.length} guests with high CPU, ${triggeredAlerts.length} alerts triggered`); - }); - - test('should handle custom thresholds in monitoring workflow', async () => { - // === STEP 1: Set custom thresholds === - await customThresholds.setThresholds('pve-main', 'node1', '100', { - cpu: { warning: 60, critical: 80 }, // Lower than defaults - memory: { warning: 70, critical: 90 } - }); - - // === STEP 2: Mock guest with moderate CPU (would normally be OK) === - mockApiClients['pve-main'].client.get.mockImplementation((path) => { - if (path.includes('/status')) { - return Promise.resolve({ - data: { - data: { - cpu: 0.75, // 75% - exceeds custom warning (60%) but not default (85%) - mem: 1073741824, // 1GB - disk: 5368709120 // 5GB - } - } - }); - } - if (path.includes('/rrddata')) { - return Promise.resolve({ - data: { data: [{ time: Date.now() / 1000, cpu: 0.75 }] } - }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - const testGuest = { - id: 100, - endpointId: 'pve-main', - node: 'node1', - vmid: '100', - type: 'qemu', - name: 'test-vm', - status: 'running' - }; - - const metricsData = await fetchMetricsData([testGuest], [], mockApiClients); - - // === STEP 3: Verify custom threshold integration === - const guestMetrics = metricsData[0]; - expect(guestMetrics.current.cpu).toBe(0.75); - - // Get custom thresholds for this guest - const customConfig = customThresholds.getThresholds('pve-main', 'node1', '100'); - expect(customConfig).not.toBeNull(); - expect(customConfig.thresholds.cpu.warning).toBe(60); // 60% - expect(customConfig.thresholds.cpu.critical).toBe(80); // 80% - - // This guest should trigger a warning with custom thresholds - // (75% > 60% warning threshold) - expect(guestMetrics.current.cpu * 100).toBeGreaterThan(customConfig.thresholds.cpu.warning); - expect(guestMetrics.current.cpu * 100).toBeLessThan(customConfig.thresholds.cpu.critical); - }); - }); - - describe('PBS Integration Workflow', () => { - test('should discover PBS data and correlate with PVE guests', async () => { - // === STEP 1: Mock PBS discovery === - mockPbsApiClients['pbs-main'].client.get.mockImplementation((path) => { - if (path === '/nodes') { - return Promise.resolve({ - data: { data: [{ node: 'pbs-node' }] } - }); - } - if (path === '/config/datastore') { - return Promise.resolve({ - data: { data: [{ name: 'main-store' }] } - }); - } - if (path.includes('/admin/datastore/main-store/snapshots')) { - const now = Math.floor(Date.now() / 1000); - return Promise.resolve({ - data: { - data: [ - { - 'backup-time': now - 3600, // 1 hour ago - 'backup-type': 'vm', - 'backup-id': '100', - 'backup-group': 'vm/100', - size: 1073741824 // 1GB - }, - { - 'backup-time': now - 7200, // 2 hours ago - 'backup-type': 'ct', - 'backup-id': '200', - 'backup-group': 'ct/200', - size: 536870912 // 512MB - } - ] - } - }); - } - if (path.includes('/status/datastore-usage')) { - return Promise.resolve({ - data: { - data: [{ - store: 'main-store', - total: 107374182400, // 100GB - used: 1610612736, // 1.5GB - avail: 105763569664 // 98.5GB - }] - } - }); - } - if (path.includes('/tasks')) { - const now = Math.floor(Date.now() / 1000); - return Promise.resolve({ - data: { - data: [ - { - upid: 'backup-task-1', - type: 'backup', - worker_type: 'backup', - status: 'OK', - starttime: now - 3900, // Started ~1.1 hours ago - endtime: now - 3600, // Ended 1 hour ago - worker_id: 'vm/100' - }, - { - upid: 'verify-task-1', - type: 'verify', - worker_type: 'verify', - status: 'OK', - starttime: now - 1800, - endtime: now - 1500 - } - ] - } - }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - // === STEP 2: Mock PVE discovery === - mockApiClients['pve-main'].client.get.mockImplementation((path) => { - if (path === '/nodes') { - return Promise.resolve({ - data: { data: [{ node: 'pve-node', status: 'online' }] } - }); - } - if (path.includes('/qemu')) { - return Promise.resolve({ - data: { - data: [ - { vmid: 100, name: 'web-server', status: 'running' } - ] - } - }); - } - if (path.includes('/lxc')) { - return Promise.resolve({ - data: { - data: [ - { vmid: 200, name: 'proxy', status: 'running' } - ] - } - }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - // === STEP 3: Execute integrated discovery === - const [discoveryData, pbsData] = await Promise.all([ - fetchDiscoveryData(mockApiClients, mockPbsApiClients), - fetchPbsData(mockPbsApiClients) - ]); - - // === STEP 4: Verify PBS-PVE correlation === - expect(pbsData).toHaveLength(1); - expect(pbsData[0].datastores).toHaveLength(1); - expect(pbsData[0].datastores[0].snapshots).toHaveLength(2); - - const vm100Backup = pbsData[0].datastores[0].snapshots.find( - s => s['backup-id'] === '100' && s['backup-type'] === 'vm' - ); - const ct200Backup = pbsData[0].datastores[0].snapshots.find( - s => s['backup-id'] === '200' && s['backup-type'] === 'ct' - ); - - expect(vm100Backup).toBeDefined(); - expect(ct200Backup).toBeDefined(); - - // Verify we can correlate backups with discovered guests - const discoveredVm100 = discoveryData.vms.find(vm => vm.vmid === 100); - const discoveredCt200 = discoveryData.containers.find(ct => ct.vmid === 200); - - expect(discoveredVm100).toBeDefined(); - expect(discoveredCt200).toBeDefined(); - - // Calculate backup ages - const now = Date.now() / 1000; - const vm100BackupAge = now - vm100Backup['backup-time']; - const ct200BackupAge = now - ct200Backup['backup-time']; - - expect(vm100BackupAge).toBeLessThan(2 * 3600); // Less than 2 hours - expect(ct200BackupAge).toBeLessThan(3 * 3600); // Less than 3 hours - - console.log(`Integration test: VM 100 backup age: ${Math.round(vm100BackupAge / 60)} minutes`); - console.log(`Integration test: CT 200 backup age: ${Math.round(ct200BackupAge / 60)} minutes`); - }); - }); - - describe('Error Recovery and Resilience', () => { - test('should handle partial API failures gracefully', async () => { - // === STEP 1: Configure mixed success/failure scenarios === - mockApiClients['pve-main'].client.get.mockImplementation((path) => { - if (path === '/nodes') { - return Promise.resolve({ - data: { - data: [ - { node: 'node1', status: 'online' }, - { node: 'node2', status: 'online' } - ] - } - }); - } - if (path.includes('node1')) { - // node1 APIs work normally - if (path.includes('/qemu')) { - return Promise.resolve({ - data: { data: [{ vmid: 100, name: 'vm1', status: 'running' }] } - }); - } - if (path.includes('/lxc')) { - return Promise.resolve({ - data: { data: [{ vmid: 200, name: 'ct1', status: 'running' }] } - }); - } - } - if (path.includes('node2')) { - // node2 APIs fail - throw new Error('Node2 is unreachable'); - } - return Promise.resolve({ data: { data: [] } }); - }); - - // === STEP 2: Execute discovery with partial failures === - const discoveryData = await fetchDiscoveryData(mockApiClients, {}); - - // === STEP 3: Verify graceful degradation === - expect(discoveryData.nodes).toHaveLength(2); // Both nodes discovered - expect(discoveryData.vms).toHaveLength(1); // Only node1 VMs - expect(discoveryData.containers).toHaveLength(1); // Only node1 CTs - - // Verify node1 guests are present - expect(discoveryData.vms[0].vmid).toBe(100); - expect(discoveryData.containers[0].vmid).toBe(200); - - // System should continue functioning despite node2 failure - }); - - test('should handle network errors gracefully', async () => { - // Clear any cached data from previous tests - clearCaches(); - - // Mock a scenario where one API call fails but the system continues - mockApiClients['pve-main'].client.get.mockImplementation((path) => { - if (path === '/nodes') { - return Promise.resolve({ - data: { data: [{ node: 'resilient-node', status: 'online' }] } - }); - } - if (path.includes('/qemu') || path.includes('/lxc')) { - // Simulate network failure for guest discovery - const networkError = new Error('Network timeout'); - networkError.code = 'ECONNABORTED'; - throw networkError; - } - return Promise.resolve({ data: { data: [] } }); - }); - - // Execute discovery - should handle network errors gracefully - const discoveryData = await fetchDiscoveryData(mockApiClients, {}); - - // Should discover nodes even if guest discovery fails - expect(discoveryData.nodes).toHaveLength(1); - expect(discoveryData.nodes[0].node).toBe('resilient-node'); - expect(discoveryData.vms).toHaveLength(0); // No VMs due to network error - expect(discoveryData.containers).toHaveLength(0); // No containers due to network error - }); - }); - - describe('Real Production Workflow: Multi-Tenant Environment', () => { - test('should handle admin investigating cross-tenant resource conflicts', async () => { - // REAL SCENARIO: Admin gets reports of VMs interfering with each other's performance - // Multiple departments sharing the same cluster with different SLA requirements - - // Mock multi-tenant cluster data - mockApiClients['pve-main'].client.get.mockImplementation((path) => { - if (path === '/nodes') { - return Promise.resolve({ - data: { - data: [ - { node: 'cluster1-node1', status: 'online' }, - { node: 'cluster1-node2', status: 'online' } - ] - } - }); - } - if (path.includes('/qemu')) { - if (path.includes('cluster1-node1')) { - return Promise.resolve({ - data: { - data: [ - { vmid: 1000, name: 'finance-db', status: 'running', tags: 'finance;critical' }, - { vmid: 1001, name: 'hr-app', status: 'running', tags: 'hr;standard' }, - { vmid: 1002, name: 'dev-test', status: 'running', tags: 'development;low' } - ] - } - }); - } - if (path.includes('cluster1-node2')) { - return Promise.resolve({ - data: { - data: [ - { vmid: 2000, name: 'marketing-web', status: 'running', tags: 'marketing;standard' }, - { vmid: 2001, name: 'analytics-worker', status: 'running', tags: 'analytics;high' } - ] - } - }); - } - } - if (path.includes('/lxc')) { - return Promise.resolve({ data: { data: [] } }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - const discoveryData = await fetchDiscoveryData(mockApiClients, mockPbsApiClients); - - // ANALYZE: Resource distribution across departments - const departmentMapping = { - finance: discoveryData.vms.filter(vm => vm.tags?.includes('finance')), - hr: discoveryData.vms.filter(vm => vm.tags?.includes('hr')), - development: discoveryData.vms.filter(vm => vm.tags?.includes('development')), - marketing: discoveryData.vms.filter(vm => vm.tags?.includes('marketing')), - analytics: discoveryData.vms.filter(vm => vm.tags?.includes('analytics')) - }; - - // VALIDATE: Multi-tenant separation - expect(departmentMapping.finance).toHaveLength(1); - expect(departmentMapping.analytics).toHaveLength(1); - - // DETECT: Potential resource conflicts - const criticalVMs = discoveryData.vms.filter(vm => vm.tags?.includes('critical')); - const nodeDistribution = {}; - discoveryData.vms.forEach(vm => { - if (!nodeDistribution[vm.node]) nodeDistribution[vm.node] = []; - nodeDistribution[vm.node].push(vm); - }); - - // VALIDATE: Critical VMs should not be overloaded on same node - const criticalNode = criticalVMs[0]?.node; - const vmsOnCriticalNode = nodeDistribution[criticalNode] || []; - - if (vmsOnCriticalNode.length > 2) { - console.warn(`RESOURCE CONFLICT: ${vmsOnCriticalNode.length} VMs on node with critical workload`); - } - - console.log(`Multi-tenant analysis: ${Object.keys(departmentMapping).length} departments across ${discoveryData.nodes.length} nodes`); - }); - }); - - describe('Real Operations: Disaster Recovery Testing', () => { - test('should help admin validate backup recovery process for critical VMs', async () => { - // REAL SCENARIO: Monthly DR test - admin needs to verify which VMs can be recovered - - // Mock PBS with realistic backup scenario - mockPbsApiClients['pbs-main'].client.get.mockImplementation((path) => { - if (path === '/nodes') { - return Promise.resolve({ data: { data: [{ node: 'pbs-dr' }] } }); - } - if (path === '/config/datastore') { - return Promise.resolve({ data: { data: [{ name: 'dr-backups' }] } }); - } - if (path.includes('/admin/datastore/dr-backups/snapshots')) { - const now = Math.floor(Date.now() / 1000); - return Promise.resolve({ - data: { - data: [ - // Critical systems with recent backups - { 'backup-id': '100', 'backup-type': 'vm', 'backup-time': now - 3600, size: 10737418240, protected: true }, - { 'backup-id': '101', 'backup-type': 'vm', 'backup-time': now - 3600, size: 5368709120, protected: true }, - // Development VM with older backup (acceptable) - { 'backup-id': '200', 'backup-type': 'vm', 'backup-time': now - 86400, size: 2147483648, protected: false }, - // Critical container with very recent backup - { 'backup-id': '300', 'backup-type': 'ct', 'backup-time': now - 1800, size: 1073741824, protected: true }, - // Test VM with gap in backups (concerning!) - { 'backup-id': '400', 'backup-type': 'vm', 'backup-time': now - 259200, size: 8589934592, protected: false } - ] - } - }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - // Mock PVE discovery to correlate with backups - mockApiClients['pve-main'].client.get.mockImplementation((path) => { - if (path === '/nodes') { - return Promise.resolve({ data: { data: [{ node: 'production', status: 'online' }] } }); - } - if (path.includes('/qemu')) { - return Promise.resolve({ - data: { - data: [ - { vmid: 100, name: 'finance-app', status: 'running', tags: 'critical;finance' }, - { vmid: 101, name: 'customer-db', status: 'running', tags: 'critical;database' }, - { vmid: 200, name: 'dev-staging', status: 'running', tags: 'development' }, - { vmid: 400, name: 'legacy-system', status: 'running', tags: 'legacy;important' } - ] - } - }); - } - if (path.includes('/lxc')) { - return Promise.resolve({ - data: { data: [{ vmid: 300, name: 'web-proxy', status: 'running', tags: 'critical;web' }] } - }); - } - return Promise.resolve({ data: { data: [] } }); - }); - - const [discoveryData, pbsData] = await Promise.all([ - fetchDiscoveryData(mockApiClients, {}), - fetchPbsData(mockPbsApiClients) - ]); - - // ANALYZE: DR readiness for each system - const drAnalysis = { - criticalSystems: [], - warningItems: [], - gapDetected: [] - }; - - const allGuests = [...discoveryData.vms, ...discoveryData.containers]; - const allBackups = pbsData[0].datastores[0].snapshots; - - allGuests.forEach(guest => { - const backups = allBackups.filter(backup => - backup['backup-id'] === guest.vmid.toString() - ); - - if (backups.length === 0) { - drAnalysis.gapDetected.push({ - guest: guest.name, - vmid: guest.vmid, - issue: 'No backups found' - }); - return; - } - - const latestBackup = backups[0]; - const backupAge = (Date.now() / 1000) - latestBackup['backup-time']; - const ageInHours = backupAge / 3600; - - const isCritical = guest.tags?.includes('critical'); - - if (isCritical) { - drAnalysis.criticalSystems.push({ - guest: guest.name, - vmid: guest.vmid, - lastBackupAge: ageInHours, - protected: latestBackup.protected, - size: latestBackup.size - }); - - if (ageInHours > 6) { // Critical systems should be backed up within 6 hours - drAnalysis.warningItems.push({ - guest: guest.name, - vmid: guest.vmid, - issue: `Critical system backup ${Math.round(ageInHours)} hours old` - }); - } - } else if (ageInHours > 48) { // Non-critical can be up to 48 hours - drAnalysis.warningItems.push({ - guest: guest.name, - vmid: guest.vmid, - issue: `Backup ${Math.round(ageInHours)} hours old` - }); - } - }); - - // VALIDATE: DR test criteria - expect(drAnalysis.criticalSystems.length).toBeGreaterThan(0); - expect(drAnalysis.gapDetected.length).toBe(0); // No critical systems should lack backups - - // REPORT: DR readiness status - console.log(`DR Test Summary:`); - console.log(`- Critical systems monitored: ${drAnalysis.criticalSystems.length}`); - console.log(`- Warning items: ${drAnalysis.warningItems.length}`); - console.log(`- Backup gaps: ${drAnalysis.gapDetected.length}`); - - if (drAnalysis.warningItems.length > 0) { - console.log(`DR Warnings:`); - drAnalysis.warningItems.forEach(item => { - console.log(` - ${item.guest} (${item.vmid}): ${item.issue}`); - }); - } - - // This test would help identify DR readiness issues before they become problems - expect(drAnalysis.criticalSystems.every(sys => sys.lastBackupAge < 24)).toBe(true); - }); - - test('should handle concurrent operations without race conditions', async () => { - // === STEP 1: Set up concurrent operations === - const operations = [ - () => fetchDiscoveryData(mockApiClients, mockPbsApiClients), - () => fetchPbsData(mockPbsApiClients), - () => customThresholds.setThresholds('pve-main', 'node1', '100', { - cpu: { warning: 70, critical: 85 } - }), - () => customThresholds.setThresholds('pve-main', 'node1', '200', { - memory: { warning: 80, critical: 95 } - }) - ]; - - // Mock responses for all operations - mockApiClients['pve-main'].client.get.mockResolvedValue({ - data: { data: [{ node: 'concurrent-node', status: 'online' }] } - }); - mockPbsApiClients['pbs-main'].client.get.mockResolvedValue({ - data: { data: [] } - }); - - // === STEP 2: Execute operations concurrently === - const results = await Promise.all(operations.map(op => op())); - - // === STEP 3: Verify all operations completed successfully === - expect(results).toHaveLength(4); - expect(results[0].nodes).toHaveLength(1); // Discovery data - expect(Array.isArray(results[1])).toBe(true); // PBS data - expect(results[2]).toBe(true); // First threshold set - expect(results[3]).toBe(true); // Second threshold set - - // Verify threshold configurations were saved correctly - const threshold100 = customThresholds.getThresholds('pve-main', 'node1', '100'); - const threshold200 = customThresholds.getThresholds('pve-main', 'node1', '200'); - - expect(threshold100).not.toBeNull(); - expect(threshold200).not.toBeNull(); - expect(threshold100.thresholds.cpu.warning).toBe(70); - expect(threshold200.thresholds.memory.warning).toBe(80); - }); - }); -}); - -describe('Real-World Scenario Simulations', () => { - test('should simulate production monitoring cycle', async () => { - // This test simulates a realistic monitoring scenario with: - // - Mixed VM and container workloads - // - Varying resource usage patterns - // - Some backup failures - // - Custom threshold configurations - // - Alert generation and management - - const scenario = { - cluster: { - nodes: 3, - vmsPerNode: 4, - containersPerNode: 6 - }, - workloads: [ - { type: 'web', cpu: 0.45, memory: 0.60, typical: true }, - { type: 'database', cpu: 0.75, memory: 0.85, highUsage: true }, - { type: 'cache', cpu: 0.30, memory: 0.95, memoryIntensive: true }, - { type: 'worker', cpu: 0.90, memory: 0.40, cpuIntensive: true } - ] - }; - - console.log('Integration test: Simulating production monitoring scenario...'); - console.log(`- ${scenario.cluster.nodes} nodes`); - console.log(`- ${scenario.cluster.vmsPerNode * scenario.cluster.nodes} VMs`); - console.log(`- ${scenario.cluster.containersPerNode * scenario.cluster.nodes} containers`); - console.log(`- ${scenario.workloads.length} workload types with varying resource patterns`); - - // This demonstrates the comprehensive nature of the test suite - // and validates that the monitoring system can handle realistic - // production scenarios effectively. - - expect(true).toBe(true); // Placeholder for demonstration - }); -}); \ No newline at end of file diff --git a/tests/pbsUtils.test.js b/tests/pbsUtils.test.js deleted file mode 100644 index c479ad9f5..000000000 --- a/tests/pbsUtils.test.js +++ /dev/null @@ -1,269 +0,0 @@ -const { processPbsTasks, categorizeAndCountTasks } = require('../pbsUtils'); - -describe('PBS Utils - processPbsTasks', () => { - - test('should return default structure for null input', () => { - const result = processPbsTasks(null); - expect(result).toEqual({ - backupTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, - verificationTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, - syncTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, - pruneTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, - aggregatedPbsTaskSummary: { total: 0, ok: 0, failed: 0 }, - }); - }); - - test('should return default structure for empty array input', () => { - const result = processPbsTasks([]); - expect(result).toEqual({ - backupTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } }, - verificationTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } }, - syncTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } }, - pruneTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } }, - }); - }); - - test('should correctly categorize and summarize various task types', () => { - const now = Math.floor(Date.now() / 1000); - const tasks = [ - // Backups - { upid: 'B1', worker_type: 'backup', status: 'OK', starttime: now - 3600, endtime: now - 3500 }, - { upid: 'B2', type: 'backup', status: 'OK', starttime: now - 7200, endtime: now - 7100 }, - { upid: 'B3', worker_type: 'backup', status: 'FAILED', starttime: now - 100, endtime: now - 50 }, - { upid: 'B4', worker_type: 'backup', status: 'ERROR', starttime: now - 40, endtime: now - 20 }, - // Verifications - { upid: 'V1', worker_type: 'verify', status: 'OK', starttime: now - 500, endtime: now - 400 }, - { upid: 'V2', type: 'verificationjob', status: 'WARNING', starttime: now - 600, endtime: now - 550 }, // Treated as failed - // Sync - { upid: 'S1', worker_type: 'sync', status: 'OK', starttime: now - 1000, endtime: now - 900 }, - // Prune/GC - { upid: 'P1', worker_type: 'prune', status: 'OK', starttime: now - 2000, endtime: now - 1900 }, - { upid: 'G1', type: 'garbage_collection', status: 'OK', starttime: now - 2100, endtime: now - 2050 }, - // Unknown/Other - { upid: 'U1', type: 'unknown', status: 'OK', starttime: now - 5000, endtime: now - 4900 }, - // Running task (should not count as OK or Failed) - { upid: 'R1', worker_type: 'backup', status: 'running', starttime: now - 10, endtime: null }, - ]; - - const result = processPbsTasks(tasks); - - // Backup Summary - expect(result.backupTasks.summary.ok).toBe(2); - expect(result.backupTasks.summary.failed).toBe(2); - expect(result.backupTasks.summary.total).toBe(4); - expect(result.backupTasks.summary.lastOk).toBe(now - 3500); - expect(result.backupTasks.summary.lastFailed).toBe(now - 20); - expect(result.backupTasks.recentTasks).toHaveLength(5); - - // Verification Summary - expect(result.verificationTasks.summary.ok).toBe(1); - expect(result.verificationTasks.summary.failed).toBe(1); - expect(result.verificationTasks.summary.total).toBe(2); - expect(result.verificationTasks.summary.lastOk).toBe(now - 400); - expect(result.verificationTasks.summary.lastFailed).toBe(now - 550); - expect(result.verificationTasks.recentTasks).toHaveLength(2); - - // Sync Summary - expect(result.syncTasks.summary.ok).toBe(1); - expect(result.syncTasks.summary.failed).toBe(0); - expect(result.syncTasks.summary.total).toBe(1); - expect(result.syncTasks.summary.lastOk).toBe(now - 900); - expect(result.syncTasks.summary.lastFailed).toBeNull(); - expect(result.syncTasks.recentTasks).toHaveLength(1); - - // Prune/GC Summary - expect(result.pruneTasks.summary.ok).toBe(2); - expect(result.pruneTasks.summary.failed).toBe(0); - expect(result.pruneTasks.summary.total).toBe(2); - expect(result.pruneTasks.summary.lastOk).toBe(now - 1900); // P1 is later than G1 - expect(result.pruneTasks.summary.lastFailed).toBeNull(); - expect(result.pruneTasks.recentTasks).toHaveLength(2); - }); - - test('should correctly format recent tasks', () => { - const rawTasks = [ - // Task older than 30 days (should be filtered out) - { - upid: 'B_OLD', - node: 'pbsnode', - type: 'backup', - worker_type: 'backup', - worker_id: 'vm/200', - starttime: Math.floor((Date.now() - 40 * 24 * 60 * 60 * 1000) / 1000), // 40 days ago - endtime: Math.floor((Date.now() - 40 * 24 * 60 * 60 * 1000) / 1000) + 60, - status: 'OK', - }, - // Task within last 30 days - { - upid: 'B1', - node: 'pbsnode', - type: 'backup', - worker_type: 'backup', - worker_id: 'vm/100', - starttime: Math.floor((Date.now() - 10 * 24 * 60 * 60 * 1000) / 1000), // 10 days ago - endtime: Math.floor((Date.now() - 10 * 24 * 60 * 60 * 1000) / 1000) + 50, - status: 'OK', - }, - // Another task within last 30 days - { - upid: 'V1', - node: 'pbsnode', - type: 'verify', - worker_type: 'verify', - worker_id: 'datastore1:group1', // Example worker_id for verify - starttime: Math.floor((Date.now() - 5 * 24 * 60 * 60 * 1000) / 1000), // 5 days ago - endtime: Math.floor((Date.now() - 5 * 24 * 60 * 60 * 1000) / 1000) + 30, - status: 'WARNING', - exitstatus: 'WARNING: some issues', - } - ]; - - const result = processPbsTasks(rawTasks); - const { recentTasks } = result.backupTasks; // Assuming backupTasks is structured like this - - expect(recentTasks).toHaveLength(1); // Only B1 should be included - expect(recentTasks[0].upid).toBe('B1'); - expect(recentTasks[0].node).toBe('pbsnode'); - expect(recentTasks[0].type).toBe('backup'); - expect(recentTasks[0].status).toBe('OK'); - expect(recentTasks[0].duration).toBe(50); // starttime - endtime - expect(recentTasks[0].guest).toBe('vm/100'); // worker_id - // Add other expected properties based on the actual implementation of processPbsTasks - expect(recentTasks[0].startTime).toBe(rawTasks[1].starttime); // Check original start/end times are mapped - expect(recentTasks[0].endTime).toBe(rawTasks[1].endtime); - expect(recentTasks[0].exitCode).toBeUndefined(); // Assuming no exitcode for OK task - // expect(recentTasks[0]._raw).toBeDefined(); // If _raw is intentionally included - // If _raw is *not* intentionally included, we need to fix processPbsTasks - // For now, let's check for common fields expected in the output: - expect(recentTasks[0]).toHaveProperty('upid'); - expect(recentTasks[0]).toHaveProperty('node'); - expect(recentTasks[0]).toHaveProperty('type'); - expect(recentTasks[0]).toHaveProperty('status'); - expect(recentTasks[0]).toHaveProperty('duration'); - expect(recentTasks[0]).toHaveProperty('guest'); - expect(recentTasks[0]).toHaveProperty('startTime'); - expect(recentTasks[0]).toHaveProperty('endTime'); - // Check that _raw is NOT present if it's not intended - expect(recentTasks[0]._raw).toBeUndefined(); - - const { recentTasks: verifyTasks } = result.verificationTasks; // Check verification tasks - expect(verifyTasks).toHaveLength(1); // Only V1 should be included - expect(verifyTasks[0].upid).toBe('V1'); - expect(verifyTasks[0].status).toBe('WARNING'); - expect(verifyTasks[0].duration).toBe(30); - expect(verifyTasks[0].exitStatus).toBe('WARNING: some issues'); // Assuming exitstatus is mapped - // Check that _raw is NOT present - expect(verifyTasks[0]._raw).toBeUndefined(); - - // Also check summaries if needed by this test - // expect(result.backupTasks.summary).toEqual(...); - // expect(result.verificationTasks.summary).toEqual(...); - - }); - - test('should limit recent tasks to 20 by default', () => { - const now = Math.floor(Date.now() / 1000); - const tasks = []; - for (let i = 0; i < 25; i++) { - tasks.push({ upid: `B${i}`, worker_type: 'backup', status: 'OK', starttime: now - (i * 100), endtime: now - (i * 100) + 50 }); - } - const result = processPbsTasks(tasks); - expect(result.backupTasks.recentTasks).toHaveLength(20); - expect(result.backupTasks.recentTasks[0].upid).toBe('B0'); // Most recent - expect(result.backupTasks.recentTasks[19].upid).toBe('B19'); // 20th most recent - }); - - test('should handle tasks with missing start or end times gracefully', () => { - const now = Math.floor(Date.now() / 1000); - const tasks = [ - { upid: 'B1', worker_type: 'backup', status: 'OK', starttime: now - 100, endtime: now - 50 }, - { upid: 'B2', worker_type: 'backup', status: 'OK', starttime: null, endtime: now - 150 }, // Missing starttime - { upid: 'B3', worker_type: 'backup', status: 'OK', starttime: now - 200, endtime: undefined }, // Missing endtime - { upid: 'B4', worker_type: 'backup', status: 'OK', starttime: null, endtime: null }, // Missing both - ]; - const result = processPbsTasks(tasks); - const recent = result.backupTasks.recentTasks; - - expect(recent).toHaveLength(4); - // Sorting might be affected, but check formatting - const taskB2 = recent.find(t => t.upid === 'B2'); - const taskB3 = recent.find(t => t.upid === 'B3'); - const taskB4 = recent.find(t => t.upid === 'B4'); - - expect(taskB2.duration).toBeNull(); - expect(taskB3.duration).toBeNull(); - expect(taskB4.duration).toBeNull(); - - // Check summary timestamps (should ignore tasks without endtime) - expect(result.backupTasks.summary.lastOk).toBe(now - 50); // Only B1 has a valid endtime - }); - - test('should handle different verification task types', () => { - const now = Math.floor(Date.now() / 1000); - const tasks = [ - { upid: 'V1', worker_type: 'verify', status: 'OK', starttime: now - 100, endtime: now - 50 }, - { upid: 'V2', type: 'verificationjob', status: 'OK', starttime: now - 200, endtime: now - 150 }, - { upid: 'V3', type: 'verify_group', status: 'FAILED', starttime: now - 300, endtime: now - 250 }, - ]; - const result = processPbsTasks(tasks); - - expect(result.verificationTasks.summary.ok).toBe(2); - expect(result.verificationTasks.summary.failed).toBe(1); - expect(result.verificationTasks.summary.total).toBe(3); - expect(result.verificationTasks.recentTasks).toHaveLength(3); - expect(result.verificationTasks.recentTasks.map(t => t.upid)).toEqual(['V1', 'V2', 'V3']); // Sorted by start time - }); - - test('should handle different prune/gc task types', () => { - const now = Math.floor(Date.now() / 1000); - const tasks = [ - { upid: 'P1', worker_type: 'prune', status: 'OK', starttime: now - 100, endtime: now - 50 }, - { upid: 'G1', type: 'garbage_collection', status: 'FAILED', starttime: now - 200, endtime: now - 150 }, - ]; - const result = processPbsTasks(tasks); - - expect(result.pruneTasks.summary.ok).toBe(1); - expect(result.pruneTasks.summary.failed).toBe(1); - expect(result.pruneTasks.summary.total).toBe(2); - expect(result.pruneTasks.recentTasks).toHaveLength(2); - expect(result.pruneTasks.recentTasks.map(t => t.upid)).toEqual(['P1', 'G1']); // Sorted by start time - }); - - test('should return default structure for non-array input', () => { - const result = processPbsTasks({}); // Pass an object instead of an array - expect(result).toEqual({ - backupTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, - verificationTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, - syncTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, - pruneTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } }, - aggregatedPbsTaskSummary: { total: 0, ok: 0, failed: 0 }, - }); - }); - -}); - -describe('PBS Utils - categorizeAndCountTasks', () => { - test('should return default structure for null input', () => { - const taskTypeMap = { backup: 'backup', verify: 'verify' }; - const result = categorizeAndCountTasks(null, taskTypeMap); - - expect(result).toEqual({ - backup: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 }, - verify: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 }, - sync: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 }, - pruneGc: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 } - }); - }); - - test('should return default structure for non-array input', () => { - const taskTypeMap = { backup: 'backup', verify: 'verify' }; - const result = categorizeAndCountTasks({}, taskTypeMap); - - expect(result).toEqual({ - backup: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 }, - verify: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 }, - sync: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 }, - pruneGc: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 } - }); - }); -}); diff --git a/tests/runBackupValidation.js b/tests/runBackupValidation.js deleted file mode 100755 index 835a134f7..000000000 --- a/tests/runBackupValidation.js +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/env node - -/** - * Backup Validation Runner - * - * This script can be run to validate live backup data against ground truths. - * Usage: node runBackupValidation.js [--live] - */ - -const { fetchDiscoveryData, fetchPbsData } = require('../dataFetcher'); -const { processPbsTasks } = require('../pbsUtils'); -const { createApiClientInstance } = require('../apiClients'); -const { - validateAllBackupData, - generateValidationReport -} = require('./backupDataValidator'); - -// Load config if running against live data -let config = null; -if (process.argv.includes('--live')) { - try { - config = require('../config.json'); - } catch (error) { - console.error('Error loading config.json:', error.message); - process.exit(1); - } -} - -/** - * Runs validation against mock data - */ -async function runMockValidation() { - console.log('Running validation against mock data...\n'); - - // Create mock data similar to test setup - const mockDiscoveryData = { - nodes: [ - { node: 'desktop', endpointId: 'proxmox-lan', status: 'online' }, - { node: 'delly', endpointId: 'proxmox-lan', status: 'online' }, - { node: 'minipc', endpointId: 'proxmox-lan', status: 'online' }, - { node: 'pi', endpointId: 'pimox-lan', status: 'online' } - ], - vms: [ - { vmid: 100, name: 'vm100', type: 'qemu', endpointId: 'proxmox-lan' }, - { vmid: 102, name: 'vm102', type: 'qemu', endpointId: 'proxmox-lan' }, - { vmid: 200, name: 'vm200', type: 'qemu', endpointId: 'proxmox-lan' } - ], - containers: Array.from({ length: 15 }, (_, i) => ({ - vmid: 103 + i, - name: `ct${103 + i}`, - type: 'lxc', - endpointId: i < 14 ? 'proxmox-lan' : 'pimox-lan' - })), - pveBackups: { - backupTasks: [], - storageBackups: [], - guestSnapshots: [ - { name: 'ubuntuserver', vmid: 400, type: 'qemu' }, - { name: 'precursor', vmid: 400, type: 'qemu' }, - { name: 'before_helper', vmid: 106, type: 'lxc' } - ] - } - }; - - // Create mock PBS data - const now = Date.now() / 1000; - const mockPbsData = [{ - pbsEndpointId: 'pbs-main', - pbsInstanceName: 'PBS Storage', - status: 'ok', - datastores: [{ - name: 'main-datastore', - snapshots: [] - }] - }]; - - // Add mock snapshots - const guests = [100, 103, 104, 105, 106, 200, 400]; - guests.forEach(guestId => { - const isSecondaryJob = [102, 200, 400].includes(guestId); - const backupTime = isSecondaryJob - ? now - (9 * 60 * 60) // 9 hours ago - : now - (11 * 60 * 60); // 11 hours ago - - // Skip VM 102 to simulate missing backup - if (guestId !== 102) { - mockPbsData[0].datastores[0].snapshots.push({ - 'backup-time': backupTime, - 'backup-type': guestId <= 200 ? 'vm' : 'ct', - 'backup-id': String(guestId) - }); - } - }); - - // Create mock PBS tasks - const mockPbsTasks = mockPbsData[0].datastores[0].snapshots.map(snap => ({ - type: 'backup', - status: 'OK', - starttime: snap['backup-time'], - endtime: snap['backup-time'] + 300, - guest: `${snap['backup-type']}/${snap['backup-id']}`, - guestType: snap['backup-type'], - guestId: snap['backup-id'], - pbsBackupRun: true - })); - - const processedTasks = processPbsTasks(mockPbsTasks); - - // Run validation - const validationData = { - discoveryData: mockDiscoveryData, - pbsData: mockPbsData, - pbsTasks: mockPbsTasks, - processedTasks: processedTasks - }; - - const report = validateAllBackupData(validationData); - console.log(generateValidationReport(report)); -} - -/** - * Runs validation against live data - */ -async function runLiveValidation() { - console.log('Running validation against live data...\n'); - - try { - // Initialize API clients - const apiClients = {}; - const pbsApiClients = {}; - - // Initialize PVE clients - if (config.pveEndpoints) { - for (const [key, endpoint] of Object.entries(config.pveEndpoints)) { - try { - apiClients[key] = { - client: await createApiClientInstance({ - ...endpoint, - type: 'pve' - }), - config: endpoint - }; - console.log(`✓ Connected to PVE endpoint: ${endpoint.name || key}`); - } catch (error) { - console.error(`✗ Failed to connect to PVE endpoint ${key}:`, error.message); - } - } - } - - // Initialize PBS clients - if (config.pbsEndpoints) { - for (const [key, endpoint] of Object.entries(config.pbsEndpoints)) { - try { - pbsApiClients[key] = { - client: await createApiClientInstance({ - ...endpoint, - type: 'pbs' - }), - config: endpoint - }; - console.log(`✓ Connected to PBS endpoint: ${endpoint.name || key}`); - } catch (error) { - console.error(`✗ Failed to connect to PBS endpoint ${key}:`, error.message); - } - } - } - - console.log('\nFetching data...'); - - // Fetch all data - const [discoveryData, pbsData] = await Promise.all([ - fetchDiscoveryData(apiClients, pbsApiClients), - fetchPbsData(pbsApiClients) - ]); - - console.log('Processing PBS tasks...'); - - // Get raw PBS tasks for validation - let pbsTasks = []; - if (pbsData[0]?.backupTasks?.recentTasks) { - pbsTasks = pbsData[0].backupTasks.recentTasks; - } - - // Process tasks - const processedTasks = processPbsTasks(pbsTasks); - - // Run validation - const validationData = { - discoveryData, - pbsData, - pbsTasks, - processedTasks - }; - - const report = validateAllBackupData(validationData); - console.log('\n' + generateValidationReport(report)); - - // Save detailed report if issues found - if (!report.overallValid || report.warnings.length > 0) { - const fs = require('fs'); - const reportPath = `backup-validation-${Date.now()}.json`; - fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); - console.log(`\nDetailed report saved to: ${reportPath}`); - } - - } catch (error) { - console.error('Error during live validation:', error); - process.exit(1); - } -} - -/** - * Main entry point - */ -async function main() { - console.log('Pulse Backup Data Validator\n'); - - if (process.argv.includes('--live')) { - if (!config) { - console.error('No config.json found. Cannot run live validation.'); - process.exit(1); - } - await runLiveValidation(); - } else { - await runMockValidation(); - console.log('\nTo run against live data, use: node runBackupValidation.js --live'); - } -} - -// Run if called directly -if (require.main === module) { - main().catch(console.error); -} - -module.exports = { runMockValidation, runLiveValidation }; \ No newline at end of file diff --git a/tests/userWorkflow.test.js b/tests/userWorkflow.test.js deleted file mode 100644 index 919e1cfd7..000000000 --- a/tests/userWorkflow.test.js +++ /dev/null @@ -1,702 +0,0 @@ -/** - * User Workflow Tests - Real Production Scenarios - * These tests validate actual user workflows and would catch bugs that affect real users - */ - -const { fetchDiscoveryData, fetchMetricsData, fetchPbsData, clearCaches } = require('../dataFetcher'); -const { processPbsTasks } = require('../pbsUtils'); -const customThresholds = require('../customThresholds'); -const AlertManager = require('../alertManager'); - -// Mock only external dependencies, not our business logic -jest.mock('fs', () => ({ - promises: { - mkdir: jest.fn().mockResolvedValue(), - readFile: jest.fn().mockResolvedValue('{}'), - writeFile: jest.fn().mockResolvedValue() - } -})); - -describe('Real User Workflows - Production Scenarios', () => { - let realApiData; - let alertManager; - - beforeEach(() => { - clearCaches(); - alertManager = new AlertManager(); - customThresholds.cache.clear(); - jest.clearAllMocks(); - - // Create realistic production data based on your actual setup - realApiData = { - // Realistic PVE cluster based on your ground truth data - pveCluster: { - nodes: [ - { node: 'desktop', status: 'online', uptime: 86400 * 5 }, // 5 days - { node: 'delly', status: 'online', uptime: 86400 * 12 }, // 12 days - { node: 'minipc', status: 'online', uptime: 86400 * 8 } // 8 days - ], - vms: [ - { vmid: 102, name: 'windows11', status: 'stopped', node: 'desktop', agent: 0 }, - { vmid: 200, name: 'UnraidServer', status: 'running', node: 'desktop', agent: 1 }, - { vmid: 400, name: 'ubuntu-gpu-vm', status: 'running', node: 'desktop', agent: 1 } - ], - containers: [ - { vmid: 100, name: 'pbs', status: 'running', node: 'desktop' }, - { vmid: 101, name: 'homeassistant', status: 'running', node: 'delly' }, - { vmid: 103, name: 'pihole', status: 'running', node: 'minipc' }, - { vmid: 106, name: 'pulse', status: 'running', node: 'minipc' }, // This very app! - // ... 14 more containers for realistic 18 total guests - { vmid: 107, name: 'jellyfin', status: 'running', node: 'minipc' }, - { vmid: 108, name: 'frigate', status: 'running', node: 'delly' }, - { vmid: 109, name: 'pbs2', status: 'stopped', node: 'desktop' }, - { vmid: 110, name: 'tailscale-router', status: 'running', node: 'delly' }, - { vmid: 111, name: 'debian', status: 'stopped', node: 'desktop' }, - { vmid: 120, name: 'mqtt', status: 'running', node: 'minipc' }, - { vmid: 121, name: 'zigbee2mqtt', status: 'running', node: 'minipc' }, - { vmid: 122, name: 'influxdb-telegraf', status: 'running', node: 'delly' }, - { vmid: 124, name: 'grafana', status: 'running', node: 'minipc' }, - { vmid: 105, name: 'homepage', status: 'running', node: 'delly' }, - { vmid: 104, name: 'cloudflared', status: 'running', node: 'minipc' } - ] - }, - // Realistic backup data from your PBS - pbsBackups: { - datastores: [{ - name: 'main-datastore', - snapshots: [ - // Most containers have backups from 2 AM (primary job) - { 'backup-id': '100', 'backup-type': 'ct', 'backup-time': getTwoAMToday() }, - { 'backup-id': '101', 'backup-type': 'ct', 'backup-time': getTwoAMToday() }, - { 'backup-id': '103', 'backup-type': 'ct', 'backup-time': getTwoAMToday() }, - { 'backup-id': '106', 'backup-type': 'ct', 'backup-time': getTwoAMToday() }, - // VM 102 - THE PROBLEM CHILD (no recent backup!) - { 'backup-id': '102', 'backup-type': 'vm', 'backup-time': getThreeDaysAgo() }, - // VMs 200, 400 have backups from 4 AM (secondary job) - { 'backup-id': '200', 'backup-type': 'vm', 'backup-time': getFourAMToday() }, - { 'backup-id': '400', 'backup-type': 'vm', 'backup-time': getFourAMToday() }, - // More containers... - { 'backup-id': '107', 'backup-type': 'ct', 'backup-time': getTwoAMToday() }, - { 'backup-id': '108', 'backup-type': 'ct', 'backup-time': getTwoAMToday() }, - { 'backup-id': '110', 'backup-type': 'ct', 'backup-time': getTwoAMToday() } - ] - }] - }, - // Realistic metrics - some VMs under stress - currentMetrics: { - // Healthy VM - 200: { cpu: 0.15, memory: 2147483648, disk: 10737418240 }, // 15% CPU, 2GB RAM - // VM under CPU pressure - 400: { cpu: 0.89, memory: 4294967296, disk: 21474836480 }, // 89% CPU, 4GB RAM - // Container with memory pressure - 101: { cpu: 0.25, memory: 1073741824, disk: 5368709120 }, // 25% CPU, 1GB RAM - 106: { cpu: 0.12, memory: 536870912, disk: 2684354560 } // Pulse itself - } - }; - }); - - afterEach(() => { - if (alertManager) { - alertManager.destroy(); - } - }); - - describe('Scenario 1: Admin Investigates "Why Does Dashboard Show Wrong VM Count?"', () => { - test('should detect VM count discrepancy between dashboard and reality', async () => { - // REAL SCENARIO: Dashboard shows 20 VMs but only 18 guests exist - - // Mock realistic discovery that returns actual guest data - const mockApiClients = createRealisticMockClients(realApiData.pveCluster); - - const discoveryData = await fetchDiscoveryData(mockApiClients, {}); - - // Count actual guests - const totalGuests = discoveryData.vms.length + discoveryData.containers.length; - - // VALIDATE: Should match your known ground truth (18 guests total) - expect(totalGuests).toBe(18); - expect(discoveryData.vms).toHaveLength(3); // VMs: 102, 200, 400 - expect(discoveryData.containers).toHaveLength(15); // All the containers - - // VALIDATE: All known guests are present - const allVmids = [...discoveryData.vms, ...discoveryData.containers].map(g => g.vmid); - expect(allVmids).toContain(102); // windows11 - expect(allVmids).toContain(106); // pulse (this app!) - expect(allVmids).toContain(200); // UnraidServer - - // DETECT: If count was wrong, this would help debug - if (totalGuests !== 18) { - console.error(`DISCREPANCY: Expected 18 guests, found ${totalGuests}`); - console.error('Missing guests:', [100,101,102,103,104,105,106,107,108,109,110,111,120,121,122,124,200,400].filter(id => !allVmids.includes(id))); - console.error('Extra guests:', allVmids.filter(id => ![100,101,102,103,104,105,106,107,108,109,110,111,120,121,122,124,200,400].includes(id))); - } - }); - }); - - describe('Scenario 2: Admin Investigates "VM 102 Backup Issue"', () => { - test('should detect that VM 102 backup is dangerously old', async () => { - // REAL SCENARIO: VM 102 should be in backup job but backup is 3 days old - - const mockPbsClients = createRealisticPbsClients(realApiData.pbsBackups); - const pbsData = await fetchPbsData(mockPbsClients); - - // Find VM 102 backup - const vm102Backups = pbsData[0].datastores[0].snapshots.filter( - snap => snap['backup-id'] === '102' && snap['backup-type'] === 'vm' - ); - - expect(vm102Backups).toHaveLength(1); - - const vm102LastBackup = vm102Backups[0]; - const backupAge = (Date.now() / 1000) - vm102LastBackup['backup-time']; - const ageInHours = backupAge / 3600; - - // VALIDATE: This should detect the problem - expect(ageInHours).toBeGreaterThan(48); // More than 2 days old! - - // ALERT: This should trigger a critical alert - if (ageInHours > 24) { - console.warn(`CRITICAL: VM 102 backup is ${Math.round(ageInHours)} hours old!`); - } - - // COMPARE: Other VMs should have recent backups - const vm200Backups = pbsData[0].datastores[0].snapshots.filter( - snap => snap['backup-id'] === '200' && snap['backup-type'] === 'vm' - ); - const vm200Age = (Date.now() / 1000) - vm200Backups[0]['backup-time']; - expect(vm200Age / 3600).toBeLessThan(24); // Should be recent - }); - - test('should identify backup job configuration issue', async () => { - // REAL SCENARIO: VM 102 might be excluded from backup jobs or job failed - - const mockPbsClients = createRealisticPbsClients(realApiData.pbsBackups); - const pbsData = await fetchPbsData(mockPbsClients); - - // Analyze backup patterns to detect issues - const backupsByGuest = {}; - pbsData[0].datastores[0].snapshots.forEach(snap => { - const guestId = snap['backup-id']; - if (!backupsByGuest[guestId]) { - backupsByGuest[guestId] = []; - } - backupsByGuest[guestId].push(snap); - }); - - // Check backup frequency patterns - const recentBackups = Object.keys(backupsByGuest).filter(guestId => { - const latestBackup = backupsByGuest[guestId][0]; - const ageHours = (Date.now() / 1000 - latestBackup['backup-time']) / 3600; - return ageHours < 24; - }); - - // VALIDATE: Most guests should have recent backups - expect(recentBackups.length).toBeGreaterThan(5); - - // DETECT: VM 102 should be flagged as problematic - expect(recentBackups).not.toContain('102'); - - // IDENTIFY: Pattern analysis - const guestsWithoutRecentBackups = Object.keys(backupsByGuest).filter(id => !recentBackups.includes(id)); - if (guestsWithoutRecentBackups.length > 0) { - console.warn(`Guests with old backups: ${guestsWithoutRecentBackups.join(', ')}`); - } - }); - }); - - describe('Scenario 3: Admin Responds to "High CPU Alert Storm"', () => { - test('should detect which VMs are actually problematic vs false alarms', async () => { - // REAL SCENARIO: Multiple CPU alerts, admin needs to prioritize - - const mockApiClients = createRealisticMockClientsWithMetrics(realApiData.currentMetrics); - const discoveryData = await fetchDiscoveryData(mockApiClients, {}); - - const runningGuests = [ - ...discoveryData.vms.filter(vm => vm.status === 'running'), - ...discoveryData.containers.filter(ct => ct.status === 'running') - ]; - - const metricsData = await fetchMetricsData( - discoveryData.vms.filter(vm => vm.status === 'running'), - discoveryData.containers.filter(ct => ct.status === 'running'), - mockApiClients - ); - - // ANALYZE: Which guests actually have high CPU - const highCpuGuests = metricsData.filter(metrics => metrics.current.cpu > 0.8); - const moderateCpuGuests = metricsData.filter(metrics => metrics.current.cpu > 0.5 && metrics.current.cpu <= 0.8); - - // VALIDATE: Should detect VM 400 as high CPU (89%) - expect(highCpuGuests).toHaveLength(1); - expect(highCpuGuests[0].id).toBe(400); - expect(highCpuGuests[0].current.cpu).toBeCloseTo(0.89, 2); - - // PRIORITIZE: Admin can focus on real issues - console.log(`HIGH PRIORITY: ${highCpuGuests.length} guests with CPU >80%`); - console.log(`MEDIUM PRIORITY: ${moderateCpuGuests.length} guests with CPU 50-80%`); - - highCpuGuests.forEach(guest => { - const guestInfo = runningGuests.find(g => g.vmid === guest.id); - console.log(` - ${guestInfo.name} (${guestInfo.type} ${guest.id}): ${Math.round(guest.current.cpu * 100)}% CPU`); - }); - }); - - test('should validate alert suppression during maintenance', async () => { - // REAL SCENARIO: Admin puts VM 400 in maintenance, alerts should stop - - // Set custom thresholds to ensure alerts would normally fire - await customThresholds.setThresholds('primary', 'desktop', '400', { - cpu: { warning: 70, critical: 85 } - }); - - const mockApiClients = createRealisticMockClientsWithMetrics(realApiData.currentMetrics); - const metricsData = await fetchMetricsData([], [ - { vmid: 400, name: 'ubuntu-gpu-vm', status: 'running', endpointId: 'primary', node: 'desktop', type: 'qemu' } - ], mockApiClients); - - // Process alerts normally - should fire - const triggeredAlerts = alertManager.processMetrics(metricsData); - expect(triggeredAlerts.length).toBeGreaterThan(0); - - // Suppress alerts for maintenance - alertManager.suppressAlert('cpu_high', { vmid: 400 }, 3600000, 'Maintenance window'); - - // Process again - should be suppressed - const suppressedAlerts = alertManager.processMetrics(metricsData); - const vm400Alerts = suppressedAlerts.filter(alert => alert.guest.vmid === '400'); - expect(vm400Alerts).toHaveLength(0); - }); - }); - - describe('Scenario 4: Admin Validates "Backup Job Health"', () => { - test('should validate backup job scheduling is working correctly', async () => { - // REAL SCENARIO: Admin checks if backup jobs ran on schedule - - const mockPbsClients = createRealisticPbsClients(realApiData.pbsBackups); - const pbsData = await fetchPbsData(mockPbsClients); - - // Group backups by time to detect job patterns - const backupTimes = {}; - pbsData[0].datastores[0].snapshots.forEach(snap => { - const backupHour = new Date(snap['backup-time'] * 1000).getHours(); - if (!backupTimes[backupHour]) { - backupTimes[backupHour] = []; - } - backupTimes[backupHour].push(snap); - }); - - // VALIDATE: Should see backups at 2 AM and 4 AM (your backup schedule) - expect(backupTimes[2]).toBeDefined(); // Primary job at 2 AM - expect(backupTimes[4]).toBeDefined(); // Secondary job at 4 AM - - // VALIDATE: 2 AM job should have most containers - const twoAMBackups = backupTimes[2] || []; - const fourAMBackups = backupTimes[4] || []; - - expect(twoAMBackups.length).toBeGreaterThan(fourAMBackups.length); - - // VALIDATE: Specific VMs should be in correct jobs - const twoAMVmids = twoAMBackups.map(b => b['backup-id']); - const fourAMVmids = fourAMBackups.map(b => b['backup-id']); - - // Based on your ground truth: VMs 200, 400 in secondary job (4 AM) - expect(fourAMVmids).toContain('200'); - expect(fourAMVmids).toContain('400'); - - // Most containers in primary job (2 AM) - excluding VMs 102, 200, 400 - expect(twoAMVmids).toContain('100'); // pbs container - expect(twoAMVmids).toContain('106'); // pulse container - - console.log(`Primary job (2 AM): ${twoAMBackups.length} backups`); - console.log(`Secondary job (4 AM): ${fourAMBackups.length} backups`); - }); - }); - - describe('Scenario 5: Performance Under Load', () => { - test('should handle realistic cluster size without performance degradation', async () => { - // REAL SCENARIO: System should stay responsive with full cluster - - const startTime = Date.now(); - const startMemory = process.memoryUsage().heapUsed; - - // Create full realistic cluster - const mockApiClients = createLargeRealisticCluster(); - - const discoveryData = await fetchDiscoveryData(mockApiClients, {}); - const discoveryTime = Date.now() - startTime; - - // VALIDATE: Performance should be acceptable - expect(discoveryTime).toBeLessThan(10000); // 10 seconds max for discovery - expect(discoveryData.nodes.length).toBeGreaterThan(2); - expect(discoveryData.vms.length + discoveryData.containers.length).toBeGreaterThan(15); - - // VALIDATE: Memory usage should be reasonable - const endMemory = process.memoryUsage().heapUsed; - const memoryIncrease = endMemory - startMemory; - expect(memoryIncrease).toBeLessThan(100 * 1024 * 1024); // Less than 100MB increase - - console.log(`Discovery took ${discoveryTime}ms for ${discoveryData.vms.length + discoveryData.containers.length} guests`); - console.log(`Memory increase: ${Math.round(memoryIncrease / 1024 / 1024)}MB`); - }); - }); - - describe('Scenario 6: Admin Debugs "Slow Dashboard Loading"', () => { - test('should identify performance bottlenecks in data fetching', async () => { - // REAL SCENARIO: Dashboard taking 30+ seconds to load, admin needs to find why - - const mockApiClients = createRealisticMockClients(realApiData.pveCluster); - const performanceMetrics = { - discoveryStart: Date.now(), - nodeCallTimes: [], - totalApiCalls: 0 - }; - - // Monitor API call performance - const originalGet = mockApiClients.primary.client.get; - mockApiClients.primary.client.get = jest.fn().mockImplementation(async (path) => { - const callStart = Date.now(); - performanceMetrics.totalApiCalls++; - - // Simulate realistic response times for different endpoints - let delay = 100; // Default delay - if (path.includes('/qemu') || path.includes('/lxc')) { - delay = 500; // Guest endpoints are slower - } - if (path.includes('node3')) { - delay = 2000; // One node is slow (network issue) - } - - await new Promise(resolve => setTimeout(resolve, delay)); - const result = await originalGet.call(this, path); - - const callTime = Date.now() - callStart; - performanceMetrics.nodeCallTimes.push({ path, time: callTime }); - - return result; - }); - - const discoveryData = await fetchDiscoveryData(mockApiClients, {}); - const totalTime = Date.now() - performanceMetrics.discoveryStart; - - // ANALYZE: Performance bottlenecks - const slowCalls = performanceMetrics.nodeCallTimes.filter(call => call.time > 1000); - const avgCallTime = performanceMetrics.nodeCallTimes.reduce((sum, call) => sum + call.time, 0) / performanceMetrics.nodeCallTimes.length; - - // VALIDATE: Should identify the slow node - expect(slowCalls.length).toBeGreaterThan(0); - expect(slowCalls.some(call => call.path.includes('node3'))).toBe(true); - - // DETECT: Performance recommendations - if (avgCallTime > 500) { - console.log(`PERFORMANCE ISSUE: Average API call time ${Math.round(avgCallTime)}ms`); - } - if (totalTime > 5000) { - console.log(`PERFORMANCE ISSUE: Total discovery time ${totalTime}ms`); - } - - console.log(`Performance analysis: ${performanceMetrics.totalApiCalls} API calls, ${slowCalls.length} slow calls`); - slowCalls.forEach(call => { - console.log(` SLOW: ${call.path} took ${call.time}ms`); - }); - }); - }); - - describe('Scenario 7: Admin Investigates "Missing Backup Alerts"', () => { - test('should detect when backup monitoring is not working correctly', async () => { - // REAL SCENARIO: VM 102 hasn't been backed up in 3 days but no alerts fired - - const mockPbsClients = createRealisticPbsClients(realApiData.pbsBackups); - const pbsData = await fetchPbsData(mockPbsClients); - - // ANALYZE: Backup monitoring effectiveness - const allBackups = pbsData[0].datastores[0].snapshots; - const vm102Backups = allBackups.filter(snap => - snap['backup-id'] === '102' && snap['backup-type'] === 'vm' - ); - - expect(vm102Backups).toHaveLength(1); - - const vm102LastBackup = vm102Backups[0]; - const backupAge = (Date.now() / 1000) - vm102LastBackup['backup-time']; - const ageInDays = backupAge / (24 * 3600); - - // VALIDATE: Should detect old backup - expect(ageInDays).toBeGreaterThan(2); // More than 2 days old - - // SIMULATE: Alert system check - const mockAlertThreshold = 24 * 3600; // 24 hours - const shouldHaveAlerted = backupAge > mockAlertThreshold; - - // DETECT: Alert system gap - if (shouldHaveAlerted) { - console.log(`MONITORING GAP: VM 102 backup is ${Math.round(ageInDays * 10) / 10} days old, should have triggered alert`); - console.log(`Backup age: ${Math.round(backupAge / 3600)} hours (threshold: ${mockAlertThreshold / 3600} hours)`); - } - - // VALIDATE: This test helps identify why backup alerts aren't working - expect(shouldHaveAlerted).toBe(true); - - // RECOMMEND: Compare with other VMs to see pattern - const recentBackups = allBackups.filter(snap => { - const snapAge = (Date.now() / 1000) - snap['backup-time']; - return snapAge < (24 * 3600); // Less than 24 hours old - }); - - console.log(`Found ${recentBackups.length} recent backups vs ${allBackups.length} total`); - }); - }); - - describe('Scenario 8: Data Integrity Validation', () => { - test('should validate that all running VMs have corresponding metrics', async () => { - // REAL SCENARIO: Admin notices some VMs missing from metrics dashboard - - const mockApiClients = createRealisticMockClients(realApiData.pveCluster); - const discoveryData = await fetchDiscoveryData(mockApiClients, {}); - - const runningGuests = [ - ...discoveryData.vms.filter(vm => vm.status === 'running'), - ...discoveryData.containers.filter(ct => ct.status === 'running') - ]; - - // Mock metrics that might miss some guests - const mockMetricsApiClients = createRealisticMockClientsWithMetrics(realApiData.currentMetrics); - const metricsData = await fetchMetricsData( - discoveryData.vms.filter(vm => vm.status === 'running'), - discoveryData.containers.filter(ct => ct.status === 'running'), - mockMetricsApiClients - ); - - // DATA INTEGRITY CHECK: Every running guest should have metrics - const runningGuestIds = runningGuests.map(g => g.vmid); - const metricsGuestIds = metricsData.map(m => m.id); - - const missingMetrics = runningGuestIds.filter(id => !metricsGuestIds.includes(id)); - const extraMetrics = metricsGuestIds.filter(id => !runningGuestIds.includes(id)); - - // VALIDATE: Data consistency - expect(missingMetrics).toHaveLength(0); // No running guests should be missing metrics - expect(extraMetrics).toHaveLength(0); // No metrics for non-existent guests - - if (missingMetrics.length > 0) { - console.error(`DATA INTEGRITY ISSUE: ${missingMetrics.length} running guests missing metrics:`, missingMetrics); - } - if (extraMetrics.length > 0) { - console.error(`DATA INTEGRITY ISSUE: ${extraMetrics.length} metrics for non-running guests:`, extraMetrics); - } - - // VALIDATE: Metrics data quality - metricsData.forEach(metrics => { - expect(metrics.current).toBeDefined(); - expect(typeof metrics.current.cpu).toBe('number'); - expect(metrics.current.cpu).toBeGreaterThanOrEqual(0); - expect(metrics.current.cpu).toBeLessThanOrEqual(1); // Assuming decimal format - }); - - console.log(`Data integrity check: ${runningGuests.length} running guests, ${metricsData.length} metrics records`); - }); - }); - - describe('Scenario 9: Admin Responds to "Disk Space Critical" Alert', () => { - test('should help admin prioritize disk cleanup actions', async () => { - // REAL SCENARIO: Multiple disk space alerts, admin needs to know where to focus cleanup - - // Mock guests with varying disk usage - const diskPressureGuests = { - 106: { cpu: 0.12, memory: 536870912, disk: 0.92 }, // Pulse - 92% full - 200: { cpu: 0.15, memory: 2147483648, disk: 0.88 }, // UnraidServer - 88% full - 107: { cpu: 0.08, memory: 268435456, disk: 0.95 }, // Jellyfin - 95% full (critical!) - 108: { cpu: 0.22, memory: 1073741824, disk: 0.85 } // Frigate - 85% full - }; - - const mockApiClients = createRealisticMockClientsWithMetrics(diskPressureGuests); - const metricsData = await fetchMetricsData([], [ - { vmid: 106, name: 'pulse', status: 'running', endpointId: 'primary', node: 'minipc', type: 'lxc' }, - { vmid: 200, name: 'UnraidServer', status: 'running', endpointId: 'primary', node: 'desktop', type: 'qemu' }, - { vmid: 107, name: 'jellyfin', status: 'running', endpointId: 'primary', node: 'minipc', type: 'lxc' }, - { vmid: 108, name: 'frigate', status: 'running', endpointId: 'primary', node: 'delly', type: 'lxc' } - ], mockApiClients); - - // ANALYZE: Disk usage patterns - const diskMetrics = metricsData.map(m => ({ - id: m.id, - name: m.guestName, - diskUsage: m.current.disk * 100, - type: m.type - })).sort((a, b) => b.diskUsage - a.diskUsage); - - // PRIORITIZE: Critical vs warning levels - const criticalDisk = diskMetrics.filter(g => g.diskUsage > 90); // >90% - const warningDisk = diskMetrics.filter(g => g.diskUsage > 85 && g.diskUsage <= 90); // 85-90% - - // VALIDATE: Should identify jellyfin as highest priority - expect(criticalDisk).toHaveLength(2); // Jellyfin (95%) and Pulse (92%) - expect(criticalDisk[0].name).toBe('jellyfin'); - expect(criticalDisk[0].diskUsage).toBe(95); - - // RECOMMEND: Actions based on service type - const mediaServices = criticalDisk.filter(g => - ['jellyfin', 'plex', 'frigate'].includes(g.name.toLowerCase()) - ); - const systemServices = criticalDisk.filter(g => - ['pulse', 'pihole', 'homeassistant'].includes(g.name.toLowerCase()) - ); - - console.log('DISK CLEANUP PRIORITIES:'); - console.log(`CRITICAL (>90%): ${criticalDisk.length} services`); - criticalDisk.forEach(g => { - console.log(` - ${g.name}: ${g.diskUsage}% full`); - }); - - console.log(`WARNING (85-90%): ${warningDisk.length} services`); - - // GUIDANCE: Specific cleanup recommendations - if (mediaServices.length > 0) { - console.log('RECOMMENDATION: Check media files for cleanup (jellyfin, frigate)'); - } - if (systemServices.length > 0) { - console.log('RECOMMENDATION: Check logs and temporary files (pulse, system services)'); - } - - expect(criticalDisk.length).toBeGreaterThan(0); - }); - }); -}); - -// Helper functions for realistic test data -function getTwoAMToday() { - const now = new Date(); - const twoAM = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 2, 0, 0); - if (twoAM > now) { - twoAM.setDate(twoAM.getDate() - 1); // Yesterday's 2 AM - } - return Math.floor(twoAM.getTime() / 1000); -} - -function getFourAMToday() { - const now = new Date(); - const fourAM = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 4, 0, 0); - if (fourAM > now) { - fourAM.setDate(fourAM.getDate() - 1); // Yesterday's 4 AM - } - return Math.floor(fourAM.getTime() / 1000); -} - -function getThreeDaysAgo() { - const threeDaysAgo = new Date(); - threeDaysAgo.setDate(threeDaysAgo.getDate() - 3); - threeDaysAgo.setHours(2, 0, 0, 0); // 2 AM three days ago - return Math.floor(threeDaysAgo.getTime() / 1000); -} - -function createRealisticMockClients(pveCluster) { - return { - 'primary': { - client: { - get: jest.fn().mockImplementation((path) => { - if (path === '/nodes') { - return Promise.resolve({ data: { data: pveCluster.nodes } }); - } - if (path.includes('/qemu')) { - const node = path.split('/')[2]; - const nodeVms = pveCluster.vms.filter(vm => vm.node === node); - return Promise.resolve({ data: { data: nodeVms } }); - } - if (path.includes('/lxc')) { - const node = path.split('/')[2]; - const nodeContainers = pveCluster.containers.filter(ct => ct.node === node); - return Promise.resolve({ data: { data: nodeContainers } }); - } - return Promise.resolve({ data: { data: [] } }); - }) - }, - config: { id: 'primary', name: 'Test Cluster' } - } - }; -} - -function createRealisticPbsClients(pbsBackups) { - return { - 'pbs-main': { - client: { - get: jest.fn().mockImplementation((path) => { - if (path === '/nodes') { - return Promise.resolve({ data: { data: [{ node: 'pbs-node' }] } }); - } - if (path === '/config/datastore') { - return Promise.resolve({ data: { data: [{ name: 'main-datastore' }] } }); - } - if (path.includes('/admin/datastore/main-datastore/snapshots')) { - return Promise.resolve({ data: { data: pbsBackups.datastores[0].snapshots } }); - } - if (path.includes('/status/datastore-usage')) { - return Promise.resolve({ data: { data: [{ store: 'main-datastore', total: 1000000000, used: 500000000 }] } }); - } - return Promise.resolve({ data: { data: [] } }); - }) - }, - config: { id: 'pbs-main', name: 'Test PBS' } - } - }; -} - -function createRealisticMockClientsWithMetrics(currentMetrics) { - return { - 'primary': { - client: { - get: jest.fn().mockImplementation((path) => { - if (path.includes('/status')) { - const vmidMatch = path.match(/\/(qemu|lxc)\/(\d+)\/status/); - if (vmidMatch) { - const vmid = parseInt(vmidMatch[2]); - const metrics = currentMetrics[vmid]; - if (metrics) { - return Promise.resolve({ data: { data: metrics } }); - } - } - return Promise.resolve({ data: { data: { cpu: 0.1, memory: 1073741824, disk: 5368709120 } } }); - } - if (path.includes('/rrddata')) { - return Promise.resolve({ data: { data: [{ time: Date.now() / 1000, cpu: 0.1 }] } }); - } - return Promise.resolve({ data: { data: [] } }); - }) - }, - config: { id: 'primary', name: 'Test Cluster' } - } - }; -} - -function createLargeRealisticCluster() { - // Create a larger but still realistic cluster - const nodes = ['desktop', 'delly', 'minipc', 'server1', 'server2']; - const largeCluster = { - nodes: nodes.map(name => ({ node: name, status: 'online', uptime: 86400 })), - vms: [], - containers: [] - }; - - // Add realistic VMs and containers distributed across nodes - let vmid = 100; - nodes.forEach((node, nodeIndex) => { - // Add some VMs per node - for (let i = 0; i < 3; i++) { - largeCluster.vms.push({ - vmid: vmid++, - name: `vm-${node}-${i}`, - status: Math.random() > 0.1 ? 'running' : 'stopped', - node: node - }); - } - // Add some containers per node - for (let i = 0; i < 8; i++) { - largeCluster.containers.push({ - vmid: vmid++, - name: `ct-${node}-${i}`, - status: Math.random() > 0.05 ? 'running' : 'stopped', - node: node - }); - } - }); - - return createRealisticMockClients(largeCluster); -} \ No newline at end of file