diff --git a/datadog_sync/model/synthetics_test_suites.py b/datadog_sync/model/synthetics_test_suites.py new file mode 100644 index 00000000..ca183aaa --- /dev/null +++ b/datadog_sync/model/synthetics_test_suites.py @@ -0,0 +1,112 @@ +# Unless explicitly stated otherwise all files in this repository are licensed +# under the 3-clause BSD style license (see LICENSE). +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2019 Datadog, Inc. + +from __future__ import annotations +from typing import TYPE_CHECKING, Optional, List, Dict, Tuple + +from datadog_sync.utils.base_resource import BaseResource, ResourceConfig, TaggingConfig + +if TYPE_CHECKING: + from datadog_sync.utils.custom_client import CustomClient + + +class SyntheticsTestSuites(BaseResource): + resource_type = "synthetics_test_suites" + resource_config = ResourceConfig( + resource_connections={ + "synthetics_tests": ["attributes.tests.public_id"], + }, + base_path="/api/v2/synthetics/suites", + excluded_attributes=[ + "id", + "attributes.public_id", + "attributes.created_at", + "attributes.modified_at", + "attributes.created_by", + "attributes.modified_by", + "attributes.monitor_id", + "attributes.org_id", + "attributes.version", + "attributes.version_uuid", + "attributes.overall_state", + "attributes.overall_state_modified", + "attributes.options.slo_id", + ], + tagging_config=TaggingConfig(path="attributes.tags"), + ) + search_path = "/api/v2/synthetics/suites/search" + bulk_delete_path = "/api/v2/synthetics/suites/bulk-delete" + + _SEARCH_PAGE_SIZE = 100 + + async def get_resources(self, client: CustomClient) -> List[Dict]: + suites: List[Dict] = [] + start = 0 + while True: + resp = await client.get( + self.search_path, + params={"start": start, "count": self._SEARCH_PAGE_SIZE}, + ) + page = resp["data"]["attributes"]["suites"] + suites.extend(page) + if len(page) < self._SEARCH_PAGE_SIZE: + break + start += len(page) + return suites + + async def import_resource(self, _id: Optional[str] = None, resource: Optional[Dict] = None) -> Tuple[str, Dict]: + source_client = self.config.source_client + if _id: + resp = await source_client.get(self.resource_config.base_path + f"/{_id}") + else: + resp = await source_client.get(self.resource_config.base_path + f"/{resource['public_id']}") + + return resp["data"]["attributes"]["public_id"], resp["data"] + + async def pre_resource_action_hook(self, _id, resource: Dict) -> None: + pass + + async def pre_apply_hook(self) -> None: + pass + + async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: + destination_client = self.config.destination_client + payload = {"data": resource} + resp = await destination_client.post(self.resource_config.base_path, payload) + return _id, resp["data"] + + async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: + destination_client = self.config.destination_client + dest_public_id = self.config.state.destination[self.resource_type][_id]["attributes"]["public_id"] + payload = {"data": resource} + resp = await destination_client.put( + self.resource_config.base_path + f"/{dest_public_id}", + payload, + ) + return _id, resp["data"] + + async def delete_resource(self, _id: str) -> None: + destination_client = self.config.destination_client + dest_public_id = self.config.state.destination[self.resource_type][_id]["attributes"]["public_id"] + await destination_client.post( + self.bulk_delete_path, + {"data": {"type": "delete_suites_request", "attributes": {"public_ids": [dest_public_id]}}}, + ) + + def connect_id(self, key: str, r_obj: Dict, resource_to_connect: str) -> Optional[List[str]]: + if resource_to_connect == "synthetics_tests": + resources = self.config.state.destination[resource_to_connect] + failed_connections = [] + source_public_id = str(r_obj[key]) + # synthetics_tests state keys are "{public_id}#{monitor_id}" + # Find the key that starts with the source public_id + for state_key, dest_resource in resources.items(): + if state_key.startswith(source_public_id + "#"): + r_obj[key] = dest_resource["public_id"] + return failed_connections + failed_connections.append(source_public_id) + return failed_connections + + return super().connect_id(key, r_obj, resource_to_connect) diff --git a/datadog_sync/models/__init__.py b/datadog_sync/models/__init__.py index 78ac3c67..600ed24c 100644 --- a/datadog_sync/models/__init__.py +++ b/datadog_sync/models/__init__.py @@ -39,6 +39,7 @@ from datadog_sync.model.synthetics_mobile_applications import SyntheticsMobileApplications from datadog_sync.model.synthetics_mobile_applications_versions import SyntheticsMobileApplicationsVersions from datadog_sync.model.synthetics_private_locations import SyntheticsPrivateLocations +from datadog_sync.model.synthetics_test_suites import SyntheticsTestSuites from datadog_sync.model.synthetics_tests import SyntheticsTests from datadog_sync.model.teams import Teams from datadog_sync.model.team_memberships import TeamMemberships diff --git a/tests/unit/test_synthetics_test_suites.py b/tests/unit/test_synthetics_test_suites.py new file mode 100644 index 00000000..3e776cd9 --- /dev/null +++ b/tests/unit/test_synthetics_test_suites.py @@ -0,0 +1,192 @@ +# Unless explicitly stated otherwise all files in this repository are licensed +# under the 3-clause BSD style license (see LICENSE). +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2019 Datadog, Inc. + +""" +Unit tests for synthetics test suites resource handling. + +These tests verify: +1. Excluded attributes are correctly configured +2. Create wraps payload in JSON:API envelope +3. Update uses destination public_id +4. Delete uses bulk-delete endpoint +5. connect_id maps test public_ids between orgs +""" + +import asyncio +from collections import defaultdict + +import pytest +from unittest.mock import AsyncMock, MagicMock +from datadog_sync.model.synthetics_test_suites import SyntheticsTestSuites +from datadog_sync.utils.configuration import Configuration + + +class TestSyntheticsTestSuitesConfig: + """Test suite for resource configuration.""" + + def test_excluded_attributes(self): + """Verify auto-generated fields are excluded from sync.""" + excluded = SyntheticsTestSuites.resource_config.excluded_attributes + for attr in [ + "root['id']", + "root['attributes']['public_id']", + "root['attributes']['created_at']", + "root['attributes']['modified_at']", + "root['attributes']['created_by']", + "root['attributes']['modified_by']", + "root['attributes']['monitor_id']", + "root['attributes']['org_id']", + "root['attributes']['version']", + "root['attributes']['version_uuid']", + "root['attributes']['overall_state']", + "root['attributes']['overall_state_modified']", + "root['attributes']['options']['slo_id']", + ]: + assert attr in excluded, f"{attr} should be in excluded_attributes" + + def test_resource_connections(self): + """Verify test suites depend on synthetics_tests.""" + connections = SyntheticsTestSuites.resource_config.resource_connections + assert "synthetics_tests" in connections + assert "attributes.tests.public_id" in connections["synthetics_tests"] + + def test_tagging_config(self): + """Verify tags path is correct.""" + assert SyntheticsTestSuites.resource_config.tagging_config.path == "attributes.tags" + + +class TestSyntheticsTestSuitesCRUD: + """Test suite for CRUD operations.""" + + def _make_instance(self): + mock_config = MagicMock(spec=Configuration) + mock_client = AsyncMock() + mock_config.destination_client = mock_client + mock_config.state = MagicMock() + mock_config.state.destination = defaultdict(dict) + instance = SyntheticsTestSuites(mock_config) + return instance, mock_config, mock_client + + def test_create_resource_wraps_envelope(self): + """Verify create sends JSON:API data envelope.""" + instance, mock_config, mock_client = self._make_instance() + mock_client.post = AsyncMock(return_value={ + "data": { + "type": "suites", + "id": "abc-def-ghi", + "attributes": {"name": "My Suite", "public_id": "abc-def-ghi"}, + } + }) + + resource = { + "type": "suites", + "attributes": { + "name": "My Suite", + "tests": [], + "tags": [], + "type": "suite", + }, + } + + _id, resp = asyncio.run(instance.create_resource("src-id", resource)) + + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + assert call_args[0][0] == "/api/v2/synthetics/suites" + assert call_args[0][1] == {"data": resource} + assert _id == "src-id" + assert resp["type"] == "suites" + + def test_update_resource_uses_dest_public_id(self): + """Verify update targets destination public_id.""" + instance, mock_config, mock_client = self._make_instance() + mock_config.state.destination["synthetics_test_suites"] = { + "src-id": {"attributes": {"public_id": "dest-pub-id"}} + } + mock_client.put = AsyncMock(return_value={ + "data": { + "type": "suites", + "id": "dest-pub-id", + "attributes": {"name": "Updated", "public_id": "dest-pub-id"}, + } + }) + + resource = { + "type": "suites", + "attributes": {"name": "Updated", "tests": [], "type": "suite"}, + } + + _id, resp = asyncio.run(instance.update_resource("src-id", resource)) + + call_args = mock_client.put.call_args + assert "/dest-pub-id" in call_args[0][0] + assert call_args[0][1] == {"data": resource} + + def test_delete_resource_uses_bulk_delete(self): + """Verify delete uses bulk-delete endpoint.""" + instance, mock_config, mock_client = self._make_instance() + mock_config.state.destination["synthetics_test_suites"] = { + "src-id": {"attributes": {"public_id": "dest-pub-id"}} + } + mock_client.post = AsyncMock(return_value={}) + + asyncio.run(instance.delete_resource("src-id")) + + call_args = mock_client.post.call_args + assert call_args[0][0] == "/api/v2/synthetics/suites/bulk-delete" + expected = {"data": {"type": "delete_suites_request", "attributes": {"public_ids": ["dest-pub-id"]}}} + assert call_args[0][1] == expected + + +class TestSyntheticsTestSuitesConnectId: + """Test suite for test reference ID mapping.""" + + def _make_instance_with_state(self, dest_tests_state): + mock_config = MagicMock(spec=Configuration) + mock_config.state = MagicMock() + mock_config.state.destination = {"synthetics_tests": dest_tests_state} + instance = SyntheticsTestSuites(mock_config) + return instance + + def test_connect_id_maps_test_public_id(self): + """Verify source test public_id is mapped to destination.""" + dest_state = { + "src-abc-123#99999": {"public_id": "dest-xyz-789"}, + } + instance = self._make_instance_with_state(dest_state) + + r_obj = {"public_id": "src-abc-123"} + failed = instance.connect_id("public_id", r_obj, "synthetics_tests") + + assert r_obj["public_id"] == "dest-xyz-789" + assert failed == [] + + def test_connect_id_missing_test(self): + """Verify failed connection when source test not found.""" + instance = self._make_instance_with_state({}) + + r_obj = {"public_id": "nonexistent-id"} + failed = instance.connect_id("public_id", r_obj, "synthetics_tests") + + assert failed == ["nonexistent-id"] + assert r_obj["public_id"] == "nonexistent-id" # unchanged + + def test_connect_id_multiple_tests_different_monitor_ids(self): + """Verify correct match when multiple keys share a public_id prefix.""" + dest_state = { + "src-abc-123#11111": {"public_id": "dest-xyz-789"}, + "src-abc-456#22222": {"public_id": "dest-xyz-000"}, + } + instance = self._make_instance_with_state(dest_state) + + r_obj = {"public_id": "src-abc-456"} + failed = instance.connect_id("public_id", r_obj, "synthetics_tests") + + assert r_obj["public_id"] == "dest-xyz-000" + assert failed == [] + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])