From eb9d750c050723743848554a58fd47d9fd277d01 Mon Sep 17 00:00:00 2001 From: vikramlc Date: Mon, 7 Sep 2026 17:38:48 +0530 Subject: [PATCH] test(integrations): Add unit tests for Integrations API client Unit tests (mocked HTTP) for IntegrationsAPI's CRUD methods and the Tasks, Errors, Config, and Actions sub-APIs added in the previous PR, plus retry-idempotency coverage in test_api_client.py. Co-Authored-By: Claude Sonnet 5 --- .../test_api/test_integrations/__init__.py | 0 .../test_integrations/test_actions.py | 87 +++++++++++++ .../test_api/test_integrations/test_config.py | 78 ++++++++++++ .../test_api/test_integrations/test_errors.py | 41 ++++++ .../test_integrations/test_integrations.py | 118 ++++++++++++++++++ .../test_api/test_integrations/test_tasks.py | 65 ++++++++++ tests/tests_unit/test_api_client.py | 9 ++ 7 files changed, 398 insertions(+) create mode 100644 tests/tests_unit/test_api/test_integrations/__init__.py create mode 100644 tests/tests_unit/test_api/test_integrations/test_actions.py create mode 100644 tests/tests_unit/test_api/test_integrations/test_config.py create mode 100644 tests/tests_unit/test_api/test_integrations/test_errors.py create mode 100644 tests/tests_unit/test_api/test_integrations/test_integrations.py create mode 100644 tests/tests_unit/test_api/test_integrations/test_tasks.py diff --git a/tests/tests_unit/test_api/test_integrations/__init__.py b/tests/tests_unit/test_api/test_integrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/tests_unit/test_api/test_integrations/test_actions.py b/tests/tests_unit/test_api/test_integrations/test_actions.py new file mode 100644 index 0000000000..68e00a1795 --- /dev/null +++ b/tests/tests_unit/test_api/test_integrations/test_actions.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import re + +from pytest_httpx import HTTPXMock + +from cognite.client import AsyncCogniteClient, CogniteClient +from cognite.client.data_classes.integrations import Action, ActionList, ActionWrite +from tests.utils import get_url, jsgz_load + +ACTION_RESPONSE = { + "externalId": "my-action", + "actionName": "restart", + "status": "pending", + "createdTime": 0, + "lastUpdatedTime": 0, +} + + +class TestIntegrationActions: + def test_create_single( + self, cognite_client: CogniteClient, async_client: AsyncCogniteClient, httpx_mock: HTTPXMock + ) -> None: + httpx_mock.add_response( + method="POST", + url=re.compile(re.escape(get_url(async_client.integrations.actions, "/integrations/actions")) + r"\?.*"), + json={"items": [ACTION_RESPONSE]}, + status_code=201, + ) + + res = cognite_client.integrations.actions.create( + "my-integration", ActionWrite(external_id="my-action", action_name="restart") + ) + + assert isinstance(res, Action) + assert res.status == "pending" + + request = httpx_mock.get_requests()[0] + assert "externalId=my-integration" in str(request.url) + body = jsgz_load(request.content) + assert body == {"items": [{"externalId": "my-action", "actionName": "restart"}]} + + def test_list(self, cognite_client: CogniteClient, async_client: AsyncCogniteClient, httpx_mock: HTTPXMock) -> None: + url_pattern = re.compile( + re.escape(get_url(async_client.integrations.actions, "/integrations/actions")) + r"(?:\?.*)?$" + ) + httpx_mock.add_response(method="GET", url=url_pattern, json={"items": [ACTION_RESPONSE]}) + + res = cognite_client.integrations.actions.list(external_id="my-integration", include_completed=False) + + assert isinstance(res, ActionList) + assert len(res) == 1 + + request = httpx_mock.get_requests()[0] + assert "includeCompleted=false" in str(request.url) + + def test_retrieve( + self, cognite_client: CogniteClient, async_client: AsyncCogniteClient, httpx_mock: HTTPXMock + ) -> None: + httpx_mock.add_response( + method="POST", + url=get_url(async_client.integrations.actions, "/integrations/actions/byids"), + json={"items": [ACTION_RESPONSE]}, + ) + + res = cognite_client.integrations.actions.retrieve("my-action") + + assert isinstance(res, Action) + assert res.external_id == "my-action" + + def test_cancel( + self, cognite_client: CogniteClient, async_client: AsyncCogniteClient, httpx_mock: HTTPXMock + ) -> None: + cancelled_response = {**ACTION_RESPONSE, "status": "cancel_pending"} + httpx_mock.add_response( + method="POST", + url=get_url(async_client.integrations.actions, "/integrations/actions/cancel"), + json={"items": [cancelled_response]}, + ) + + res = cognite_client.integrations.actions.cancel("my-action") + + assert isinstance(res, ActionList) + assert res[0].status == "cancel_pending" + + body = jsgz_load(httpx_mock.get_requests()[0].content) + assert body == {"items": [{"externalId": "my-action"}]} diff --git a/tests/tests_unit/test_api/test_integrations/test_config.py b/tests/tests_unit/test_api/test_integrations/test_config.py new file mode 100644 index 0000000000..9e85f70ead --- /dev/null +++ b/tests/tests_unit/test_api/test_integrations/test_config.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import re + +from pytest_httpx import HTTPXMock + +from cognite.client import AsyncCogniteClient, CogniteClient +from cognite.client.data_classes.integrations import ConfigRevision, ConfigRevisionMetadataList, ConfigRevisionWrite +from tests.utils import get_url, jsgz_load + +CONFIG_REVISION_RESPONSE = { + "externalId": "my-integration", + "revision": 1, + "config": "my config contents", + "createdTime": 0, + "lastUpdatedTime": 0, +} +CONFIG_REVISION_METADATA_RESPONSE = { + "externalId": "my-integration", + "revision": 1, + "createdTime": 0, + "lastUpdatedTime": 0, +} + + +class TestIntegrationConfig: + def test_create( + self, cognite_client: CogniteClient, async_client: AsyncCogniteClient, httpx_mock: HTTPXMock + ) -> None: + httpx_mock.add_response( + method="POST", + url=get_url(async_client.integrations.config, "/integrations/config"), + json=CONFIG_REVISION_RESPONSE, + ) + + res = cognite_client.integrations.config.create( + ConfigRevisionWrite(external_id="my-integration", config="my config contents") + ) + + assert isinstance(res, ConfigRevision) + assert res.revision == 1 + assert res.config == "my config contents" + + body = jsgz_load(httpx_mock.get_requests()[0].content) + assert body == {"externalId": "my-integration", "config": "my config contents"} + + def test_retrieve( + self, cognite_client: CogniteClient, async_client: AsyncCogniteClient, httpx_mock: HTTPXMock + ) -> None: + url_pattern = re.compile( + re.escape(get_url(async_client.integrations.config, "/integrations/config")) + r"(?:\?.*)?$" + ) + httpx_mock.add_response(method="GET", url=url_pattern, json=CONFIG_REVISION_RESPONSE) + + res = cognite_client.integrations.config.retrieve("my-integration", revision=1) + + assert isinstance(res, ConfigRevision) + assert res.revision == 1 + + request = httpx_mock.get_requests()[0] + assert "externalId=my-integration" in str(request.url) + assert "revision=1" in str(request.url) + + def test_list(self, cognite_client: CogniteClient, async_client: AsyncCogniteClient, httpx_mock: HTTPXMock) -> None: + url_pattern = re.compile( + re.escape(get_url(async_client.integrations.config, "/integrations/config/revisions")) + r"(?:\?.*)?$" + ) + httpx_mock.add_response(method="GET", url=url_pattern, json={"items": [CONFIG_REVISION_METADATA_RESPONSE]}) + + res = cognite_client.integrations.config.list(external_id="my-integration") + + assert isinstance(res, ConfigRevisionMetadataList) + assert len(res) == 1 + assert res[0].revision == 1 + + request = httpx_mock.get_requests()[0] + assert "externalId=my-integration" in str(request.url) + assert "limit=25" in str(request.url) diff --git a/tests/tests_unit/test_api/test_integrations/test_errors.py b/tests/tests_unit/test_api/test_integrations/test_errors.py new file mode 100644 index 0000000000..cbc79b611e --- /dev/null +++ b/tests/tests_unit/test_api/test_integrations/test_errors.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import re + +import pytest +from pytest_httpx import HTTPXMock + +from cognite.client import AsyncCogniteClient, CogniteClient +from cognite.client.data_classes.integrations.errors import IntegrationErrorList +from tests.utils import get_url + +ERROR_RESPONSE = { + "externalId": "my-integration", + "level": "error", + "description": "Something went wrong", + "startTime": 100, + "task": "poll", +} + + +class TestIntegrationErrors: + def test_list(self, cognite_client: CogniteClient, async_client: AsyncCogniteClient, httpx_mock: HTTPXMock) -> None: + url_pattern = re.compile( + re.escape(get_url(async_client.integrations.errors, "/integrations/errors")) + r"(?:\?.*)?$" + ) + httpx_mock.add_response(method="GET", url=url_pattern, json={"items": [ERROR_RESPONSE]}) + + res = cognite_client.integrations.errors.list(external_id="my-integration", task="poll") + + assert isinstance(res, IntegrationErrorList) + assert len(res) == 1 + assert res[0].level == "error" + assert res[0].description == "Something went wrong" + + request = httpx_mock.get_requests()[0] + assert "externalId=my-integration" in str(request.url) + assert "task=poll" in str(request.url) + + def test_list_task_without_external_id_raises(self, cognite_client: CogniteClient) -> None: + with pytest.raises(ValueError, match="'task' requires 'external_id'"): + cognite_client.integrations.errors.list(task="poll") diff --git a/tests/tests_unit/test_api/test_integrations/test_integrations.py b/tests/tests_unit/test_api/test_integrations/test_integrations.py new file mode 100644 index 0000000000..170aae84e7 --- /dev/null +++ b/tests/tests_unit/test_api/test_integrations/test_integrations.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import re + +from pytest_httpx import HTTPXMock + +from cognite.client import AsyncCogniteClient, CogniteClient +from cognite.client.data_classes.integrations import ( + Extractor, + Integration, + IntegrationList, + IntegrationUpdate, + IntegrationWrite, +) +from tests.utils import get_url, jsgz_load + +INTEGRATION_RESPONSE = { + "externalId": "my-integration", + "extractor": {"externalId": "cognite-simple-influxdb-extractor", "version": "1.0.0"}, + "name": "My integration", + "createdTime": 0, + "lastUpdatedTime": 0, + "tasks": [{"type": "continuous", "name": "poll", "action": False}], +} + + +class TestIntegrations: + def test_list(self, cognite_client: CogniteClient, async_client: AsyncCogniteClient, httpx_mock: HTTPXMock) -> None: + url_pattern = re.compile(re.escape(get_url(async_client.integrations, "/integrations")) + r"(?:\?.*)?$") + httpx_mock.add_response(method="GET", url=url_pattern, json={"items": [INTEGRATION_RESPONSE]}) + + res = cognite_client.integrations.list(limit=10) + + assert isinstance(res, IntegrationList) + assert len(res) == 1 + assert res[0].external_id == "my-integration" + assert res[0].tasks[0].name == "poll" + + request = httpx_mock.get_requests()[0] + assert request.method == "GET" + assert request.headers["cdf-version"] == async_client.integrations._beta_version_header()["cdf-version"] + + def test_create( + self, cognite_client: CogniteClient, async_client: AsyncCogniteClient, httpx_mock: HTTPXMock + ) -> None: + httpx_mock.add_response( + method="POST", + url=get_url(async_client.integrations, "/integrations"), + json={"items": [INTEGRATION_RESPONSE]}, + ) + integration = IntegrationWrite( + external_id="my-integration", + extractor=Extractor(external_id="cognite-simple-influxdb-extractor", version="1.0.0"), + name="My integration", + ) + + res = cognite_client.integrations.create(integration) + + assert isinstance(res, Integration) + assert res.external_id == "my-integration" + + body = jsgz_load(httpx_mock.get_requests()[0].content) + assert body == { + "items": [ + { + "externalId": "my-integration", + "name": "My integration", + "extractor": {"externalId": "cognite-simple-influxdb-extractor", "version": "1.0.0"}, + } + ] + } + + def test_retrieve( + self, cognite_client: CogniteClient, async_client: AsyncCogniteClient, httpx_mock: HTTPXMock + ) -> None: + httpx_mock.add_response( + method="POST", + url=get_url(async_client.integrations, "/integrations/byids"), + json={"items": [INTEGRATION_RESPONSE]}, + ) + + res = cognite_client.integrations.retrieve("my-integration") + + assert isinstance(res, Integration) + assert res.external_id == "my-integration" + + body = jsgz_load(httpx_mock.get_requests()[0].content) + assert body == {"items": [{"externalId": "my-integration"}], "ignoreUnknownIds": False} + + def test_update( + self, cognite_client: CogniteClient, async_client: AsyncCogniteClient, httpx_mock: HTTPXMock + ) -> None: + httpx_mock.add_response( + method="POST", + url=get_url(async_client.integrations, "/integrations/update"), + json={"items": [INTEGRATION_RESPONSE]}, + ) + update = IntegrationUpdate(external_id="my-integration") + update.description.set("My new description") + + res = cognite_client.integrations.update(update) + + assert isinstance(res, Integration) + + body = jsgz_load(httpx_mock.get_requests()[0].content) + assert body == { + "items": [{"externalId": "my-integration", "update": {"description": {"set": "My new description"}}}] + } + + def test_delete( + self, cognite_client: CogniteClient, async_client: AsyncCogniteClient, httpx_mock: HTTPXMock + ) -> None: + httpx_mock.add_response(method="POST", url=get_url(async_client.integrations, "/integrations/delete"), json={}) + + cognite_client.integrations.delete("my-integration") + + body = jsgz_load(httpx_mock.get_requests()[0].content) + assert body == {"items": [{"externalId": "my-integration"}], "ignoreUnknownIds": False} diff --git a/tests/tests_unit/test_api/test_integrations/test_tasks.py b/tests/tests_unit/test_api/test_integrations/test_tasks.py new file mode 100644 index 0000000000..3b3faf235d --- /dev/null +++ b/tests/tests_unit/test_api/test_integrations/test_tasks.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import re + +import pytest +from pytest_httpx import HTTPXMock + +from cognite.client import AsyncCogniteClient, CogniteClient +from cognite.client.data_classes.integrations.tasks import SyncResult, TaskHistoryList +from tests.utils import get_url + +TASK_HISTORY_RESPONSE = { + "externalId": "my-integration", + "taskName": "poll", + "startTime": 100, + "endTime": 200, + "errorCount": 0, +} + + +class TestIntegrationTasks: + def test_list_history( + self, cognite_client: CogniteClient, async_client: AsyncCogniteClient, httpx_mock: HTTPXMock + ) -> None: + url_pattern = re.compile( + re.escape(get_url(async_client.integrations.tasks, "/integrations/history")) + r"(?:\?.*)?$" + ) + httpx_mock.add_response(method="GET", url=url_pattern, json={"items": [TASK_HISTORY_RESPONSE]}) + + res = cognite_client.integrations.tasks.list_history(external_id="my-integration", task_name="poll") + + assert isinstance(res, TaskHistoryList) + assert len(res) == 1 + assert res[0].task_name == "poll" + + request = httpx_mock.get_requests()[0] + assert "externalId=my-integration" in str(request.url) + assert "taskName=poll" in str(request.url) + + def test_list_history_task_name_without_external_id_raises(self, cognite_client: CogniteClient) -> None: + with pytest.raises(ValueError, match="'task_name' requires 'external_id'"): + cognite_client.integrations.tasks.list_history(task_name="poll") + + def test_sync(self, cognite_client: CogniteClient, async_client: AsyncCogniteClient, httpx_mock: HTTPXMock) -> None: + url_pattern = re.compile( + re.escape(get_url(async_client.integrations.tasks, "/integrations/sync")) + r"(?:\?.*)?$" + ) + httpx_mock.add_response( + method="GET", + url=url_pattern, + json={"nextCursor": "abc", "moreData": False, "history": [TASK_HISTORY_RESPONSE]}, + ) + + res = cognite_client.integrations.tasks.sync(external_id="my-integration", include_task_updates=True) + + assert isinstance(res, SyncResult) + assert res.next_cursor == "abc" + assert res.more_data is False + assert res.history is not None + assert res.history[0].task_name == "poll" + assert res.errors is None + + def test_sync_requires_include_errors_or_include_task_updates(self, cognite_client: CogniteClient) -> None: + with pytest.raises(ValueError, match="'include_errors' or 'include_task_updates'"): + cognite_client.integrations.tasks.sync(external_id="my-integration") diff --git a/tests/tests_unit/test_api_client.py b/tests/tests_unit/test_api_client.py index d6937df5d0..84faaa9b64 100644 --- a/tests/tests_unit/test_api_client.py +++ b/tests/tests_unit/test_api_client.py @@ -1781,6 +1781,15 @@ async def test_is_retryable_resource_api_endpoints(self, method: str, path: str, ("POST", "https://api.cognitedata.com/api/v1/projects/bla/extpipes/runs/list", True), ("POST", "https://api.cognitedata.com/api/v1/projects/bla/extpipes/config", False), ("POST", "https://api.cognitedata.com/api/v1/projects/bla/extpipes/config/revert", False), + # Integrations + ("POST", "https://api.cognitedata.com/api/v1/projects/bla/integrations", False), + ("POST", "https://api.cognitedata.com/api/v1/projects/bla/integrations/byids", True), + ("POST", "https://api.cognitedata.com/api/v1/projects/bla/integrations/delete", False), + ("POST", "https://api.cognitedata.com/api/v1/projects/bla/integrations/update", True), + ("POST", "https://api.cognitedata.com/api/v1/projects/bla/integrations/config", False), + ("POST", "https://api.cognitedata.com/api/v1/projects/bla/integrations/actions", False), + ("POST", "https://api.cognitedata.com/api/v1/projects/bla/integrations/actions/byids", True), + ("POST", "https://api.cognitedata.com/api/v1/projects/bla/integrations/actions/cancel", False), # Transformations ("POST", "https://api.cognitedata.com/api/v1/projects/bla/transformations", False), ("POST", "https://api.cognitedata.com/api/v1/projects/bla/transformations/filter", True),