From 4f9b67f7e973f2428c0a90a4b95a19678b0d5cdf Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 7 Aug 2026 13:11:48 +0100 Subject: [PATCH 1/2] Add the Database Reco Counts CSV report endpoint Route `POST /imagetargets/databases/{database_id}/reports/recoCounts` on both the in-process and Flask backends, returning a `presigned_url` pointing at a new `GET /reports/recoCounts/{report_id}` download route. That route needs no authorization and returns 404 until the report is ready, then 200 with the CSV. The `month` field must be a `YYYY-mm` string naming the current or previous month; anything else returns `Fail`. The CSV is header-only because the mock does not count recognitions, which is now documented along with the unverified error behaviour. Making the report non-empty is #3356. The Flask app cannot recover its external host from the request, so it gains a `VWS_BASE_URL` setting for building the download URL. Closes #3343. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/test.yml | 1 + docs/source/differences-to-vws.rst | 27 +++ docs/source/docker.rst | 7 + newsfragments/reco-counts-report.change | 1 + spelling_private_dict.txt | 4 + src/mock_vws/_flask_server/vws.py | 60 +++++- src/mock_vws/_mock_common.py | 9 + src/mock_vws/_reco_counts_web_api.py | 163 ++++++++++++++++ .../mock_web_services_api.py | 64 +++++- .../_services_validators/key_validators.py | 10 + .../_services_validators/target_validators.py | 8 + src/mock_vws/decorators.py | 1 + src/mock_vws/reco_counts.py | 52 +++++ src/mock_vws/target_manager.py | 16 ++ tests/mock_vws/test_reco_counts_report.py | 183 ++++++++++++++++++ 15 files changed, 603 insertions(+), 3 deletions(-) create mode 100644 newsfragments/reco-counts-report.change create mode 100644 src/mock_vws/_reco_counts_web_api.py create mode 100644 src/mock_vws/reco_counts.py create mode 100644 tests/mock_vws/test_reco_counts_report.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cffabc96d..05920b3d6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -87,6 +87,7 @@ jobs: - tests/mock_vws/test_invalid_json.py::TestInvalidJSON::test_invalid_json - tests/mock_vws/test_invalid_json.py::TestInvalidJSON::test_invalid_json_with_skewed_time - tests/mock_vws/test_target_list.py + - tests/mock_vws/test_reco_counts_report.py - tests/mock_vws/test_target_raters.py - tests/mock_vws/test_target_summary.py - tests/mock_vws/test_unexpected_json.py diff --git a/docs/source/differences-to-vws.rst b/docs/source/differences-to-vws.rst index a73698264..5ff5c53df 100644 --- a/docs/source/differences-to-vws.rst +++ b/docs/source/differences-to-vws.rst @@ -201,6 +201,33 @@ Two Model Target Web API error paths remain mock-only in ``tests/mock_vws/test_m Downloads of still-processing datasets are mock-only because exercising the path against real Vuforia would require creating a dataset on every test run; the mock drives the processing window deterministically. Advanced-dataset creation with more than 20 models is mock-only because the available test account lacks the advanced-dataset scope and real Vuforia rejects the request with a 403 before validating model counts. +Reco counts reports +------------------- + +The mock does not count recognitions, so a generated reco counts report +contains only the ``target_id,reco_count`` header row. +The mock returns the same report for the current month and the previous month. + +The mock does not use the database ID in the request path. +It uses the database which matches the request's server keys, and it accepts +any database ID. + +Real Vuforia returns a presigned URL for cloud storage, and the report takes +between a few seconds and one hour to generate. +The mock returns a URL served by the mock itself, without the query +parameters of a presigned URL, and the report takes +:paramref:`~mock_vws.MockVWS.processing_time_seconds` seconds to generate. +The URL returned by the Flask and Docker mock is built from the +:envvar:`VWS_BASE_URL` environment variable. +As with real Vuforia, the URL returns a 404 response until the report is +ready, and it requires no authorization. + +The ``Fail`` result code returned for a ``month`` which is not in the +``YYYY-mm`` form, and for a ``month`` which is neither the current month nor +the previous month, has not been verified against real Vuforia. +The columns of the CSV report, and the headers of the download response, have +not been verified against a real report either. + Header cases ------------ diff --git a/docs/source/docker.rst b/docs/source/docker.rst index ab5c2220c..0e705caf5 100644 --- a/docs/source/docker.rst +++ b/docs/source/docker.rst @@ -138,6 +138,13 @@ VWS container Default: ``2.0`` +.. envvar:: VWS_BASE_URL + + The base URL which clients use to reach the VWS container. + The download URL of a reco counts report is built from this URL. + + Default: ``https://vws.vuforia.com`` + .. envvar:: DUPLICATES_IMAGE_MATCHER The matcher to use for the duplicates endpoint. diff --git a/newsfragments/reco-counts-report.change b/newsfragments/reco-counts-report.change new file mode 100644 index 000000000..6680a70d3 --- /dev/null +++ b/newsfragments/reco-counts-report.change @@ -0,0 +1 @@ +Add the reco counts report endpoint, and a download URL for the generated CSV report. diff --git a/spelling_private_dict.txt b/spelling_private_dict.txt index b1f0fd74e..f96162f5c 100644 --- a/spelling_private_dict.txt +++ b/spelling_private_dict.txt @@ -1,8 +1,10 @@ +CSV KiB MPixel MiB MissingSchema OAuth +Reco Ubuntu VuMark admin @@ -76,12 +78,14 @@ pdict plugins png pragma +presigned processable pyrefly pyright pytest readme readthedocs +reco recognitions refactoring regex diff --git a/src/mock_vws/_flask_server/vws.py b/src/mock_vws/_flask_server/vws.py index f8b7f98ef..0a1e84026 100644 --- a/src/mock_vws/_flask_server/vws.py +++ b/src/mock_vws/_flask_server/vws.py @@ -38,6 +38,10 @@ from mock_vws._model_target_web_api import ( oauth2_token as model_target_oauth2_token, ) +from mock_vws._reco_counts_web_api import create_reco_counts_report +from mock_vws._reco_counts_web_api import ( + download_reco_counts_report as download_report, +) from mock_vws._services_validators import run_services_validators from mock_vws._services_validators.exceptions import ( FailError, @@ -93,6 +97,9 @@ class VWSSettings(BaseSettings): target_manager_base_url: str processing_time_seconds: float = 2.0 vws_host: str = "" + # The base URL which clients use to reach this application. + # Generated reco counts reports are served from this URL. + vws_base_url: str = "https://vws.vuforia.com" duplicates_image_matcher: _ImageMatcherChoice = ( _ImageMatcherChoice.STRUCTURAL_SIMILARITY ) @@ -189,11 +196,16 @@ def validate_request() -> None: The VuMark endpoint does its own validation because it needs to authenticate against both cloud and VuMark databases. + + Reco counts report downloads stand in for presigned URLs, which are not + authorized with VWS credentials. """ if request.endpoint == "generate_vumark_instance": return - if request.path == "/oauth2/token" or request.path.startswith( - "/modeltargets/", + if ( + request.path == "/oauth2/token" + or request.path.startswith("/modeltargets/") + or request.path.startswith("/reports/recoCounts/") ): return run_services_validators( @@ -385,6 +397,50 @@ def delete_advanced_model_target_dataset(dataset_uuid: str) -> Response: ) +@VWS_FLASK_APP.route( + rule="/imagetargets/databases//reports/recoCounts", + methods=[HTTPMethod.POST], +) +@beartype +def reco_counts_report(database_id: str) -> Response: + """Request a reco counts report for a database. + + Fake implementation of + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api + """ + # The mock authenticates with the request's server keys, so the database + # ID in the path is not used. + del database_id + settings = VWSSettings.model_validate(obj={}) + return _to_flask_response( + api_response=create_reco_counts_report( + request_body=request.data, + target_manager=TARGET_MANAGER, + generation_time_seconds=settings.processing_time_seconds, + base_url=settings.vws_base_url.rstrip("/"), + ), + ) + + +@VWS_FLASK_APP.route( + rule="/reports/recoCounts/", + methods=[HTTPMethod.GET], +) +@beartype +def download_reco_counts_report(report_id: str) -> Response: + """Download a generated reco counts report. + + This stands in for the presigned URL which real Vuforia returns, so it + does not require any authorization. + """ + return _to_flask_response( + api_response=download_report( + target_manager=TARGET_MANAGER, + report_id=report_id, + ), + ) + + @VWS_FLASK_APP.route(rule="/targets", methods=[HTTPMethod.POST]) @beartype def add_target() -> Response: diff --git a/src/mock_vws/_mock_common.py b/src/mock_vws/_mock_common.py index 2c975d86a..d565aaa73 100644 --- a/src/mock_vws/_mock_common.py +++ b/src/mock_vws/_mock_common.py @@ -7,6 +7,15 @@ from beartype import beartype +# A database ID as it appears in the path of a reco counts report request. +DATABASE_ID_PATTERN = "[A-Za-z0-9_-]+" +# The path of the endpoint which requests a reco counts report. +RECO_COUNTS_REPORT_PATH_PATTERN = ( + f"/imagetargets/databases/{DATABASE_ID_PATTERN}/reports/recoCounts" +) +# The path which stands in for a reco counts report presigned URL. +RECO_COUNTS_DOWNLOAD_PATH_PATTERN = "/reports/recoCounts/[A-Za-z0-9]+" + @beartype class MissingSchemeError(Exception): diff --git a/src/mock_vws/_reco_counts_web_api.py b/src/mock_vws/_reco_counts_web_api.py new file mode 100644 index 000000000..acb62e933 --- /dev/null +++ b/src/mock_vws/_reco_counts_web_api.py @@ -0,0 +1,163 @@ +"""A fake implementation of the Vuforia reco counts report endpoints.""" + +import datetime +import email.utils +import json +import logging +import re +import uuid +from http import HTTPStatus +from typing import Any +from zoneinfo import ZoneInfo + +from beartype import beartype + +from mock_vws._constants import ResultCodes +from mock_vws._mock_common import json_dump +from mock_vws._services_validators.exceptions import FailError +from mock_vws.reco_counts import RecoCountsReport +from mock_vws.target_manager import TargetManager + +_ResponseType = tuple[int, dict[str, str], str | bytes] +_LOGGER = logging.getLogger(name=__name__) +_MONTH_PATTERN = re.compile(pattern=r"[0-9]{4}-[0-9]{2}") + + +@beartype +def _headers(*, content_type: str, content_length: int) -> dict[str, str]: + """Return response headers which match other VWS endpoints.""" + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) + return { + "Connection": "keep-alive", + "Content-Length": str(object=content_length), + "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", + } + + +@beartype +def _download_headers( + *, content_type: str, content_length: int +) -> dict[str, str]: + """Return response headers for a report download. + + Real Vuforia serves reports from cloud storage, so these do not match the + headers of the VWS API. + """ + date = email.utils.formatdate(timeval=None, localtime=False, usegmt=True) + return { + "Content-Length": str(object=content_length), + "Content-Type": content_type, + "Date": date, + } + + +@beartype +def _months_in_range() -> set[str]: + """Return the months which a report can be requested for. + + Only the current month and the previous month can be requested. + """ + now = datetime.datetime.now(tz=ZoneInfo(key="UTC")) + first_of_month = now.replace(day=1) + last_of_previous_month = first_of_month - datetime.timedelta(days=1) + return { + now.strftime(format="%Y-%m"), + last_of_previous_month.strftime(format="%Y-%m"), + } + + +@beartype +def create_reco_counts_report( + *, + request_body: bytes, + target_manager: TargetManager, + generation_time_seconds: float, + base_url: str, +) -> _ResponseType: + """Request a reco counts report for a database. + + Args: + request_body: The body of the request. + target_manager: The target manager which stores generated reports. + generation_time_seconds: The number of seconds before a generated + report is available to download. + base_url: The base URL to serve the generated report from. + + Returns: + A response which includes a URL to download the report from. + + Raises: + FailError: The given month is not a month in the ``YYYY-mm`` form + which the report can be requested for. + """ + request_json: dict[str, Any] = json.loads(s=request_body) + month = request_json["month"] + if not isinstance(month, str) or not _MONTH_PATTERN.fullmatch( + string=month, + ): + _LOGGER.warning(msg='The given "month" is not in the YYYY-mm form.') + raise FailError(status_code=HTTPStatus.BAD_REQUEST) + + if month not in _months_in_range(): + _LOGGER.warning( + msg=( + 'The given "month" is not the current month or the previous ' + "month." + ), + ) + raise FailError(status_code=HTTPStatus.BAD_REQUEST) + + report = RecoCountsReport( + generation_time_seconds=generation_time_seconds, + ) + target_manager.add_reco_counts_report(reco_counts_report=report) + + body = { + "result_code": ResultCodes.SUCCESS.value, + "transaction_id": uuid.uuid4().hex, + "presigned_url": f"{base_url}/reports/recoCounts/{report.uuid_}", + } + body_json = json_dump(body=body) + headers = _headers( + content_type="application/json", + content_length=len(body_json), + ) + return HTTPStatus.OK, headers, body_json + + +@beartype +def download_reco_counts_report( + *, + target_manager: TargetManager, + report_id: str, +) -> _ResponseType: + """Download a generated reco counts report. + + Args: + target_manager: The target manager which stores generated reports. + report_id: The identifier of the report to download. + + Returns: + The CSV content of the report, or a 404 response while the report is + not ready. + """ + report = target_manager.reco_counts_reports.get(report_id) + if report is None or not report.is_available: + return ( + HTTPStatus.NOT_FOUND, + _download_headers(content_type="text/plain", content_length=0), + "", + ) + + body = report.csv_content + headers = _download_headers( + content_type="text/csv", + content_length=len(body), + ) + return HTTPStatus.OK, headers, body 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 4a8afc5e2..b91e77096 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 @@ -25,7 +25,13 @@ TargetStatuses, ) from mock_vws._database_matchers import get_database_matching_server_keys -from mock_vws._mock_common import RequestData, Route, json_dump +from mock_vws._mock_common import ( + RECO_COUNTS_DOWNLOAD_PATH_PATTERN, + RECO_COUNTS_REPORT_PATH_PATTERN, + RequestData, + Route, + json_dump, +) from mock_vws._model_target_web_api import ( create_model_target_dataset, delete_model_target_dataset, @@ -33,6 +39,10 @@ get_model_target_dataset_status, oauth2_token, ) +from mock_vws._reco_counts_web_api import ( + create_reco_counts_report, + download_reco_counts_report, +) from mock_vws._services_validators import run_services_validators from mock_vws._services_validators.exceptions import ( FailError, @@ -127,6 +137,7 @@ def __init__( self, *, target_manager: TargetManager, + base_vws_url: str, processing_time_seconds: float, model_target_generation_failure: (ModelTargetGenerationFailure | None), model_target_generation_warning: (ModelTargetGenerationWarning | None), @@ -137,6 +148,9 @@ def __init__( """ Args: target_manager: Target Manager which stores databases. + base_vws_url: The base URL which the mock VWS API is served + from. + Generated reco counts reports are served from this URL. processing_time_seconds: The number of seconds to process each image for. In the real Vuforia Web Services, this is not deterministic. @@ -156,6 +170,7 @@ def __init__( routes: The `Route`s to be used in the mock. """ self._target_manager = target_manager + self._base_vws_url = base_vws_url self.routes = _ROUTES self._processing_time_seconds = processing_time_seconds self._model_target_generation_failure = model_target_generation_failure @@ -321,6 +336,53 @@ def delete_advanced_model_target_dataset( dataset_uuid=dataset_uuid, ) + @route( + path_pattern=RECO_COUNTS_REPORT_PATH_PATTERN, + http_methods={HTTPMethod.POST}, + ) + def reco_counts_report(self, request: RequestData) -> _ResponseType: + """Request a reco counts report for a database. + + Fake implementation of + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api + """ + try: + run_services_validators( + request_headers=request.headers, + request_body=request.body, + request_method=request.method, + request_path=request.path, + databases=self._target_manager.cloud_databases, + request_rate_limiter=self._target_manager.request_rate_limiter, + ) + return create_reco_counts_report( + request_body=request.body, + target_manager=self._target_manager, + generation_time_seconds=self._processing_time_seconds, + base_url=self._base_vws_url.rstrip("/"), + ) + except ValidatorError as exc: + return exc.status_code, exc.headers, exc.response_text + + @route( + path_pattern=RECO_COUNTS_DOWNLOAD_PATH_PATTERN, + http_methods={HTTPMethod.GET}, + ) + def download_reco_counts_report( + self, + request: RequestData, + ) -> _ResponseType: + """Download a generated reco counts report. + + This stands in for the presigned URL which real Vuforia returns, so + it does not require any authorization. + """ + report_id = request.path.split(sep="/")[-1] + return download_reco_counts_report( + target_manager=self._target_manager, + report_id=report_id, + ) + @route( path_pattern="/targets", http_methods={HTTPMethod.POST}, diff --git a/src/mock_vws/_services_validators/key_validators.py b/src/mock_vws/_services_validators/key_validators.py index 708d3b09d..bdd0e6d8e 100644 --- a/src/mock_vws/_services_validators/key_validators.py +++ b/src/mock_vws/_services_validators/key_validators.py @@ -9,6 +9,8 @@ from beartype import beartype +from mock_vws._mock_common import RECO_COUNTS_REPORT_PATH_PATTERN + from .exceptions import FailError _LOGGER = logging.getLogger(name=__name__) @@ -129,8 +131,16 @@ def validate_keys( optional_keys=set(), ) + reco_counts_report = _Route( + path_pattern=RECO_COUNTS_REPORT_PATH_PATTERN, + http_methods={HTTPMethod.POST}, + mandatory_keys={"month"}, + optional_keys=set(), + ) + routes = ( add_target, + reco_counts_report, delete_target, database_summary, target_list, diff --git a/src/mock_vws/_services_validators/target_validators.py b/src/mock_vws/_services_validators/target_validators.py index 58f1da0d7..005649cf8 100644 --- a/src/mock_vws/_services_validators/target_validators.py +++ b/src/mock_vws/_services_validators/target_validators.py @@ -1,6 +1,7 @@ """Validators for given target IDs.""" import logging +import re from collections.abc import Iterable, Mapping from beartype import beartype @@ -9,6 +10,7 @@ AnyDatabase, get_database_matching_server_keys, ) +from mock_vws._mock_common import RECO_COUNTS_REPORT_PATH_PATTERN from mock_vws._services_validators.exceptions import UnknownTargetError _LOGGER = logging.getLogger(name=__name__) @@ -38,6 +40,12 @@ def validate_target_id_exists( UnknownTargetError: There are no matching targets for a given target ID. """ + if re.fullmatch( + pattern=RECO_COUNTS_REPORT_PATH_PATTERN, + string=request_path, + ): + return + split_path = request_path.split(sep="/") request_path_no_target_id_length = 2 diff --git a/src/mock_vws/decorators.py b/src/mock_vws/decorators.py index e4a82d0b6..e69972d1d 100644 --- a/src/mock_vws/decorators.py +++ b/src/mock_vws/decorators.py @@ -149,6 +149,7 @@ def __init__( self._mock_vws_api = MockVuforiaWebServicesAPI( target_manager=self._target_manager, + base_vws_url=base_vws_url, processing_time_seconds=float(processing_time_seconds), model_target_generation_failure=model_target_generation_failure, model_target_generation_warning=model_target_generation_warning, diff --git a/src/mock_vws/reco_counts.py b/src/mock_vws/reco_counts.py new file mode 100644 index 000000000..fd67ad1e9 --- /dev/null +++ b/src/mock_vws/reco_counts.py @@ -0,0 +1,52 @@ +"""Reco counts report objects.""" + +import datetime +import uuid +from dataclasses import dataclass, field +from zoneinfo import ZoneInfo + +from beartype import beartype + +# The mock does not count recognitions, so a generated report never has any +# rows for targets. +_CSV_CONTENT = "target_id,reco_count\n" + + +@beartype +def _now() -> datetime.datetime: + """Return the current time in UTC.""" + return datetime.datetime.now(tz=ZoneInfo(key="UTC")) + + +@beartype +@dataclass(frozen=True, kw_only=True) +class RecoCountsReport: + """A requested reco counts report. + + Args: + generation_time_seconds: The number of seconds before the report is + available to download. + uuid_: The report identifier, used in the report's download URL. + created_at: When the report was requested. + """ + + generation_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 available_at(self) -> datetime.datetime: + """When the report becomes available to download.""" + return self.created_at + datetime.timedelta( + seconds=self.generation_time_seconds, + ) + + @property + def is_available(self) -> bool: + """Whether the report is available to download.""" + return _now() >= self.available_at + + @property + def csv_content(self) -> str: + """The content of the generated CSV report.""" + return _CSV_CONTENT diff --git a/src/mock_vws/target_manager.py b/src/mock_vws/target_manager.py index 321671cad..365a2242c 100644 --- a/src/mock_vws/target_manager.py +++ b/src/mock_vws/target_manager.py @@ -10,6 +10,7 @@ ) from mock_vws.database import CloudDatabase, VuMarkDatabase from mock_vws.model_target import ModelTargetDataset +from mock_vws.reco_counts import RecoCountsReport if TYPE_CHECKING: from mock_vws._database_matchers import AnyDatabase @@ -28,6 +29,7 @@ def __init__(self) -> None: self._cloud_databases: set[CloudDatabase] = set() self._vumark_databases: set[VuMarkDatabase] = set() self._model_target_datasets: dict[str, ModelTargetDataset] = {} + self._reco_counts_reports: dict[str, RecoCountsReport] = {} self._request_rate_limiter = RequestRateLimiter( time_function=time.monotonic, ) @@ -52,6 +54,20 @@ def model_target_datasets(self) -> dict[str, ModelTargetDataset]: """All Model Target datasets, keyed by UUID.""" return dict(self._model_target_datasets) + @property + def reco_counts_reports(self) -> dict[str, RecoCountsReport]: + """All reco counts reports, keyed by report identifier.""" + return dict(self._reco_counts_reports) + + def add_reco_counts_report( + self, + reco_counts_report: RecoCountsReport, + ) -> None: + """Add a reco counts report.""" + self._reco_counts_reports[reco_counts_report.uuid_] = ( + reco_counts_report + ) + def remove_cloud_database(self, cloud_database: CloudDatabase) -> None: """Remove a cloud database. diff --git a/tests/mock_vws/test_reco_counts_report.py b/tests/mock_vws/test_reco_counts_report.py new file mode 100644 index 000000000..92bb18cfd --- /dev/null +++ b/tests/mock_vws/test_reco_counts_report.py @@ -0,0 +1,183 @@ +"""Tests for the mock of the reco counts report endpoint.""" + +import datetime +import json +import time +import uuid +from http import HTTPMethod, HTTPStatus +from string import hexdigits +from zoneinfo import ZoneInfo + +import pytest +import requests +from beartype import beartype +from vws_auth_tools import authorization_header, rfc_1123_date + +from mock_vws._constants import ResultCodes +from mock_vws.database import CloudDatabase + +_VWS_HOST = "https://vws.vuforia.com" +# The number of seconds which the mocks take to generate a report. +# This matches the default processing time of the mocks. +_GENERATION_TIME_SECONDS = 2 + + +@beartype +def _month_offset_from_now(*, months: int) -> str: + """Return a month in ``YYYY-mm`` form, offset from the current + month. + """ + now = datetime.datetime.now(tz=ZoneInfo(key="UTC")) + total_months = now.year * 12 + now.month - 1 + months + year, month_index = divmod(total_months, 12) + return f"{year:04d}-{month_index + 1:02d}" + + +@beartype +def _request_reco_counts_report( + *, + vuforia_database: CloudDatabase, + month: str | int, +) -> requests.Response: + """Request a reco counts report and return the response.""" + database_id = uuid.uuid4().hex + request_path = f"/imagetargets/databases/{database_id}/reports/recoCounts" + content_type = "application/json" + content = json.dumps(obj={"month": month}).encode(encoding="utf-8") + date = rfc_1123_date() + authorization_string = authorization_header( + access_key=vuforia_database.server_access_key, + secret_key=vuforia_database.server_secret_key, + method=HTTPMethod.POST, + content=content, + content_type=content_type, + date=date, + request_path=request_path, + ) + + return requests.post( + url=_VWS_HOST + request_path, + headers={ + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Content-Type": content_type, + "Date": date, + }, + data=content, + timeout=30, + ) + + +@pytest.mark.usefixtures("verify_mock_vuforia") +class TestRecoCountsReport: + """Tests for requesting a reco counts report.""" + + @staticmethod + @pytest.mark.parametrize( + argnames="months_ago", + argvalues=[0, 1], + ids=["current_month", "previous_month"], + ) + def test_reco_counts_report( + *, + vuforia_database: CloudDatabase, + months_ago: int, + ) -> None: + """A report can be requested for the current and previous + month. + """ + response = _request_reco_counts_report( + vuforia_database=vuforia_database, + month=_month_offset_from_now(months=-months_ago), + ) + + assert response.status_code == HTTPStatus.OK + response_json = json.loads(s=response.text) + assert response_json.keys() == { + "result_code", + "transaction_id", + "presigned_url", + } + assert response_json["result_code"] == ResultCodes.SUCCESS.value + transaction_id = response_json["transaction_id"] + assert all(char in hexdigits for char in transaction_id) + assert response_json["presigned_url"].startswith("https://") + + @staticmethod + @pytest.mark.parametrize( + argnames="months_ago", + argvalues=[2, -1], + ids=["too_old", "in_the_future"], + ) + def test_month_out_of_range( + *, + vuforia_database: CloudDatabase, + months_ago: int, + ) -> None: + """Only the current and the previous month can be requested.""" + response = _request_reco_counts_report( + vuforia_database=vuforia_database, + month=_month_offset_from_now(months=-months_ago), + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + response_json = json.loads(s=response.text) + assert response_json["result_code"] == ResultCodes.FAIL.value + + @staticmethod + @pytest.mark.parametrize( + argnames="month", + argvalues=["2020", "2020-1", "January", "2020-01-01", 202001], + ids=["year_only", "one_digit", "name", "date", "not_a_string"], + ) + def test_malformed_month( + *, + vuforia_database: CloudDatabase, + month: str | int, + ) -> None: + """The month must be given in the ``YYYY-mm`` form.""" + response = _request_reco_counts_report( + vuforia_database=vuforia_database, + month=month, + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + response_json = json.loads(s=response.text) + assert response_json["result_code"] == ResultCodes.FAIL.value + + +@pytest.mark.usefixtures("mock_only_vuforia") +class TestDownloadReport: + """Tests for downloading a generated reco counts report. + + Downloads are tested against the mocks only. + Real Vuforia takes between a few seconds and one hour to generate a + report, which is too long to wait for in a test. + """ + + @staticmethod + def test_download_report(*, vuforia_database: CloudDatabase) -> None: + """The report is available from the given URL once it is ready.""" + response = _request_reco_counts_report( + vuforia_database=vuforia_database, + month=_month_offset_from_now(months=0), + ) + presigned_url = json.loads(s=response.text)["presigned_url"] + + not_ready_response = requests.get(url=presigned_url, timeout=30) + assert not_ready_response.status_code == HTTPStatus.NOT_FOUND + + time.sleep(_GENERATION_TIME_SECONDS + 1) + + ready_response = requests.get(url=presigned_url, timeout=30) + assert ready_response.status_code == HTTPStatus.OK + assert ready_response.headers["Content-Type"] == "text/csv" + assert ready_response.text == "target_id,reco_count\n" + + @staticmethod + def test_unknown_report() -> None: + """An unknown report is not available.""" + url = f"{_VWS_HOST}/reports/recoCounts/{uuid.uuid4().hex}" + response = requests.get(url=url, timeout=30) + + assert response.status_code == HTTPStatus.NOT_FOUND From cb36816a262c4095a79c1c4f0a9bf036594a57f3 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Fri, 7 Aug 2026 13:17:40 +0100 Subject: [PATCH 2/2] Test the reco counts report against the mocks only Real Vuforia returned 401 for a request signed with valid server keys which named a random database ID in the path, so the path's database ID appears to have to belong to the credentials. The test credentials do not include a database ID, so the request cannot be made against real Vuforia at all. Run the tests under `mock_only_vuforia` and record in the differences documentation that nothing about the endpoint is verified. Co-Authored-By: Claude Opus 5 (1M context) --- docs/source/differences-to-vws.rst | 15 ++++++++++----- tests/mock_vws/test_reco_counts_report.py | 12 ++++++++++-- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/docs/source/differences-to-vws.rst b/docs/source/differences-to-vws.rst index 5ff5c53df..050dd5a3f 100644 --- a/docs/source/differences-to-vws.rst +++ b/docs/source/differences-to-vws.rst @@ -211,6 +211,8 @@ The mock returns the same report for the current month and the previous month. The mock does not use the database ID in the request path. It uses the database which matches the request's server keys, and it accepts any database ID. +Real Vuforia returns a 401 response for a request which is signed with valid +server keys but which names a database ID that those keys do not belong to. Real Vuforia returns a presigned URL for cloud storage, and the report takes between a few seconds and one hour to generate. @@ -222,11 +224,14 @@ The URL returned by the Flask and Docker mock is built from the As with real Vuforia, the URL returns a 404 response until the report is ready, and it requires no authorization. -The ``Fail`` result code returned for a ``month`` which is not in the -``YYYY-mm`` form, and for a ``month`` which is neither the current month nor -the previous month, has not been verified against real Vuforia. -The columns of the CSV report, and the headers of the download response, have -not been verified against a real report either. +The whole endpoint is mock-only in +``tests/mock_vws/test_reco_counts_report.py``, because the test credentials do +not include a database ID and so a request cannot be made which real Vuforia +authenticates. +Nothing about it has been verified against real Vuforia: not the ``Fail`` +result code returned for a ``month`` which is not in the ``YYYY-mm`` form or +which is neither the current month nor the previous month, not the columns of +the CSV report, and not the headers of either response. Header cases ------------ diff --git a/tests/mock_vws/test_reco_counts_report.py b/tests/mock_vws/test_reco_counts_report.py index 92bb18cfd..b903e8f73 100644 --- a/tests/mock_vws/test_reco_counts_report.py +++ b/tests/mock_vws/test_reco_counts_report.py @@ -40,6 +40,8 @@ def _request_reco_counts_report( month: str | int, ) -> requests.Response: """Request a reco counts report and return the response.""" + # The mocks accept any database ID, and the test credentials do not + # include the ID of the real database. database_id = uuid.uuid4().hex request_path = f"/imagetargets/databases/{database_id}/reports/recoCounts" content_type = "application/json" @@ -68,9 +70,15 @@ def _request_reco_counts_report( ) -@pytest.mark.usefixtures("verify_mock_vuforia") +@pytest.mark.usefixtures("mock_only_vuforia") class TestRecoCountsReport: - """Tests for requesting a reco counts report.""" + """Tests for requesting a reco counts report. + + These are tested against the mocks only. + Real Vuforia returns a 401 response for a request which is signed with + valid server keys but which names a database ID that the keys do not + belong to, and the test credentials do not include a database ID. + """ @staticmethod @pytest.mark.parametrize(