From 718f4c1bd18289db1b91c9fa7ef3cc073a8b3557 Mon Sep 17 00:00:00 2001 From: JoshuaVulcan <38018017+JoshuaVulcan@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:24:42 -0800 Subject: [PATCH] feat: add task status endpoint (ERA-12690) Add get_task_status(task_id) to both ERClient (sync) and AsyncERClient (async) for polling the status of background tasks. Endpoint: core/taskstatus/{task_id}/ Returns a dict with: - task_id: the task identifier - status: PENDING | STARTED | SUCCESS | FAILURE - result: task result data (null while pending/started) - location: relative URL to the status endpoint This enables clients to track the progress of async operations like GPX uploads and data exports. Includes 12 new tests across sync and async test suites covering all task states, URL construction, and error handling. Co-authored-by: Cursor --- erclient/client.py | 18 ++++ tests/async_client/test_task_status.py | 139 +++++++++++++++++++++++++ tests/sync_client/__init__.py | 0 tests/sync_client/conftest.py | 21 ++++ tests/sync_client/test_task_status.py | 123 ++++++++++++++++++++++ 5 files changed, 301 insertions(+) create mode 100644 tests/async_client/test_task_status.py create mode 100644 tests/sync_client/__init__.py create mode 100644 tests/sync_client/conftest.py create mode 100644 tests/sync_client/test_task_status.py diff --git a/erclient/client.py b/erclient/client.py index 02bbbc0..dddf011 100644 --- a/erclient/client.py +++ b/erclient/client.py @@ -987,6 +987,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): """ @@ -1393,6 +1402,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/__init__.py b/tests/sync_client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/sync_client/conftest.py b/tests/sync_client/conftest.py new file mode 100644 index 0000000..6d9d6da --- /dev/null +++ b/tests/sync_client/conftest.py @@ -0,0 +1,21 @@ +import pytest + +from erclient.client import ERClient + + +@pytest.fixture +def er_server_info(): + return { + "service_root": "https://fake-site.erdomain.org/api/v1.0", + "username": "test", + "password": "test", + "token": "1110c87681cd1d12ad07c2d0f57d15d6079ae5d8", + "token_url": "https://fake-auth.erdomain.org/oauth2/token", + "client_id": "das_web_client", + "provider_key": "testintegration", + } + + +@pytest.fixture +def er_client(er_server_info): + return ERClient(**er_server_info) 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")