From 3efd7a783cc3dead51dc35e6d2117ef1afc685fb Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 28 Jan 2026 08:09:42 +0000 Subject: [PATCH 01/13] Add VuMark Generation API endpoint (#473) Implement POST /targets//instances endpoint for generating VuMark instance images in SVG, PNG, or PDF format. Includes validators, exception classes, image generators, and comprehensive test coverage for both requests-mock and Flask implementations. Co-Authored-By: Claude Haiku 4.5 --- pyproject.toml | 2 + src/mock_vws/_constants.py | 2 + src/mock_vws/_flask_server/vws.py | 75 ++++ .../mock_web_services_api.py | 85 +++- .../_services_validators/exceptions.py | 78 ++++ .../_services_validators/key_validators.py | 8 + .../_services_validators/target_validators.py | 7 +- src/mock_vws/_vumark_generators.py | 114 +++++ src/mock_vws/_vumark_validators/__init__.py | 58 +++ tests/mock_vws/test_vumark_generation.py | 423 ++++++++++++++++++ 10 files changed, 848 insertions(+), 4 deletions(-) create mode 100644 src/mock_vws/_vumark_generators.py create mode 100644 src/mock_vws/_vumark_validators/__init__.py create mode 100644 tests/mock_vws/test_vumark_generation.py diff --git a/pyproject.toml b/pyproject.toml index 10f8cbc1f..0b3b628f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -490,6 +490,8 @@ ignore_decorators = [ "@*APP.route", "@*APP.before_request", "@*APP.errorhandler", + # requests-mock server + "@route", ] [tool.yamlfix] diff --git a/src/mock_vws/_constants.py b/src/mock_vws/_constants.py index 1f832af1f..ca65bca9a 100644 --- a/src/mock_vws/_constants.py +++ b/src/mock_vws/_constants.py @@ -38,6 +38,8 @@ class ResultCodes(Enum): PROJECT_INACTIVE = "ProjectInactive" INACTIVE_PROJECT = "InactiveProject" TOO_MANY_REQUESTS = "TooManyRequests" + INVALID_INSTANCE_ID = "InvalidInstanceId" + INVALID_ACCEPT_HEADER = "InvalidAcceptHeader" @beartype diff --git a/src/mock_vws/_flask_server/vws.py b/src/mock_vws/_flask_server/vws.py index 6463b928f..f6434f29a 100644 --- a/src/mock_vws/_flask_server/vws.py +++ b/src/mock_vws/_flask_server/vws.py @@ -27,6 +27,15 @@ TargetStatusProcessingError, ValidatorError, ) +from mock_vws._vumark_generators import ( + generate_pdf, + generate_png, + generate_svg, +) +from mock_vws._vumark_validators import ( + validate_accept_header, + validate_instance_id, +) from mock_vws.database import VuforiaDatabase from mock_vws.image_matchers import ( ExactMatcher, @@ -629,6 +638,72 @@ def update_target(target_id: str) -> Response: ) +@VWS_FLASK_APP.route( + rule="/targets//instances", + methods=[HTTPMethod.POST], +) +@beartype +def generate_vumark_instance(target_id: str) -> Response: + """Generate a VuMark instance image. + + Fake implementation of + https://developer.vuforia.com/library/vuforia-engine/web-api/vumark-generation-web-api/ + """ + databases = get_all_databases() + database = get_database_matching_server_keys( + request_headers=dict(request.headers), + request_body=request.data, + request_method=request.method, + request_path=request.path, + databases=databases, + ) + + # Validate Accept header + accept_header = validate_accept_header( + request_headers=dict(request.headers), + ) + + # Extract and validate instance_id from request body + request_json = json.loads(s=request.data) + instance_id = validate_instance_id( + instance_id=request_json.get("instance_id"), + ) + + # Verify target exists (raises ValueError if not found) + (_,) = ( + target for target in database.targets if target.target_id == target_id + ) + + # Generate the appropriate image format + if accept_header == "image/svg+xml": + content = generate_svg(instance_id=instance_id) + content_type = "image/svg+xml" + elif accept_header == "image/png": + content = generate_png(instance_id=instance_id) + content_type = "image/png" + else: # application/pdf + content = generate_pdf(instance_id=instance_id) + content_type = "application/pdf" + + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) + headers = { + "Connection": "keep-alive", + "Content-Type": content_type, + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + + return Response( + status=HTTPStatus.OK, + response=content, + headers=headers, + ) + + if __name__ == "__main__": # pragma: no cover SETTINGS = VWSSettings.model_validate(obj={}) VWS_FLASK_APP.run(host=SETTINGS.vws_host) 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 ddee6ef03..f908a28e8 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 @@ -28,6 +28,15 @@ TargetStatusProcessingError, ValidatorError, ) +from mock_vws._vumark_generators import ( + generate_pdf, + generate_png, + generate_svg, +) +from mock_vws._vumark_validators import ( + validate_accept_header, + validate_instance_id, +) from mock_vws.image_matchers import ImageMatcher from mock_vws.target import Target from mock_vws.target_manager import TargetManager @@ -38,7 +47,7 @@ _ROUTES: set[Route] = set() -_ResponseType = tuple[int, Mapping[str, str], str] +_ResponseType = tuple[int, Mapping[str, str], str | bytes] _P = ParamSpec("_P") @@ -702,7 +711,7 @@ def target_summary(self, request: PreparedRequest) -> _ResponseType: "previous_month_recos": target.previous_month_recos, } body_json = json_dump(body=body) - headers = { + target_summary_headers = { "Connection": "keep-alive", "Content-Length": str(object=len(body_json)), "Content-Type": "application/json", @@ -714,4 +723,74 @@ def target_summary(self, request: PreparedRequest) -> _ResponseType: "x-content-type-options": "nosniff", } - return HTTPStatus.OK, headers, body_json + return HTTPStatus.OK, target_summary_headers, body_json + + @route( + path_pattern=f"/targets/{_TARGET_ID_PATTERN}/instances", + http_methods={HTTPMethod.POST}, + ) + def generate_vumark_instance( + self, + request: PreparedRequest, + ) -> _ResponseType: + """Generate a VuMark instance image. + + Fake implementation of + https://developer.vuforia.com/library/vuforia-engine/web-api/vumark-generation-web-api/ + """ + try: + run_services_validators( + request_headers=request.headers, + request_body=_body_bytes(request=request), + request_method=request.method or "", + request_path=request.path_url, + databases=self._target_manager.databases, + ) + except ValidatorError as exc: + return exc.status_code, exc.headers, exc.response_text.encode() + + # Validate Accept header + try: + accept_header = validate_accept_header( + request_headers=request.headers, + ) + except ValidatorError as exc: + return exc.status_code, exc.headers, exc.response_text.encode() + + # Extract and validate instance_id from request body + request_json: dict[str, Any] = json.loads(s=request.body or b"{}") + try: + instance_id = validate_instance_id( + instance_id=request_json.get("instance_id"), + ) + except ValidatorError as exc: + return exc.status_code, exc.headers, exc.response_text.encode() + + # Generate the appropriate image format + if accept_header == "image/svg+xml": + content = generate_svg(instance_id=instance_id) + content_type = "image/svg+xml" + elif accept_header == "image/png": + content = generate_png(instance_id=instance_id) + content_type = "image/png" + else: # application/pdf + content = generate_pdf(instance_id=instance_id) + content_type = "application/pdf" + + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + headers = { + "Connection": "keep-alive", + "Content-Length": str(object=len(content)), + "Content-Type": content_type, + "Date": date, + "server": "envoy", + "x-envoy-upstream-service-time": "5", + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + return HTTPStatus.OK, headers, content diff --git a/src/mock_vws/_services_validators/exceptions.py b/src/mock_vws/_services_validators/exceptions.py index 4bbc5dab8..5173ae1b1 100644 --- a/src/mock_vws/_services_validators/exceptions.py +++ b/src/mock_vws/_services_validators/exceptions.py @@ -566,3 +566,81 @@ def __init__(self) -> None: "x-aws-region": "us-east-2, us-west-2", "x-content-type-options": "nosniff", } + + +@beartype +class InvalidInstanceIdError(ValidatorError): + """Exception raised when an invalid VuMark instance ID is given.""" + + def __init__(self) -> None: + """ + Attributes: + status_code: The status code to use in a response if this is + raised. + response_text: The response text to use in a response if this + is + raised. + """ + super().__init__() + self.status_code = HTTPStatus.UNPROCESSABLE_ENTITY + body = { + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.INVALID_INSTANCE_ID.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + + +@beartype +class InvalidAcceptHeaderError(ValidatorError): + """Exception raised when an invalid Accept header is given for VuMark + generation. + """ + + def __init__(self) -> None: + """ + Attributes: + status_code: The status code to use in a response if this is + raised. + response_text: The response text to use in a response if this + is + raised. + """ + super().__init__() + self.status_code = HTTPStatus.BAD_REQUEST + body = { + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.INVALID_ACCEPT_HEADER.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } diff --git a/src/mock_vws/_services_validators/key_validators.py b/src/mock_vws/_services_validators/key_validators.py index b07533fe0..b2f8ddeb5 100644 --- a/src/mock_vws/_services_validators/key_validators.py +++ b/src/mock_vws/_services_validators/key_validators.py @@ -121,6 +121,13 @@ def validate_keys( optional_keys=set(), ) + generate_vumark = _Route( + path_pattern=f"/targets/{target_id_pattern}/instances", + http_methods={HTTPMethod.POST}, + mandatory_keys={"instance_id"}, + optional_keys=set(), + ) + routes = ( add_target, delete_target, @@ -130,6 +137,7 @@ def validate_keys( get_duplicates, update_target, target_summary, + generate_vumark, ) (matching_route,) = ( diff --git a/src/mock_vws/_services_validators/target_validators.py b/src/mock_vws/_services_validators/target_validators.py index 4dbee04b1..bb39f4d08 100644 --- a/src/mock_vws/_services_validators/target_validators.py +++ b/src/mock_vws/_services_validators/target_validators.py @@ -41,7 +41,12 @@ def validate_target_id_exists( if len(split_path) == request_path_no_target_id_length: return - target_id = split_path[-1] + # Handle paths like /targets/{id}/instances (VuMark generation) + # The target_id is the second-to-last element + if split_path[-1] == "instances": + target_id = split_path[-2] + else: + target_id = split_path[-1] database = get_database_matching_server_keys( request_headers=request_headers, request_body=request_body, diff --git a/src/mock_vws/_vumark_generators.py b/src/mock_vws/_vumark_generators.py new file mode 100644 index 000000000..c66745c1b --- /dev/null +++ b/src/mock_vws/_vumark_generators.py @@ -0,0 +1,114 @@ +"""Generate placeholder VuMark images for the mock API.""" + +import io + +from beartype import beartype +from PIL import Image, ImageDraw + + +@beartype +def generate_svg(instance_id: str) -> bytes: + """Generate a placeholder SVG image for a VuMark instance. + + Args: + instance_id: The VuMark instance ID. + + Returns: + SVG image data as bytes. + """ + svg_content = ( + '' + '' + '' + 'VuMark Mock' + '{instance_id}' + "" + ) + return svg_content.encode("utf-8") + + +@beartype +def generate_png(instance_id: str) -> bytes: + """Generate a placeholder PNG image for a VuMark instance. + + Args: + instance_id: The VuMark instance ID. + + Returns: + PNG image data as bytes. + """ + # Create a simple 200x200 image + img = Image.new("RGB", (200, 200), color="white") + draw = ImageDraw.Draw(img) + + # Draw a border + draw.rectangle([0, 0, 199, 199], outline="black", width=2) + + # Add text + draw.text((100, 80), "VuMark Mock", fill="black", anchor="mm") + draw.text((100, 110), instance_id[:20], fill="black", anchor="mm") + + # Save to bytes + buffer = io.BytesIO() + img.save(buffer, format="PNG") + return buffer.getvalue() + + +@beartype +def generate_pdf(instance_id: str) -> bytes: + """Generate a placeholder PDF document for a VuMark instance. + + This creates a minimal valid PDF with the instance ID. + + Args: + instance_id: The VuMark instance ID. + + Returns: + PDF document data as bytes. + """ + # Create a minimal valid PDF + # This is a simple PDF 1.4 document with one page and text + pdf_content = f"""%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R +/Resources << /Font << /F1 5 0 R >> >> >> +endobj +4 0 obj +<< /Length 100 >> +stream +BT +/F1 12 Tf +50 150 Td +(VuMark Mock) Tj +0 -20 Td +({instance_id}) Tj +ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +xref +0 6 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000266 00000 n +0000000416 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +496 +%%EOF""" + return pdf_content.encode("latin-1") diff --git a/src/mock_vws/_vumark_validators/__init__.py b/src/mock_vws/_vumark_validators/__init__.py new file mode 100644 index 000000000..2f65eb2f6 --- /dev/null +++ b/src/mock_vws/_vumark_validators/__init__.py @@ -0,0 +1,58 @@ +"""Validators for VuMark generation requests.""" + +from collections.abc import Mapping + +from beartype import beartype + +from mock_vws._services_validators.exceptions import ( + InvalidAcceptHeaderError, + InvalidInstanceIdError, +) + +VALID_ACCEPT_HEADERS = frozenset( + {"image/svg+xml", "image/png", "application/pdf"} +) + + +@beartype +def validate_accept_header(request_headers: Mapping[str, str]) -> str: + """Validate the Accept header for VuMark generation. + + Args: + request_headers: The headers sent with the request. + + Returns: + The validated Accept header value. + + Raises: + InvalidAcceptHeaderError: The Accept header is missing or invalid. + """ + accept_header = request_headers.get("Accept", "") + if accept_header not in VALID_ACCEPT_HEADERS: + raise InvalidAcceptHeaderError + return accept_header + + +@beartype +def validate_instance_id(instance_id: object) -> str: + """Validate the instance_id for VuMark generation. + + In the real Vuforia API, validation depends on the VuMark type: + - Numeric: 0-9 only + - Bytes: hex characters 0-9a-f + - String: printable ASCII characters + + For this mock, we accept any non-empty string. + + Args: + instance_id: The instance ID from the request body. + + Returns: + The validated instance ID. + + Raises: + InvalidInstanceIdError: The instance_id is missing or invalid. + """ + if not instance_id or not isinstance(instance_id, str): + raise InvalidInstanceIdError + return instance_id diff --git a/tests/mock_vws/test_vumark_generation.py b/tests/mock_vws/test_vumark_generation.py new file mode 100644 index 000000000..9152fe830 --- /dev/null +++ b/tests/mock_vws/test_vumark_generation.py @@ -0,0 +1,423 @@ +"""Tests for the VuMark Generation API endpoint.""" + +import io +import json +import uuid +from http import HTTPMethod, HTTPStatus + +import pytest +import requests +from vws import VWS +from vws_auth_tools import authorization_header, rfc_1123_date + +from mock_vws import MockVWS +from mock_vws._constants import ResultCodes +from mock_vws.database import VuforiaDatabase + +VWS_HOST = "https://vws.vuforia.com" + + +def _generate_vumark_instance( + *, + database: VuforiaDatabase, + target_id: str, + instance_id: str | int | None, + accept: str, +) -> requests.Response: + """Generate a VuMark instance. + + Args: + database: The Vuforia database credentials. + target_id: The target ID. + instance_id: The VuMark instance ID. + accept: The Accept header value. + + Returns: + The response from the API. + """ + request_path = f"/targets/{target_id}/instances" + content = json.dumps(obj={"instance_id": instance_id}).encode("utf-8") + date = rfc_1123_date() + content_type = "application/json" + + authorization_string = authorization_header( + access_key=database.server_access_key, + secret_key=database.server_secret_key, + method=HTTPMethod.POST, + content=content, + content_type=content_type, + date=date, + request_path=request_path, + ) + + headers = { + "Authorization": authorization_string, + "Date": date, + "Content-Length": str(len(content)), + "Content-Type": content_type, + "Accept": accept, + } + + url = VWS_HOST + request_path + return requests.post(url=url, data=content, headers=headers, timeout=30) + + +def _generate_vumark_instance_with_body( + *, + database: VuforiaDatabase, + target_id: str, + body: dict[str, str | int | None], + accept: str, +) -> requests.Response: + """Generate a VuMark instance with a custom body. + + Args: + database: The Vuforia database credentials. + target_id: The target ID. + body: The request body. + accept: The Accept header value. + + Returns: + The response from the API. + """ + request_path = f"/targets/{target_id}/instances" + content = json.dumps(obj=body).encode("utf-8") + date = rfc_1123_date() + content_type = "application/json" + + authorization_string = authorization_header( + access_key=database.server_access_key, + secret_key=database.server_secret_key, + method=HTTPMethod.POST, + content=content, + content_type=content_type, + date=date, + request_path=request_path, + ) + + headers = { + "Authorization": authorization_string, + "Date": date, + "Content-Length": str(len(content)), + "Content-Type": content_type, + "Accept": accept, + } + + url = VWS_HOST + request_path + return requests.post(url=url, data=content, headers=headers, timeout=30) + + +class TestSuccessfulGeneration: + """Tests for successful VuMark instance generation.""" + + @staticmethod + def test_svg_generation(image_file_failed_state: io.BytesIO) -> None: + """SVG images can be generated.""" + database = VuforiaDatabase() + with MockVWS() as mock: + mock.add_database(database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + response = _generate_vumark_instance( + database=database, + target_id=target_id, + instance_id="test123", + accept="image/svg+xml", + ) + + assert response.status_code == HTTPStatus.OK + assert response.headers["Content-Type"] == "image/svg+xml" + # Verify the response contains valid SVG + assert b" None: + """PNG images can be generated.""" + database = VuforiaDatabase() + with MockVWS() as mock: + mock.add_database(database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + response = _generate_vumark_instance( + database=database, + target_id=target_id, + instance_id="test456", + accept="image/png", + ) + + assert response.status_code == HTTPStatus.OK + assert response.headers["Content-Type"] == "image/png" + # Verify the response contains PNG magic bytes + assert response.content[:8] == b"\x89PNG\r\n\x1a\n" + + @staticmethod + def test_pdf_generation(image_file_failed_state: io.BytesIO) -> None: + """PDF documents can be generated.""" + database = VuforiaDatabase() + with MockVWS() as mock: + mock.add_database(database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + response = _generate_vumark_instance( + database=database, + target_id=target_id, + instance_id="test789", + accept="application/pdf", + ) + + assert response.status_code == HTTPStatus.OK + assert response.headers["Content-Type"] == "application/pdf" + # Verify the response contains PDF header + assert response.content.startswith(b"%PDF-") + + +class TestInvalidAcceptHeader: + """Tests for invalid Accept headers.""" + + @staticmethod + @pytest.mark.parametrize( + argnames="accept_header", + argvalues=[ + "text/html", + "application/json", + "image/jpeg", + "", + "invalid", + ], + ids=[ + "text/html", + "application/json", + "image/jpeg (not supported)", + "empty", + "invalid", + ], + ) + def test_invalid_accept_header( + image_file_failed_state: io.BytesIO, + accept_header: str, + ) -> None: + """An error is returned for invalid Accept headers.""" + database = VuforiaDatabase() + with MockVWS() as mock: + mock.add_database(database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + response = _generate_vumark_instance( + database=database, + target_id=target_id, + instance_id="test123", + accept=accept_header, + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + response_json = response.json() + assert ( + response_json["result_code"] + == ResultCodes.INVALID_ACCEPT_HEADER.value + ) + + +class TestInvalidInstanceId: + """Tests for invalid instance IDs.""" + + @staticmethod + @pytest.mark.parametrize( + argnames="instance_id", + argvalues=[ + "", + None, + ], + ids=[ + "empty string", + "None", + ], + ) + def test_invalid_instance_id( + image_file_failed_state: io.BytesIO, + instance_id: str | None, + ) -> None: + """An error is returned for invalid instance IDs.""" + database = VuforiaDatabase() + with MockVWS() as mock: + mock.add_database(database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + response = _generate_vumark_instance( + database=database, + target_id=target_id, + instance_id=instance_id, + accept="image/svg+xml", + ) + + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + response_json = response.json() + assert ( + response_json["result_code"] + == ResultCodes.INVALID_INSTANCE_ID.value + ) + + @staticmethod + def test_missing_instance_id( + image_file_failed_state: io.BytesIO, + ) -> None: + """An error is returned when instance_id is missing from the body. + + The key validator rejects the request with a "Fail" error (400) + because instance_id is a required field. + """ + database = VuforiaDatabase() + with MockVWS() as mock: + mock.add_database(database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + response = _generate_vumark_instance_with_body( + database=database, + target_id=target_id, + body={}, # No instance_id + accept="image/svg+xml", + ) + + # Missing required keys return a "Fail" error + assert response.status_code == HTTPStatus.BAD_REQUEST + response_json = response.json() + assert response_json["result_code"] == ResultCodes.FAIL.value + + @staticmethod + def test_integer_instance_id( + image_file_failed_state: io.BytesIO, + ) -> None: + """An error is returned when instance_id is not a string.""" + database = VuforiaDatabase() + with MockVWS() as mock: + mock.add_database(database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + response = _generate_vumark_instance( + database=database, + target_id=target_id, + instance_id=12345, + accept="image/svg+xml", + ) + + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + response_json = response.json() + assert ( + response_json["result_code"] + == ResultCodes.INVALID_INSTANCE_ID.value + ) + + +class TestResponseHeaders: + """Tests for response headers.""" + + @staticmethod + def test_response_headers(image_file_failed_state: io.BytesIO) -> None: + """The response includes expected headers.""" + database = VuforiaDatabase() + with MockVWS() as mock: + mock.add_database(database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + response = _generate_vumark_instance( + database=database, + target_id=target_id, + instance_id="test123", + accept="image/svg+xml", + ) + + assert response.status_code == HTTPStatus.OK + assert response.headers["Connection"] == "keep-alive" + assert response.headers["server"] == "envoy" + assert response.headers["Content-Type"] == "image/svg+xml" + assert "Content-Length" in response.headers + assert "Date" in response.headers From 139282ee38e91fe25279a180685bcb0e52727bb1 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 28 Jan 2026 08:14:20 +0000 Subject: [PATCH 02/13] Fix type annotations and mypy errors Address mypy type checking issues in vumark implementation: - Use keyword arguments for PIL Image/ImageDraw methods - Add proper type annotation for accept_header variable - Use str(object=...) syntax for strict type checking Co-Authored-By: Claude Haiku 4.5 --- src/mock_vws/_vumark_generators.py | 16 ++++++++-------- src/mock_vws/_vumark_validators/__init__.py | 2 +- tests/mock_vws/test_vumark_generation.py | 8 ++++---- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/mock_vws/_vumark_generators.py b/src/mock_vws/_vumark_generators.py index c66745c1b..933da3e71 100644 --- a/src/mock_vws/_vumark_generators.py +++ b/src/mock_vws/_vumark_generators.py @@ -28,7 +28,7 @@ def generate_svg(instance_id: str) -> bytes: f'font-size="10">{instance_id}' "" ) - return svg_content.encode("utf-8") + return svg_content.encode() @beartype @@ -42,19 +42,19 @@ def generate_png(instance_id: str) -> bytes: PNG image data as bytes. """ # Create a simple 200x200 image - img = Image.new("RGB", (200, 200), color="white") - draw = ImageDraw.Draw(img) + img = Image.new(mode="RGB", size=(200, 200)) + draw = ImageDraw.Draw(im=img) # Draw a border - draw.rectangle([0, 0, 199, 199], outline="black", width=2) + draw.rectangle(xy=[0, 0, 199, 199], outline="black") # Add text - draw.text((100, 80), "VuMark Mock", fill="black", anchor="mm") - draw.text((100, 110), instance_id[:20], fill="black", anchor="mm") + draw.text(xy=(100, 80), text="VuMark Mock", fill="black") + draw.text(xy=(100, 110), text=instance_id[:20], fill="black") # Save to bytes buffer = io.BytesIO() - img.save(buffer, format="PNG") + img.save(fp=buffer, format="PNG") return buffer.getvalue() @@ -111,4 +111,4 @@ def generate_pdf(instance_id: str) -> bytes: startxref 496 %%EOF""" - return pdf_content.encode("latin-1") + return pdf_content.encode() diff --git a/src/mock_vws/_vumark_validators/__init__.py b/src/mock_vws/_vumark_validators/__init__.py index 2f65eb2f6..498ce266f 100644 --- a/src/mock_vws/_vumark_validators/__init__.py +++ b/src/mock_vws/_vumark_validators/__init__.py @@ -27,7 +27,7 @@ def validate_accept_header(request_headers: Mapping[str, str]) -> str: Raises: InvalidAcceptHeaderError: The Accept header is missing or invalid. """ - accept_header = request_headers.get("Accept", "") + accept_header: str = request_headers.get("Accept") or "" if accept_header not in VALID_ACCEPT_HEADERS: raise InvalidAcceptHeaderError return accept_header diff --git a/tests/mock_vws/test_vumark_generation.py b/tests/mock_vws/test_vumark_generation.py index 9152fe830..d3f146568 100644 --- a/tests/mock_vws/test_vumark_generation.py +++ b/tests/mock_vws/test_vumark_generation.py @@ -36,7 +36,7 @@ def _generate_vumark_instance( The response from the API. """ request_path = f"/targets/{target_id}/instances" - content = json.dumps(obj={"instance_id": instance_id}).encode("utf-8") + content = json.dumps(obj={"instance_id": instance_id}).encode() date = rfc_1123_date() content_type = "application/json" @@ -53,7 +53,7 @@ def _generate_vumark_instance( headers = { "Authorization": authorization_string, "Date": date, - "Content-Length": str(len(content)), + "Content-Length": str(object=len(content)), "Content-Type": content_type, "Accept": accept, } @@ -81,7 +81,7 @@ def _generate_vumark_instance_with_body( The response from the API. """ request_path = f"/targets/{target_id}/instances" - content = json.dumps(obj=body).encode("utf-8") + content = json.dumps(obj=body).encode() date = rfc_1123_date() content_type = "application/json" @@ -98,7 +98,7 @@ def _generate_vumark_instance_with_body( headers = { "Authorization": authorization_string, "Date": date, - "Content-Length": str(len(content)), + "Content-Length": str(object=len(content)), "Content-Type": content_type, "Accept": accept, } From f193a40763b097d1d1f685b2ff5f5d2f0da5bb28 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 28 Jan 2026 08:35:32 +0000 Subject: [PATCH 03/13] Fix pylint spelling errors in VuMark implementation Add 'svg' to spelling dictionary and fix 'pdf' to 'PDF' in comments to pass pylint spell checking. Co-Authored-By: Claude Haiku 4.5 --- spelling_private_dict.txt | 1 + src/mock_vws/_flask_server/vws.py | 2 +- src/mock_vws/_requests_mock_server/mock_web_services_api.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/spelling_private_dict.txt b/spelling_private_dict.txt index 365309073..354d8c044 100644 --- a/spelling_private_dict.txt +++ b/spelling_private_dict.txt @@ -100,6 +100,7 @@ resjsonarr rfc rgb str +svg timestamp todo travis diff --git a/src/mock_vws/_flask_server/vws.py b/src/mock_vws/_flask_server/vws.py index f6434f29a..388883285 100644 --- a/src/mock_vws/_flask_server/vws.py +++ b/src/mock_vws/_flask_server/vws.py @@ -681,7 +681,7 @@ def generate_vumark_instance(target_id: str) -> Response: elif accept_header == "image/png": content = generate_png(instance_id=instance_id) content_type = "image/png" - else: # application/pdf + else: # PDF content = generate_pdf(instance_id=instance_id) content_type = "application/pdf" 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 f908a28e8..4a8f1d0fa 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 @@ -773,7 +773,7 @@ def generate_vumark_instance( elif accept_header == "image/png": content = generate_png(instance_id=instance_id) content_type = "image/png" - else: # application/pdf + else: # PDF content = generate_pdf(instance_id=instance_id) content_type = "application/pdf" From dcb8ec35ca2b22a98da7dbf742bbafc48eca8330 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 15 Feb 2026 07:55:12 +0000 Subject: [PATCH 04/13] Refactor VuMark tests to use verify_mock_vuforia fixture Use the standard test pattern with the verify_mock_vuforia fixture so tests run against all backends (real, mock, docker). Add test classes to the CI matrix in test.yml. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test.yml | 4 + tests/mock_vws/test_vumark_generation.py | 437 +++++++++++------------ 2 files changed, 204 insertions(+), 237 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 386db8031..52634b6fe 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -111,6 +111,10 @@ jobs: - tests/mock_vws/test_update_target.py::TestUpdate - tests/mock_vws/test_update_target.py::TestWidth - tests/mock_vws/test_update_target.py::TestInactiveProject + - tests/mock_vws/test_vumark_generation.py::TestSuccessfulGeneration + - tests/mock_vws/test_vumark_generation.py::TestInvalidAcceptHeader + - tests/mock_vws/test_vumark_generation.py::TestInvalidInstanceId + - tests/mock_vws/test_vumark_generation.py::TestResponseHeaders - tests/mock_vws/test_requests_mock_usage.py - tests/mock_vws/test_flask_app_usage.py - tests/mock_vws/test_docker.py diff --git a/tests/mock_vws/test_vumark_generation.py b/tests/mock_vws/test_vumark_generation.py index d3f146568..f98e69c29 100644 --- a/tests/mock_vws/test_vumark_generation.py +++ b/tests/mock_vws/test_vumark_generation.py @@ -10,7 +10,6 @@ from vws import VWS from vws_auth_tools import authorization_header, rfc_1123_date -from mock_vws import MockVWS from mock_vws._constants import ResultCodes from mock_vws.database import VuforiaDatabase @@ -107,105 +106,95 @@ def _generate_vumark_instance_with_body( return requests.post(url=url, data=content, headers=headers, timeout=30) +@pytest.mark.usefixtures("verify_mock_vuforia") class TestSuccessfulGeneration: """Tests for successful VuMark instance generation.""" @staticmethod - def test_svg_generation(image_file_failed_state: io.BytesIO) -> None: + def test_svg_generation( + image_file_failed_state: io.BytesIO, + vuforia_database: VuforiaDatabase, + vws_client: VWS, + ) -> None: """SVG images can be generated.""" - database = VuforiaDatabase() - with MockVWS() as mock: - mock.add_database(database=database) - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image_file_failed_state, - active_flag=True, - application_metadata=None, - ) - - response = _generate_vumark_instance( - database=database, - target_id=target_id, - instance_id="test123", - accept="image/svg+xml", - ) - - assert response.status_code == HTTPStatus.OK - assert response.headers["Content-Type"] == "image/svg+xml" - # Verify the response contains valid SVG - assert b" None: + def test_png_generation( + image_file_failed_state: io.BytesIO, + vuforia_database: VuforiaDatabase, + vws_client: VWS, + ) -> None: """PNG images can be generated.""" - database = VuforiaDatabase() - with MockVWS() as mock: - mock.add_database(database=database) - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image_file_failed_state, - active_flag=True, - application_metadata=None, - ) - - response = _generate_vumark_instance( - database=database, - target_id=target_id, - instance_id="test456", - accept="image/png", - ) - - assert response.status_code == HTTPStatus.OK - assert response.headers["Content-Type"] == "image/png" - # Verify the response contains PNG magic bytes - assert response.content[:8] == b"\x89PNG\r\n\x1a\n" + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + response = _generate_vumark_instance( + database=vuforia_database, + target_id=target_id, + instance_id="test456", + accept="image/png", + ) + + assert response.status_code == HTTPStatus.OK + assert response.headers["Content-Type"] == "image/png" + # Verify the response contains PNG magic bytes + assert response.content[:8] == b"\x89PNG\r\n\x1a\n" @staticmethod - def test_pdf_generation(image_file_failed_state: io.BytesIO) -> None: + def test_pdf_generation( + image_file_failed_state: io.BytesIO, + vuforia_database: VuforiaDatabase, + vws_client: VWS, + ) -> None: """PDF documents can be generated.""" - database = VuforiaDatabase() - with MockVWS() as mock: - mock.add_database(database=database) - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image_file_failed_state, - active_flag=True, - application_metadata=None, - ) - - response = _generate_vumark_instance( - database=database, - target_id=target_id, - instance_id="test789", - accept="application/pdf", - ) - - assert response.status_code == HTTPStatus.OK - assert response.headers["Content-Type"] == "application/pdf" - # Verify the response contains PDF header - assert response.content.startswith(b"%PDF-") - - + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + response = _generate_vumark_instance( + database=vuforia_database, + target_id=target_id, + instance_id="test789", + accept="application/pdf", + ) + + assert response.status_code == HTTPStatus.OK + assert response.headers["Content-Type"] == "application/pdf" + # Verify the response contains PDF header + assert response.content.startswith(b"%PDF-") + + +@pytest.mark.usefixtures("verify_mock_vuforia") class TestInvalidAcceptHeader: """Tests for invalid Accept headers.""" @@ -229,40 +218,35 @@ class TestInvalidAcceptHeader: ) def test_invalid_accept_header( image_file_failed_state: io.BytesIO, + vuforia_database: VuforiaDatabase, + vws_client: VWS, accept_header: str, ) -> None: """An error is returned for invalid Accept headers.""" - database = VuforiaDatabase() - with MockVWS() as mock: - mock.add_database(database=database) - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image_file_failed_state, - active_flag=True, - application_metadata=None, - ) - - response = _generate_vumark_instance( - database=database, - target_id=target_id, - instance_id="test123", - accept=accept_header, - ) - - assert response.status_code == HTTPStatus.BAD_REQUEST - response_json = response.json() - assert ( - response_json["result_code"] - == ResultCodes.INVALID_ACCEPT_HEADER.value - ) - - + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + response = _generate_vumark_instance( + database=vuforia_database, + target_id=target_id, + instance_id="test123", + accept=accept_header, + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + response_json = response.json() + assert ( + response_json["result_code"] + == ResultCodes.INVALID_ACCEPT_HEADER.value + ) + + +@pytest.mark.usefixtures("verify_mock_vuforia") class TestInvalidInstanceId: """Tests for invalid instance IDs.""" @@ -280,144 +264,123 @@ class TestInvalidInstanceId: ) def test_invalid_instance_id( image_file_failed_state: io.BytesIO, + vuforia_database: VuforiaDatabase, + vws_client: VWS, instance_id: str | None, ) -> None: """An error is returned for invalid instance IDs.""" - database = VuforiaDatabase() - with MockVWS() as mock: - mock.add_database(database=database) - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image_file_failed_state, - active_flag=True, - application_metadata=None, - ) - - response = _generate_vumark_instance( - database=database, - target_id=target_id, - instance_id=instance_id, - accept="image/svg+xml", - ) - - assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - response_json = response.json() - assert ( - response_json["result_code"] - == ResultCodes.INVALID_INSTANCE_ID.value - ) + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + response = _generate_vumark_instance( + database=vuforia_database, + target_id=target_id, + instance_id=instance_id, + accept="image/svg+xml", + ) + + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + response_json = response.json() + assert ( + response_json["result_code"] + == ResultCodes.INVALID_INSTANCE_ID.value + ) @staticmethod def test_missing_instance_id( image_file_failed_state: io.BytesIO, + vuforia_database: VuforiaDatabase, + vws_client: VWS, ) -> None: """An error is returned when instance_id is missing from the body. The key validator rejects the request with a "Fail" error (400) because instance_id is a required field. """ - database = VuforiaDatabase() - with MockVWS() as mock: - mock.add_database(database=database) - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image_file_failed_state, - active_flag=True, - application_metadata=None, - ) - - response = _generate_vumark_instance_with_body( - database=database, - target_id=target_id, - body={}, # No instance_id - accept="image/svg+xml", - ) - - # Missing required keys return a "Fail" error - assert response.status_code == HTTPStatus.BAD_REQUEST - response_json = response.json() - assert response_json["result_code"] == ResultCodes.FAIL.value + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + response = _generate_vumark_instance_with_body( + database=vuforia_database, + target_id=target_id, + body={}, # No instance_id + accept="image/svg+xml", + ) + + # Missing required keys return a "Fail" error + assert response.status_code == HTTPStatus.BAD_REQUEST + response_json = response.json() + assert response_json["result_code"] == ResultCodes.FAIL.value @staticmethod def test_integer_instance_id( image_file_failed_state: io.BytesIO, + vuforia_database: VuforiaDatabase, + vws_client: VWS, ) -> None: """An error is returned when instance_id is not a string.""" - database = VuforiaDatabase() - with MockVWS() as mock: - mock.add_database(database=database) - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image_file_failed_state, - active_flag=True, - application_metadata=None, - ) - - response = _generate_vumark_instance( - database=database, - target_id=target_id, - instance_id=12345, - accept="image/svg+xml", - ) - - assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - response_json = response.json() - assert ( - response_json["result_code"] - == ResultCodes.INVALID_INSTANCE_ID.value - ) - - + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + response = _generate_vumark_instance( + database=vuforia_database, + target_id=target_id, + instance_id=12345, + accept="image/svg+xml", + ) + + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + response_json = response.json() + assert ( + response_json["result_code"] + == ResultCodes.INVALID_INSTANCE_ID.value + ) + + +@pytest.mark.usefixtures("verify_mock_vuforia") class TestResponseHeaders: """Tests for response headers.""" @staticmethod - def test_response_headers(image_file_failed_state: io.BytesIO) -> None: + def test_response_headers( + image_file_failed_state: io.BytesIO, + vuforia_database: VuforiaDatabase, + vws_client: VWS, + ) -> None: """The response includes expected headers.""" - database = VuforiaDatabase() - with MockVWS() as mock: - mock.add_database(database=database) - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image_file_failed_state, - active_flag=True, - application_metadata=None, - ) - - response = _generate_vumark_instance( - database=database, - target_id=target_id, - instance_id="test123", - accept="image/svg+xml", - ) - - assert response.status_code == HTTPStatus.OK - assert response.headers["Connection"] == "keep-alive" - assert response.headers["server"] == "envoy" - assert response.headers["Content-Type"] == "image/svg+xml" - assert "Content-Length" in response.headers - assert "Date" in response.headers + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + response = _generate_vumark_instance( + database=vuforia_database, + target_id=target_id, + instance_id="test123", + accept="image/svg+xml", + ) + + assert response.status_code == HTTPStatus.OK + assert response.headers["Connection"] == "keep-alive" + assert response.headers["server"] == "envoy" + assert response.headers["Content-Type"] == "image/svg+xml" + assert "Content-Length" in response.headers + assert "Date" in response.headers From a24762c60c84b94743f2dac8d359f4f7c40a1ac2 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 15 Feb 2026 08:58:00 +0000 Subject: [PATCH 05/13] Fix BugBot issues in VuMark image generators - PNG: Use white background (was black, making text invisible) - SVG: Escape instance_id with xml.sax.saxutils.escape to prevent malformed XML from special characters - PDF: Compute stream length, xref offsets, and startxref dynamically instead of hardcoding them. Escape PDF string literal characters. Co-Authored-By: Claude Opus 4.6 --- src/mock_vws/_vumark_generators.py | 113 ++++++++++++++++------------- 1 file changed, 64 insertions(+), 49 deletions(-) diff --git a/src/mock_vws/_vumark_generators.py b/src/mock_vws/_vumark_generators.py index 933da3e71..407573932 100644 --- a/src/mock_vws/_vumark_generators.py +++ b/src/mock_vws/_vumark_generators.py @@ -1,6 +1,7 @@ """Generate placeholder VuMark images for the mock API.""" import io +from xml.sax.saxutils import escape from beartype import beartype from PIL import Image, ImageDraw @@ -16,6 +17,7 @@ def generate_svg(instance_id: str) -> bytes: Returns: SVG image data as bytes. """ + escaped_id = escape(data=instance_id) svg_content = ( '' ' bytes: 'VuMark Mock' '{instance_id}' + f'font-size="10">{escaped_id}' "" ) return svg_content.encode() @@ -41,18 +43,13 @@ def generate_png(instance_id: str) -> bytes: Returns: PNG image data as bytes. """ - # Create a simple 200x200 image - img = Image.new(mode="RGB", size=(200, 200)) + img = Image.new(mode="RGB", size=(200, 200), color=(255, 255, 255)) draw = ImageDraw.Draw(im=img) - # Draw a border draw.rectangle(xy=[0, 0, 199, 199], outline="black") - - # Add text draw.text(xy=(100, 80), text="VuMark Mock", fill="black") draw.text(xy=(100, 110), text=instance_id[:20], fill="black") - # Save to bytes buffer = io.BytesIO() img.save(fp=buffer, format="PNG") return buffer.getvalue() @@ -70,45 +67,63 @@ def generate_pdf(instance_id: str) -> bytes: Returns: PDF document data as bytes. """ - # Create a minimal valid PDF - # This is a simple PDF 1.4 document with one page and text - pdf_content = f"""%PDF-1.4 -1 0 obj -<< /Type /Catalog /Pages 2 0 R >> -endobj -2 0 obj -<< /Type /Pages /Kids [3 0 R] /Count 1 >> -endobj -3 0 obj -<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R -/Resources << /Font << /F1 5 0 R >> >> >> -endobj -4 0 obj -<< /Length 100 >> -stream -BT -/F1 12 Tf -50 150 Td -(VuMark Mock) Tj -0 -20 Td -({instance_id}) Tj -ET -endstream -endobj -5 0 obj -<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> -endobj -xref -0 6 -0000000000 65535 f -0000000009 00000 n -0000000058 00000 n -0000000115 00000 n -0000000266 00000 n -0000000416 00000 n -trailer -<< /Size 6 /Root 1 0 R >> -startxref -496 -%%EOF""" - return pdf_content.encode() + # Escape parentheses in instance_id for PDF string literals. + safe_id = instance_id.replace("\\", "\\\\") + safe_id = safe_id.replace("(", "\\(") + safe_id = safe_id.replace(")", "\\)") + + # Build the stream content first so we can measure its length. + stream = ( + "BT\n" + "/F1 12 Tf\n" + "50 150 Td\n" + "(VuMark Mock) Tj\n" + "0 -20 Td\n" + f"({safe_id}) Tj\n" + "ET\n" + ) + stream_bytes = stream.encode() + stream_length = len(stream_bytes) + + # Build each object, tracking byte offsets for the xref table. + obj1 = "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n" + obj2 = "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" + obj3 = ( + "3 0 obj\n" + "<< /Type /Page /Parent 2 0 R" + " /MediaBox [0 0 200 200]" + " /Contents 4 0 R" + " /Resources << /Font << /F1 5 0 R >> >> >>\n" + "endobj\n" + ) + obj4 = ( + f"4 0 obj\n<< /Length {stream_length} >>\n" + f"stream\n{stream}endstream\nendobj\n" + ) + obj5 = ( + "5 0 obj\n" + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\n" + "endobj\n" + ) + + header = "%PDF-1.4\n" + offsets: list[int] = [] + body = header + for obj in (obj1, obj2, obj3, obj4, obj5): + offsets.append(len(body.encode())) + body += obj + + xref_offset = len(body.encode()) + xref = f"xref\n0 {len(offsets) + 1}\n0000000000 65535 f \n" + for offset in offsets: + xref += f"{offset:010d} 00000 n \n" + + trailer = ( + "trailer\n" + f"<< /Size {len(offsets) + 1} /Root 1 0 R >>\n" + "startxref\n" + f"{xref_offset}\n" + "%%EOF" + ) + + return (body + xref + trailer).encode() From e2db9b585bd3066d3da7b19e571db33c279e6615 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 15 Feb 2026 09:12:31 +0000 Subject: [PATCH 06/13] Remove unnecessary .encode() on error paths in VuMark endpoint Return exc.response_text as str (consistent with all other endpoints) instead of encoding to bytes on error paths. Co-Authored-By: Claude Opus 4.6 --- src/mock_vws/_requests_mock_server/mock_web_services_api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 4a8f1d0fa..eb725ca72 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 @@ -747,7 +747,7 @@ def generate_vumark_instance( databases=self._target_manager.databases, ) except ValidatorError as exc: - return exc.status_code, exc.headers, exc.response_text.encode() + return exc.status_code, exc.headers, exc.response_text # Validate Accept header try: @@ -755,7 +755,7 @@ def generate_vumark_instance( request_headers=request.headers, ) except ValidatorError as exc: - return exc.status_code, exc.headers, exc.response_text.encode() + return exc.status_code, exc.headers, exc.response_text # Extract and validate instance_id from request body request_json: dict[str, Any] = json.loads(s=request.body or b"{}") @@ -764,7 +764,7 @@ def generate_vumark_instance( instance_id=request_json.get("instance_id"), ) except ValidatorError as exc: - return exc.status_code, exc.headers, exc.response_text.encode() + return exc.status_code, exc.headers, exc.response_text # Generate the appropriate image format if accept_header == "image/svg+xml": From 0edf26a61904e57a40dc9640839536d941002efc Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 15 Feb 2026 09:29:59 +0000 Subject: [PATCH 07/13] Use mock_only_vuforia for VuMark tests Real Vuforia returns 422 InvalidTargetType because the test targets are regular image targets, not VuMark targets. Use mock_only_vuforia to skip real Vuforia backend. Co-Authored-By: Claude Opus 4.6 --- tests/mock_vws/test_vumark_generation.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/mock_vws/test_vumark_generation.py b/tests/mock_vws/test_vumark_generation.py index f98e69c29..3a7d437ae 100644 --- a/tests/mock_vws/test_vumark_generation.py +++ b/tests/mock_vws/test_vumark_generation.py @@ -106,7 +106,7 @@ def _generate_vumark_instance_with_body( return requests.post(url=url, data=content, headers=headers, timeout=30) -@pytest.mark.usefixtures("verify_mock_vuforia") +@pytest.mark.usefixtures("mock_only_vuforia") class TestSuccessfulGeneration: """Tests for successful VuMark instance generation.""" @@ -194,7 +194,7 @@ def test_pdf_generation( assert response.content.startswith(b"%PDF-") -@pytest.mark.usefixtures("verify_mock_vuforia") +@pytest.mark.usefixtures("mock_only_vuforia") class TestInvalidAcceptHeader: """Tests for invalid Accept headers.""" @@ -246,7 +246,7 @@ def test_invalid_accept_header( ) -@pytest.mark.usefixtures("verify_mock_vuforia") +@pytest.mark.usefixtures("mock_only_vuforia") class TestInvalidInstanceId: """Tests for invalid instance IDs.""" @@ -352,7 +352,7 @@ def test_integer_instance_id( ) -@pytest.mark.usefixtures("verify_mock_vuforia") +@pytest.mark.usefixtures("mock_only_vuforia") class TestResponseHeaders: """Tests for response headers.""" From 3c6ec7bd26e3c3ec9979d73467029f7c19d9955a Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 15 Feb 2026 11:03:32 +0000 Subject: [PATCH 08/13] Add VuMark validation and related test updates --- .github/workflows/test.yml | 2 + src/mock_vws/_constants.py | 1 + src/mock_vws/_flask_server/target_manager.py | 6 + src/mock_vws/_flask_server/vws.py | 9 +- .../mock_web_services_api.py | 26 ++++ .../_services_validators/exceptions.py | 40 ++++++ src/mock_vws/_vumark_validators/__init__.py | 32 +++++ src/mock_vws/database.py | 4 + src/mock_vws/target.py | 5 + tests/mock_vws/fixtures/vuforia_backends.py | 1 + tests/mock_vws/test_vumark_generation.py | 123 ++++++++++++++++-- 11 files changed, 239 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 52634b6fe..9a90a811e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -114,6 +114,8 @@ jobs: - tests/mock_vws/test_vumark_generation.py::TestSuccessfulGeneration - tests/mock_vws/test_vumark_generation.py::TestInvalidAcceptHeader - tests/mock_vws/test_vumark_generation.py::TestInvalidInstanceId + - tests/mock_vws/test_vumark_generation.py::TestInvalidTargetType + - tests/mock_vws/test_vumark_generation.py::TestTargetStatusNotSuccess - tests/mock_vws/test_vumark_generation.py::TestResponseHeaders - tests/mock_vws/test_requests_mock_usage.py - tests/mock_vws/test_flask_app_usage.py diff --git a/src/mock_vws/_constants.py b/src/mock_vws/_constants.py index ca65bca9a..32e0cc582 100644 --- a/src/mock_vws/_constants.py +++ b/src/mock_vws/_constants.py @@ -40,6 +40,7 @@ class ResultCodes(Enum): TOO_MANY_REQUESTS = "TooManyRequests" INVALID_INSTANCE_ID = "InvalidInstanceId" INVALID_ACCEPT_HEADER = "InvalidAcceptHeader" + INVALID_TARGET_TYPE = "InvalidTargetType" @beartype diff --git a/src/mock_vws/_flask_server/target_manager.py b/src/mock_vws/_flask_server/target_manager.py index 90f3341eb..282aad9c7 100644 --- a/src/mock_vws/_flask_server/target_manager.py +++ b/src/mock_vws/_flask_server/target_manager.py @@ -159,6 +159,10 @@ def create_database() -> Response: "state_name", random_database.state.name, ) + default_target_type = request_json.get( + "default_target_type", + random_database.default_target_type, + ) state = States[state_name] @@ -168,6 +172,7 @@ def create_database() -> Response: client_access_key=client_access_key, client_secret_key=client_secret_key, database_name=database_name, + default_target_type=default_target_type, state=state, ) try: @@ -210,6 +215,7 @@ def create_target(database_name: str) -> Response: processing_time_seconds=request_json["processing_time_seconds"], application_metadata=request_json["application_metadata"], target_id=request_json["target_id"], + target_type=request_json.get("target_type", "cloud_target"), target_tracking_rater=target_tracking_rater, ) database.targets.add(target) diff --git a/src/mock_vws/_flask_server/vws.py b/src/mock_vws/_flask_server/vws.py index 388883285..fe352e7f5 100644 --- a/src/mock_vws/_flask_server/vws.py +++ b/src/mock_vws/_flask_server/vws.py @@ -35,6 +35,8 @@ from mock_vws._vumark_validators import ( validate_accept_header, validate_instance_id, + validate_target_status_success, + validate_target_type, ) from mock_vws.database import VuforiaDatabase from mock_vws.image_matchers import ( @@ -190,6 +192,7 @@ def add_target() -> Response: processing_time_seconds=settings.processing_time_seconds, application_metadata=request_json.get("application_metadata"), target_tracking_rater=target_tracking_rater, + target_type=database.default_target_type, ) databases_url = f"{settings.target_manager_base_url}/databases" @@ -669,10 +672,12 @@ def generate_vumark_instance(target_id: str) -> Response: instance_id=request_json.get("instance_id"), ) - # Verify target exists (raises ValueError if not found) - (_,) = ( + # Verify target exists and validate type/status + (target,) = ( target for target in database.targets if target.target_id == target_id ) + validate_target_type(target=target) + validate_target_status_success(target=target) # Generate the appropriate image format if accept_header == "image/svg+xml": 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 eb725ca72..a7ab2e983 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 @@ -36,6 +36,8 @@ from mock_vws._vumark_validators import ( validate_accept_header, validate_instance_id, + validate_target_status_success, + validate_target_type, ) from mock_vws.image_matchers import ImageMatcher from mock_vws.target import Target @@ -196,6 +198,7 @@ def add_target(self, request: PreparedRequest) -> _ResponseType: processing_time_seconds=self._processing_time_seconds, application_metadata=application_metadata, target_tracking_rater=self._target_tracking_rater, + target_type=database.default_target_type, ) database.targets.add(new_target) @@ -766,6 +769,29 @@ def generate_vumark_instance( except ValidatorError as exc: return exc.status_code, exc.headers, exc.response_text + # Look up the target and validate type and status + database = get_database_matching_server_keys( + request_headers=request.headers, + request_body=_body_bytes(request=request), + request_method=request.method or "", + request_path=request.path_url, + databases=self._target_manager.databases, + ) + + split_path = request.path_url.split(sep="/") + target_id = split_path[-2] + target = database.get_target(target_id=target_id) + + try: + validate_target_type(target=target) + except ValidatorError as exc: + return exc.status_code, exc.headers, exc.response_text + + try: + validate_target_status_success(target=target) + except ValidatorError as exc: + return exc.status_code, exc.headers, exc.response_text + # Generate the appropriate image format if accept_header == "image/svg+xml": content = generate_svg(instance_id=instance_id) diff --git a/src/mock_vws/_services_validators/exceptions.py b/src/mock_vws/_services_validators/exceptions.py index 5173ae1b1..29d53ebf5 100644 --- a/src/mock_vws/_services_validators/exceptions.py +++ b/src/mock_vws/_services_validators/exceptions.py @@ -606,6 +606,46 @@ def __init__(self) -> None: } +@beartype +class InvalidTargetTypeError(ValidatorError): + """Exception raised when a non-VuMark target is used for VuMark + generation. + """ + + def __init__(self) -> None: + """ + Attributes: + status_code: The status code to use in a response if this is + raised. + response_text: The response text to use in a response if this + is + raised. + """ + super().__init__() + self.status_code = HTTPStatus.UNPROCESSABLE_ENTITY + body = { + "transaction_id": uuid.uuid4().hex, + "result_code": ResultCodes.INVALID_TARGET_TYPE.value, + } + self.response_text = json_dump(body=body) + date = email.utils.formatdate( + timeval=None, + localtime=False, + usegmt=True, + ) + self.headers = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "server": "envoy", + "Date": date, + "x-envoy-upstream-service-time": "5", + "Content-Length": str(object=len(self.response_text)), + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + } + + @beartype class InvalidAcceptHeaderError(ValidatorError): """Exception raised when an invalid Accept header is given for VuMark diff --git a/src/mock_vws/_vumark_validators/__init__.py b/src/mock_vws/_vumark_validators/__init__.py index 498ce266f..fab86ec76 100644 --- a/src/mock_vws/_vumark_validators/__init__.py +++ b/src/mock_vws/_vumark_validators/__init__.py @@ -4,10 +4,14 @@ from beartype import beartype +from mock_vws._constants import TargetStatuses from mock_vws._services_validators.exceptions import ( InvalidAcceptHeaderError, InvalidInstanceIdError, + InvalidTargetTypeError, + TargetStatusNotSuccessError, ) +from mock_vws.target import Target VALID_ACCEPT_HEADERS = frozenset( {"image/svg+xml", "image/png", "application/pdf"} @@ -56,3 +60,31 @@ def validate_instance_id(instance_id: object) -> str: if not instance_id or not isinstance(instance_id, str): raise InvalidInstanceIdError return instance_id + + +@beartype +def validate_target_type(target: Target) -> None: + """Validate that the target is a VuMark target. + + Args: + target: The target to validate. + + Raises: + InvalidTargetTypeError: The target is not a VuMark target. + """ + if target.target_type != "vumark": + raise InvalidTargetTypeError + + +@beartype +def validate_target_status_success(target: Target) -> None: + """Validate that the target has a success status. + + Args: + target: The target to validate. + + Raises: + TargetStatusNotSuccessError: The target is not in success status. + """ + if target.status != TargetStatuses.SUCCESS.value: + raise TargetStatusNotSuccessError diff --git a/src/mock_vws/database.py b/src/mock_vws/database.py index 2e28a9f61..c7f8b4cc5 100644 --- a/src/mock_vws/database.py +++ b/src/mock_vws/database.py @@ -21,6 +21,7 @@ class DatabaseDict(TypedDict): server_secret_key: str client_access_key: str client_secret_key: str + default_target_type: str state_name: str targets: Iterable[TargetDict] @@ -62,6 +63,7 @@ class VuforiaDatabase: # In particular, we might want to inspect the ``database`` object's targets # as they change via API requests. targets: set[Target] = field(default_factory=set[Target], hash=False) + default_target_type: str = "cloud_target" state: States = States.WORKING request_quota: int = 100000 @@ -80,6 +82,7 @@ def to_dict(self) -> DatabaseDict: "server_secret_key": self.server_secret_key, "client_access_key": self.client_access_key, "client_secret_key": self.client_secret_key, + "default_target_type": self.default_target_type, "state_name": self.state.name, "targets": targets, } @@ -100,6 +103,7 @@ def from_dict(cls, database_dict: DatabaseDict) -> Self: server_secret_key=database_dict["server_secret_key"], client_access_key=database_dict["client_access_key"], client_secret_key=database_dict["client_secret_key"], + default_target_type=database_dict["default_target_type"], state=States[database_dict["state_name"]], targets={ Target.from_dict(target_dict=target_dict) diff --git a/src/mock_vws/target.py b/src/mock_vws/target.py index afd8f20b1..dbbbb35d7 100644 --- a/src/mock_vws/target.py +++ b/src/mock_vws/target.py @@ -29,6 +29,7 @@ class TargetDict(TypedDict): processing_time_seconds: float application_metadata: str | None target_id: str + target_type: str last_modified_date: str delete_date_optional: str | None upload_date: str @@ -69,6 +70,7 @@ class Target: previous_month_recos: int = 0 reco_rating: str = "" target_id: str = field(default_factory=_random_hex) + target_type: str = "cloud_target" total_recos: int = 0 upload_date: datetime.datetime = field(default_factory=_time_now) @@ -156,6 +158,7 @@ def from_dict(cls, target_dict: TargetDict) -> Self: processing_time_seconds = target_dict["processing_time_seconds"] application_metadata = target_dict["application_metadata"] target_id = target_dict["target_id"] + target_type = target_dict["target_type"] delete_date_optional = target_dict["delete_date_optional"] if delete_date_optional is None: delete_date = None @@ -175,6 +178,7 @@ def from_dict(cls, target_dict: TargetDict) -> Self: ) return cls( target_id=target_id, + target_type=target_type, name=name, active_flag=active_flag, width=width, @@ -203,6 +207,7 @@ def to_dict(self) -> TargetDict: "processing_time_seconds": float(self.processing_time_seconds), "application_metadata": self.application_metadata, "target_id": self.target_id, + "target_type": self.target_type, "last_modified_date": self.last_modified_date.isoformat(), "delete_date_optional": delete_date, "upload_date": self.upload_date.isoformat(), diff --git a/tests/mock_vws/fixtures/vuforia_backends.py b/tests/mock_vws/fixtures/vuforia_backends.py index 39013b5b9..7b3528304 100644 --- a/tests/mock_vws/fixtures/vuforia_backends.py +++ b/tests/mock_vws/fixtures/vuforia_backends.py @@ -86,6 +86,7 @@ def _enable_use_mock_vuforia( server_secret_key=working_database.server_secret_key, client_access_key=working_database.client_access_key, client_secret_key=working_database.client_secret_key, + default_target_type=working_database.default_target_type, ) inactive_database = VuforiaDatabase( diff --git a/tests/mock_vws/test_vumark_generation.py b/tests/mock_vws/test_vumark_generation.py index 3a7d437ae..9ff64bc18 100644 --- a/tests/mock_vws/test_vumark_generation.py +++ b/tests/mock_vws/test_vumark_generation.py @@ -1,5 +1,6 @@ """Tests for the VuMark Generation API endpoint.""" +import dataclasses import io import json import uuid @@ -110,9 +111,20 @@ def _generate_vumark_instance_with_body( class TestSuccessfulGeneration: """Tests for successful VuMark instance generation.""" + @pytest.fixture + def vuforia_database( + self, + vuforia_database: VuforiaDatabase, + ) -> VuforiaDatabase: + """Override to create a VuMark database.""" + return dataclasses.replace( + vuforia_database, + default_target_type="vumark", + ) + @staticmethod def test_svg_generation( - image_file_failed_state: io.BytesIO, + image_file_success_state_low_rating: io.BytesIO, vuforia_database: VuforiaDatabase, vws_client: VWS, ) -> None: @@ -120,10 +132,11 @@ def test_svg_generation( target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, - image=image_file_failed_state, + image=image_file_success_state_low_rating, active_flag=True, application_metadata=None, ) + vws_client.wait_for_target_processed(target_id=target_id) response = _generate_vumark_instance( database=vuforia_database, @@ -141,7 +154,7 @@ def test_svg_generation( @staticmethod def test_png_generation( - image_file_failed_state: io.BytesIO, + image_file_success_state_low_rating: io.BytesIO, vuforia_database: VuforiaDatabase, vws_client: VWS, ) -> None: @@ -149,10 +162,11 @@ def test_png_generation( target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, - image=image_file_failed_state, + image=image_file_success_state_low_rating, active_flag=True, application_metadata=None, ) + vws_client.wait_for_target_processed(target_id=target_id) response = _generate_vumark_instance( database=vuforia_database, @@ -168,7 +182,7 @@ def test_png_generation( @staticmethod def test_pdf_generation( - image_file_failed_state: io.BytesIO, + image_file_success_state_low_rating: io.BytesIO, vuforia_database: VuforiaDatabase, vws_client: VWS, ) -> None: @@ -176,10 +190,11 @@ def test_pdf_generation( target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, - image=image_file_failed_state, + image=image_file_success_state_low_rating, active_flag=True, application_metadata=None, ) + vws_client.wait_for_target_processed(target_id=target_id) response = _generate_vumark_instance( database=vuforia_database, @@ -352,13 +367,104 @@ def test_integer_instance_id( ) +@pytest.mark.usefixtures("mock_only_vuforia") +class TestInvalidTargetType: + """Tests for non-VuMark target types.""" + + @staticmethod + def test_cloud_target( + image_file_failed_state: io.BytesIO, + vuforia_database: VuforiaDatabase, + vws_client: VWS, + ) -> None: + """An error is returned for non-VuMark targets.""" + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + response = _generate_vumark_instance( + database=vuforia_database, + target_id=target_id, + instance_id="test123", + accept="image/svg+xml", + ) + + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + response_json = response.json() + assert ( + response_json["result_code"] + == ResultCodes.INVALID_TARGET_TYPE.value + ) + + +@pytest.mark.usefixtures("mock_only_vuforia") +class TestTargetStatusNotSuccess: + """Tests for targets not in success status.""" + + @pytest.fixture + def vuforia_database( + self, + vuforia_database: VuforiaDatabase, + ) -> VuforiaDatabase: + """Override to create a VuMark database.""" + return dataclasses.replace( + vuforia_database, + default_target_type="vumark", + ) + + @staticmethod + def test_failed_target( + image_file_failed_state: io.BytesIO, + vuforia_database: VuforiaDatabase, + vws_client: VWS, + ) -> None: + """An error is returned when target status is not success.""" + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id) + + response = _generate_vumark_instance( + database=vuforia_database, + target_id=target_id, + instance_id="test123", + accept="image/svg+xml", + ) + + assert response.status_code == HTTPStatus.FORBIDDEN + response_json = response.json() + assert ( + response_json["result_code"] + == ResultCodes.TARGET_STATUS_NOT_SUCCESS.value + ) + + @pytest.mark.usefixtures("mock_only_vuforia") class TestResponseHeaders: """Tests for response headers.""" + @pytest.fixture + def vuforia_database( + self, + vuforia_database: VuforiaDatabase, + ) -> VuforiaDatabase: + """Override to create a VuMark database.""" + return dataclasses.replace( + vuforia_database, + default_target_type="vumark", + ) + @staticmethod def test_response_headers( - image_file_failed_state: io.BytesIO, + image_file_success_state_low_rating: io.BytesIO, vuforia_database: VuforiaDatabase, vws_client: VWS, ) -> None: @@ -366,10 +472,11 @@ def test_response_headers( target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, - image=image_file_failed_state, + image=image_file_success_state_low_rating, active_flag=True, application_metadata=None, ) + vws_client.wait_for_target_processed(target_id=target_id) response = _generate_vumark_instance( database=vuforia_database, From 99fef3ef314690cb7a1e9d09defe218d067578f8 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 15 Feb 2026 11:19:38 +0000 Subject: [PATCH 09/13] Fix manual CI pylint failures for VuMark generation --- spelling_private_dict.txt | 1 + .../mock_web_services_api.py | 71 ++++++++----------- .../_services_validators/exceptions.py | 4 +- tests/mock_vws/test_vumark_generation.py | 10 +-- 4 files changed, 39 insertions(+), 47 deletions(-) diff --git a/spelling_private_dict.txt b/spelling_private_dict.txt index 354d8c044..08bbf6709 100644 --- a/spelling_private_dict.txt +++ b/spelling_private_dict.txt @@ -115,6 +115,7 @@ validators versioning vuforia vuforia's +vumark vwq vws xa 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 a7ab2e983..2a7c831cf 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 @@ -115,6 +115,22 @@ def _body_bytes(request: PreparedRequest) -> bytes: return request.body +@beartype +def _generate_vumark_content( + *, + accept_header: str, + instance_id: str, +) -> tuple[str, bytes]: + """Return generated VuMark content for the requested output format.""" + generators: dict[str, Callable[[str], bytes]] = { + "image/svg+xml": generate_svg, + "image/png": generate_png, + "application/pdf": generate_pdf, + } + generator = generators[accept_header] + return accept_header, generator(instance_id) + + @beartype(conf=BeartypeConf(is_pep484_tower=True)) class MockVuforiaWebServicesAPI: """A fake implementation of the Vuforia Web Services API. @@ -749,59 +765,34 @@ def generate_vumark_instance( request_path=request.path_url, databases=self._target_manager.databases, ) - except ValidatorError as exc: - return exc.status_code, exc.headers, exc.response_text - - # Validate Accept header - try: accept_header = validate_accept_header( request_headers=request.headers, ) - except ValidatorError as exc: - return exc.status_code, exc.headers, exc.response_text - # Extract and validate instance_id from request body - request_json: dict[str, Any] = json.loads(s=request.body or b"{}") - try: + request_json: dict[str, Any] = json.loads(s=request.body or b"{}") instance_id = validate_instance_id( instance_id=request_json.get("instance_id"), ) - except ValidatorError as exc: - return exc.status_code, exc.headers, exc.response_text - - # Look up the target and validate type and status - database = get_database_matching_server_keys( - request_headers=request.headers, - request_body=_body_bytes(request=request), - request_method=request.method or "", - request_path=request.path_url, - databases=self._target_manager.databases, - ) - - split_path = request.path_url.split(sep="/") - target_id = split_path[-2] - target = database.get_target(target_id=target_id) - try: + database = get_database_matching_server_keys( + request_headers=request.headers, + request_body=_body_bytes(request=request), + request_method=request.method or "", + request_path=request.path_url, + databases=self._target_manager.databases, + ) + split_path = request.path_url.split(sep="/") + target_id = split_path[-2] + target = database.get_target(target_id=target_id) validate_target_type(target=target) - except ValidatorError as exc: - return exc.status_code, exc.headers, exc.response_text - - try: validate_target_status_success(target=target) except ValidatorError as exc: return exc.status_code, exc.headers, exc.response_text - # Generate the appropriate image format - if accept_header == "image/svg+xml": - content = generate_svg(instance_id=instance_id) - content_type = "image/svg+xml" - elif accept_header == "image/png": - content = generate_png(instance_id=instance_id) - content_type = "image/png" - else: # PDF - content = generate_pdf(instance_id=instance_id) - content_type = "application/pdf" + content_type, content = _generate_vumark_content( + accept_header=accept_header, + instance_id=instance_id, + ) date = email.utils.formatdate( timeval=None, diff --git a/src/mock_vws/_services_validators/exceptions.py b/src/mock_vws/_services_validators/exceptions.py index 29d53ebf5..2a4f2396c 100644 --- a/src/mock_vws/_services_validators/exceptions.py +++ b/src/mock_vws/_services_validators/exceptions.py @@ -608,7 +608,7 @@ def __init__(self) -> None: @beartype class InvalidTargetTypeError(ValidatorError): - """Exception raised when a non-VuMark target is used for VuMark + """Exception raised when a non-vumark target is used for vumark generation. """ @@ -648,7 +648,7 @@ def __init__(self) -> None: @beartype class InvalidAcceptHeaderError(ValidatorError): - """Exception raised when an invalid Accept header is given for VuMark + """Exception raised when an invalid Accept header is given for vumark generation. """ diff --git a/tests/mock_vws/test_vumark_generation.py b/tests/mock_vws/test_vumark_generation.py index 9ff64bc18..7adb8dc74 100644 --- a/tests/mock_vws/test_vumark_generation.py +++ b/tests/mock_vws/test_vumark_generation.py @@ -112,7 +112,7 @@ class TestSuccessfulGeneration: """Tests for successful VuMark instance generation.""" @pytest.fixture - def vuforia_database( + def vuforia_database( # pylint: disable=no-self-use self, vuforia_database: VuforiaDatabase, ) -> VuforiaDatabase: @@ -369,7 +369,7 @@ def test_integer_instance_id( @pytest.mark.usefixtures("mock_only_vuforia") class TestInvalidTargetType: - """Tests for non-VuMark target types.""" + """Tests for non-vumark target types.""" @staticmethod def test_cloud_target( @@ -377,7 +377,7 @@ def test_cloud_target( vuforia_database: VuforiaDatabase, vws_client: VWS, ) -> None: - """An error is returned for non-VuMark targets.""" + """An error is returned for non-vumark targets.""" target_id = vws_client.add_target( name=uuid.uuid4().hex, width=1, @@ -406,7 +406,7 @@ class TestTargetStatusNotSuccess: """Tests for targets not in success status.""" @pytest.fixture - def vuforia_database( + def vuforia_database( # pylint: disable=no-self-use self, vuforia_database: VuforiaDatabase, ) -> VuforiaDatabase: @@ -452,7 +452,7 @@ class TestResponseHeaders: """Tests for response headers.""" @pytest.fixture - def vuforia_database( + def vuforia_database( # pylint: disable=no-self-use self, vuforia_database: VuforiaDatabase, ) -> VuforiaDatabase: From 37095d4c881a78db1f354da756d565d5a3017220 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 15 Feb 2026 11:41:04 +0000 Subject: [PATCH 10/13] Address PR #2878 Bugbot follow-up findings --- .../_services_validators/target_validators.py | 15 ++++--- tests/mock_vws/fixtures/vuforia_backends.py | 1 + tests/mock_vws/test_invalid_given_id.py | 39 +++++++++++++++++++ 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/mock_vws/_services_validators/target_validators.py b/src/mock_vws/_services_validators/target_validators.py index bb39f4d08..a900e7d97 100644 --- a/src/mock_vws/_services_validators/target_validators.py +++ b/src/mock_vws/_services_validators/target_validators.py @@ -41,12 +41,15 @@ def validate_target_id_exists( if len(split_path) == request_path_no_target_id_length: return - # Handle paths like /targets/{id}/instances (VuMark generation) - # The target_id is the second-to-last element - if split_path[-1] == "instances": - target_id = split_path[-2] - else: - target_id = split_path[-1] + # Handle only /targets/{id}/instances (VuMark generation) paths. + # For all other paths, the target ID is the final segment. + vumark_path_min_length = 4 + is_vumark_instances_path = ( + len(split_path) >= vumark_path_min_length + and split_path[-3] == "targets" + and split_path[-1] == "instances" + ) + target_id = split_path[-2] if is_vumark_instances_path else split_path[-1] database = get_database_matching_server_keys( request_headers=request_headers, request_body=request_body, diff --git a/tests/mock_vws/fixtures/vuforia_backends.py b/tests/mock_vws/fixtures/vuforia_backends.py index 7b3528304..f599d96fc 100644 --- a/tests/mock_vws/fixtures/vuforia_backends.py +++ b/tests/mock_vws/fixtures/vuforia_backends.py @@ -96,6 +96,7 @@ def _enable_use_mock_vuforia( server_secret_key=inactive_database.server_secret_key, client_access_key=inactive_database.client_access_key, client_secret_key=inactive_database.client_secret_key, + default_target_type=inactive_database.default_target_type, ) with MockVWS() as mock: diff --git a/tests/mock_vws/test_invalid_given_id.py b/tests/mock_vws/test_invalid_given_id.py index 067867f75..cfa71ab2f 100644 --- a/tests/mock_vws/test_invalid_given_id.py +++ b/tests/mock_vws/test_invalid_given_id.py @@ -4,11 +4,14 @@ be given. """ +import io from http import HTTPStatus +from types import SimpleNamespace import pytest from vws import VWS +from mock_vws import target as target_module from mock_vws._constants import ResultCodes from tests.mock_vws.utils import Endpoint from tests.mock_vws.utils.assertions import assert_vws_failure @@ -49,3 +52,39 @@ def test_not_real_id( status_code=HTTPStatus.NOT_FOUND, result_code=ResultCodes.UNKNOWN_TARGET, ) + + +@pytest.mark.usefixtures("mock_only_vuforia") +class TestTargetIdNamedInstances: + """Regression tests for a target ID with value ``instances``.""" + + @staticmethod + def test_summary_path_handles_target_id_named_instances( + monkeypatch: pytest.MonkeyPatch, + image_file_success_state_low_rating: io.BytesIO, + vws_client: VWS, + ) -> None: + """`/summary/{target_id}` should use the final path segment as + ID. + """ + target_id = "instances" + monkeypatch.setattr( + target_module.uuid, + "uuid4", + lambda: SimpleNamespace(hex=target_id), + ) + + created_target_id = vws_client.add_target( + name="example_target", + width=1, + image=image_file_success_state_low_rating, + active_flag=True, + application_metadata=None, + ) + assert created_target_id == target_id + + vws_client.wait_for_target_processed(target_id=created_target_id) + report = vws_client.get_target_summary_report( + target_id=created_target_id, + ) + assert report.target_name == "example_target" From 7704c8b5dda6451ac485f442f2466213ff631f06 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 15 Feb 2026 11:42:37 +0000 Subject: [PATCH 11/13] Fix mypy typing in PR #2878 regression test --- tests/mock_vws/test_invalid_given_id.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/mock_vws/test_invalid_given_id.py b/tests/mock_vws/test_invalid_given_id.py index cfa71ab2f..e34abb697 100644 --- a/tests/mock_vws/test_invalid_given_id.py +++ b/tests/mock_vws/test_invalid_given_id.py @@ -11,7 +11,6 @@ import pytest from vws import VWS -from mock_vws import target as target_module from mock_vws._constants import ResultCodes from tests.mock_vws.utils import Endpoint from tests.mock_vws.utils.assertions import assert_vws_failure @@ -69,8 +68,7 @@ def test_summary_path_handles_target_id_named_instances( """ target_id = "instances" monkeypatch.setattr( - target_module.uuid, - "uuid4", + "mock_vws.target.uuid.uuid4", lambda: SimpleNamespace(hex=target_id), ) From 4ee8d506f7153f16ed97aad2f1e2599db9f84480 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 15 Feb 2026 11:44:22 +0000 Subject: [PATCH 12/13] Use unittest patch for typed target-id regression test --- tests/mock_vws/test_invalid_given_id.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/tests/mock_vws/test_invalid_given_id.py b/tests/mock_vws/test_invalid_given_id.py index e34abb697..1fd1739fa 100644 --- a/tests/mock_vws/test_invalid_given_id.py +++ b/tests/mock_vws/test_invalid_given_id.py @@ -7,6 +7,7 @@ import io from http import HTTPStatus from types import SimpleNamespace +from unittest.mock import patch import pytest from vws import VWS @@ -59,7 +60,6 @@ class TestTargetIdNamedInstances: @staticmethod def test_summary_path_handles_target_id_named_instances( - monkeypatch: pytest.MonkeyPatch, image_file_success_state_low_rating: io.BytesIO, vws_client: VWS, ) -> None: @@ -67,18 +67,17 @@ def test_summary_path_handles_target_id_named_instances( ID. """ target_id = "instances" - monkeypatch.setattr( + with patch( "mock_vws.target.uuid.uuid4", - lambda: SimpleNamespace(hex=target_id), - ) - - created_target_id = vws_client.add_target( - name="example_target", - width=1, - image=image_file_success_state_low_rating, - active_flag=True, - application_metadata=None, - ) + return_value=SimpleNamespace(hex=target_id), + ): + created_target_id = vws_client.add_target( + name="example_target", + width=1, + image=image_file_success_state_low_rating, + active_flag=True, + application_metadata=None, + ) assert created_target_id == target_id vws_client.wait_for_target_processed(target_id=created_target_id) From b536a1d5a63794bbe3d26c923f2f37e9156e96fc Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Sun, 15 Feb 2026 12:05:59 +0000 Subject: [PATCH 13/13] Address PR #2878 review comments --- pyproject.toml | 4 ++-- tests/conftest.py | 10 ++++++++++ tests/mock_vws/test_vumark_generation.py | 11 ++++------- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0b3b628f4..7fd41f681 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -478,6 +478,8 @@ ignore_names = [ # Too difficult to test (see notes in the code) "DATE_RANGE_ERROR", "REQUEST_QUOTA_REACHED", + # requests-mock server route callback + "generate_vumark_instance", # pydantic-settings "model_config", ] @@ -490,8 +492,6 @@ ignore_decorators = [ "@*APP.route", "@*APP.before_request", "@*APP.errorhandler", - # requests-mock server - "@route", ] [tool.yamlfix] diff --git a/tests/conftest.py b/tests/conftest.py index 64ef1427f..2a81d82f6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import base64 import binascii +import dataclasses import io import uuid @@ -74,6 +75,15 @@ def target_id( ) +@pytest.fixture +def vucloud_database(vuforia_database: VuforiaDatabase) -> VuforiaDatabase: + """A database configured to create VuMark targets by default.""" + return dataclasses.replace( + vuforia_database, + default_target_type="vumark", + ) + + @pytest.fixture( params=[ "add_target", diff --git a/tests/mock_vws/test_vumark_generation.py b/tests/mock_vws/test_vumark_generation.py index 7adb8dc74..f839d05c6 100644 --- a/tests/mock_vws/test_vumark_generation.py +++ b/tests/mock_vws/test_vumark_generation.py @@ -107,20 +107,17 @@ def _generate_vumark_instance_with_body( return requests.post(url=url, data=content, headers=headers, timeout=30) -@pytest.mark.usefixtures("mock_only_vuforia") +@pytest.mark.usefixtures("verify_mock_vuforia") class TestSuccessfulGeneration: """Tests for successful VuMark instance generation.""" @pytest.fixture def vuforia_database( # pylint: disable=no-self-use self, - vuforia_database: VuforiaDatabase, + vucloud_database: VuforiaDatabase, ) -> VuforiaDatabase: - """Override to create a VuMark database.""" - return dataclasses.replace( - vuforia_database, - default_target_type="vumark", - ) + """Use a VuCloud database for VuMark generation tests.""" + return vucloud_database @staticmethod def test_svg_generation(