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..050dd5a3f 100644 --- a/docs/source/differences-to-vws.rst +++ b/docs/source/differences-to-vws.rst @@ -201,6 +201,38 @@ 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 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. +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 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/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..b903e8f73 --- /dev/null +++ b/tests/mock_vws/test_reco_counts_report.py @@ -0,0 +1,191 @@ +"""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.""" + # 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" + 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("mock_only_vuforia") +class TestRecoCountsReport: + """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( + 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