From 26277db5bd0f211208891c659d096b3efa25bb66 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Thu, 21 May 2026 14:07:29 +0100 Subject: [PATCH] Add Model Target Web API mock --- .github/workflows/lint.yml | 3 + .github/workflows/test.yml | 1 + README.rst | 2 +- admin/create_secrets_files.py | 36 +- docs/source/differences-to-vws.rst | 7 + newsfragments/2114.change | 1 + pyproject.toml | 2 +- secrets.tar.gpg | Bin 18110 -> 19230 bytes spelling_private_dict.txt | 1 + src/mock_vws/_flask_server/vws.py | 195 ++++++- src/mock_vws/_model_target_web_api.py | 341 ++++++++++++ .../mock_web_services_api.py | 162 ++++++ src/mock_vws/model_target.py | 79 +++ src/mock_vws/target_manager.py | 20 + tests/mock_vws/fixtures/credentials.py | 35 ++ tests/mock_vws/fixtures/vuforia_backends.py | 77 +++ tests/mock_vws/test_docker.py | 2 +- tests/mock_vws/test_flask_app_usage.py | 73 +++ tests/mock_vws/test_model_target_web_api.py | 484 ++++++++++++++++++ tests/mock_vws/test_requests_mock_usage.py | 113 ++++ tests/mock_vws/test_respx_mock_usage.py | 48 ++ tests/mock_vws/test_target_validators.py | 2 +- vuforia_secrets.env.example | 4 + 23 files changed, 1682 insertions(+), 6 deletions(-) create mode 100644 newsfragments/2114.change create mode 100644 src/mock_vws/_model_target_web_api.py create mode 100644 src/mock_vws/model_target.py create mode 100644 tests/mock_vws/test_model_target_web_api.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c3af5d0b9..4918681f6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -42,6 +42,9 @@ jobs: run: uv run --extra=dev prek run --all-files --hook-stage ${{ matrix.hook-stage }} --verbose env: + # Avoid intermittent uv distribution cache rename failures while + # prek installs hook environments on Windows. + UV_NO_CACHE: '1' UV_PYTHON: ${{ matrix.python-version }} - uses: pre-commit-ci/lite-action@v1.1.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 92b251db9..b830d99da 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -115,6 +115,7 @@ jobs: - tests/mock_vws/test_requests_mock_usage.py - tests/mock_vws/test_respx_mock_usage.py - tests/mock_vws/test_flask_app_usage.py + - tests/mock_vws/test_model_target_web_api.py - tests/mock_vws/test_vumark_generation_api.py - tests/mock_vws/test_target_validators.py - tests/mock_vws/test_docker.py diff --git a/README.rst b/README.rst index f49e6924f..1a96b097e 100644 --- a/README.rst +++ b/README.rst @@ -6,7 +6,7 @@ VWS Mock .. contents:: :local: -Mock for the Vuforia Web Services (VWS) API and the Vuforia Web Query API. +Mock for the Vuforia Web Services (VWS) API, the Vuforia Web Query API, and the Model Target Web API. Mocking calls made to Vuforia ------------------------------ diff --git a/admin/create_secrets_files.py b/admin/create_secrets_files.py index a3fdfaf5f..1cf5daf1d 100644 --- a/admin/create_secrets_files.py +++ b/admin/create_secrets_files.py @@ -12,7 +12,11 @@ import vws_web_tools from selenium.common.exceptions import TimeoutException from selenium.webdriver.remote.webdriver import WebDriver -from vws_web_tools import DatabaseDict, VuMarkDatabaseDict +from vws_web_tools import ( + DatabaseDict, + ModelTargetWebAPIDict, + VuMarkDatabaseDict, +) VUMARK_TEMPLATE_SVG_FILE_PATH = Path(__file__).with_name( name="vumark_template.svg", @@ -72,11 +76,13 @@ def _create_and_get_vumark_details( def _generate_secrets_file_content( + *, cloud_database_details: DatabaseDict, vumark_details: VuMarkDatabaseDict, inactive_database_details: DatabaseDict, inactive_vumark_details: VuMarkDatabaseDict, vumark_target_id: str, + model_target_web_api_details: ModelTargetWebAPIDict, ) -> str: """Generate the content of a secrets file.""" return textwrap.dedent( @@ -101,6 +107,10 @@ def _generate_secrets_file_content( INACTIVE_VUMARK_VUFORIA_TARGET_MANAGER_DATABASE_NAME={inactive_vumark_details["database_name"]} INACTIVE_VUMARK_VUFORIA_SERVER_ACCESS_KEY={inactive_vumark_details["server_access_key"]} INACTIVE_VUMARK_VUFORIA_SERVER_SECRET_KEY={inactive_vumark_details["server_secret_key"]} + + MODEL_TARGET_VUFORIA_CLIENT_ID={model_target_web_api_details["client_id"]} + MODEL_TARGET_VUFORIA_CLIENT_SECRET={model_target_web_api_details["client_secret"]} + MODEL_TARGET_VUFORIA_CAD_DATA_URL={model_target_web_api_details["cad_data_url"]} """, ) @@ -193,6 +203,21 @@ def _create_and_get_inactive_vumark_details( return vumark_database_details +def _get_model_target_web_api_details( + driver: WebDriver, + email_address: str, + password: str, +) -> ModelTargetWebAPIDict: + """Get credentials and input data for the Model Target Web API.""" + vws_web_tools.log_in( + driver=driver, + email_address=email_address, + password=password, + ) + vws_web_tools.wait_for_logged_in(driver=driver) + return vws_web_tools.get_model_target_web_api_details(driver=driver) + + def _create_vuforia_resource_names() -> tuple[str, str, str, str]: """Create names for Vuforia resources.""" time = datetime.datetime.now(tz=datetime.UTC).strftime( @@ -236,6 +261,14 @@ def main() -> None: ) inactive_vumark_driver.quit() + model_target_driver = vws_web_tools.create_chrome_driver() + model_target_web_api_details = _get_model_target_web_api_details( + driver=model_target_driver, + email_address=email_address, + password=password, + ) + model_target_driver.quit() + num_databases = 100 required_files = [ (new_secrets_dir / f"vuforia_secrets_{i}.env") @@ -291,6 +324,7 @@ def main() -> None: inactive_database_details=inactive_database_details, inactive_vumark_details=inactive_vumark_details, vumark_target_id=vumark_target_id, + model_target_web_api_details=model_target_web_api_details, ) file.write_text(data=file_contents) sys.stdout.write(f"Created database {file.name}\n") diff --git a/docs/source/differences-to-vws.rst b/docs/source/differences-to-vws.rst index 1f4876ea9..c2ae9a2c8 100644 --- a/docs/source/differences-to-vws.rst +++ b/docs/source/differences-to-vws.rst @@ -110,6 +110,13 @@ The mock returns a fixed minimal image in the requested format. The ``instance_id`` value is not encoded into the response image. Real Vuforia encodes the instance ID into the VuMark pattern. +Model Target datasets +--------------------- + +The Model Target Web API mock supports OAuth2 token requests, standard and advanced dataset creation, status polling, dataset downloads, and deletion. +The generated dataset download is a small valid zip file containing request metadata, not a real Vuforia Engine Model Target dataset. +Model Target API routes accept any non-empty bearer token. + Header cases ------------ diff --git a/newsfragments/2114.change b/newsfragments/2114.change new file mode 100644 index 000000000..e0ddb0890 --- /dev/null +++ b/newsfragments/2114.change @@ -0,0 +1 @@ +Add a mock implementation of the Model Target Web API, including OAuth2 token creation, standard and advanced dataset creation, status polling, dataset download, and deletion. diff --git a/pyproject.toml b/pyproject.toml index 1e2825c86..7f944c285 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,7 +111,7 @@ optional-dependencies.dev = [ "vulture==2.16", "vws-python==2026.2.25.1", "vws-test-fixtures==2023.3.5", - "vws-web-tools==2026.2.22.1", + "vws-web-tools==2026.5.21", "yamlfix==1.19.1", "zizmor==1.25.2", ] diff --git a/secrets.tar.gpg b/secrets.tar.gpg index 576c697502629868f233e409af31385a775c4b9c..41bcc48daa77620fa5e9b3a9d2d3ede230ecd945 100644 GIT binary patch literal 19230 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 Fl~IG=hNA!g literal 18110 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 set[VuMarkDatabase]: } +@beartype +def _flask_request_data() -> RequestData: + """Return the current Flask request as shared request data.""" + return RequestData( + method=request.method, + path=request.path, + headers=dict(request.headers), + body=request.data, + ) + + +@beartype +def _model_target_manager() -> TargetManager: + """Return the target manager backing the Flask app.""" + return TARGET_MANAGER + + +@beartype +def _to_flask_response( + api_response: tuple[int, dict[str, str], str | bytes], +) -> Response: + """Convert a shared API response to a Flask response.""" + status_code, headers, body = api_response + return Response(response=body, status=status_code, headers=headers) + + @VWS_FLASK_APP.before_request @beartype def set_terminate_wsgi_input() -> None: @@ -154,6 +192,10 @@ def validate_request() -> None: """ if request.endpoint == "generate_vumark_instance": return + if request.path == "/oauth2/token" or request.path.startswith( + "/modeltargets/", + ): + return run_services_validators( request_headers=dict(request.headers), request_body=request.data, @@ -187,6 +229,157 @@ def handle_exceptions(exc: ValidatorError) -> Response: return response +@VWS_FLASK_APP.route(rule="/oauth2/token", methods=[HTTPMethod.POST]) +@beartype +def oauth2_token() -> Response: + """Obtain an OAuth2 token for the Model Target Web API.""" + return _to_flask_response( + api_response=model_target_oauth2_token( + request=_flask_request_data(), + ), + ) + + +@VWS_FLASK_APP.route( + rule="/modeltargets/datasets", + methods=[HTTPMethod.POST], +) +@beartype +def create_standard_model_target_dataset() -> Response: + """Create a standard Model Target dataset.""" + settings = VWSSettings.model_validate(obj={}) + return _to_flask_response( + api_response=create_model_target_dataset( + request=_flask_request_data(), + target_manager=_model_target_manager(), + processing_time_seconds=settings.processing_time_seconds, + dataset_type=ModelTargetDatasetType.STANDARD, + ), + ) + + +@VWS_FLASK_APP.route( + rule="/modeltargets/advancedDatasets", + methods=[HTTPMethod.POST], +) +@beartype +def create_advanced_model_target_dataset() -> Response: + """Create an advanced Model Target dataset.""" + settings = VWSSettings.model_validate(obj={}) + return _to_flask_response( + api_response=create_model_target_dataset( + request=_flask_request_data(), + target_manager=_model_target_manager(), + processing_time_seconds=settings.processing_time_seconds, + dataset_type=ModelTargetDatasetType.ADVANCED, + ), + ) + + +@VWS_FLASK_APP.route( + rule="/modeltargets/datasets//status", + methods=[HTTPMethod.GET], +) +@beartype +def get_standard_model_target_dataset_status( + dataset_uuid: str, +) -> Response: + """Return a standard Model Target dataset creation status.""" + return _to_flask_response( + api_response=get_model_target_dataset_status( + request=_flask_request_data(), + target_manager=_model_target_manager(), + dataset_uuid=dataset_uuid, + ), + ) + + +@VWS_FLASK_APP.route( + rule="/modeltargets/advancedDatasets//status", + methods=[HTTPMethod.GET], +) +@beartype +def get_advanced_model_target_dataset_status( + dataset_uuid: str, +) -> Response: + """Return an advanced Model Target dataset creation status.""" + return _to_flask_response( + api_response=get_model_target_dataset_status( + request=_flask_request_data(), + target_manager=_model_target_manager(), + dataset_uuid=dataset_uuid, + ), + ) + + +@VWS_FLASK_APP.route( + rule="/modeltargets/datasets//dataset", + methods=[HTTPMethod.GET], +) +@beartype +def download_standard_model_target_dataset( + dataset_uuid: str, +) -> Response: + """Download a standard Model Target dataset.""" + return _to_flask_response( + api_response=download_model_target_dataset( + request=_flask_request_data(), + target_manager=_model_target_manager(), + dataset_uuid=dataset_uuid, + ), + ) + + +@VWS_FLASK_APP.route( + rule="/modeltargets/advancedDatasets//dataset", + methods=[HTTPMethod.GET], +) +@beartype +def download_advanced_model_target_dataset( + dataset_uuid: str, +) -> Response: + """Download an advanced Model Target dataset.""" + return _to_flask_response( + api_response=download_model_target_dataset( + request=_flask_request_data(), + target_manager=_model_target_manager(), + dataset_uuid=dataset_uuid, + ), + ) + + +@VWS_FLASK_APP.route( + rule="/modeltargets/datasets/", + methods=[HTTPMethod.DELETE], +) +@beartype +def delete_standard_model_target_dataset(dataset_uuid: str) -> Response: + """Delete a standard Model Target dataset.""" + return _to_flask_response( + api_response=delete_model_target_dataset( + request=_flask_request_data(), + target_manager=_model_target_manager(), + dataset_uuid=dataset_uuid, + ), + ) + + +@VWS_FLASK_APP.route( + rule="/modeltargets/advancedDatasets/", + methods=[HTTPMethod.DELETE], +) +@beartype +def delete_advanced_model_target_dataset(dataset_uuid: str) -> Response: + """Delete an advanced Model Target dataset.""" + return _to_flask_response( + api_response=delete_model_target_dataset( + request=_flask_request_data(), + target_manager=_model_target_manager(), + dataset_uuid=dataset_uuid, + ), + ) + + @VWS_FLASK_APP.route(rule="/targets", methods=[HTTPMethod.POST]) @beartype def add_target() -> Response: diff --git a/src/mock_vws/_model_target_web_api.py b/src/mock_vws/_model_target_web_api.py new file mode 100644 index 000000000..899d0bc6d --- /dev/null +++ b/src/mock_vws/_model_target_web_api.py @@ -0,0 +1,341 @@ +"""A fake implementation of the Model Target Web API.""" + +import base64 +import io +import json +import zipfile +from http import HTTPStatus +from typing import Any +from urllib.parse import parse_qs + +from beartype import beartype + +from mock_vws._mock_common import RequestData, json_dump +from mock_vws.model_target import ModelTargetDataset, ModelTargetDatasetType +from mock_vws.target_manager import TargetManager + +_ResponseType = tuple[int, dict[str, str], str | bytes] +_MAX_ADVANCED_MODEL_COUNT = 20 + + +@beartype +def _json_response( + *, + status_code: HTTPStatus, + body: dict[str, Any], +) -> _ResponseType: + """Return a JSON response.""" + body_json = json_dump(body=body) + return ( + status_code, + { + "Content-Length": str(object=len(body_json)), + "Content-Type": "application/json", + }, + body_json, + ) + + +@beartype +def _error_response( + *, + status_code: HTTPStatus, + code: str, + message: str, + target: str, +) -> _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, + }, + }, + ) + + +@beartype +def _get_header(request: RequestData, name: str) -> str | None: + """Return a request header, case-insensitively.""" + lower_name = name.casefold() + for key, value in request.headers.items(): + if key.casefold() == lower_name: + return value + return None + + +@beartype +def _require_bearer_token(request: RequestData) -> _ResponseType | None: + """Return an error response if the request has no bearer token.""" + auth_header = _get_header(request=request, name="Authorization") + if auth_header is None or not auth_header.startswith("Bearer "): + return _error_response( + status_code=HTTPStatus.UNAUTHORIZED, + code="401", + message="no Bearer token", + target="jwt", + ) + if not auth_header.removeprefix("Bearer ").strip(): + return _error_response( + status_code=HTTPStatus.UNAUTHORIZED, + code="401", + message="invalid Bearer token", + target="jwt", + ) + return None + + +@beartype +def oauth2_token(request: RequestData) -> _ResponseType: + """Return a fake OAuth2 access token.""" + auth_header = _get_header(request=request, name="Authorization") + form = parse_qs(qs=request.body.decode(encoding="utf-8")) + grant_type = form.get("grant_type", [""])[0] + has_basic_auth = auth_header is not None and auth_header.startswith( + "Basic ", + ) + has_password_credentials = all( + form.get(field, [""])[0] for field in ("username", "password") + ) + if grant_type not in {"", "client_credentials", "password"} or ( + not has_basic_auth and not has_password_credentials + ): + return _error_response( + status_code=HTTPStatus.BAD_REQUEST, + code="BAD_REQUEST", + message="Invalid OAuth2 token request.", + target="grant_type", + ) + + token_source = request.body or (auth_header or "").encode() + access_token = base64.urlsafe_b64encode(s=token_source).decode( + encoding="ascii", + ) + access_token = access_token.rstrip("=") or "mock-vuforia-access-token" + return _json_response( + status_code=HTTPStatus.OK, + body={ + "access_token": access_token, + "token_type": "bearer", + "expires_in": 3600, + }, + ) + + +@beartype +def _load_request_json(request: RequestData) -> dict[str, Any] | _ResponseType: + """Load a Model Target dataset creation request body.""" + 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", + ) + try: + request_json: dict[str, Any] = json.loads(s=request.body) + except json.JSONDecodeError: + return _error_response( + status_code=HTTPStatus.BAD_REQUEST, + code="BAD_REQUEST", + message="Request body must be valid JSON.", + target="body", + ) + return request_json + + +@beartype +def _validate_dataset_request( + *, + request_json: dict[str, Any], + 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, + ) + + 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", + ) + + 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", + ) + + 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 None + + +@beartype +def create_model_target_dataset( + *, + request: RequestData, + target_manager: TargetManager, + processing_time_seconds: float, + dataset_type: ModelTargetDatasetType, +) -> _ResponseType: + """Create a standard or advanced Model Target dataset.""" + auth_error = _require_bearer_token(request=request) + if auth_error is not None: + return auth_error + + request_json_or_error = _load_request_json(request=request) + if not isinstance(request_json_or_error, dict): + return request_json_or_error + + validation_error = _validate_dataset_request( + request_json=request_json_or_error, + dataset_type=dataset_type, + ) + if validation_error is not None: + return validation_error + + dataset = ModelTargetDataset( + request_body=request_json_or_error, + dataset_type=dataset_type, + processing_time_seconds=processing_time_seconds, + ) + target_manager.add_model_target_dataset(model_target_dataset=dataset) + return _json_response( + status_code=HTTPStatus.CREATED, + body={"uuid": dataset.uuid_}, + ) + + +@beartype +def get_model_target_dataset_status( + *, + request: RequestData, + target_manager: TargetManager, + dataset_uuid: str, +) -> _ResponseType: + """Return the status of a Model Target dataset.""" + auth_error = _require_bearer_token(request=request) + if auth_error is not None: + return auth_error + try: + dataset = target_manager.model_target_datasets[dataset_uuid] + except KeyError: + return _error_response( + status_code=HTTPStatus.NOT_FOUND, + code="404", + message="The dataset was not found.", + target="uuid", + ) + return _json_response( + status_code=HTTPStatus.OK, + body=dataset.status_body(), + ) + + +@beartype +def _dataset_zip_bytes(dataset: ModelTargetDataset) -> bytes: + """Return a small valid zip file for a generated dataset.""" + zip_buffer = io.BytesIO() + with zipfile.ZipFile(file=zip_buffer, mode="w") as zip_file: + zip_file.writestr( + zinfo_or_arcname="dataset.json", + data=json.dumps( + obj={ + "uuid": dataset.uuid_, + "type": dataset.dataset_type.value, + "request": dataset.request_body, + }, + separators=(",", ":"), + ), + ) + return zip_buffer.getvalue() + + +@beartype +def download_model_target_dataset( + *, + request: RequestData, + target_manager: TargetManager, + dataset_uuid: str, +) -> _ResponseType: + """Download a generated Model Target dataset.""" + auth_error = _require_bearer_token(request=request) + if auth_error is not None: + return auth_error + try: + dataset = target_manager.model_target_datasets[dataset_uuid] + except KeyError: + return _error_response( + status_code=HTTPStatus.NOT_FOUND, + code="404", + message="The dataset was not found.", + target="uuid", + ) + if dataset.status != "done": + return _error_response( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + code="UNPROCESSABLE_ENTITY", + message="The dataset is still processing.", + target="uuid", + ) + + body = _dataset_zip_bytes(dataset=dataset) + return ( + HTTPStatus.OK, + { + "Content-Length": str(object=len(body)), + "Content-Type": "application/zip", + }, + body, + ) + + +@beartype +def delete_model_target_dataset( + *, + request: RequestData, + target_manager: TargetManager, + dataset_uuid: str, +) -> _ResponseType: + """Delete a Model Target dataset.""" + auth_error = _require_bearer_token(request=request) + if auth_error is not None: + return auth_error + try: + target_manager.remove_model_target_dataset(dataset_uuid=dataset_uuid) + except KeyError: + return _error_response( + status_code=HTTPStatus.NOT_FOUND, + code="404", + message="The dataset was not found.", + target="uuid", + ) + return HTTPStatus.OK, {"Content-Length": "0"}, "" diff --git a/src/mock_vws/_requests_mock_server/mock_web_services_api.py b/src/mock_vws/_requests_mock_server/mock_web_services_api.py index 50fc7aa95..542dbc9cf 100644 --- a/src/mock_vws/_requests_mock_server/mock_web_services_api.py +++ b/src/mock_vws/_requests_mock_server/mock_web_services_api.py @@ -26,6 +26,13 @@ ) from mock_vws._database_matchers import get_database_matching_server_keys from mock_vws._mock_common import RequestData, Route, json_dump +from mock_vws._model_target_web_api import ( + create_model_target_dataset, + delete_model_target_dataset, + download_model_target_dataset, + get_model_target_dataset_status, + oauth2_token, +) from mock_vws._services_validators import run_services_validators from mock_vws._services_validators.exceptions import ( FailError, @@ -38,6 +45,7 @@ ) from mock_vws.database import VuMarkDatabase from mock_vws.image_matchers import ImageMatcher +from mock_vws.model_target import ModelTargetDatasetType from mock_vws.target import ImageTarget from mock_vws.target_manager import TargetManager from mock_vws.target_raters import TargetTrackingRater @@ -46,6 +54,7 @@ from mock_vws.database import CloudDatabase _TARGET_ID_PATTERN = "[A-Za-z0-9]+" +_MODEL_TARGET_DATASET_UUID_PATTERN = "[A-Za-z0-9-]+" _ROUTES: set[Route] = set() @@ -138,6 +147,159 @@ def __init__( self._duplicate_match_checker = duplicate_match_checker self._target_tracking_rater = target_tracking_rater + @route(path_pattern="/oauth2/token", http_methods={HTTPMethod.POST}) + def oauth2_token( # pylint: disable=no-self-use + self, + request: RequestData, + ) -> _ResponseType: + """Obtain an OAuth2 token for the Model Target Web API.""" + return oauth2_token(request=request) + + @route( + path_pattern="/modeltargets/datasets", + http_methods={HTTPMethod.POST}, + ) + def create_standard_model_target_dataset( + self, + request: RequestData, + ) -> _ResponseType: + """Create a standard Model Target dataset.""" + return create_model_target_dataset( + request=request, + target_manager=self._target_manager, + processing_time_seconds=self._processing_time_seconds, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + @route( + path_pattern="/modeltargets/advancedDatasets", + http_methods={HTTPMethod.POST}, + ) + def create_advanced_model_target_dataset( + self, + request: RequestData, + ) -> _ResponseType: + """Create an advanced Model Target dataset.""" + return create_model_target_dataset( + request=request, + target_manager=self._target_manager, + processing_time_seconds=self._processing_time_seconds, + dataset_type=ModelTargetDatasetType.ADVANCED, + ) + + @route( + path_pattern=( + "/modeltargets/datasets/" + f"{_MODEL_TARGET_DATASET_UUID_PATTERN}/status" + ), + http_methods={HTTPMethod.GET}, + ) + def get_standard_model_target_dataset_status( + self, + request: RequestData, + ) -> _ResponseType: + """Return a standard Model Target dataset creation status.""" + dataset_uuid = request.path.split(sep="/")[-2] + return get_model_target_dataset_status( + request=request, + target_manager=self._target_manager, + dataset_uuid=dataset_uuid, + ) + + @route( + path_pattern=( + "/modeltargets/advancedDatasets/" + f"{_MODEL_TARGET_DATASET_UUID_PATTERN}/status" + ), + http_methods={HTTPMethod.GET}, + ) + def get_advanced_model_target_dataset_status( + self, + request: RequestData, + ) -> _ResponseType: + """Return an advanced Model Target dataset creation status.""" + dataset_uuid = request.path.split(sep="/")[-2] + return get_model_target_dataset_status( + request=request, + target_manager=self._target_manager, + dataset_uuid=dataset_uuid, + ) + + @route( + path_pattern=( + "/modeltargets/datasets/" + f"{_MODEL_TARGET_DATASET_UUID_PATTERN}/dataset" + ), + http_methods={HTTPMethod.GET}, + ) + def download_standard_model_target_dataset( + self, + request: RequestData, + ) -> _ResponseType: + """Download a standard Model Target dataset.""" + dataset_uuid = request.path.split(sep="/")[-2] + return download_model_target_dataset( + request=request, + target_manager=self._target_manager, + dataset_uuid=dataset_uuid, + ) + + @route( + path_pattern=( + "/modeltargets/advancedDatasets/" + f"{_MODEL_TARGET_DATASET_UUID_PATTERN}/dataset" + ), + http_methods={HTTPMethod.GET}, + ) + def download_advanced_model_target_dataset( + self, + request: RequestData, + ) -> _ResponseType: + """Download an advanced Model Target dataset.""" + dataset_uuid = request.path.split(sep="/")[-2] + return download_model_target_dataset( + request=request, + target_manager=self._target_manager, + dataset_uuid=dataset_uuid, + ) + + @route( + path_pattern=( + f"/modeltargets/datasets/{_MODEL_TARGET_DATASET_UUID_PATTERN}" + ), + http_methods={HTTPMethod.DELETE}, + ) + def delete_standard_model_target_dataset( + self, + request: RequestData, + ) -> _ResponseType: + """Delete a standard Model Target dataset.""" + dataset_uuid = request.path.split(sep="/")[-1] + return delete_model_target_dataset( + request=request, + target_manager=self._target_manager, + dataset_uuid=dataset_uuid, + ) + + @route( + path_pattern=( + "/modeltargets/advancedDatasets/" + f"{_MODEL_TARGET_DATASET_UUID_PATTERN}" + ), + http_methods={HTTPMethod.DELETE}, + ) + def delete_advanced_model_target_dataset( + self, + request: RequestData, + ) -> _ResponseType: + """Delete an advanced Model Target dataset.""" + dataset_uuid = request.path.split(sep="/")[-1] + return delete_model_target_dataset( + request=request, + target_manager=self._target_manager, + dataset_uuid=dataset_uuid, + ) + @route( path_pattern="/targets", http_methods={HTTPMethod.POST}, diff --git a/src/mock_vws/model_target.py b/src/mock_vws/model_target.py new file mode 100644 index 000000000..10ecb83bf --- /dev/null +++ b/src/mock_vws/model_target.py @@ -0,0 +1,79 @@ +"""Model Target dataset objects.""" + +import datetime +import uuid +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any +from zoneinfo import ZoneInfo + +from beartype import beartype + + +@beartype +class ModelTargetDatasetType(StrEnum): + """The kind of Model Target dataset.""" + + STANDARD = "standard" + ADVANCED = "advanced" + + +@beartype +def _now() -> datetime.datetime: + """Return the current time in UTC.""" + return datetime.datetime.now(tz=ZoneInfo(key="UTC")) + + +@beartype +def _format_datetime(value: datetime.datetime) -> str: + """Format a timestamp like the Model Target Web API.""" + return value.isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +@beartype +@dataclass(frozen=True, kw_only=True) +class ModelTargetDataset: + """A Model Target dataset generation request. + + Args: + request_body: The JSON request body used to start dataset creation. + dataset_type: Whether this is a standard or advanced dataset. + processing_time_seconds: The number of seconds before the generated + dataset becomes available. + uuid_: The dataset UUID. + created_at: When the dataset creation was requested. + """ + + request_body: dict[str, Any] = field(hash=False) + dataset_type: ModelTargetDatasetType + processing_time_seconds: float = field(hash=False) + uuid_: str = field(default_factory=lambda: uuid.uuid4().hex) + created_at: datetime.datetime = field(default_factory=_now) + + @property + def completed_at(self) -> datetime.datetime: + """When the dataset completes processing.""" + return self.created_at + datetime.timedelta( + seconds=self.processing_time_seconds, + ) + + @property + def status(self) -> str: + """The current dataset generation status.""" + if _now() < self.completed_at: + return "processing" + return "done" + + def status_body(self) -> dict[str, Any]: + """Return a status response body for this dataset.""" + body: dict[str, Any] = { + "status": self.status, + "uuid": self.uuid_, + "createdAt": _format_datetime(value=self.created_at), + } + if self.status == "processing": + body["eta"] = _format_datetime(value=self.completed_at) + else: + body["completedAt"] = _format_datetime(value=self.completed_at) + + return body diff --git a/src/mock_vws/target_manager.py b/src/mock_vws/target_manager.py index 14850df8e..24b78dcbd 100644 --- a/src/mock_vws/target_manager.py +++ b/src/mock_vws/target_manager.py @@ -5,6 +5,7 @@ from beartype import beartype from mock_vws.database import CloudDatabase, VuMarkDatabase +from mock_vws.model_target import ModelTargetDataset if TYPE_CHECKING: from mock_vws._database_matchers import AnyDatabase @@ -22,6 +23,7 @@ def __init__(self) -> None: """Create a target manager with no databases.""" self._cloud_databases: set[CloudDatabase] = set() self._vumark_databases: set[VuMarkDatabase] = set() + self._model_target_datasets: dict[str, ModelTargetDataset] = {} @property def cloud_databases(self) -> set[CloudDatabase]: @@ -33,6 +35,11 @@ def vumark_databases(self) -> set[VuMarkDatabase]: """All VuMark databases.""" return set(self._vumark_databases) + @property + def model_target_datasets(self) -> dict[str, ModelTargetDataset]: + """All Model Target datasets, keyed by UUID.""" + return dict(self._model_target_datasets) + def remove_cloud_database(self, cloud_database: CloudDatabase) -> None: """Remove a cloud database. @@ -56,6 +63,19 @@ def remove_vumark_database(self, vumark_database: VuMarkDatabase) -> None: db for db in self._vumark_databases if db != vumark_database } + def add_model_target_dataset( + self, + model_target_dataset: ModelTargetDataset, + ) -> None: + """Add a Model Target dataset.""" + self._model_target_datasets[model_target_dataset.uuid_] = ( + model_target_dataset + ) + + def remove_model_target_dataset(self, dataset_uuid: str) -> None: + """Remove a Model Target dataset.""" + del self._model_target_datasets[dataset_uuid] + def add_cloud_database(self, cloud_database: CloudDatabase) -> None: """Add a cloud database. diff --git a/tests/mock_vws/fixtures/credentials.py b/tests/mock_vws/fixtures/credentials.py index ba357b30d..0b187daf0 100644 --- a/tests/mock_vws/fixtures/credentials.py +++ b/tests/mock_vws/fixtures/credentials.py @@ -68,6 +68,20 @@ class _VuMarkCloudDatabaseSettings(BaseSettings): ) +class _ModelTargetSettings(BaseSettings): + """Settings for the Model Target Web API.""" + + client_id: str + client_secret: str + cad_data_url: str + + model_config = SettingsConfigDict( + env_prefix="MODEL_TARGET_VUFORIA_", + env_file=Path("vuforia_secrets.env"), + extra="allow", + ) + + @dataclass(frozen=True, kw_only=True) class InactiveVuMarkCloudDatabase: """Credentials for an inactive VuMark database.""" @@ -88,6 +102,27 @@ class VuMarkCloudDatabase: processing_target_id: str = field(repr=False) +@dataclass(frozen=True, kw_only=True) +class ModelTargetCredentials: + """Credentials and input data for the Model Target Web API.""" + + client_id: str = field(repr=False) + client_secret: str = field(repr=False) + cad_data_url: str = field(repr=False) + + +def get_model_target_credentials() -> ModelTargetCredentials: + """Return Model Target Web API credentials from environment + variables. + """ + settings = _ModelTargetSettings.model_validate(obj={}) + return ModelTargetCredentials( + client_id=settings.client_id, + client_secret=settings.client_secret, + cad_data_url=settings.cad_data_url, + ) + + @pytest.fixture def vuforia_database() -> CloudDatabase: """Return VWS credentials from environment variables.""" diff --git a/tests/mock_vws/fixtures/vuforia_backends.py b/tests/mock_vws/fixtures/vuforia_backends.py index adb281569..4300120de 100644 --- a/tests/mock_vws/fixtures/vuforia_backends.py +++ b/tests/mock_vws/fixtures/vuforia_backends.py @@ -261,6 +261,49 @@ def _enable_use_docker_in_memory( yield +@beartype +def _enable_use_real_model_target_vuforia( + *, + monkeypatch: pytest.MonkeyPatch, +) -> Generator[None]: + """Test against the real Model Target Web API.""" + assert monkeypatch + yield + + +@beartype +def _enable_use_mock_model_target_vuforia( + *, + monkeypatch: pytest.MonkeyPatch, +) -> Generator[None]: + """Test against the in-memory mock Model Target Web API.""" + assert monkeypatch + with MockVWS(): + yield + + +@beartype +def _enable_use_docker_in_memory_model_target_vuforia( + *, + monkeypatch: pytest.MonkeyPatch, +) -> Generator[None]: + """Test against the Flask-backed mock Model Target Web API.""" + assert monkeypatch + VWS_FLASK_APP.config["VWS_MOCK_TERMINATE_WSGI_INPUT"] = True + monkeypatch.setenv( + name="TARGET_MANAGER_BASE_URL", + value="http://example.com", + ) + + with responses.RequestsMock(assert_all_requests_are_fired=False) as mock: + add_flask_app_to_mock( + mock_obj=mock, + flask_app=VWS_FLASK_APP, + base_url="https://vws.vuforia.com", + ) + yield + + class VuforiaBackend(Enum): """Backends for tests.""" @@ -356,6 +399,40 @@ def fixture_verify_mock_vuforia( ) +@pytest.fixture( + name="verify_model_target_mock_vuforia", + params=list(VuforiaBackend), + ids=[backend.value for backend in list(VuforiaBackend)], +) +def fixture_verify_model_target_mock_vuforia( + *, + request: pytest.FixtureRequest, + monkeypatch: pytest.MonkeyPatch, +) -> Generator[VuforiaBackend]: + """Run Model Target Web API contract tests against real and mock + APIs. + """ + backend: VuforiaBackend = request.param + should_skip = request.config.getoption( + name=f"--skip-{backend.name.lower()}", + ) + if should_skip: + pytest.skip() + + enable_function = { + VuforiaBackend.REAL: _enable_use_real_model_target_vuforia, + VuforiaBackend.MOCK: _enable_use_mock_model_target_vuforia, + VuforiaBackend.DOCKER_IN_MEMORY: ( + _enable_use_docker_in_memory_model_target_vuforia + ), + }[backend] + + with contextlib.contextmanager(func=enable_function)( + monkeypatch=monkeypatch, + ): + yield backend + + @pytest.fixture( params=[item for item in VuforiaBackend if item != VuforiaBackend.REAL], ids=[ diff --git a/tests/mock_vws/test_docker.py b/tests/mock_vws/test_docker.py index 74d55b859..3bf78f074 100644 --- a/tests/mock_vws/test_docker.py +++ b/tests/mock_vws/test_docker.py @@ -52,7 +52,7 @@ def wait_for_health_check(container: Container) -> None: """Wait for a container to pass its health check. On failure, augment the error with the container's logs and the - Docker health check probe history so CI failures are diagnosable. + Docker health check probe history so CI failures are easier to diagnose. """ try: _poll_health_check(container=container) diff --git a/tests/mock_vws/test_flask_app_usage.py b/tests/mock_vws/test_flask_app_usage.py index 4db00b7e5..24388e60c 100644 --- a/tests/mock_vws/test_flask_app_usage.py +++ b/tests/mock_vws/test_flask_app_usage.py @@ -5,6 +5,7 @@ import json import time import uuid +import zipfile from collections.abc import Iterator from http import HTTPMethod, HTTPStatus @@ -30,6 +31,25 @@ ) _EXAMPLE_URL_FOR_TARGET_MANAGER = "http://" + uuid.uuid4().hex + ".com" +_MODEL_TARGET_DATASET_REQUEST = { + "name": "dataset-name", + "targetSdk": "10.18", + "models": [ + { + "name": "model-name", + "cadDataUrl": "https://example.com/model.glb", + "views": [ + { + "name": "view-name", + "guideViewPosition": { + "translation": [0, 0, 5], + "rotation": [0, 0, 0, 1], + }, + }, + ], + }, + ], +} @pytest.fixture(autouse=True) @@ -67,6 +87,8 @@ def _(*, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: TARGET_MANAGER.remove_cloud_database(cloud_database=cloud_database) for vumark_database in TARGET_MANAGER.vumark_databases: TARGET_MANAGER.remove_vumark_database(vumark_database=vumark_database) + for dataset_uuid in TARGET_MANAGER.model_target_datasets: + TARGET_MANAGER.remove_model_target_dataset(dataset_uuid=dataset_uuid) class TestProcessingTime: @@ -789,6 +811,57 @@ def test_processing_target_returns_forbidden() -> None: ) +class TestModelTargetWebAPI: + """Tests for the Model Target Web API through the Flask app.""" + + @staticmethod + def test_standard_dataset_workflow( + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A Model Target dataset can be created and downloaded.""" + monkeypatch.setenv(name="PROCESSING_TIME_SECONDS", value="0") + token_response = requests.post( + url="https://vws.vuforia.com/oauth2/token", + auth=("client-id", "client-secret"), + data={"grant_type": "client_credentials"}, + timeout=30, + ) + token = token_response.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + create_response = requests.post( + url="https://vws.vuforia.com/modeltargets/datasets", + headers=headers, + json=_MODEL_TARGET_DATASET_REQUEST, + timeout=30, + ) + dataset_uuid = create_response.json()["uuid"] + status_response = requests.get( + url=( + "https://vws.vuforia.com/modeltargets/datasets/" + f"{dataset_uuid}/status" + ), + headers=headers, + timeout=30, + ) + dataset_response = requests.get( + url=( + "https://vws.vuforia.com/modeltargets/datasets/" + f"{dataset_uuid}/dataset" + ), + headers=headers, + timeout=30, + ) + + assert token_response.status_code == HTTPStatus.OK + assert create_response.status_code == HTTPStatus.CREATED + assert status_response.json()["status"] == "done" + with zipfile.ZipFile( + file=io.BytesIO(initial_bytes=dataset_response.content), + ) as dataset_zip: + assert dataset_zip.namelist() == ["dataset.json"] + + class TestResponseDelay: """Tests for the response delay feature. diff --git a/tests/mock_vws/test_model_target_web_api.py b/tests/mock_vws/test_model_target_web_api.py new file mode 100644 index 000000000..fdd9059a5 --- /dev/null +++ b/tests/mock_vws/test_model_target_web_api.py @@ -0,0 +1,484 @@ +"""Verified fake tests for the Model Target Web API.""" + +import json +from http import HTTPMethod, HTTPStatus +from typing import Any +from uuid import uuid4 + +import pytest +import requests + +from mock_vws import MockVWS +from tests.mock_vws.fixtures.credentials import ( + ModelTargetCredentials, + get_model_target_credentials, +) +from tests.mock_vws.fixtures.vuforia_backends import VuforiaBackend + +_VWS_HOST = "https://vws.vuforia.com" +_DATASET_UUID = "0b12466eee5d49409a440927006ff5d8" + + +def _dataset_request(*, cad_data_url: str) -> dict[str, Any]: + """Return a standard Model Target dataset request.""" + return { + "name": f"dataset-{uuid4().hex}", + "targetSdk": "10.18", + "models": [ + { + "name": "model-name", + "cadDataUrl": cad_data_url, + "views": [ + { + "name": "view-name", + "guideViewPosition": { + "translation": [0, 0, 5], + "rotation": [0, 0, 0, 1], + }, + }, + ], + }, + ], + } + + +_UNAUTHENTICATED_DATASET_REQUEST = { + "name": "dataset-name", + "targetSdk": "10.18", + "models": [ + { + "name": "model-name", + "cadDataUrl": "https://example.com/model.glb", + "views": [ + { + "name": "view-name", + "guideViewPosition": { + "translation": [0, 0, 5], + "rotation": [0, 0, 0, 1], + }, + }, + ], + }, + ], +} + + +def _credentials_for_backend( + *, + backend: VuforiaBackend, +) -> ModelTargetCredentials: + """Return credentials for the chosen backend.""" + if backend == VuforiaBackend.REAL: + return get_model_target_credentials() + + return ModelTargetCredentials( + client_id="client-id", + client_secret="client-secret", + cad_data_url="https://example.com/model.glb", + ) + + +def _get_access_token(*, credentials: ModelTargetCredentials) -> str: + """Return an OAuth2 access token.""" + response = requests.post( + url=f"{_VWS_HOST}/oauth2/token", + auth=(credentials.client_id, credentials.client_secret), + data={"grant_type": "client_credentials"}, + timeout=30, + ) + + assert response.status_code == HTTPStatus.OK + response_json: dict[str, Any] = json.loads(s=response.text) + access_token = response_json["access_token"] + assert isinstance(access_token, str) + assert response_json["token_type"] == "bearer" + return access_token + + +def _assert_model_target_error( + *, + response: requests.Response, + status_code: HTTPStatus, + code: str, + message: str, + target: str, +) -> None: + """Assert a Model Target Web API error response.""" + assert response.status_code == status_code + assert response.json() == { + "error": { + "code": code, + "message": message, + "target": target, + }, + } + + +@pytest.mark.usefixtures("verify_model_target_mock_vuforia") +class TestAuthentication: + """Tests for Model Target Web API authentication.""" + + @staticmethod + @pytest.mark.parametrize( + argnames=("method", "path", "json_body"), + argvalues=[ + pytest.param( + HTTPMethod.POST, + "/modeltargets/datasets", + _UNAUTHENTICATED_DATASET_REQUEST, + id="create-standard-dataset", + ), + pytest.param( + HTTPMethod.POST, + "/modeltargets/advancedDatasets", + _UNAUTHENTICATED_DATASET_REQUEST, + id="create-advanced-dataset", + ), + pytest.param( + HTTPMethod.GET, + f"/modeltargets/datasets/{_DATASET_UUID}/status", + None, + id="standard-dataset-status", + ), + pytest.param( + HTTPMethod.GET, + f"/modeltargets/advancedDatasets/{_DATASET_UUID}/status", + None, + id="advanced-dataset-status", + ), + pytest.param( + HTTPMethod.GET, + f"/modeltargets/datasets/{_DATASET_UUID}/dataset", + None, + id="download-standard-dataset", + ), + pytest.param( + HTTPMethod.GET, + f"/modeltargets/advancedDatasets/{_DATASET_UUID}/dataset", + None, + id="download-advanced-dataset", + ), + pytest.param( + HTTPMethod.DELETE, + f"/modeltargets/datasets/{_DATASET_UUID}", + None, + id="delete-standard-dataset", + ), + pytest.param( + HTTPMethod.DELETE, + f"/modeltargets/advancedDatasets/{_DATASET_UUID}", + None, + id="delete-advanced-dataset", + ), + ], + ) + def test_missing_bearer_token( + *, + method: HTTPMethod, + path: str, + json_body: dict[str, object] | None, + ) -> None: + """Model Target routes require an OAuth2 bearer token.""" + response = requests.request( + method=method, + url=f"{_VWS_HOST}{path}", + json=json_body, + timeout=30, + ) + + assert response.status_code == HTTPStatus.UNAUTHORIZED + assert response.json() == { + "error": { + "code": "401", + "message": "no Bearer token", + "target": "jwt", + }, + } + + +class TestMockErrors: + """Tests for mock-only Model Target Web API error paths.""" + + @staticmethod + def test_invalid_oauth2_token_request() -> None: + """Invalid OAuth2 token requests are rejected.""" + with MockVWS(): + response = requests.post( + url=f"{_VWS_HOST}/oauth2/token", + data={"grant_type": "unsupported"}, + timeout=30, + ) + + _assert_model_target_error( + response=response, + status_code=HTTPStatus.BAD_REQUEST, + code="BAD_REQUEST", + message="Invalid OAuth2 token request.", + target="grant_type", + ) + + @staticmethod + def test_blank_bearer_token() -> None: + """A blank bearer token is rejected.""" + with MockVWS(): + response = requests.get( + url=f"{_VWS_HOST}/modeltargets/datasets/{_DATASET_UUID}/status", + headers={"Authorization": "Bearer "}, + timeout=30, + ) + + _assert_model_target_error( + response=response, + status_code=HTTPStatus.UNAUTHORIZED, + code="401", + message="invalid Bearer token", + target="jwt", + ) + + @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( + *, + body: str, + headers: dict[str, str], + message: str, + target: str, + ) -> None: + """Invalid dataset request bodies are rejected.""" + with MockVWS(): + response = requests.post( + url=f"{_VWS_HOST}/modeltargets/datasets", + headers={"Authorization": "Bearer token", **headers}, + data=body, + timeout=30, + ) + + _assert_model_target_error( + response=response, + status_code=HTTPStatus.BAD_REQUEST, + code="BAD_REQUEST", + message=message, + target=target, + ) + + @staticmethod + @pytest.mark.parametrize( + argnames=("path", "body", "message", "target"), + argvalues=[ + pytest.param( + "/modeltargets/datasets", + {}, + "Missing required field: name.", + "name", + id="missing-name", + ), + pytest.param( + "/modeltargets/datasets", + { + "name": "dataset-name", + "targetSdk": "10.18", + "models": "model", + }, + "models must be a list.", + "models", + 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", + ), + ], + ) + def test_invalid_dataset_request( + *, + path: str, + body: dict[str, object], + message: str, + target: str, + ) -> None: + """Invalid dataset creation requests are rejected.""" + with MockVWS(): + response = requests.post( + url=f"{_VWS_HOST}{path}", + headers={"Authorization": "Bearer token"}, + json=body, + timeout=30, + ) + + _assert_model_target_error( + response=response, + status_code=HTTPStatus.BAD_REQUEST, + code="BAD_REQUEST", + message=message, + target=target, + ) + + @staticmethod + @pytest.mark.parametrize( + argnames=("method", "path"), + argvalues=[ + pytest.param( + HTTPMethod.GET, + f"/modeltargets/datasets/{_DATASET_UUID}/status", + id="status", + ), + pytest.param( + HTTPMethod.GET, + f"/modeltargets/datasets/{_DATASET_UUID}/dataset", + id="download", + ), + pytest.param( + HTTPMethod.DELETE, + f"/modeltargets/datasets/{_DATASET_UUID}", + id="delete", + ), + ], + ) + def test_unknown_dataset( + *, + method: HTTPMethod, + path: str, + ) -> None: + """Unknown datasets are rejected.""" + with MockVWS(): + response = requests.request( + method=method, + url=f"{_VWS_HOST}{path}", + headers={"Authorization": "Bearer token"}, + timeout=30, + ) + + _assert_model_target_error( + response=response, + status_code=HTTPStatus.NOT_FOUND, + code="404", + message="The dataset was not found.", + target="uuid", + ) + + @staticmethod + def test_processing_dataset_cannot_be_downloaded() -> None: + """A dataset cannot be downloaded while it is still processing.""" + with MockVWS(processing_time_seconds=60): + create_response = requests.post( + url=f"{_VWS_HOST}/modeltargets/datasets", + headers={"Authorization": "Bearer token"}, + json=_UNAUTHENTICATED_DATASET_REQUEST, + timeout=30, + ) + response = requests.get( + url=( + f"{_VWS_HOST}/modeltargets/datasets/" + f"{create_response.json()['uuid']}/dataset" + ), + headers={"Authorization": "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", + ) + + +class TestStandardDataset: + """Tests for standard Model Target datasets.""" + + @staticmethod + def test_create_status_and_delete( + *, + verify_model_target_mock_vuforia: VuforiaBackend, + ) -> None: + """A standard Model Target dataset can be created and deleted.""" + credentials = _credentials_for_backend( + backend=verify_model_target_mock_vuforia, + ) + access_token = _get_access_token(credentials=credentials) + headers = {"Authorization": f"Bearer {access_token}"} + dataset_uuid: str | None = None + + try: + create_response = requests.post( + url=f"{_VWS_HOST}/modeltargets/datasets", + headers=headers, + json=_dataset_request(cad_data_url=credentials.cad_data_url), + timeout=30, + ) + + assert create_response.status_code == HTTPStatus.CREATED + create_response_json: dict[str, Any] = json.loads( + s=create_response.text, + ) + dataset_uuid_value = create_response_json["uuid"] + assert isinstance(dataset_uuid_value, str) + dataset_uuid = dataset_uuid_value + + status_response = requests.get( + url=( + f"{_VWS_HOST}/modeltargets/datasets/{dataset_uuid}/status" + ), + headers=headers, + timeout=30, + ) + + assert status_response.status_code == HTTPStatus.OK + status_response_json: dict[str, Any] = json.loads( + s=status_response.text, + ) + assert status_response_json["status"] in { + "processing", + "done", + "failed", + } + assert isinstance(status_response_json["createdAt"], str) + finally: + if dataset_uuid is not None: # pragma: no branch + delete_response = requests.delete( + url=f"{_VWS_HOST}/modeltargets/datasets/{dataset_uuid}", + headers=headers, + timeout=30, + ) + assert delete_response.status_code in { + HTTPStatus.OK, + HTTPStatus.NO_CONTENT, + } diff --git a/tests/mock_vws/test_requests_mock_usage.py b/tests/mock_vws/test_requests_mock_usage.py index eaa82a494..34d6d8aa5 100644 --- a/tests/mock_vws/test_requests_mock_usage.py +++ b/tests/mock_vws/test_requests_mock_usage.py @@ -5,6 +5,7 @@ import io import json import socket +import zipfile from http import HTTPStatus from urllib.parse import urlparse @@ -26,6 +27,26 @@ processing_time_seconds, ) +_MODEL_TARGET_DATASET_REQUEST = { + "name": "dataset-name", + "targetSdk": "10.18", + "models": [ + { + "name": "model-name", + "cadDataUrl": "https://example.com/model.glb", + "views": [ + { + "name": "view-name", + "guideViewPosition": { + "translation": [0, 0, 5], + "rotation": [0, 0, 0, 1], + }, + }, + ], + }, + ], +} + @beartype def _not_exact_matcher( @@ -1052,3 +1073,95 @@ def test_httpx_real_http() -> None: pytest.raises(expected_exception=httpx.ConnectError), ): httpx.get(url=f"http://localhost:{port}", timeout=30) + + +class TestModelTargetWebAPI: + """Tests for the Model Target Web API.""" + + @staticmethod + def test_standard_dataset_workflow() -> None: + """A standard Model Target dataset can be created and + downloaded. + """ + with MockVWS(processing_time_seconds=0): + token_response = requests.post( + url="https://vws.vuforia.com/oauth2/token", + auth=("client-id", "client-secret"), + data={"grant_type": "client_credentials"}, + timeout=30, + ) + token = token_response.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + create_response = requests.post( + url="https://vws.vuforia.com/modeltargets/datasets", + headers=headers, + json=_MODEL_TARGET_DATASET_REQUEST, + timeout=30, + ) + dataset_uuid = create_response.json()["uuid"] + + status_response = requests.get( + url=( + "https://vws.vuforia.com/modeltargets/datasets/" + f"{dataset_uuid}/status" + ), + headers=headers, + timeout=30, + ) + dataset_response = requests.get( + url=( + "https://vws.vuforia.com/modeltargets/datasets/" + f"{dataset_uuid}/dataset" + ), + headers=headers, + timeout=30, + ) + + assert token_response.status_code == HTTPStatus.OK + assert create_response.status_code == HTTPStatus.CREATED + assert status_response.json()["status"] == "done" + with zipfile.ZipFile( + file=io.BytesIO(initial_bytes=dataset_response.content), + ) as dataset_zip: + assert dataset_zip.namelist() == ["dataset.json"] + + @staticmethod + def test_advanced_dataset_workflow() -> None: + """An advanced Model Target dataset can be created.""" + with MockVWS(processing_time_seconds=0): + response = requests.post( + url="https://vws.vuforia.com/modeltargets/advancedDatasets", + headers={"Authorization": "Bearer token"}, + json=_MODEL_TARGET_DATASET_REQUEST, + timeout=30, + ) + dataset_uuid = response.json()["uuid"] + status_response = requests.get( + url=( + "https://vws.vuforia.com/modeltargets/" + f"advancedDatasets/{dataset_uuid}/status" + ), + headers={"Authorization": "Bearer token"}, + timeout=30, + ) + + assert response.status_code == HTTPStatus.CREATED + assert status_response.json()["uuid"] == dataset_uuid + + @staticmethod + def test_bearer_token_required() -> None: + """Model Target dataset routes require a bearer token.""" + with MockVWS(): + response = requests.post( + url="https://vws.vuforia.com/modeltargets/datasets", + json=_MODEL_TARGET_DATASET_REQUEST, + timeout=30, + ) + + assert response.status_code == HTTPStatus.UNAUTHORIZED + assert response.json()["error"] == { + "code": "401", + "message": "no Bearer token", + "target": "jwt", + } diff --git a/tests/mock_vws/test_respx_mock_usage.py b/tests/mock_vws/test_respx_mock_usage.py index 5db88b2c5..467becf3f 100644 --- a/tests/mock_vws/test_respx_mock_usage.py +++ b/tests/mock_vws/test_respx_mock_usage.py @@ -4,6 +4,7 @@ import io import uuid +from http import HTTPStatus import httpx import pytest @@ -18,6 +19,26 @@ from mock_vws.image_matchers import ExactMatcher from mock_vws.target import VuMarkTarget +_MODEL_TARGET_DATASET_REQUEST = { + "name": "dataset-name", + "targetSdk": "10.18", + "models": [ + { + "name": "model-name", + "cadDataUrl": "https://example.com/model.glb", + "views": [ + { + "name": "view-name", + "guideViewPosition": { + "translation": [0, 0, 5], + "rotation": [0, 0, 0, 1], + }, + }, + ], + }, + ], +} + class TestVWS: """Synchronous ``vws-python`` client usage through the mock via @@ -159,3 +180,30 @@ def test_generate_vumark_instance_returns_png_bytes() -> None: ) assert response_content.startswith(b"\x89PNG") + + +class TestModelTargetWebAPI: + """Model Target Web API usage through the mock via ``httpx``.""" + + @staticmethod + def test_standard_dataset_status() -> None: + """``httpx`` requests can use Model Target Web API routes.""" + with MockVWS(processing_time_seconds=0): + create_response = httpx.post( + url="https://vws.vuforia.com/modeltargets/datasets", + headers={"Authorization": "Bearer token"}, + json=_MODEL_TARGET_DATASET_REQUEST, + timeout=30, + ) + dataset_uuid = create_response.json()["uuid"] + status_response = httpx.get( + url=( + "https://vws.vuforia.com/modeltargets/datasets/" + f"{dataset_uuid}/status" + ), + headers={"Authorization": "Bearer token"}, + timeout=30, + ) + + assert create_response.status_code == HTTPStatus.CREATED + assert status_response.json()["status"] == "done" diff --git a/tests/mock_vws/test_target_validators.py b/tests/mock_vws/test_target_validators.py index 0dff2f865..0fc74601c 100644 --- a/tests/mock_vws/test_target_validators.py +++ b/tests/mock_vws/test_target_validators.py @@ -71,7 +71,7 @@ def test_validate_target_id_exists_uses_correct_path_segment( """ database = _database_with_target(target_id=target_id) - monkeypatch.setattr( # pylint: disable=bad-builtin + monkeypatch.setattr( target=target_validators, name="get_database_matching_server_keys", value=partial(_always_match_database, database=database), diff --git a/vuforia_secrets.env.example b/vuforia_secrets.env.example index 760e0407e..ae1990e24 100644 --- a/vuforia_secrets.env.example +++ b/vuforia_secrets.env.example @@ -24,3 +24,7 @@ INACTIVE_VUMARK_VUFORIA_TARGET_MANAGER_DATABASE_NAME=example_inactive_vumark_dat INACTIVE_VUMARK_VUFORIA_SERVER_ACCESS_KEY=example_inactive_vumark_server_access_key INACTIVE_VUMARK_VUFORIA_SERVER_SECRET_KEY=example_inactive_vumark_server_secret_key + +MODEL_TARGET_VUFORIA_CLIENT_ID=example_model_target_client_id +MODEL_TARGET_VUFORIA_CLIENT_SECRET=example_model_target_client_secret +MODEL_TARGET_VUFORIA_CAD_DATA_URL=https://example.com/model.glb