From 70aab3e39a6cbb2c6e54efb4096087bec1d78317 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 26 Aug 2026 11:32:36 +0100 Subject: [PATCH] Test Model Target errors through the mock --- newsfragments/3169.change | 2 + pyproject.toml | 2 +- src/vws/_model_targets.py | 8 +- tests/test_async_model_targets.py | 120 ++++++++++++++++++++++--- tests/test_model_targets.py | 145 ++++++++++++++---------------- 5 files changed, 180 insertions(+), 97 deletions(-) create mode 100644 newsfragments/3169.change diff --git a/newsfragments/3169.change b/newsfragments/3169.change new file mode 100644 index 000000000..2024dbc37 --- /dev/null +++ b/newsfragments/3169.change @@ -0,0 +1,2 @@ +Test synchronous and asynchronous Model Target error responses through the +public mock API, and include rate-limit and server-error branches in coverage. diff --git a/pyproject.toml b/pyproject.toml index d9ce981bc..31ad5a496 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,7 +86,7 @@ optional-dependencies.dev = [ "types-requests==2.33.0.20260712", "vale==3.18.0.0", "vulture==2.16", - "vws-python-mock==2026.8.14", + "vws-python-mock==2026.8.26.1", "vws-test-fixtures==2026.8.23", "yamlfix==1.19.1", "zizmor==1.29.0", diff --git a/src/vws/_model_targets.py b/src/vws/_model_targets.py index c391342c2..b0027a702 100644 --- a/src/vws/_model_targets.py +++ b/src/vws/_model_targets.py @@ -269,15 +269,11 @@ def raise_for_error(*, response: Response) -> None: ~vws.exceptions.vws_exceptions.TooManyRequestsError: Vuforia is rate limiting access. """ - if ( - response.status_code == HTTPStatus.TOO_MANY_REQUESTS - ): # pragma: no cover + if response.status_code == HTTPStatus.TOO_MANY_REQUESTS: # The Vuforia API returns a 429 response with no JSON body. raise TooManyRequestsError(response=response) - if ( - response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR - ): # pragma: no cover + if response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR: raise ServerError(response=response) if response.status_code < HTTPStatus.BAD_REQUEST: diff --git a/tests/test_async_model_targets.py b/tests/test_async_model_targets.py index 4bcd6a2f3..c29101e8e 100644 --- a/tests/test_async_model_targets.py +++ b/tests/test_async_model_targets.py @@ -1,7 +1,6 @@ """Tests for the async Model Target Web API client.""" import io -import json import uuid import zipfile from http import HTTPStatus @@ -9,18 +8,23 @@ import pytest from mock_vws import ( MockVWS, + ModelTargetFailureResponse, ModelTargetGenerationFailure, ModelTargetGenerationWarning, ) from vws import AsyncModelTargetService +from vws.exceptions.custom_exceptions import ServerError from vws.exceptions.model_target_exceptions import ( + ModelTargetAuthenticationError, ModelTargetDatasetNotDoneError, ModelTargetDatasetTimeoutError, + ModelTargetError, ModelTargetOAuth2Error, ModelTargetValidationError, UnknownModelTargetDatasetError, ) +from vws.exceptions.vws_exceptions import TooManyRequestsError from vws.model_target_datasets import ( CadDataFormat, ModelTargetDatasetType, @@ -40,6 +44,39 @@ ] +async def _assert_dataset_error_response( + *, + model_target_model: ModelTargetModel, + status_code: HTTPStatus, + body: str, + expected_exception: ( + type[ModelTargetError | TooManyRequestsError | ServerError] + ), +) -> None: + """Assert that a mocked dataset failure maps to an exception.""" + async with AsyncModelTargetService( + client_id=_CLIENT_ID, + client_secret=_CLIENT_SECRET, + ) as client: + with pytest.raises( + expected_exception=( + ModelTargetError, + TooManyRequestsError, + ServerError, + ) + ) as exc: + await client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert isinstance(exc.value, expected_exception) + assert exc.value.response.status_code == status_code + assert exc.value.response.text == body + + class TestAccessToken: """Tests for getting an access token.""" @@ -69,6 +106,66 @@ async def test_invalid_credentials() -> None: assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED assert exc.value.error == "invalid_client" + @staticmethod + @pytest.mark.asyncio + @pytest.mark.parametrize( + argnames=("status_code", "body", "expected_exception"), + argvalues=[ + pytest.param( + HTTPStatus.UNAUTHORIZED, + '{"error":{"code":"AUTHENTICATION_ERROR","message":"No"}}', + ModelTargetAuthenticationError, + id="authentication", + ), + pytest.param( + HTTPStatus.FORBIDDEN, + '{"error":{"code":"FORBIDDEN","message":"Denied"}}', + ModelTargetError, + id="generic-json", + ), + pytest.param( + HTTPStatus.CONFLICT, + "not json", + ModelTargetError, + id="generic-non-json", + ), + pytest.param( + HTTPStatus.TOO_MANY_REQUESTS, + "rate limited", + TooManyRequestsError, + id="rate-limit", + ), + pytest.param( + HTTPStatus.BAD_GATEWAY, + "server error", + ServerError, + id="server-error", + ), + ], + ) + async def test_dataset_error_response( + *, + model_target_model: ModelTargetModel, + status_code: HTTPStatus, + body: str, + expected_exception: ( + type[ModelTargetError | TooManyRequestsError | ServerError] + ), + ) -> None: + """Dataset failures map to exceptions through the mock.""" + failure = ModelTargetFailureResponse( + status_code=status_code, + body=body, + ) + + with MockVWS(model_target_failure_response=failure): + await _assert_dataset_error_response( + model_target_model=model_target_model, + status_code=status_code, + body=body, + expected_exception=expected_exception, + ) + class TestDatasetLifecycle: """Tests for the dataset lifecycle.""" @@ -113,10 +210,7 @@ async def test_create_wait_download_delete( with zipfile.ZipFile( file=io.BytesIO(initial_bytes=dataset) ) as archive: - dataset_json = json.loads(s=archive.read(name="dataset.json")) - - assert dataset_json["uuid"] == dataset_uuid - assert dataset_json["type"] == dataset_type.value + assert archive.namelist() == ["MTDataset.dat", "MTDataset.xml"] await async_model_target_client.delete_dataset( dataset_uuid=dataset_uuid, @@ -183,12 +277,12 @@ async def test_download_while_processing( @staticmethod @pytest.mark.asyncio - async def test_dataset_types_are_separate( + async def test_dataset_is_visible_to_other_type( *, async_model_target_client: AsyncModelTargetService, model_target_model: ModelTargetModel, ) -> None: - """A dataset is not visible to requests for the other type.""" + """Standard and advanced routes share datasets by UUID.""" dataset_uuid = await async_model_target_client.create_dataset( name="dataset", target_sdk="11.0", @@ -196,11 +290,12 @@ async def test_dataset_types_are_separate( dataset_type=ModelTargetDatasetType.ADVANCED, ) - with pytest.raises(expected_exception=UnknownModelTargetDatasetError): - await async_model_target_client.get_dataset_status( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.STANDARD, - ) + report = await async_model_target_client.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert report.dataset_uuid == dataset_uuid @staticmethod @pytest.mark.asyncio @@ -215,6 +310,7 @@ async def test_advanced_dataset_takes_multiple_models( cad_data_blob="ZmFrZS1jYWQtZGF0YQ==", cad_data_format=CadDataFormat.GLB, realistic_appearance=RealisticAppearance.TRUE, + views=[], ) assert await async_model_target_client.create_dataset( diff --git a/tests/test_model_targets.py b/tests/test_model_targets.py index 4ee13db6e..23a73cb46 100644 --- a/tests/test_model_targets.py +++ b/tests/test_model_targets.py @@ -11,11 +11,13 @@ from freezegun import freeze_time from mock_vws import ( MockVWS, + ModelTargetFailureResponse, ModelTargetGenerationFailure, ModelTargetGenerationWarning, ) from vws import ModelTargetService +from vws.exceptions.custom_exceptions import ServerError from vws.exceptions.model_target_exceptions import ( ModelTargetAuthenticationError, ModelTargetDatasetNotDoneError, @@ -25,6 +27,7 @@ ModelTargetValidationError, UnknownModelTargetDatasetError, ) +from vws.exceptions.vws_exceptions import TooManyRequestsError from vws.model_target_datasets import ( CadDataFormat, GuideViewPosition, @@ -117,58 +120,6 @@ def __call__( ) -@beartype -class _BadTokenTransport: - """A transport which replaces each bearer token with an invalid - one. - """ - - def __init__(self, *, transport: Transport) -> None: - """ - Args: - transport: The transport to make requests with. - """ - self._transport = transport - - def close(self) -> None: - """Close the wrapped transport.""" - self._transport.close() - - def __call__( - self, - *, - method: str, - url: str, - headers: dict[str, str], - data: bytes, - request_timeout: float | tuple[float, float], - ) -> Response: - """Make a request with an invalid bearer token. - - Args: - method: The HTTP method. - url: The full URL. - headers: Request headers. - data: The request body. - request_timeout: The request timeout. - - Returns: - A Response populated from the HTTP response. - """ - given_headers = dict(headers) - authorization = given_headers.get("Authorization", "") - if authorization.startswith("Bearer "): - given_headers["Authorization"] = "Bearer not-a-json-web-token" - - return self._transport( - method=method, - url=url, - headers=given_headers, - data=data, - request_timeout=request_timeout, - ) - - class TestAccessToken: """Tests for getting an access token.""" @@ -262,24 +213,64 @@ def test_invalid_credentials() -> None: assert not exc.value.error_description @staticmethod - @pytest.mark.usefixtures("_mock_model_targets") - def test_invalid_bearer_token( + @pytest.mark.parametrize( + argnames=("status_code", "body", "expected_exception"), + argvalues=[ + pytest.param( + HTTPStatus.UNAUTHORIZED, + '{"error":{"code":"AUTHENTICATION_ERROR","message":"No"}}', + ModelTargetAuthenticationError, + id="authentication", + ), + pytest.param( + HTTPStatus.FORBIDDEN, + '{"error":{"code":"FORBIDDEN","message":"Denied"}}', + ModelTargetError, + id="generic-json", + ), + pytest.param( + HTTPStatus.CONFLICT, + "not json", + ModelTargetError, + id="generic-non-json", + ), + pytest.param( + HTTPStatus.TOO_MANY_REQUESTS, + "rate limited", + TooManyRequestsError, + id="rate-limit", + ), + pytest.param( + HTTPStatus.BAD_GATEWAY, + "server error", + ServerError, + id="server-error", + ), + ], + ) + def test_dataset_error_response( *, model_target_model: ModelTargetModel, + status_code: HTTPStatus, + body: str, + expected_exception: ( + type[ModelTargetError | TooManyRequestsError | ServerError] + ), ) -> None: - """An exception is raised when the bearer token is not - accepted. - """ - transport = _BadTokenTransport(transport=RequestsTransport()) + """Dataset failures map to exceptions through the mock.""" + failure = ModelTargetFailureResponse( + status_code=status_code, + body=body, + ) client = ModelTargetService( client_id=_CLIENT_ID, client_secret=_CLIENT_SECRET, - transport=transport, ) - with pytest.raises( - expected_exception=ModelTargetAuthenticationError, - ) as exc: + with ( + MockVWS(model_target_failure_response=failure), + pytest.raises(expected_exception=expected_exception) as exc, + ): client.create_dataset( name="dataset", target_sdk="11.0", @@ -287,10 +278,9 @@ def test_invalid_bearer_token( dataset_type=ModelTargetDatasetType.STANDARD, ) - assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED - assert exc.value.target == "jwt" - assert exc.value.message - transport.close() + assert isinstance(exc.value, expected_exception) + assert exc.value.response.status_code == status_code + assert exc.value.response.text == body class TestDatasetLifecycle: @@ -336,10 +326,7 @@ def test_create_wait_download_delete( with zipfile.ZipFile( file=io.BytesIO(initial_bytes=dataset) ) as archive: - dataset_json = json.loads(s=archive.read(name="dataset.json")) - - assert dataset_json["uuid"] == dataset_uuid - assert dataset_json["type"] == dataset_type.value + assert archive.namelist() == ["MTDataset.dat", "MTDataset.xml"] model_target_client.delete_dataset( dataset_uuid=dataset_uuid, @@ -405,12 +392,12 @@ def test_download_while_processing( assert exc.value.target == dataset_uuid @staticmethod - def test_dataset_types_are_separate( + def test_dataset_is_visible_to_other_type( *, model_target_client: ModelTargetService, model_target_model: ModelTargetModel, ) -> None: - """A dataset is not visible to requests for the other type.""" + """Standard and advanced routes share datasets by UUID.""" dataset_uuid = model_target_client.create_dataset( name="dataset", target_sdk="11.0", @@ -418,11 +405,12 @@ def test_dataset_types_are_separate( dataset_type=ModelTargetDatasetType.STANDARD, ) - with pytest.raises(expected_exception=UnknownModelTargetDatasetError): - model_target_client.get_dataset_status( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.ADVANCED, - ) + report = model_target_client.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.ADVANCED, + ) + + assert report.dataset_uuid == dataset_uuid @staticmethod def test_advanced_dataset_takes_multiple_models( @@ -436,6 +424,7 @@ def test_advanced_dataset_takes_multiple_models( cad_data_blob="ZmFrZS1jYWQtZGF0YQ==", cad_data_format=CadDataFormat.GLB, realistic_appearance=RealisticAppearance.TRUE, + views=[], ) dataset_uuid = model_target_client.create_dataset( @@ -532,7 +521,7 @@ def test_no_cad_data( model_target_client.create_dataset( name="dataset", target_sdk="11.0", - models=[ModelTargetModel(name="model")], + models=[ModelTargetModel(name="model", views=[])], dataset_type=ModelTargetDatasetType.STANDARD, )