diff --git a/erclient/client.py b/erclient/client.py index 49c69d2..e57751c 100644 --- a/erclient/client.py +++ b/erclient/client.py @@ -871,12 +871,49 @@ def pulse(self, message=None): """ return self._get('status') + def patch_subject(self, subject_id, data): + """ + Update a subject with partial data. + + :param subject_id: The subject UUID + :param data: Partial subject data (e.g., {"is_active": False}) + :return: Updated subject data + """ + self.logger.debug(f'Patching subject {subject_id}: {data}') + return self._patch(f'subject/{subject_id}', payload=data) + def get_subject_sources(self, subject_id): return self._get(path=f'subject/{subject_id}/sources') def get_subjectsources(self, subject_id): return self._get(path=f'subject/{subject_id}/subjectsources') + def get_source_subjects(self, source_id): + """ + Get all subjects linked to a source. + + :param source_id: The source UUID + :return: List of subject data + """ + self.logger.debug(f'Getting subjects for source: {source_id}') + return self._get(path=f'source/{source_id}/subjects') + + def get_source_assignments(self, subject_ids=None, source_ids=None): + """ + Get the source assignments (aka subject_sources). Optionally filter + by subject_ids or source_ids. + + :param subject_ids: Optional list of subject UUIDs to filter by + :param source_ids: Optional list of source UUIDs to filter by + :return: Source assignment data + """ + params = {} + if subject_ids: + params['subjects'] = ','.join(subject_ids) + if source_ids: + params['sources'] = ','.join(source_ids) + return self._get(path='subjectsources', params=params) + def get_source_provider(self, provider_key): results = self.get_objects(object="sourceproviders") @@ -1613,6 +1650,46 @@ async def get_feature_group(self, feature_group_id: str): """ return await self._get(f"spatialfeaturegroup/{feature_group_id}", params={}) + async def post_subject(self, subject): + """ + Create a new subject. + + :param subject: Subject payload dict + :return: Created subject data + """ + self.logger.debug(f"Posting subject {subject.get('name')}") + return await self._post('subjects', payload=subject) + + async def delete_subject(self, subject_id): + """ + Delete a subject by ID. + + :param subject_id: The subject UUID + :return: True on success + """ + self.logger.debug(f'Deleting subject {subject_id}') + return await self._delete(f'subject/{subject_id}/') + + async def post_source(self, source): + """ + Create a new source. + + :param source: Source payload dict + :return: Created source data + """ + self.logger.debug(f"Posting source for manufacturer_id: {source.get('manufacturer_id')}") + return await self._post('sources', payload=source) + + async def delete_source(self, source_id): + """ + Delete a source by ID. + + :param source_id: The source UUID + :return: True on success + """ + self.logger.debug(f'Deleting source {source_id}') + return await self._delete(f'source/{source_id}/') + async def _get_data(self, endpoint, params, batch_size=0): if "page" not in params: # Use cursor paginator unless the user has specified a page params["use_cursor"] = "true" diff --git a/tests/async_client/test_subject_source_write.py b/tests/async_client/test_subject_source_write.py new file mode 100644 index 0000000..1bc771b --- /dev/null +++ b/tests/async_client/test_subject_source_write.py @@ -0,0 +1,365 @@ +import httpx +import pytest +import respx + +from erclient import (ERClientBadCredentials, ERClientBadRequest, + ERClientException, ERClientInternalError, + ERClientNotFound, ERClientPermissionDenied, + ERClientRateLimitExceeded, ERClientServiceUnreachable) + + +# ---- Fixtures ---- + +@pytest.fixture +def subject_payload(): + return { + "name": "Test Elephant", + "subject_type": "wildlife", + "subject_subtype": "elephant", + "is_active": True, + "additional": {}, + } + + +@pytest.fixture +def subject_created_response(): + return { + "data": { + "content_type": "observations.subject", + "id": "aabbccdd-1234-5678-9012-abcdefabcdef", + "name": "Test Elephant", + "subject_type": "wildlife", + "subject_subtype": "elephant", + "common_name": None, + "additional": {}, + "created_at": "2026-02-10T12:00:00.000000-08:00", + "updated_at": "2026-02-10T12:00:00.000000-08:00", + "is_active": True, + "user": None, + "tracks_available": False, + "image_url": "/static/elephant-black.svg", + }, + "status": {"code": 201, "message": "Created"}, + } + + +@pytest.fixture +def source_payload(): + return { + "manufacturer_id": "collar-9999", + "source_type": "tracking-device", + "model_name": "Test Collar", + "additional": {}, + } + + +@pytest.fixture +def source_created_response(): + return { + "data": { + "id": "ee112233-4455-6677-8899-aabbccddeeff", + "manufacturer_id": "collar-9999", + "source_type": "tracking-device", + "model_name": "Test Collar", + "additional": {}, + "created_at": "2026-02-10T12:00:00.000000-08:00", + "updated_at": "2026-02-10T12:00:00.000000-08:00", + }, + "status": {"code": 201, "message": "Created"}, + } + + +# ---- post_subject tests ---- + +@pytest.mark.asyncio +async def test_post_subject_success(er_client, subject_payload, subject_created_response): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.post('subjects') + route.return_value = httpx.Response( + httpx.codes.CREATED, json=subject_created_response + ) + + result = await er_client.post_subject(subject_payload) + + assert result == subject_created_response["data"] + assert route.called + await er_client.close() + + +@pytest.mark.asyncio +async def test_post_subject_bad_request(er_client, subject_payload): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.post('subjects') + route.return_value = httpx.Response( + httpx.codes.BAD_REQUEST, json={} + ) + + with pytest.raises(ERClientBadRequest): + await er_client.post_subject(subject_payload) + + assert route.called + await er_client.close() + + +@pytest.mark.asyncio +async def test_post_subject_forbidden(er_client, subject_payload): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.post('subjects') + route.return_value = httpx.Response( + httpx.codes.FORBIDDEN, json={} + ) + + with pytest.raises(ERClientPermissionDenied): + await er_client.post_subject(subject_payload) + + assert route.called + await er_client.close() + + +@pytest.mark.asyncio +async def test_post_subject_unauthorized(er_client, subject_payload): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.post('subjects') + route.return_value = httpx.Response( + httpx.codes.UNAUTHORIZED, json={} + ) + + with pytest.raises(ERClientBadCredentials): + await er_client.post_subject(subject_payload) + + assert route.called + await er_client.close() + + +@pytest.mark.asyncio +async def test_post_subject_network_error(er_client, subject_payload): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.post('subjects') + route.side_effect = httpx.ConnectTimeout + + with pytest.raises(ERClientException): + await er_client.post_subject(subject_payload) + + assert route.called + await er_client.close() + + +# ---- delete_subject tests ---- + +@pytest.mark.asyncio +async def test_delete_subject_success(er_client): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + subject_id = "aabbccdd-1234-5678-9012-abcdefabcdef" + route = respx_mock.delete(f'subject/{subject_id}/') + route.return_value = httpx.Response( + httpx.codes.NO_CONTENT, json={} + ) + + # Should not raise + await er_client.delete_subject(subject_id) + + assert route.called + await er_client.close() + + +@pytest.mark.asyncio +async def test_delete_subject_not_found(er_client): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + subject_id = "nonexistent-id" + route = respx_mock.delete(f'subject/{subject_id}/') + route.return_value = httpx.Response( + httpx.codes.NOT_FOUND, json={} + ) + + with pytest.raises(ERClientNotFound): + await er_client.delete_subject(subject_id) + + assert route.called + await er_client.close() + + +@pytest.mark.asyncio +async def test_delete_subject_forbidden(er_client): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + subject_id = "aabbccdd-1234-5678-9012-abcdefabcdef" + route = respx_mock.delete(f'subject/{subject_id}/') + route.return_value = httpx.Response( + httpx.codes.FORBIDDEN, json={} + ) + + with pytest.raises(ERClientPermissionDenied): + await er_client.delete_subject(subject_id) + + assert route.called + await er_client.close() + + +@pytest.mark.asyncio +async def test_delete_subject_network_error(er_client): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + subject_id = "aabbccdd-1234-5678-9012-abcdefabcdef" + route = respx_mock.delete(f'subject/{subject_id}/') + route.side_effect = httpx.ReadTimeout + + with pytest.raises(ERClientException): + await er_client.delete_subject(subject_id) + + assert route.called + await er_client.close() + + +# ---- post_source tests ---- + +@pytest.mark.asyncio +async def test_post_source_success(er_client, source_payload, source_created_response): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.post('sources') + route.return_value = httpx.Response( + httpx.codes.CREATED, json=source_created_response + ) + + result = await er_client.post_source(source_payload) + + assert result == source_created_response["data"] + assert route.called + await er_client.close() + + +@pytest.mark.asyncio +async def test_post_source_bad_request(er_client, source_payload): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.post('sources') + route.return_value = httpx.Response( + httpx.codes.BAD_REQUEST, json={} + ) + + with pytest.raises(ERClientBadRequest): + await er_client.post_source(source_payload) + + assert route.called + await er_client.close() + + +@pytest.mark.asyncio +async def test_post_source_forbidden(er_client, source_payload): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.post('sources') + route.return_value = httpx.Response( + httpx.codes.FORBIDDEN, json={} + ) + + with pytest.raises(ERClientPermissionDenied): + await er_client.post_source(source_payload) + + assert route.called + await er_client.close() + + +@pytest.mark.asyncio +async def test_post_source_network_error(er_client, source_payload): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.post('sources') + route.side_effect = httpx.ConnectTimeout + + with pytest.raises(ERClientException): + await er_client.post_source(source_payload) + + assert route.called + await er_client.close() + + +# ---- delete_source tests ---- + +@pytest.mark.asyncio +async def test_delete_source_success(er_client): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + source_id = "ee112233-4455-6677-8899-aabbccddeeff" + route = respx_mock.delete(f'source/{source_id}/') + route.return_value = httpx.Response( + httpx.codes.NO_CONTENT, json={} + ) + + await er_client.delete_source(source_id) + + assert route.called + await er_client.close() + + +@pytest.mark.asyncio +async def test_delete_source_not_found(er_client): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + source_id = "nonexistent-source-id" + route = respx_mock.delete(f'source/{source_id}/') + route.return_value = httpx.Response( + httpx.codes.NOT_FOUND, json={} + ) + + with pytest.raises(ERClientNotFound): + await er_client.delete_source(source_id) + + assert route.called + await er_client.close() + + +@pytest.mark.asyncio +async def test_delete_source_forbidden(er_client): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + source_id = "ee112233-4455-6677-8899-aabbccddeeff" + route = respx_mock.delete(f'source/{source_id}/') + route.return_value = httpx.Response( + httpx.codes.FORBIDDEN, json={} + ) + + with pytest.raises(ERClientPermissionDenied): + await er_client.delete_source(source_id) + + assert route.called + await er_client.close() + + +@pytest.mark.asyncio +async def test_delete_source_network_error(er_client): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + source_id = "ee112233-4455-6677-8899-aabbccddeeff" + route = respx_mock.delete(f'source/{source_id}/') + route.side_effect = httpx.ReadTimeout + + with pytest.raises(ERClientException): + await er_client.delete_source(source_id) + + assert route.called + await er_client.close() diff --git a/tests/sync_client/test_subject_source_write.py b/tests/sync_client/test_subject_source_write.py new file mode 100644 index 0000000..8284cd5 --- /dev/null +++ b/tests/sync_client/test_subject_source_write.py @@ -0,0 +1,363 @@ +import json +from unittest.mock import MagicMock, patch + +import pytest + +from erclient import ERClientException, ERClientNotFound, ERClientPermissionDenied + + +# ---- Fixtures ---- + +@pytest.fixture +def subject_updated_response(): + return { + "data": { + "content_type": "observations.subject", + "id": "d8ad9955-8301-43c4-9000-9a02f1cba675", + "name": "MMVessel", + "subject_type": "vehicle", + "subject_subtype": "vessel", + "is_active": False, + "additional": {}, + "created_at": "2026-01-12T03:36:26.383023-08:00", + "updated_at": "2026-01-12T04:30:12.289513-08:00", + }, + "status": {"code": 200, "message": "OK"}, + } + + +@pytest.fixture +def subject_created_response(): + return { + "data": { + "content_type": "observations.subject", + "id": "aabbccdd-1234-5678-9012-abcdefabcdef", + "name": "Test Elephant", + "subject_type": "wildlife", + "subject_subtype": "elephant", + "is_active": True, + "additional": {}, + "created_at": "2026-02-10T12:00:00.000000-08:00", + "updated_at": "2026-02-10T12:00:00.000000-08:00", + }, + "status": {"code": 201, "message": "Created"}, + } + + +@pytest.fixture +def source_created_response(): + return { + "data": { + "id": "ee112233-4455-6677-8899-aabbccddeeff", + "manufacturer_id": "collar-9999", + "source_type": "tracking-device", + "model_name": "Test Collar", + "additional": {}, + }, + "status": {"code": 201, "message": "Created"}, + } + + +@pytest.fixture +def source_assignments_response(): + return { + "data": [ + { + "id": "123e4567-e89b-12d3-a456-426614174000", + "subject": "123e4567-e89b-12d3-a456-426614174001", + "source": "123e4567-e89b-12d3-a456-426614174002", + "assigned_range": { + "lower": "2023-06-01T01:41:00+02:00", + "upper": "2024-01-11T19:41:00+02:00", + "bounds": "[)", + }, + } + ], + "status": {"code": 200, "message": "OK"}, + } + + +def _mock_response(status_code=200, json_data=None, ok=True, text=None): + mock = MagicMock() + mock.ok = ok + mock.status_code = status_code + mock.text = text or json.dumps(json_data or {}) + mock.json.return_value = json_data or {} + return mock + + +# ---- patch_subject tests ---- + +class TestPatchSubject: + def test_patch_subject_success(self, er_client, subject_updated_response): + with patch.object(er_client._http_session, 'patch') as mock_patch: + mock_patch.return_value = _mock_response( + 200, subject_updated_response + ) + result = er_client.patch_subject( + "d8ad9955-8301-43c4-9000-9a02f1cba675", + {"is_active": False}, + ) + assert result == subject_updated_response["data"] + assert mock_patch.called + + def test_patch_subject_not_found(self, er_client): + with patch.object(er_client._http_session, 'patch') as mock_patch: + mock_patch.return_value = _mock_response( + 404, ok=False, text='{"status":{"detail":"not found"}}' + ) + with pytest.raises(ERClientNotFound): + er_client.patch_subject("nonexistent-id", {"is_active": False}) + + def test_patch_subject_forbidden(self, er_client): + with patch.object(er_client._http_session, 'patch') as mock_patch: + mock_patch.return_value = _mock_response( + 403, + ok=False, + text='{"status":{"detail":"forbidden"}}', + ) + with pytest.raises(ERClientPermissionDenied): + er_client.patch_subject( + "d8ad9955-8301-43c4-9000-9a02f1cba675", + {"is_active": False}, + ) + + +# ---- get_source_subjects tests ---- + +class TestGetSourceSubjects: + def test_get_source_subjects_success(self, er_client): + source_subjects_response = { + "data": [ + { + "content_type": "observations.subject", + "id": "d8ad9955-8301-43c4-9000-9a02f1cba675", + "name": "MMVessel", + "subject_type": "vehicle", + "subject_subtype": "vessel", + "is_active": True, + } + ], + "status": {"code": 200, "message": "OK"}, + } + with patch.object(er_client._http_session, 'get') as mock_get: + mock_get.return_value = _mock_response(200, source_subjects_response) + source_id = "119feb94-a6cc-4485-8614-06fb0abc2a9c" + result = er_client.get_source_subjects(source_id) + assert result == source_subjects_response["data"] + assert mock_get.called + url = mock_get.call_args[0][0] + assert f"source/{source_id}/subjects" in url + + def test_get_source_subjects_empty(self, er_client): + empty_response = { + "data": [], + "status": {"code": 200, "message": "OK"}, + } + with patch.object(er_client._http_session, 'get') as mock_get: + mock_get.return_value = _mock_response(200, empty_response) + result = er_client.get_source_subjects("119feb94-a6cc-4485-8614-06fb0abc2a9c") + assert result == [] + + def test_get_source_subjects_not_found(self, er_client): + with patch.object(er_client._http_session, 'get') as mock_get: + mock_get.return_value = _mock_response( + 404, ok=False, text='{"status":{"detail":"not found"}}' + ) + with pytest.raises(ERClientNotFound): + er_client.get_source_subjects("nonexistent-id") + + def test_get_source_subjects_forbidden(self, er_client): + with patch.object(er_client._http_session, 'get') as mock_get: + mock_get.return_value = _mock_response( + 403, ok=False, text='{"status":{"detail":"forbidden"}}' + ) + with pytest.raises(ERClientPermissionDenied): + er_client.get_source_subjects("119feb94-a6cc-4485-8614-06fb0abc2a9c") + + +# ---- get_source_subjects tests ---- + +class TestGetSourceSubjects: + def test_get_source_subjects_success(self, er_client): + source_subjects_response = { + "data": [ + { + "content_type": "observations.subject", + "id": "d8ad9955-8301-43c4-9000-9a02f1cba675", + "name": "MMVessel", + "subject_type": "vehicle", + "subject_subtype": "vessel", + "is_active": True, + } + ], + "status": {"code": 200, "message": "OK"}, + } + with patch.object(er_client._http_session, 'get') as mock_get: + mock_get.return_value = _mock_response(200, source_subjects_response) + source_id = "119feb94-a6cc-4485-8614-06fb0abc2a9c" + result = er_client.get_source_subjects(source_id) + assert result == source_subjects_response["data"] + assert mock_get.called + url = mock_get.call_args[0][0] + assert f"source/{source_id}/subjects" in url + + def test_get_source_subjects_empty(self, er_client): + empty_response = { + "data": [], + "status": {"code": 200, "message": "OK"}, + } + with patch.object(er_client._http_session, 'get') as mock_get: + mock_get.return_value = _mock_response(200, empty_response) + result = er_client.get_source_subjects("119feb94-a6cc-4485-8614-06fb0abc2a9c") + assert result == [] + + def test_get_source_subjects_not_found(self, er_client): + with patch.object(er_client._http_session, 'get') as mock_get: + mock_get.return_value = _mock_response( + 404, ok=False, text='{"status":{"detail":"not found"}}' + ) + with pytest.raises(ERClientNotFound): + er_client.get_source_subjects("nonexistent-id") + + def test_get_source_subjects_forbidden(self, er_client): + with patch.object(er_client._http_session, 'get') as mock_get: + mock_get.return_value = _mock_response( + 403, ok=False, text='{"status":{"detail":"forbidden"}}' + ) + with pytest.raises(ERClientPermissionDenied): + er_client.get_source_subjects("119feb94-a6cc-4485-8614-06fb0abc2a9c") + + +# ---- get_source_assignments tests ---- + +class TestGetSourceAssignments: + def test_get_source_assignments_success(self, er_client, source_assignments_response): + with patch.object(er_client._http_session, 'get') as mock_get: + mock_get.return_value = _mock_response( + 200, source_assignments_response + ) + result = er_client.get_source_assignments() + assert result == source_assignments_response["data"] + assert mock_get.called + + def test_get_source_assignments_with_subject_ids(self, er_client, source_assignments_response): + with patch.object(er_client._http_session, 'get') as mock_get: + mock_get.return_value = _mock_response( + 200, source_assignments_response + ) + result = er_client.get_source_assignments( + subject_ids=["123e4567-e89b-12d3-a456-426614174001"] + ) + assert result == source_assignments_response["data"] + # Verify that params were passed + call_kwargs = mock_get.call_args + assert 'params' in call_kwargs.kwargs or len(call_kwargs.args) > 1 + + def test_get_source_assignments_with_source_ids(self, er_client, source_assignments_response): + with patch.object(er_client._http_session, 'get') as mock_get: + mock_get.return_value = _mock_response( + 200, source_assignments_response + ) + result = er_client.get_source_assignments( + source_ids=["123e4567-e89b-12d3-a456-426614174002"] + ) + assert result == source_assignments_response["data"] + + def test_get_source_assignments_with_both_filters(self, er_client, source_assignments_response): + with patch.object(er_client._http_session, 'get') as mock_get: + mock_get.return_value = _mock_response( + 200, source_assignments_response + ) + result = er_client.get_source_assignments( + subject_ids=["123e4567-e89b-12d3-a456-426614174001"], + source_ids=["123e4567-e89b-12d3-a456-426614174002"], + ) + assert result == source_assignments_response["data"] + + def test_get_source_assignments_not_found(self, er_client): + with patch.object(er_client._http_session, 'get') as mock_get: + mock_get.return_value = _mock_response( + 404, ok=False, text='{"status":{"detail":"not found"}}' + ) + with pytest.raises(ERClientNotFound): + er_client.get_source_assignments() + + +# ---- sync post_subject tests (already existed, verify it works) ---- + +class TestPostSubject: + def test_post_subject_success(self, er_client, subject_created_response): + with patch.object(er_client._http_session, 'post') as mock_post: + mock_post.return_value = _mock_response( + 201, subject_created_response + ) + result = er_client.post_subject({ + "name": "Test Elephant", + "subject_type": "wildlife", + "subject_subtype": "elephant", + }) + assert result == subject_created_response["data"] + assert mock_post.called + + def test_post_subject_forbidden(self, er_client): + with patch.object(er_client._http_session, 'post') as mock_post: + mock_post.return_value = _mock_response( + 403, ok=False, text='{"status":{"detail":"forbidden"}}' + ) + with pytest.raises(ERClientPermissionDenied): + er_client.post_subject({"name": "Test"}) + + +# ---- sync post_source tests (already existed, verify it works) ---- + +class TestPostSource: + def test_post_source_success(self, er_client, source_created_response): + with patch.object(er_client._http_session, 'post') as mock_post: + mock_post.return_value = _mock_response( + 201, source_created_response + ) + result = er_client.post_source({ + "manufacturer_id": "collar-9999", + "source_type": "tracking-device", + }) + assert result == source_created_response["data"] + assert mock_post.called + + +# ---- sync delete_subject tests (already existed, verify it works) ---- + +class TestDeleteSubject: + def test_delete_subject_success(self, er_client): + with patch.object(er_client._http_session, 'delete') as mock_delete: + mock_delete.return_value = _mock_response(204, ok=True) + # delete_subject doesn't return a value; just verify no exception + er_client.delete_subject("aabbccdd-1234-5678-9012-abcdefabcdef") + assert mock_delete.called + + def test_delete_subject_not_found(self, er_client): + with patch.object(er_client._http_session, 'delete') as mock_delete: + mock_delete.return_value = _mock_response( + 404, ok=False, text='{"status":{"detail":"not found"}}' + ) + with pytest.raises(ERClientNotFound): + er_client.delete_subject("nonexistent-id") + + +# ---- sync delete_source tests (already existed, verify it works) ---- + +class TestDeleteSource: + def test_delete_source_success(self, er_client): + with patch.object(er_client._http_session, 'delete') as mock_delete: + mock_delete.return_value = _mock_response(204, ok=True) + # delete_source doesn't return a value; just verify no exception + er_client.delete_source("ee112233-4455-6677-8899-aabbccddeeff") + assert mock_delete.called + + def test_delete_source_not_found(self, er_client): + with patch.object(er_client._http_session, 'delete') as mock_delete: + mock_delete.return_value = _mock_response( + 404, ok=False, text='{"status":{"detail":"not found"}}' + ) + with pytest.raises(ERClientNotFound): + er_client.delete_source("nonexistent-source-id")