From 21795b09e38e9544bf59e10f2f3117ca5adc010e Mon Sep 17 00:00:00 2001 From: Nathanial Acosta Date: Thu, 9 Jul 2026 15:44:38 -0400 Subject: [PATCH] feat: add agent projects service, resource, and tests Adds AgentProjects service and resource with full test coverage. Registers agent_projects on ResourceBase. No integration model support included. Co-Authored-By: Claude Fable 5 --- src/asyncplatform/resources/__init__.py | 6 + src/asyncplatform/resources/agent_projects.py | 251 +++++++ src/asyncplatform/services/agent_projects.py | 295 ++++++++ tests/unit/test_resources_agent_projects.py | 709 ++++++++++++++++++ tests/unit/test_services_agent_projects.py | 415 ++++++++++ 5 files changed, 1676 insertions(+) create mode 100644 src/asyncplatform/resources/agent_projects.py create mode 100644 src/asyncplatform/services/agent_projects.py create mode 100644 tests/unit/test_resources_agent_projects.py create mode 100644 tests/unit/test_services_agent_projects.py diff --git a/src/asyncplatform/resources/__init__.py b/src/asyncplatform/resources/__init__.py index a410e06..aeacf6f 100644 --- a/src/asyncplatform/resources/__init__.py +++ b/src/asyncplatform/resources/__init__.py @@ -72,6 +72,12 @@ def configuration_manager(self) -> Any: """Get the Configuration Manager service instance.""" return self.client.configuration_manager + @property + def agent_projects(self) -> Any: + """Get the Agent Projects service instance.""" + return self.client.agent_projects + + @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/agent_projects.py b/src/asyncplatform/resources/agent_projects.py new file mode 100644 index 0000000..170ea46 --- /dev/null +++ b/src/asyncplatform/resources/agent_projects.py @@ -0,0 +1,251 @@ +# 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 + +"""Resource for managing Itential Platform Agent projects.""" + +from __future__ import annotations + +import copy + +from typing import TYPE_CHECKING +from typing import Any + +from asyncplatform import exceptions +from asyncplatform import logging +from asyncplatform.resources import ResourceBase + +if TYPE_CHECKING: + from asyncplatform.client import Client + +from asyncplatform.models.projects import ProjectMember + + +class Resource(ResourceBase): + """Resource class for managing Agent projects.""" + + name: str = "agent_projects" + + def __init__(self, client: Client) -> None: + """Initialize the agent projects resource with a client instance. + + Args: + client: The AsyncPlatform client instance providing API access + """ + super().__init__(client) + + async def _ensure_project_is_new(self, name: str) -> None: + """Ensure that a project with the given name does not exist. + + Args: + name: The project name to check + + Raises: + AsyncPlatformError: If a project with the name already exists + HTTPError: If the API request fails + """ + projects = await self.agent_projects.find_agent_projects(name=name) + if projects: + raise exceptions.AsyncPlatformError(f"Project `{name}` already exists") + + + async def _resolve_member_reference( + self, + member: ProjectMember, + all_groups: dict[str, dict[str, Any]], + all_accounts: dict[str, dict[str, Any]], + ) -> tuple[str, str]: + """Resolve a member to its reference ID and display name. + + Looks up the member in the appropriate collection (groups or accounts) + and returns the reference ID needed for project membership. + + Args: + member: The ProjectMember to resolve + all_groups: Dictionary mapping group names to group data + all_accounts: Dictionary mapping usernames to account data + + Returns: + A tuple of (reference_id, display_name) where reference_id + is the internal ID used for the member reference and display_name + is the human-readable identifier (group name or username) + + Raises: + AsyncPlatformError: If the member does not exist or has an invalid type + """ + if member.type == "group": + if member.name not in all_groups: + raise exceptions.AsyncPlatformError( + f"Group `{member.name}` does not exist on destination server" + ) + return all_groups[member.name]["_id"], member.name + + if member.type == "account": + if member.username not in all_accounts: + raise exceptions.AsyncPlatformError( + f"Account `{member.username}` does not exist on destination server" + ) + return all_accounts[member.username]["_id"], member.username + + raise exceptions.AsyncPlatformError( + f"Invalid member type `{member.type}`, must be 'group' or 'account'" + ) + + async def _update_project_members( + self, + project: dict[str, Any], + members: list[ProjectMember], + *, + preserve_existing_members: bool = False, + ) -> dict[str, Any] | None: + """Update project members, optionally preserving existing ones. + + Resolves member references and updates the project with the new member + list. When preserve_existing_members is True, existing members from the + project are appended to the incoming list before resolving. + + Args: + project: The project data containing at minimum '_id' and 'members' + members: List of ProjectMembers to add to the project + preserve_existing_members: If True, existing project members are kept + alongside the new ones. Defaults to False. + + Raises: + AsyncPlatformError: If any member does not exist or has an invalid type + HTTPError: If the patch operation fails + """ + project_id = project["_id"] + + if preserve_existing_members: + members.extend( + ProjectMember( + name=member.get("name"), + username=member.get("username"), + type=member["type"], + role=member["role"], + ) + for member in project.get("members", []) + ) + + all_groups = await self.get_groups() + all_accounts = await self.get_accounts() + + project_members = [] + + for member in members: + ref_id, _ = await self._resolve_member_reference( + member, all_groups, all_accounts + ) + + member_dict = { + "type": member.type, + "role": member.role, + "reference": ref_id, + } + project_members.append(member_dict) + + if project_members: + res = await self.agent_projects.patch_agent_project( + project_id, {"members": project_members} + ) + return res.json()["data"] + + return None + + @logging.trace + async def importer( + self, + bundle: dict[str, Any], + *, + members: list[ProjectMember] | None = None, + overwrite: bool = False, + ) -> dict[str, Any]: + """Import an agent project bundle into the platform. + + Imports a bundle using given overwrite value and optionally assigns + members to the project after import. Will NOT preserve the members of the + original project, they must be added though the members parameter. + + Args: + bundle: Complete agent project bundle including agents and configuration + members: Optional list of ProjectMember objects to assign to the project + overwrite: If true, deletes project and then reimports. If false (default) + raises an error if the project already exists + + Returns: + The imported project data including _id and name + + Raises: + AsyncPlatformError: If any specified member does not exist or has an + invalid type + HTTPError: If the import or patch operations fail + """ + project = copy.deepcopy(bundle) + + # Check if project exists and handle based on overwrite flag + if not overwrite: + await self._ensure_project_is_new(project["name"]) + else: + # Delete existing project if overwrite is True + existing_projects = await self.agent_projects.find_agent_projects( + name=project["name"] + ) + if existing_projects: + await self.agent_projects.delete_agent_project( + existing_projects[0]["_id"] + ) + + # Import the project + result = await self.agent_projects.import_agent_project(project) + + # Add members if specified + if members: + await self._update_project_members( + result, members + ) + + return result + + @logging.trace + async def set_members( + self, + project_name: str, + members: list[ProjectMember], + *, + preserve_existing_members: bool = True, + ) -> dict[str, Any] | None: + """Set or update project members. + + Adds the specified members to a project, optionally preserving or + replacing all existing members. Members are validated to ensure they + exist in the platform before being added. + + Args: + project_name: The name of the project to update + members: List of ProjectMember objects to add to the project + preserve_existing_members: If True (default), adds members to the + existing list. If False, removes all existing members before + adding new ones + + Returns: + The project data after member updates, or None if + preserve_existing_members=False and no members are added + + Raises: + AsyncPlatformError: If the project cannot be uniquely identified + by name (zero or multiple matches), or if any member does not + exist or has an invalid type + HTTPError: If the find or patch operations fail + """ + projects = await self.agent_projects.find_agent_projects(name=project_name) + + if len(projects) != 1: + raise exceptions.AsyncPlatformError( + f"Could not uniquely identify project {project_name}" + ) + + project = projects[0] + + return await self._update_project_members( + project, members, preserve_existing_members=preserve_existing_members + ) diff --git a/src/asyncplatform/services/agent_projects.py b/src/asyncplatform/services/agent_projects.py new file mode 100644 index 0000000..6a4cd1c --- /dev/null +++ b/src/asyncplatform/services/agent_projects.py @@ -0,0 +1,295 @@ +# 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.http import HTTPStatus +from asyncplatform.services import ServiceBase + + +class Service(ServiceBase): + """Service class for managing agent projects and agents in Itential Platform. + + The Service provides methods for interacting with the agent projects service, + including importing/exporting projects. All methods + support automatic pagination to handle large datasets efficiently. + + Attributes: + name: Service identifier for logging and identification + PAGINATION_LIMIT: Number of items to fetch per page when retrieving all items + """ + + name: str = "agent_projects" + + async def _get( + self, + endpoint: str, + params: dict[str, Any], + limit: int = 100, + ) -> list[dict[str, Any]]: + """Common pagination helper for fetching all results from a paginated endpoint. + + This method handles fetching all pages of results concurrently when the total + exceeds the page limit. It makes an initial request to determine the total count, + then creates concurrent tasks for remaining pages. + + Args: + endpoint: The API endpoint path to query + params: Query parameters to include in the request + limit: Maximum number of results per page (default: 100) + + Raises: + Exception: Re-raises any exceptions encountered during concurrent requests + """ + # Set pagination parameters + params.update({"skip": 0, "limit": limit}) + + # Make initial request to get total count and first page + res = await self.get(endpoint, params=params) + json_data = res.json() + + total = json_data["total"] + + # Handle empty results + if total == 0: + return [] + + # If all results fit in first page, return immediately + if total <= limit: + return json_data["results"] + + # Start with first page results + results = json_data["results"] + + # Create tasks for remaining pages + tasks = [] + for skip in range(limit, total, limit): + page_params = params.copy() + page_params.update({"limit": limit, "skip": skip}) + + tasks.append(self.get(endpoint, params=page_params)) + + # Fetch all remaining pages concurrently + if tasks: + task_results = await asyncio.gather(*tasks, return_exceptions=True) + + # Check for exceptions in results + for result in task_results: + if isinstance(result, Exception): + raise result + + # Combine all page results (mypy doesn't understand the exception check above) + for result in task_results: + results.extend(result.json()["results"]) # type: ignore[union-attr] + + return results + + @logging.trace + async def describe_agent_project(self, project_id: str) -> dict[str, Any] | None: + """Retrieve detailed information about a specific Agent project. + + Fetches comprehensive project details including metadata, configuration, + members, and associated agents by project ID. + + Args: + project_id: The unique identifier (_id) of the project to retrieve + + Returns: + A dictionary containing the complete project data, or None if the + project does not exist + + Raises: + HTTPError: If the API request fails + """ + res = await self.get(f"/agent-project-service/projects/{project_id}") + + json_data = res.json() + + if "data" not in json_data: + return None + + return json_data["data"] + + @logging.trace + async def get_agent_projects(self) -> list[Mapping[str, Any]]: + """Retrieve all Agent Projects projects from the Itential Platform. + + This method retrieves all projects with automatic pagination handling. + If the total number of projects exceeds the initial limit, additional + concurrent requests are made to fetch all remaining projects efficiently. + + Returns: + TBD + Returns an empty list if no projects exist. + + Raises: + HTTPError: If any API request fails during project retrieval + """ + + limit = 100 + + res = await self.get("/agent-project-service/projects", params={"limit": limit}) + data = res.json()["data"] + + total = data["metadata"]["total"] + + if total == 0: + return [] + + if total <= limit: + return data["items"] + + tasks = [] + results = data["items"] + + for skip in range(limit, total, limit): + remaining = total - skip + fetch_limit = min(limit, remaining) + + tasks.append( + self.get( + "/agent-project-service/projects", + params={"limit": fetch_limit, "skip": skip}, + ) + ) + + if tasks: + task_results = await asyncio.gather(*tasks, return_exceptions=True) + + for result in task_results: + if isinstance(result, Exception): + raise result + results.extend(result.json()["data"]["items"]) # type: ignore[union-attr] + + return results + + @logging.trace + async def delete_agent_project(self, project_id: str) -> dict[str, Any]: + """Delete an agent project by its ID. + + Args: + id: The unique identifier (_id) of the agent project to delete. + + Returns: + A dictionary containing the deletion result with the following structure: + - message: Success message string + - data: None + - metadata: Dictionary containing 'deletedComponents' list with + information about all deleted workflows and components + + Raises: + AsyncPlatformError: If the delete request fails. + """ + res = await self.delete(f"/agent-project-service/projects/{project_id}") + return res.json() + + @logging.trace + async def patch_agent_project(self, project_id: str, data: dict[str, Any]) -> Any: + """Update specific fields of an Agent project. + + Performs a partial update (PATCH) on a project, modifying only the + fields specified in the data parameter without affecting other fields. + + Args: + project_id: The unique identifier (_id) of the project to update + data: A dictionary containing the fields to update with their new values. + Common fields include 'name', 'description', 'members', etc. + + Returns: + The HTTP response object from the patch operation + + Raises: + HTTPError: If the update request fails or project doesn't exist + """ + return await self.patch(f"/agent-project-service/projects/{project_id}", json=data) + + @logging.trace + async def find_agent_projects(self, *, name: str | None = None) -> list[dict[str, Any]]: + """Search for Agent projects by name. + + Queries the platform for projects matching the specified criteria. If no + name is provided, returns all projects without filtering. + + Args: + name: Optional project name to search for using exact match. If None, + no name filtering is applied + + Returns: + A list of project dictionaries matching the search criteria. Returns + an empty list if no matching projects are found. + + Raises: + HTTPError: If the API request fails + """ + + res = await self.get("/agent-project-service/projects") + + json_data = res.json()["data"] + + logging.info(f"Found {json_data['metadata']['total']} project(s)") + + found = json_data["items"] + if name is None: + return found + + for proj in found: + if proj["name"] == name: + return [proj] + + return [] + + @logging.trace + async def import_agent_project( + self, + bundle: Mapping[str, Any], + ) -> Mapping[str, Any]: + """Import a project into Agent Projects. + + Imports a project configuration into the Itential Platform. The import + uses "replace" and replaces any existing projects in-place without changing + the name and uuid. + + Args: + bundle: A mapping containing the complete project definition including + name, description, workflows, and other project components + + Returns: + A mapping containing the imported project data, including the newly + assigned project ID (_id) and complete project configuration + + Raises: + HTTPError: If the import request fails or the project format is invalid + """ + + provider_resolutions = { + agent["_id"]: {"profileName": "anthropic", "modelName": "claude-sonnet-4-6"} + for agent in bundle["agents"] + } + + res = await self.post( + "/agent-project-service/project-bundles/import", + json={ + "bundle": bundle, + "conflictMode": "replace", + "providerResolutions": provider_resolutions, + }, + expected_status=HTTPStatus.OK, + ) + + json_data = res.json() + + projectid = json_data["data"]["_id"] + + logging.info(f"Successfully imported project {bundle['name']} (id: {projectid})") + + return json_data["data"] diff --git a/tests/unit/test_resources_agent_projects.py b/tests/unit/test_resources_agent_projects.py new file mode 100644 index 0000000..0ef5ea6 --- /dev/null +++ b/tests/unit/test_resources_agent_projects.py @@ -0,0 +1,709 @@ +# 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.agent_projects module.""" + +from unittest.mock import AsyncMock +from unittest.mock import MagicMock +from unittest.mock import Mock + +import pytest + +from asyncplatform import exceptions +from asyncplatform.models.projects import ProjectMember +from asyncplatform.resources.agent_projects import Resource + + +class TestResourceInit: + """Test suite for Resource initialization.""" + + def test_resource_initialization(self): + """Test Resource initializes with client and sets cache attributes to None.""" + mock_client = MagicMock() + resource = Resource(mock_client) + + assert resource.client is mock_client + assert resource._groups_cache is None + assert resource._accounts_cache is None + + def test_resource_agent_projects_property(self): + """Test agent_projects property returns the client's agent_projects service.""" + mock_client = MagicMock() + mock_agent_projects = MagicMock() + mock_client.agent_projects = mock_agent_projects + + resource = Resource(mock_client) + assert resource.agent_projects is mock_agent_projects + + def test_resource_authorization_property(self): + """Test authorization property returns the client's authorization service.""" + mock_client = MagicMock() + mock_auth = MagicMock() + mock_client.authorization = mock_auth + + resource = Resource(mock_client) + assert resource.authorization is mock_auth + + +class TestGetGroups: + """Test suite for _get_groups method.""" + + @pytest.mark.asyncio + async def test_get_groups_first_call(self): + """Test that _get_groups fetches and caches groups on first call. + + Args: + None + + Returns: + None + + Raises: + None + """ + mock_client = MagicMock() + mock_auth = MagicMock() + mock_auth.get_groups = AsyncMock( + return_value=[ + {"_id": "1", "name": "Admins"}, + {"_id": "2", "name": "Operators"}, + ] + ) + mock_client.authorization = mock_auth + + resource = Resource(mock_client) + result = await resource.get_groups() + + assert result == { + "Admins": {"_id": "1", "name": "Admins"}, + "Operators": {"_id": "2", "name": "Operators"}, + } + assert resource._groups_cache == result + mock_auth.get_groups.assert_called_once() + + @pytest.mark.asyncio + async def test_get_groups_cached_call(self): + """Test that _get_groups returns cached data on subsequent calls. + + Args: + None + + Returns: + None + + Raises: + None + """ + mock_client = MagicMock() + mock_auth = MagicMock() + mock_auth.get_groups = AsyncMock( + return_value=[ + {"_id": "1", "name": "Admins"}, + ] + ) + mock_client.authorization = mock_auth + + resource = Resource(mock_client) + + # First call + result1 = await resource.get_groups() + # Second call + result2 = await resource.get_groups() + + assert result1 == result2 + # Should only be called once due to caching + mock_auth.get_groups.assert_called_once() + + @pytest.mark.asyncio + async def test_get_groups_empty_list(self): + """Test that _get_groups handles empty groups list. + + Args: + None + + Returns: + None + + Raises: + None + """ + mock_client = MagicMock() + mock_auth = MagicMock() + mock_auth.get_groups = AsyncMock(return_value=[]) + mock_client.authorization = mock_auth + + resource = Resource(mock_client) + result = await resource.get_groups() + + assert result == {} + + +class TestGetAccounts: + """Test suite for _get_accounts method.""" + + @pytest.mark.asyncio + async def test_get_accounts_first_call(self): + """Test that _get_accounts fetches and caches accounts on first call. + + Args: + None + + Returns: + None + + Raises: + None + """ + mock_client = MagicMock() + mock_auth = MagicMock() + mock_auth.get_accounts = AsyncMock( + return_value=[ + {"_id": "1", "username": "john.doe"}, + {"_id": "2", "username": "jane.smith"}, + ] + ) + mock_client.authorization = mock_auth + + resource = Resource(mock_client) + result = await resource.get_accounts() + + assert result == { + "john.doe": {"_id": "1", "username": "john.doe"}, + "jane.smith": {"_id": "2", "username": "jane.smith"}, + } + assert resource._accounts_cache == result + mock_auth.get_accounts.assert_called_once() + + @pytest.mark.asyncio + async def test_get_accounts_cached_call(self): + """Test that _get_accounts returns cached data on subsequent calls. + + Args: + None + + Returns: + None + + Raises: + None + """ + mock_client = MagicMock() + mock_auth = MagicMock() + mock_auth.get_accounts = AsyncMock( + return_value=[ + {"_id": "1", "username": "john.doe"}, + ] + ) + mock_client.authorization = mock_auth + + resource = Resource(mock_client) + + # First call + result1 = await resource.get_accounts() + # Second call + result2 = await resource.get_accounts() + + assert result1 == result2 + # Should only be called once due to caching + mock_auth.get_accounts.assert_called_once() + + @pytest.mark.asyncio + async def test_get_accounts_empty_list(self): + """Test that _get_accounts handles empty accounts list. + + Args: + None + + Returns: + None + + Raises: + None + """ + mock_client = MagicMock() + mock_auth = MagicMock() + mock_auth.get_accounts = AsyncMock(return_value=[]) + mock_client.authorization = mock_auth + + resource = Resource(mock_client) + result = await resource.get_accounts() + + assert result == {} + + +class TestResolveMemberReference: + """Test suite for _resolve_member_reference method.""" + + @pytest.mark.asyncio + async def test_resolve_group_member(self): + """Test resolving a group member reference. + + Args: + None + + Returns: + None + + Raises: + None + """ + mock_client = MagicMock() + resource = Resource(mock_client) + + member = ProjectMember(name="Admins", type="group", role="owner") + all_groups = {"Admins": {"_id": "group1", "name": "Admins"}} + all_accounts = {} + + ref_id, display_name = await resource._resolve_member_reference( + member, all_groups, all_accounts + ) + + assert ref_id == "group1" + assert display_name == "Admins" + + @pytest.mark.asyncio + async def test_resolve_account_member(self): + """Test resolving an account member reference. + + Args: + None + + Returns: + None + + Raises: + None + """ + mock_client = MagicMock() + resource = Resource(mock_client) + + member = ProjectMember(username="john.doe", type="account", role="editor") + all_groups = {} + all_accounts = {"john.doe": {"_id": "user1", "username": "john.doe"}} + + ref_id, display_name = await resource._resolve_member_reference( + member, all_groups, all_accounts + ) + + assert ref_id == "user1" + assert display_name == "john.doe" + + @pytest.mark.asyncio + async def test_resolve_invalid_member_type(self): + """Test that invalid member type raises error. + + Args: + None + + Returns: + None + + Raises: + None + """ + mock_client = MagicMock() + resource = Resource(mock_client) + + member = ProjectMember(name="test", type="invalid", role="viewer") + all_groups = {} + all_accounts = {} + + with pytest.raises(exceptions.AsyncPlatformError) as exc_info: + await resource._resolve_member_reference(member, all_groups, all_accounts) + + assert "Invalid member type `invalid`" in str(exc_info.value) + assert "must be 'group' or 'account'" in str(exc_info.value) + + +class TestSetMembers: + """Test suite for set_members method.""" + + @pytest.mark.asyncio + async def test_set_members_add_to_existing(self): + """Test adding members to existing project members. + + Args: + None + + Returns: + None + + Raises: + None + """ + mock_client = MagicMock() + mock_agent_projects = MagicMock() + + mock_agent_projects.find_agent_projects = AsyncMock( + return_value=[ + { + "_id": "proj1", + "name": "Test Project", + "members": [{"reference": "existing1", "type": "group", "role": "editor", "name": "ExistingGroup"}], + } + ] + ) + mock_patch_response = Mock() + mock_patch_response.json.return_value = {"data": {"_id": "proj1"}} + mock_agent_projects.patch_agent_project = AsyncMock(return_value=mock_patch_response) + + mock_client.agent_projects = mock_agent_projects + + resource = Resource(mock_client) + resource.get_groups = AsyncMock( + return_value={ + "NewGroup": {"_id": "group2", "name": "NewGroup"}, + "ExistingGroup": {"_id": "existing1", "name": "ExistingGroup"}, + } + ) + resource.get_accounts = AsyncMock(return_value={}) + + members = [ProjectMember(name="NewGroup", type="group", role="editor")] + print(members) + result = await resource.set_members( + "Test Project", members, preserve_existing_members=True + ) + + assert result["_id"] == "proj1" + mock_agent_projects.find_agent_projects.assert_called_once_with(name="Test Project") + mock_agent_projects.patch_agent_project.assert_called_once() + + @pytest.mark.asyncio + async def test_set_members_replace_existing(self): + """Test replacing all existing members with new ones. + + Args: + None + + Returns: + None + + Raises: + None + """ + mock_client = MagicMock() + mock_agent_projects = MagicMock() + + mock_agent_projects.find_agent_projects = AsyncMock( + return_value=[ + { + "_id": "proj1", + "name": "Test Project", + "members": [ + {"reference": "old1", "type": "group"}, + {"reference": "old2", "type": "account"}, + ], + } + ] + ) + mock_patch_response = Mock() + mock_patch_response.json.return_value = {"data": {"_id": "proj1"}} + mock_agent_projects.patch_agent_project = AsyncMock(return_value=mock_patch_response) + + mock_client.agent_projects = mock_agent_projects + + resource = Resource(mock_client) + resource.get_groups = AsyncMock( + return_value={"NewGroup": {"_id": "newgroup", "name": "NewGroup"}} + ) + resource.get_accounts = AsyncMock(return_value={}) + + members = [ProjectMember(name="NewGroup", type="group", role="owner")] + + result = await resource.set_members( + "Test Project", members, preserve_existing_members=False + ) + + assert result["_id"] == "proj1" + # Verify members were cleared before adding new ones + call_args = mock_agent_projects.patch_agent_project.call_args + members_list = call_args[0][1]["members"] + # Should only have the new member, not old ones + assert len(members_list) == 1 + + @pytest.mark.asyncio + async def test_set_members_project_not_found(self): + """Test that error is raised when project is not found. + + Args: + None + + Returns: + None + + Raises: + None + """ + mock_client = MagicMock() + mock_agent_projects = MagicMock() + + mock_agent_projects.find_agent_projects = AsyncMock(return_value=[]) + mock_client.agent_projects = mock_agent_projects + + resource = Resource(mock_client) + members = [ProjectMember(name="Group", type="group", role="owner")] + + with pytest.raises(exceptions.AsyncPlatformError) as exc_info: + await resource.set_members("NonExistent", members) + + assert "Could not uniquely identify project NonExistent" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_set_members_multiple_matches(self): + """Test that error is raised when multiple projects match name. + + Args: + None + + Returns: + None + + Raises: + None + """ + mock_client = MagicMock() + mock_agent_projects = MagicMock() + + mock_agent_projects.find_agent_projects = AsyncMock( + return_value=[ + {"_id": "proj1", "name": "Duplicate"}, + {"_id": "proj2", "name": "Duplicate"}, + ] + ) + mock_client.agent_projects = mock_agent_projects + + resource = Resource(mock_client) + members = [ProjectMember(name="Group", type="group", role="owner")] + + with pytest.raises(exceptions.AsyncPlatformError) as exc_info: + await resource.set_members("Duplicate", members) + + assert "Could not uniquely identify project Duplicate" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_set_members_with_nonexistent_member(self): + """Test that error is raised when member does not exist. + + Args: + None + + Returns: + None + + Raises: + None + """ + mock_client = MagicMock() + mock_agent_projects = MagicMock() + + mock_agent_projects.find_agent_projects = AsyncMock( + return_value=[{"_id": "proj1", "name": "Test Project", "members": []}] + ) + + mock_client.agent_projects = mock_agent_projects + + resource = Resource(mock_client) + resource.get_groups = AsyncMock(return_value={}) + resource.get_accounts = AsyncMock(return_value={}) + + members = [ProjectMember(name="NonExistent", type="group", role="owner")] + + with pytest.raises(exceptions.AsyncPlatformError) as exc_info: + await resource.set_members("Test Project", members) + + assert "does not exist" in str(exc_info.value) + + + +class TestImporter: + """Test suite for importer method.""" + + @pytest.mark.asyncio + async def test_importer_without_members(self): + """Test importer imports project and returns result when no members specified.""" + mock_client = MagicMock() + mock_agent_projects = MagicMock() + mock_agent_projects.find_agent_projects = AsyncMock(return_value=[]) + mock_agent_projects.import_agent_project = AsyncMock( + return_value={"_id": "proj1", "name": "Test Project"} + ) + mock_client.agent_projects = mock_agent_projects + + resource = Resource(mock_client) + project = {"name": "Test Project", "description": "Test"} + + result = await resource.importer(project) + + assert result == {"_id": "proj1", "name": "Test Project"} + mock_agent_projects.find_agent_projects.assert_called_once_with(name="Test Project") + mock_agent_projects.import_agent_project.assert_called_once_with(project) + + @pytest.mark.asyncio + async def test_importer_raises_when_project_exists_and_overwrite_false(self): + """Test importer raises AsyncPlatformError when project exists and overwrite=False.""" + mock_client = MagicMock() + mock_agent_projects = MagicMock() + mock_agent_projects.find_agent_projects = AsyncMock( + return_value=[{"_id": "existing", "name": "Test Project"}] + ) + mock_client.agent_projects = mock_agent_projects + + resource = Resource(mock_client) + project = {"name": "Test Project", "description": "Test"} + + with pytest.raises(exceptions.AsyncPlatformError) as exc_info: + await resource.importer(project) + + assert "Project `Test Project` already exists" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_importer_with_overwrite_true_deletes_existing_project(self): + """Test importer deletes existing project before importing when overwrite=True.""" + mock_client = MagicMock() + mock_agent_projects = MagicMock() + mock_agent_projects.find_agent_projects = AsyncMock( + return_value=[{"_id": "existing", "name": "Test Project"}] + ) + mock_agent_projects.delete_agent_project = AsyncMock(return_value=None) + mock_agent_projects.import_agent_project = AsyncMock(return_value=None) + mock_client.agent_projects = mock_agent_projects + + resource = Resource(mock_client) + project = {"name": "Test Project", "description": "Test2"} + + await resource.importer(project, overwrite=True) + + mock_agent_projects.delete_agent_project.assert_called_once() + + @pytest.mark.asyncio + async def test_importer_with_overwrite_true_no_existing_project(self): + """Test importer skips delete and imports directly when overwrite=True and project absent.""" + mock_client = MagicMock() + mock_agent_projects = MagicMock() + mock_agent_projects.find_agent_projects = AsyncMock( + return_value=[] + ) + mock_agent_projects.delete_agent_project = AsyncMock(return_value=None) + mock_agent_projects.import_agent_project = AsyncMock(return_value=None) + mock_client.agent_projects = mock_agent_projects + + resource = Resource(mock_client) + project = {"name": "Test Project", "description": "Test2"} + + await resource.importer(project, overwrite=True) + + mock_agent_projects.delete_agent_project.assert_not_called() + + @pytest.mark.asyncio + async def test_importer_with_group_member(self): + """Test importer calls _update_project_members when a group member is provided.""" + mock_client = MagicMock() + mock_agent_projects = MagicMock() + resource = Resource(mock_client) + project = {"_id": "1", "name": "Test Project", "description": "Test2"} + members = [ProjectMember(name="Admins", type="group", role="editor")] + + mock_agent_projects.find_agent_projects = AsyncMock( + return_value=[] + ) + mock_agent_projects.delete_agent_project = AsyncMock(return_value=None) + mock_agent_projects.import_agent_project = AsyncMock(return_value=project) + mock_agent_projects.patch_agent_project = AsyncMock(return_value=members) + + resource.get_groups = AsyncMock( + return_value={"Admins": {"_id": "group1", "name": "Admins"}} + ) + resource.get_accounts = AsyncMock(return_value={}) + resource._update_project_members = AsyncMock() + mock_client.agent_projects = mock_agent_projects + + await resource.importer(project, members=members, overwrite=True) + + resource._update_project_members.assert_called_once() + + @pytest.mark.asyncio + async def test_importer_with_account_member(self): + """Test importer calls _update_project_members when an account member is provided.""" + mock_client = MagicMock() + mock_agent_projects = MagicMock() + resource = Resource(mock_client) + project = {"_id": "1", "name": "Test Project", "description": "Test2"} + members = [ProjectMember(name="admin", type="account", role="editor")] + + mock_agent_projects.find_agent_projects = AsyncMock( + return_value=[] + ) + mock_agent_projects.delete_agent_project = AsyncMock(return_value=None) + mock_agent_projects.import_agent_project = AsyncMock(return_value=project) + mock_agent_projects.patch_agent_project = AsyncMock(return_value=members) + + resource.get_groups = AsyncMock(return_value=None) + resource.get_accounts = AsyncMock( + return_value={"admin": {"_id": "account1", "name": "admin"}} + ) + resource._update_project_members = AsyncMock() + mock_client.agent_projects = mock_agent_projects + + await resource.importer(project, members=members, overwrite=True) + + resource._update_project_members.assert_called_once() + + @pytest.mark.asyncio + async def test_importer_with_multiple_members(self): + """Test importer passes all members to _update_project_members.""" + mock_client = MagicMock() + mock_agent_projects = MagicMock() + resource = Resource(mock_client) + project = {"_id": "1", "name": "Test Project", "description": "Test2"} + members = [ + ProjectMember(username="admin", type="account", role="editor"), + ProjectMember(username="admin2", type="account", role="editor"), + ProjectMember(username="admin3", type="account", role="editor"), + ] + + mock_agent_projects.find_agent_projects = AsyncMock(return_value=[]) + mock_agent_projects.import_agent_project = AsyncMock(return_value=project) + + resource._update_project_members = AsyncMock() + mock_client.agent_projects = mock_agent_projects + + await resource.importer(project, members=members, overwrite=True) + + resource._update_project_members.assert_called_once_with(project, members) + + @pytest.mark.asyncio + async def test_importer_does_not_mutate_input_bundle(self): + """Test importer operates on a deep copy so the caller's bundle is unchanged.""" + mock_client = MagicMock() + mock_agent_projects = MagicMock() + mock_agent_projects.find_agent_projects = AsyncMock(return_value=[]) + mock_agent_projects.import_agent_project = AsyncMock(return_value={"_id": "proj1"}) + mock_client.agent_projects = mock_agent_projects + + resource = Resource(mock_client) + bundle = {"name": "Test Project", "description": "Original", "agents": []} + original = bundle.copy() + + await resource.importer(bundle) + + assert bundle == original + + @pytest.mark.asyncio + async def test_importer_skips_member_update_when_members_is_none(self): + """Test importer does not call _update_project_members when members=None.""" + mock_client = MagicMock() + mock_agent_projects = MagicMock() + resource = Resource(mock_client) + project = {"_id": "1", "name": "Test Project", "description": "Test2"} + members = [] + + mock_agent_projects.find_agent_projects = AsyncMock(return_value=[]) + mock_agent_projects.import_agent_project = AsyncMock(return_value=project) + + resource._update_project_members = AsyncMock() + mock_client.agent_projects = mock_agent_projects + + await resource.importer(project, members=members, overwrite=True) + + resource._update_project_members.assert_not_called() diff --git a/tests/unit/test_services_agent_projects.py b/tests/unit/test_services_agent_projects.py new file mode 100644 index 0000000..9f20f5f --- /dev/null +++ b/tests/unit/test_services_agent_projects.py @@ -0,0 +1,415 @@ +# 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.agent_projects module.""" + +from unittest.mock import AsyncMock +from unittest.mock import Mock +from unittest.mock import patch + +import pytest + +from asyncplatform import context +from asyncplatform.services.agent_projects import Service + + +class TestAgentProjectsServiceInitialization: + """Test suite for AgentProjects Service initialization.""" + + def test_service_has_name_attribute(self): + """Test Service has name attribute set to 'agent_projects'.""" + assert hasattr(Service, "name") + assert Service.name == "agent_projects" + + def test_service_init_with_context(self): + """Test Service initializes with context and stores it.""" + ctx = context.Context() + service = Service(ctx) + + assert service.ctx is ctx + + +class TestAgentProjectsServiceDescribeAgentProject: + """Test suite for describe_agent_project method.""" + + @pytest.mark.asyncio + async def test_describe_agent_project_returns_data(self): + """Test describe_agent_project returns project dict when found.""" + ctx = context.Context() + mock_client = Mock() + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "_id": "project123", + "name": "TestProject", + "description": "A test project", + } + } + mock_client.get = AsyncMock(return_value=mock_response) + ctx.client = mock_client + + service = Service(ctx) + + result = await service.describe_agent_project("project123") + + assert result is not None + assert result["_id"] == "project123" + assert result["name"] == "TestProject" + + @pytest.mark.asyncio + async def test_describe_agent_project_returns_none_when_missing_data_key(self): + """Test describe_agent_project returns None when response has no 'data' key.""" + 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.describe_agent_project("project123") + + assert result is None + + +class TestAgentProjectsServiceGetAgentProjects: + """Test suite for get_agent_projects method.""" + + @pytest.mark.asyncio + @patch.object(Service, "get") + async def test_get_agent_projects_returns_list(self, mock_get): + """Test get_agent_projects returns list of projects.""" + ctx = context.Context() + service = Service(ctx) + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "metadata": {"total": 2}, + "items": [ + {"_id": "1", "name": "Project1"}, + {"_id": "2", "name": "Project2"}, + ], + } + } + mock_get.return_value = mock_response + + result = await service.get_agent_projects() + + assert isinstance(result, list) + assert len(result) == 2 + assert result[0]["name"] == "Project1" + assert result[1]["name"] == "Project2" + + @pytest.mark.asyncio + @patch.object(Service, "get") + async def test_get_agent_projects_returns_empty_list_when_total_is_zero(self, mock_get): + """Test get_agent_projects returns empty list when total is 0.""" + ctx = context.Context() + service = Service(ctx) + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "metadata": {"total": 0}, + "items": [], + } + } + mock_get.return_value = mock_response + + result = await service.get_agent_projects() + assert result == [] + + @pytest.mark.asyncio + @patch.object(Service, "get") + async def test_get_agent_projects_handles_pagination(self, mock_get): + """Test get_agent_projects returns items directly when total fits one page.""" + page1 = Mock() + page1.json.return_value = { + "data": { + "metadata": {"total": 250}, + "items": [{"_id": f"{i}", "name": f"Project{i}"} for i in range(100)], + } + } + + page2 = Mock() + page2.json.return_value = { + "data": { + "metadata": {"total": 250}, + "items": [{"_id": f"{i}", "name": f"Project{i}"} for i in range(100, 200)], + } + } + + page3 = Mock() + page3.json.return_value = { + "data": { + "metadata": {"total": 250}, + "items": [{"_id": f"{i}", "name": f"Project{i}"} for i in range(200, 250)], + } + } + + mock_get.side_effect = [page1, page2, page3] + + ctx = context.Context() + service = Service(ctx) + + result = await service.get_agent_projects() + + assert len(result) == 250 + assert result[0]["name"] == "Project0" + assert result[249]["name"] == "Project249" + + @pytest.mark.asyncio + @patch.object(Service, "get") + async def test_get_agent_projects_handles_pagination_with_exception(self, mock_get): + """Test get_agent_projects re-raises exceptions from concurrent page requests.""" + page1 = Mock() + page1.json.return_value = { + "data": { + "metadata": {"total": 250}, + "items": [{"_id": f"{i}", "name": f"Project{i}"} for i in range(100)], + } + } + + mock_get.side_effect = [page1, RuntimeError("API error")] + + ctx = context.Context() + service = Service(ctx) + + with pytest.raises(RuntimeError, match="API error"): + await service.get_agent_projects() + + +class TestAgentProjectsServiceDeleteAgentProject: + """Test suite for delete_agent_project method.""" + + @pytest.mark.asyncio + async def test_delete_agent_project_returns_json_response(self): + """Test delete_agent_project returns the parsed JSON response body.""" + ctx = context.Context() + service = Service(ctx) + mock_client = Mock() + mock_response = Mock() + mock_response.json.return_value = { + "message": "success", + "data": { + "_id": "0", + "name": "Project0", + }, + } + + mock_client.delete = AsyncMock(return_value=mock_response) + ctx.client = mock_client + + result = await service.delete_agent_project("0") + + assert result["message"] == "success" + assert len(result["data"]) == 2 + + +class TestAgentProjectsServicePatchAgentProject: + """Test suite for patch_agent_project method.""" + + @pytest.mark.asyncio + async def test_patch_project_updates_fields(self): + """Test patch_agent_project hits /agent-project-service/projects/{id}.""" + ctx = context.Context() + mock_client = Mock() + mock_response = Mock() + mock_client.patch = AsyncMock(return_value=mock_response) + ctx.client = mock_client + + service = Service(ctx) + + update_data = {"name": "UpdatedName", "description": "Updated description"} + + result = await service.patch_agent_project("project123", update_data) + + assert result is mock_response + + +class TestAgentProjectsServiceFindAgentProjects: + """Test suite for find_agent_projects method.""" + + @pytest.mark.asyncio + @patch.object(Service, "get") + async def test_find_agent_projects_filters_by_name(self, mock_get): + """Test find_agent_projects returns all items when name is not provided.""" + ctx = context.Context() + mock_client = Mock() + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "metadata": {"total": 2}, + "items": [ + {"_id": "1", "name": "TestProject"}, + {"_id": "2", "name": "OtherProject"}, + ], + } + } + mock_get.return_value = mock_response + ctx.client = mock_client + + service = Service(ctx) + + result = await service.find_agent_projects(name="TestProject") + + assert len(result) == 1 + assert result[0]["name"] == "TestProject" + + @pytest.mark.asyncio + @patch.object(Service, "get") + async def test_find_agent_projects_returns_all_when_name_is_none(self, mock_get): + """Test find_agent_projects returns only projects matching the given name.""" + ctx = context.Context() + mock_client = Mock() + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "metadata": {"total": 2}, + "items": [ + {"_id": "1", "name": "TestProject"}, + {"_id": "2", "name": "OtherProject"}, + ], + } + } + mock_get.return_value = mock_response + ctx.client = mock_client + + service = Service(ctx) + + result = await service.find_agent_projects(name=None) + + assert len(result) == 2 + + @pytest.mark.asyncio + @patch.object(Service, "get") + async def test_find_agent_projects_returns_empty_list_when_no_match(self, mock_get): + """Test find_agent_projects returns empty list when name does not match any project.""" + ctx = context.Context() + mock_client = Mock() + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "metadata": {"total": 2}, + "items": [ + {"_id": "1", "name": "TestProject"}, + {"_id": "2", "name": "OtherProject"}, + ], + } + } + mock_get.return_value = mock_response + ctx.client = mock_client + + service = Service(ctx) + + result = await service.find_agent_projects(name="") + + assert len(result) == 0 + + @pytest.mark.asyncio + @patch.object(Service, "get") + async def test_find_agent_projects_returns_empty_list_when_no_projects_exist(self, mock_get): + """Test find_agent_projects returns empty list when there are no projects.""" + ctx = context.Context() + mock_client = Mock() + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "metadata": {"total": 0}, + "items": [], + } + } + mock_get.return_value = mock_response + ctx.client = mock_client + service = Service(ctx) + + result = await service.find_agent_projects(name="") + + assert result == [] + + +class TestAgentProjectsServiceImportAgentProject: + """Test suite for import_agent_project method.""" + + @pytest.mark.asyncio + async def test_import_agent_project_returns_project_data(self): + """Test import_agent_project returns the imported project dict from response.""" + ctx = context.Context() + mock_client = Mock() + mock_response = Mock() + mock_response.json.return_value = { + "message": "success", + "data": { + "_id": "new_project_123", + } + } + + mock_client.post = AsyncMock(return_value=mock_response) + ctx.client = mock_client + + service = Service(ctx) + + project_data = {"name": "ImportedProject", "description": "Imported project", "agents": []} + + result = await service.import_agent_project(project_data) + + assert result["_id"] == "new_project_123" + + @pytest.mark.asyncio + async def test_import_project_passes_correct_params(self): + """Test import_project passes correct parameters to API.""" + ctx = context.Context() + mock_client = Mock() + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "_id": "123", + "name": "Test" + } + } + mock_client.post = AsyncMock(return_value=mock_response) + ctx.client = mock_client + + service = Service(ctx) + + project_data = {"name": "Test", "agents": []} + + await service.import_agent_project(project_data) + + # Verify correct endpoint and payload + call_args = mock_client.post.call_args + assert call_args[0][0] == "/agent-project-service/project-bundles/import" + payload = call_args[1]["json"] + assert payload["bundle"] == project_data + assert payload["conflictMode"] == "replace" + + + @pytest.mark.asyncio + async def test_import_agent_project_builds_provider_resolutions_from_agents(self): + """Test import_agent_project builds providerResolutions keyed by each agent _id.""" + ctx = context.Context() + mock_client = Mock() + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "_id": "123", + "name": "Test" + } + } + mock_client.post = AsyncMock(return_value=mock_response) + ctx.client = mock_client + + service = Service(ctx) + + project_data = {"name": "Test", "agents": [{"_id": "1", "name": "test",}]} + + await service.import_agent_project(project_data) + + # Verify correct endpoint and payload + call_args = mock_client.post.call_args + payload = call_args[1]["json"] + print(payload) + assert payload["providerResolutions"]["1"] == {"profileName": "anthropic", "modelName": "claude-sonnet-4-6"} +