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]