diff --git a/.idea/runConfigurations/index_ts.xml b/.idea/runConfigurations/index_ts.xml index e23e69f..819149e 100644 --- a/.idea/runConfigurations/index_ts.xml +++ b/.idea/runConfigurations/index_ts.xml @@ -2,7 +2,7 @@ - + diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 40ef872..8f6b5aa 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -5,18 +5,34 @@ + + + + + - + + + + + + + + + + + + + - - - + + - - + - @@ -153,7 +169,7 @@ - + @@ -185,11 +201,11 @@ + + + - - - @@ -488,7 +504,7 @@ file://$PROJECT_DIR$/src/datasources/zabbix-request.ts - 213 + 253 diff --git a/README.md b/README.md index 03e3efc..b8e8c2b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index ace4fdb..afe92f9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/howtos/cookbook.md b/docs/howtos/cookbook.md index a1d6aa7..44d3a39 100644 --- a/docs/howtos/cookbook.md +++ b/docs/howtos/cookbook.md @@ -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 diff --git a/docs/howtos/query_optimization.md b/docs/howtos/query_optimization.md index 95bcfff..35e26f9 100644 --- a/docs/howtos/query_optimization.md +++ b/docs/howtos/query_optimization.md @@ -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. diff --git a/docs/howtos/schema.md b/docs/howtos/schema.md index c121a4d..46a98f5 100644 --- a/docs/howtos/schema.md +++ b/docs/howtos/schema.md @@ -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 ``` diff --git a/docs/queries/README.md b/docs/queries/README.md index e08de52..4e664c7 100644 --- a/docs/queries/README.md +++ b/docs/queries/README.md @@ -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. diff --git a/docs/queries/sample_distance_tracker_test_query.graphql b/docs/queries/sample_distance_tracker_test_query.graphql new file mode 100644 index 0000000..0482c99 --- /dev/null +++ b/docs/queries/sample_distance_tracker_test_query.graphql @@ -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 + } + } + } + } + } + } +``` diff --git a/docs/tests.md b/docs/tests.md index 6806fdd..72fad66 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -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) | diff --git a/mcp/operations/runAllRegressionTests.graphql b/mcp/operations/runAllRegressionTests.graphql index d61fe75..cadf52f 100644 --- a/mcp/operations/runAllRegressionTests.graphql +++ b/mcp/operations/runAllRegressionTests.graphql @@ -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 { diff --git a/schema/extensions/display_devices.graphql b/samples/extensions/display_devices.graphql similarity index 100% rename from schema/extensions/display_devices.graphql rename to samples/extensions/display_devices.graphql diff --git a/schema/extensions/ground_value_checker.graphql b/samples/extensions/ground_value_checker.graphql similarity index 100% rename from schema/extensions/ground_value_checker.graphql rename to samples/extensions/ground_value_checker.graphql diff --git a/schema/extensions/location_tracker_commons.graphql b/samples/extensions/location_tracker_commons.graphql similarity index 100% rename from schema/extensions/location_tracker_commons.graphql rename to samples/extensions/location_tracker_commons.graphql diff --git a/schema/extensions/location_tracker_devices.graphql b/samples/extensions/location_tracker_devices.graphql similarity index 97% rename from schema/extensions/location_tracker_devices.graphql rename to samples/extensions/location_tracker_devices.graphql index 2f69bb6..bd0c12c 100644 --- a/schema/extensions/location_tracker_devices.graphql +++ b/samples/extensions/location_tracker_devices.graphql @@ -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. diff --git a/schema/extensions/weather_sensor.graphql b/samples/extensions/weather_sensor.graphql similarity index 100% rename from schema/extensions/weather_sensor.graphql rename to samples/extensions/weather_sensor.graphql diff --git a/schema/mutations.graphql b/schema/mutations.graphql index f528517..124cf79 100644 --- a/schema/mutations.graphql +++ b/schema/mutations.graphql @@ -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! } """ diff --git a/src/api/resolvers.ts b/src/api/resolvers.ts index 0362247..b941e7a 100644 --- a/src/api/resolvers.ts +++ b/src/api/resolvers.ts @@ -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) } }, diff --git a/src/datasources/graphql-params-to-zabbix-output.ts b/src/datasources/graphql-params-to-zabbix-output.ts index 9372644..4fb1629 100644 --- a/src/datasources/graphql-params-to-zabbix-output.ts +++ b/src/datasources/graphql-params-to-zabbix-output.ts @@ -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); } } diff --git a/src/datasources/zabbix-hosts.ts b/src/datasources/zabbix-hosts.ts index e476c67..4c18c6e 100644 --- a/src/datasources/zabbix-hosts.ts +++ b/src/datasources/zabbix-hosts.ts @@ -21,6 +21,8 @@ export class ZabbixQueryHostsGenericRequest { + public static async runAllRegressionTests(zabbixAuthToken?: string, cookie?: string): Promise { 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); diff --git a/src/execution/template_importer.ts b/src/execution/template_importer.ts index 5a82af2..e826806 100644 --- a/src/execution/template_importer.ts +++ b/src/execution/template_importer.ts @@ -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; diff --git a/src/schema/generated/graphql.ts b/src/schema/generated/graphql.ts index 5179d36..3d543c9 100644 --- a/src/schema/generated/graphql.ts +++ b/src/schema/generated/graphql.ts @@ -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>, ParentType, ContextType, RequireFields>; importTemplates?: Resolver>, ParentType, ContextType, RequireFields>; importUserRights?: Resolver, ParentType, ContextType, RequireFields>; - runAllRegressionTests?: Resolver>; + runAllRegressionTests?: Resolver; runSmoketest?: Resolver>; }; diff --git a/src/test/host_query.test.ts b/src/test/host_query.test.ts index 7f093ac..0762084 100644 --- a/src/test/host_query.test.ts +++ b/src/test/host_query.test.ts @@ -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 + }]) }) }) })); diff --git a/src/test/query_optimization.test.ts b/src/test/query_optimization.test.ts index 37a4dd5..a7765ac 100644 --- a/src/test/query_optimization.test.ts +++ b/src/test/query_optimization.test.ts @@ -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([]); diff --git a/src/test/schema_dependent_queries.test.ts b/src/test/schema_dependent_queries.test.ts new file mode 100644 index 0000000..4b94f61 --- /dev/null +++ b/src/test/schema_dependent_queries.test.ts @@ -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}`); + } + }); +});