diff --git a/erclient/client.py b/erclient/client.py index 49c69d2..27b46af 100644 --- a/erclient/client.py +++ b/erclient/client.py @@ -1054,6 +1054,15 @@ def get_sources(self, page_size=100): def get_users(self): return self._get('users') + def get_task_status(self, task_id): + """ + Get the status of an async background task. + + :param task_id: the task ID returned by an async operation (e.g. GPX upload) + :return: dict with task_id, status, result, and location + """ + return self._get(f'core/taskstatus/{task_id}/') + class AsyncERClient(object): """ @@ -1613,6 +1622,15 @@ async def get_feature_group(self, feature_group_id: str): """ return await self._get(f"spatialfeaturegroup/{feature_group_id}", params={}) + async def get_task_status(self, task_id): + """ + Get the status of an async background task. + + :param task_id: the task ID returned by an async operation (e.g. GPX upload) + :return: dict with task_id, status, result, and location + """ + return await self._get(f'core/taskstatus/{task_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_task_status.py b/tests/async_client/test_task_status.py new file mode 100644 index 0000000..1c36cba --- /dev/null +++ b/tests/async_client/test_task_status.py @@ -0,0 +1,139 @@ +"""Tests for get_task_status in the async AsyncERClient.""" +import httpx +import pytest +import respx + + +SERVICE_ROOT = "https://fake-site.erdomain.org/api/v1.0" +TASK_ID = "abc12345-def6-7890-ghij-klmnopqrstuv" + + +@pytest.fixture +def task_pending_response(): + return { + "data": { + "task_id": TASK_ID, + "status": "PENDING", + "result": None, + "location": f"/api/v1.0/core/taskstatus/{TASK_ID}/", + } + } + + +@pytest.fixture +def task_success_response(): + return { + "data": { + "task_id": TASK_ID, + "status": "SUCCESS", + "result": {"imported": 42, "errors": 0}, + "location": f"/api/v1.0/core/taskstatus/{TASK_ID}/", + } + } + + +@pytest.fixture +def task_failure_response(): + return { + "data": { + "task_id": TASK_ID, + "status": "FAILURE", + "result": "File format not recognized", + "location": f"/api/v1.0/core/taskstatus/{TASK_ID}/", + } + } + + +@pytest.fixture +def task_started_response(): + return { + "data": { + "task_id": TASK_ID, + "status": "STARTED", + "result": None, + "location": f"/api/v1.0/core/taskstatus/{TASK_ID}/", + } + } + + +@pytest.mark.asyncio +async def test_get_task_status_pending(er_client, task_pending_response): + async with respx.mock(assert_all_called=False) as respx_mock: + route = respx_mock.get( + f"{SERVICE_ROOT}/core/taskstatus/{TASK_ID}/" + ).respond(httpx.codes.OK, json=task_pending_response) + + result = await er_client.get_task_status(TASK_ID) + assert route.called + assert result["task_id"] == TASK_ID + assert result["status"] == "PENDING" + assert result["result"] is None + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_task_status_success(er_client, task_success_response): + async with respx.mock(assert_all_called=False) as respx_mock: + route = respx_mock.get( + f"{SERVICE_ROOT}/core/taskstatus/{TASK_ID}/" + ).respond(httpx.codes.OK, json=task_success_response) + + result = await er_client.get_task_status(TASK_ID) + assert route.called + assert result["status"] == "SUCCESS" + assert result["result"]["imported"] == 42 + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_task_status_failure(er_client, task_failure_response): + async with respx.mock(assert_all_called=False) as respx_mock: + route = respx_mock.get( + f"{SERVICE_ROOT}/core/taskstatus/{TASK_ID}/" + ).respond(httpx.codes.OK, json=task_failure_response) + + result = await er_client.get_task_status(TASK_ID) + assert route.called + assert result["status"] == "FAILURE" + assert result["result"] == "File format not recognized" + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_task_status_started(er_client, task_started_response): + async with respx.mock(assert_all_called=False) as respx_mock: + route = respx_mock.get( + f"{SERVICE_ROOT}/core/taskstatus/{TASK_ID}/" + ).respond(httpx.codes.OK, json=task_started_response) + + result = await er_client.get_task_status(TASK_ID) + assert route.called + assert result["status"] == "STARTED" + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_task_status_not_found(er_client): + from erclient.er_errors import ERClientNotFound + async with respx.mock(assert_all_called=False) as respx_mock: + respx_mock.get( + f"{SERVICE_ROOT}/core/taskstatus/nonexistent-task-id/" + ).respond(httpx.codes.NOT_FOUND, json={"status": {"code": 404}}) + + with pytest.raises(ERClientNotFound): + await er_client.get_task_status("nonexistent-task-id") + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_task_status_url_construction(er_client, task_pending_response): + async with respx.mock(assert_all_called=False) as respx_mock: + route = respx_mock.get( + f"{SERVICE_ROOT}/core/taskstatus/{TASK_ID}/" + ).respond(httpx.codes.OK, json=task_pending_response) + + await er_client.get_task_status(TASK_ID) + assert route.called + req = route.calls[0].request + assert str(req.url).startswith(f"{SERVICE_ROOT}/core/taskstatus/{TASK_ID}/") + await er_client.close() diff --git a/tests/sync_client/test_task_status.py b/tests/sync_client/test_task_status.py new file mode 100644 index 0000000..d5cff35 --- /dev/null +++ b/tests/sync_client/test_task_status.py @@ -0,0 +1,123 @@ +"""Tests for get_task_status in the sync ERClient.""" +import json +from unittest.mock import patch, MagicMock + +import pytest + + +SERVICE_ROOT = "https://fake-site.erdomain.org/api/v1.0" +TASK_ID = "abc12345-def6-7890-ghij-klmnopqrstuv" + + +def _mock_response(json_data=None, status_code=200, ok=True): + """Helper to build a mock requests.Response.""" + resp = MagicMock() + resp.ok = ok + resp.status_code = status_code + resp.text = json.dumps(json_data) if json_data is not None else "" + resp.json.return_value = json_data + return resp + + +@pytest.fixture +def task_pending_response(): + return { + "data": { + "task_id": TASK_ID, + "status": "PENDING", + "result": None, + "location": f"/api/v1.0/core/taskstatus/{TASK_ID}/", + } + } + + +@pytest.fixture +def task_success_response(): + return { + "data": { + "task_id": TASK_ID, + "status": "SUCCESS", + "result": {"imported": 42, "errors": 0}, + "location": f"/api/v1.0/core/taskstatus/{TASK_ID}/", + } + } + + +@pytest.fixture +def task_failure_response(): + return { + "data": { + "task_id": TASK_ID, + "status": "FAILURE", + "result": "File format not recognized", + "location": f"/api/v1.0/core/taskstatus/{TASK_ID}/", + } + } + + +@pytest.fixture +def task_started_response(): + return { + "data": { + "task_id": TASK_ID, + "status": "STARTED", + "result": None, + "location": f"/api/v1.0/core/taskstatus/{TASK_ID}/", + } + } + + +def test_get_task_status_pending(er_client, task_pending_response): + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(task_pending_response) + result = er_client.get_task_status(TASK_ID) + + assert mock_get.called + call_url = mock_get.call_args[0][0] + assert f"core/taskstatus/{TASK_ID}/" in call_url + assert result["task_id"] == TASK_ID + assert result["status"] == "PENDING" + assert result["result"] is None + + +def test_get_task_status_success(er_client, task_success_response): + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(task_success_response) + result = er_client.get_task_status(TASK_ID) + + assert result["status"] == "SUCCESS" + assert result["result"]["imported"] == 42 + + +def test_get_task_status_failure(er_client, task_failure_response): + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(task_failure_response) + result = er_client.get_task_status(TASK_ID) + + assert result["status"] == "FAILURE" + assert result["result"] == "File format not recognized" + + +def test_get_task_status_started(er_client, task_started_response): + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(task_started_response) + result = er_client.get_task_status(TASK_ID) + + assert result["status"] == "STARTED" + + +def test_get_task_status_url_construction(er_client, task_pending_response): + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(task_pending_response) + er_client.get_task_status(TASK_ID) + + call_url = mock_get.call_args[0][0] + assert call_url == f"{SERVICE_ROOT}/core/taskstatus/{TASK_ID}/" + + +def test_get_task_status_not_found(er_client): + from erclient.er_errors import ERClientNotFound + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(status_code=404, ok=False) + with pytest.raises(ERClientNotFound): + er_client.get_task_status("nonexistent-task-id")