feat: optimize Zabbix queries and enhance specialized device support

- Implement query optimization (reduced output, parameter skipping) to minimize Zabbix API traffic.

- Add indirect dependency handling: deviceType implies tags and state implies items.

- Move schema extensions to samples/extensions/ to clarify their role as samples.

- Enhance DistanceTrackerDevice with String time fields to support optional date portions.

- Ensure allDevices strictly filters by deviceType and populates the field in results.

- Refactor runAllRegressionTests mutation to use internal unique names and improve stability.

- Fix unnecessary Zabbix API calls for item preprocessing during template and host imports.

- Update documentation including cookbook recipes, test specifications, and optimization guides.

- Add extensive unit, integration, and regression tests covering all implemented changes.

- Update docker-compose.yml to mount the samples/ directory as a volume.

- Update IntelliJ .idea run configurations to reflect the new sample extension paths.
This commit is contained in:
Andreas Hilbig 2026-02-02 13:20:06 +01:00
parent 97a0f70fd6
commit b646b8c606
28 changed files with 551 additions and 74 deletions

View file

@ -2,7 +2,7 @@
<configuration default="false" name="index.ts" type="NodeJSConfigurationType" path-to-node="wsl://Ubuntu@/home/ahilbig/.nvm/versions/node/v24.12.0/bin/node" nameIsGenerated="true" path-to-js-file="src/index.ts" node-parameters="--import tsx" working-dir="$PROJECT_DIR$">
<envs>
<env name="ADDITIONAL_RESOLVERS" value="SinglePanelDevice,FourPanelDevice,DistanceTrackerDevice" />
<env name="ADDITIONAL_SCHEMAS" value="./schema/extensions/display_devices.graphql,./schema/extensions/location_tracker_devices.graphql,./schema/extensions/location_tracker_commons.graphql" />
<env name="ADDITIONAL_SCHEMAS" value="./samples/extensions/display_devices.graphql,./samples/extensions/location_tracker_devices.graphql,./samples/extensions/location_tracker_commons.graphql" />
<env name="DEBUG" value="device-control-center-api:*" />
<env name="ZABBIX_PRIVILEGE_ESCALATION_TOKEN" value="$ZABBIX_AUTH_TOKEN_VCR_DEV$" />
<env name="ZABBIX_BASE_URL" value="http://cockpit.vcr.develop.hilbigit.com/" />

50
.idea/workspace.xml generated
View file

