From 4873a7bf79d08be38213021f3e9059eaa0ece7ef Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 22 May 2026 07:24:35 +0100 Subject: [PATCH 1/3] Match real Vuforia Model Target error responses Probed real Vuforia to discover actual error response shapes, updated the mock to match, and converted previously mock-only error-path tests into verified-fake tests that run against real Vuforia + both mock backends. Closes #3197, #3193, #3194. Partial progress on #3192, #3195. The advanced-dataset model-count case remains mock-only (tracked by #3202, blocked on Enterprise scope entitlement). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/source/differences-to-vws.rst | 7 + newsfragments/3193.change | 1 + newsfragments/3194.change | 1 + newsfragments/3197.change | 1 + src/mock_vws/_model_target_web_api.py | 151 +++++++---- tests/mock_vws/test_model_target_web_api.py | 269 ++++++++++++-------- 6 files changed, 264 insertions(+), 166 deletions(-) create mode 100644 newsfragments/3193.change create mode 100644 newsfragments/3194.change create mode 100644 newsfragments/3197.change diff --git a/docs/source/differences-to-vws.rst b/docs/source/differences-to-vws.rst index 2a1359df4..b5bc079f2 100644 --- a/docs/source/differences-to-vws.rst +++ b/docs/source/differences-to-vws.rst @@ -118,6 +118,13 @@ The generated dataset download is a small valid zip file containing request meta Model Target API routes require a syntactically JSON Web Token-shaped bearer token, such as the token returned by the mock OAuth2 route. The mock does not verify token signatures, claims, expiry, or revocation. +For unknown Model Target datasets, the mock returns an error whose ``target`` is ``userId:mock``. +Real Vuforia uses ``userId:`` where the numeric portion is per-account. + +Two Model Target Web API error paths remain mock-only in ``tests/mock_vws/test_model_target_web_api.py::TestMockOnlyErrors``. +Downloads of still-processing datasets are mock-only because exercising the path against real Vuforia would require creating a dataset on every test run; the mock drives the processing window deterministically. +Advanced-dataset creation with more than 20 models is mock-only because the available test account lacks the advanced-dataset scope and real Vuforia rejects the request with a 403 before validating model counts. + Header cases ------------ diff --git a/newsfragments/3193.change b/newsfragments/3193.change new file mode 100644 index 000000000..c8bc15515 --- /dev/null +++ b/newsfragments/3193.change @@ -0,0 +1 @@ +Match real Vuforia Model Target dataset creation validation error shape, including per-request UUID, details list, and status codes (415 for unsupported media type, 400 with ``BAD_REQUEST`` validation details). diff --git a/newsfragments/3194.change b/newsfragments/3194.change new file mode 100644 index 000000000..40965b022 --- /dev/null +++ b/newsfragments/3194.change @@ -0,0 +1 @@ +Match real Vuforia Model Target unknown-dataset response shape (``NOT_FOUND`` code, ``Could not find a model-view database with uuid `` message, ``userId:`` target). diff --git a/newsfragments/3197.change b/newsfragments/3197.change new file mode 100644 index 000000000..f7b8af414 --- /dev/null +++ b/newsfragments/3197.change @@ -0,0 +1 @@ +Match real Vuforia Model Target Web API error responses for invalid request bodies, invalid dataset creation payloads, unknown datasets, and downloads of still-processing datasets. diff --git a/src/mock_vws/_model_target_web_api.py b/src/mock_vws/_model_target_web_api.py index 515a2756d..04262d710 100644 --- a/src/mock_vws/_model_target_web_api.py +++ b/src/mock_vws/_model_target_web_api.py @@ -3,6 +3,7 @@ import base64 import io import json +import uuid import zipfile from http import HTTPStatus from typing import Any @@ -19,6 +20,11 @@ _JWT_DOT_COUNT = 2 _MOCK_MODEL_TARGET_CLIENT_ID = "client-id" _MOCK_MODEL_TARGET_CLIENT_SECRET = "client-secret" # noqa: S105 +# A stable mock value standing in for the user-id segment that real +# Vuforia embeds in some Model Target error targets such as +# ``userId:7635391``. The numeric portion is per-account in real Vuforia; +# the mock uses a fixed placeholder. +_MOCK_USER_TARGET = "userId:mock" @beartype @@ -45,18 +51,36 @@ def _error_response( status_code: HTTPStatus, code: str, message: str, - target: str, + target: str | None = None, + details: list[dict[str, str]] | None = None, ) -> _ResponseType: """Return an error response shaped like the Model Target Web API.""" - return _json_response( - status_code=status_code, - body={ - "error": { - "code": code, - "message": message, - "target": target, - }, - }, + error: dict[str, Any] = {"code": code, "message": message} + if target is not None: + error["target"] = target + if details is not None: + error["details"] = details + return _json_response(status_code=status_code, body={"error": error}) + + +@beartype +def _validation_error_response( + *, + details: list[dict[str, str]], +) -> _ResponseType: + """Return a Vuforia-style validation error. + + Real Vuforia tags each validation error with a per-request UUID that + appears in both ``message`` and ``target``. The mock generates a fresh + UUID so the shape matches. + """ + request_uuid = uuid.uuid4().hex + return _error_response( + status_code=HTTPStatus.BAD_REQUEST, + code="BAD_REQUEST", + message=f"Validation error for request {request_uuid}", + target=request_uuid, + details=details, ) @@ -214,19 +238,17 @@ def _load_request_json(request: RequestData) -> dict[str, Any] | _ResponseType: content_type = _get_header(request=request, name="Content-Type") or "" if "application/json" not in content_type: return _error_response( - status_code=HTTPStatus.BAD_REQUEST, - code="BAD_REQUEST", - message="Content-Type must be application/json.", - target="Content-Type", + status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE, + code="ERROR", + message="Expecting text/json or application/json body", ) try: request_json: dict[str, Any] = json.loads(s=request.body) - except json.JSONDecodeError: + except json.JSONDecodeError as exc: return _error_response( status_code=HTTPStatus.BAD_REQUEST, - code="BAD_REQUEST", - message="Request body must be valid JSON.", - target="body", + code="ERROR", + message=f"Invalid Json: {exc}", ) return request_json @@ -238,44 +260,55 @@ def _validate_dataset_request( dataset_type: ModelTargetDatasetType, ) -> _ResponseType | None: """Validate the dataset request enough for useful mock feedback.""" - for field in ("name", "models", "targetSdk"): - if field not in request_json: - return _error_response( - status_code=HTTPStatus.BAD_REQUEST, - code="BAD_REQUEST", - message=f"Missing required field: {field}.", - target=field, - ) + missing_details = [ + { + "code": "VALIDATION_ERROR", + "message": f"/{field}: element is required", + } + for field in ("models", "name", "targetSdk") + if field not in request_json + ] + if missing_details: + return _validation_error_response(details=missing_details) models_value = request_json["models"] if not isinstance(models_value, list): - return _error_response( - status_code=HTTPStatus.BAD_REQUEST, - code="BAD_REQUEST", - message="models must be a list.", - target="models", + return _validation_error_response( + details=[ + { + "code": "VALIDATION_ERROR", + "message": "/models: error.expected.jsarray", + }, + ], ) models: list[Any] = [*models_value] model_count = len(models) if dataset_type == ModelTargetDatasetType.STANDARD and model_count != 1: - return _error_response( - status_code=HTTPStatus.BAD_REQUEST, - code="BAD_REQUEST", - message="Standard Model Target datasets must have one model.", - target="models", + return _validation_error_response( + details=[ + { + "code": "VALIDATION_ERROR", + "message": "exactly one model should be provided", + }, + ], ) if ( dataset_type == ModelTargetDatasetType.ADVANCED and not 1 <= model_count <= _MAX_ADVANCED_MODEL_COUNT ): - return _error_response( - status_code=HTTPStatus.BAD_REQUEST, - code="BAD_REQUEST", - message="Advanced Model Target datasets must have 1 to 20 models.", - target="models", + return _validation_error_response( + details=[ + { + "code": "VALIDATION_ERROR", + "message": ( + "models must contain between 1 and " + f"{_MAX_ADVANCED_MODEL_COUNT} entries" + ), + }, + ], ) return None @@ -333,9 +366,12 @@ def get_model_target_dataset_status( except KeyError: return _error_response( status_code=HTTPStatus.NOT_FOUND, - code="404", - message="The dataset was not found.", - target="uuid", + code="NOT_FOUND", + message=( + "Could not find a model-view database with uuid " + f"{dataset_uuid}" + ), + target=_MOCK_USER_TARGET, ) return _json_response( status_code=HTTPStatus.OK, @@ -378,16 +414,22 @@ def download_model_target_dataset( except KeyError: return _error_response( status_code=HTTPStatus.NOT_FOUND, - code="404", - message="The dataset was not found.", - target="uuid", + code="NOT_FOUND", + message=( + "Could not find a model-view database with uuid " + f"{dataset_uuid}" + ), + target=_MOCK_USER_TARGET, ) if dataset.status != "done": return _error_response( status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - code="UNPROCESSABLE_ENTITY", - message="The dataset is still processing.", - target="uuid", + code="UNSUPPORTED_STATE", + message=( + f"Training status for dataset {dataset_uuid} is " + "not-started != done" + ), + target=dataset_uuid, ) body = _dataset_zip_bytes(dataset=dataset) @@ -417,8 +459,11 @@ def delete_model_target_dataset( except KeyError: return _error_response( status_code=HTTPStatus.NOT_FOUND, - code="404", - message="The dataset was not found.", - target="uuid", + code="NOT_FOUND", + message=( + "Could not find a model-view database with uuid " + f"{dataset_uuid}" + ), + target=_MOCK_USER_TARGET, ) return HTTPStatus.OK, {"Content-Length": "0"}, "" diff --git a/tests/mock_vws/test_model_target_web_api.py b/tests/mock_vws/test_model_target_web_api.py index b67cc6649..eed82c5a9 100644 --- a/tests/mock_vws/test_model_target_web_api.py +++ b/tests/mock_vws/test_model_target_web_api.py @@ -105,7 +105,9 @@ def _assert_model_target_error( message: str, target: str, ) -> None: - """Assert a Model Target Web API error response.""" + """Assert a Model Target Web API error response with the legacy + shape. + """ assert response.status_code == status_code assert response.json() == { "error": { @@ -294,8 +296,9 @@ def test_invalid_oauth2_token_request( ) -class TestMockErrors: - """Tests for mock-only Model Target Web API error paths.""" +@pytest.mark.usefixtures("verify_model_target_mock_vuforia") +class TestErrorResponses: + """Verified fake tests for Model Target Web API error responses.""" @staticmethod @pytest.mark.parametrize( @@ -313,13 +316,12 @@ class TestMockErrors: ) def test_invalid_basic_auth_header(*, authorization: str) -> None: """Malformed OAuth2 Basic auth headers are rejected.""" - with MockVWS(): - response = requests.post( - url=f"{_VWS_HOST}/oauth2/token", - headers={"Authorization": authorization}, - data={"grant_type": "client_credentials"}, - timeout=30, - ) + response = requests.post( + url=f"{_VWS_HOST}/oauth2/token", + headers={"Authorization": authorization}, + data={"grant_type": "client_credentials"}, + timeout=30, + ) _assert_oauth2_error( response=response, @@ -331,122 +333,116 @@ def test_invalid_basic_auth_header(*, authorization: str) -> None: ) @staticmethod - @pytest.mark.parametrize( - argnames=("body", "headers", "message", "target"), - argvalues=[ - pytest.param( - "{}", - {}, - "Content-Type must be application/json.", - "Content-Type", - id="wrong-content-type", - ), - pytest.param( - "{", - {"Content-Type": "application/json"}, - "Request body must be valid JSON.", - "body", - id="invalid-json", - ), - ], - ) - def test_invalid_request_body( + def test_wrong_content_type( *, - body: str, - headers: dict[str, str], - message: str, - target: str, + verify_model_target_mock_vuforia: VuforiaBackend, ) -> None: - """Invalid dataset request bodies are rejected.""" - with MockVWS(): - response = requests.post( - url=f"{_VWS_HOST}/modeltargets/datasets", - headers={ - "Authorization": f"Bearer {_MOCK_BEARER_TOKEN}", - **headers, - }, - data=body, - timeout=30, - ) + """Non-JSON dataset bodies are rejected with 415.""" + credentials = _credentials_for_backend( + backend=verify_model_target_mock_vuforia, + ) + access_token = _get_access_token(credentials=credentials) + response = requests.post( + url=f"{_VWS_HOST}/modeltargets/datasets", + headers={"Authorization": f"Bearer {access_token}"}, + data="{}", + timeout=30, + ) - _assert_model_target_error( - response=response, - status_code=HTTPStatus.BAD_REQUEST, - code="BAD_REQUEST", - message=message, - target=target, + assert response.status_code == HTTPStatus.UNSUPPORTED_MEDIA_TYPE + error = response.json()["error"] + assert error["code"] == "ERROR" + assert error["message"] == ( + "Expecting text/json or application/json body" ) + assert "target" not in error + + @staticmethod + def test_invalid_json( + *, + verify_model_target_mock_vuforia: VuforiaBackend, + ) -> None: + """Unparseable JSON bodies are rejected with 400.""" + credentials = _credentials_for_backend( + backend=verify_model_target_mock_vuforia, + ) + access_token = _get_access_token(credentials=credentials) + response = requests.post( + url=f"{_VWS_HOST}/modeltargets/datasets", + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + }, + data="{", + timeout=30, + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + error = response.json()["error"] + assert error["code"] == "ERROR" + assert error["message"].startswith("Invalid Json") + assert "target" not in error @staticmethod @pytest.mark.parametrize( - argnames=("path", "body", "message", "target"), + argnames=("body", "expected_messages"), argvalues=[ pytest.param( - "/modeltargets/datasets", {}, - "Missing required field: name.", - "name", - id="missing-name", + { + "/models: element is required", + "/name: element is required", + "/targetSdk: element is required", + }, + id="empty-body", ), pytest.param( - "/modeltargets/datasets", { "name": "dataset-name", "targetSdk": "10.18", "models": "model", }, - "models must be a list.", - "models", + {"/models: error.expected.jsarray"}, id="models-not-list", ), pytest.param( - "/modeltargets/datasets", { **_UNAUTHENTICATED_DATASET_REQUEST, "models": [], }, - "Standard Model Target datasets must have one model.", - "models", - id="standard-model-count", - ), - pytest.param( - "/modeltargets/advancedDatasets", - { - **_UNAUTHENTICATED_DATASET_REQUEST, - "models": [ - *_UNAUTHENTICATED_DATASET_REQUEST["models"], - ] - * 21, - }, - "Advanced Model Target datasets must have 1 to 20 models.", - "models", - id="advanced-model-count", + {"exactly one model should be provided"}, + id="standard-zero-models", ), ], ) def test_invalid_dataset_request( *, - path: str, + verify_model_target_mock_vuforia: VuforiaBackend, body: dict[str, object], - message: str, - target: str, + expected_messages: set[str], ) -> None: - """Invalid dataset creation requests are rejected.""" - with MockVWS(): - response = requests.post( - url=f"{_VWS_HOST}{path}", - headers={"Authorization": f"Bearer {_MOCK_BEARER_TOKEN}"}, - json=body, - timeout=30, - ) + """Invalid standard dataset creation requests are rejected.""" + credentials = _credentials_for_backend( + backend=verify_model_target_mock_vuforia, + ) + access_token = _get_access_token(credentials=credentials) + response = requests.post( + url=f"{_VWS_HOST}/modeltargets/datasets", + headers={"Authorization": f"Bearer {access_token}"}, + json=body, + timeout=30, + ) - _assert_model_target_error( - response=response, - status_code=HTTPStatus.BAD_REQUEST, - code="BAD_REQUEST", - message=message, - target=target, + assert response.status_code == HTTPStatus.BAD_REQUEST + error = response.json()["error"] + assert error["code"] == "BAD_REQUEST" + assert error["message"] == ( + f"Validation error for request {error['target']}" ) + actual_messages = {detail["message"] for detail in error["details"]} + assert actual_messages == expected_messages + for detail in error["details"]: + assert detail["code"] == "VALIDATION_ERROR" @staticmethod @pytest.mark.parametrize( @@ -471,29 +467,75 @@ def test_invalid_dataset_request( ) def test_unknown_dataset( *, + verify_model_target_mock_vuforia: VuforiaBackend, method: HTTPMethod, path: str, ) -> None: - """Unknown datasets are rejected.""" + """Unknown datasets are rejected with a NOT_FOUND error.""" + credentials = _credentials_for_backend( + backend=verify_model_target_mock_vuforia, + ) + access_token = _get_access_token(credentials=credentials) + response = requests.request( + method=method, + url=f"{_VWS_HOST}{path}", + headers={"Authorization": f"Bearer {access_token}"}, + timeout=30, + ) + + assert response.status_code == HTTPStatus.NOT_FOUND + error = response.json()["error"] + assert error["code"] == "NOT_FOUND" + assert error["message"] == ( + f"Could not find a model-view database with uuid {_DATASET_UUID}" + ) + # The user-id portion is per-account in real Vuforia, so check only + # the stable prefix. + assert error["target"].startswith("userId:") + + +class TestMockOnlyErrors: + """Mock-only Model Target Web API error paths. + + These cases cannot easily be verified against real Vuforia with the + currently available test account and are kept mock-only by design. + """ + + @staticmethod + def test_advanced_model_count_exceeds_limit() -> None: + """Advanced dataset requests with too many models are rejected. + + Real Vuforia returns a 403 for the currently available test account + because the account lacks the advanced-dataset scope, so the + validation-error shape cannot be observed end-to-end. The mock + therefore enforces the documented advanced-dataset model count + limit on its own. + """ + body = { + **_UNAUTHENTICATED_DATASET_REQUEST, + "models": [*_UNAUTHENTICATED_DATASET_REQUEST["models"]] * 21, + } with MockVWS(): - response = requests.request( - method=method, - url=f"{_VWS_HOST}{path}", + response = requests.post( + url=f"{_VWS_HOST}/modeltargets/advancedDatasets", headers={"Authorization": f"Bearer {_MOCK_BEARER_TOKEN}"}, + json=body, timeout=30, ) - _assert_model_target_error( - response=response, - status_code=HTTPStatus.NOT_FOUND, - code="404", - message="The dataset was not found.", - target="uuid", - ) + assert response.status_code == HTTPStatus.BAD_REQUEST + error = response.json()["error"] + assert error["code"] == "BAD_REQUEST" + assert error["details"][0]["code"] == "VALIDATION_ERROR" @staticmethod def test_processing_dataset_cannot_be_downloaded() -> None: - """A dataset cannot be downloaded while it is still processing.""" + """A dataset cannot be downloaded while it is still processing. + + Mock-only because exercising this against real Vuforia would require + creating a dataset on every test run; the mock lets us drive the + processing window deterministically. + """ with MockVWS(processing_time_seconds=60): create_response = requests.post( url=f"{_VWS_HOST}/modeltargets/datasets", @@ -501,22 +543,23 @@ def test_processing_dataset_cannot_be_downloaded() -> None: json=_UNAUTHENTICATED_DATASET_REQUEST, timeout=30, ) + dataset_uuid = create_response.json()["uuid"] response = requests.get( url=( - f"{_VWS_HOST}/modeltargets/datasets/" - f"{create_response.json()['uuid']}/dataset" + f"{_VWS_HOST}/modeltargets/datasets/{dataset_uuid}/dataset" ), headers={"Authorization": f"Bearer {_MOCK_BEARER_TOKEN}"}, timeout=30, ) - _assert_model_target_error( - response=response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - code="UNPROCESSABLE_ENTITY", - message="The dataset is still processing.", - target="uuid", + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + error = response.json()["error"] + assert error["code"] == "UNSUPPORTED_STATE" + assert error["message"] == ( + f"Training status for dataset {dataset_uuid} is " + "not-started != done" ) + assert error["target"] == dataset_uuid class TestStandardDataset: From 96203ac5179c11e37a893e841bc8b212224fd7b1 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 22 May 2026 07:36:54 +0100 Subject: [PATCH 2/3] Fix pylint spelling: Unparseable -> Malformed Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/mock_vws/test_model_target_web_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/mock_vws/test_model_target_web_api.py b/tests/mock_vws/test_model_target_web_api.py index eed82c5a9..e6f294cf2 100644 --- a/tests/mock_vws/test_model_target_web_api.py +++ b/tests/mock_vws/test_model_target_web_api.py @@ -362,7 +362,7 @@ def test_invalid_json( *, verify_model_target_mock_vuforia: VuforiaBackend, ) -> None: - """Unparseable JSON bodies are rejected with 400.""" + """Malformed JSON bodies are rejected with 400.""" credentials = _credentials_for_backend( backend=verify_model_target_mock_vuforia, ) From bbea24189dbb10034faf7801b26b42f853600911 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 22 May 2026 07:42:27 +0100 Subject: [PATCH 3/3] Require target and details kwargs in Model Target _error_response Co-Authored-By: Claude Opus 4.7 (1M context) --- src/mock_vws/_model_target_web_api.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/mock_vws/_model_target_web_api.py b/src/mock_vws/_model_target_web_api.py index 04262d710..24058e32f 100644 --- a/src/mock_vws/_model_target_web_api.py +++ b/src/mock_vws/_model_target_web_api.py @@ -51,8 +51,8 @@ def _error_response( status_code: HTTPStatus, code: str, message: str, - target: str | None = None, - details: list[dict[str, str]] | None = None, + target: str | None, + details: list[dict[str, str]] | None, ) -> _ResponseType: """Return an error response shaped like the Model Target Web API.""" error: dict[str, Any] = {"code": code, "message": message} @@ -136,6 +136,7 @@ def _require_bearer_token(request: RequestData) -> _ResponseType | None: code="401", message="no Bearer token", target="jwt", + details=None, ) bearer_token = auth_header.removeprefix("Bearer ").strip() if not bearer_token: @@ -144,6 +145,7 @@ def _require_bearer_token(request: RequestData) -> _ResponseType | None: code="401", message="no Bearer token", target="jwt", + details=None, ) if bearer_token.count(".") != _JWT_DOT_COUNT: return _error_response( @@ -151,6 +153,7 @@ def _require_bearer_token(request: RequestData) -> _ResponseType | None: code="401", message="Invalid JWT serialization: Missing dot delimiter(s)", target="jwt", + details=None, ) return None @@ -241,6 +244,8 @@ def _load_request_json(request: RequestData) -> dict[str, Any] | _ResponseType: status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE, code="ERROR", message="Expecting text/json or application/json body", + target=None, + details=None, ) try: request_json: dict[str, Any] = json.loads(s=request.body) @@ -249,6 +254,8 @@ def _load_request_json(request: RequestData) -> dict[str, Any] | _ResponseType: status_code=HTTPStatus.BAD_REQUEST, code="ERROR", message=f"Invalid Json: {exc}", + target=None, + details=None, ) return request_json @@ -372,6 +379,7 @@ def get_model_target_dataset_status( f"{dataset_uuid}" ), target=_MOCK_USER_TARGET, + details=None, ) return _json_response( status_code=HTTPStatus.OK, @@ -420,6 +428,7 @@ def download_model_target_dataset( f"{dataset_uuid}" ), target=_MOCK_USER_TARGET, + details=None, ) if dataset.status != "done": return _error_response( @@ -430,6 +439,7 @@ def download_model_target_dataset( "not-started != done" ), target=dataset_uuid, + details=None, ) body = _dataset_zip_bytes(dataset=dataset) @@ -465,5 +475,6 @@ def delete_model_target_dataset( f"{dataset_uuid}" ), target=_MOCK_USER_TARGET, + details=None, ) return HTTPStatus.OK, {"Content-Length": "0"}, ""