From db622e0b00ca851d2da1f680952cb8270cc83be1 Mon Sep 17 00:00:00 2001 From: Nathanial Acosta Date: Tue, 16 Jun 2026 13:21:03 -0400 Subject: [PATCH] feat: add integration models and integrations services, resources, and tests Adds services and resources for managing integration models (OpenAPI specs) and integration instances on the Itential Platform. Services are thin HTTP wrappers; resources provide higher-level lifecycle operations including delete-before-replace for models and overwrite support for instances. Existence checks are enforced at the service layer to protect against duplicates regardless of how the caller creates resources. Co-Authored-By: Claude Sonnet 4.6 --- src/asyncplatform/resources/__init__.py | 10 + .../resources/integration_models.py | 97 ++++++ src/asyncplatform/resources/integrations.py | 108 +++++++ .../services/integration_models.py | 214 +++++++++++++ src/asyncplatform/services/integrations.py | 181 +++++++++++ .../unit/test_resources_integration_models.py | 148 +++++++++ tests/unit/test_resources_integrations.py | 179 +++++++++++ .../unit/test_services_integration_models.py | 284 +++++++++++++++++ tests/unit/test_services_integrations.py | 290 ++++++++++++++++++ 9 files changed, 1511 insertions(+) create mode 100644 src/asyncplatform/resources/integration_models.py create mode 100644 src/asyncplatform/resources/integrations.py create mode 100644 src/asyncplatform/services/integration_models.py create mode 100644 src/asyncplatform/services/integrations.py create mode 100644 tests/unit/test_resources_integration_models.py create mode 100644 tests/unit/test_resources_integrations.py create mode 100644 tests/unit/test_services_integration_models.py create mode 100644 tests/unit/test_services_integrations.py diff --git a/src/asyncplatform/resources/__init__.py b/src/asyncplatform/resources/__init__.py index a410e06..e70bba9 100644 --- a/src/asyncplatform/resources/__init__.py +++ b/src/asyncplatform/resources/__init__.py @@ -72,6 +72,16 @@ def configuration_manager(self) -> Any: """Get the Configuration Manager service instance.""" return self.client.configuration_manager + @property + def integration_models(self) -> Any: + """Get the Integration Models service instance.""" + return self.client.integration_models + + @property + def integrations(self) -> Any: + """Get the Integrations service instance.""" + return self.client.integrations + @logging.trace async def get_groups(self) -> dict[str, dict[str, Any]]: """Retrieve and cache all authorization groups from the platform. diff --git a/src/asyncplatform/resources/integration_models.py b/src/asyncplatform/resources/integration_models.py new file mode 100644 index 0000000..e5b07db --- /dev/null +++ b/src/asyncplatform/resources/integration_models.py @@ -0,0 +1,97 @@ +# Copyright (c) 2025 Itential, Inc +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Integration model resource for managing Itential Platform integration models. + +This module provides the Resource class for high-level integration model +management operations including importing OpenAPI specs with delete-before-replace +semantics and deleting models by version identifier. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from typing import Any + +from asyncplatform import logging +from asyncplatform.resources import ResourceBase + +if TYPE_CHECKING: + from collections.abc import Mapping + + +class Resource(ResourceBase): + """Resource class for managing integration models. + + Provides high-level lifecycle operations for integration models, wrapping + the integration_models service with import and delete convenience methods. + + Attributes: + integration_models: Property that returns the Integration Models + service instance + """ + + name: str = "integration_models" + + @logging.trace + async def importer(self, spec: Mapping[str, Any]) -> dict[str, Any]: + """Import an integration model, replacing any existing version. + + Derives the version identifier from the spec's info block, deletes any + existing model with the same version identifier, then creates the new + model. Follows delete-before-replace to avoid version conflicts on + re-import. + + Args: + spec: A valid OpenAPI 3.x specification. Must include info.title + and info.version fields + + Returns: + A dictionary containing the created integration model data + + Raises: + AsyncPlatformError: If the spec exceeds 15 MB or any API request fails + """ + title: str = spec["info"]["title"] + version: str = spec["info"]["version"] + version_id = f"{title}:{version}" + + existing = await self.integration_models.find_integration_models( + name=version_id + ) + if existing: + await self.integration_models.delete_integration_model(version_id) + logging.info(f"Deleted existing integration model: {version_id}") + + result = await self.integration_models.create_integration_model(spec) + + logging.info(f"Successfully imported integration model: {version_id}") + + return result + + @logging.trace + async def delete(self, version_id: str) -> dict[str, Any]: + """Delete an integration model by version identifier. + + Searches for a model by version identifier and deletes it if found. + Returns an empty dictionary if no matching model exists. + + Args: + version_id: The version identifier of the model to delete, in the + form title:version (e.g. "My API:1.0.0") + + Returns: + A dictionary containing the deletion result, or an empty dictionary + if no model with the specified version identifier was found + + Raises: + AsyncPlatformError: If the delete operation fails + """ + existing = await self.integration_models.find_integration_models( + name=version_id + ) + if not existing: + return {} + + return await self.integration_models.delete_integration_model(version_id) diff --git a/src/asyncplatform/resources/integrations.py b/src/asyncplatform/resources/integrations.py new file mode 100644 index 0000000..497f07a --- /dev/null +++ b/src/asyncplatform/resources/integrations.py @@ -0,0 +1,108 @@ +# Copyright (c) 2025 Itential, Inc +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Integration resource for managing Itential Platform integration instances. + +This module provides the Resource class for high-level integration instance +management operations including creating instances and deleting instances by name. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from typing import Any + +from asyncplatform import logging +from asyncplatform.resources import ResourceBase + +if TYPE_CHECKING: + from collections.abc import Mapping + + +class Resource(ResourceBase): + """Resource class for managing integration instances. + + Provides high-level lifecycle operations for integration instances, wrapping + the integrations service with import and delete convenience methods. + + Attributes: + integrations: Property that returns the Integrations service instance + """ + + name: str = "integrations" + + @logging.trace + async def importer( + self, + *, + name: str, + type: str, + properties: Mapping[str, Any], + virtual: bool | None = None, + model: str | None = None, + overwrite: bool = False, + ) -> dict[str, Any]: + """Create an integration instance, optionally replacing an existing one. + + Creates an integration instance with the given configuration. By default, + raises an error if an instance with the same name already exists. Set + overwrite=True to delete the existing instance before creating the new one. + + Args: + name: Name for the integration instance + type: The integration adapter type + properties: Configuration properties for the integration + virtual: Whether to create a virtual integration + model: Optional integration model name to associate with the instance + overwrite: If True, deletes an existing instance with the same name + before creating. If False (default), raises an error if the + instance already exists + + Returns: + A dictionary containing the created integration instance data + + Raises: + AsyncPlatformError: If overwrite is False and an instance with the + same name already exists, or if any API request fails + """ + if overwrite: + existing = await self.integrations.find_integrations(name=name) + if existing: + await self.integrations.delete_integration(name) + logging.info(f"Deleted existing integration instance: {name}") + + result = await self.integrations.create_integration( + name=name, + type=type, + properties=properties, + virtual=virtual, + model=model, + ) + + logging.info(f"Successfully created integration instance: {name}") + + return result + + @logging.trace + async def delete(self, name: str) -> dict[str, Any]: + """Delete an integration instance by name. + + Searches for an integration instance by name and deletes it if found. + Returns an empty dictionary if no matching instance exists. + + Args: + name: The name of the integration instance to delete + + Returns: + A dictionary containing the deletion result, or an empty dictionary + if no instance with the specified name was found + + Raises: + AsyncPlatformError: If the delete operation fails + """ + existing = await self.integrations.find_integrations(name=name) + if not existing: + return {} + + return await self.integrations.delete_integration(name) diff --git a/src/asyncplatform/services/integration_models.py b/src/asyncplatform/services/integration_models.py new file mode 100644 index 0000000..3b1508d --- /dev/null +++ b/src/asyncplatform/services/integration_models.py @@ -0,0 +1,214 @@ +# Copyright (c) 2025 Itential, Inc +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +import asyncio +import json + +from typing import TYPE_CHECKING +from typing import Any + +if TYPE_CHECKING: + from collections.abc import Mapping + +from asyncplatform import logging +from asyncplatform.exceptions import AsyncPlatformError +from asyncplatform.http import HTTPStatus +from asyncplatform.services import ServiceBase + +_MAX_SPEC_SIZE_BYTES: int = 15 * 1024 * 1024 # 15 MB + + +class Service(ServiceBase): + """Service class for managing integration models in Itential Platform. + + Provides methods for interacting with integration model resources, + including searching, retrieving, creating, updating, and deleting models. + Integration models are imported OpenAPI 3.x specifications that define + how the platform communicates with external systems. + + Attributes: + name: Service identifier for logging and identification + PAGINATION_LIMIT: Number of items to fetch per page + """ + + name: str = "integration_models" + PAGINATION_LIMIT: int = 100 + + @logging.trace + async def find_integration_models(self, *, name: str | None = None) -> list[dict[str, Any]]: + """Search for integration models with automatic pagination. + + Queries the platform for integration models matching the specified + criteria. Paginates by name to ensure consistent ordering since name + is guaranteed unique for integration models. + + Args: + name: Optional model name to search for using exact match. If None, + all integration models are returned. + + Returns: + A list of integration model dictionaries. Each entry includes model, + versionId, description, and properties. Returns an empty list if no + matching models are found. + + Raises: + AsyncPlatformError: If any API request fails during retrieval + """ + limit = self.PAGINATION_LIMIT + params: dict[str, Any] = {"limit": limit, "sort": "name", "order": 1} + + if name is not None: + params.update({"equalsField": "name", "equals": name}) + + res = await self.get("/integration-models", params=params) + json_data = res.json() + + total = json_data.get("total", 0) + + logging.info(f"Found {total} integration model(s)") + + if total == 0: + return [] + + results = json_data.get("integrationModels", []) + + if total <= limit: + return results + + tasks = [ + self.get( + "/integration-models", + params={"limit": min(limit, total - skip), "skip": skip, **params}, + ) + for skip in range(limit, total, limit) + ] + + task_results = await asyncio.gather(*tasks, return_exceptions=True) + + for result in task_results: + if isinstance(result, Exception): + raise result + results.extend(result.json().get("integrationModels", [])) # type: ignore[union-attr] + + return results + + @logging.trace + async def create_integration_model(self, spec: Mapping[str, Any]) -> dict[str, Any]: + """Create an integration model from an OpenAPI spec. + + Validates the spec size and checks for an existing model with the same + version identifier before posting. The spec must be a valid OpenAPI 3.x + document. + + Args: + spec: A mapping containing a valid OpenAPI 3.x specification + + Returns: + A dictionary containing the created integration model data + + Raises: + AsyncPlatformError: If the spec exceeds 15 MB, if a model with the + same version identifier already exists, or if the API request fails + """ + title = spec["info"]["title"] + version = spec["info"]["version"] + version_id = f"{title}:{version}" + + existing = await self.find_integration_models(name=version_id) + if existing: + raise AsyncPlatformError( + f"Integration model `{version_id}` already exists" + ) + + spec_size = len(json.dumps(spec).encode("utf-8")) + + if spec_size >= _MAX_SPEC_SIZE_BYTES: + size_mb = spec_size / (1024 * 1024) + raise AsyncPlatformError( + f"Spec size {size_mb:.2f} MB exceeds the 15 MB limit" + ) + + res = await self.post("/integration-models", json={"model": spec}) + json_data = res.json() + + logging.info(json_data.get("message", "Integration model created")) + + return json_data.get("data", {}) + + @logging.trace + async def get_integration_model(self, name: str) -> dict[str, Any]: + """Retrieve a single integration model by name. + + Args: + name: The name of the integration model to retrieve + + Returns: + A dictionary containing the integration model data including + model, versionId, description, and properties + + Raises: + AsyncPlatformError: If the API request fails or model does not exist + """ + res = await self.get(f"/integration-models/{name}") + return res.json() + + @logging.trace + async def update_integration_model(self, spec: Mapping[str, Any]) -> dict[str, Any]: + """Update an existing integration model with a new OpenAPI spec. + + Replaces the existing model in-place. The spec must identify the + target model via its title and version fields. Validates spec size + before sending to avoid platform rejections. + + Args: + spec: A mapping containing a valid OpenAPI 3.x specification + + Returns: + A dictionary containing the updated integration model data + + Raises: + AsyncPlatformError: If the spec exceeds 15 MB or the API request fails + """ + spec_size = len(json.dumps(spec).encode("utf-8")) + + if spec_size >= _MAX_SPEC_SIZE_BYTES: + size_mb = spec_size / (1024 * 1024) + raise AsyncPlatformError( + f"Spec size {size_mb:.2f} MB exceeds the 15 MB limit" + ) + + res = await self.put("/integration-models", json={"model": spec}) + json_data = res.json() + + logging.info(json_data.get("message", "Integration model updated")) + + return json_data.get("data", {}) + + @logging.trace + async def delete_integration_model(self, name: str) -> dict[str, Any]: + """Delete an integration model by name. + + Permanently removes an integration model from the platform. This + operation cannot be undone. + + Args: + name: The name of the integration model to delete + + Returns: + A dictionary containing the deletion result + + Raises: + AsyncPlatformError: If the deletion request fails + """ + res = await self.delete( + f"/integration-models/{name}", + expected_status=HTTPStatus.OK, + ) + json_data = res.json() + + logging.info(f"Successfully deleted integration model: {name}") + + return json_data diff --git a/src/asyncplatform/services/integrations.py b/src/asyncplatform/services/integrations.py new file mode 100644 index 0000000..55ae453 --- /dev/null +++ b/src/asyncplatform/services/integrations.py @@ -0,0 +1,181 @@ +# Copyright (c) 2025 Itential, Inc +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +import asyncio + +from typing import TYPE_CHECKING +from typing import Any + +if TYPE_CHECKING: + from collections.abc import Mapping + +from asyncplatform import logging +from asyncplatform.exceptions import AsyncPlatformError +from asyncplatform.http import HTTPStatus +from asyncplatform.services import ServiceBase + + +class Service(ServiceBase): + """Service class for managing integration instances in Itential Platform. + + Provides methods for interacting with integration instance resources, + including searching, retrieving, creating, and deleting instances. + Integration instances are active connections to external systems created + from integration models. + + Attributes: + name: Service identifier for logging and identification + PAGINATION_LIMIT: Number of items to fetch per page + """ + + name: str = "integrations" + PAGINATION_LIMIT: int = 100 + + @logging.trace + async def find_integrations(self, *, name: str | None = None) -> list[dict[str, Any]]: + """Search for integration instances with automatic pagination. + + Paginates by name since integration instance names are unique and + enforced by the platform. + + Args: + name: Optional instance name to filter results. If None, all + integration instances are returned. + + Returns: + A list of integration instance dictionaries. Returns an empty list + if no matching instances are found. + + Raises: + AsyncPlatformError: If any API request fails during retrieval + """ + limit = self.PAGINATION_LIMIT + params: dict[str, Any] = {"limit": limit, "sort": "name", "order": 1} + + if name is not None: + params.update({"equalsField": "name", "equals": name}) + + res = await self.get("/integrations", params=params) + json_data = res.json() + + total = json_data.get("total", 0) + + logging.info(f"Found {total} integration instance(s)") + + if total == 0: + return [] + + results = json_data.get("integrations", []) + + if total <= limit: + return results + + tasks = [ + self.get( + "/integrations", + params={"limit": min(limit, total - skip), "skip": skip, **params}, + ) + for skip in range(limit, total, limit) + ] + + task_results = await asyncio.gather(*tasks, return_exceptions=True) + + for result in task_results: + if isinstance(result, Exception): + raise result + results.extend(result.json().get("integrations", [])) # type: ignore[union-attr] + + return results + + @logging.trace + async def get_integration(self, name: str) -> dict[str, Any]: + """Retrieve a single integration instance by name. + + Args: + name: The name of the integration instance to retrieve + + Returns: + A dictionary containing the integration instance data including + metadata (isActive, activeSync) and data (type, properties) + + Raises: + AsyncPlatformError: If the API request fails or instance does not exist + """ + res = await self.get(f"/integrations/{name}") + return res.json() + + @logging.trace + async def create_integration( + self, + *, + name: str, + type: str, + properties: Mapping[str, Any], + virtual: bool | None = None, + model: str | None = None, + ) -> dict[str, Any]: + """Create an integration instance. + + Args: + name: Name for the new integration instance + type: The integration adapter type + properties: Configuration properties for the integration + virtual: Whether to create a virtual integration. If None, the + platform default is used. + model: Optional integration model name to associate with the instance + + Returns: + A dictionary containing the created integration instance data + + Raises: + AsyncPlatformError: If an integration instance with the same name + already exists, or if the API request fails + """ + existing = await self.find_integrations(name=name) + if existing: + raise AsyncPlatformError( + f"Integration instance `{name}` already exists" + ) + + body: dict[str, Any] = {"name": name, "type": type, "properties": dict(properties)} + + if virtual is not None: + body["virtual"] = virtual + if model is not None: + body["model"] = model + + res = await self.post("/integrations", json=body) + json_data = res.json() + + logging.info(json_data.get("message", f"Integration instance created: {name}")) + + return json_data.get("data", json_data) + + @logging.trace + async def delete_integration(self, name: str) -> dict[str, Any]: + """Delete an integration instance by name. + + Permanently removes an integration instance from the platform. This + operation cannot be undone. + + Args: + name: The name of the integration instance to delete + + Returns: + A dictionary containing the deletion result + + Raises: + AsyncPlatformError: If the deletion request fails + """ + res = await self.delete( + f"/integrations/{name}", + expected_status=HTTPStatus.OK, + ) + json_data = res.json() + + logging.info(f"Successfully deleted integration instance: {name}") + + return json_data diff --git a/tests/unit/test_resources_integration_models.py b/tests/unit/test_resources_integration_models.py new file mode 100644 index 0000000..93794fb --- /dev/null +++ b/tests/unit/test_resources_integration_models.py @@ -0,0 +1,148 @@ +# Copyright (c) 2025 Itential, Inc +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Unit tests for asyncplatform.resources.integration_models module.""" + +from unittest.mock import AsyncMock +from unittest.mock import MagicMock + +import pytest + +from asyncplatform import exceptions +from asyncplatform.resources.integration_models import Resource + +SAMPLE_SPEC = { + "info": {"title": "My API", "version": "1.0.0"}, + "openapi": "3.0.0", + "paths": {}, +} + +VERSION_ID = "My API:1.0.0" + + +def _make_resource(): + mock_client = MagicMock() + mock_service = MagicMock() + mock_client.integration_models = mock_service + resource = Resource(mock_client) + return resource, mock_service + + +class TestIntegrationModelsResourceInit: + """Test suite for Resource initialization.""" + + def test_resource_has_name_attribute(self): + """Test Resource has correct name attribute.""" + assert Resource.name == "integration_models" + + def test_resource_initializes_with_client(self): + """Test Resource initializes with client.""" + mock_client = MagicMock() + resource = Resource(mock_client) + assert resource.client is mock_client + + def test_integration_models_property(self): + """Test integration_models property returns correct service.""" + mock_client = MagicMock() + mock_service = MagicMock() + mock_client.integration_models = mock_service + + resource = Resource(mock_client) + assert resource.integration_models is mock_service + + +class TestImporter: + """Test suite for importer method.""" + + @pytest.mark.asyncio + async def test_importer_creates_new_model_when_none_exists(self): + """Test importer creates model when no existing version is found.""" + resource, mock_service = _make_resource() + mock_service.find_integration_models = AsyncMock(return_value=[]) + mock_service.create_integration_model = AsyncMock( + return_value={"name": VERSION_ID} + ) + + result = await resource.importer(SAMPLE_SPEC) + + assert result == {"name": VERSION_ID} + mock_service.find_integration_models.assert_called_once_with(name=VERSION_ID) + mock_service.create_integration_model.assert_called_once_with(SAMPLE_SPEC) + mock_service.delete_integration_model.assert_not_called() + + @pytest.mark.asyncio + async def test_importer_deletes_existing_model_before_creating(self): + """Test importer deletes existing model before creating new one.""" + resource, mock_service = _make_resource() + mock_service.find_integration_models = AsyncMock( + return_value=[{"name": VERSION_ID}] + ) + mock_service.delete_integration_model = AsyncMock( + return_value={"message": "Deleted"} + ) + mock_service.create_integration_model = AsyncMock( + return_value={"name": VERSION_ID} + ) + + result = await resource.importer(SAMPLE_SPEC) + + assert result == {"name": VERSION_ID} + mock_service.delete_integration_model.assert_called_once_with(VERSION_ID) + mock_service.create_integration_model.assert_called_once_with(SAMPLE_SPEC) + + @pytest.mark.asyncio + async def test_importer_derives_version_id_from_spec(self): + """Test importer correctly derives version_id from spec info block.""" + resource, mock_service = _make_resource() + mock_service.find_integration_models = AsyncMock(return_value=[]) + mock_service.create_integration_model = AsyncMock(return_value={}) + + spec = {"info": {"title": "Other API", "version": "2.5.0"}, "openapi": "3.0.0"} + await resource.importer(spec) + + mock_service.find_integration_models.assert_called_once_with(name="Other API:2.5.0") + + @pytest.mark.asyncio + async def test_importer_raises_on_duplicate_via_service(self): + """Test importer propagates AsyncPlatformError when service raises on duplicate.""" + resource, mock_service = _make_resource() + mock_service.find_integration_models = AsyncMock(return_value=[]) + mock_service.create_integration_model = AsyncMock( + side_effect=exceptions.AsyncPlatformError("already exists") + ) + + with pytest.raises(exceptions.AsyncPlatformError, match="already exists"): + await resource.importer(SAMPLE_SPEC) + + +class TestDelete: + """Test suite for delete method.""" + + @pytest.mark.asyncio + async def test_delete_existing_model(self): + """Test delete removes an existing integration model.""" + resource, mock_service = _make_resource() + mock_service.find_integration_models = AsyncMock( + return_value=[{"name": VERSION_ID}] + ) + mock_service.delete_integration_model = AsyncMock( + return_value={"message": "Deleted"} + ) + + result = await resource.delete(VERSION_ID) + + assert result == {"message": "Deleted"} + mock_service.find_integration_models.assert_called_once_with(name=VERSION_ID) + mock_service.delete_integration_model.assert_called_once_with(VERSION_ID) + + @pytest.mark.asyncio + async def test_delete_returns_empty_dict_when_not_found(self): + """Test delete returns empty dict when no model with version_id exists.""" + resource, mock_service = _make_resource() + mock_service.find_integration_models = AsyncMock(return_value=[]) + + result = await resource.delete(VERSION_ID) + + assert result == {} + mock_service.delete_integration_model.assert_not_called() diff --git a/tests/unit/test_resources_integrations.py b/tests/unit/test_resources_integrations.py new file mode 100644 index 0000000..ad6db6a --- /dev/null +++ b/tests/unit/test_resources_integrations.py @@ -0,0 +1,179 @@ +# Copyright (c) 2025 Itential, Inc +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Unit tests for asyncplatform.resources.integrations module.""" + +from unittest.mock import AsyncMock +from unittest.mock import MagicMock + +import pytest + +from asyncplatform import exceptions +from asyncplatform.resources.integrations import Resource + +SAMPLE_PROPERTIES = {"host": "device.example.com", "port": 22} + + +def _make_resource(): + mock_client = MagicMock() + mock_service = MagicMock() + mock_client.integrations = mock_service + resource = Resource(mock_client) + return resource, mock_service + + +class TestIntegrationsResourceInit: + """Test suite for Resource initialization.""" + + def test_resource_has_name_attribute(self): + """Test Resource has correct name attribute.""" + assert Resource.name == "integrations" + + def test_resource_initializes_with_client(self): + """Test Resource initializes with client.""" + mock_client = MagicMock() + resource = Resource(mock_client) + assert resource.client is mock_client + + def test_integrations_property(self): + """Test integrations property returns correct service.""" + mock_client = MagicMock() + mock_service = MagicMock() + mock_client.integrations = mock_service + + resource = Resource(mock_client) + assert resource.integrations is mock_service + + +class TestImporter: + """Test suite for importer method.""" + + @pytest.mark.asyncio + async def test_importer_creates_instance_when_none_exists(self): + """Test importer creates instance when no existing instance is found.""" + resource, mock_service = _make_resource() + mock_service.create_integration = AsyncMock( + return_value={"name": "my-integration"} + ) + + result = await resource.importer( + name="my-integration", + type="ssh", + properties=SAMPLE_PROPERTIES, + ) + + assert result == {"name": "my-integration"} + mock_service.find_integrations.assert_not_called() + mock_service.create_integration.assert_called_once() + + @pytest.mark.asyncio + async def test_importer_with_overwrite_deletes_existing_before_creating(self): + """Test importer deletes existing instance when overwrite=True.""" + resource, mock_service = _make_resource() + mock_service.find_integrations = AsyncMock( + return_value=[{"name": "my-integration"}] + ) + mock_service.delete_integration = AsyncMock( + return_value={"message": "Deleted"} + ) + mock_service.create_integration = AsyncMock( + return_value={"name": "my-integration"} + ) + + result = await resource.importer( + name="my-integration", + type="ssh", + properties=SAMPLE_PROPERTIES, + overwrite=True, + ) + + assert result == {"name": "my-integration"} + mock_service.find_integrations.assert_called_once_with(name="my-integration") + mock_service.delete_integration.assert_called_once_with("my-integration") + mock_service.create_integration.assert_called_once() + + @pytest.mark.asyncio + async def test_importer_with_overwrite_skips_delete_when_not_found(self): + """Test importer skips delete when overwrite=True but no instance exists.""" + resource, mock_service = _make_resource() + mock_service.find_integrations = AsyncMock(return_value=[]) + mock_service.create_integration = AsyncMock( + return_value={"name": "my-integration"} + ) + + await resource.importer( + name="my-integration", + type="ssh", + properties=SAMPLE_PROPERTIES, + overwrite=True, + ) + + mock_service.find_integrations.assert_called_once_with(name="my-integration") + mock_service.delete_integration.assert_not_called() + mock_service.create_integration.assert_called_once() + + @pytest.mark.asyncio + async def test_importer_raises_on_duplicate_via_service(self): + """Test importer propagates AsyncPlatformError when service raises on duplicate.""" + resource, mock_service = _make_resource() + mock_service.create_integration = AsyncMock( + side_effect=exceptions.AsyncPlatformError("already exists") + ) + + with pytest.raises(exceptions.AsyncPlatformError, match="already exists"): + await resource.importer( + name="my-integration", + type="ssh", + properties=SAMPLE_PROPERTIES, + ) + + @pytest.mark.asyncio + async def test_importer_passes_optional_fields(self): + """Test importer forwards optional virtual and model fields to service.""" + resource, mock_service = _make_resource() + mock_service.create_integration = AsyncMock(return_value={}) + + await resource.importer( + name="my-integration", + type="ssh", + properties=SAMPLE_PROPERTIES, + virtual=True, + model="My API:1.0.0", + ) + + call_kwargs = mock_service.create_integration.call_args[1] + assert call_kwargs["virtual"] is True + assert call_kwargs["model"] == "My API:1.0.0" + + +class TestDelete: + """Test suite for delete method.""" + + @pytest.mark.asyncio + async def test_delete_existing_instance(self): + """Test delete removes an existing integration instance.""" + resource, mock_service = _make_resource() + mock_service.find_integrations = AsyncMock( + return_value=[{"name": "my-integration"}] + ) + mock_service.delete_integration = AsyncMock( + return_value={"message": "Deleted"} + ) + + result = await resource.delete("my-integration") + + assert result == {"message": "Deleted"} + mock_service.find_integrations.assert_called_once_with(name="my-integration") + mock_service.delete_integration.assert_called_once_with("my-integration") + + @pytest.mark.asyncio + async def test_delete_returns_empty_dict_when_not_found(self): + """Test delete returns empty dict when no instance with name exists.""" + resource, mock_service = _make_resource() + mock_service.find_integrations = AsyncMock(return_value=[]) + + result = await resource.delete("my-integration") + + assert result == {} + mock_service.delete_integration.assert_not_called() diff --git a/tests/unit/test_services_integration_models.py b/tests/unit/test_services_integration_models.py new file mode 100644 index 0000000..7b1f82e --- /dev/null +++ b/tests/unit/test_services_integration_models.py @@ -0,0 +1,284 @@ +# Copyright (c) 2025 Itential, Inc +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Unit tests for asyncplatform.services.integration_models module.""" + +from unittest.mock import AsyncMock +from unittest.mock import Mock +from unittest.mock import patch + +import pytest + +from asyncplatform import context +from asyncplatform import exceptions +from asyncplatform.services.integration_models import Service + +SAMPLE_SPEC = { + "info": {"title": "My API", "version": "1.0.0"}, + "openapi": "3.0.0", + "paths": {}, +} + +VERSION_ID = "My API:1.0.0" + + +def _make_service(): + ctx = context.Context() + ctx.client = Mock() + return Service(ctx) + + +def _mock_response(data: dict) -> Mock: + res = Mock() + res.json.return_value = data + return res + + +class TestIntegrationModelsServiceInit: + """Test suite for Service initialization.""" + + def test_service_has_name_attribute(self): + """Test Service has correct name attribute.""" + assert Service.name == "integration_models" + + def test_service_init_with_context(self): + """Test Service initializes with context.""" + ctx = context.Context() + service = Service(ctx) + assert service.ctx is ctx + + def test_service_has_pagination_limit(self): + """Test Service has PAGINATION_LIMIT attribute.""" + assert Service.PAGINATION_LIMIT == 100 + + +class TestFindIntegrationModels: + """Test suite for find_integration_models method.""" + + @pytest.mark.asyncio + async def test_find_returns_empty_list_when_none_exist(self): + """Test find returns empty list when total is zero.""" + service = _make_service() + service.ctx.client.get = AsyncMock( + return_value=_mock_response({"total": 0, "integrationModels": []}) + ) + + result = await service.find_integration_models() + + assert result == [] + + @pytest.mark.asyncio + async def test_find_returns_single_page(self): + """Test find returns results fitting in a single page.""" + models = [{"name": f"model_{i}"} for i in range(3)] + service = _make_service() + service.ctx.client.get = AsyncMock( + return_value=_mock_response({"total": 3, "integrationModels": models}) + ) + + result = await service.find_integration_models() + + assert len(result) == 3 + assert result[0]["name"] == "model_0" + + @pytest.mark.asyncio + async def test_find_paginates_across_multiple_pages(self): + """Test find fetches all pages when total exceeds limit.""" + page1_models = [{"name": f"model_{i}"} for i in range(100)] + page2_models = [{"name": f"model_{i}"} for i in range(100, 150)] + + page1 = _mock_response({"total": 150, "integrationModels": page1_models}) + page2 = _mock_response({"total": 150, "integrationModels": page2_models}) + + service = _make_service() + service.ctx.client.get = AsyncMock(side_effect=[page1, page2]) + + result = await service.find_integration_models() + + assert len(result) == 150 + assert service.ctx.client.get.call_count == 2 + + @pytest.mark.asyncio + async def test_find_with_name_filter(self): + """Test find passes name filter as query params.""" + model = [{"name": VERSION_ID}] + service = _make_service() + service.ctx.client.get = AsyncMock( + return_value=_mock_response({"total": 1, "integrationModels": model}) + ) + + result = await service.find_integration_models(name=VERSION_ID) + + assert len(result) == 1 + call_kwargs = service.ctx.client.get.call_args[1] + assert call_kwargs["params"]["equals"] == VERSION_ID + + @pytest.mark.asyncio + async def test_find_propagates_exception_from_pagination(self): + """Test find raises AsyncPlatformError when a page request fails.""" + page1 = _mock_response({"total": 150, "integrationModels": [{"name": "m"}] * 100}) + + service = _make_service() + service.ctx.client.get = AsyncMock( + side_effect=[page1, RuntimeError("Network error")] + ) + + with pytest.raises(exceptions.AsyncPlatformError): + await service.find_integration_models() + + +class TestCreateIntegrationModel: + """Test suite for create_integration_model method.""" + + @pytest.mark.asyncio + async def test_create_succeeds_when_no_existing_model(self): + """Test create succeeds when no model with same version_id exists.""" + service = _make_service() + service.ctx.client.get = AsyncMock( + return_value=_mock_response({"total": 0, "integrationModels": []}) + ) + service.ctx.client.post = AsyncMock( + return_value=_mock_response({"message": "Created", "data": {"name": VERSION_ID}}) + ) + + result = await service.create_integration_model(SAMPLE_SPEC) + + assert result == {"name": VERSION_ID} + service.ctx.client.post.assert_called_once() + + @pytest.mark.asyncio + async def test_create_raises_when_model_already_exists(self): + """Test create raises AsyncPlatformError when version_id already exists.""" + service = _make_service() + service.ctx.client.get = AsyncMock( + return_value=_mock_response({"total": 1, "integrationModels": [{"name": VERSION_ID}]}) + ) + + with pytest.raises(exceptions.AsyncPlatformError, match="already exists"): + await service.create_integration_model(SAMPLE_SPEC) + + service.ctx.client.post.assert_not_called() + + @pytest.mark.asyncio + async def test_create_raises_when_spec_too_large(self): + """Test create raises AsyncPlatformError when spec exceeds 15 MB.""" + service = _make_service() + service.ctx.client.get = AsyncMock( + return_value=_mock_response({"total": 0, "integrationModels": []}) + ) + + with patch("json.dumps", return_value="x" * (15 * 1024 * 1024 + 1)): + with pytest.raises(exceptions.AsyncPlatformError, match="exceeds"): + await service.create_integration_model(SAMPLE_SPEC) + + service.ctx.client.post.assert_not_called() + + @pytest.mark.asyncio + async def test_create_posts_spec_wrapped_in_model_key(self): + """Test create sends spec under the 'model' key in the request body.""" + service = _make_service() + service.ctx.client.get = AsyncMock( + return_value=_mock_response({"total": 0, "integrationModels": []}) + ) + service.ctx.client.post = AsyncMock( + return_value=_mock_response({"message": "Created", "data": {}}) + ) + + await service.create_integration_model(SAMPLE_SPEC) + + call_kwargs = service.ctx.client.post.call_args[1] + assert call_kwargs["json"]["model"] == SAMPLE_SPEC + + +class TestGetIntegrationModel: + """Test suite for get_integration_model method.""" + + @pytest.mark.asyncio + async def test_get_returns_model_data(self): + """Test get returns integration model data.""" + expected = {"name": VERSION_ID, "model": {}} + service = _make_service() + service.ctx.client.get = AsyncMock(return_value=_mock_response(expected)) + + result = await service.get_integration_model(VERSION_ID) + + assert result == expected + + @pytest.mark.asyncio + async def test_get_calls_correct_endpoint(self): + """Test get calls the correct endpoint path.""" + service = _make_service() + service.ctx.client.get = AsyncMock(return_value=_mock_response({})) + + await service.get_integration_model(VERSION_ID) + + call_args = service.ctx.client.get.call_args + assert f"/integration-models/{VERSION_ID}" in call_args[0][0] + + +class TestUpdateIntegrationModel: + """Test suite for update_integration_model method.""" + + @pytest.mark.asyncio + async def test_update_succeeds(self): + """Test update sends spec and returns updated model data.""" + service = _make_service() + service.ctx.client.put = AsyncMock( + return_value=_mock_response({"message": "Updated", "data": {"name": VERSION_ID}}) + ) + + result = await service.update_integration_model(SAMPLE_SPEC) + + assert result == {"name": VERSION_ID} + service.ctx.client.put.assert_called_once() + + @pytest.mark.asyncio + async def test_update_raises_when_spec_too_large(self): + """Test update raises AsyncPlatformError when spec exceeds 15 MB.""" + service = _make_service() + + with patch("json.dumps", return_value="x" * (15 * 1024 * 1024 + 1)): + with pytest.raises(exceptions.AsyncPlatformError, match="exceeds"): + await service.update_integration_model(SAMPLE_SPEC) + + service.ctx.client.put.assert_not_called() + + @pytest.mark.asyncio + async def test_update_puts_spec_wrapped_in_model_key(self): + """Test update sends spec under the 'model' key.""" + service = _make_service() + service.ctx.client.put = AsyncMock( + return_value=_mock_response({"message": "Updated", "data": {}}) + ) + + await service.update_integration_model(SAMPLE_SPEC) + + call_kwargs = service.ctx.client.put.call_args[1] + assert call_kwargs["json"]["model"] == SAMPLE_SPEC + + +class TestDeleteIntegrationModel: + """Test suite for delete_integration_model method.""" + + @pytest.mark.asyncio + async def test_delete_returns_response_data(self): + """Test delete returns the response body.""" + expected = {"message": "Deleted", "name": VERSION_ID} + service = _make_service() + service.ctx.client.delete = AsyncMock(return_value=_mock_response(expected)) + + result = await service.delete_integration_model(VERSION_ID) + + assert result == expected + + @pytest.mark.asyncio + async def test_delete_calls_correct_endpoint(self): + """Test delete calls the correct endpoint path.""" + service = _make_service() + service.ctx.client.delete = AsyncMock(return_value=_mock_response({})) + + await service.delete_integration_model(VERSION_ID) + + call_args = service.ctx.client.delete.call_args + assert f"/integration-models/{VERSION_ID}" in call_args[0][0] diff --git a/tests/unit/test_services_integrations.py b/tests/unit/test_services_integrations.py new file mode 100644 index 0000000..a9c89ed --- /dev/null +++ b/tests/unit/test_services_integrations.py @@ -0,0 +1,290 @@ +# Copyright (c) 2025 Itential, Inc +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Unit tests for asyncplatform.services.integrations module.""" + +from unittest.mock import AsyncMock +from unittest.mock import Mock + +import pytest + +from asyncplatform import context +from asyncplatform import exceptions +from asyncplatform.services.integrations import Service + +SAMPLE_PROPERTIES = {"host": "device.example.com", "port": 22} + + +def _make_service(): + ctx = context.Context() + ctx.client = Mock() + return Service(ctx) + + +def _mock_response(data: dict) -> Mock: + res = Mock() + res.json.return_value = data + return res + + +class TestIntegrationsServiceInit: + """Test suite for Service initialization.""" + + def test_service_has_name_attribute(self): + """Test Service has correct name attribute.""" + assert Service.name == "integrations" + + def test_service_init_with_context(self): + """Test Service initializes with context.""" + ctx = context.Context() + service = Service(ctx) + assert service.ctx is ctx + + def test_service_has_pagination_limit(self): + """Test Service has PAGINATION_LIMIT attribute.""" + assert Service.PAGINATION_LIMIT == 100 + + +class TestFindIntegrations: + """Test suite for find_integrations method.""" + + @pytest.mark.asyncio + async def test_find_returns_empty_list_when_none_exist(self): + """Test find returns empty list when total is zero.""" + service = _make_service() + service.ctx.client.get = AsyncMock( + return_value=_mock_response({"total": 0, "integrations": []}) + ) + + result = await service.find_integrations() + + assert result == [] + + @pytest.mark.asyncio + async def test_find_returns_single_page(self): + """Test find returns results fitting in a single page.""" + instances = [{"name": f"instance_{i}"} for i in range(3)] + service = _make_service() + service.ctx.client.get = AsyncMock( + return_value=_mock_response({"total": 3, "integrations": instances}) + ) + + result = await service.find_integrations() + + assert len(result) == 3 + assert result[0]["name"] == "instance_0" + + @pytest.mark.asyncio + async def test_find_paginates_across_multiple_pages(self): + """Test find fetches all pages when total exceeds limit.""" + page1_instances = [{"name": f"instance_{i}"} for i in range(100)] + page2_instances = [{"name": f"instance_{i}"} for i in range(100, 150)] + + page1 = _mock_response({"total": 150, "integrations": page1_instances}) + page2 = _mock_response({"total": 150, "integrations": page2_instances}) + + service = _make_service() + service.ctx.client.get = AsyncMock(side_effect=[page1, page2]) + + result = await service.find_integrations() + + assert len(result) == 150 + assert service.ctx.client.get.call_count == 2 + + @pytest.mark.asyncio + async def test_find_with_name_filter(self): + """Test find passes name filter as query params.""" + instance = [{"name": "my-integration"}] + service = _make_service() + service.ctx.client.get = AsyncMock( + return_value=_mock_response({"total": 1, "integrations": instance}) + ) + + result = await service.find_integrations(name="my-integration") + + assert len(result) == 1 + call_kwargs = service.ctx.client.get.call_args[1] + assert call_kwargs["params"]["equals"] == "my-integration" + + @pytest.mark.asyncio + async def test_find_sorts_by_name(self): + """Test find sorts results by name for consistent pagination.""" + service = _make_service() + service.ctx.client.get = AsyncMock( + return_value=_mock_response({"total": 0, "integrations": []}) + ) + + await service.find_integrations() + + call_kwargs = service.ctx.client.get.call_args[1] + assert call_kwargs["params"]["sort"] == "name" + + @pytest.mark.asyncio + async def test_find_propagates_exception_from_pagination(self): + """Test find raises AsyncPlatformError when a page request fails.""" + page1 = _mock_response({"total": 150, "integrations": [{"name": "i"}] * 100}) + + service = _make_service() + service.ctx.client.get = AsyncMock( + side_effect=[page1, RuntimeError("Network error")] + ) + + with pytest.raises(exceptions.AsyncPlatformError): + await service.find_integrations() + + +class TestCreateIntegration: + """Test suite for create_integration method.""" + + @pytest.mark.asyncio + async def test_create_succeeds_when_no_existing_instance(self): + """Test create succeeds when no instance with same name exists.""" + service = _make_service() + service.ctx.client.get = AsyncMock( + return_value=_mock_response({"total": 0, "integrations": []}) + ) + service.ctx.client.post = AsyncMock( + return_value=_mock_response({"message": "Created", "data": {"name": "my-integration"}}) + ) + + result = await service.create_integration( + name="my-integration", + type="ssh", + properties=SAMPLE_PROPERTIES, + ) + + assert result == {"name": "my-integration"} + service.ctx.client.post.assert_called_once() + + @pytest.mark.asyncio + async def test_create_raises_when_instance_already_exists(self): + """Test create raises AsyncPlatformError when name already exists.""" + service = _make_service() + service.ctx.client.get = AsyncMock( + return_value=_mock_response({"total": 1, "integrations": [{"name": "my-integration"}]}) + ) + + with pytest.raises(exceptions.AsyncPlatformError, match="already exists"): + await service.create_integration( + name="my-integration", + type="ssh", + properties=SAMPLE_PROPERTIES, + ) + + service.ctx.client.post.assert_not_called() + + @pytest.mark.asyncio + async def test_create_includes_optional_virtual_flag(self): + """Test create includes virtual flag when provided.""" + service = _make_service() + service.ctx.client.get = AsyncMock( + return_value=_mock_response({"total": 0, "integrations": []}) + ) + service.ctx.client.post = AsyncMock( + return_value=_mock_response({"message": "Created", "data": {}}) + ) + + await service.create_integration( + name="my-integration", + type="ssh", + properties=SAMPLE_PROPERTIES, + virtual=True, + ) + + call_kwargs = service.ctx.client.post.call_args[1] + assert call_kwargs["json"]["virtual"] is True + + @pytest.mark.asyncio + async def test_create_includes_optional_model(self): + """Test create includes model when provided.""" + service = _make_service() + service.ctx.client.get = AsyncMock( + return_value=_mock_response({"total": 0, "integrations": []}) + ) + service.ctx.client.post = AsyncMock( + return_value=_mock_response({"message": "Created", "data": {}}) + ) + + await service.create_integration( + name="my-integration", + type="ssh", + properties=SAMPLE_PROPERTIES, + model="My API:1.0.0", + ) + + call_kwargs = service.ctx.client.post.call_args[1] + assert call_kwargs["json"]["model"] == "My API:1.0.0" + + @pytest.mark.asyncio + async def test_create_omits_none_optional_fields(self): + """Test create omits virtual and model when not provided.""" + service = _make_service() + service.ctx.client.get = AsyncMock( + return_value=_mock_response({"total": 0, "integrations": []}) + ) + service.ctx.client.post = AsyncMock( + return_value=_mock_response({"message": "Created", "data": {}}) + ) + + await service.create_integration( + name="my-integration", + type="ssh", + properties=SAMPLE_PROPERTIES, + ) + + call_kwargs = service.ctx.client.post.call_args[1] + assert "virtual" not in call_kwargs["json"] + assert "model" not in call_kwargs["json"] + + +class TestGetIntegration: + """Test suite for get_integration method.""" + + @pytest.mark.asyncio + async def test_get_returns_integration_data(self): + """Test get returns integration instance data.""" + expected = {"metadata": {"isActive": True}, "data": {"type": "ssh"}} + service = _make_service() + service.ctx.client.get = AsyncMock(return_value=_mock_response(expected)) + + result = await service.get_integration("my-integration") + + assert result == expected + + @pytest.mark.asyncio + async def test_get_calls_correct_endpoint(self): + """Test get calls the correct endpoint path.""" + service = _make_service() + service.ctx.client.get = AsyncMock(return_value=_mock_response({})) + + await service.get_integration("my-integration") + + call_args = service.ctx.client.get.call_args + assert "/integrations/my-integration" in call_args[0][0] + + +class TestDeleteIntegration: + """Test suite for delete_integration method.""" + + @pytest.mark.asyncio + async def test_delete_returns_response_data(self): + """Test delete returns the response body.""" + expected = {"message": "Deleted", "name": "my-integration"} + service = _make_service() + service.ctx.client.delete = AsyncMock(return_value=_mock_response(expected)) + + result = await service.delete_integration("my-integration") + + assert result == expected + + @pytest.mark.asyncio + async def test_delete_calls_correct_endpoint(self): + """Test delete calls the correct endpoint path.""" + service = _make_service() + service.ctx.client.delete = AsyncMock(return_value=_mock_response({})) + + await service.delete_integration("my-integration") + + call_args = service.ctx.client.delete.call_args + assert "/integrations/my-integration" in call_args[0][0]