@ -5,18 +5,34 @@
</component>
<component name="ChangeListManager">
<list default="true" id="d7a71994-2699-4ae4-9fd2-ee13b7f33d35" name="Changes" comment="docs: refactor documentation and upgrade to Node.js 24&#10;&#10;This commit upgrades the project to Node.js 24 (LTS) and performs a major refactoring of the documentation to support both advanced users and AI-based automation (MCP).&#10;&#10;Changes:&#10;- Environment &amp; CI/CD:&#10; - Set Node.js version to &gt;=24 in package.json and .nvmrc.&#10; - Updated Dockerfile to use Node 24 base image.&#10; - Updated @types/node to ^24.10.9.&#10;- Documentation:&#10; - Refactored README.md with comprehensive technical reference, configuration details, and Zabbix-to-GraphQL mapping.&#10; - Created docs/howtos/cookbook.md with practical recipes for common tasks and AI test generation.&#10; - Updated docs/howtos/mcp.md to emphasize GraphQL's advantages for AI agents and Model Context Protocol.&#10; - Added readme.improvement.plan.md to track documentation evolution.&#10; - Enhanced all how-to guides with improved cross-references and up-to-date information.&#10;- Guidelines:&#10; - Updated .junie/guidelines.md with Node 24 requirements and enhanced commit message standards (Conventional Commits 1.0.0).&#10;- Infrastructure &amp; Code:&#10; - Updated docker-compose.yml with Apollo MCP server integration.&#10; - Refined configuration and schema handling in src/api/ and src/datasources/.&#10; - Synchronized generated TypeScript types with schema updates.">
<change afterPath="$PROJECT_DIR$/docs/queries/sample_distance_tracker_test_query.graphql" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/test/schema_dependent_queries.test.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/runConfigurations/index_ts.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/runConfigurations/index_ts.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docker-compose.yml" beforeDir="false" afterPath="$PROJECT_DIR$/docker-compose.yml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/howtos/cookbook.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/howtos/cookbook.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/queries/sample_import_weather_sensor_template.graphql" beforeDir="false" afterPath="$PROJECT_DIR$/docs/queries/sample_import_weather_sensor_template.graphql" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/howtos/query_optimization.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/howtos/query_optimization.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/howtos/schema.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/howtos/schema.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/queries/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/queries/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/tests.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/tests.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/mcp/operations/runAllRegressionTests.graphql" beforeDir="false" afterPath="$PROJECT_DIR$/mcp/operations/runAllRegressionTests.graphql" afterDir="false" />
<change beforePath="$PROJECT_DIR$/schema/extensions/display_devices.graphql" beforeDir="false" afterPath="$PROJECT_DIR$/samples/extensions/display_devices.graphql" afterDir="false" />
<change beforePath="$PROJECT_DIR$/schema/extensions/ground_value_checker.graphql" beforeDir="false" afterPath="$PROJECT_DIR$/samples/extensions/ground_value_checker.graphql" afterDir="false" />
<change beforePath="$PROJECT_DIR$/schema/extensions/location_tracker_commons.graphql" beforeDir="false" afterPath="$PROJECT_DIR$/samples/extensions/location_tracker_commons.graphql" afterDir="false" />
<change beforePath="$PROJECT_DIR$/schema/extensions/location_tracker_devices.graphql" beforeDir="false" afterPath="$PROJECT_DIR$/samples/extensions/location_tracker_devices.graphql" afterDir="false" />
<change beforePath="$PROJECT_DIR$/schema/extensions/weather_sensor.graphql" beforeDir="false" afterPath="$PROJECT_DIR$/samples/extensions/weather_sensor.graphql" afterDir="false" />
<change beforePath="$PROJECT_DIR$/schema/mutations.graphql" beforeDir="false" afterPath="$PROJECT_DIR$/schema/mutations.graphql" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/api/resolvers.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/api/resolvers.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/datasources/graphql-params-to-zabbix-output.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/datasources/graphql-params-to-zabbix-output.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/datasources/zabbix-hosts.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/datasources/zabbix-hosts.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/datasources/zabbix-templates.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/datasources/zabbix-templates.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/execution/host_importer.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/execution/host_importer.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/execution/regression_test_executor.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/execution/regression_test_executor.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/execution/template_importer.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/execution/template_importer.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/model/model_enum_values.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/model/model_enum_values.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/schema/generated/graphql.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/schema/generated/graphql.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/test/host_importer.test.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/test/host_importer.test.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/test/template_importer.test.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/test/template_importer.test.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/test/host_query.test.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/test/host_query.test.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/test/query_optimization.test.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/test/query_optimization.test.ts" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -27,13 +43,13 @@
<execution />
</component>
<component name="EmbeddingIndexingInfo">
<option name="cachedIndexableFilesCount" value="149" />
<option name="cachedIndexableFilesCount" value="163" />
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
</component>
<component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="license" />
<entry key="$PROJECT_DIR$" value="vlsv" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@ -95,7 +111,7 @@
"go.import.settings.migrated": "true",
"javascript.preferred.runtime.type.id": "node",
"junie.onboarding.icon.badge.shown": "true",
"last_opened_file_path": "//wsl.localhost/Ubuntu/home/ahilbig/git/vcr/zabbix-graphql-api/src",
"last_opened_file_path": "//wsl.localhost/Ubuntu/home/ahilbig/git/vcr/zabbix-graphql-api/docs",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
@ -107,8 +123,8 @@
"npm.copy-schema.executor": "Run",
"npm.prod.executor": "Run",
"npm.test.executor": "Run",
"settings.editor.selected.configurable": "ml.llm.mcp",
"settings.editor.splitter.proportion": "0.28812414",
"settings.editor.selected.configurable": "junie.mcp",
"settings.editor.splitter.proportion": "0.23751687",
"to.speed.mode.migration.done": "true",
"ts.external.directory.path": "\\\\wsl.localhost\\Ubuntu\\home\\ahilbig\\git\\vcr\\zabbix-graphql-api\\node_modules\\typescript\\lib",
"vue.rearranger.settings.migration": "true"
@ -116,16 +132,16 @@
}]]></component>
<component name="RecapSpentCounter">
<option name="endsOfQuotaMs" value="1772398800000" />
<option name="spentUsd" value="0.01011225" />
<option name="spentUsd" value="0.0915201" />
</component>
<component name="RecapUselessUpdatesCounter">
<option name="suspendCountdown" value="8" />
<option name="suspendCountdown" value="0" />
</component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="\\wsl.localhost\Ubuntu\home\ahilbig\git\vcr\zabbix-graphql-api\docs" />
<recent name="\\wsl.localhost\Ubuntu\home\ahilbig\git\vcr\zabbix-graphql-api\src" />
<recent name="\\wsl.localhost\Ubuntu\home\ahilbig\git\vcr\zabbix-graphql-api\dist" />
<recent name="\\wsl.localhost\Ubuntu\home\ahilbig\git\vcr\zabbix-graphql-api\docs" />
<recent name="\\wsl.localhost\Ubuntu\home\ahilbig\git\vcr\zabbix-graphql-api\src\testdata\templates" />
<recent name="\\wsl.localhost\Ubuntu\home\ahilbig\git\vcr\zabbix-graphql-api\src\test" />
</key>
@ -153,7 +169,7 @@
<node-interpreter value="project" />
<envs>
<env name="ADDITIONAL_RESOLVERS" value="SinglePanelDevice,FourPanelDevice,DistanceTrackerDevice" />
<env name="ADDITIONAL_SCHEMAS" value="./schema/extensions/display_devices.graphql,./schema/extensions/location_tracker_devices.graphql,./schema/extensions/location_tracker_commons.graphql" />
<env name="ADDITIONAL_SCHEMAS" value="./samples/extensions/display_devices.graphql,./samples/extensions/location_tracker_devices.graphql,./samples/extensions/location_tracker_commons.graphql" />
<env name="DEBUG" value="device-control-center-api:*" />
<env name="ZABBIX_PRIVILEGE_ESCALATION_TOKEN" value="$ZABBIX_AUTH_TOKEN_VCR_DEV$" />
<env name="ZABBIX_BASE_URL" value="http://cockpit.vcr.develop.hilbigit.com/" />
@ -185,11 +201,11 @@
</list>
<recent_temporary>
<list>
<item itemvalue="npm.prod" />
<item itemvalue="npm.copy-schema" />
<item itemvalue="npm.test" />
<item itemvalue="npm.test" />
<item itemvalue="npm.prod" />
<item itemvalue="npm.copy-schema" />
<item itemvalue="npm.prod" />
<item itemvalue="npm.copy-schema" />
</list>
</recent_temporary>
</component>
@ -488,7 +504,7 @@
</line-breakpoint>
<line-breakpoint enabled="true" type="javascript">
<url>file://$PROJECT_DIR$/src/datasources/zabbix-request.ts</url>
<line>213</line>
<line>253</line>
<option name="timeStamp" value="6" />
</line-breakpoint>
</breakpoints>

View file

