From ce340ccf2e9e2fff08b3b076e6740bbc44c36045 Mon Sep 17 00:00:00 2001 From: Andreas Hilbig Date: Fri, 20 Feb 2026 00:24:05 +0100 Subject: [PATCH] feat: implement storeGroupValue and getGroupValue with unified locator - API Refactoring: Extracted GroupValueLocator input type to unify parameters for storeGroupValue (mutation) and getGroupValue (query). - Data Retrieval: Implemented getGroupValue query to allow direct retrieval of JSON values stored in host groups via Zabbix Trapper items. - Enhanced Logic: Added ZabbixGetGroupValueRequest to fetch latest history values for group-associated items. - Improved Verification: Updated the regression suite (REG-STORE) to include a full 'Store-Update-Retrieve' verification cycle. - Documentation: - Updated docs/howtos/cookbook.md recipes to use the new locator structure and getGroupValue for verification. - Updated sample query files (docs/queries/) with corrected variables and verification queries. - Tests: - Added unit and integration tests for getGroupValue. - Updated existing tests to match the refactored storeGroupValue schema. - Verification: Verified 100% pass rate for all 16 regression steps and all unit/integration tests. --- .idea/workspace.xml | 109 +- .junie/guidelines.md | 1 + .output.txt | 1196 ++++++++++++++++- README.md | 11 +- docs/howtos/README.md | 2 +- docs/howtos/cookbook.md | 206 +++ docs/howtos/maintenance.md | 2 +- .../sample_store_group_value_mutation.graphql | 9 + .../sample_store_parking_geojson.graphql | 70 + docs/testcases/store_group_value.md | 7 + docs/testcases/tests.md | 179 +++ docs/tests.md | 161 +-- mcp-config.yaml | 2 + schema/api_commons.graphql | 24 + schema/mutations.graphql | 30 + schema/queries.graphql | 10 + src/api/resolvers.ts | 27 +- src/common_utils.ts | 9 + src/datasources/zabbix-api.ts | 4 +- src/datasources/zabbix-hostgroups.ts | 2 +- src/datasources/zabbix-hosts.ts | 2 +- src/datasources/zabbix-request.ts | 12 +- .../zabbix-store-in-item-history.ts | 371 +++++ src/execution/regression_test_executor.ts | 161 ++- src/schema/generated/graphql.ts | 71 + .../store_group_value.integration.test.ts | 119 ++ src/test/store_group_value.unit.test.ts | 219 +++ 27 files changed, 2788 insertions(+), 228 deletions(-) create mode 100644 docs/queries/sample_store_group_value_mutation.graphql create mode 100644 docs/queries/sample_store_parking_geojson.graphql create mode 100644 docs/testcases/store_group_value.md create mode 100644 docs/testcases/tests.md create mode 100644 src/datasources/zabbix-store-in-item-history.ts create mode 100644 src/test/store_group_value.integration.test.ts create mode 100644 src/test/store_group_value.unit.test.ts diff --git a/.idea/workspace.xml b/.idea/workspace.xml index df11802..3e2e581 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -4,33 +4,21 @@ - - - @@ -494,7 +492,10 @@ - @@ -502,14 +503,20 @@ file://$PROJECT_DIR$/src/datasources/zabbix-request.ts - 152 + 156 file://$PROJECT_DIR$/src/datasources/zabbix-request.ts - 338 + 342 + + file://$PROJECT_DIR$/src/execution/host_importer.ts + 58 + + diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 3ecfdac..65b107d 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -35,6 +35,7 @@ The [Roadmap](../roadmap.md) is to be considered as outlook giving constraints o ## Best Practices & Standards - **ESM & Imports**: The project uses ECMAScript Modules (ESM). Always use the `.js` extension when importing local files (e.g. `import { Config } from "../common_utils.js";`), even though the source files are `.ts`. +- **Zabbix API Requests**: Always use dedicated request classes (extending `ZabbixRequest`) for interacting with the Zabbix API. Avoid using `zabbixAPI.requestByPath` directly in business logic or data sources, as request classes provide better type safety and parameter optimization. - **Configuration**: Always use the `Config` class to access environment variables. Avoid direct `process.env` calls. - **Type Safety**: Leverage types generated via `npx graphql-codegen --config codegen.ts` (or `npm run codegen` for watch mode) for resolvers and data handling to ensure consistency with the schema. - **Import Optimization**: diff --git a/.output.txt b/.output.txt index 18b4226..306e063 100644 --- a/.output.txt +++ b/.output.txt @@ -1 +1,1195 @@ -{"data":{"runAllRegressionTests":{"success":true,"message":"Regression tests passed successfully","steps":[{"name":"REG-LOC: Locations query argument order","success":true,"message":"Locations query executed without session error"},{"name":"REG-TEMP: Template technical name lookup","success":true,"message":"Template REG_TEMP_o1fedi created and searchable by technical name"},{"name":"REG-HTTP: HTTP Agent URL support","success":true,"message":"Template REG_HTTP_cnbxkf with HTTP Agent item created successfully"},{"name":"REG-MACRO: User Macro assignment","success":true,"message":"Macros successfully assigned to template and host"},{"name":"REG-HOST: Host retrieval and visibility (incl. groups and templates)","success":true,"message":"Host REG_HOST_ngacal retrieved successfully with groups and templates"},{"name":"REG-ITEM-META: Item metadata (preprocessing, units, description, error)","success":true,"message":"Item metadata successfully retrieved including preprocessing and units"},{"name":"REG-OPT: Query Optimization and Skippable Parameters","success":true,"message":"Optimization logic correctly filters output fields and skippable parameters"},{"name":"REG-EMPTY: Empty result handling","success":true,"message":"Correctly returned empty array for non-existent host"},{"name":"REG-DEP: Dependent Items support","success":true,"message":"Template with master and dependent items imported successfully"},{"name":"REG-STATE: State sub-properties retrieval (indirect dependency)","success":true,"message":"State sub-properties correctly trigger item fetching and are available"},{"name":"REG-OPT-NEG: Negative Optimization - items not requested (allDevices)","success":true,"message":"Optimization correctly omits items when neither items nor state are requested"},{"name":"REG-DEV-FILTER: allDevices deviceType filter","success":true,"message":"allDevices correctly filtered out hosts without deviceType tag"},{"name":"REG-PUSH: pushHistory mutation","success":true,"message":"Successfully pushed history data to trapper item"},{"name":"Create Host Group","success":true,"message":"Host group REG_GROUP_l5t5hv created"}]}}} \ No newline at end of file + PASS src/test/store_group_value.integration.test.ts + ● Console + console.log + [dotenv@17.2.3] injecting env (14) from .env -- tip: ⚙️ write to custom object with { processEnv: myObject } + at _log (node_modules/dotenv/lib/main.js:142:11) + console.log + Loading schema from path: ./schema/, cwd=/home/ahilbig/git/vcr/zabbix-graphql-api + at schema_loader (src/api/schema.ts:31:13) + PASS src/test/store_group_value.unit.test.ts + ● Console + console.log + [dotenv@17.2.3] injecting env (14) from .env -- tip: ✅ audit secrets and track compliance: https://dotenvx.com/ops + at _log (node_modules/dotenv/lib/main.js:142:11) + PASS src/test/template_integration.test.ts + ● Console + console.log + [dotenv@17.2.3] injecting env (14) from .env -- tip: ⚙️ load multiple .env files with { path: ['.env.local', '.env'] } + at _log (node_modules/dotenv/lib/main.js:142:11) + console.log + Loading schema from path: ./schema/, cwd=/home/ahilbig/git/vcr/zabbix-graphql-api + at schema_loader (src/api/schema.ts:31:13) + PASS src/test/schema_dependent_queries.test.ts + ● Console + console.log + [dotenv@17.2.3] injecting env (14) from .env -- tip: 🔑 add access controls to secrets: https://dotenvx.com/ops + at _log (node_modules/dotenv/lib/main.js:142:11) + console.log + Connecting to Zabbix at url=http://cockpit.vcr.develop.hilbigit.com/ + at Logger.info (src/logging/logger.ts:42:21) + console.log + Loading schema from path: ./schema/, cwd=/home/ahilbig/git/vcr/zabbix-graphql-api + at schema_loader (src/api/schema.ts:31:13) + console.log + checking host Distance Tracker 01 for deviceType - found DistanceTrackerDevice + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving value of scalar field parent.deviceType (DistanceTrackerDevice), looking up key from item {"key":"state.current.count","value":"5"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving value of scalar field parent.deviceType (DistanceTrackerDevice), looking up key from item {"key":"state.current.timeFrom","value":"2024-01-01T10:00:00Z"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving value of scalar field parent.deviceType (DistanceTrackerDevice), looking up key from item {"key":"state.current.timeUntil","value":"2024-01-01T11:00:00Z"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.log + Device data mapped: "DistanceTrackerDevice" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving value of scalar field parent.host (TRACKER_01), looking up key from item {"key":"state.current.count","value":"5"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving value of scalar field parent.host (TRACKER_01), looking up key from item {"key":"state.current.timeFrom","value":"2024-01-01T10:00:00Z"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving value of scalar field parent.host (TRACKER_01), looking up key from item {"key":"state.current.timeUntil","value":"2024-01-01T11:00:00Z"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.log + Device data mapped: "TRACKER_01" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving value of scalar field parent.name (Distance Tracker 01), looking up key from item {"key":"state.current.count","value":"5"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving value of scalar field parent.name (Distance Tracker 01), looking up key from item {"key":"state.current.timeFrom","value":"2024-01-01T10:00:00Z"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving value of scalar field parent.name (Distance Tracker 01), looking up key from item {"key":"state.current.timeUntil","value":"2024-01-01T11:00:00Z"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.log + Device data mapped: "Distance Tracker 01" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving attributes of object field parent.state (undefined), looking up key from item {"key":"state.current.count","value":"5"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Adding attribute count, result: {"count":5} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Adding attribute current, result: {"current":{"count":5}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Detected matching item key state in item , result: {"current":{"count":5}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving attributes of object field parent.state ([object Object]), looking up key from item {"key":"state.current.timeFrom","value":"2024-01-01T10:00:00Z"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Adding attribute timeFrom, result: {"count":5,"timeFrom":"2024-01-01T10:00:00Z"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Adding attribute current, result: {"current":{"count":5,"timeFrom":"2024-01-01T10:00:00Z"}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Detected matching item key state in item , result: {"current":{"count":5,"timeFrom":"2024-01-01T10:00:00Z"}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving attributes of object field parent.state ([object Object]), looking up key from item {"key":"state.current.timeUntil","value":"2024-01-01T11:00:00Z"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Adding attribute timeUntil, result: {"count":5,"timeFrom":"2024-01-01T10:00:00Z","timeUntil":"2024-01-01T11:00:00Z"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Adding attribute current, result: {"current":{"count":5,"timeFrom":"2024-01-01T10:00:00Z","timeUntil":"2024-01-01T11:00:00Z"}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Detected matching item key state in item , result: {"current":{"count":5,"timeFrom":"2024-01-01T10:00:00Z","timeUntil":"2024-01-01T11:00:00Z"}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.log + Device data mapped: {"current":{"count":5,"timeFrom":"2024-01-01T10:00:00Z","timeUntil":"2024-01-01T11:00:00Z"}} + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 3) + console.log + checking host Distance Tracker 02 for deviceType - found DistanceTrackerDevice + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving value of scalar field parent.deviceType (DistanceTrackerDevice), looking up key from item {"key":"state.current.count","value":"10"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving value of scalar field parent.deviceType (DistanceTrackerDevice), looking up key from item {"key":"state.current.timeFrom","value":"09:58:09"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.log + Device data mapped: "DistanceTrackerDevice" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving value of scalar field parent.host (TRACKER_02), looking up key from item {"key":"state.current.count","value":"10"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving value of scalar field parent.host (TRACKER_02), looking up key from item {"key":"state.current.timeFrom","value":"09:58:09"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.log + Device data mapped: "TRACKER_02" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving value of scalar field parent.name (Distance Tracker 02), looking up key from item {"key":"state.current.count","value":"10"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving value of scalar field parent.name (Distance Tracker 02), looking up key from item {"key":"state.current.timeFrom","value":"09:58:09"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.log + Device data mapped: "Distance Tracker 02" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving attributes of object field parent.state (undefined), looking up key from item {"key":"state.current.count","value":"10"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Adding attribute count, result: {"count":10} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Adding attribute current, result: {"current":{"count":10}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Detected matching item key state in item , result: {"current":{"count":10}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving attributes of object field parent.state ([object Object]), looking up key from item {"key":"state.current.timeFrom","value":"09:58:09"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Adding attribute timeFrom, result: {"count":10,"timeFrom":"09:58:09"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Adding attribute current, result: {"current":{"count":10,"timeFrom":"09:58:09"}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Detected matching item key state in item , result: {"current":{"count":10,"timeFrom":"09:58:09"}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.log + Device data mapped: {"current":{"count":10,"timeFrom":"09:58:09"}} + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 3) + console.log + checking host Distance Tracker 03 for deviceType - found DistanceTrackerDevice + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving value of scalar field parent.deviceType (DistanceTrackerDevice), looking up key from item {"key":"state.current.count","value":"0"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving value of scalar field parent.deviceType (DistanceTrackerDevice), looking up key from item {"key":"state.current.timeFrom","value":""} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.log + Device data mapped: "DistanceTrackerDevice" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving value of scalar field parent.host (TRACKER_03), looking up key from item {"key":"state.current.count","value":"0"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving value of scalar field parent.host (TRACKER_03), looking up key from item {"key":"state.current.timeFrom","value":""} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.log + Device data mapped: "TRACKER_03" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving value of scalar field parent.name (Distance Tracker 03), looking up key from item {"key":"state.current.count","value":"0"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving value of scalar field parent.name (Distance Tracker 03), looking up key from item {"key":"state.current.timeFrom","value":""} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.log + Device data mapped: "Distance Tracker 03" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving attributes of object field parent.state (undefined), looking up key from item {"key":"state.current.count","value":"0"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Adding attribute count, result: {"count":0} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Adding attribute current, result: {"current":{"count":0}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Detected matching item key state in item , result: {"current":{"count":0}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving attributes of object field parent.state ([object Object]), looking up key from item {"key":"state.current.timeFrom","value":""} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Adding attribute timeFrom, result: {"count":0,"timeFrom":""} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Adding attribute current, result: {"current":{"count":0,"timeFrom":""}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.debug + Detected matching item key state in item , result: {"current":{"count":0,"timeFrom":""}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.log + Device data mapped: {"current":{"count":0,"timeFrom":""}} + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 3) + console.log + checking host LED Display 01 for deviceType - found SinglePanelDevice + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving value of scalar field parent.deviceType (SinglePanelDevice), looking up key from item {"key":"state.current.values.1.contentText","value":"Roadwork Ahead"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.log + Device data mapped: "SinglePanelDevice" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving value of scalar field parent.host (DISPLAY_01), looking up key from item {"key":"state.current.values.1.contentText","value":"Roadwork Ahead"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.log + Device data mapped: "DISPLAY_01" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 3) + console.debug + Resolving value of scalar field parent.name (LED Display 01), looking up key from item {"key":"state.current.values.1.contentText","value":"Roadwork Ahead"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 3) + console.log + Device data mapped: "LED Display 01" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 3) + console.log + checking host Distance Tracker 01 for deviceType - found DistanceTrackerDevice + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.hostid (10001), looking up key from item {"key":"state.current.count","value":"5"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.hostid (10001), looking up key from item {"key":"state.current.timeFrom","value":"2024-01-01T10:00:00Z"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.hostid (10001), looking up key from item {"key":"state.current.timeUntil","value":"2024-01-01T11:00:00Z"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.log + Device data mapped: "10001" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.host (TRACKER_01), looking up key from item {"key":"state.current.count","value":"5"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.host (TRACKER_01), looking up key from item {"key":"state.current.timeFrom","value":"2024-01-01T10:00:00Z"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.host (TRACKER_01), looking up key from item {"key":"state.current.timeUntil","value":"2024-01-01T11:00:00Z"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.log + Device data mapped: "TRACKER_01" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.name (Distance Tracker 01), looking up key from item {"key":"state.current.count","value":"5"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.name (Distance Tracker 01), looking up key from item {"key":"state.current.timeFrom","value":"2024-01-01T10:00:00Z"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.name (Distance Tracker 01), looking up key from item {"key":"state.current.timeUntil","value":"2024-01-01T11:00:00Z"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.log + Device data mapped: "Distance Tracker 01" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.deviceType (DistanceTrackerDevice), looking up key from item {"key":"state.current.count","value":"5"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.deviceType (DistanceTrackerDevice), looking up key from item {"key":"state.current.timeFrom","value":"2024-01-01T10:00:00Z"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.deviceType (DistanceTrackerDevice), looking up key from item {"key":"state.current.timeUntil","value":"2024-01-01T11:00:00Z"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.log + Device data mapped: "DistanceTrackerDevice" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving attributes of object field parent.deviceWidgetPreview ([object Object]), looking up key from item {"key":"deviceType","value":"DistanceTrackerDevice"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Item key deviceType not matched fieldname=deviceWidgetPreview, result: {} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.log + Device tags mapped: {} + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving attributes of object field parent.state (undefined), looking up key from item {"key":"state.current.count","value":"5"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Adding attribute count, result: {"count":5} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Adding attribute current, result: {"current":{"count":5}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Detected matching item key state in item , result: {"current":{"count":5}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving attributes of object field parent.state ([object Object]), looking up key from item {"key":"state.current.timeFrom","value":"2024-01-01T10:00:00Z"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Adding attribute timeFrom, result: {"count":5,"timeFrom":"2024-01-01T10:00:00Z"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Adding attribute current, result: {"current":{"count":5,"timeFrom":"2024-01-01T10:00:00Z"}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Detected matching item key state in item , result: {"current":{"count":5,"timeFrom":"2024-01-01T10:00:00Z"}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving attributes of object field parent.state ([object Object]), looking up key from item {"key":"state.current.timeUntil","value":"2024-01-01T11:00:00Z"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Adding attribute timeUntil, result: {"count":5,"timeFrom":"2024-01-01T10:00:00Z","timeUntil":"2024-01-01T11:00:00Z"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Adding attribute current, result: {"current":{"count":5,"timeFrom":"2024-01-01T10:00:00Z","timeUntil":"2024-01-01T11:00:00Z"}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Detected matching item key state in item , result: {"current":{"count":5,"timeFrom":"2024-01-01T10:00:00Z","timeUntil":"2024-01-01T11:00:00Z"}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.log + Device data mapped: {"current":{"count":5,"timeFrom":"2024-01-01T10:00:00Z","timeUntil":"2024-01-01T11:00:00Z"}} + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.log + checking host Distance Tracker 02 for deviceType - found DistanceTrackerDevice + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.hostid (10003), looking up key from item {"key":"state.current.count","value":"10"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.hostid (10003), looking up key from item {"key":"state.current.timeFrom","value":"09:58:09"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.log + Device data mapped: "10003" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.host (TRACKER_02), looking up key from item {"key":"state.current.count","value":"10"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.host (TRACKER_02), looking up key from item {"key":"state.current.timeFrom","value":"09:58:09"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.log + Device data mapped: "TRACKER_02" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.name (Distance Tracker 02), looking up key from item {"key":"state.current.count","value":"10"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.name (Distance Tracker 02), looking up key from item {"key":"state.current.timeFrom","value":"09:58:09"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.log + Device data mapped: "Distance Tracker 02" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.deviceType (DistanceTrackerDevice), looking up key from item {"key":"state.current.count","value":"10"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.deviceType (DistanceTrackerDevice), looking up key from item {"key":"state.current.timeFrom","value":"09:58:09"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.log + Device data mapped: "DistanceTrackerDevice" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving attributes of object field parent.deviceWidgetPreview ([object Object]), looking up key from item {"key":"deviceType","value":"DistanceTrackerDevice"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Item key deviceType not matched fieldname=deviceWidgetPreview, result: {} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.log + Device tags mapped: {} + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving attributes of object field parent.state (undefined), looking up key from item {"key":"state.current.count","value":"10"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Adding attribute count, result: {"count":10} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Adding attribute current, result: {"current":{"count":10}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Detected matching item key state in item , result: {"current":{"count":10}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving attributes of object field parent.state ([object Object]), looking up key from item {"key":"state.current.timeFrom","value":"09:58:09"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Adding attribute timeFrom, result: {"count":10,"timeFrom":"09:58:09"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Adding attribute current, result: {"current":{"count":10,"timeFrom":"09:58:09"}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Detected matching item key state in item , result: {"current":{"count":10,"timeFrom":"09:58:09"}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.log + Device data mapped: {"current":{"count":10,"timeFrom":"09:58:09"}} + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.log + checking host Distance Tracker 03 for deviceType - found DistanceTrackerDevice + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.hostid (10004), looking up key from item {"key":"state.current.count","value":"0"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.hostid (10004), looking up key from item {"key":"state.current.timeFrom","value":""} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.log + Device data mapped: "10004" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.host (TRACKER_03), looking up key from item {"key":"state.current.count","value":"0"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.host (TRACKER_03), looking up key from item {"key":"state.current.timeFrom","value":""} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.log + Device data mapped: "TRACKER_03" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.name (Distance Tracker 03), looking up key from item {"key":"state.current.count","value":"0"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.name (Distance Tracker 03), looking up key from item {"key":"state.current.timeFrom","value":""} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.log + Device data mapped: "Distance Tracker 03" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.deviceType (DistanceTrackerDevice), looking up key from item {"key":"state.current.count","value":"0"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.deviceType (DistanceTrackerDevice), looking up key from item {"key":"state.current.timeFrom","value":""} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.log + Device data mapped: "DistanceTrackerDevice" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving attributes of object field parent.deviceWidgetPreview ([object Object]), looking up key from item {"key":"deviceType","value":"DistanceTrackerDevice"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Item key deviceType not matched fieldname=deviceWidgetPreview, result: {} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.log + Device tags mapped: {} + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving attributes of object field parent.state (undefined), looking up key from item {"key":"state.current.count","value":"0"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Adding attribute count, result: {"count":0} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Adding attribute current, result: {"current":{"count":0}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Detected matching item key state in item , result: {"current":{"count":0}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving attributes of object field parent.state ([object Object]), looking up key from item {"key":"state.current.timeFrom","value":""} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Adding attribute timeFrom, result: {"count":0,"timeFrom":""} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Adding attribute current, result: {"current":{"count":0,"timeFrom":""}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Detected matching item key state in item , result: {"current":{"count":0,"timeFrom":""}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.log + Device data mapped: {"current":{"count":0,"timeFrom":""}} + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.log + checking host LED Display 01 for deviceType - found SinglePanelDevice + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.hostid (10002), looking up key from item {"key":"state.current.values.1.contentText","value":"Roadwork Ahead"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.log + Device data mapped: "10002" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.host (DISPLAY_01), looking up key from item {"key":"state.current.values.1.contentText","value":"Roadwork Ahead"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.log + Device data mapped: "DISPLAY_01" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.name (LED Display 01), looking up key from item {"key":"state.current.values.1.contentText","value":"Roadwork Ahead"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.log + Device data mapped: "LED Display 01" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving value of scalar field parent.deviceType (SinglePanelDevice), looking up key from item {"key":"state.current.values.1.contentText","value":"Roadwork Ahead"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.log + Device data mapped: "SinglePanelDevice" + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving attributes of object field parent.deviceWidgetPreview ([object Object]), looking up key from item {"key":"deviceType","value":"SinglePanelDevice"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Item key deviceType not matched fieldname=deviceWidgetPreview, result: {} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.log + Device tags mapped: {} + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + console.debug + Resolving attributes of object field parent.state (undefined), looking up key from item {"key":"state.current.values.1.contentText","value":"Roadwork Ahead"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Adding attribute contentText, result: {"contentText":"Roadwork Ahead"} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Adding attribute 1, result: {"1":{"contentText":"Roadwork Ahead"}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Adding attribute values, result: {"values":{"1":{"contentText":"Roadwork Ahead"}}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Adding attribute current, result: {"current":{"values":{"1":{"contentText":"Roadwork Ahead"}}}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.debug + Detected matching item key state in item , result: {"current":{"values":{"1":{"contentText":"Roadwork Ahead"}}}} + at Logger.debug (src/logging/logger.ts:58:21) + at Array.forEach () + at Array.from () + at async Promise.all (index 4) + console.log + Device data mapped: {"current":{"values":{"1":{"contentText":"Roadwork Ahead"}}}} + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 4) + PASS src/test/zabbix_docs_samples.test.ts + ● Console + console.log + [dotenv@17.2.3] injecting env (14) from .env -- tip: 🛠️ run anywhere with `dotenvx run -- yourcommand` + at _log (node_modules/dotenv/lib/main.js:142:11) + console.log + Loading schema from path: ./schema/, cwd=/home/ahilbig/git/vcr/zabbix-graphql-api + at schema_loader (src/api/schema.ts:31:13) + console.error + Unable to find groupName=New Host Group 1 + 48 | public error(...data: any[]) { + 49 | if (!this.levels || this.levels.has(Loglevel.ERROR)) { + > 50 | console.error(...data) + | ^ + 51 | if (this.logMqtt) { + 52 | // TODO Push to mqtt TEST_STATS_LOG_TOPIC topic + 53 | } + at Logger.error (src/logging/logger.ts:50:21) + at GroupHelper.findHostGroupIdsByName (src/datasources/zabbix-hostgroups.ts:132:24) + at HostImporter.importHostGroups (src/execution/host_importer.ts:57:26) + console.error + Unable to find groupName=New Host Group 2 + 48 | public error(...data: any[]) { + 49 | if (!this.levels || this.levels.has(Loglevel.ERROR)) { + > 50 | console.error(...data) + | ^ + 51 | if (this.logMqtt) { + 52 | // TODO Push to mqtt TEST_STATS_LOG_TOPIC topic + 53 | } + at Logger.error (src/logging/logger.ts:50:21) + at GroupHelper.findHostGroupIdsByName (src/datasources/zabbix-hostgroups.ts:132:24) + at HostImporter.importHostGroups (src/execution/host_importer.ts:57:26) + console.error + Unable to find groupName=undefined + 48 | public error(...data: any[]) { + 49 | if (!this.levels || this.levels.has(Loglevel.ERROR)) { + > 50 | console.error(...data) + | ^ + 51 | if (this.logMqtt) { + 52 | // TODO Push to mqtt TEST_STATS_LOG_TOPIC topic + 53 | } + at Logger.error (src/logging/logger.ts:50:21) + at GroupHelper.findHostGroupIdsByName (src/datasources/zabbix-hostgroups.ts:132:24) + at HostImporter.importHosts (src/execution/host_importer.ts:125:28) + PASS src/test/history_push_integration.test.ts + ● Console + console.log + [dotenv@17.2.3] injecting env (14) from .env -- tip: 🔐 prevent building .env in docker: https://dotenvx.com/prebuild + at _log (node_modules/dotenv/lib/main.js:142:11) + console.log + Loading schema from path: ./schema/, cwd=/home/ahilbig/git/vcr/zabbix-graphql-api + at schema_loader (src/api/schema.ts:31:13) + PASS src/test/mcp_operations_validation.test.ts + ● Console + console.log + [dotenv@17.2.3] injecting env (14) from .env -- tip: 👥 sync secrets across teammates & machines: https://dotenvx.com/ops + at _log (node_modules/dotenv/lib/main.js:142:11) + console.log + Connecting to Zabbix at url=http://cockpit.vcr.develop.hilbigit.com/ + at Logger.info (src/logging/logger.ts:42:21) + console.log + Loading schema from path: ./schema/, cwd=/home/ahilbig/git/vcr/zabbix-graphql-api + at schema_loader (src/api/schema.ts:31:13) + PASS src/test/host_integration.test.ts + ● Console + console.log + [dotenv@17.2.3] injecting env (14) from .env -- tip: ✅ audit secrets and track compliance: https://dotenvx.com/ops + at _log (node_modules/dotenv/lib/main.js:142:11) + console.log + Loading schema from path: ./schema/, cwd=/home/ahilbig/git/vcr/zabbix-graphql-api + at schema_loader (src/api/schema.ts:31:13) + console.log + checking host BT_DEVICE_1 for deviceType - no device type found, returning as ZabbixHost + at Logger.info (src/logging/logger.ts:42:21) + at Array.from () + at async Promise.all (index 0) + PASS src/test/template_link.test.ts + ● Console + console.log + [dotenv@17.2.3] injecting env (14) from .env -- tip: ⚙️ write to custom object with { processEnv: myObject } + at _log (node_modules/dotenv/lib/main.js:142:11) + console.log + Loading schema from path: ./schema/, cwd=/home/ahilbig/git/vcr/zabbix-graphql-api + at schema_loader (src/api/schema.ts:31:13) + PASS src/test/user_rights_integration.test.ts + ● Console + console.log + [dotenv@17.2.3] injecting env (14) from .env -- tip: 🔐 prevent committing .env to code: https://dotenvx.com/precommit + at _log (node_modules/dotenv/lib/main.js:142:11) + console.log + Loading schema from path: ./schema/, cwd=/home/ahilbig/git/vcr/zabbix-graphql-api + at schema_loader (src/api/schema.ts:31:13) + PASS src/test/history_push.test.ts + ● Console + console.log + [dotenv@17.2.3] injecting env (14) from .env -- tip: 📡 add observability to secrets: https://dotenvx.com/ops + at _log (node_modules/dotenv/lib/main.js:142:11) + PASS src/test/host_query.test.ts + PASS src/test/user_rights.test.ts + PASS src/test/template_importer.test.ts + ● Console + console.log + [dotenv@17.2.3] injecting env (14) from .env -- tip: ⚙️ override existing env vars with { override: true } + at _log (node_modules/dotenv/lib/main.js:142:11) + console.debug + Template group Existing Group already exists with groupid=102 - skipping + at Logger.debug (src/logging/logger.ts:58:21) + PASS src/test/query_optimization.test.ts + PASS src/test/indirect_dependencies.test.ts + PASS src/test/misc_resolvers.test.ts + PASS src/test/zabbix_api_args_parser.test.ts + ● Console + console.log + [dotenv@17.2.3] injecting env (14) from .env -- tip: ⚙️ enable debug logging with { debug: true } + at _log (node_modules/dotenv/lib/main.js:142:11) + PASS src/test/template_query.test.ts + ● Console + console.log + [dotenv@17.2.3] injecting env (14) from .env -- tip: 📡 add observability to secrets: https://dotenvx.com/ops + at _log (node_modules/dotenv/lib/main.js:142:11) + PASS src/test/schema_config.test.ts + ● Console + console.log + Loading schema from path: ./test_schema/, cwd=/home/ahilbig/git/vcr/zabbix-graphql-api + at schema_loader (src/api/schema.ts:31:13) + PASS src/test/host_importer.test.ts + ● Console + console.log + [dotenv@17.2.3] injecting env (14) from .env -- tip: 🔑 add access controls to secrets: https://dotenvx.com/ops + at _log (node_modules/dotenv/lib/main.js:142:11) + console.error + Unable to find groupName=Parent + 48 | public error(...data: any[]) { + 49 | if (!this.levels || this.levels.has(Loglevel.ERROR)) { + > 50 | console.error(...data) + | ^ + 51 | if (this.logMqtt) { + 52 | // TODO Push to mqtt TEST_STATS_LOG_TOPIC topic + 53 | } + at Logger.error (src/logging/logger.ts:50:21) + at GroupHelper.findHostGroupIdsByName (src/datasources/zabbix-hostgroups.ts:132:24) + at HostImporter.importHostGroups (src/execution/host_importer.ts:57:26) + at Object. (src/test/host_importer.test.ts:53:24) + console.error + Unable to find groupName=Parent/Child + 48 | public error(...data: any[]) { + 49 | if (!this.levels || this.levels.has(Loglevel.ERROR)) { + > 50 | console.error(...data) + | ^ + 51 | if (this.logMqtt) { + 52 | // TODO Push to mqtt TEST_STATS_LOG_TOPIC topic + 53 | } + at Logger.error (src/logging/logger.ts:50:21) + at GroupHelper.findHostGroupIdsByName (src/datasources/zabbix-hostgroups.ts:132:24) + at HostImporter.importHostGroups (src/execution/host_importer.ts:57:26) + at Object. (src/test/host_importer.test.ts:53:24) + PASS src/test/template_deleter.test.ts + ● Console + console.log + [dotenv@17.2.3] injecting env (14) from .env -- tip: ⚙️ load multiple .env files with { path: ['.env.local', '.env'] } + at _log (node_modules/dotenv/lib/main.js:142:11) + PASS src/test/zabbix_api_config.test.ts + ● Console + console.log + Connecting to Zabbix at url=http://custom-zabbix + at Logger.info (src/logging/logger.ts:42:21) + PASS src/test/logger_config.test.ts +Test Suites: 24 passed, 24 total +Tests: 100 passed, 100 total +Snapshots: 0 total +Time: 10.819 s +Ran all test suites. \ No newline at end of file diff --git a/README.md b/README.md index b20297d..3cbfa22 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ The Zabbix GraphQL API acts as a wrapper and enhancer for the native Zabbix JSON - **Mass Operations**: Import/export capabilities for hosts, templates, and user rights - *Reference*: `schema/mutations.graphql` (importHosts, importTemplates, importUserRights, etc.), `docs/queries/sample_import_*.graphql` +- **Group-Level Data Storage**: Persistence and retrieval of JSON-based configuration or metadata associated with host groups + - *Reference*: `schema/mutations.graphql` (`storeGroupValue`), `schema/queries.graphql` (`getGroupValue`), `docs/howtos/cookbook.md` + - **Dynamic Schema Extension**: Extend the schema without code changes using environment variables - *Reference*: `src/api/schema.ts`, `samples/extensions/` (sample extensions), `src/common_utils.ts` (ADDITIONAL_SCHEMAS, ADDITIONAL_RESOLVERS) @@ -40,7 +43,7 @@ For detailed information on specific topics and practical step-by-step instructi - [**Hierarchical Data Mapping**](./docs/howtos/hierarchical_data_mapping.md): How Zabbix items are mapped to nested GraphQL fields. - [**Roles & Permissions**](./docs/howtos/permissions.md): Managing user rights through Zabbix template groups. - [**Technical Maintenance Guide**](./docs/howtos/maintenance.md): Guide on code generation, testing, and Docker maintenance. -- [**Test Specification**](./docs/tests.md): Detailed list of test cases and coverage checklist. +- [**Test Specification**](./docs/testcases/tests.md): Detailed list of test cases and coverage checklist. - [**MCP & Agent Integration**](./docs/howtos/mcp.md): Connecting LLMs and autonomous agents via Model Context Protocol. See the [How-To Overview](./docs/howtos/README.md) for a complete list of documentation. @@ -154,6 +157,7 @@ The API maps Zabbix entities to GraphQL types as follows: |---------------|--------------|-------------| | Host | `Host` / `Device` | Represents a Zabbix host; `Device` is a specialized `Host` with a `deviceType` tag | | Host Group | `HostGroup` | Represents a Zabbix host group | +| Group Value | `JSONObject` | Stored configuration or metadata associated with a host group (managed via `storeGroupValue` / `getGroupValue`) | | Template | `Template` | Represents a Zabbix template | | Template Group | `HostGroup` | Represents a Zabbix template group | | Item | Nested fields | Zabbix items become nested fields based on their key names (hierarchical mapping) | @@ -258,12 +262,13 @@ This API is officially supported and productively used with **Zabbix 7.0 (LTS)** - **Zabbix 7.0+ (including 7.4)**: - Full feature support. - **History Push**: Uses the native `history.push` API for efficient data ingestion. + - **Group-Level Storage**: Efficiently store/retrieve configuration objects using `storeGroupValue` and `getGroupValue`. - **Zabbix 6.4**: - - **History Push**: Not supported (requires Zabbix 7.0+). The `pushHistory` mutation returns a clear error. + - **History Push / Group Storage**: Not supported (requires Zabbix 7.0+). The `pushHistory` and `storeGroupValue` mutations return a clear error. - **Group Propagation**: Fully supported via the `hostgroup.propagate` API. - **UUIDs**: Fully supported for both Host Groups and Template Groups. - **Zabbix 6.2**: - - **History Push**: Not supported. + - **History Push / Group Storage**: Not supported. - **Authentication**: Fully supported. The API automatically falls back to using the `auth` field in JSON-RPC request bodies since Bearer token headers were only introduced in 6.4. #### ⚠️ Dropped Support for Zabbix 6.0 diff --git a/docs/howtos/README.md b/docs/howtos/README.md index d619569..61c2b94 100644 --- a/docs/howtos/README.md +++ b/docs/howtos/README.md @@ -25,7 +25,7 @@ Guide on code generation (GraphQL Codegen), running Jest tests, and local Docker ### 💻 [Local Development Environment](./local_development.md) Detailed instructions for setting up a fully isolated local development environment with Zabbix using Docker Compose. -### 🧪 [Test Specification](../tests.md) +### 🧪 [Test Specification](../testcases/tests.md) Detailed list of test cases, categories (Unit, Integration, E2E), and coverage checklist. ### 🤖 [MCP & Agent Integration](./mcp.md) diff --git a/docs/howtos/cookbook.md b/docs/howtos/cookbook.md index c83a559..1d69740 100644 --- a/docs/howtos/cookbook.md +++ b/docs/howtos/cookbook.md @@ -139,6 +139,12 @@ Execute the `importTemplates` mutation to create the template and items automati > **Reference**: Use the [Sample: Distance Tracker Import](../queries/sample_import_distance_tracker_template.graphql) for a complete mutation and variables example. ### ✅ Step 4: Verify the Extension +#### Automated Regression Test +Parts of this functionality are covered by the regression suite. To run it: +- Execute the `runAllRegressionTests` mutation. +- Check the step `REG-STATE: State sub-properties retrieval (indirect dependency)`. + +#### Manual Verification Verify that the new type is available and correctly mapped by creating a test host and querying it. #### 1. Create a Test Host @@ -299,6 +305,16 @@ Use the `importTemplates` mutation to create the template. Use **HTTP agent** or - Description: The average ground value (Bodenrichtwert) extracted from the BORIS NRW GeoJSON response. ### ✅ Step 5: Verification +#### Automated Regression Test +Parts of this functionality are covered by the regression suite. To run it: +- Execute the `runAllRegressionTests` mutation. +- Check the following steps: + - REG-HTTP: HTTP Agent URL support + - REG-DEP: Dependent Items support + - REG-ITEM-META: Item metadata (preprocessing, units, description, error) + - REG-STATE: State sub-properties retrieval (indirect dependency) + +#### Manual Verification Create a host, assign it macros for coordinates, and query its state. 1. **Create Host (Weather Example)**: @@ -401,6 +417,12 @@ Push GeoJSON data to your simulated device using the `pushHistory` mutation. Thi > **Reference**: See the [Sample: Push GeoJSON History](../queries/sample_push_geojson_history.graphql) for a complete example of pushing historical data. ### ✅ Step 5: Verification +#### Automated Regression Test +Covered by the automated regression test suite. To run it: +1. Execute the `runAllRegressionTests` mutation. +2. Check for the step `REG-PUSH: pushHistory mutation`. + +#### Manual Verification Verify that the device correctly resolves to the new type and that both the current state and historical data are accessible. - **Create Host**: Use the `importHosts` mutation to create a host (e.g. `Vehicle1`) and link it to the simulated template. @@ -448,6 +470,14 @@ This recipe shows how to execute a comprehensive query to verify the state and c Execute the query against your GraphQL endpoint. This query retrieves information from `allHostGroups`, `allDevices`, and `allHosts`, using inline fragments to access fields specific to `DistanceTrackerDevice`. ### ✅ Step 3: Verification +#### Automated Regression Test +Parts of this functionality are covered by the regression suite. To run it: +- Execute the `runAllRegressionTests` mutation. +- Check the following steps: + - REG-STATE: State sub-properties retrieval (indirect dependency) + - REG-DEV-FILTER: allDevices deviceType filter + +#### Manual Verification Check the response for the following: - **apiVersion** and **zabbixVersion** are returned. - **allHostGroups** contains the expected groups. @@ -482,6 +512,12 @@ mutation CreateNewHost($host: String!, $groups: [Int!]!, $templates: [Int], $tem ``` ### ✅ Step 3: Verify Host Creation +#### Automated Regression Test +Covered by the automated regression test suite. To run it: +1. Execute the `runAllRegressionTests` mutation. +2. Check for the step `REG-HOST: Host retrieval and visibility (incl. groups and templates)`. + +#### Manual Verification Check if the host is correctly provisioned and linked to groups: ```graphql query VerifyHost($host: String!) { @@ -565,6 +601,170 @@ For detailed examples of the input structures, refer to [Sample Import Templates --- +## 🍳 Recipe: Storing Configuration in a Host Group + +This recipe demonstrates how to store a JSON-based configuration or state object and associate it with a host group. This is useful for managing application settings, device configurations, or any other metadata that needs to be persisted in Zabbix and retrieved via GraphQL. + +### 📋 Prerequisites +- Zabbix GraphQL API is running. +- A host group exists where the configuration should be stored (e.g. `Infrastructure/Configurations`). + +### 🛠️ Step 1: Preparation/Definition +Identify the target host group name and the configuration data you want to store. + +### ⚙️ Step 2: Configuration/Settings +No additional Zabbix configuration is required. The API will automatically handle host and item creation if they don't exist. + +### 🚀 Step 3: Execution/Action +Execute the `storeGroupValue` mutation. The API will: +- Look for a host in the group with the tag `valueType` matching your `valueType` argument. +- If not found, create a new host with that tag. +- Ensure a Zabbix Trapper item with your `key` exists on that host. +- Push the JSON `value` to that item. + +```graphql +mutation StoreConfig($locator: GroupValueLocator!, $config: JSONObject!) { + storeGroupValue( + locator: $locator, + value: $config + ) { + itemid + error { message } + } +} +``` + +- *Variables*: +```json +{ + "locator": { + "groupName": "Infrastructure/Configurations", + "valueType": "GlobalSettings", + "key": "api.config.json" + }, + "config": { + "maintenanceMode": false, + "logLevel": "DEBUG", + "updatedAt": "2024-05-20T10:00:00Z" + } +} +``` + +### ✅ Step 4: Verification +Verify the stored value by querying the host and its items. + +#### Automated Regression Test +The functionality is covered by the automated regression test suite. To run it: +1. Execute the `runAllRegressionTests` mutation. +2. Check for the step `REG-STORE: storeGroupValue mutation`. + +#### Manual Verification +You can verify the stored value using the `getGroupValue` query: + +```graphql +query GetConfig($locator: GroupValueLocator!) { + getGroupValue(locator: $locator) +} +``` + +Alternatively, verify by querying the host and its items: + +```graphql +query VerifyConfig($pattern: String!) { + allHosts(name_pattern: $pattern) { + host + ... on ZabbixHost { + tags + } + items { + name + key_ + lastvalue + } + } +} +``` + +--- + +## 🍳 Recipe: Retrieving Stored Group Values + +This recipe shows how to retrieve a JSON-based configuration or state object previously stored using the `storeGroupValue` mutation. + +### 📋 Prerequisites +- Zabbix GraphQL API is running. +- A value has been stored using the `storeGroupValue` mutation. + +### 🛠️ Step 1: Preparation/Definition +Identify the locator parameters used when the value was stored: +- `groupName` or `groupid` +- `valueType` +- `key` + +### 🚀 Step 2: Execution/Action +Execute the `getGroupValue` query. + +```graphql +query GetStoredConfig($locator: GroupValueLocator!) { + getGroupValue(locator: $locator) +} +``` + +- *Variables*: +```json +{ + "locator": { + "groupName": "Infrastructure/Configurations", + "valueType": "GlobalSettings", + "key": "api.config.json" + } +} +``` + +### ✅ Step 3: Verification +The query will return the stored JSON object as the result. If no matching value is found, `null` is returned. + +--- + +## 🍳 Recipe: Creating a GeoJSON Feature Collection for Cologne Trade Fair Parking + +This recipe shows how to persist a GeoJSON `FeatureCollection` using the `storeGroupValue` mutation. As a concrete example, we store the areas of parking lots belonging to the Cologne Trade Fair (Koelnmesse) under the host group `Roadwork/CologneTradeFair`. Each feature represents a parking lot polygon and includes descriptive metadata (e.g. name, type, operator) derived from public sources (e.g. OpenStreetMap). + +### 📋 Prerequisites +- Zabbix GraphQL API is running. +- You have a valid Zabbix user/session or token. +- The base host group prefix `Roadwork` exists. +- The subgroup `Roadwork/CologneTradeFair` exists. If it does not exist, create it manually first (via the `importHostGroups` mutation or in the Zabbix UI). + +### 🛠️ Step 1: Preparation/Definition +Prepare a GeoJSON `FeatureCollection` with one feature per parking lot. Include descriptive metadata (e.g. name, type, operator) derived from public sources like OpenStreetMap. + +> **Reference**: For a complete sample `FeatureCollection` including parking lot "P22", see [Sample: Store Parking GeoJSON](../queries/sample_store_parking_geojson.graphql). + +- *Note*: Coordinates used in the samples are illustrative and simplified for documentation. For production, use the most accurate polygons available from authoritative or open data sources. + +### ⚙️ Step 2: Configuration/Settings +- Manually ensure the host group `Roadwork/CologneTradeFair` exists (see Prerequisites). +- The API will automatically: + - Create (or reuse) a storage host in that group tagged with `valueType=FeatureCollection`. + - Create (or update) a Zabbix Trapper item with key `geometry.areas.parking` on that host. + +### 🚀 Step 3: Execution/Action +Execute the `storeGroupValue` mutation to store the `FeatureCollection` in Zabbix. + +> **Reference**: Use the [Sample: Store Parking GeoJSON](../queries/sample_store_parking_geojson.graphql) for the complete mutation and variables JSON. + +### ✅ Step 4: Verification +Verify the stored value using the `getGroupValue` query or by querying the host and its items. + +> **Reference**: Use the **Verification Query** from [Sample: Store Parking GeoJSON](../queries/sample_store_parking_geojson.graphql). + +- *Automated Regression Test*: This functionality is covered by the regression suite. To run it: + - Execute the `runAllRegressionTests` mutation. + - Check for the step `REG-STORE: storeGroupValue mutation`. + +--- + ## 🍳 Recipe: Running the Smoketest via MCP This recipe explains how to execute the end-to-end smoketest using the integrated MCP server. This is the fastest way to verify that your Zabbix GraphQL API is correctly connected to a Zabbix instance and all core features (Groups, Templates, Hosts) are working. @@ -725,6 +925,12 @@ mutation PushDeviceData($host: String, $key: String, $itemid: Int, $values: [His ``` ### ✅ Step 3: Verification +#### Automated Regression Test +The functionality is covered by the automated regression test suite. To run it: +1. Execute the `runAllRegressionTests` mutation. +2. Check for the step `REG-PUSH: pushHistory mutation`. + +#### Manual Verification Verify that the data was successfully pushed by querying the item's last value: ```graphql diff --git a/docs/howtos/maintenance.md b/docs/howtos/maintenance.md index 39f1405..37bab41 100644 --- a/docs/howtos/maintenance.md +++ b/docs/howtos/maintenance.md @@ -40,7 +40,7 @@ For running integration tests against a real Zabbix instance, it is recommended #### Adding New Tests - **Location**: Place new test files in `src/test/` with the `.test.ts` extension. - **Coverage**: Ensure you cover both successful operations and error scenarios. -- **Test Specification**: Every new test must be documented in the [Test Specification](../tests.md). +- **Test Specification**: Every new test must be documented in the [Test Specification](../testcases/tests.md). - **Best Practice**: If you find a bug, first create a reproduction test in `src/test/` to verify the fix. ## 🔄 Updating Dependencies diff --git a/docs/queries/sample_store_group_value_mutation.graphql b/docs/queries/sample_store_group_value_mutation.graphql new file mode 100644 index 0000000..9c7a524 --- /dev/null +++ b/docs/queries/sample_store_group_value_mutation.graphql @@ -0,0 +1,9 @@ +mutation StoreConfiguration($locator: GroupValueLocator!, $value: JSONObject!) { + storeGroupValue( + locator: $locator, + value: $value + ) { + itemid + error { message } + } +} diff --git a/docs/queries/sample_store_parking_geojson.graphql b/docs/queries/sample_store_parking_geojson.graphql new file mode 100644 index 0000000..0de2e07 --- /dev/null +++ b/docs/queries/sample_store_parking_geojson.graphql @@ -0,0 +1,70 @@ +### Mutation +Store a GeoJSON `FeatureCollection` for Cologne Trade Fair parking lots using the `storeGroupValue` mutation. + +```graphql +mutation StoreParkingGeoJSON($locator: GroupValueLocator!, $value: JSONObject!) { + storeGroupValue(locator: $locator, value: $value) { + itemid + error { message } + } +} +``` + +### Variables +```json +{ + "locator": { + "groupName": "Roadwork/CologneTradeFair", + "valueType": "FeatureCollection", + "key": "geometry.areas.parking" + }, + "value": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { "name": "P22", "type": "parking lot", "operator": "Koelnmesse", "ref": "P22", "source": "OpenStreetMap", "website": "https://www.koelnmesse.de/", "updatedAt": "2026-02-19T00:00:00Z" }, + "geometry": { "type": "Polygon", "coordinates": [[[6.9812, 50.9469], [6.9823, 50.9467], [6.9820, 50.9459], [6.9810, 50.9461], [6.9812, 50.9469]]] } + }, + { + "type": "Feature", + "properties": { "name": "P21", "type": "parking lot", "operator": "Koelnmesse", "ref": "P21", "source": "OpenStreetMap" }, + "geometry": { "type": "Polygon", "coordinates": [[[6.9800, 50.9476], [6.9810, 50.9473], [6.9809, 50.9468], [6.9798, 50.9470], [6.9800, 50.9476]]] } + }, + { + "type": "Feature", + "properties": { "name": "P32", "type": "parking lot", "operator": "Koelnmesse", "ref": "P32", "source": "OpenStreetMap" }, + "geometry": { "type": "Polygon", "coordinates": [[[6.9835, 50.9438], [6.9843, 50.9436], [6.9841, 50.9430], [6.9833, 50.9432], [6.9835, 50.9438]]] } + } + ] + } +} +``` + +### Verification Query +Verify the stored GeoJSON using the `getGroupValue` query. + +```graphql +query VerifyParkingGeoJSON($locator: GroupValueLocator!) { + getGroupValue(locator: $locator) +} +``` + +Alternatively, verify by querying the host and its items: + +```graphql +query VerifyParkingGeoJSONAlt($hostPattern: String!) { + allHosts(name_pattern: $hostPattern) { + host + ... on ZabbixHost { + tags + } + items { + name + key_ + lastvalue + lastclock + } + } +} +``` diff --git a/docs/testcases/store_group_value.md b/docs/testcases/store_group_value.md new file mode 100644 index 0000000..14b7bbb --- /dev/null +++ b/docs/testcases/store_group_value.md @@ -0,0 +1,7 @@ +# Test Cases: storeGroupValue (Moved) + +This content has been consolidated into the main Test Specification document. + +- New location: [docs/testcases/tests.md#store-group-value-storegroupvalue-test-cases](./tests.md#store-group-value-storegroupvalue-test-cases) + +Please update any references to this file. diff --git a/docs/testcases/tests.md b/docs/testcases/tests.md new file mode 100644 index 0000000..fa0c3b3 --- /dev/null +++ b/docs/testcases/tests.md @@ -0,0 +1,179 @@ +# Test Specification + +This document outlines the test cases and coverage for the Zabbix GraphQL API. + +## 📂 Test Categories + +- **Unit Tests**: Verify individual functions, classes, or logic in isolation. All external dependencies (Zabbix API, Config) are mocked to ensure the test is fast and deterministic. These tests are executed on each build. + - *Reference*: `src/test/host_importer.test.ts`, `src/test/template_query.test.ts` +- **Integration Tests**: Test the interaction between multiple internal components. Typically, these tests use a mock Apollo Server to execute actual GraphQL operations against the resolvers and data sources, with the Zabbix API mocked at the network layer. These tests are executed on each build. + - *Reference*: `src/test/host_integration.test.ts`, `src/test/user_rights_integration.test.ts` +- **End-to-End (E2E) Tests**: Validate complete, multi-step business workflows from start to finish (e.g., a full import-verify-cleanup cycle). These tests are executed against a real, running Zabbix instance to ensure the entire system achieves the desired business outcome. These tests are triggered after startup or on demand via GraphQL/MCP endpoints. + - *Reference*: `mcp/operations/runSmoketest.graphql` (executed via MCP) + +## 🧪 Test Case Definitions + +### Host Management +- **TC-HOST-01**: Query all hosts using sample query. +- **TC-HOST-02**: Import hosts using sample mutation. +- **TC-HOST-03**: Import host groups and create new hierarchy. +- **TC-HOST-04**: Import basic host. +- **TC-HOST-05**: Query all hosts with name pattern. +- **TC-HOST-06**: Query all devices by host ID. +- **TC-HOST-07**: Query all host groups with search pattern. +- **TC-HOST-08**: Query host groups using default search pattern. +- **TC-HOST-09**: Query locations. + +### Template Management +- **TC-TEMP-01**: Import templates using sample query and variables. +- **TC-TEMP-02**: Import and export templates comparison. +- **TC-TEMP-03**: Import and export template groups comparison. +- **TC-TEMP-04**: Query all templates. +- **TC-TEMP-05**: Filter templates by host IDs. +- **TC-TEMP-06**: Filter templates by name pattern. +- **TC-TEMP-07**: Filter templates by name pattern with wildcard. +- **TC-TEMP-08**: Import template groups (new group). +- **TC-TEMP-09**: Import template groups (existing group). +- **TC-TEMP-10**: Import basic template. +- **TC-TEMP-11**: Import templates with items, linked templates, and dependent items. +- **TC-TEMP-12**: Import templates query validation. +- **TC-TEMP-13**: Import templates error handling (data field inclusion). +- **TC-TEMP-14**: Delete templates successfully. +- **TC-TEMP-15**: Delete templates error handling. +- **TC-TEMP-16**: Delete templates by name pattern. +- **TC-TEMP-17**: Delete templates with merged IDs and name pattern. +- **TC-TEMP-18**: Delete template groups successfully. +- **TC-TEMP-19**: Delete template groups error handling. +- **TC-TEMP-20**: Delete template groups by name pattern. + +### User Rights and Permissions +- **TC-AUTH-01**: Export user rights. +- **TC-AUTH-02**: Query user permissions. +- **TC-AUTH-03**: Check if user has permissions. +- **TC-AUTH-04**: Import user rights. +- **TC-AUTH-05**: Import user rights using sample mutation. + +### History and Data Pushing +- **TC-HIST-01**: Push history data using `pushHistory` mutation. + +### Query Optimization +- **TC-OPT-01**: Verify that GraphQL queries only fetch requested fields from Zabbix (reduced output). +- **TC-OPT-02**: Verify that skippable Zabbix parameters (like selectItems) are omitted if not requested in GraphQL. +- **TC-OPT-03**: Verify that indirect dependencies (e.g., `state` requiring `items`) are correctly handled by the optimization logic. + +### System and Configuration +- **TC-CONF-01**: Schema loader uses Config variables. +- **TC-CONF-02**: Zabbix API constants derived from Config. +- **TC-CONF-03**: Logger levels initialized from Config. +- **TC-CONF-04**: API version query. +- **TC-CONF-05**: Login query. +- **TC-CONF-06**: Logout query. +- **TC-CONF-07**: Parse Zabbix arguments. + +### Documentation and MCP +- **TC-DOCS-01**: Validate all Zabbix documentation sample queries. +- **TC-MCP-01**: Validate all MCP operation files against the schema. + +### Schema-dependent Tests +- **TC-SCHEMA-01**: Verify comprehensive query for `DistanceTrackerDevice` works correctly when schema is extended. + +### End-to-End (E2E) Tests +- **TC-E2E-01**: Run a complete smoketest using MCP (creates template, group, and host, verifies, and cleans up). +- **TC-E2E-02**: Run all regression tests to verify critical system behavior and prevent known issues. + +#### Currently Contained Regression Tests +The `runAllRegressionTests` mutation (TC-E2E-02) executes the following checks: +- **Host without items**: Verifies that hosts created without any items or linked templates can be successfully queried by the system. This ensures that the hierarchical mapping and resolvers handle empty item lists gracefully. +- **Locations query argument order**: Verifies that the `locations` query correctly handles its parameters and successfully contacts the Zabbix API without session errors (verifying the fix for argument order in the resolver). +- **Template technical name lookup**: Verifies that templates can be correctly identified by their technical name (`host` field) when linking them to hosts during import. +- **HTTP Agent URL support**: Verifies that templates containing HTTP Agent items with a configured URL can be imported successfully (verifying the addition of the `url` field to `CreateTemplateItem`). +- **Host retrieval and visibility**: Verifies that newly imported hosts are immediately visible and retrievable via the `allHosts` query, including correctly delivered assigned templates and assigned host groups (verifying the fix for `output` fields in the host query data source). +- **Query Optimization**: Verifies that GraphQL requests correctly translate into optimized Zabbix parameters, reducing the amount of data fetched (verifying the query optimization feature). +- **Empty result handling**: Verifies that queries return an empty array instead of an error when no entities match the provided filters. +- **Dependent Items**: Verifies that templates with master and dependent items can be imported successfully, correctly resolving the dependency within the same import operation. +- **State sub-properties**: Verifies that requesting device state sub-properties correctly triggers the retrieval of required Zabbix items, even if `items` is not explicitly requested (verifying the indirect dependency logic). +- **Negative Optimization (allDevices)**: Verifies that items are NOT requested from Zabbix if neither `items` nor `state` (or state sub-properties) are requested within the `allDevices` query. +- **allDevices deviceType filter**: Verifies that the `allDevices` query only returns hosts that have a `deviceType` tag, and that the `deviceType` field is populated for all results. +- **pushHistory mutation**: Verifies that the `pushHistory` mutation correctly pushes data to ZABBIX_TRAP items, using either item ID or a combination of host and item key. + +### Store Group Value (storeGroupValue) + +- **TC-SGV-01 (Unit)**: Input validation — missing `valueType` when neither `host` nor `itemid` is provided. Covered by `src/test/store_group_value.unit.test.ts`. +- **TC-SGV-02 (Unit)**: Input validation — missing `groupid`/`groupName` when `host` is not provided. Covered by `src/test/store_group_value.unit.test.ts`. +- **TC-SGV-03 (Unit)**: Group lookup failure — `groupName` not found. Covered by `src/test/store_group_value.unit.test.ts`. +- **TC-SGV-04 (Unit)**: Automated host creation when no host with the `valueType` tag exists in the group. Covered by `src/test/store_group_value.unit.test.ts`. +- **TC-SGV-05 (Integration)**: Full `storeGroupValue` mutation flow with mocked Zabbix API. Covered by `src/test/store_group_value.integration.test.ts`. +- **TC-SGV-06 (Unit)**: Different keys result in different item lookups for the same storage host. Covered by `src/test/store_group_value.unit.test.ts`. +- **REG-STORE-1 (E2E/Regression)**: Group name resolution to `groupid`. Implemented in `src/execution/regression_test_executor.ts` (step: `REG-STORE: storeGroupValue mutation`). +- **REG-STORE-2 (E2E/Regression)**: Auto-provision storage host with `valueType` tag if absent. Implemented in `src/execution/regression_test_executor.ts`. +- **REG-STORE-3 (E2E/Regression)**: Idempotent update — same `key` updates existing item instead of creating a new one. Implemented in `src/execution/regression_test_executor.ts`. +- **REG-STORE-4 (E2E/Regression)**: Different keys result in different items on the same storage host. Implemented in `src/execution/regression_test_executor.ts`. +- **REG-STORE-5 (E2E/Regression)**: Value retrieval — verify that `getGroupValue` correctly retrieves the stored JSON data. Implemented in `src/execution/regression_test_executor.ts`. +- **REG-STORE-6 (E2E/Regression)**: Cleanup — delete created storage host and any host group(s) that were created by the test run (pre-existing groups are preserved). Implemented in `src/execution/regression_test_executor.ts`. + +## ✅ Test Coverage Checklist + +| ID | Test Case | Category | Technology | Code Link | +|:---|:---|:---|:---|:---| +| TC-HOST-01 | Query allHosts using sample | Integration | Jest | [src/test/host_integration.test.ts](../../src/test/host_integration.test.ts) | +| TC-HOST-02 | Import hosts using sample | Integration | Jest | [src/test/host_integration.test.ts](../../src/test/host_integration.test.ts) | +| TC-HOST-03 | importHostGroups - create new hierarchy | Unit | Jest | [src/test/host_importer.test.ts](../../src/test/host_importer.test.ts) | +| TC-HOST-04 | importHosts - basic host | Unit | Jest | [src/test/host_importer.test.ts](../../src/test/host_importer.test.ts) | +| TC-HOST-05 | allHosts query | Unit | Jest | [src/test/host_query.test.ts](../../src/test/host_query.test.ts) | +| TC-HOST-06 | allDevices query | Unit | Jest | [src/test/host_query.test.ts](../../src/test/host_query.test.ts) | +| TC-HOST-07 | allHostGroups query | Unit | Jest | [src/test/host_query.test.ts](../../src/test/host_query.test.ts) | +| TC-HOST-08 | allHostGroups query - default pattern | Unit | Jest | [src/test/host_query.test.ts](../../src/test/host_query.test.ts) | +| TC-HOST-09 | locations query | Unit | Jest | [src/test/host_query.test.ts](../../src/test/host_query.test.ts) | +| TC-TEMP-01 | Import templates using sample | Integration | Jest | [src/test/template_integration.test.ts](../../src/test/template_integration.test.ts) | +| TC-TEMP-02 | Import and Export templates comparison | Integration | Jest | [src/test/template_integration.test.ts](../../src/test/template_integration.test.ts) | +| TC-TEMP-03 | Import and Export template groups comparison | Integration | Jest | [src/test/template_integration.test.ts](../../src/test/template_integration.test.ts) | +| TC-TEMP-04 | templates query - returns all | Unit | Jest | [src/test/template_query.test.ts](../../src/test/template_query.test.ts) | +| TC-TEMP-05 | templates query - filters by hostids | Unit | Jest | [src/test/template_query.test.ts](../../src/test/template_query.test.ts) | +| TC-TEMP-06 | templates query - filters by name_pattern | Unit | Jest | [src/test/template_query.test.ts](../../src/test/template_query.test.ts) | +| TC-TEMP-07 | templates query - name_pattern wildcard | Unit | Jest | [src/test/template_query.test.ts](../../src/test/template_query.test.ts) | +| TC-TEMP-08 | importTemplateGroups - create new | Unit | Jest | [src/test/template_importer.test.ts](../../src/test/template_importer.test.ts) | +| TC-TEMP-09 | importTemplateGroups - group exists | Unit | Jest | [src/test/template_importer.test.ts](../../src/test/template_importer.test.ts) | +| TC-TEMP-10 | importTemplates - basic template | Unit | Jest | [src/test/template_importer.test.ts](../../src/test/template_importer.test.ts) | +| TC-TEMP-11 | importTemplates - complex template | Unit | Jest | [src/test/template_importer.test.ts](../../src/test/template_importer.test.ts) | +| TC-TEMP-12 | importTemplates - template query | Unit | Jest | [src/test/template_importer.test.ts](../../src/test/template_importer.test.ts) | +| TC-TEMP-13 | importTemplates - error data field | Unit | Jest | [src/test/template_importer.test.ts](../../src/test/template_importer.test.ts) | +| TC-TEMP-14 | deleteTemplates - success | Unit | Jest | [src/test/template_deleter.test.ts](../../src/test/template_deleter.test.ts) | +| TC-TEMP-15 | deleteTemplates - error | Unit | Jest | [src/test/template_deleter.test.ts](../../src/test/template_deleter.test.ts) | +| TC-TEMP-16 | deleteTemplates - by name_pattern | Unit | Jest | [src/test/template_deleter.test.ts](../../src/test/template_deleter.test.ts) | +| TC-TEMP-17 | deleteTemplates - merged IDs | Unit | Jest | [src/test/template_deleter.test.ts](../../src/test/template_deleter.test.ts) | +| TC-TEMP-18 | deleteTemplateGroups - success | Unit | Jest | [src/test/template_deleter.test.ts](../../src/test/template_deleter.test.ts) | +| TC-TEMP-19 | deleteTemplateGroups - error | Unit | Jest | [src/test/template_deleter.test.ts](../../src/test/template_deleter.test.ts) | +| TC-TEMP-20 | deleteTemplateGroups - by name_pattern | Unit | Jest | [src/test/template_deleter.test.ts](../../src/test/template_deleter.test.ts) | +| TC-AUTH-01 | exportUserRights query | Unit | Jest | [src/test/user_rights.test.ts](../../src/test/user_rights.test.ts) | +| TC-AUTH-02 | userPermissions query | Unit | Jest | [src/test/user_rights.test.ts](../../src/test/user_rights.test.ts) | +| TC-AUTH-03 | hasPermissions query | Unit | Jest | [src/test/user_rights.test.ts](../../src/test/user_rights.test.ts) | +| TC-AUTH-04 | importUserRights mutation | Unit | Jest | [src/test/user_rights.test.ts](../../src/test/user_rights.test.ts) | +| TC-AUTH-05 | Import user rights using sample | Integration | Jest | [src/test/user_rights_integration.test.ts](../../src/test/user_rights_integration.test.ts) | +| TC-OPT-01 | Verify Query Optimization (reduced output) | Unit/E2E | Jest/Regression | [src/test/query_optimization.test.ts](../../src/test/query_optimization.test.ts) | +| TC-OPT-02 | Verify skippable parameters | Unit/E2E | Jest/Regression | [src/test/query_optimization.test.ts](../../src/test/query_optimization.test.ts) | +| TC-OPT-03 | Verify indirect dependencies | Unit | Jest | [src/test/indirect_dependencies.test.ts](../../src/test/indirect_dependencies.test.ts) | +| TC-CONF-01 | schema_loader uses Config variables | Unit | Jest | [src/test/schema_config.test.ts](../../src/test/schema_config.test.ts) | +| TC-CONF-02 | constants are derived from Config | Unit | Jest | [src/test/zabbix_api_config.test.ts](../../src/test/zabbix_api_config.test.ts) | +| TC-CONF-03 | logger levels initialized from Config | Unit | Jest | [src/test/logger_config.test.ts](../../src/test/logger_config.test.ts) | +| TC-CONF-04 | apiVersion query | Unit | Jest | [src/test/misc_resolvers.test.ts](../../src/test/misc_resolvers.test.ts) | +| TC-CONF-05 | login query | Unit | Jest | [src/test/misc_resolvers.test.ts](../../src/test/misc_resolvers.test.ts) | +| TC-CONF-06 | logout query | Unit | Jest | [src/test/misc_resolvers.test.ts](../../src/test/misc_resolvers.test.ts) | +| TC-CONF-07 | Parse Zabbix Args | Unit | Jest | [src/test/zabbix_api_args_parser.test.ts](../../src/test/zabbix_api_args_parser.test.ts) | +| TC-DOCS-01 | Zabbix Docs Samples Integration | Integration | Jest | [src/test/zabbix_docs_samples.test.ts](../../src/test/zabbix_docs_samples.test.ts) | +| TC-MCP-01 | MCP Operations Validation | Integration | Jest | [src/test/mcp_operations_validation.test.ts](../../src/test/mcp_operations_validation.test.ts) | +| TC-SCHEMA-01 | DistanceTrackerDevice Query | Integration | Jest | [src/test/schema_dependent_queries.test.ts](../../src/test/schema_dependent_queries.test.ts) | +| TC-SGV-05 | storeGroupValue Integration | Integration | Jest | [src/test/store_group_value.integration.test.ts](../../src/test/store_group_value.integration.test.ts) | +| TC-SGV-06 | storeGroupValue different keys | Unit | Jest | [src/test/store_group_value.unit.test.ts](../../src/test/store_group_value.unit.test.ts) | +| TC-SGV-07 | getGroupValue unit test | Unit | Jest | [src/test/store_group_value.unit.test.ts](../../src/test/store_group_value.unit.test.ts) | +| TC-SGV-08 | getGroupValue Integration | Integration | Jest | [src/test/store_group_value.integration.test.ts](../../src/test/store_group_value.integration.test.ts) | +| TC-E2E-01 | Run complete smoketest | E2E | GraphQL / MCP | [mcp/operations/runSmoketest.graphql](../../mcp/operations/runSmoketest.graphql) | +| TC-E2E-02 | Run all regression tests | E2E | GraphQL / MCP | [mcp/operations/runAllRegressionTests.graphql](../../mcp/operations/runAllRegressionTests.graphql) | + +## 📝 Test Case Obligations + +As per project guidelines, every new feature or bug fix must be accompanied by a described test case in this specification. + +- **Feature**: A new feature must have a corresponding test case (TC) defined before implementation. +- **Bug Fix**: A bug fix must include a reproduction test case that fails without the fix and passes with it. Additionally, a permanent regression test must be added to the automated suite (e.g., `RegressionTestExecutor`) to prevent the issue from re-occurring. +- **Documentation**: The `docs/testcases/tests.md` file must be updated to reflect any changes in test coverage. +- **Categorization**: Tests must be categorized as Unit, Integration, or End-to-End (E2E). diff --git a/docs/tests.md b/docs/tests.md index 9051c41..8a4c14e 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -1,160 +1,7 @@ -# Test Specification +# Test Specification (Moved) -This document outlines the test cases and coverage for the Zabbix GraphQL API. +This document has moved. Please use the consolidated location: -## 📂 Test Categories +- [docs/testcases/tests.md](./testcases/tests.md) -- **Unit Tests**: Verify individual functions, classes, or logic in isolation. All external dependencies (Zabbix API, Config) are mocked to ensure the test is fast and deterministic. These tests are executed on each build. - - *Reference*: `src/test/host_importer.test.ts`, `src/test/template_query.test.ts` -- **Integration Tests**: Test the interaction between multiple internal components. Typically, these tests use a mock Apollo Server to execute actual GraphQL operations against the resolvers and data sources, with the Zabbix API mocked at the network layer. These tests are executed on each build. - - *Reference*: `src/test/host_integration.test.ts`, `src/test/user_rights_integration.test.ts` -- **End-to-End (E2E) Tests**: Validate complete, multi-step business workflows from start to finish (e.g., a full import-verify-cleanup cycle). These tests are executed against a real, running Zabbix instance to ensure the entire system achieves the desired business outcome. These tests are triggered after startup or on demand via GraphQL/MCP endpoints. - - *Reference*: `mcp/operations/runSmoketest.graphql` (executed via MCP) - -## 🧪 Test Case Definitions - -### Host Management -- **TC-HOST-01**: Query all hosts using sample query. -- **TC-HOST-02**: Import hosts using sample mutation. -- **TC-HOST-03**: Import host groups and create new hierarchy. -- **TC-HOST-04**: Import basic host. -- **TC-HOST-05**: Query all hosts with name pattern. -- **TC-HOST-06**: Query all devices by host ID. -- **TC-HOST-07**: Query all host groups with search pattern. -- **TC-HOST-08**: Query host groups using default search pattern. -- **TC-HOST-09**: Query locations. - -### Template Management -- **TC-TEMP-01**: Import templates using sample query and variables. -- **TC-TEMP-02**: Import and export templates comparison. -- **TC-TEMP-03**: Import and export template groups comparison. -- **TC-TEMP-04**: Query all templates. -- **TC-TEMP-05**: Filter templates by host IDs. -- **TC-TEMP-06**: Filter templates by name pattern. -- **TC-TEMP-07**: Filter templates by name pattern with wildcard. -- **TC-TEMP-08**: Import template groups (new group). -- **TC-TEMP-09**: Import template groups (existing group). -- **TC-TEMP-10**: Import basic template. -- **TC-TEMP-11**: Import templates with items, linked templates, and dependent items. -- **TC-TEMP-12**: Import templates query validation. -- **TC-TEMP-13**: Import templates error handling (data field inclusion). -- **TC-TEMP-14**: Delete templates successfully. -- **TC-TEMP-15**: Delete templates error handling. -- **TC-TEMP-16**: Delete templates by name pattern. -- **TC-TEMP-17**: Delete templates with merged IDs and name pattern. -- **TC-TEMP-18**: Delete template groups successfully. -- **TC-TEMP-19**: Delete template groups error handling. -- **TC-TEMP-20**: Delete template groups by name pattern. - -### User Rights and Permissions -- **TC-AUTH-01**: Export user rights. -- **TC-AUTH-02**: Query user permissions. -- **TC-AUTH-03**: Check if user has permissions. -- **TC-AUTH-04**: Import user rights. -- **TC-AUTH-05**: Import user rights using sample mutation. - -### History and Data Pushing -- **TC-HIST-01**: Push history data using `pushHistory` mutation. - -### Query Optimization -- **TC-OPT-01**: Verify that GraphQL queries only fetch requested fields from Zabbix (reduced output). -- **TC-OPT-02**: Verify that skippable Zabbix parameters (like selectItems) are omitted if not requested in GraphQL. -- **TC-OPT-03**: Verify that indirect dependencies (e.g., `state` requiring `items`) are correctly handled by the optimization logic. - -### System and Configuration -- **TC-CONF-01**: Schema loader uses Config variables. -- **TC-CONF-02**: Zabbix API constants derived from Config. -- **TC-CONF-03**: Logger levels initialized from Config. -- **TC-CONF-04**: API version query. -- **TC-CONF-05**: Login query. -- **TC-CONF-06**: Logout query. -- **TC-CONF-07**: Parse Zabbix arguments. - -### Documentation and MCP -- **TC-DOCS-01**: Validate all Zabbix documentation sample queries. -- **TC-MCP-01**: Validate all MCP operation files against the schema. - -### Schema-dependent Tests -- **TC-SCHEMA-01**: Verify comprehensive query for `DistanceTrackerDevice` works correctly when schema is extended. - -### End-to-End (E2E) Tests -- **TC-E2E-01**: Run a complete smoketest using MCP (creates template, group, and host, verifies, and cleans up). -- **TC-E2E-02**: Run all regression tests to verify critical system behavior and prevent known issues. - -#### Currently Contained Regression Tests -The `runAllRegressionTests` mutation (TC-E2E-02) executes the following checks: -- **Host without items**: Verifies that hosts created without any items or linked templates can be successfully queried by the system. This ensures that the hierarchical mapping and resolvers handle empty item lists gracefully. -- **Locations query argument order**: Verifies that the `locations` query correctly handles its parameters and successfully contacts the Zabbix API without session errors (verifying the fix for argument order in the resolver). -- **Template technical name lookup**: Verifies that templates can be correctly identified by their technical name (`host` field) when linking them to hosts during import. -- **HTTP Agent URL support**: Verifies that templates containing HTTP Agent items with a configured URL can be imported successfully (verifying the addition of the `url` field to `CreateTemplateItem`). -- **Host retrieval and visibility**: Verifies that newly imported hosts are immediately visible and retrievable via the `allHosts` query, including correctly delivered assigned templates and assigned host groups (verifying the fix for `output` fields in the host query data source). -- **Query Optimization**: Verifies that GraphQL requests correctly translate into optimized Zabbix parameters, reducing the amount of data fetched (verifying the query optimization feature). -- **Empty result handling**: Verifies that queries return an empty array instead of an error when no entities match the provided filters. -- **Dependent Items**: Verifies that templates with master and dependent items can be imported successfully, correctly resolving the dependency within the same import operation. -- **State sub-properties**: Verifies that requesting device state sub-properties correctly triggers the retrieval of required Zabbix items, even if `items` is not explicitly requested (verifying the indirect dependency logic). -- **Negative Optimization (allDevices)**: Verifies that items are NOT requested from Zabbix if neither `items` nor `state` (or state sub-properties) are requested within the `allDevices` query. -- **allDevices deviceType filter**: Verifies that the `allDevices` query only returns hosts that have a `deviceType` tag, and that the `deviceType` field is populated for all results. -- **pushHistory mutation**: Verifies that the `pushHistory` mutation correctly pushes data to ZABBIX_TRAP items, using either item ID or a combination of host and item key. - -## ✅ Test Coverage Checklist - -| ID | Test Case | Category | Technology | Code Link | -|:---|:---|:---|:---|:---| -| TC-HOST-01 | Query allHosts using sample | Integration | Jest | [src/test/host_integration.test.ts](../src/test/host_integration.test.ts) | -| TC-HOST-02 | Import hosts using sample | Integration | Jest | [src/test/host_integration.test.ts](../src/test/host_integration.test.ts) | -| TC-HOST-03 | importHostGroups - create new hierarchy | Unit | Jest | [src/test/host_importer.test.ts](../src/test/host_importer.test.ts) | -| TC-HOST-04 | importHosts - basic host | Unit | Jest | [src/test/host_importer.test.ts](../src/test/host_importer.test.ts) | -| TC-HOST-05 | allHosts query | Unit | Jest | [src/test/host_query.test.ts](../src/test/host_query.test.ts) | -| TC-HOST-06 | allDevices query | Unit | Jest | [src/test/host_query.test.ts](../src/test/host_query.test.ts) | -| TC-HOST-07 | allHostGroups query | Unit | Jest | [src/test/host_query.test.ts](../src/test/host_query.test.ts) | -| TC-HOST-08 | allHostGroups query - default pattern | Unit | Jest | [src/test/host_query.test.ts](../src/test/host_query.test.ts) | -| TC-HOST-09 | locations query | Unit | Jest | [src/test/host_query.test.ts](../src/test/host_query.test.ts) | -| TC-TEMP-01 | Import templates using sample | Integration | Jest | [src/test/template_integration.test.ts](../src/test/template_integration.test.ts) | -| TC-TEMP-02 | Import and Export templates comparison | Integration | Jest | [src/test/template_integration.test.ts](../src/test/template_integration.test.ts) | -| TC-TEMP-03 | Import and Export template groups comparison | Integration | Jest | [src/test/template_integration.test.ts](../src/test/template_integration.test.ts) | -| TC-TEMP-04 | templates query - returns all | Unit | Jest | [src/test/template_query.test.ts](../src/test/template_query.test.ts) | -| TC-TEMP-05 | templates query - filters by hostids | Unit | Jest | [src/test/template_query.test.ts](../src/test/template_query.test.ts) | -| TC-TEMP-06 | templates query - filters by name_pattern | Unit | Jest | [src/test/template_query.test.ts](../src/test/template_query.test.ts) | -| TC-TEMP-07 | templates query - name_pattern wildcard | Unit | Jest | [src/test/template_query.test.ts](../src/test/template_query.test.ts) | -| TC-TEMP-08 | importTemplateGroups - create new | Unit | Jest | [src/test/template_importer.test.ts](../src/test/template_importer.test.ts) | -| TC-TEMP-09 | importTemplateGroups - group exists | Unit | Jest | [src/test/template_importer.test.ts](../src/test/template_importer.test.ts) | -| TC-TEMP-10 | importTemplates - basic template | Unit | Jest | [src/test/template_importer.test.ts](../src/test/template_importer.test.ts) | -| TC-TEMP-11 | importTemplates - complex template | Unit | Jest | [src/test/template_importer.test.ts](../src/test/template_importer.test.ts) | -| TC-TEMP-12 | importTemplates - template query | Unit | Jest | [src/test/template_importer.test.ts](../src/test/template_importer.test.ts) | -| TC-TEMP-13 | importTemplates - error data field | Unit | Jest | [src/test/template_importer.test.ts](../src/test/template_importer.test.ts) | -| TC-TEMP-14 | deleteTemplates - success | Unit | Jest | [src/test/template_deleter.test.ts](../src/test/template_deleter.test.ts) | -| TC-TEMP-15 | deleteTemplates - error | Unit | Jest | [src/test/template_deleter.test.ts](../src/test/template_deleter.test.ts) | -| TC-TEMP-16 | deleteTemplates - by name_pattern | Unit | Jest | [src/test/template_deleter.test.ts](../src/test/template_deleter.test.ts) | -| TC-TEMP-17 | deleteTemplates - merged IDs | Unit | Jest | [src/test/template_deleter.test.ts](../src/test/template_deleter.test.ts) | -| TC-TEMP-18 | deleteTemplateGroups - success | Unit | Jest | [src/test/template_deleter.test.ts](../src/test/template_deleter.test.ts) | -| TC-TEMP-19 | deleteTemplateGroups - error | Unit | Jest | [src/test/template_deleter.test.ts](../src/test/template_deleter.test.ts) | -| TC-TEMP-20 | deleteTemplateGroups - by name_pattern | Unit | Jest | [src/test/template_deleter.test.ts](../src/test/template_deleter.test.ts) | -| TC-AUTH-01 | exportUserRights query | Unit | Jest | [src/test/user_rights.test.ts](../src/test/user_rights.test.ts) | -| TC-AUTH-02 | userPermissions query | Unit | Jest | [src/test/user_rights.test.ts](../src/test/user_rights.test.ts) | -| TC-AUTH-03 | hasPermissions query | Unit | Jest | [src/test/user_rights.test.ts](../src/test/user_rights.test.ts) | -| TC-AUTH-04 | importUserRights mutation | Unit | Jest | [src/test/user_rights.test.ts](../src/test/user_rights.test.ts) | -| TC-AUTH-05 | Import user rights using sample | Integration | Jest | [src/test/user_rights_integration.test.ts](../src/test/user_rights_integration.test.ts) | -| TC-OPT-01 | Verify Query Optimization (reduced output) | Unit/E2E | Jest/Regression | [src/test/query_optimization.test.ts](../src/test/query_optimization.test.ts) | -| TC-OPT-02 | Verify skippable parameters | Unit/E2E | Jest/Regression | [src/test/query_optimization.test.ts](../src/test/query_optimization.test.ts) | -| TC-OPT-03 | Verify indirect dependencies | Unit | Jest | [src/test/indirect_dependencies.test.ts](../src/test/indirect_dependencies.test.ts) | -| TC-CONF-01 | schema_loader uses Config variables | Unit | Jest | [src/test/schema_config.test.ts](../src/test/schema_config.test.ts) | -| TC-CONF-02 | constants are derived from Config | Unit | Jest | [src/test/zabbix_api_config.test.ts](../src/test/zabbix_api_config.test.ts) | -| TC-CONF-03 | logger levels initialized from Config | Unit | Jest | [src/test/logger_config.test.ts](../src/test/logger_config.test.ts) | -| TC-CONF-04 | apiVersion query | Unit | Jest | [src/test/misc_resolvers.test.ts](../src/test/misc_resolvers.test.ts) | -| TC-CONF-05 | login query | Unit | Jest | [src/test/misc_resolvers.test.ts](../src/test/misc_resolvers.test.ts) | -| TC-CONF-06 | logout query | Unit | Jest | [src/test/misc_resolvers.test.ts](../src/test/misc_resolvers.test.ts) | -| TC-CONF-07 | Parse Zabbix Args | Unit | Jest | [src/test/zabbix_api_args_parser.test.ts](../src/test/zabbix_api_args_parser.test.ts) | -| TC-DOCS-01 | Zabbix Docs Samples Integration | Integration | Jest | [src/test/zabbix_docs_samples.test.ts](../src/test/zabbix_docs_samples.test.ts) | -| TC-MCP-01 | MCP Operations Validation | Integration | Jest | [src/test/mcp_operations_validation.test.ts](../src/test/mcp_operations_validation.test.ts) | -| TC-SCHEMA-01 | DistanceTrackerDevice Query | Integration | Jest | [src/test/schema_dependent_queries.test.ts](../src/test/schema_dependent_queries.test.ts) | -| TC-E2E-01 | Run complete smoketest | E2E | GraphQL / MCP | [mcp/operations/runSmoketest.graphql](../mcp/operations/runSmoketest.graphql) | -| TC-E2E-02 | Run all regression tests | E2E | GraphQL / MCP | [mcp/operations/runAllRegressionTests.graphql](../mcp/operations/runAllRegressionTests.graphql) | - -## 📝 Test Case Obligations - -As per project guidelines, every new feature or bug fix must be accompanied by a described test case in this specification. - -- **Feature**: A new feature must have a corresponding test case (TC) defined before implementation. -- **Bug Fix**: A bug fix must include a reproduction test case that fails without the fix and passes with it. Additionally, a permanent regression test must be added to the automated suite (e.g., `RegressionTestExecutor`) to prevent the issue from re-occurring. -- **Documentation**: The `docs/tests.md` file must be updated to reflect any changes in test coverage. -- **Categorization**: Tests must be categorized as Unit, Integration, or End-to-End (E2E). +Update any bookmarks or references accordingly. diff --git a/mcp-config.yaml b/mcp-config.yaml index c900c32..f154381 100644 --- a/mcp-config.yaml +++ b/mcp-config.yaml @@ -3,6 +3,8 @@ overrides: mutation_mode: all transport: type: streamable_http + address: 0.0.0.0 + port: 3000 stateful_mode: false operations: source: local diff --git a/schema/api_commons.graphql b/schema/api_commons.graphql index ca601d1..5bd2a32 100644 --- a/schema/api_commons.graphql +++ b/schema/api_commons.graphql @@ -159,3 +159,27 @@ enum SortOrder { "Deliver values in descending order" desc } + +""" +Input for locating a specific value stored within a host group. +Used by both retrieval queries and storage mutations. +""" +input GroupValueLocator { + """ID of the target host group (either groupid or groupName is required).""" + groupid: Int + """Name of the target host group (either groupid or groupName is required).""" + groupName: String + """Name of the host to store/retrieve the value (optional). If not provided, valueType is used to find or create a storage host.""" + host: String + """ + The value for the "valueType" tag of the storage host. + Mandatory if no host is provided. Used to identify the host within the group. + """ + valueType: String + """Item ID if an existing item should be used.""" + itemid: Int + """The technical key of the item.""" + key: String! + """The visible name of the item (optional).""" + name: String +} diff --git a/schema/mutations.graphql b/schema/mutations.graphql index 6dfd3cf..20f2f7f 100644 --- a/schema/mutations.graphql +++ b/schema/mutations.graphql @@ -143,6 +143,30 @@ type Mutation { values: [HistoryPushInput!]! ): HistoryPushResponse + """ + Store JSON object (e.g. config value) and assign it to a host group by groupid or groupName. + If both groupid or groupName are unset an error will be returned and the dataset will not be stored. + + If host is provided the corresponding host will be looked up and the value will be pushed to + an item of this host with the corresponding key - if such an item does not exist it will be created, + if it exists it must be a ZABBIX_TRAP item, otherwise an error is returned. If a name is specified it will be + set as item name. + + If no host is provided the field valueType is mandatory - the hosts of the specified group will + be looked up for a host having a corresponding tag "valueType" matching to the specified value. + If multiple hosts exist with this tag and this group, an error will be thrown. + If no hosts exist with this tag and this group a new host will be created and the tag and the group will be assigned. + + Return value: If no error occurs, a hostid and an itemid will be returned. + + Authentication: Requires `zbx_session` cookie or `zabbix-auth-token` header. + """ + storeGroupValue( + """The locator for the group value.""" + locator: GroupValueLocator! + """The JSON object to store.""" + value: JSONObject!): HistoryPushData + """ Runs a smoketest: creates a template, links a host, verifies it, and cleans up. """ @@ -161,6 +185,12 @@ type Mutation { runAllRegressionTests: SmoketestResponse! } +input Tag { + tag: String!, + value: String! +} + + """ Response object for the smoketest operation. """ diff --git a/schema/queries.graphql b/schema/queries.graphql index 326fd67..d4dd3d0 100644 --- a/schema/queries.graphql +++ b/schema/queries.graphql @@ -169,5 +169,15 @@ type Query { """Wildcard name pattern for filtering template groups.""" name_pattern: String ): [HostGroup] + + """ + Retrieves the last value stored with `storeGroupValue`. + + Authentication: Requires `zbx_session` cookie or `zabbix-auth-token` header. + """ + getGroupValue( + """Parameters to locate the stored value.""" + locator: GroupValueLocator! + ): JSONObject } diff --git a/src/api/resolvers.ts b/src/api/resolvers.ts index 1f1ee53..4b91c5b 100644 --- a/src/api/resolvers.ts +++ b/src/api/resolvers.ts @@ -11,7 +11,7 @@ import { MutationImportTemplateGroupsArgs, MutationImportTemplatesArgs, MutationImportUserRightsArgs, - MutationPushHistoryArgs, + MutationPushHistoryArgs, MutationStoreGroupValueArgs, Permission, QueryAllDevicesArgs, QueryAllHostGroupsArgs, @@ -21,6 +21,7 @@ import { QueryHasPermissionsArgs, QueryTemplatesArgs, QueryUserPermissionsArgs, + QueryGetGroupValueArgs, Resolvers, StorageItemType, } from "../schema/generated/graphql.js"; @@ -34,7 +35,7 @@ import {TemplateImporter} from "../execution/template_importer.js"; import {TemplateDeleter} from "../execution/template_deleter.js"; import {HostValueExporter} from "../execution/host_exporter.js"; import {logger} from "../logging/logger.js"; -import {ParsedArgs, ZabbixRequest} from "../datasources/zabbix-request.js"; +import {isZabbixErrorResult, ParsedArgs, ZabbixRequest} from "../datasources/zabbix-request.js"; import {ZabbixHistoryPushParams, ZabbixHistoryPushRequest} from "../datasources/zabbix-history.js"; import { ZabbixCreateHostRequest, @@ -65,6 +66,12 @@ import {isDevice} from "./resolver_helpers.js"; import {ZabbixPermissionsHelper} from "../datasources/zabbix-permissions.js"; import {Config} from "../common_utils.js"; import {GraphqlParamsToNeededZabbixOutput} from "../datasources/graphql-params-to-zabbix-output.js"; +import { + ZabbixGetGroupValueRequest, + ZabbixGroupValueLocatorParams, + ZabbixStoreObjectInItemHistoryRequest, + ZabbixStoreValueInItemParams +} from "../datasources/zabbix-store-in-item-history.js"; /** @@ -187,6 +194,14 @@ export function createResolvers(): Resolvers { }: any) => { return await new ZabbixQueryTemplateGroupRequest(zabbixAuthToken, cookie) .executeRequestThrowError(zabbixAPI, new ParsedArgs(args)); + }, + + getGroupValue: async (_parent: any, args: QueryGetGroupValueArgs, { + zabbixAuthToken, + cookie + }: any) => { + return await new ZabbixGetGroupValueRequest(zabbixAuthToken, cookie) + .executeRequestThrowError(zabbixAPI, new ZabbixGroupValueLocatorParams(args)); } }, Mutation: { @@ -268,7 +283,15 @@ export function createResolvers(): Resolvers { error: Array.isArray(d.error) ? {message: d.error.join(", ")} : d.error })) } + }, storeGroupValue: async (_parent: any, args: MutationStoreGroupValueArgs, { + zabbixAuthToken, + cookie + }: any) => { + const request = new ZabbixStoreObjectInItemHistoryRequest(zabbixAuthToken, cookie) + const result = await request.executeRequestReturnError(zabbixAPI, new ZabbixStoreValueInItemParams(args)) + return isZabbixErrorResult(result) ? { error: result.error } : { itemid: String(request.itemid ?? "") } }, + deleteTemplates: async (_parent: any, args: MutationDeleteTemplatesArgs, { zabbixAuthToken, cookie diff --git a/src/common_utils.ts b/src/common_utils.ts index 997027a..220bf8d 100644 --- a/src/common_utils.ts +++ b/src/common_utils.ts @@ -20,4 +20,13 @@ static readonly DRY_RUN = process.env.DRY_RUN static readonly VERBOSITY_RESPONSES = process.env.VERBOSITY_RESPONSES ? (parseInt(process.env.VERBOSITY_RESPONSES) || (process.env.VERBOSITY_RESPONSES === 'true' ? 1 : 0)) : 0 static readonly HOST_TYPE_FILTER_DEFAULT = process.env.HOST_TYPE_FILTER_DEFAULT; static readonly HOST_GROUP_FILTER_DEFAULT = process.env.HOST_GROUP_FILTER_DEFAULT; +} + +export function sleep(ms: number): { promise: Promise, cancel: () => void } { + let timeoutId: NodeJS.Timeout; + const promise = new Promise((resolve) => { + timeoutId = setTimeout(resolve, ms); + }); + const cancel = () => clearTimeout(timeoutId); + return { promise, cancel }; } \ No newline at end of file diff --git a/src/datasources/zabbix-api.ts b/src/datasources/zabbix-api.ts index 4d515fa..c9b4f8c 100644 --- a/src/datasources/zabbix-api.ts +++ b/src/datasources/zabbix-api.ts @@ -128,7 +128,7 @@ export class ZabbixAPI * @param output - The list of fields to return. * @returns A promise that resolves to the result or an error result. */ - async requestByPath(path: string, args?: A, authToken?: string | null, cookies?: string, throwApiError: boolean = true, output?: string[]) { + async requestByPath(path: string, args?: A, authToken?: string | null, cookies?: string | null, throwApiError: boolean = true, output?: string[]) { return this.executeRequest(new ZabbixRequest(path, authToken, cookies), args, throwApiError, output); } @@ -139,7 +139,7 @@ export class ZabbixAPI * @param cookies - Optional session cookies. * @returns A promise that resolves to an array of location objects. */ - async getLocations(args?: ParsedArgs, authToken?: string, cookies?: string) { + async getLocations(args?: ParsedArgs, authToken?: string | null, cookies?: string | null) { const hosts_promise = this.requestByPath("host.get", args, authToken, cookies); return hosts_promise.then(response => { // @ts-ignore diff --git a/src/datasources/zabbix-hostgroups.ts b/src/datasources/zabbix-hostgroups.ts index 9960577..80d7716 100644 --- a/src/datasources/zabbix-hostgroups.ts +++ b/src/datasources/zabbix-hostgroups.ts @@ -120,7 +120,7 @@ export class GroupHelper { * @param cookie - Optional session cookie. * @returns A promise that resolves to an array of host group IDs. */ - public static async findHostGroupIdsByName(groupNames: string[], zabbixApi: ZabbixAPI, zabbixAuthToken?: string, cookie?: string) { + public static async findHostGroupIdsByName(groupNames: string[], zabbixApi: ZabbixAPI, zabbixAuthToken?: string | null, cookie?: string | null) { let result: number[] = [] for (let groupName of groupNames) { let queryGroupsArgs = new ZabbixQueryHostgroupsParams({ diff --git a/src/datasources/zabbix-hosts.ts b/src/datasources/zabbix-hosts.ts index 06a7284..1aa6b6b 100644 --- a/src/datasources/zabbix-hosts.ts +++ b/src/datasources/zabbix-hosts.ts @@ -337,7 +337,7 @@ export class ZabbixCreateHostRequest extends ZabbixRequest { * @param authToken - Optional Zabbix authentication token. * @param cookie - Optional session cookie. */ - constructor(authToken?: string | null, cookie?: string) { + constructor(authToken?: string | null, cookie?: string | null) { super("host.create", authToken, cookie); } diff --git a/src/datasources/zabbix-request.ts b/src/datasources/zabbix-request.ts index f4f815f..449c666 100644 --- a/src/datasources/zabbix-request.ts +++ b/src/datasources/zabbix-request.ts @@ -40,26 +40,30 @@ export interface ZabbixWithTagsParams extends ZabbixParams { export class ParsedArgs { public name_pattern?: string public distinct_by_name?: boolean; - public zabbix_params: ZabbixParams[] | ZabbixParams + protected _zabbix_params: ZabbixParams[] | ZabbixParams /** * @param params - The raw parameters to parse. */ constructor(params?: any) { if (Array.isArray(params)) { - this.zabbix_params = params.map(arg => this.parseArgObject(arg)) + this._zabbix_params = params.map(arg => this.parseArgObject(arg)) } else { - this.zabbix_params = this.parseArgObject(params) + this._zabbix_params = this.parseArgObject(params) } } + get zabbix_params(): ZabbixParams[] | ZabbixParams { + return this._zabbix_params; + } + /** * Retrieves a parameter value by name. * @param paramName - The name of the parameter to retrieve. * @returns The parameter value or undefined. */ getParam(paramName: string): any { - if (this.zabbix_params instanceof Array) { + if (this._zabbix_params instanceof Array) { return undefined } // @ts-ignore diff --git a/src/datasources/zabbix-store-in-item-history.ts b/src/datasources/zabbix-store-in-item-history.ts new file mode 100644 index 0000000..d1c6e85 --- /dev/null +++ b/src/datasources/zabbix-store-in-item-history.ts @@ -0,0 +1,371 @@ +import {isZabbixErrorResult, ParsedArgs, ZabbixErrorResult, ZabbixParams, ZabbixRequest} from "./zabbix-request.js"; +import {ApiErrorCode, DeviceCommunicationType, StorageItemType} from "../model/model_enum_values.js"; +import {ZabbixHistoryGetParams, ZabbixHistoryPushResult, ZabbixQueryHistoryRequest} from "./zabbix-history.js"; +import {zabbixAPI, ZabbixAPI} from "./zabbix-api.js"; +import {ZabbixForceCacheReloadRequest} from "./zabbix-script.js"; +import {logger} from "../logging/logger.js"; +import {sleep} from "../common_utils.js"; +import { + GroupValueLocator, + MutationStoreGroupValueArgs, + QueryGetGroupValueArgs, + SortOrder +} from "../schema/generated/graphql.js"; +import {ZabbixCreateHostRequest, ZabbixQueryHostsMetaRequest} from "./zabbix-hosts.js"; +import {GroupHelper} from "./zabbix-hostgroups.js"; +import {ZabbixQueryItemRequest} from "./zabbix-templates.js"; + +export class ZabbixGroupValueLocatorParams extends ParsedArgs { + constructor(params: { locator: GroupValueLocator }) { + super(params); + } + + get locator(): GroupValueLocator { + return (this._zabbix_params as { locator: GroupValueLocator }).locator; + } +} + +export class ZabbixStoreValueInItemParams extends ZabbixGroupValueLocatorParams { + constructor(params: MutationStoreGroupValueArgs) { + super(params); + } + + get value(): any { + return (this._zabbix_params as MutationStoreGroupValueArgs).value; + } +} + +const isUpdateValueInItemParams = (locator: GroupValueLocator): boolean => + !!locator.itemid; + + +export class ZabbixCreateOrUpdateStorageItemRequest extends ZabbixRequest< + { + "itemids": string[], + "hostids"?: string[] + }, ZabbixStoreValueInItemParams> { + static MAX_ZABBIX_ITEM_STORAGE_PERIOD = "9125d"; // Maximum possible value is 25 years, which corresponds to 9125 days + + hostid: string | undefined + private createdHostids: string[] = []; + private itemid: string | undefined; + + async prepare(zabbixAPI: ZabbixAPI, _args?: ZabbixStoreValueInItemParams): Promise { + let locator = _args?.locator; + if (!locator) { + return { + error: { + message: "Missing locator in request" + } + }; + } + if (!isUpdateValueInItemParams(locator) && !locator.host) { + if (!locator.valueType) { + return { + error: { + message: "valueType in request is mandatory if itemid and host are not present" + } + }; + } + let groupid = 0; + if (locator.groupid) { + groupid = locator.groupid; + } else if (locator.groupName) { + let groups = await GroupHelper.findHostGroupIdsByName([locator.groupName], zabbixAPI, this.authToken, this.cookie) + if (groups?.length) { + groupid = groups[0] + } else { + return { + error: { + message: "Unable to find group=" + locator.groupName + } + }; + } + + } else { + return { + error: { + message: "If groupid is empty groupName must be present in request" + } + }; + } + let hosts = await new ZabbixQueryHostsMetaRequest(this.authToken, this.cookie).executeRequestReturnError(zabbixAPI, + new ParsedArgs({ + groupids: groupid, + tags: [ + {tag: "valueType", value: locator.valueType, operator: 1} + ] + })); + if (!isZabbixErrorResult(hosts) && hosts && hosts.length <= 1) { + let hostid: string; + if (hosts.length == 0) { + let createHostResult = await new ZabbixCreateHostRequest(this.authToken, this.cookie) + .executeRequestThrowError( + zabbixAPI, + new ParsedArgs({ + host: locator.valueType + "-store-" + groupid, + hostgroupids: [groupid], + tags: [ + {tag: "valueType", value: locator.valueType} + ] + }) + ) + if (isZabbixErrorResult(createHostResult)) { + return { + error: { + message: "Unable to create host for storing value in item", + code: ApiErrorCode.ZABBIX_HOST_NOT_FOUND, + path: this.path, + data: createHostResult, + } + } + } + const hostids = (createHostResult.hostids || []).filter((id): id is number => id !== null && id !== undefined).map(id => id.toString()); + this.createdHostids = hostids; + hostid = hostids[0]; + } else { + hostid = hosts[0].hostid!; + } + + this.hostid = hostid; + + // Now check if item already exists on this host with this key + const items = await new ZabbixQueryItemRequest(this.authToken || null, this.cookie || null).executeRequestReturnError(zabbixAPI, + new ParsedArgs({ + hostids: hostid, + filter_key_: locator.key + })); + + if (!isZabbixErrorResult(items) && items && items.length > 0) { + this.itemid = items[0].itemid; + this.path = "item.update"; + // @ts-ignore + this.requestBodyTemplate.method = "item.update"; + } + } else { + return { + error: { + message: "Request for retrieving host for storing value in item was expected to deliver exactly one or no host.", + code: ApiErrorCode.ZABBIX_HOST_NOT_FOUND, + path: this.path, + data: hosts, + } + } + } + return super.prepare(zabbixAPI, _args); + } + } + + async executeRequestReturnError(zabbixAPI: ZabbixAPI, args?: ZabbixStoreValueInItemParams, output?: string[]): Promise { + const result = await super.executeRequestReturnError(zabbixAPI, args, output); + if (!isZabbixErrorResult(result)) { + if (this.createdHostids.length > 0) { + result.hostids = this.createdHostids; + } else if (this.hostid) { + result.hostids = [this.hostid]; + } + } + return result; + } + + createZabbixParams(args?: ZabbixStoreValueInItemParams): ZabbixParams { + if (args?.locator) { + let createOrUpdateItemParams = { + key_: args.locator.key, + name: args.locator.name || args.locator.key, + "type": DeviceCommunicationType.ZABBIX_TRAP.valueOf(), + "history": ZabbixCreateOrUpdateStorageItemRequest.MAX_ZABBIX_ITEM_STORAGE_PERIOD, + "value_type": StorageItemType.Text.valueOf() + } + + if (this.itemid) { + return { + itemid: this.itemid, + ...createOrUpdateItemParams + } + } + + // When update path is selected by caller via args.locator.itemid, ensure we pass itemid + if (isUpdateValueInItemParams(args.locator)) { + return { + itemid: String(args.locator.itemid), + ...createOrUpdateItemParams + } + } + + return { + hostid: this.hostid, + ...createOrUpdateItemParams + } + + } + + return {}; + + } + +} + +export class ZabbixStoreObjectInItemHistoryRequest extends ZabbixRequest { + // After creating an item or host zabbix needs some time before the created object can be referenced in other + // operations - the reason is the config-cache. In case of having ZBX_CACHEUPDATEFREQUENCY=1 (seconds) set within the + // Zabbix - config the delay of 1 second will be sufficient + private static readonly ZABBIX_DELAY_UNTIL_CONFIG_CHANGED: number = 0 + public itemid: number | undefined + public hostid: number | undefined + + constructor(authToken?: string | null, cookie?: string) { + super("history.push.jsonobject", authToken, cookie); + } + + async prepare(zabbixAPI: ZabbixAPI, args?: ZabbixStoreValueInItemParams): Promise { + // Create or update zabbix Item + this.itemid = args?.locator.itemid ?? undefined; + let timeoutForValueUpdate = this.itemid ? 0 : ZabbixStoreObjectInItemHistoryRequest.ZABBIX_DELAY_UNTIL_CONFIG_CHANGED; + + // Create or update item + let result: { + "itemids": string[], + "hostids"?: string[] + } | undefined = await new ZabbixCreateOrUpdateStorageItemRequest( + this.itemid ? "item.update.storeiteminhistory" : "item.create.storeiteminhistory", + this.authToken, this.cookie).executeRequestThrowError(zabbixAPI, args) + + if (result && result.hasOwnProperty("itemids") && result.itemids.length > 0) { + const newItemid = Number(result.itemids[0]); + if (!isNaN(newItemid)) { + this.itemid = newItemid; + } + if (result.hostids && result.hostids.length > 0) { + const newHostid = Number(result.hostids[0]); + if (!isNaN(newHostid)) { + this.hostid = newHostid; + } + } + let scriptExecResult = + await new ZabbixForceCacheReloadRequest(this.authToken, this.cookie).executeRequestThrowError(zabbixAPI) + if (scriptExecResult.response != "success") { + logger.error(`cache reload not successful: ${scriptExecResult.value}`) + } + await sleep(timeoutForValueUpdate).promise + } + + if (!this.itemid) { + this.prepResult = { + error: { + message: "Unable to create/update item", + code: ApiErrorCode.ZABBIX_NO_ITEM_PUSH_ITEM, + path: this.path, + args: args, + } + } + } + } + + createZabbixParams(args?: ZabbixStoreValueInItemParams): ZabbixParams { + return { + itemid: this.itemid, + value: JSON.stringify(args?.value) + } + } + +} + +export class GroupValueHelper { + public static async findStorageItem(locator: GroupValueLocator, zabbixAPI: ZabbixAPI, authToken?: string | null, cookie?: string | null): Promise<{ hostid?: string, itemid?: string } | ZabbixErrorResult> { + let hostid: string | undefined; + let itemid: string | undefined = locator.itemid?.toString(); + + if (itemid) return { itemid }; + + if (locator.host) { + const hosts = await new ZabbixQueryHostsMetaRequest(authToken, cookie).executeRequestReturnError(zabbixAPI, + new ParsedArgs({ filter_host: locator.host })); + if (isZabbixErrorResult(hosts)) return hosts; + if (hosts?.length) { + hostid = hosts[0].hostid; + } + } else if (locator.valueType) { + let groupid = locator.groupid; + if (!groupid && locator.groupName) { + let groups = await GroupHelper.findHostGroupIdsByName([locator.groupName], zabbixAPI, authToken, cookie) + if (groups?.length) { + groupid = groups[0] + } else { + return { error: { message: "Unable to find group=" + locator.groupName } }; + } + } + if (groupid) { + let hosts = await new ZabbixQueryHostsMetaRequest(authToken, cookie).executeRequestReturnError(zabbixAPI, + new ParsedArgs({ + groupids: groupid, + tags: [{tag: "valueType", value: locator.valueType, operator: 1}] + })); + if (isZabbixErrorResult(hosts)) return hosts; + if (hosts?.length) { + hostid = hosts[0].hostid; + } + } else { + return { error: { message: "Missing groupid or groupName" } }; + } + } + + if (hostid && !itemid) { + const items = await new ZabbixQueryItemRequest(authToken, cookie).executeRequestReturnError(zabbixAPI, + new ParsedArgs({ + hostids: hostid, + filter_key_: locator.key + })); + if (isZabbixErrorResult(items)) return items; + if (items?.length) { + itemid = items[0].itemid; + } + } + + return { hostid, itemid }; + } +} + +export class ZabbixGetGroupValueRequest extends ZabbixRequest { + constructor(authToken?: string | null, cookie?: string) { + super("history.get", authToken, cookie); + } + + async executeRequestReturnError(zabbixAPI: ZabbixAPI, args?: ZabbixGroupValueLocatorParams): Promise { + const locator = args?.locator; + if (!locator) return { error: { message: "Missing locator" } }; + + const lookupResult = await GroupValueHelper.findStorageItem(locator, zabbixAPI, this.authToken, this.cookie); + if (isZabbixErrorResult(lookupResult)) return lookupResult; + + const itemid = lookupResult.itemid; + if (!itemid) return null; + + const history = await new ZabbixQueryHistoryRequest(this.authToken, this.cookie).executeRequestReturnError(zabbixAPI, new ZabbixHistoryGetParams( + [Number(itemid)], + ["value"], + 1, + StorageItemType.Text, + undefined, + undefined, + ["clock"], + SortOrder.Desc + )); + + if (!isZabbixErrorResult(history) && history?.length) { + try { + return JSON.parse(history[0].value); + } catch (e) { + return history[0].value; + } + } + + return null; + } +} \ No newline at end of file diff --git a/src/execution/regression_test_executor.ts b/src/execution/regression_test_executor.ts index efb662f..aa51db8 100644 --- a/src/execution/regression_test_executor.ts +++ b/src/execution/regression_test_executor.ts @@ -14,6 +14,12 @@ import { import {ZabbixQueryTemplatesRequest} from "../datasources/zabbix-templates.js"; import {isZabbixErrorResult, ParsedArgs, ZabbixRequest} from "../datasources/zabbix-request.js"; import {ZabbixHistoryPushParams, ZabbixHistoryPushRequest} from "../datasources/zabbix-history.js"; +import { + ZabbixGetGroupValueRequest, + ZabbixGroupValueLocatorParams, + ZabbixStoreObjectInItemHistoryRequest, + ZabbixStoreValueInItemParams +} from "../datasources/zabbix-store-in-item-history.js"; /** * Handles the execution of regression tests to ensure bug fixes remain effective. @@ -45,6 +51,9 @@ export class RegressionTestExecutor { const devHostNameWithoutTag = "REG_DEV_WITHOUT_TAG_" + Math.random().toString(36).substring(7); const pushHostName = "REG_PUSH_HOST_" + Math.random().toString(36).substring(7); + const hostGroupsToCleanup: string[] = []; + const templateGroupsToCleanup: string[] = []; + try { // Regression 1: Locations query argument order // This verifies the fix where getLocations was called with (authToken, args) instead of (args, authToken) @@ -70,10 +79,14 @@ export class RegressionTestExecutor { const hostGroupName = "Roadwork/Devices"; // Assure template group exists - await TemplateImporter.importTemplateGroups([{ + const regGroupResult = await TemplateImporter.importTemplateGroups([{ groupName: regGroupName }], zabbixAuthToken, cookie); + if (regGroupResult?.length && !regGroupResult[0].message) { + templateGroupsToCleanup.push(regGroupName); + } + const tempResult = await TemplateImporter.importTemplates([{ host: regTemplateName, name: "Regression Test Template " + regTemplateName, @@ -593,11 +606,140 @@ export class RegressionTestExecutor { }); if (!pushSuccess) success = false; + // Regression 14: storeGroupValue mutation + let storeSuccess = false; + let storeGroupName = "REG_STORE_GROUP_" + Math.random().toString(36).substring(7); + let itemid1_dbg: any = null; + let itemid2_dbg: any = null; + try { + const storeValueType = "RegStoreType"; + const storeKey = "reg.store.key"; + const storeValue = { status: "ok", timestamp: Date.now() }; + + // 1. Create group + const storeGroupResult = await HostImporter.importHostGroups([{ groupName: storeGroupName }], zabbixAuthToken, cookie); + if (storeGroupResult?.length && !storeGroupResult[0].message) { + hostGroupsToCleanup.push(storeGroupName); + } + + // 2. Store value (should create host and item) + const storeRequest1 = new ZabbixStoreObjectInItemHistoryRequest(zabbixAuthToken, cookie); + const storeResult1 = await storeRequest1.executeRequestReturnError(zabbixAPI, new ZabbixStoreValueInItemParams({ + locator: { + groupName: storeGroupName, + valueType: storeValueType, + key: storeKey, + }, + value: storeValue + })); + + if (isZabbixErrorResult(storeResult1)) { + console.error("REG-STORE: Step 1 failed with Zabbix error: " + JSON.stringify(storeResult1)); + } + + if (!isZabbixErrorResult(storeResult1)) { + const itemid1 = storeRequest1.itemid; + itemid1_dbg = itemid1; + const hostid1 = storeRequest1.hostid; + + // 3. Store again (should update existing item) + const storeRequest2 = new ZabbixStoreObjectInItemHistoryRequest(zabbixAuthToken, cookie); + const storeResult2 = await storeRequest2.executeRequestReturnError(zabbixAPI, new ZabbixStoreValueInItemParams({ + locator: { + groupName: storeGroupName, + valueType: storeValueType, + key: storeKey, + itemid: itemid1 + }, + value: { ...storeValue, updated: true }, + })); + + if (isZabbixErrorResult(storeResult2)) { + console.error("REG-STORE: Step 2 failed with Zabbix error: " + JSON.stringify(storeResult2)); + } + + if (!isZabbixErrorResult(storeResult2)) { + const itemid2 = storeRequest2.itemid; + itemid2_dbg = itemid2; + storeSuccess = (itemid1?.toString() === itemid2?.toString() && !!itemid1); + + if (storeSuccess) { + // 4. Store different key (should create new item on same host) + const storeKey2 = "reg.store.key.2"; + const storeRequest3 = new ZabbixStoreObjectInItemHistoryRequest(zabbixAuthToken, cookie); + const storeResult3 = await storeRequest3.executeRequestReturnError(zabbixAPI, new ZabbixStoreValueInItemParams({ + locator: { + groupName: storeGroupName, + valueType: storeValueType, + key: storeKey2, + }, + value: { another: "value" } + })); + + if (!isZabbixErrorResult(storeResult3)) { + const itemid3 = storeRequest3.itemid; + const hostid3 = storeRequest3.hostid; + // Verify itemid3 is different from itemid1, but hostid is the same + const idsDifferent = itemid3?.toString() !== itemid1?.toString(); + const hostSame = hostid3?.toString() === hostid1?.toString(); + if (!idsDifferent || !hostSame) { + storeSuccess = false; + console.error(`REG-STORE: Step 4 failed. idsDifferent=${idsDifferent} (itemid1=${itemid1}, itemid3=${itemid3}), hostSame=${hostSame} (hostid1=${hostid1}, hostid3=${hostid3})`); + } else { + // 5. Retrieve value (getGroupValue) + const getRequest = new ZabbixGetGroupValueRequest(zabbixAuthToken, cookie); + const getResult = await getRequest.executeRequestReturnError(zabbixAPI, new ZabbixGroupValueLocatorParams({ + locator: { + groupName: storeGroupName, + valueType: storeValueType, + key: storeKey + } + })); + + if (isZabbixErrorResult(getResult)) { + storeSuccess = false; + console.error("REG-STORE: Step 5 failed with Zabbix error: " + JSON.stringify(getResult)); + } else { + // Verify retrieved value matches Step 3 updated value + const expectedValue = { ...storeValue, updated: true }; + if (JSON.stringify(getResult) !== JSON.stringify(expectedValue)) { + storeSuccess = false; + console.error(`REG-STORE: Step 5 failed. Retrieved value mismatch. Expected=${JSON.stringify(expectedValue)}, Actual=${JSON.stringify(getResult)}`); + } + } + } + } else { + storeSuccess = false; + console.error("REG-STORE: Step 4 failed with Zabbix error: " + JSON.stringify(storeResult3)); + } + } + } + + // Cleanup storage host + if (hostid1) { + await HostDeleter.deleteHosts([hostid1], null, zabbixAuthToken, cookie); + } + } + } catch (e: any) { + console.error("REG-STORE failed: " + (e.stack || e)); + } + + steps.push({ + name: "REG-STORE: storeGroupValue mutation", + success: storeSuccess, + message: storeSuccess ? "Successfully stored and updated group value" : `Failed to store/update group value correctly (itemid1=${itemid1_dbg}, itemid2=${itemid2_dbg})` + }); + if (!storeSuccess) success = false; + // Step 1: Create Host Group (Legacy test kept for compatibility) const groupResult = await HostImporter.importHostGroups([{ groupName: groupName }], zabbixAuthToken, cookie); + if (groupResult?.length && !groupResult[0].message) { + hostGroupsToCleanup.push(groupName); + } + const groupSuccess = !!groupResult?.length && !groupResult[0].error; steps.push({ name: "Create Host Group", @@ -630,21 +772,32 @@ export class RegressionTestExecutor { message: error.message || String(error) }); } finally { - // Cleanup + // Cleanup hosts await HostDeleter.deleteHosts(null, hostName, zabbixAuthToken, cookie); await HostDeleter.deleteHosts(null, macroHostName, zabbixAuthToken, cookie); await HostDeleter.deleteHosts(null, metaHostName, zabbixAuthToken, cookie); await HostDeleter.deleteHosts(null, devHostNameWithTag, zabbixAuthToken, cookie); await HostDeleter.deleteHosts(null, devHostNameWithoutTag, zabbixAuthToken, cookie); await HostDeleter.deleteHosts(null, pushHostName, zabbixAuthToken, cookie); + await HostDeleter.deleteHosts(null, stateHostName, zabbixAuthToken, cookie); + + // Cleanup templates await TemplateDeleter.deleteTemplates(null, regTemplateName, zabbixAuthToken, cookie); await TemplateDeleter.deleteTemplates(null, httpTempName, zabbixAuthToken, cookie); await TemplateDeleter.deleteTemplates(null, macroTemplateName, zabbixAuthToken, cookie); await TemplateDeleter.deleteTemplates(null, metaTempName, zabbixAuthToken, cookie); await TemplateDeleter.deleteTemplates(null, depTempName, zabbixAuthToken, cookie); await TemplateDeleter.deleteTemplates(null, stateTempName, zabbixAuthToken, cookie); - await HostDeleter.deleteHosts(null, stateHostName, zabbixAuthToken, cookie); - // We don't delete the group here as it might be shared or used by other tests in this run + + // Cleanup host groups created during this run (only those we created) + for (const g of hostGroupsToCleanup) { + await HostDeleter.deleteHostGroups(null, g, zabbixAuthToken, cookie); + } + + // Cleanup template groups created during this run (only those we created) + for (const tg of templateGroupsToCleanup) { + await TemplateDeleter.deleteTemplateGroups(null, tg, zabbixAuthToken, cookie); + } } return { diff --git a/src/schema/generated/graphql.ts b/src/schema/generated/graphql.ts index e2ccc3d..0593940 100644 --- a/src/schema/generated/graphql.ts +++ b/src/schema/generated/graphql.ts @@ -400,6 +400,30 @@ export interface GpsPosition { longitude?: Maybe; } +/** + * Input for locating a specific value stored within a host group. + * Used by both retrieval queries and storage mutations. + */ +export interface GroupValueLocator { + /** Name of the target host group (either groupid or groupName is required). */ + groupName?: InputMaybe; + /** ID of the target host group (either groupid or groupName is required). */ + groupid?: InputMaybe; + /** Name of the host to store/retrieve the value (optional). If not provided, valueType is used to find or create a storage host. */ + host?: InputMaybe; + /** Item ID if an existing item should be used. */ + itemid?: InputMaybe; + /** The technical key of the item. */ + key: Scalars['String']['input']; + /** The visible name of the item (optional). */ + name?: InputMaybe; + /** + * The value for the "valueType" tag of the storage host. + * Mandatory if no host is provided. Used to identify the host within the group. + */ + valueType?: InputMaybe; +} + /** Detailed result for a single pushed value. */ export interface HistoryPushData { __typename?: 'HistoryPushData'; @@ -614,6 +638,25 @@ export interface Mutation { runAllRegressionTests: SmoketestResponse; /** Runs a smoketest: creates a template, links a host, verifies it, and cleans up. */ runSmoketest: SmoketestResponse; + /** + * Store JSON object (e.g. config value) and assign it to a host group by groupid or groupName. + * If both groupid or groupName are unset an error will be returned and the dataset will not be stored. + * + * If host is provided the corresponding host will be looked up and the value will be pushed to + * an item of this host with the corresponding key - if such an item does not exist it will be created, + * if it exists it must be a ZABBIX_TRAP item, otherwise an error is returned. If a name is specified it will be + * set as item name. + * + * If no host is provided the field valueType is mandatory - the hosts of the specified group will + * be looked up for a host having a corresponding tag "valueType" matching to the specified value. + * If multiple hosts exist with this tag and this group, an error will be thrown. + * If no hosts exist with this tag and this group a new host will be created and the tag and the group will be assigned. + * + * Return value: If no error occurs, a hostid and an itemid will be returned. + * + * Authentication: Requires `zbx_session` cookie or `zabbix-auth-token` header. + */ + storeGroupValue?: Maybe; } @@ -690,6 +733,12 @@ export interface MutationRunSmoketestArgs { templateName: Scalars['String']['input']; } + +export interface MutationStoreGroupValueArgs { + locator: GroupValueLocator; + value: Scalars['JSONObject']['input']; +} + /** Operational data common to most devices. */ export interface OperationalDeviceData { __typename?: 'OperationalDeviceData'; @@ -752,6 +801,12 @@ export interface Query { exportHostValueHistory?: Maybe; /** Exports user rights (roles and groups). */ exportUserRights?: Maybe; + /** + * Retrieves the last value stored with `storeGroupValue`. + * + * Authentication: Requires `zbx_session` cookie or `zabbix-auth-token` header. + */ + getGroupValue?: Maybe; /** Checks if the current user has the requested permissions. */ hasPermissions?: Maybe; /** @@ -830,6 +885,11 @@ export interface QueryExportUserRightsArgs { } +export interface QueryGetGroupValueArgs { + locator: GroupValueLocator; +} + + export interface QueryHasPermissionsArgs { permissions: Array; } @@ -889,6 +949,11 @@ export enum SortOrder { export { StorageItemType }; +export interface Tag { + tag: Scalars['String']['input']; + value: Scalars['String']['input']; +} + /** Represents a Zabbix template. */ export interface Template { __typename?: 'Template'; @@ -1301,6 +1366,7 @@ export type ResolversTypes = { GenericDeviceState: ResolverTypeWrapper; GenericResponse: ResolverTypeWrapper; GpsPosition: ResolverTypeWrapper['GpsPosition']>; + GroupValueLocator: GroupValueLocator; HistoryPushData: ResolverTypeWrapper; HistoryPushInput: HistoryPushInput; HistoryPushResponse: ResolverTypeWrapper; @@ -1326,6 +1392,7 @@ export type ResolversTypes = { SortOrder: SortOrder; StorageItemType: StorageItemType; String: ResolverTypeWrapper; + Tag: Tag; Template: ResolverTypeWrapper & { items?: Maybe> }>; Time: ResolverTypeWrapper; UserGroup: ResolverTypeWrapper; @@ -1380,6 +1447,7 @@ export type ResolversParentTypes = { GenericDeviceState: GenericDeviceState; GenericResponse: GenericResponse; GpsPosition: ResolversInterfaceTypes['GpsPosition']; + GroupValueLocator: GroupValueLocator; HistoryPushData: HistoryPushData; HistoryPushInput: HistoryPushInput; HistoryPushResponse: HistoryPushResponse; @@ -1402,6 +1470,7 @@ export type ResolversParentTypes = { SmoketestResponse: SmoketestResponse; SmoketestStep: SmoketestStep; String: Scalars['String']['output']; + Tag: Tag; Template: Omit & { items?: Maybe> }; Time: Scalars['Time']['output']; UserGroup: UserGroup; @@ -1655,6 +1724,7 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; runAllRegressionTests?: Resolver; runSmoketest?: Resolver>; + storeGroupValue?: Resolver, ParentType, ContextType, RequireFields>; }; export type OperationalDeviceDataResolvers = { @@ -1677,6 +1747,7 @@ export type QueryResolvers; exportHostValueHistory?: Resolver, ParentType, ContextType, RequireFields>; exportUserRights?: Resolver, ParentType, ContextType, RequireFields>; + getGroupValue?: Resolver, ParentType, ContextType, RequireFields>; hasPermissions?: Resolver, ParentType, ContextType, RequireFields>; locations?: Resolver>>, ParentType, ContextType, RequireFields>; login?: Resolver, ParentType, ContextType, RequireFields>; diff --git a/src/test/store_group_value.integration.test.ts b/src/test/store_group_value.integration.test.ts new file mode 100644 index 0000000..944bce7 --- /dev/null +++ b/src/test/store_group_value.integration.test.ts @@ -0,0 +1,119 @@ +import {ApolloServer} from '@apollo/server'; +import {schema_loader} from '../api/schema.js'; +import {readFileSync} from 'fs'; +import {join} from 'path'; +import {zabbixAPI} from '../datasources/zabbix-api.js'; + +// Mocking ZabbixAPI.post +jest.mock("../datasources/zabbix-api.js", () => ({ + zabbixAPI: { + post: jest.fn(), + getVersion: jest.fn().mockResolvedValue("7.0.0"), + executeRequest: jest.fn(), + baseURL: 'http://localhost/zabbix', + requestByPath: jest.fn() + } +})); + +describe("storeGroupValue Integration Tests", () => { + let server: ApolloServer; + + beforeAll(async () => { + const schema = await schema_loader(); + server = new ApolloServer({ + schema, + }); + }); + + test("Store group value using sample mutation", async () => { + const queryFile = readFileSync(join(process.cwd(), 'docs', 'queries', 'sample_store_group_value_mutation.graphql'), 'utf-8'); + + const variables = { + locator: { + groupName: "Infrastructure/Configurations", + valueType: "GlobalSettings", + key: "api.config.json", + }, + value: { + maintenanceMode: false, + logLevel: "DEBUG" + } + }; + + // Mock Zabbix API sequence for storeGroupValue + (zabbixAPI.post as jest.Mock) + .mockResolvedValueOnce([{ groupid: "777", name: "Infrastructure/Configurations" }]) // group.get (GroupHelper) + .mockResolvedValueOnce([]) // host.get (ZabbixQueryHostsMetaRequest) + .mockResolvedValueOnce({ hostids: ["7777"] }) // host.create (ZabbixCreateHostRequest) + .mockResolvedValueOnce([]) // item.get (ZabbixQueryItemRequest - check if exists) + .mockResolvedValueOnce({ itemids: ["9999"] }) // item.create.storeiteminhistory + .mockResolvedValueOnce([{ hostid: "7777" }]) // host.get (ZabbixForceCacheReloadRequest - find some host) + .mockResolvedValueOnce([]) // script.get (force cache reload) + .mockResolvedValueOnce({ scriptids: ["42"] }) // script.create + .mockResolvedValueOnce({ response: "success", value: "OK" }) // script.execute + .mockResolvedValueOnce({ response: "success", data: [{ itemid: "9999" }] }); // history.push.jsonobject + + const response = await server.executeOperation({ + query: queryFile, + variables: variables, + }, { + contextValue: { zabbixAuthToken: 'test-token', dataSources: { zabbixAPI: zabbixAPI } } + }); + + expect(zabbixAPI.post).toHaveBeenCalledWith( + "host.create", + expect.objectContaining({ + body: expect.objectContaining({ + params: expect.objectContaining({ + groups: expect.arrayContaining([ + expect.objectContaining({ groupid: 777 }) + ]) + }) + }) + }) + ); + + expect(response.body.kind).toBe('single'); + // @ts-ignore + const result = response.body.singleResult; + expect(result.errors).toBeUndefined(); + expect(result.data.storeGroupValue).toBeDefined(); + expect(result.data.storeGroupValue.itemid).toBe("9999"); + expect(result.data.storeGroupValue.error).toBeNull(); + }); + + test("Retrieve group value using getGroupValue query", async () => { + const query = ` + query GetValue($locator: GroupValueLocator!) { + getGroupValue(locator: $locator) + } + `; + + const variables = { + locator: { + groupName: "Infrastructure/Configurations", + valueType: "GlobalSettings", + key: "api.config.json" + } + }; + + (zabbixAPI.post as jest.Mock) + .mockResolvedValueOnce([{ groupid: "777", name: "Infrastructure/Configurations" }]) // group.get + .mockResolvedValueOnce([{ hostid: "7777" }]) // host.get + .mockResolvedValueOnce([{ itemid: "9999" }]) // item.get + .mockResolvedValueOnce([{ value: JSON.stringify({ maintenanceMode: false }) }]); // history.get + + const response = await server.executeOperation({ + query: query, + variables: variables, + }, { + contextValue: { zabbixAuthToken: 'test-token', dataSources: { zabbixAPI: zabbixAPI } } + }); + + expect(response.body.kind).toBe('single'); + // @ts-ignore + const result = response.body.singleResult; + expect(result.errors).toBeUndefined(); + expect(result.data.getGroupValue).toEqual({ maintenanceMode: false }); + }); +}); diff --git a/src/test/store_group_value.unit.test.ts b/src/test/store_group_value.unit.test.ts new file mode 100644 index 0000000..c219233 --- /dev/null +++ b/src/test/store_group_value.unit.test.ts @@ -0,0 +1,219 @@ +import {zabbixAPI} from "../datasources/zabbix-api.js"; +import {GroupHelper} from "../datasources/zabbix-hostgroups.js"; +import { + ZabbixStoreObjectInItemHistoryRequest, + ZabbixStoreValueInItemParams, + ZabbixGetGroupValueRequest, + ZabbixGroupValueLocatorParams +} from "../datasources/zabbix-store-in-item-history.js"; +import {isZabbixErrorResult} from "../datasources/zabbix-request.js"; +import {ZabbixQueryHostsMetaRequest, ZabbixCreateHostRequest} from "../datasources/zabbix-hosts.js"; +import {ZabbixQueryHistoryRequest} from "../datasources/zabbix-history.js"; + +// Mock Zabbix API +jest.mock("../datasources/zabbix-api.js", () => ({ + zabbixAPI: { + post: jest.fn(), + getVersion: jest.fn().mockResolvedValue("7.4.0"), + requestByPath: jest.fn(), + } +})); + +// Spy helpers from other modules +const spyFindGroupIds = jest.spyOn(GroupHelper, "findHostGroupIdsByName"); +const spyHostsMeta = jest.spyOn(ZabbixQueryHostsMetaRequest.prototype, "executeRequestReturnError"); +const spyCreateHost = jest.spyOn(ZabbixCreateHostRequest.prototype, "executeRequestThrowError"); +const spyQueryHistory = jest.spyOn(ZabbixQueryHistoryRequest.prototype, "executeRequestReturnError"); + + +describe("storeGroupValue - unit validation & preparation", () => { + beforeEach(() => { + jest.clearAllMocks(); + spyFindGroupIds.mockReset(); + spyHostsMeta.mockReset(); + spyCreateHost.mockReset(); + }); + + test("fails when neither host nor itemid given and valueType is missing", async () => { + const req = new ZabbixStoreObjectInItemHistoryRequest("token"); + const params = new ZabbixStoreValueInItemParams({ + locator: { + key: "cfg.key", + // no host, no itemid, no valueType, no group info + }, + value: { a: 1 } + } as any); + + await expect(req.executeRequestReturnError(zabbixAPI as any, params)) + .rejects.toThrow(/valueType in request is mandatory/i); + }); + + test("fails when groupid and groupName missing if host not provided (with valueType)", async () => { + const req = new ZabbixStoreObjectInItemHistoryRequest("token"); + const params = new ZabbixStoreValueInItemParams({ + locator: { + key: "cfg.key", + valueType: "GlobalSettings" + }, + value: { a: 1 }, + } as any); + + await expect(req.executeRequestReturnError(zabbixAPI as any, params)) + .rejects.toThrow(/groupName must be present/i); + }); + + test("fails when groupName provided but not found", async () => { + spyFindGroupIds.mockResolvedValueOnce([]); + + const req = new ZabbixStoreObjectInItemHistoryRequest("token"); + const params = new ZabbixStoreValueInItemParams({ + locator: { + key: "cfg.key", + valueType: "GlobalSettings", + groupName: "Infrastructure/Configurations" + }, + value: { a: 1 }, + } as any); + + await expect(req.executeRequestReturnError(zabbixAPI as any, params)) + .rejects.toThrow(/Unable to find group=/); + }); + + test("creates a new host if none with valueType tag exists in group", async () => { + // Group lookup resolves to id 777 + spyFindGroupIds.mockResolvedValue([777]); + // No host found with tag valueType, but for script reload we need one host + spyHostsMeta + .mockResolvedValueOnce([] as any) // first call: check for storage host + .mockResolvedValueOnce([{ hostid: "1" }] as any); // second call: ZabbixForceCacheReloadRequest.prepare + // Host gets created + spyCreateHost.mockResolvedValue({ hostids: [7777] } as any); + + // item.get (not found), then item.create for new item, then script calls for cache reload, then history.push.jsonobject + (zabbixAPI.post as jest.Mock) + .mockResolvedValueOnce([]) // item.get + .mockResolvedValueOnce({ itemids: ["9999"], hostids: ["7777"] }) // item.create.storeiteminhistory + .mockResolvedValueOnce([]) // script.get + .mockResolvedValueOnce({ scriptids: ["42"] }) // script.create + .mockResolvedValueOnce({ response: "success", value: "OK" }) // script.execute + .mockResolvedValueOnce({ response: "success", data: [{ itemid: "9999" }] }); // history.push.jsonobject + + const req = new ZabbixStoreObjectInItemHistoryRequest("token"); + const params = new ZabbixStoreValueInItemParams({ + locator: { + key: "api.config.json", + valueType: "GlobalSettings", + groupName: "Infrastructure/Configurations" + }, + value: { maintenanceMode: false }, + } as any); + + const res = await req.executeRequestReturnError(zabbixAPI as any, params); + expect(isZabbixErrorResult(res)).toBe(false); + // ensure underlying calls performed + expect(spyFindGroupIds).toHaveBeenCalledWith(["Infrastructure/Configurations"], expect.anything(), expect.anything(), undefined); + expect(spyCreateHost).toHaveBeenCalled(); + const calls = (zabbixAPI.post as jest.Mock).mock.calls; + // index 0 is item.get (to check if already exists) + expect(calls[0][0]).toBe("item.get"); + expect(calls[1][0]).toBe("item.create.storeiteminhistory"); + expect(calls.pop()?.[0]).toBe("history.push.jsonobject"); + }); + + test("uses different item lookups for different keys in same group/valueType", async () => { + // Group lookup resolves to id 777 + spyFindGroupIds.mockResolvedValue([777]); + // One host found with tag valueType + spyHostsMeta.mockResolvedValue([{ hostid: "7777" }] as any); + + (zabbixAPI.post as jest.Mock).mockImplementation((method, options) => { + const params = options.body.params; + if (method === "item.get") return Promise.resolve([]); + if (method === "item.create.storeiteminhistory") { + return Promise.resolve({ itemids: [params.key_ === "key1" ? "1111" : "2222"] }); + } + if (method === "script.get") return Promise.resolve([{ scriptid: "42" }]); + if (method === "script.execute") return Promise.resolve({ response: "success", value: "OK" }); + if (method === "history.push.jsonobject") { + return Promise.resolve({ response: "success", data: [{ itemid: params.itemid }] }); + } + return Promise.resolve([]); + }); + + const req1 = new ZabbixStoreObjectInItemHistoryRequest("token"); + const params1 = new ZabbixStoreValueInItemParams({ + locator: { + key: "key1", + valueType: "TypeA", + groupName: "GroupName" + }, + value: { v: 1 }, + } as any); + await req1.executeRequestReturnError(zabbixAPI as any, params1); + expect(req1.itemid).toBe(1111); + + const req2 = new ZabbixStoreObjectInItemHistoryRequest("token"); + const params2 = new ZabbixStoreValueInItemParams({ + locator: { + key: "key2", + valueType: "TypeA", + groupName: "GroupName" + }, + value: { v: 2 }, + } as any); + await req2.executeRequestReturnError(zabbixAPI as any, params2); + expect(req2.itemid).toBe(2222); + + expect(req1.itemid).not.toBe(req2.itemid); + + // Verify item.get calls had correct keys + const itemGetCalls = (zabbixAPI.post as jest.Mock).mock.calls.filter(c => c[0] === "item.get"); + expect(itemGetCalls[0][1].body.params.filter.key_).toBe("key1"); + expect(itemGetCalls[1][1].body.params.filter.key_).toBe("key2"); + }); +}); +describe("getGroupValue - unit validation & execution", () => { + beforeEach(() => { + jest.clearAllMocks(); + spyFindGroupIds.mockReset(); + spyHostsMeta.mockReset(); + }); + + test("retrieves last value correctly", async () => { + spyFindGroupIds.mockResolvedValue([777]); + spyHostsMeta.mockResolvedValue([{ hostid: "7777" }] as any); + spyQueryHistory.mockResolvedValue([{ value: JSON.stringify({ status: "OK" }) }] as any); + + (zabbixAPI.post as jest.Mock).mockImplementation((method, options) => { + if (method === "item.get") return Promise.resolve([{ itemid: "9999" }]); + return Promise.resolve([]); + }); + + const req = new ZabbixGetGroupValueRequest("token"); + const params = new ZabbixGroupValueLocatorParams({ + locator: { + key: "api.status", + valueType: "Monitor", + groupName: "Services" + } + } as any); + + const res = await req.executeRequestReturnError(zabbixAPI as any, params); + expect(res).toEqual({ status: "OK" }); + + expect(spyQueryHistory).toHaveBeenCalled(); + }); + + test("returns null if item not found", async () => { + spyFindGroupIds.mockResolvedValue([777]); + spyHostsMeta.mockResolvedValue([{ hostid: "7777" }] as any); + (zabbixAPI.post as jest.Mock).mockResolvedValue([]); // item.get returns empty + + const req = new ZabbixGetGroupValueRequest("token"); + const res = await req.executeRequestReturnError(zabbixAPI as any, new ZabbixGroupValueLocatorParams({ + locator: { key: "missing", valueType: "T", groupName: "G" } + } as any)); + + expect(res).toBeNull(); + }); +});