Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions datadog_sync/model/synthetics_test_suites.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions datadog_sync/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
192 changes: 192 additions & 0 deletions tests/unit/test_synthetics_test_suites.py
Original file line number Diff line number Diff line change
@@ -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"])