@ -18,7 +18,7 @@ The Zabbix GraphQL API acts as a wrapper and enhancer for the native Zabbix JSON
- *Reference*: `schema/mutations.graphql` (importHosts, importTemplates, importUserRights, etc.), `docs/sample_import_*.graphql`
- **Dynamic Schema Extension**: Extend the schema without code changes using environment variables
- *Reference*: `src/api/schema.ts`, `schema/extensions/`, `src/common_utils.ts` (ADDITIONAL_SCHEMAS, ADDITIONAL_RESOLVERS)
- *Reference*: `src/api/schema.ts`, `samples/extensions/` (sample extensions), `src/common_utils.ts` (ADDITIONAL_SCHEMAS, ADDITIONAL_RESOLVERS)
- **Permission System**: Role-based access control using Zabbix template groups
- *Reference*: `schema/api_commons.graphql` (Permission enum, PermissionRequest), `src/api/resolvers.ts` (hasPermissions, userPermissions), `docs/sample_import_permissions_template_groups_mutation.graphql`
@ -213,7 +213,7 @@ HOST_GROUP_FILTER_DEFAULT=Roadwork/Devices/*
HOST_TYPE_FILTER_DEFAULT=Roadwork/Devices
# Schema Extensions (No-Code)
ADDITIONAL_SCHEMAS=./schema/extensions/display_devices.graphql,./schema/extensions/location_tracker_devices.graphql,./schema/extensions/location_tracker_commons.graphql
ADDITIONAL_SCHEMAS=./samples/extensions/display_devices.graphql,./samples/extensions/location_tracker_devices.graphql,./samples/extensions/location_tracker_commons.graphql
ADDITIONAL_RESOLVERS=SinglePanelDevice,FourPanelDevice,DistanceTrackerDevice
# Logging

View file

@ -11,6 +11,8 @@ services:
environment:
- SCHEMA_PATH=/usr/app/dist/schema/
- ZABBIX_DEVELOPMENT_TOKEN=${ZABBIX_DEVELOPMENT_TOKEN}
volumes:
- ./samples:/usr/app/dist/samples
apollo-mcp-server:
image: ghcr.io/apollographql/apollo-mcp-server:latest

View file

@ -73,12 +73,14 @@ Compare the GraphQL response with the expected output described in the Zabbix do
This recipe shows how to add support for a new specialized device type without modifying the core API code. We will use the `DistanceTrackerDevice` as an example.
> **Important**: Schema extensions are not part of the core API source code. They are loaded dynamically at runtime via environment variables. The extensions provided in the `samples/extensions/` directory of this repository are **samples** to demonstrate how to use this mechanism. You can place your own extension files in any directory accessible by the API server.
### 📋 Prerequisites
- Zabbix Template Group `Templates/Roadwork/Devices` exists.
- Zabbix GraphQL API is running.
### 🛠️ Step 1: Define the Schema Extension
Create a new `.graphql` file in `schema/extensions/` (e.g. `distance_tracker.graphql`).
Create a new `.graphql` file in `samples/extensions/` (e.g. `distance_tracker.graphql`).
> **Advice**: A new device type must always implement both the `Host` and `Device` interfaces to ensure compatibility with the API's core logic and resolvers.
@ -102,20 +104,20 @@ type DistanceTrackerState implements DeviceState {
}
type DistanceTrackerValues {
timeFrom: Time
timeUntil: Time
timeFrom: String
timeUntil: String
count: Int
# The distances are modelled using a type which is already defined in location_tracker_commons.graphql
distances: [SensorDistanceValue!]
}
```
> **Reference**: This example is based on the already prepared sample: [location_tracker_devices.graphql](../../schema/extensions/location_tracker_devices.graphql).
> **Reference**: This example is based on the already prepared sample: [location_tracker_devices.graphql](../../samples/extensions/location_tracker_devices.graphql).
### ⚙️ Step 2: Configure Environment Variables
Add the new schema and resolver to your `.env` file:
```env
ADDITIONAL_SCHEMAS=./schema/extensions/distance_tracker.graphql,./schema/extensions/location_tracker_commons.graphql
ADDITIONAL_SCHEMAS=./samples/extensions/distance_tracker.graphql,./samples/extensions/location_tracker_commons.graphql
ADDITIONAL_RESOLVERS=DistanceTrackerDevice
```
Restart the API server.
@ -203,7 +205,7 @@ This recipe demonstrates how to extend the schema with new device types that ret
- The device has geo-coordinates set via user macros (e.g. `{$LAT}` and `{$LON}`).
### 🛠️ Step 1: Define the Schema Extension
Create a new `.graphql` file in `schema/extensions/` (e.g. `weather_sensor.graphql` or `ground_value_checker.graphql`).
Create a new `.graphql` file in `samples/extensions/` (e.g. `weather_sensor.graphql` or `ground_value_checker.graphql`).
**Sample: Weather Sensor**
```graphql
@ -262,7 +264,7 @@ type GroundValues {
### ⚙️ Step 2: Register the Resolver
Add the new types and schemas to your `.env` file to enable the dynamic resolver:
```env
ADDITIONAL_SCHEMAS=./schema/extensions/weather_sensor.graphql,./schema/extensions/ground_value_checker.graphql
ADDITIONAL_SCHEMAS=./samples/extensions/weather_sensor.graphql,./samples/extensions/ground_value_checker.graphql
ADDITIONAL_RESOLVERS=WeatherSensorDevice,GroundValueChecker
```
Restart the API server to apply the changes.
@ -344,6 +346,30 @@ Create a host, assign it macros for coordinates, and query its state.
---
## 🍳 Recipe: Testing Specialized Device Types
This recipe shows how to execute a comprehensive query to verify the state and configuration of specialized device types, such as the `DistanceTrackerDevice`. This is useful for validating that your schema extensions and hierarchical mappings are working correctly.
### 📋 Prerequisites
- Zabbix GraphQL API is running.
- The schema has been extended with the `DistanceTrackerDevice` type (see [Recipe: Extending Schema with a New Device Type](#-recipe-extending-schema-with-a-new-device-type)). Sample extensions can be found in the `samples/extensions` directory.
- At least one host with `deviceType` set to `DistanceTrackerDevice` exists in Zabbix.
### 🛠️ Step 1: Get the Sample Query
1. **Open the Sample**: Open [docs/queries/sample_distance_tracker_test_query.graphql](../queries/sample_distance_tracker_test_query.graphql).
2. **Copy the Query**: Copy the GraphQL code block under the `### Query` header.
### 🚀 Step 2: Execution/Action
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
Check the response for the following:
- **apiVersion** and **zabbixVersion** are returned.
- **allHostGroups** contains the expected groups.
- **allDevices** and **allHosts** include your `DistanceTrackerDevice` with its specialized `state` (count, timeFrom, timeUntil) and `tags` (deviceWidgetPreview).
---
## 🍳 Recipe: Provisioning a New Host
### 📋 Prerequisites

View file

@ -22,7 +22,7 @@ The `GraphqlParamsToNeededZabbixOutput` class provides static methods to map Gra
### 3. Resolver Integration
Resolvers use the mapper to determine the required output and pass it to the datasource:
```typescript
const output = GraphqlParamsToNeededZabbixOutput.mapAllHosts(args, info);
const output = GraphqlParamsToNeededZabbixOutput.mapAllHosts(info);
return await new ZabbixQueryHostsRequestWithItemsAndInventory(...)
.executeRequestThrowError(dataSources.zabbixAPI, new ParsedArgs(args), output);
```
@ -30,6 +30,7 @@ return await new ZabbixQueryHostsRequestWithItemsAndInventory(...)
### 4. Indirect Dependencies
Some GraphQL fields are not directly returned by Zabbix but are computed from other data. The optimization logic ensures these dependencies are handled:
- **`state`**: Requesting the `state` field on a `Device` requires Zabbix `items`. The mapper automatically adds `items` to the requested output if `state` is present.
- **`deviceType`**: Requesting `deviceType` requires Zabbix `tags` (or `inheritedTags`). This is needed because the `deviceType` is resolved from a Zabbix tag and will be empty otherwise. The optimization logic ensures that `selectTags` and `selectInheritedTags` are not skipped when `deviceType` is requested.
## 🛠️ Configuration
Optimization rules are defined in the constructor of specialized `ZabbixRequest` classes.
@ -37,7 +38,7 @@ Optimization rules are defined in the constructor of specialized `ZabbixRequest`
### 📋 Supported Optimizations
- **Hosts & Devices**:
- `selectParentTemplates` skipped if `parentTemplates` not requested.
- `selectTags` and `selectInheritedTags` skipped if `tags` not requested.
- `selectTags` and `selectInheritedTags` skipped if `tags` (or `deviceType`) not requested.
- `selectHostGroups` skipped if `hostgroups` not requested.
- `selectItems` skipped if `items` (or `state`) not requested.
- `selectInventory` skipped if `inventory` not requested.

View file

@ -10,7 +10,7 @@ The GraphQL schema is located in the `../../schema/` directory and consists of:
- `zabbix.graphql` - Zabbix-specific types (see detailed documentation in file comments)
- `device_value_commons.graphql` - Common value types (see detailed documentation in file comments)
- `api_commons.graphql` - Common API types and permission system (see detailed documentation in file comments)
- `extensions/` - Custom device type extensions
- `samples/extensions/` - Sample device type extensions (not part of the core source)
For comprehensive understanding of each operation, read the detailed comments in the respective schema files.
@ -38,7 +38,8 @@ The `Location` type represents geographical information from Zabbix host invento
Extend the schema without code changes using environment variables:
```bash
ADDITIONAL_SCHEMAS=./schema/extensions/display_devices.graphql,./schema/extensions/location_tracker_devices.graphql
# Extensions can be located anywhere; samples are provided in samples/extensions/
ADDITIONAL_SCHEMAS=./samples/extensions/display_devices.graphql,./samples/extensions/location_tracker_devices.graphql
ADDITIONAL_RESOLVERS=SinglePanelDevice,FourPanelDevice,DistanceTrackerDevice
```

View file

@ -8,6 +8,7 @@ This directory contains practical examples of GraphQL operations for the Zabbix
- [Query All Hosts](./sample_all_hosts_query.graphql): Retrieve basic host information and inventory.
- [Import Hosts](./sample_import_hosts_mutation.graphql): Create or update multiple hosts with tags and group assignments.
- [Query All Devices](./sample_all_devices_query.graphql): Query specialized devices using the `allDevices` query.
- [Distance Tracker Test Query](./sample_distance_tracker_test_query.graphql): Comprehensive query for testing specialized `DistanceTrackerDevice` types.
### 📄 Templates
- [Query Templates](./sample_templates_query.graphql): List available templates and their items.

View file

@ -0,0 +1,118 @@
### Query
This query demonstrates how to retrieve data from multiple sources, including specialized device types like `DistanceTrackerDevice`.
> **Precondition**: This query will only work if the GraphQL schema has been extended with the `DistanceTrackerDevice` type (see the sample in `samples/extensions/location_tracker_devices.graphql`).
```graphql
query DistanceTrackerDeviceTest {
apiVersion
zabbixVersion
allHostGroups(search_name: "Roadwork/Devices/*") {
groupid
name
}
allDevices {
deviceType
host
name
... on DistanceTrackerDevice {
state {
current {
count
timeFrom
timeUntil
}
}
}
}
allHosts {
hostid
host
name
deviceType
... on Device {
tags {
deviceWidgetPreview {
TOP_LEFT {
key
emptyValue
unit
value_font_size
g_value_transform
unit_font_size
g_unit_transform
}
TOP_RIGHT {
key
emptyValue
unit
value_font_size
g_value_transform
unit_font_size
g_unit_transform
}
BOTTOM_LEFT {
key
emptyValue
unit
value_font_size
g_value_transform
unit_font_size
g_unit_transform
}
BOTTOM_RIGHT {
key
emptyValue
unit
value_font_size
g_value_transform
unit_font_size
g_unit_transform
}
}
}
}
... on DistanceTrackerDevice {
state {
current {
count
timeFrom
timeUntil
}
}
}
... on ZabbixHost {
items {
itemid
name
key_
hostid
lastclock
lastvalue
value_type
attributeName
status
type
}
}
... on GenericDevice {
deviceType
state {
generic: current
}
}
... on SinglePanelDevice {
deviceType
state {
current {
values {
contentIndex
contentKey
contentText
}
}
}
}
}
}
```

View file

@ -71,6 +71,9 @@ This document outlines the test cases and coverage for the Zabbix GraphQL API.
- **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.
@ -87,6 +90,7 @@ The `runAllRegressionTests` mutation (TC-E2E-02) executes the following checks:
- **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.
## ✅ Test Coverage Checklist
@ -138,6 +142,7 @@ The `runAllRegressionTests` mutation (TC-E2E-02) executes the following checks:
| 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) |

View file

@ -1,7 +1,6 @@
# Runs all regression tests.
# Variables: hostName, groupName
mutation RunAllRegressionTests($hostName: String!, $groupName: String!) {
runAllRegressionTests(hostName: $hostName, groupName: $groupName) {
mutation RunAllRegressionTests {
runAllRegressionTests {
success
message
steps {

View file

@ -42,11 +42,11 @@ type DistanceTrackerValues {
"""
Start of time interval for the delivered device counting value.
"""
timeFrom: Time
timeFrom: String
"""
End of time interval for the delivered device counting value.
"""
timeUntil: Time
timeUntil: String
"""
Number of unique device keys detected between timeFrom and timeUntil.

View file

@ -142,12 +142,7 @@ type Mutation {
"""
Runs all regression tests.
"""
runAllRegressionTests(
"""Technical name for the test host."""
hostName: String!,
"""Technical name for the test host group."""
groupName: String!
): SmoketestResponse!
runAllRegressionTests: SmoketestResponse!
}
"""

View file

@ -107,7 +107,7 @@ export function createResolvers(): Resolvers {
if (Config.HOST_TYPE_FILTER_DEFAULT) {
args.tag_hostType ??= [Config.HOST_TYPE_FILTER_DEFAULT];
}
const output = GraphqlParamsToNeededZabbixOutput.mapAllHosts(args, info);
const output = GraphqlParamsToNeededZabbixOutput.mapAllHosts(info);
return await new ZabbixQueryHostsRequestWithItemsAndInventory(zabbixAuthToken, cookie)
.executeRequestThrowError(
dataSources?.zabbixAPI || zabbixAPI, new ParsedArgs(args), output
@ -120,7 +120,7 @@ export function createResolvers(): Resolvers {
if (Config.HOST_TYPE_FILTER_DEFAULT) {
args.tag_hostType ??= [Config.HOST_TYPE_FILTER_DEFAULT];
}
const output = GraphqlParamsToNeededZabbixOutput.mapAllDevices(args, info);
const output = GraphqlParamsToNeededZabbixOutput.mapAllDevices(info);
return await new ZabbixQueryDevices(zabbixAuthToken, cookie)
.executeRequestThrowError(
dataSources?.zabbixAPI || zabbixAPI, new ZabbixQueryDevicesArgs(args), output
@ -133,7 +133,7 @@ export function createResolvers(): Resolvers {
if (!args.search_name && Config.HOST_GROUP_FILTER_DEFAULT) {
args.search_name = Config.HOST_GROUP_FILTER_DEFAULT
}
const output = GraphqlParamsToNeededZabbixOutput.mapAllHostGroups(args, info);
const output = GraphqlParamsToNeededZabbixOutput.mapAllHostGroups(info);
return await new ZabbixQueryHostgroupsRequest(zabbixAuthToken, cookie).executeRequestThrowError(
dataSources?.zabbixAPI || zabbixAPI, new ZabbixQueryHostgroupsParams(args), output
)
@ -173,7 +173,7 @@ export function createResolvers(): Resolvers {
name: args.name_pattern
}
}
const output = GraphqlParamsToNeededZabbixOutput.mapTemplates(args, info);
const output = GraphqlParamsToNeededZabbixOutput.mapTemplates(info);
return await new ZabbixQueryTemplatesRequest(zabbixAuthToken, cookie)
.executeRequestThrowError(dataSources?.zabbixAPI || zabbixAPI, new ParsedArgs(params), output);
},
@ -287,11 +287,11 @@ export function createResolvers(): Resolvers {
}: any) => {
return SmoketestExecutor.runSmoketest(args.hostName, args.templateName, args.groupName, zabbixAuthToken, cookie)
},
runAllRegressionTests: async (_parent: any, args: any, {
runAllRegressionTests: async (_parent: any, _args: any, {
zabbixAuthToken,
cookie
}: any) => {
return RegressionTestExecutor.runAllRegressionTests(args.hostName, args.groupName, zabbixAuthToken, cookie)
return RegressionTestExecutor.runAllRegressionTests(zabbixAuthToken, cookie)
}
},

View file

@ -1,26 +1,20 @@
import {GraphQLResolveInfo} from "graphql";
import {getRequestedFields} from "../api/graphql_utils.js";
import {
QueryAllDevicesArgs,
QueryAllHostGroupsArgs,
QueryAllHostsArgs,
QueryTemplatesArgs
} from "../schema/generated/graphql.js";
export class GraphqlParamsToNeededZabbixOutput {
static mapAllHosts(args: QueryAllHostsArgs, info: GraphQLResolveInfo): string[] {
static mapAllHosts(info: GraphQLResolveInfo): string[] {
return getRequestedFields(info);
}
static mapAllDevices(args: QueryAllDevicesArgs, info: GraphQLResolveInfo): string[] {
static mapAllDevices(info: GraphQLResolveInfo): string[] {
return getRequestedFields(info);
}
static mapAllHostGroups(args: QueryAllHostGroupsArgs, info: GraphQLResolveInfo): string[] {
static mapAllHostGroups(info: GraphQLResolveInfo): string[] {
return getRequestedFields(info);
}
static mapTemplates(args: QueryTemplatesArgs, info: GraphQLResolveInfo): string[] {
static mapTemplates(info: GraphQLResolveInfo): string[] {
return getRequestedFields(info);
}
}

View file

@ -21,6 +21,8 @@ export class ZabbixQueryHostsGenericRequest<T extends ZabbixResult, A extends Pa
this.skippableZabbixParams.set("selectTags", "tags");
this.skippableZabbixParams.set("selectInheritedTags", "tags");
this.skippableZabbixParams.set("selectHostGroups", "hostgroups");
this.impliedFields.set("deviceType", ["tags"]);
this.impliedFields.set("hostType", ["tags"]);
}
createZabbixParams(args?: A, output?: string[]): ZabbixParams {

View file

@ -115,7 +115,7 @@ export class TemplateHelper {
// Use name_pattern which now searches both visibility name and technical name (host)
let templates = await new ZabbixQueryTemplatesRequest(zabbixAuthToken, cookie).executeRequestReturnError(zabbixApi, new ParsedArgs({
name_pattern: templateName
}))
}), ["templateid", "host"])
if (isZabbixErrorResult(templates) || !templates?.length) {
logger.error(`Unable to find templateName=${templateName}`)

View file

@ -174,7 +174,7 @@ export class HostImporter {
{
tag_deviceType: deviceType
}
));
), ["templateid"]);
if (templates?.length) {
result = Number(templates[0].templateid)

View file

@ -12,13 +12,16 @@ import {
ZabbixQueryHostsGenericRequestWithItems
} from "../datasources/zabbix-hosts.js";
import {ZabbixQueryTemplatesRequest} from "../datasources/zabbix-templates.js";
import {ParsedArgs} from "../datasources/zabbix-request.js";
import {isZabbixErrorResult, ParsedArgs, ZabbixRequest} from "../datasources/zabbix-request.js";
export class RegressionTestExecutor {
public static async runAllRegressionTests(hostName: string, groupName: string, zabbixAuthToken?: string, cookie?: string): Promise<SmoketestResponse> {
public static async runAllRegressionTests(zabbixAuthToken?: string, cookie?: string): Promise<SmoketestResponse> {
const steps: SmoketestStep[] = [];
let success = true;
const hostName = "REG_HOST_" + Math.random().toString(36).substring(7);
const groupName = "REG_GROUP_" + Math.random().toString(36).substring(7);
try {
// Regression 1: Locations query argument order
// This verifies the fix where getLocations was called with (authToken, args) instead of (args, authToken)
@ -309,8 +312,15 @@ export class RegressionTestExecutor {
optSuccess = optSuccess && hasSelectItems3 && hasOutput3;
// 4. Test indirect dependencies: deviceType implies tags
const testParams4 = optRequest.createZabbixParams(new ParsedArgs({}), ["hostid", "deviceType"]);
const hasSelectTags4 = "selectTags" in testParams4;
const hasOutput4 = Array.isArray(testParams4.output) && testParams4.output.includes("hostid");
optSuccess = optSuccess && hasSelectTags4 && hasOutput4;
if (!optSuccess) {
logger.error(`REG-OPT: Optimization verification failed. hasSelectItems1: ${hasSelectItems1}, hasOutput1: ${hasOutput1}, hasSelectItems2: ${hasSelectItems2}, hasSelectTags2: ${hasSelectTags2}, hasSelectItems3: ${hasSelectItems3}, hasOutput3: ${hasOutput3}`);
logger.error(`REG-OPT: Optimization verification failed. hasSelectItems1: ${hasSelectItems1}, hasOutput1: ${hasOutput1}, hasSelectItems2: ${hasSelectItems2}, hasSelectTags2: ${hasSelectTags2}, hasSelectItems3: ${hasSelectItems3}, hasOutput3: ${hasOutput3}, hasSelectTags4: ${hasSelectTags4}, hasOutput4: ${hasOutput4}`);
}
} catch (error) {
logger.error(`REG-OPT: Error during optimization test: ${error}`);
@ -469,6 +479,57 @@ export class RegressionTestExecutor {
});
if (!optNegSuccess) success = false;
// Regression 12: allDevices deviceType filter
// Verifies that allDevices only returns hosts with a deviceType tag
const devHostNameWithTag = "REG_DEV_WITH_TAG_" + Math.random().toString(36).substring(7);
const devHostNameWithoutTag = "REG_DEV_WITHOUT_TAG_" + Math.random().toString(36).substring(7);
// Get groupid for hostGroupName
const groupQuery: any = await new ZabbixRequest("hostgroup.get", zabbixAuthToken, cookie)
.executeRequestReturnError(zabbixAPI, new ParsedArgs({ filter_name: hostGroupName }));
const regGroupId = Array.isArray(groupQuery) && groupQuery[0]?.groupid;
if (regGroupId) {
await HostImporter.importHosts([{
deviceKey: devHostNameWithTag,
deviceType: "RegressionDevice",
groupNames: [hostGroupName]
}], zabbixAuthToken, cookie);
await new ZabbixRequest("host.create", zabbixAuthToken, cookie).executeRequestReturnError(zabbixAPI, new ParsedArgs({
host: devHostNameWithoutTag,
name: devHostNameWithoutTag,
groups: [{ groupid: regGroupId }]
}));
const allDevicesResult: any = await new ZabbixQueryDevices(zabbixAuthToken, cookie)
.executeRequestReturnError(zabbixAPI, new ZabbixQueryDevicesArgs({
filter_host: [devHostNameWithTag, devHostNameWithoutTag]
}), ["name", "host", "hostid", "deviceType"]);
if (isZabbixErrorResult(allDevicesResult)) {
steps.push({
name: "REG-DEV-FILTER: allDevices deviceType filter",
success: false,
message: `Zabbix error: ${allDevicesResult.error.message}`
});
} else {
const hasHostWithTag = allDevicesResult.some((d: any) => d.host === devHostNameWithTag);
const hasHostWithoutTag = allDevicesResult.some((d: any) => d.host === devHostNameWithoutTag);
const devTypeNotNull = allDevicesResult.length > 0 && allDevicesResult.every((d: any) => d.deviceType !== null && d.deviceType !== undefined && d.deviceType !== "");
const devFilterSuccess = hasHostWithTag && !hasHostWithoutTag && devTypeNotNull;
steps.push({
name: "REG-DEV-FILTER: allDevices deviceType filter",
success: devFilterSuccess,
message: devFilterSuccess
? `allDevices correctly filtered out hosts without deviceType tag`
: `Failed: withTag=${hasHostWithTag}, withoutTag=${hasHostWithoutTag}, typeNotNull=${devTypeNotNull}, result=${JSON.stringify(allDevicesResult)}`
});
if (!devFilterSuccess) success = false;
}
}
// Step 1: Create Host Group (Legacy test kept for compatibility)
const groupResult = await HostImporter.importHostGroups([{
groupName: groupName
@ -486,6 +547,8 @@ export class RegressionTestExecutor {
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 TemplateDeleter.deleteTemplates(null, regTemplateName, zabbixAuthToken, cookie);
await TemplateDeleter.deleteTemplates(null, httpTempName, zabbixAuthToken, cookie);
await TemplateDeleter.deleteTemplates(null, macroTemplateName, zabbixAuthToken, cookie);

View file

@ -108,7 +108,7 @@ export class TemplateImporter {
let templateNames = template.templates.map(t => t.name)
let queryResult = await new ZabbixQueryTemplatesRequest(zabbixAuthToken, cookie).executeRequestReturnError(zabbixAPI, new ParsedArgs({
filter_host: templateNames
}))
}), ["templateid"])
if (isZabbixErrorResult(queryResult)) {
let errorMessage = queryResult.error.message;

View file

@ -642,12 +642,6 @@ export interface MutationImportUserRightsArgs {
}
export interface MutationRunAllRegressionTestsArgs {
groupName: Scalars['String']['input'];
hostName: Scalars['String']['input'];
}
export interface MutationRunSmoketestArgs {
groupName: Scalars['String']['input'];
hostName: Scalars['String']['input'];
@ -1597,7 +1591,7 @@ export type MutationResolvers<ContextType = any, ParentType extends ResolversPar
importTemplateGroups?: Resolver<Maybe<Array<ResolversTypes['CreateTemplateGroupResponse']>>, ParentType, ContextType, RequireFields<MutationImportTemplateGroupsArgs, 'templateGroups'>>;
importTemplates?: Resolver<Maybe<Array<ResolversTypes['ImportTemplateResponse']>>, ParentType, ContextType, RequireFields<MutationImportTemplatesArgs, 'templates'>>;
importUserRights?: Resolver<Maybe<ResolversTypes['ImportUserRightsResult']>, ParentType, ContextType, RequireFields<MutationImportUserRightsArgs, 'dryRun' | 'input'>>;
runAllRegressionTests?: Resolver<ResolversTypes['SmoketestResponse'], ParentType, ContextType, RequireFields<MutationRunAllRegressionTestsArgs, 'groupName' | 'hostName'>>;
runAllRegressionTests?: Resolver<ResolversTypes['SmoketestResponse'], ParentType, ContextType>;
runSmoketest?: Resolver<ResolversTypes['SmoketestResponse'], ParentType, ContextType, RequireFields<MutationRunSmoketestArgs, 'groupName' | 'hostName' | 'templateName'>>;
};

View file

@ -57,8 +57,8 @@ describe("Host and HostGroup Resolvers", () => {
}));
});
test("allDevices query", async () => {
const mockDevices = [{ hostid: "2", host: "Device 1" }];
test("allDevices query - with hostid", async () => {
const mockDevices = [{ hostid: "2", host: "Device 1", deviceType: "GenericDevice" }];
(zabbixAPI.post as jest.Mock).mockResolvedValueOnce(mockDevices);
const args: QueryAllDevicesArgs = { hostids: 2 };
@ -74,7 +74,63 @@ describe("Host and HostGroup Resolvers", () => {
body: expect.objectContaining({
method: "host.get",
params: expect.objectContaining({
hostids: 2
hostids: 2,
tags: expect.arrayContaining([{
tag: "deviceType",
operator: 4
}])
})
})
}));
});
test("allDevices query - with deviceType filter", async () => {
const mockDevices = [{ hostid: "2", host: "Device 1", deviceType: "SomeType" }];
(zabbixAPI.post as jest.Mock).mockResolvedValueOnce(mockDevices);
const args: QueryAllDevicesArgs = { tag_deviceType: ["SomeType"] };
const context = {
zabbixAuthToken: "test-token",
dataSources: { zabbixAPI: zabbixAPI }
};
const result = await resolvers.Query.allDevices(null, args, context);
expect(result).toEqual(mockDevices);
expect(zabbixAPI.post).toHaveBeenCalledWith("host.get.with_items", expect.objectContaining({
body: expect.objectContaining({
params: expect.objectContaining({
tags: expect.arrayContaining([{
tag: "deviceType",
operator: 1,
value: "SomeType"
}])
})
})
}));
});
test("allDevices query - ensures deviceType exists if no filter provided", async () => {
const mockDevices = [{ hostid: "3", host: "Device with tag", deviceType: "SomeType" }];
(zabbixAPI.post as jest.Mock).mockResolvedValueOnce(mockDevices);
const args: QueryAllDevicesArgs = {};
const context = {
zabbixAuthToken: "test-token",
dataSources: { zabbixAPI: zabbixAPI }
};
const result = await resolvers.Query.allDevices(null, args, context);
expect(result).toEqual(mockDevices);
expect(zabbixAPI.post).toHaveBeenCalledWith("host.get.with_items", expect.objectContaining({
body: expect.objectContaining({
method: "host.get",
params: expect.objectContaining({
tags: expect.arrayContaining([{
tag: "deviceType",
operator: 4
}])
})
})
}));

View file

@ -163,6 +163,37 @@ describe("Query Optimization", () => {
expect(callParams.output).toContain("items");
});
test("allHosts optimization - keep selectTags when deviceType requested", async () => {
(zabbixAPI.post as jest.Mock).mockResolvedValueOnce([]);
const args: QueryAllHostsArgs = {};
const context = {
zabbixAuthToken: "test-token",
dataSources: { zabbixAPI: zabbixAPI }
};
const info = {
fieldNodes: [{
selectionSet: {
selections: [
{ kind: 'Field', name: { value: 'hostid' } },
{ kind: 'Field', name: { value: 'deviceType' } }
]
}
}]
};
await resolvers.Query.allHosts(null, args, context, info);
expect(zabbixAPI.post).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
body: expect.objectContaining({
params: expect.objectContaining({
output: ["hostid"],
selectTags: expect.any(Array)
})
})
}));
});
test("allDevices optimization - skip items when not requested", async () => {
(zabbixAPI.post as jest.Mock).mockResolvedValueOnce([]);

View file

@ -0,0 +1,173 @@
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';
import {Config} from "../common_utils.js";
describe("Schema-dependent Queries Integration Tests", () => {
let server: ApolloServer;
let postSpy: jest.SpyInstance;
let originalSchemas: any;
let originalResolvers: any;
let originalApiVersion: any;
beforeAll(async () => {
originalSchemas = Config.ADDITIONAL_SCHEMAS;
originalResolvers = Config.ADDITIONAL_RESOLVERS;
originalApiVersion = Config.API_VERSION;
// We need to bypass the static readonly nature of Config for this test.
Object.defineProperty(Config, 'ADDITIONAL_SCHEMAS', {
value: "./samples/extensions/location_tracker_devices.graphql,./samples/extensions/location_tracker_commons.graphql,./samples/extensions/display_devices.graphql",
configurable: true
});
Object.defineProperty(Config, 'ADDITIONAL_RESOLVERS', {
value: "DistanceTrackerDevice,SinglePanelDevice",
configurable: true
});
Object.defineProperty(Config, 'API_VERSION', {
value: "1.2.3",
configurable: true
});
const schema = await schema_loader();
server = new ApolloServer({
schema,
});
postSpy = jest.spyOn(zabbixAPI, 'post');
});
afterAll(() => {
postSpy.mockRestore();
Object.defineProperty(Config, 'ADDITIONAL_SCHEMAS', { value: originalSchemas });
Object.defineProperty(Config, 'ADDITIONAL_RESOLVERS', { value: originalResolvers });
Object.defineProperty(Config, 'API_VERSION', { value: originalApiVersion });
});
test("TC-SCHEMA-01: DistanceTrackerDevice Comprehensive Query", async () => {
const filePath = join(process.cwd(), 'docs', 'queries', 'sample_distance_tracker_test_query.graphql');
const content = readFileSync(filePath, 'utf-8').replace(/\r\n/g, '\n');
const queryMatch = content.match(/```graphql\n([\s\S]*?)\n```/);
if (!queryMatch) {
throw new Error(`No graphql block found in sample query file`);
}
const query = queryMatch[1];
// Setup mock responses for Zabbix API
postSpy.mockImplementation((method: string) => {
if (method === 'apiinfo.version') return Promise.resolve("7.4.0");
if (method.startsWith('hostgroup.get')) {
return Promise.resolve([
{ groupid: "1", name: "Roadwork/Devices/Tracker" }
]);
}
if (method.startsWith('host.get')) {
return Promise.resolve([
{
hostid: "10001",
host: "TRACKER_01",
name: "Distance Tracker 01",
deviceType: "DistanceTrackerDevice", // Manually mapped because we mock post()
tags: [
{ tag: "deviceType", value: "DistanceTrackerDevice" }
],
items: [
{ itemid: "1", name: "Count", key_: "state.current.count", lastvalue: "5", lastclock: 1704103200, value_type: "3" },
{ itemid: "2", name: "Time From", key_: "state.current.timeFrom", lastvalue: "2024-01-01T10:00:00Z", lastclock: 1704103200, value_type: "4" },
{ itemid: "3", name: "Time Until", key_: "state.current.timeUntil", lastvalue: "2024-01-01T11:00:00Z", lastclock: 1704103200, value_type: "4" }
],
inheritedTags: []
},
{
hostid: "10003",
host: "TRACKER_02",
name: "Distance Tracker 02",
deviceType: "DistanceTrackerDevice",
tags: [
{ tag: "deviceType", value: "DistanceTrackerDevice" }
],
items: [
{ itemid: "10", name: "Count", key_: "state.current.count", lastvalue: "10", lastclock: 1704103200, value_type: "3" },
{ itemid: "11", name: "Time From", key_: "state.current.timeFrom", lastvalue: "09:58:09", lastclock: 1704103200, value_type: "4" }
],
inheritedTags: []
},
{
hostid: "10004",
host: "TRACKER_03",
name: "Distance Tracker 03",
deviceType: "DistanceTrackerDevice",
tags: [
{ tag: "deviceType", value: "DistanceTrackerDevice" }
],
items: [
{ itemid: "20", name: "Count", key_: "state.current.count", lastvalue: "0", lastclock: 1704103200, value_type: "3" },
{ itemid: "21", name: "Time From", key_: "state.current.timeFrom", lastvalue: "", lastclock: 1704103200, value_type: "4" }
],
inheritedTags: []
},
{
hostid: "10002",
host: "DISPLAY_01",
name: "LED Display 01",
deviceType: "SinglePanelDevice", // Manually mapped because we mock post()
tags: [
{ tag: "deviceType", value: "SinglePanelDevice" }
],
items: [
{ itemid: "4", name: "Content", key_: "state.current.values.1.contentText", lastvalue: "Roadwork Ahead", lastclock: 1704103200, value_type: "4" }
],
inheritedTags: []
}
]);
}
return Promise.resolve([]);
});
const response = await server.executeOperation({
query: query,
}, {
contextValue: { zabbixAuthToken: 'test-token', dataSources: { zabbixAPI: zabbixAPI } }
});
if (response.body.kind === 'single') {
const result = response.body.singleResult;
if (result.errors) {
console.error(`Errors in query:`, JSON.stringify(result.errors, null, 2));
}
expect(result.errors).toBeUndefined();
const data = result.data as any;
expect(data.apiVersion).toBe("1.2.3");
expect(data.zabbixVersion).toBe("7.4.0");
expect(data.allHostGroups).toHaveLength(1);
expect(data.allDevices).toBeDefined();
// Verify DistanceTrackerDevice resolution
const tracker = data.allDevices.find((d: any) => d.host === "TRACKER_01");
expect(tracker.deviceType).toBe("DistanceTrackerDevice");
expect(tracker.state.current.count).toBe(5);
expect(tracker.state.current.timeFrom).toBe("2024-01-01T10:00:00Z");
const tracker02 = data.allDevices.find((d: any) => d.host === "TRACKER_02");
expect(tracker02.state.current.count).toBe(10);
expect(tracker02.state.current.timeFrom).toBe("09:58:09");
const tracker03 = data.allDevices.find((d: any) => d.host === "TRACKER_03");
expect(tracker03.state.current.timeFrom).toBe("");
// Verify allHosts with fragments
const trackerInHosts = data.allHosts.find((h: any) => h.host === "TRACKER_01");
expect(trackerInHosts.state.current.count).toBe(5);
const displayInHosts = data.allHosts.find((h: any) => h.host === "DISPLAY_01");
expect(displayInHosts.deviceType).toBe("SinglePanelDevice");
} else {
throw new Error(`Unexpected response kind: ${response.body.kind}`);
}
});
});