diff --git a/src/asyncplatform/resources/__init__.py b/src/asyncplatform/resources/__init__.py index dd36211..a410e06 100644 --- a/src/asyncplatform/resources/__init__.py +++ b/src/asyncplatform/resources/__init__.py @@ -67,6 +67,11 @@ def lifecycle_manager(self) -> Any: """Get the Lifecycle Manager service instance.""" return self.client.lifecycle_manager + @property + def configuration_manager(self) -> Any: + """Get the Configuration Manager service instance.""" + return self.client.configuration_manager + @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/configuration_manager.py b/src/asyncplatform/resources/configuration_manager.py new file mode 100644 index 0000000..fa2946b --- /dev/null +++ b/src/asyncplatform/resources/configuration_manager.py @@ -0,0 +1,178 @@ +# 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 + +"""Configuration Manager resource for managing Itential Platform Golden Config trees. + +This module provides the Resource class for high-level Configuration Manager +operations including exporting and importing Golden Config trees. +""" + +from __future__ import annotations + +import copy + +from typing import Any + +from asyncplatform import exceptions +from asyncplatform import logging +from asyncplatform.resources import ResourceBase + + +class Resource(ResourceBase): + """Resource class for managing Configuration Manager Golden Config trees. + + This resource provides high-level operations for Configuration Manager + golden config management including exporting a golden config tree by ID + and importing one or more golden config trees into the platform. + + Attributes: + configuration_manager: Property that returns the Configuration Manager + service instance + """ + + name: str = "configuration_manager" + + async def check_if_golden_config_exists(self, name: str) -> bool: + """Check if a Golden Config tree with the given name exists. + + Args: + name: The Golden Config tree name to search for + + Returns: + True if at least one tree with the specified name exists, + False otherwise + + Raises: + AsyncPlatformError: If the API request fails + """ + configs = await self.configuration_manager.find_golden_configs(name=name) + return len(configs) > 0 + + async def _ensure_golden_config_is_new(self, name: str) -> None: + """Ensure that a Golden Config tree with the given name does not exist. + + Args: + name: The Golden Config tree name to check + + Raises: + AsyncPlatformError: If a Golden Config tree with the name already exists + """ + if await self.check_if_golden_config_exists(name): + raise exceptions.AsyncPlatformError( + f"Golden Config tree `{name}` already exists" + ) + + @logging.trace + async def get_golden_config_by_name(self, name: str) -> dict[str, Any] | None: + """Retrieve a Golden Config tree by name. + + Searches for a Golden Config tree by exact name and returns the first match. + + Args: + name: The Golden Config tree name to search for + + Returns: + The Golden Config tree dictionary if an exact name match is found, + None otherwise + + Raises: + AsyncPlatformError: If the API request fails + """ + configs = await self.configuration_manager.find_golden_configs(name=name) + return next((c for c in configs if c.get("name") == name), None) + + @logging.trace + async def delete(self, name: str) -> None: + """Delete a Golden Config tree by name. + + Searches for a Golden Config tree by name and deletes it if found. + + Args: + name: The name of the Golden Config tree to delete + + Raises: + AsyncPlatformError: If the delete operation fails + """ + config = await self.get_golden_config_by_name(name) + if config is not None: + await self.configuration_manager.delete_golden_config(config["id"]) + + @logging.trace + async def importer( + self, + trees: list[dict[str, Any]], + *, + options: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Import one or more Golden Config trees with validation. + + Creates a deep copy of the input trees to avoid mutation, then imports + them into the Configuration Manager. Typically used to restore exported + trees or migrate configurations between environments. + + Args: + trees: List of Golden Config tree objects to import. Each object + contains a ``data`` key with a list of tree version summary + documents. + options: Optional import options dictionary + + Returns: + A dictionary containing the import result with keys: + - status: "success" + - message: Human-readable summary (e.g. "2 golden config trees + imported successfully") + + Raises: + AsyncPlatformError: If a Golden Config tree with the same name already + exists, or if the import request fails or data is invalid + """ + trees = copy.deepcopy(trees) + + for tree in trees: + name = tree["data"][0]["name"] + await self._ensure_golden_config_is_new(name) + + result = await self.configuration_manager.import_golden_config( + trees, options=options + ) + + logging.info(f"Successfully imported {len(trees)} golden config tree(s)") + + return result + + @logging.trace + async def import_golden_config( + self, + trees: list[dict[str, Any]], + *, + options: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Import one or more Golden Config trees. + + Delegates to the Configuration Manager service to insert the provided + golden config tree documents into the platform. Typically used to restore + exported trees or migrate configurations between environments. + + Args: + trees: List of Golden Config tree objects to import. Each object + contains a ``data`` key with a list of tree version summary + documents. + options: Optional import options dictionary + + Returns: + A dictionary containing the import result with keys: + - status: "success" + - message: Human-readable summary (e.g. "2 golden config trees + imported successfully") + + Raises: + AsyncPlatformError: If the import request fails or data is invalid + """ + result = await self.configuration_manager.import_golden_config( + trees, options=options + ) + + logging.info(f"Successfully imported {len(trees)} golden config tree(s)") + + return result diff --git a/src/asyncplatform/services/configuration_manager.py b/src/asyncplatform/services/configuration_manager.py index ea64a2e..8ab2e8b 100644 --- a/src/asyncplatform/services/configuration_manager.py +++ b/src/asyncplatform/services/configuration_manager.py @@ -968,3 +968,100 @@ async def search_configs(self, query: dict[str, Any]) -> dict[str, Any]: expected_status=HTTPStatus.OK, ) return res.json() + + # Golden Config Management + + @logging.trace + async def find_golden_configs( + self, + *, + name: str | None = None, + ) -> list[dict[str, Any]]: + """Search for Golden Config trees by name. + + Fetches all Golden Config trees and filters them client-side. If no + name is provided, all trees are returned without filtering. + + Args: + name: Optional exact name to filter by. If None, no filtering + is applied and all trees are returned. + + Returns: + A list of Golden Config tree dictionaries matching the criteria. + Returns an empty list if no matching trees are found. + + Raises: + AsyncPlatformError: If the API request fails + """ + res = await self.get("/configuration_manager/configs") + configs: list[dict[str, Any]] = res.json() + + if name is not None: + configs = [c for c in configs if c.get("name") == name] + + logging.info(f"Found {len(configs)} golden config(s)") + + return configs + + @logging.trace + async def delete_golden_config(self, tree_id: str) -> dict[str, Any]: + """Delete a Golden Config tree by ID. + + Permanently removes the specified Golden Config tree from the platform. + This operation cannot be undone. + + Args: + tree_id: The unique identifier of the Golden Config tree to delete + + Returns: + A dictionary containing deletion status with keys: + - status: "success" or "conflict" + - deleted: Number of trees deleted + + Raises: + AsyncPlatformError: If the deletion request fails + """ + res = await self.delete( + f"/configuration_manager/configs/{tree_id}", + params={}, + expected_status=HTTPStatus.OK, + ) + return res.json() + + @logging.trace + async def import_golden_config( + self, + trees: list[dict[str, Any]], + *, + options: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Import Golden Config tree documents. + + Inserts Golden Config documents into the golden config collection. + Typically used to restore exported trees or migrate between environments. + + Args: + trees: List of Golden Config tree objects to import. Each object + contains a ``data`` key with a list of tree version summary + documents. + options: Optional import options dictionary + + Returns: + A dictionary containing the import result with keys: + - status: "success" + - message: Human-readable summary (e.g. "2 golden config trees + imported successfully") + + Raises: + AsyncPlatformError: If the import request fails or data is invalid + """ + payload: dict[str, Any] = {"trees": trees} + if options is not None: + payload["options"] = options + + res = await self.post( + "/configuration_manager/import/goldenconfigs", + json=payload, + expected_status=HTTPStatus.OK, + ) + return res.json() diff --git a/tests/unit/test_resources_configuration_manager.py b/tests/unit/test_resources_configuration_manager.py new file mode 100644 index 0000000..7f530c9 --- /dev/null +++ b/tests/unit/test_resources_configuration_manager.py @@ -0,0 +1,429 @@ +# 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.configuration_manager module.""" + +import copy + +from unittest.mock import AsyncMock +from unittest.mock import MagicMock + +import pytest + +from asyncplatform import exceptions +from asyncplatform.resources.configuration_manager import Resource + + +class TestResourceInit: + """Test suite for Resource initialization.""" + + def test_resource_initialization(self): + """Test that Resource initializes correctly with a client.""" + mock_client = MagicMock() + resource = Resource(mock_client) + + assert resource.client is mock_client + + def test_resource_name_attribute(self): + """Test that Resource has correct name attribute.""" + assert hasattr(Resource, "name") + assert Resource.name == "configuration_manager" + + def test_resource_configuration_manager_property(self): + """Test that configuration_manager property returns client's service.""" + mock_client = MagicMock() + mock_cm = MagicMock() + mock_client.configuration_manager = mock_cm + + resource = Resource(mock_client) + assert resource.configuration_manager is mock_cm + + +class TestCheckIfGoldenConfigExists: + """Test suite for check_if_golden_config_exists method.""" + + @pytest.mark.asyncio + async def test_returns_true_when_config_exists(self): + """Test returns True when a matching Golden Config tree is found.""" + mock_client = MagicMock() + mock_cm = MagicMock() + mock_cm.find_golden_configs = AsyncMock( + return_value=[{"_id": "tree1", "name": "Cisco Edge - Day 0"}] + ) + mock_client.configuration_manager = mock_cm + + resource = Resource(mock_client) + result = await resource.check_if_golden_config_exists("Cisco Edge - Day 0") + + assert result is True + mock_cm.find_golden_configs.assert_called_once_with(name="Cisco Edge - Day 0") + + @pytest.mark.asyncio + async def test_returns_false_when_config_not_found(self): + """Test returns False when no matching Golden Config tree is found.""" + mock_client = MagicMock() + mock_cm = MagicMock() + mock_cm.find_golden_configs = AsyncMock(return_value=[]) + mock_client.configuration_manager = mock_cm + + resource = Resource(mock_client) + result = await resource.check_if_golden_config_exists("NonExistent Tree") + + assert result is False + + @pytest.mark.asyncio + async def test_returns_false_when_api_returns_empty(self): + """Test returns False when the API returns an empty list.""" + mock_client = MagicMock() + mock_cm = MagicMock() + mock_cm.find_golden_configs = AsyncMock(return_value=[]) + mock_client.configuration_manager = mock_cm + + resource = Resource(mock_client) + result = await resource.check_if_golden_config_exists("Any Tree") + + assert result is False + + +class TestEnsureGoldenConfigIsNew: + """Test suite for _ensure_golden_config_is_new method.""" + + @pytest.mark.asyncio + async def test_succeeds_when_config_does_not_exist(self): + """Test passes without raising when config does not exist.""" + mock_client = MagicMock() + mock_cm = MagicMock() + mock_cm.find_golden_configs = AsyncMock(return_value=[]) + mock_client.configuration_manager = mock_cm + + resource = Resource(mock_client) + + # Should not raise + await resource._ensure_golden_config_is_new("New Tree") + + @pytest.mark.asyncio + async def test_raises_when_config_already_exists(self): + """Test raises AsyncPlatformError when a config with the name already exists.""" + mock_client = MagicMock() + mock_cm = MagicMock() + mock_cm.find_golden_configs = AsyncMock( + return_value=[{"_id": "tree1", "name": "Existing Tree"}] + ) + mock_client.configuration_manager = mock_cm + + resource = Resource(mock_client) + + with pytest.raises(exceptions.AsyncPlatformError) as exc_info: + await resource._ensure_golden_config_is_new("Existing Tree") + + assert "already exists" in str(exc_info.value) + assert "Existing Tree" in str(exc_info.value) + + +class TestGetGoldenConfigByName: + """Test suite for get_golden_config_by_name method.""" + + @pytest.mark.asyncio + async def test_returns_config_when_found(self): + """Test returns the matching config dict when an exact name match exists.""" + mock_client = MagicMock() + mock_cm = MagicMock() + expected = {"_id": "tree1", "name": "Cisco Edge - Day 0"} + mock_cm.find_golden_configs = AsyncMock(return_value=[expected]) + mock_client.configuration_manager = mock_cm + + resource = Resource(mock_client) + result = await resource.get_golden_config_by_name("Cisco Edge - Day 0") + + assert result == expected + mock_cm.find_golden_configs.assert_called_once_with(name="Cisco Edge - Day 0") + + @pytest.mark.asyncio + async def test_returns_none_when_not_found(self): + """Test returns None when no config with the given name exists.""" + mock_client = MagicMock() + mock_cm = MagicMock() + mock_cm.find_golden_configs = AsyncMock(return_value=[]) + mock_client.configuration_manager = mock_cm + + resource = Resource(mock_client) + result = await resource.get_golden_config_by_name("NonExistent Tree") + + assert result is None + + @pytest.mark.asyncio + async def test_returns_exact_name_match_only(self): + """Test returns only the config with an exact name match.""" + mock_client = MagicMock() + mock_cm = MagicMock() + mock_cm.find_golden_configs = AsyncMock( + return_value=[ + {"_id": "tree1", "name": "Cisco Edge"}, + {"_id": "tree2", "name": "Cisco Edge - Day 0"}, + {"_id": "tree3", "name": "Cisco Edge - Day 1"}, + ] + ) + mock_client.configuration_manager = mock_cm + + resource = Resource(mock_client) + result = await resource.get_golden_config_by_name("Cisco Edge - Day 0") + + assert result == {"_id": "tree2", "name": "Cisco Edge - Day 0"} + + @pytest.mark.asyncio + async def test_returns_none_when_api_returns_non_matching_names(self): + """Test returns None when API results contain no exact name match.""" + mock_client = MagicMock() + mock_cm = MagicMock() + mock_cm.find_golden_configs = AsyncMock( + return_value=[{"_id": "tree1", "name": "Cisco Core - Day 0"}] + ) + mock_client.configuration_manager = mock_cm + + resource = Resource(mock_client) + result = await resource.get_golden_config_by_name("Cisco Edge - Day 0") + + assert result is None + + +class TestDelete: + """Test suite for delete method.""" + + @pytest.mark.asyncio + async def test_delete_calls_service_with_config_id(self): + """Test delete finds the config and calls delete_golden_config with its ID.""" + mock_client = MagicMock() + mock_cm = MagicMock() + mock_cm.find_golden_configs = AsyncMock( + return_value=[{"id": "tree-abc", "name": "Cisco Edge - Day 0"}] + ) + mock_cm.delete_golden_config = AsyncMock( + return_value={"status": "success", "deleted": 1} + ) + mock_client.configuration_manager = mock_cm + + resource = Resource(mock_client) + await resource.delete("Cisco Edge - Day 0") + + mock_cm.delete_golden_config.assert_called_once_with("tree-abc") + + @pytest.mark.asyncio + async def test_delete_does_nothing_when_not_found(self): + """Test delete does not call the service when config is not found.""" + mock_client = MagicMock() + mock_cm = MagicMock() + mock_cm.find_golden_configs = AsyncMock(return_value=[]) + mock_client.configuration_manager = mock_cm + + resource = Resource(mock_client) + await resource.delete("NonExistent Tree") + + mock_cm.delete_golden_config.assert_not_called() + + @pytest.mark.asyncio + async def test_delete_uses_id_field(self): + """Test delete uses the id field from the found config.""" + mock_client = MagicMock() + mock_cm = MagicMock() + mock_cm.find_golden_configs = AsyncMock( + return_value=[{"id": "abc-xyz-123", "name": "MyTree"}] + ) + mock_cm.delete_golden_config = AsyncMock(return_value={}) + mock_client.configuration_manager = mock_cm + + resource = Resource(mock_client) + await resource.delete("MyTree") + + mock_cm.delete_golden_config.assert_called_once_with("abc-xyz-123") + + +class TestImporter: + """Test suite for importer method.""" + + @pytest.mark.asyncio + async def test_importer_success(self): + """Test importer successfully imports trees when no duplicates exist.""" + mock_client = MagicMock() + mock_cm = MagicMock() + mock_cm.find_golden_configs = AsyncMock(return_value=[]) + mock_cm.import_golden_config = AsyncMock( + return_value={ + "status": "success", + "message": "1 golden config trees imported", + } + ) + mock_client.configuration_manager = mock_cm + + resource = Resource(mock_client) + + trees = [{"data": [{"name": "Cisco Edge - Day 0", "version": "1.0"}]}] + result = await resource.importer(trees) + + assert result["status"] == "success" + mock_cm.import_golden_config.assert_called_once() + + @pytest.mark.asyncio + async def test_importer_raises_when_tree_already_exists(self): + """Test importer raises AsyncPlatformError when a tree with same name exists.""" + mock_client = MagicMock() + mock_cm = MagicMock() + mock_cm.find_golden_configs = AsyncMock( + return_value=[{"_id": "tree1", "name": "Cisco Edge - Day 0"}] + ) + mock_client.configuration_manager = mock_cm + + resource = Resource(mock_client) + + trees = [{"data": [{"name": "Cisco Edge - Day 0", "version": "1.0"}]}] + + with pytest.raises(exceptions.AsyncPlatformError) as exc_info: + await resource.importer(trees) + + assert "already exists" in str(exc_info.value) + assert "Cisco Edge - Day 0" in str(exc_info.value) + mock_cm.import_golden_config.assert_not_called() + + @pytest.mark.asyncio + async def test_importer_does_not_mutate_input(self): + """Test importer deep-copies trees so the caller's data is not modified.""" + mock_client = MagicMock() + mock_cm = MagicMock() + mock_cm.find_golden_configs = AsyncMock(return_value=[]) + mock_cm.import_golden_config = AsyncMock( + return_value={"status": "success", "message": "imported"} + ) + mock_client.configuration_manager = mock_cm + + resource = Resource(mock_client) + + original_trees = [{"data": [{"name": "Tree A", "version": "1.0"}]}] + original_copy = copy.deepcopy(original_trees) + + await resource.importer(original_trees) + + assert original_trees == original_copy + + @pytest.mark.asyncio + async def test_importer_passes_options_to_service(self): + """Test importer forwards options to the service call.""" + mock_client = MagicMock() + mock_cm = MagicMock() + mock_cm.find_golden_configs = AsyncMock(return_value=[]) + mock_cm.import_golden_config = AsyncMock( + return_value={"status": "success", "message": "imported"} + ) + mock_client.configuration_manager = mock_cm + + resource = Resource(mock_client) + + trees = [{"data": [{"name": "Tree A"}]}] + options = {"overwrite": True} + await resource.importer(trees, options=options) + + mock_cm.import_golden_config.assert_called_once_with(trees, options=options) + + @pytest.mark.asyncio + async def test_importer_checks_all_trees_before_importing(self): + """Test importer checks existence for every tree before calling import.""" + mock_client = MagicMock() + mock_cm = MagicMock() + # First call (Tree A) returns empty, second call (Tree B) returns a match + mock_cm.find_golden_configs = AsyncMock( + side_effect=[ + [], + [{"_id": "tree2", "name": "Tree B"}], + ] + ) + mock_client.configuration_manager = mock_cm + + resource = Resource(mock_client) + + trees = [ + {"data": [{"name": "Tree A"}]}, + {"data": [{"name": "Tree B"}]}, + ] + + with pytest.raises(exceptions.AsyncPlatformError) as exc_info: + await resource.importer(trees) + + assert "Tree B" in str(exc_info.value) + mock_cm.import_golden_config.assert_not_called() + + @pytest.mark.asyncio + async def test_importer_returns_service_result(self): + """Test importer returns the full result from the service.""" + mock_client = MagicMock() + mock_cm = MagicMock() + mock_cm.find_golden_configs = AsyncMock(return_value=[]) + expected = { + "status": "success", + "message": "2 golden config trees imported successfully", + } + mock_cm.import_golden_config = AsyncMock(return_value=expected) + mock_client.configuration_manager = mock_cm + + resource = Resource(mock_client) + + trees = [ + {"data": [{"name": "Tree A"}]}, + {"data": [{"name": "Tree B"}]}, + ] + result = await resource.importer(trees) + + assert result == expected + + +class TestImportGoldenConfig: + """Test suite for import_golden_config method.""" + + @pytest.mark.asyncio + async def test_import_returns_service_result(self): + """Test import_golden_config returns the full result from the service.""" + mock_client = MagicMock() + mock_cm = MagicMock() + expected = { + "status": "success", + "message": "1 golden config trees imported successfully", + } + mock_cm.import_golden_config = AsyncMock(return_value=expected) + mock_client.configuration_manager = mock_cm + + resource = Resource(mock_client) + trees = [{"data": [{"name": "Tree A"}]}] + result = await resource.import_golden_config(trees) + + assert result == expected + + @pytest.mark.asyncio + async def test_import_forwards_trees_to_service(self): + """Test import_golden_config passes trees to the service.""" + mock_client = MagicMock() + mock_cm = MagicMock() + mock_cm.import_golden_config = AsyncMock( + return_value={"status": "success", "message": "imported"} + ) + mock_client.configuration_manager = mock_cm + + resource = Resource(mock_client) + trees = [{"data": [{"name": "Tree A"}]}, {"data": [{"name": "Tree B"}]}] + await resource.import_golden_config(trees) + + mock_cm.import_golden_config.assert_called_once_with(trees, options=None) + + @pytest.mark.asyncio + async def test_import_forwards_options_to_service(self): + """Test import_golden_config passes options kwarg to the service.""" + mock_client = MagicMock() + mock_cm = MagicMock() + mock_cm.import_golden_config = AsyncMock( + return_value={"status": "success", "message": "imported"} + ) + mock_client.configuration_manager = mock_cm + + resource = Resource(mock_client) + trees = [{"data": [{"name": "Tree A"}]}] + options = {"overwrite": True} + await resource.import_golden_config(trees, options=options) + + mock_cm.import_golden_config.assert_called_once_with(trees, options=options) diff --git a/tests/unit/test_services_configuration_manager.py b/tests/unit/test_services_configuration_manager.py index ef865c3..69fe640 100644 --- a/tests/unit/test_services_configuration_manager.py +++ b/tests/unit/test_services_configuration_manager.py @@ -1195,3 +1195,199 @@ async def test_search_configs(self): call_args = mock_client.post.call_args assert call_args[0][0] == "/configuration_manager/search/configs" assert call_args[1]["json"] == query + + +class TestConfigurationManagerGoldenConfigs: + """Test suite for Golden Config management methods.""" + + @pytest.mark.asyncio + async def test_find_golden_configs_returns_all_when_no_name(self): + """Test find_golden_configs returns all configs when no name filter provided.""" + ctx = context.Context() + mock_client = Mock() + mock_response = Mock() + mock_response.json.return_value = [ + {"_id": "tree1", "name": "Cisco Edge - Day 0"}, + {"_id": "tree2", "name": "Cisco Core - Day 0"}, + ] + mock_client.get = AsyncMock(return_value=mock_response) + ctx.client = mock_client + + service = Service(ctx) + + result = await service.find_golden_configs() + + assert isinstance(result, list) + assert len(result) == 2 + call_args = mock_client.get.call_args + assert call_args[0][0] == "/configuration_manager/configs" + + @pytest.mark.asyncio + async def test_find_golden_configs_filters_by_name(self): + """Test find_golden_configs returns only configs matching the given name.""" + ctx = context.Context() + mock_client = Mock() + mock_response = Mock() + mock_response.json.return_value = [ + {"_id": "tree1", "name": "Cisco Edge - Day 0"}, + {"_id": "tree2", "name": "Cisco Core - Day 0"}, + ] + mock_client.get = AsyncMock(return_value=mock_response) + ctx.client = mock_client + + service = Service(ctx) + + result = await service.find_golden_configs(name="Cisco Edge - Day 0") + + assert len(result) == 1 + assert result[0]["name"] == "Cisco Edge - Day 0" + assert result[0]["_id"] == "tree1" + + @pytest.mark.asyncio + async def test_find_golden_configs_returns_empty_when_no_match(self): + """Test find_golden_configs returns empty list when name does not match.""" + ctx = context.Context() + mock_client = Mock() + mock_response = Mock() + mock_response.json.return_value = [ + {"_id": "tree1", "name": "Cisco Edge - Day 0"}, + ] + mock_client.get = AsyncMock(return_value=mock_response) + ctx.client = mock_client + + service = Service(ctx) + + result = await service.find_golden_configs(name="NonExistent Tree") + + assert result == [] + + @pytest.mark.asyncio + async def test_find_golden_configs_returns_empty_list_from_api(self): + """Test find_golden_configs handles empty API response.""" + ctx = context.Context() + mock_client = Mock() + mock_response = Mock() + mock_response.json.return_value = [] + mock_client.get = AsyncMock(return_value=mock_response) + ctx.client = mock_client + + service = Service(ctx) + + result = await service.find_golden_configs() + + assert result == [] + + @pytest.mark.asyncio + async def test_delete_golden_config_returns_deletion_result(self): + """Test delete_golden_config returns status dict from the API.""" + ctx = context.Context() + mock_client = Mock() + mock_response = Mock() + mock_response.json.return_value = {"status": "success", "deleted": 1} + mock_client.delete = AsyncMock(return_value=mock_response) + ctx.client = mock_client + + service = Service(ctx) + + result = await service.delete_golden_config("tree123") + + assert result["status"] == "success" + assert result["deleted"] == 1 + + @pytest.mark.asyncio + async def test_delete_golden_config_calls_correct_endpoint(self): + """Test delete_golden_config calls the path endpoint with the tree ID.""" + ctx = context.Context() + mock_client = Mock() + mock_response = Mock() + mock_response.json.return_value = {"status": "success", "deleted": 1} + mock_client.delete = AsyncMock(return_value=mock_response) + ctx.client = mock_client + + service = Service(ctx) + + await service.delete_golden_config("abc-123") + + call_args = mock_client.delete.call_args + assert call_args[0][0] == "/configuration_manager/configs/abc-123" + + @pytest.mark.asyncio + async def test_import_golden_config_returns_import_result(self): + """Test import_golden_config returns status and message from the API.""" + ctx = context.Context() + mock_client = Mock() + mock_response = Mock() + mock_response.json.return_value = { + "status": "success", + "message": "2 golden config trees imported successfully", + } + mock_client.post = AsyncMock(return_value=mock_response) + ctx.client = mock_client + + service = Service(ctx) + + trees = [{"data": [{"name": "Tree A"}]}, {"data": [{"name": "Tree B"}]}] + result = await service.import_golden_config(trees) + + assert result["status"] == "success" + assert "imported successfully" in result["message"] + + @pytest.mark.asyncio + async def test_import_golden_config_sends_trees_in_payload(self): + """Test import_golden_config sends trees list as request body.""" + ctx = context.Context() + mock_client = Mock() + mock_response = Mock() + mock_response.json.return_value = {"status": "success", "message": "imported"} + mock_client.post = AsyncMock(return_value=mock_response) + ctx.client = mock_client + + service = Service(ctx) + + trees = [{"data": [{"name": "Tree A"}]}] + await service.import_golden_config(trees) + + call_args = mock_client.post.call_args + assert call_args[0][0] == "/configuration_manager/import/goldenconfigs" + assert call_args[1]["json"]["trees"] == trees + assert "options" not in call_args[1]["json"] + + @pytest.mark.asyncio + async def test_import_golden_config_includes_options_when_provided(self): + """Test import_golden_config includes options in payload when provided.""" + ctx = context.Context() + mock_client = Mock() + mock_response = Mock() + mock_response.json.return_value = {"status": "success", "message": "imported"} + mock_client.post = AsyncMock(return_value=mock_response) + ctx.client = mock_client + + service = Service(ctx) + + trees = [{"data": [{"name": "Tree A"}]}] + options = {"overwrite": True} + await service.import_golden_config(trees, options=options) + + call_args = mock_client.post.call_args + payload = call_args[1]["json"] + assert payload["trees"] == trees + assert payload["options"] == options + + @pytest.mark.asyncio + async def test_import_golden_config_omits_options_when_none(self): + """Test import_golden_config does not include options key when options is None.""" + ctx = context.Context() + mock_client = Mock() + mock_response = Mock() + mock_response.json.return_value = {"status": "success", "message": "imported"} + mock_client.post = AsyncMock(return_value=mock_response) + ctx.client = mock_client + + service = Service(ctx) + + trees = [{"data": [{"name": "Tree A"}]}] + await service.import_golden_config(trees, options=None) + + call_args = mock_client.post.call_args + payload = call_args[1]["json"] + assert "options" not in payload