diff --git a/pyproject.toml b/pyproject.toml index 0e9a995c7..a7f734f1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -148,7 +148,7 @@ lint.per-file-ignores."docs/source/*.py" = [ # Allow asserts in docs. "S101", ] -lint.per-file-ignores."tests/*.py" = [ +lint.per-file-ignores."tests/**/*.py" = [ # Allow asserts in tests. "S101", ] @@ -410,6 +410,7 @@ plugins = [ num_workers = "auto" [tool.pyrefly] +search-path = [ "." ] errors.non-exhaustive-match = "error" enabled-ignores = [ "pyrefly" ] preset = "all" diff --git a/tests/async_model_targets/__init__.py b/tests/async_model_targets/__init__.py new file mode 100644 index 000000000..7bca5dad6 --- /dev/null +++ b/tests/async_model_targets/__init__.py @@ -0,0 +1 @@ +"""Async model targets tests.""" diff --git a/tests/async_model_targets/test_authentication.py b/tests/async_model_targets/test_authentication.py new file mode 100644 index 000000000..a580a7923 --- /dev/null +++ b/tests/async_model_targets/test_authentication.py @@ -0,0 +1,143 @@ +"""Tests for asynchronous Model Target authentication.""" + +import secrets +from http import HTTPStatus + +import pytest +from mock_vws import ( + MockVWS, + ModelTargetFailureResponse, +) + +from tests.model_target_credentials import CLIENT_CREDENTIALS, CLIENT_ID +from vws import AsyncModelTargetService +from vws.exceptions.custom_exceptions import ServerError +from vws.exceptions.model_target_exceptions import ( + ModelTargetAuthenticationError, + ModelTargetError, + ModelTargetOAuth2Error, +) +from vws.exceptions.vws_exceptions import TooManyRequestsError +from vws.model_target_datasets import ( + ModelTargetDatasetType, + ModelTargetModel, +) + + +async def _assert_dataset_error_response( + *, + model_target_model: ModelTargetModel, + status_code: HTTPStatus, + body: str, + expected_exception: ( + type[ModelTargetError | TooManyRequestsError | ServerError] + ), +) -> None: + """Assert that a mocked dataset failure maps to an exception.""" + async with AsyncModelTargetService( + client_id=CLIENT_ID, + client_secret=CLIENT_CREDENTIALS[1], + ) as client: + with pytest.raises( + expected_exception=( + ModelTargetError, + TooManyRequestsError, + ServerError, + ) + ) as exc: + await client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert isinstance(exc.value, expected_exception) + assert exc.value.response.status_code == status_code + assert exc.value.response.text == body + + +@pytest.mark.asyncio +async def test_token_is_a_bearer_token( + *, + async_model_target_client: AsyncModelTargetService, +) -> None: + """An access token is given for valid credentials.""" + assert bool(await async_model_target_client.get_access_token()) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("_mock_model_targets") +async def test_invalid_credentials() -> None: + """An exception is raised when the credentials are not known.""" + async with AsyncModelTargetService( + client_id="not-a-client-id", + client_secret=secrets.token_hex(), + ) as client: + with pytest.raises( + expected_exception=ModelTargetOAuth2Error, + ) as exc: + await client.get_access_token() + + assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED + assert exc.value.error == "invalid_client" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + argnames=("status_code", "body", "expected_exception"), + argvalues=[ + pytest.param( + HTTPStatus.UNAUTHORIZED, + '{"error":{"code":"AUTHENTICATION_ERROR","message":"No"}}', + ModelTargetAuthenticationError, + id="authentication", + ), + pytest.param( + HTTPStatus.FORBIDDEN, + '{"error":{"code":"FORBIDDEN","message":"Denied"}}', + ModelTargetError, + id="generic-json", + ), + pytest.param( + HTTPStatus.CONFLICT, + "not json", + ModelTargetError, + id="generic-non-json", + ), + pytest.param( + HTTPStatus.TOO_MANY_REQUESTS, + "rate limited", + TooManyRequestsError, + id="rate-limit", + ), + pytest.param( + HTTPStatus.BAD_GATEWAY, + "server error", + ServerError, + id="server-error", + ), + ], +) +async def test_dataset_error_response( + *, + model_target_model: ModelTargetModel, + status_code: HTTPStatus, + body: str, + expected_exception: ( + type[ModelTargetError | TooManyRequestsError | ServerError] + ), +) -> None: + """Dataset failures map to exceptions through the mock.""" + failure = ModelTargetFailureResponse( + status_code=status_code, + body=body, + ) + + with MockVWS(model_target_failure_response=failure): + await _assert_dataset_error_response( + model_target_model=model_target_model, + status_code=status_code, + body=body, + expected_exception=expected_exception, + ) diff --git a/tests/async_model_targets/test_datasets.py b/tests/async_model_targets/test_datasets.py new file mode 100644 index 000000000..5c4a18bc7 --- /dev/null +++ b/tests/async_model_targets/test_datasets.py @@ -0,0 +1,350 @@ +"""Tests for asynchronous Model Target datasets.""" + +import io +import uuid +import zipfile +from http import HTTPStatus + +import pytest +from mock_vws import ( + MockVWS, + ModelTargetGenerationFailure, + ModelTargetGenerationWarning, +) + +from tests.model_target_credentials import CLIENT_CREDENTIALS, CLIENT_ID +from vws import AsyncModelTargetService +from vws.exceptions.model_target_exceptions import ( + ModelTargetDatasetNotDoneError, + ModelTargetDatasetTimeoutError, + ModelTargetValidationError, + UnknownModelTargetDatasetError, +) +from vws.model_target_datasets import ( + CadDataFormat, + ModelTargetDatasetType, + ModelTargetModel, + RealisticAppearance, +) +from vws.reports import ModelTargetDatasetStatuses + +_DATASET_TYPES = [ + ModelTargetDatasetType.STANDARD, + ModelTargetDatasetType.ADVANCED, +] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + argnames="dataset_type", + argvalues=_DATASET_TYPES, +) +async def test_create_wait_download_delete( + *, + async_model_target_client: AsyncModelTargetService, + model_target_model: ModelTargetModel, + dataset_type: ModelTargetDatasetType, +) -> None: + """A dataset can be created, downloaded and then deleted.""" + dataset_uuid = await async_model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=dataset_type, + ) + + report = await async_model_target_client.wait_for_dataset_generated( + dataset_uuid=dataset_uuid, + dataset_type=dataset_type, + ) + + assert report.status == ModelTargetDatasetStatuses.DONE + assert report.dataset_uuid == dataset_uuid + assert report.completed_at is not None + assert report.eta is None + assert report.error is None + assert report.warning is None + + dataset = await async_model_target_client.download_dataset( + dataset_uuid=dataset_uuid, + dataset_type=dataset_type, + ) + + with zipfile.ZipFile(file=io.BytesIO(initial_bytes=dataset)) as archive: + assert archive.namelist() == ["MTDataset.dat", "MTDataset.xml"] + + await async_model_target_client.delete_dataset( + dataset_uuid=dataset_uuid, + dataset_type=dataset_type, + ) + + with pytest.raises(expected_exception=UnknownModelTargetDatasetError): + await async_model_target_client.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=dataset_type, + ) + + +@pytest.mark.asyncio +async def test_status_while_processing( + *, + async_model_target_client: AsyncModelTargetService, + model_target_model: ModelTargetModel, +) -> None: + """A processing dataset has an estimated completion time.""" + dataset_uuid = await async_model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + report = await async_model_target_client.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert report.status == ModelTargetDatasetStatuses.PROCESSING + assert report.eta is not None + assert report.completed_at is None + + +@pytest.mark.asyncio +async def test_download_while_processing( + *, + async_model_target_client: AsyncModelTargetService, + model_target_model: ModelTargetModel, +) -> None: + """A dataset cannot be downloaded before it is generated.""" + dataset_uuid = await async_model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + with pytest.raises( + expected_exception=ModelTargetDatasetNotDoneError, + ) as exc: + await async_model_target_client.download_dataset( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert exc.value.code == "UNSUPPORTED_STATE" + + +@pytest.mark.asyncio +async def test_dataset_is_visible_to_other_type( + *, + async_model_target_client: AsyncModelTargetService, + model_target_model: ModelTargetModel, +) -> None: + """Standard and advanced routes share datasets by UUID.""" + dataset_uuid = await async_model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.ADVANCED, + ) + + report = await async_model_target_client.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert report.dataset_uuid == dataset_uuid + + +@pytest.mark.asyncio +async def test_advanced_dataset_takes_multiple_models( + *, + async_model_target_client: AsyncModelTargetService, + model_target_model: ModelTargetModel, +) -> None: + """An advanced dataset can be generated from multiple models.""" + other_model = ModelTargetModel( + name="other-model", + cad_data_blob="ZmFrZS1jYWQtZGF0YQ==", + cad_data_format=CadDataFormat.GLB, + realistic_appearance=RealisticAppearance.TRUE, + views=[], + ) + + assert bool( + await async_model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model, other_model], + dataset_type=ModelTargetDatasetType.ADVANCED, + ) + ) + + +@pytest.mark.asyncio +async def test_get_status( + *, + async_model_target_client: AsyncModelTargetService, +) -> None: + """An exception is raised for an unknown dataset.""" + dataset_uuid = uuid.uuid4().hex + with pytest.raises( + expected_exception=UnknownModelTargetDatasetError, + ) as exc: + await async_model_target_client.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert exc.value.response.status_code == HTTPStatus.NOT_FOUND + assert dataset_uuid in exc.value.message + + +@pytest.mark.asyncio +async def test_download( + *, + async_model_target_client: AsyncModelTargetService, +) -> None: + """An exception is raised for an unknown dataset.""" + with pytest.raises(expected_exception=UnknownModelTargetDatasetError): + await async_model_target_client.download_dataset( + dataset_uuid=uuid.uuid4().hex, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + +@pytest.mark.asyncio +async def test_delete( + *, + async_model_target_client: AsyncModelTargetService, +) -> None: + """An exception is raised for an unknown dataset.""" + with pytest.raises(expected_exception=UnknownModelTargetDatasetError): + await async_model_target_client.delete_dataset( + dataset_uuid=uuid.uuid4().hex, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + +@pytest.mark.asyncio +async def test_no_cad_data( + *, + async_model_target_client: AsyncModelTargetService, +) -> None: + """A model needs exactly one CAD data source.""" + with pytest.raises( + expected_exception=ModelTargetValidationError, + ) as exc: + await async_model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[ModelTargetModel(name="model")], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST + (detail,) = exc.value.details + assert detail.code == "VALIDATION_ERROR" + + +@pytest.mark.asyncio +async def test_generation_failure( + *, + model_target_model: ModelTargetModel, +) -> None: + """A dataset which fails to generate reports the failure.""" + message = "Model Target dataset generation failed" + failure = ModelTargetGenerationFailure(message=message) + with MockVWS( + processing_time_seconds=0.2, + model_target_generation_failure=failure, + ): + async with AsyncModelTargetService( + client_id=CLIENT_ID, + client_secret=CLIENT_CREDENTIALS[1], + ) as client: + dataset_uuid = await client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + report = await client.wait_for_dataset_generated( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert report.status == ModelTargetDatasetStatuses.FAILED + assert report.error is not None + assert report.error.message == message + + +@pytest.mark.asyncio +async def test_generation_warning( + *, + model_target_model: ModelTargetModel, +) -> None: + """A dataset which generates with a warning reports the + warning. + """ + warning = ModelTargetGenerationWarning() + with MockVWS( + processing_time_seconds=0.2, + model_target_generation_warning=warning, + ): + async with AsyncModelTargetService( + client_id=CLIENT_ID, + client_secret=CLIENT_CREDENTIALS[1], + ) as client: + dataset_uuid = await client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + report = await client.wait_for_dataset_generated( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert report.status == ModelTargetDatasetStatuses.DONE + assert report.warning is not None + assert report.warning.target == dataset_uuid + (detail,) = report.warning.details + assert detail.code == "LOW_RECOGNITION_QUALITY" + + +@pytest.mark.asyncio +async def test_timeout(*, model_target_model: ModelTargetModel) -> None: + """An exception is raised when the wait times out.""" + with MockVWS(processing_time_seconds=60): + async with AsyncModelTargetService( + client_id=CLIENT_ID, + client_secret=CLIENT_CREDENTIALS[1], + ) as client: + dataset_uuid = await client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + with pytest.raises( + expected_exception=ModelTargetDatasetTimeoutError, + ): + await client.wait_for_dataset_generated( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + seconds_between_requests=0.01, + timeout_seconds=0.05, + ) + + report = await client.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert report.status == ModelTargetDatasetStatuses.PROCESSING diff --git a/tests/async_vws/__init__.py b/tests/async_vws/__init__.py new file mode 100644 index 000000000..a5dc1e863 --- /dev/null +++ b/tests/async_vws/__init__.py @@ -0,0 +1 @@ +"""Async vws tests.""" diff --git a/tests/async_vws/test_configuration.py b/tests/async_vws/test_configuration.py new file mode 100644 index 000000000..d873f563a --- /dev/null +++ b/tests/async_vws/test_configuration.py @@ -0,0 +1,40 @@ +"""Tests for `AsyncVWS` configuration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, BinaryIO + +import pytest +from mock_vws import MockVWS +from mock_vws.database import CloudDatabase + +from vws import AsyncVWS + +if TYPE_CHECKING: + import io + + +@pytest.mark.asyncio +async def test_custom_base_url( + image: io.BytesIO | BinaryIO, +) -> None: + """It is possible to add a target to a database under a + custom VWS URL. + """ + base_vws_url = "http://example.com" + with MockVWS(base_vws_url=base_vws_url) as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + async_vws_client = AsyncVWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + base_vws_url=base_vws_url, + ) + + await async_vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) diff --git a/tests/async_vws/test_processing.py b/tests/async_vws/test_processing.py new file mode 100644 index 000000000..c4014d8e5 --- /dev/null +++ b/tests/async_vws/test_processing.py @@ -0,0 +1,77 @@ +"""Tests for `AsyncVWS` processing.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, BinaryIO + +import pytest +from mock_vws import MockVWS +from mock_vws.database import CloudDatabase + +from vws import AsyncVWS +from vws.exceptions.custom_exceptions import ( + TargetProcessingTimeoutError, +) +from vws.reports import ( + TargetStatuses, +) + +if TYPE_CHECKING: + import io + + +@pytest.mark.asyncio +async def test_wait_for_target_processed( + *, + async_vws_client: AsyncVWS, + image: io.BytesIO | BinaryIO, +) -> None: + """It is possible to wait until a target is processed.""" + target_id = await async_vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + report = await async_vws_client.get_target_summary_report( + target_id=target_id, + ) + assert report.status == TargetStatuses.PROCESSING + await async_vws_client.wait_for_target_processed( + target_id=target_id, + ) + report = await async_vws_client.get_target_summary_report( + target_id=target_id, + ) + assert report.status != TargetStatuses.PROCESSING + + +@pytest.mark.asyncio +async def test_custom_timeout( + image: io.BytesIO | BinaryIO, +) -> None: + """It is possible to set a maximum timeout.""" + with MockVWS(processing_time_seconds=0.5) as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + async_vws_client = AsyncVWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + target_id = await async_vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + with pytest.raises( + expected_exception=(TargetProcessingTimeoutError), + ): + await async_vws_client.wait_for_target_processed( + target_id=target_id, + timeout_seconds=0.1, + ) diff --git a/tests/async_vws/test_reports.py b/tests/async_vws/test_reports.py new file mode 100644 index 000000000..eac3ceb13 --- /dev/null +++ b/tests/async_vws/test_reports.py @@ -0,0 +1,281 @@ +"""Tests for `AsyncVWS` reports.""" + +from __future__ import annotations + +import calendar +import time +import uuid +from http import HTTPStatus +from typing import TYPE_CHECKING, BinaryIO + +import pytest +from mock_vws import MockVWS +from mock_vws.database import CloudDatabase + +from vws import AsyncVWS +from vws.exceptions.custom_exceptions import ( + DatabaseIdNotSetError, + RecoCountsReportDownloadError, + RecoCountsReportNotReadyError, + RecoCountsReportTimeoutError, +) +from vws.exceptions.vws_exceptions import ( + AuthenticationFailureError, + FailError, +) +from vws.reports import ( + DatabaseSummaryReport, +) +from vws.response import Response + +if TYPE_CHECKING: + import datetime + import io + + +class _ForbiddenDownloadTransport: + """An async transport which refuses to serve a report, as an expired + URL would. + """ + + async def aclose(self) -> None: + """Close the transport.""" + + async def __call__( + self, + *, + method: str, + url: str, + headers: dict[str, str], + data: bytes, + request_timeout: float | tuple[float, float], + ) -> Response: + """Return a "forbidden" response.""" + del method, headers, request_timeout + body = "AccessDenied" + return Response( + text=body, + url=url, + status_code=HTTPStatus.FORBIDDEN, + headers={}, + request_body=data, + tell_position=0, + content=body.encode(encoding="utf-8"), + ) + + +@pytest.mark.asyncio +async def test_get_target_summary_report( + *, + async_vws_client: AsyncVWS, + image: io.BytesIO | BinaryIO, +) -> None: + """Details of a target are returned by + ``get_target_summary_report``. + """ + target_name = uuid.uuid4().hex + target_id = await async_vws_client.add_target( + name=target_name, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + report = await async_vws_client.get_target_summary_report( + target_id=target_id, + ) + + assert report.target_name == target_name + assert report.active_flag is True + assert report.total_recos == 0 + + +@pytest.mark.asyncio +async def test_get_target( + async_vws_client: AsyncVWS, +) -> None: + """Details of a database are returned by + ``get_database_summary_report``. + """ + report = await async_vws_client.get_database_summary_report() + + assert isinstance(report, DatabaseSummaryReport) + assert report.active_images == 0 + + +@pytest.mark.asyncio +async def test_reco_counts_report( + *, + async_vws_client: AsyncVWS, + report_month: datetime.date, +) -> None: + """A report can be requested, waited for and downloaded.""" + client = async_vws_client + report_request = await client.request_database_reco_counts_report( + year=report_month.year, + month=calendar.Month(value=report_month.month), + ) + assert bool(report_request.transaction_id) + assert bool(report_request.presigned_url) + + report = await client.wait_for_reco_counts_report( + presigned_url=report_request.presigned_url, + ) + + # No targets have been recognized, so the report has no rows. + assert not bool(report.reco_counts) + assert report.raw_csv.startswith(b"target_id,reco_count") + + +@pytest.mark.asyncio +async def test_not_ready(*, current_month: datetime.date) -> None: + """Downloading a report before Vuforia has generated it raises an + error. + """ + with MockVWS(processing_time_seconds=60) as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + async with AsyncVWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + database_id=database.database_id, + ) as client: + report_request = await client.request_database_reco_counts_report( + year=current_month.year, + month=calendar.Month(value=current_month.month), + ) + + with pytest.raises( + expected_exception=RecoCountsReportNotReadyError, + ) as exc: + await client.download_reco_counts_report( + presigned_url=report_request.presigned_url, + ) + + assert exc.value.response.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +async def test_wait_timeout(*, current_month: datetime.date) -> None: + """Waiting for a report which is not generated in time raises an + error. + """ + with MockVWS(processing_time_seconds=60) as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + async with AsyncVWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + database_id=database.database_id, + ) as client: + report_request = await client.request_database_reco_counts_report( + year=current_month.year, + month=calendar.Month(value=current_month.month), + ) + + maximum_wait_seconds = 5 + start_time = time.monotonic() + + with pytest.raises( + expected_exception=RecoCountsReportTimeoutError, + ): + await client.wait_for_reco_counts_report( + presigned_url=report_request.presigned_url, + seconds_between_requests=0.01, + timeout_seconds=0.05, + ) + + elapsed_time = time.monotonic() - start_time + assert elapsed_time < maximum_wait_seconds + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + argnames=("year", "month"), + argvalues=[ + pytest.param(1999, calendar.Month.JANUARY, id="year-in-the-past"), + pytest.param( + 1999, + calendar.Month.DECEMBER, + id="year-in-the-past-december", + ), + ], +) +async def test_month_not_accepted( + *, + async_vws_client: AsyncVWS, + year: int, + month: calendar.Month, +) -> None: + """Months other than the current and previous month are + rejected. + """ + with pytest.raises(expected_exception=FailError) as exc: + await async_vws_client.request_database_reco_counts_report( + year=year, + month=month, + ) + + assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST + + +@pytest.mark.asyncio +async def test_database_id_does_not_match_keys( + *, + current_month: datetime.date, +) -> None: + """A database ID which does not match the given keys is + rejected. + """ + with MockVWS() as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + async with AsyncVWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + database_id=uuid.uuid4().hex, + ) as client: + with pytest.raises( + expected_exception=AuthenticationFailureError, + ) as exc: + await client.request_database_reco_counts_report( + year=current_month.year, + month=calendar.Month(value=current_month.month), + ) + + assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED + + +@pytest.mark.asyncio +async def test_download_error() -> None: + """An error response from the report's URL raises an error.""" + async with AsyncVWS( + server_access_key=uuid.uuid4().hex, + server_secret_key=uuid.uuid4().hex, + transport=_ForbiddenDownloadTransport(), + ) as client: + with pytest.raises( + expected_exception=RecoCountsReportDownloadError, + ) as exc: + await client.download_reco_counts_report( + presigned_url="https://example.com/reports/recoCounts/x", + ) + + assert exc.value.response.status_code == HTTPStatus.FORBIDDEN + + +@pytest.mark.asyncio +async def test_no_database_id(*, current_month: datetime.date) -> None: + """A client which was given no database ID cannot request a + report. + """ + async with AsyncVWS( + server_access_key=uuid.uuid4().hex, + server_secret_key=uuid.uuid4().hex, + ) as client: + with pytest.raises(expected_exception=DatabaseIdNotSetError): + await client.request_database_reco_counts_report( + year=current_month.year, + month=calendar.Month(value=current_month.month), + ) diff --git a/tests/async_vws/test_targets.py b/tests/async_vws/test_targets.py new file mode 100644 index 000000000..ff16890a0 --- /dev/null +++ b/tests/async_vws/test_targets.py @@ -0,0 +1,297 @@ +"""Tests for `AsyncVWS` targets. + +Test for deleting a target. +""" + +from __future__ import annotations + +import base64 +import uuid +from typing import TYPE_CHECKING, BinaryIO + +import pytest + +# pytest-beartype resolves fixture annotations at runtime. +from vws import AsyncCloudRecoService, AsyncVWS # noqa: TC001 +from vws.reports import ( + TargetRecord, + TargetStatuses, +) + +if TYPE_CHECKING: + import io + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + argnames="application_metadata", + argvalues=[None, b"a"], +) +@pytest.mark.parametrize( + argnames="active_flag", + argvalues=[True, False], +) +async def test_add_target( + *, + async_vws_client: AsyncVWS, + image: io.BytesIO | BinaryIO, + application_metadata: bytes | None, + async_cloud_reco_client: AsyncCloudRecoService, + active_flag: bool, +) -> None: + """No exception is raised when adding one target.""" + name = "x" + width = 1 + if application_metadata is None: + encoded_metadata = None + else: + encoded_metadata_bytes = base64.b64encode( + s=application_metadata, + ) + encoded_metadata = encoded_metadata_bytes.decode( + encoding="utf-8", + ) + + target_id = await async_vws_client.add_target( + name=name, + width=width, + image=image, + application_metadata=encoded_metadata, + active_flag=active_flag, + ) + target_record = ( + await async_vws_client.get_target_record( + target_id=target_id, + ) + ).target_record + assert target_record.name == name + assert target_record.width == width + assert target_record.active_flag is active_flag + await async_vws_client.wait_for_target_processed( + target_id=target_id, + ) + matching_targets = await async_cloud_reco_client.query( + image=image, + ) + if active_flag: + [matching_target] = matching_targets + assert matching_target.target_id == target_id + assert matching_target.target_data is not None + query_metadata = matching_target.target_data.application_metadata + assert query_metadata == encoded_metadata + else: + assert matching_targets == [] + + +@pytest.mark.asyncio +async def test_add_two_targets( + *, + async_vws_client: AsyncVWS, + image: io.BytesIO | BinaryIO, +) -> None: + """No exception is raised when adding two targets with + different names. + + This demonstrates that the image seek position is not + changed. + """ + for name in ("a", "b"): + await async_vws_client.add_target( + name=name, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + +@pytest.mark.asyncio +async def test_list_targets( + *, + async_vws_client: AsyncVWS, + image: io.BytesIO | BinaryIO, +) -> None: + """It is possible to get a list of target IDs.""" + id_1 = await async_vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + id_2 = await async_vws_client.add_target( + name="a", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + targets = await async_vws_client.list_targets() + assert sorted(targets) == sorted([id_1, id_2]) + + +@pytest.mark.asyncio +async def test_delete_target( + *, + async_vws_client: AsyncVWS, + image: io.BytesIO | BinaryIO, +) -> None: + """It is possible to delete a target.""" + target_id = await async_vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + await async_vws_client.wait_for_target_processed( + target_id=target_id, + ) + targets = await async_vws_client.list_targets() + assert targets == [target_id] + await async_vws_client.delete_target( + target_id=target_id, + ) + targets = await async_vws_client.list_targets() + assert targets == [] + + +@pytest.mark.asyncio +async def test_get_target_record( + *, + async_vws_client: AsyncVWS, + image: io.BytesIO | BinaryIO, +) -> None: + """Details of a target are returned by + ``get_target_record``. + """ + target_id = await async_vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + result = await async_vws_client.get_target_record( + target_id=target_id, + ) + expected_target_record = TargetRecord( + target_id=target_id, + active_flag=True, + name="x", + width=1, + tracking_rating=-1, + reco_rating="", + ) + + assert result.target_record == expected_target_record + assert result.status == TargetStatuses.PROCESSING + + +@pytest.mark.asyncio +async def test_get_duplicate_targets( + *, + async_vws_client: AsyncVWS, + image: io.BytesIO | BinaryIO, +) -> None: + """It is possible to get the IDs of similar targets.""" + target_id = await async_vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + similar_target_id = await async_vws_client.add_target( + name="a", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + await async_vws_client.wait_for_target_processed( + target_id=target_id, + ) + await async_vws_client.wait_for_target_processed( + target_id=similar_target_id, + ) + duplicates = await async_vws_client.get_duplicate_targets( + target_id=target_id, + ) + assert duplicates == [similar_target_id] + + +@pytest.mark.asyncio +async def test_update_target( + *, + async_vws_client: AsyncVWS, + async_cloud_reco_client: AsyncCloudRecoService, + image: io.BytesIO | BinaryIO, + different_high_quality_image: io.BytesIO, +) -> None: + """It is possible to update a target.""" + target_id = await async_vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + await async_vws_client.wait_for_target_processed( + target_id=target_id, + ) + [matching_target] = await async_cloud_reco_client.query( + image=image, + ) + assert matching_target.target_id == target_id + query_target_data = matching_target.target_data + assert query_target_data is not None + assert query_target_data.application_metadata is None + + new_name = uuid.uuid4().hex + new_width = 2.0 + new_application_metadata = base64.b64encode( + s=b"a", + ).decode(encoding="ascii") + await async_vws_client.update_target( + target_id=target_id, + name=new_name, + width=new_width, + active_flag=True, + image=different_high_quality_image, + application_metadata=new_application_metadata, + ) + + await async_vws_client.wait_for_target_processed( + target_id=target_id, + ) + target_details = await async_vws_client.get_target_record( + target_id=target_id, + ) + assert target_details.target_record.name == new_name + assert target_details.target_record.active_flag + + +@pytest.mark.asyncio +async def test_no_fields_given( + *, + async_vws_client: AsyncVWS, + image: io.BytesIO | BinaryIO, +) -> None: + """It is possible to give no update fields.""" + target_id = await async_vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + await async_vws_client.wait_for_target_processed( + target_id=target_id, + ) + await async_vws_client.update_target( + target_id=target_id, + ) diff --git a/tests/async_vws/test_vumark.py b/tests/async_vws/test_vumark.py new file mode 100644 index 000000000..2d94289ff --- /dev/null +++ b/tests/async_vws/test_vumark.py @@ -0,0 +1,46 @@ +"""Tests for `AsyncVWS` vumark.""" + +from __future__ import annotations + +import pytest + +# pytest-beartype resolves fixture annotations at runtime. +from vws import AsyncVuMarkService # noqa: TC001 +from vws.vumark_accept import VuMarkAccept + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + argnames=("accept", "expected_prefix"), + argvalues=[ + pytest.param( + VuMarkAccept.PNG, + b"\x89PNG\r\n\x1a\n", + id="png", + ), + pytest.param( + VuMarkAccept.SVG, + b"<", + id="svg", + ), + pytest.param( + VuMarkAccept.PDF, + b"%PDF", + id="pdf", + ), + ], +) +async def test_generate_vumark_instance( + *, + async_vumark_service_client: AsyncVuMarkService, + vumark_target_id: str, + accept: VuMarkAccept, + expected_prefix: bytes, +) -> None: + """The returned bytes match the requested format.""" + result = await async_vumark_service_client.generate_vumark_instance( + target_id=vumark_target_id, + instance_id="12345", + accept=accept, + ) + assert result.startswith(expected_prefix) diff --git a/tests/model_target_credentials.py b/tests/model_target_credentials.py new file mode 100644 index 000000000..6e42b5a39 --- /dev/null +++ b/tests/model_target_credentials.py @@ -0,0 +1,6 @@ +"""Credentials accepted by the mock Model Target Web API.""" + +# The mock accepts one hard-coded pair of Model Target Web API OAuth2 +# credentials, which it does not expose. +CLIENT_ID = "client-id" +CLIENT_CREDENTIALS = ("client-id", "client-secret") diff --git a/tests/model_targets/__init__.py b/tests/model_targets/__init__.py new file mode 100644 index 000000000..5a18c1e4a --- /dev/null +++ b/tests/model_targets/__init__.py @@ -0,0 +1 @@ +"""Model targets tests.""" diff --git a/tests/model_targets/responses.py b/tests/model_targets/responses.py new file mode 100644 index 000000000..fa7c8c334 --- /dev/null +++ b/tests/model_targets/responses.py @@ -0,0 +1,34 @@ +"""Response builders for Model Target API tests.""" + +from http import HTTPStatus + +from beartype import beartype + +from vws.response import Response + + +@beartype +def response_with_status( + *, + text: str, + status_code: HTTPStatus, +) -> Response: + """Get a response with a given body. + + Args: + text: The body of the response. + status_code: The response status code. + + Returns: + A response with the given body. + """ + content = text.encode(encoding="utf-8") + return Response( + text=text, + url="https://vws.vuforia.com/modeltargets/datasets", + status_code=status_code, + headers={}, + request_body=None, + tell_position=len(content), + content=content, + ) diff --git a/tests/model_targets/test_authentication.py b/tests/model_targets/test_authentication.py new file mode 100644 index 000000000..c410ada7f --- /dev/null +++ b/tests/model_targets/test_authentication.py @@ -0,0 +1,236 @@ +"""Tests for Model Target authentication.""" + +import secrets +from http import HTTPStatus + +import pytest +from beartype import beartype +from freezegun import freeze_time +from mock_vws import ( + MockVWS, + ModelTargetFailureResponse, +) + +from tests.model_target_credentials import CLIENT_CREDENTIALS, CLIENT_ID +from vws import ModelTargetService +from vws.exceptions.custom_exceptions import ServerError +from vws.exceptions.model_target_exceptions import ( + ModelTargetAuthenticationError, + ModelTargetError, + ModelTargetOAuth2Error, +) +from vws.exceptions.vws_exceptions import TooManyRequestsError +from vws.model_target_datasets import ( + ModelTargetDatasetType, + ModelTargetModel, +) + +# beartype resolves the decorated transport's return type at runtime. +from vws.response import Response # noqa: TC001 +from vws.transports import RequestsTransport, Transport + + +@beartype +class _CountingTransport: + """A transport which counts the requests made to each path.""" + + def __init__(self, *, transport: Transport) -> None: + """ + Args: + transport: The transport to make requests with. + """ + self._transport = transport + self.urls: list[str] = [] + + def close(self) -> None: + """Close the wrapped transport.""" + self._transport.close() + + def __call__( + self, + *, + method: str, + url: str, + headers: dict[str, str], + data: bytes, + request_timeout: float | tuple[float, float], + ) -> Response: + """Make a request, recording the URL. + + Args: + method: The HTTP method. + url: The full URL. + headers: Request headers. + data: The request body. + request_timeout: The request timeout. + + Returns: + A Response populated from the HTTP response. + """ + self.urls.append(url) + return self._transport( + method=method, + url=url, + headers=headers, + data=data, + request_timeout=request_timeout, + ) + + +@pytest.mark.usefixtures("_mock_model_targets") +def test_token_is_a_bearer_token() -> None: + """An access token is given for valid credentials.""" + client = ModelTargetService( + client_id=CLIENT_ID, + client_secret=CLIENT_CREDENTIALS[1], + ) + + assert bool(client.get_access_token()) + + +@pytest.mark.usefixtures("_mock_model_targets") +def test_token_is_reused( + *, + model_target_model: ModelTargetModel, +) -> None: + """One access token is used for multiple requests.""" + transport = _CountingTransport(transport=RequestsTransport()) + client = ModelTargetService( + client_id=CLIENT_ID, + client_secret=CLIENT_CREDENTIALS[1], + transport=transport, + ) + + for _ in range(2): + _ = client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + token_urls = [url for url in transport.urls if "oauth2" in url] + assert len(token_urls) == 1 + transport.close() + + +@pytest.mark.usefixtures("_mock_model_targets") +def test_expired_token_is_replaced( + *, + model_target_model: ModelTargetModel, +) -> None: + """A new access token is requested once the old one expires.""" + transport = _CountingTransport(transport=RequestsTransport()) + client = ModelTargetService( + client_id=CLIENT_ID, + client_secret=CLIENT_CREDENTIALS[1], + transport=transport, + ) + + with freeze_time(time_to_freeze="2026-01-01") as frozen_time: + _ = client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + # Mock tokens last an hour. + _ = frozen_time.tick(delta=60 * 60 + 1) + _ = client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + token_urls = [url for url in transport.urls if "oauth2" in url] + expected_token_request_count = 2 + assert len(token_urls) == expected_token_request_count + + +@pytest.mark.usefixtures("_mock_model_targets") +def test_invalid_credentials() -> None: + """An exception is raised when the credentials are not known.""" + client = ModelTargetService( + client_id="not-a-client-id", + client_secret=secrets.token_hex(), + ) + + with pytest.raises( + expected_exception=ModelTargetOAuth2Error, + ) as exc: + _ = client.get_access_token() + + assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED + assert exc.value.error == "invalid_client" + assert not bool(exc.value.error_description) + + +@pytest.mark.parametrize( + argnames=("status_code", "body", "expected_exception"), + argvalues=[ + pytest.param( + HTTPStatus.UNAUTHORIZED, + '{"error":{"code":"AUTHENTICATION_ERROR","message":"No"}}', + ModelTargetAuthenticationError, + id="authentication", + ), + pytest.param( + HTTPStatus.FORBIDDEN, + '{"error":{"code":"FORBIDDEN","message":"Denied"}}', + ModelTargetError, + id="generic-json", + ), + pytest.param( + HTTPStatus.CONFLICT, + "not json", + ModelTargetError, + id="generic-non-json", + ), + pytest.param( + HTTPStatus.TOO_MANY_REQUESTS, + "rate limited", + TooManyRequestsError, + id="rate-limit", + ), + pytest.param( + HTTPStatus.BAD_GATEWAY, + "server error", + ServerError, + id="server-error", + ), + ], +) +def test_dataset_error_response( + *, + model_target_model: ModelTargetModel, + status_code: HTTPStatus, + body: str, + expected_exception: ( + type[ModelTargetError | TooManyRequestsError | ServerError] + ), +) -> None: + """Dataset failures map to exceptions through the mock.""" + failure = ModelTargetFailureResponse( + status_code=status_code, + body=body, + ) + client = ModelTargetService( + client_id=CLIENT_ID, + client_secret=CLIENT_CREDENTIALS[1], + ) + + with ( + MockVWS(model_target_failure_response=failure), + pytest.raises(expected_exception=expected_exception) as exc, + ): + _ = client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert isinstance(exc.value, expected_exception) + assert exc.value.response.status_code == status_code + assert exc.value.response.text == body diff --git a/tests/model_targets/test_configuration.py b/tests/model_targets/test_configuration.py new file mode 100644 index 000000000..00a0736a9 --- /dev/null +++ b/tests/model_targets/test_configuration.py @@ -0,0 +1,44 @@ +"""Tests for Model Target configuration.""" + +from mock_vws import ( + MockVWS, +) + +from tests.model_target_credentials import CLIENT_CREDENTIALS, CLIENT_ID +from vws import ModelTargetService +from vws.model_target_datasets import ( + ModelTargetDatasetType, + ModelTargetModel, +) + + +def test_custom_base_url( + *, + model_target_model: ModelTargetModel, +) -> None: + """The Model Target Web API can be served from a URL with a + path. + """ + base_vws_url = "https://example.com/vws" + with MockVWS( + base_vws_url=base_vws_url, + processing_time_seconds=0.2, + ): + client = ModelTargetService( + client_id=CLIENT_ID, + client_secret=CLIENT_CREDENTIALS[1], + base_vws_url=base_vws_url, + ) + dataset_uuid = client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + report = client.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert report.dataset_uuid == dataset_uuid diff --git a/tests/model_targets/test_datasets.py b/tests/model_targets/test_datasets.py new file mode 100644 index 000000000..8cdae58e3 --- /dev/null +++ b/tests/model_targets/test_datasets.py @@ -0,0 +1,445 @@ +"""Tests for Model Target datasets.""" + +import io +import json +import uuid +import zipfile +from http import HTTPStatus + +import pytest +from mock_vws import ( + MockVWS, + ModelTargetGenerationFailure, + ModelTargetGenerationWarning, +) + +from tests.model_target_credentials import CLIENT_CREDENTIALS, CLIENT_ID +from tests.model_targets.responses import response_with_status +from vws import ModelTargetService +from vws._model_targets import access_token_from_response +from vws.exceptions.model_target_exceptions import ( + ModelTargetDatasetNotDoneError, + ModelTargetDatasetTimeoutError, + ModelTargetOAuth2Error, + ModelTargetValidationError, + UnknownModelTargetDatasetError, +) +from vws.model_target_datasets import ( + CadDataFormat, + GuideViewPosition, + ModelTargetDatasetType, + ModelTargetModel, + ModelTargetView, + RealisticAppearance, +) +from vws.reports import ModelTargetDatasetStatuses + +_DATASET_TYPES = [ + ModelTargetDatasetType.STANDARD, + ModelTargetDatasetType.ADVANCED, +] + + +@pytest.mark.parametrize( + argnames="dataset_type", + argvalues=_DATASET_TYPES, +) +def test_create_wait_download_delete( + *, + model_target_client: ModelTargetService, + model_target_model: ModelTargetModel, + dataset_type: ModelTargetDatasetType, +) -> None: + """A dataset can be created, downloaded and then deleted.""" + dataset_uuid = model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=dataset_type, + ) + + report = model_target_client.wait_for_dataset_generated( + dataset_uuid=dataset_uuid, + dataset_type=dataset_type, + ) + + assert report.status == ModelTargetDatasetStatuses.DONE + assert report.dataset_uuid == dataset_uuid + assert report.completed_at is not None + assert report.completed_at >= report.created_at + assert report.eta is None + assert report.error is None + assert report.warning is None + + dataset = model_target_client.download_dataset( + dataset_uuid=dataset_uuid, + dataset_type=dataset_type, + ) + + with zipfile.ZipFile(file=io.BytesIO(initial_bytes=dataset)) as archive: + assert archive.namelist() == ["MTDataset.dat", "MTDataset.xml"] + + model_target_client.delete_dataset( + dataset_uuid=dataset_uuid, + dataset_type=dataset_type, + ) + + with pytest.raises(expected_exception=UnknownModelTargetDatasetError): + _ = model_target_client.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=dataset_type, + ) + + +def test_status_while_processing( + *, + model_target_client: ModelTargetService, + model_target_model: ModelTargetModel, +) -> None: + """A processing dataset has an estimated completion time.""" + dataset_uuid = model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + report = model_target_client.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert report.status == ModelTargetDatasetStatuses.PROCESSING + assert report.eta is not None + assert report.eta >= report.created_at + assert report.completed_at is None + + +def test_download_while_processing( + *, + model_target_client: ModelTargetService, + model_target_model: ModelTargetModel, +) -> None: + """A dataset cannot be downloaded before it is generated.""" + dataset_uuid = model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + with pytest.raises( + expected_exception=ModelTargetDatasetNotDoneError, + ) as exc: + _ = model_target_client.download_dataset( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert exc.value.code == "UNSUPPORTED_STATE" + assert exc.value.target == dataset_uuid + + +def test_dataset_is_visible_to_other_type( + *, + model_target_client: ModelTargetService, + model_target_model: ModelTargetModel, +) -> None: + """Standard and advanced routes share datasets by UUID.""" + dataset_uuid = model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + report = model_target_client.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.ADVANCED, + ) + + assert report.dataset_uuid == dataset_uuid + + +def test_advanced_dataset_takes_multiple_models( + *, + model_target_client: ModelTargetService, + model_target_model: ModelTargetModel, +) -> None: + """An advanced dataset can be generated from multiple models.""" + other_model = ModelTargetModel( + name="other-model", + cad_data_blob="ZmFrZS1jYWQtZGF0YQ==", + cad_data_format=CadDataFormat.GLB, + realistic_appearance=RealisticAppearance.TRUE, + views=[], + ) + + dataset_uuid = model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model, other_model], + dataset_type=ModelTargetDatasetType.ADVANCED, + ) + + assert bool(dataset_uuid) + + +def test_state_based_model( + *, + model_target_client: ModelTargetService, +) -> None: + """A State-Based Model Target dataset can be created.""" + configuration = json.dumps(obj={"states": {"open": {}, "closed": {}}}) + model = ModelTargetModel( + name="model", + cad_data_url="https://example.com/model.zip", + cad_data_format=CadDataFormat.ZIP, + state_based_configuration_json_string=configuration, + views=[ + ModelTargetView( + name="front", + guide_view_position=GuideViewPosition( + rotation=[0.0, 0.0, 0.0, 1.0], + translation=[0.0, 0.0, 1.0], + ), + states=["open"], + ), + ], + ) + + assert bool( + model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + ) + + +def test_get_status(*, model_target_client: ModelTargetService) -> None: + """An exception is raised for an unknown dataset.""" + dataset_uuid = uuid.uuid4().hex + with pytest.raises( + expected_exception=UnknownModelTargetDatasetError, + ) as exc: + _ = model_target_client.get_dataset_status( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert exc.value.response.status_code == HTTPStatus.NOT_FOUND + assert exc.value.code == "NOT_FOUND" + assert dataset_uuid in exc.value.message + + +def test_download(*, model_target_client: ModelTargetService) -> None: + """An exception is raised for an unknown dataset.""" + with pytest.raises(expected_exception=UnknownModelTargetDatasetError): + _ = model_target_client.download_dataset( + dataset_uuid=uuid.uuid4().hex, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + +def test_delete(*, model_target_client: ModelTargetService) -> None: + """An exception is raised for an unknown dataset.""" + with pytest.raises(expected_exception=UnknownModelTargetDatasetError): + model_target_client.delete_dataset( + dataset_uuid=uuid.uuid4().hex, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + +def test_no_cad_data( + *, + model_target_client: ModelTargetService, +) -> None: + """A model needs exactly one CAD data source.""" + with pytest.raises( + expected_exception=ModelTargetValidationError, + ) as exc: + _ = model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[ModelTargetModel(name="model", views=[])], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST + assert exc.value.code == "BAD_REQUEST" + (detail,) = exc.value.details + assert detail.code == "VALIDATION_ERROR" + assert "cadDataUrl" in detail.message + + +def test_two_cad_data_sources( + *, + model_target_client: ModelTargetService, +) -> None: + """A model cannot give two CAD data sources.""" + model = ModelTargetModel( + name="model", + cad_data_url="https://example.com/model.zip", + cad_data_blob="ZmFrZS1jYWQtZGF0YQ==", + ) + + with pytest.raises(expected_exception=ModelTargetValidationError): + _ = model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + +def test_two_models_in_a_standard_dataset( + *, + model_target_client: ModelTargetService, + model_target_model: ModelTargetModel, +) -> None: + """A standard dataset takes exactly one model.""" + with pytest.raises( + expected_exception=ModelTargetValidationError, + ) as exc: + _ = model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model, model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + (detail,) = exc.value.details + assert detail.message == "exactly one model should be provided" + + +def test_generation_failure( + *, + model_target_model: ModelTargetModel, +) -> None: + """A dataset which fails to generate reports the failure.""" + message = "Model Target dataset generation failed" + failure = ModelTargetGenerationFailure(message=message) + with MockVWS( + processing_time_seconds=0.2, + model_target_generation_failure=failure, + ): + client = ModelTargetService( + client_id=CLIENT_ID, + client_secret=CLIENT_CREDENTIALS[1], + ) + dataset_uuid = client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + report = client.wait_for_dataset_generated( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert report.status == ModelTargetDatasetStatuses.FAILED + assert report.error is not None + assert report.error.message == message + assert report.warning is None + + with pytest.raises( + expected_exception=ModelTargetDatasetNotDoneError, + ): + _ = client.download_dataset( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + +def test_generation_warning( + *, + model_target_model: ModelTargetModel, +) -> None: + """A dataset which generates with a warning reports the + warning. + """ + warning = ModelTargetGenerationWarning() + with MockVWS( + processing_time_seconds=0.2, + model_target_generation_warning=warning, + ): + client = ModelTargetService( + client_id=CLIENT_ID, + client_secret=CLIENT_CREDENTIALS[1], + ) + dataset_uuid = client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + report = client.wait_for_dataset_generated( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + assert report.status == ModelTargetDatasetStatuses.DONE + assert report.error is None + assert report.warning is not None + assert report.warning.message == warning.message + assert report.warning.target == dataset_uuid + (detail,) = report.warning.details + assert detail.code == "LOW_RECOGNITION_QUALITY" + + assert bool( + client.download_dataset( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + ) + + +def test_timeout(*, model_target_model: ModelTargetModel) -> None: + """An exception is raised when the wait times out.""" + with MockVWS(processing_time_seconds=60): + client = ModelTargetService( + client_id=CLIENT_ID, + client_secret=CLIENT_CREDENTIALS[1], + ) + dataset_uuid = client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + with pytest.raises( + expected_exception=ModelTargetDatasetTimeoutError, + ): + _ = client.wait_for_dataset_generated( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + seconds_between_requests=0.01, + timeout_seconds=0.05, + ) + + +@pytest.mark.parametrize( + argnames="payload", + argvalues=[ + {"access_token": 1, "expires_in": 3600}, + {"access_token": "token", "expires_in": []}, + ], +) +def test_invalid_oauth2_token_response_values( + *, payload: dict[str, object] +) -> None: + """OAuth token responses must contain correctly typed values.""" + response = response_with_status( + text=json.dumps(obj=payload), + status_code=HTTPStatus.OK, + ) + + with pytest.raises(expected_exception=ModelTargetOAuth2Error): + _ = access_token_from_response(response=response) diff --git a/tests/model_targets/test_errors.py b/tests/model_targets/test_errors.py new file mode 100644 index 000000000..d50ad68fa --- /dev/null +++ b/tests/model_targets/test_errors.py @@ -0,0 +1,84 @@ +"""Tests for Model Target errors.""" + +import json +from http import HTTPStatus + +import pytest +from beartype import beartype + +from tests.model_targets.responses import response_with_status +from vws.exceptions.model_target_exceptions import ( + ModelTargetError, + ModelTargetOAuth2Error, +) +from vws.response import Response + + +@beartype +def _response(*, text: str) -> Response: + """Get a bad-request response with a given body.""" + return response_with_status( + text=text, + status_code=HTTPStatus.BAD_REQUEST, + ) + + +@pytest.mark.parametrize( + argnames="text", + argvalues=[ + "", + "Not JSON", + "[]", + "{}", + '{"error": "not-an-object"}', + '{"transaction_id": "abc", "result_code": "Fail"}', + ], +) +def test_unknown_error_shape(*, text: str) -> None: + """An error without a Model Target error object gives empty + values. + """ + error = ModelTargetError(response=_response(text=text)) + + assert not bool(error.code) + assert not bool(error.message) + assert not bool(error.target) + assert not bool(error.details) + + +def test_error_without_details() -> None: + """An error which gives no details has no details.""" + text = json.dumps(obj={"error": {"code": "ERROR", "message": "No"}}) + error = ModelTargetError(response=_response(text=text)) + + assert error.code == "ERROR" + assert error.message == "No" + assert not bool(error.target) + assert not bool(error.details) + + +@pytest.mark.parametrize( + argnames="text", + argvalues=["Not JSON", "[]", "{}"], +) +def test_unknown_oauth2_error_shape(*, text: str) -> None: + """An OAuth2 error without an error code gives empty values.""" + error = ModelTargetOAuth2Error(response=_response(text=text)) + + assert not bool(error.error) + assert not bool(error.error_description) + + +def test_oauth2_error_description() -> None: + """An OAuth2 error description is given when Vuforia gives one.""" + description = "Missing or invalid authorization header" + text = json.dumps( + obj={ + "error": "invalid_request", + "error_description": description, + }, + ) + error = ModelTargetOAuth2Error(response=_response(text=text)) + + assert error.error == "invalid_request" + assert error.error_description == description diff --git a/tests/test_async_model_targets.py b/tests/test_async_model_targets.py deleted file mode 100644 index ca4662694..000000000 --- a/tests/test_async_model_targets.py +++ /dev/null @@ -1,508 +0,0 @@ -"""Tests for the async Model Target Web API client.""" - -import io -import secrets -import uuid -import zipfile -from http import HTTPStatus - -import pytest -from mock_vws import ( - MockVWS, - ModelTargetFailureResponse, - ModelTargetGenerationFailure, - ModelTargetGenerationWarning, -) - -from vws import AsyncModelTargetService -from vws.exceptions.custom_exceptions import ServerError -from vws.exceptions.model_target_exceptions import ( - ModelTargetAuthenticationError, - ModelTargetDatasetNotDoneError, - ModelTargetDatasetTimeoutError, - ModelTargetError, - ModelTargetOAuth2Error, - ModelTargetValidationError, - UnknownModelTargetDatasetError, -) -from vws.exceptions.vws_exceptions import TooManyRequestsError -from vws.model_target_datasets import ( - CadDataFormat, - ModelTargetDatasetType, - ModelTargetModel, - RealisticAppearance, -) -from vws.reports import ModelTargetDatasetStatuses - -# The mock accepts one hard-coded pair of Model Target Web API OAuth2 -# credentials, which it does not expose. -_CLIENT_ID = "client-id" -_CLIENT_CREDENTIALS = ("client-id", "client-secret") - -_DATASET_TYPES = [ - ModelTargetDatasetType.STANDARD, - ModelTargetDatasetType.ADVANCED, -] - - -async def _assert_dataset_error_response( - *, - model_target_model: ModelTargetModel, - status_code: HTTPStatus, - body: str, - expected_exception: ( - type[ModelTargetError | TooManyRequestsError | ServerError] - ), -) -> None: - """Assert that a mocked dataset failure maps to an exception.""" - async with AsyncModelTargetService( - client_id=_CLIENT_ID, - client_secret=_CLIENT_CREDENTIALS[1], - ) as client: - with pytest.raises( - expected_exception=( - ModelTargetError, - TooManyRequestsError, - ServerError, - ) - ) as exc: - await client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model], - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - assert isinstance(exc.value, expected_exception) - assert exc.value.response.status_code == status_code - assert exc.value.response.text == body - - -class TestAccessToken: - """Tests for getting an access token.""" - - @staticmethod - @pytest.mark.asyncio - async def test_token_is_a_bearer_token( - *, - async_model_target_client: AsyncModelTargetService, - ) -> None: - """An access token is given for valid credentials.""" - assert bool(await async_model_target_client.get_access_token()) - - @staticmethod - @pytest.mark.asyncio - @pytest.mark.usefixtures("_mock_model_targets") - async def test_invalid_credentials() -> None: - """An exception is raised when the credentials are not known.""" - async with AsyncModelTargetService( - client_id="not-a-client-id", - client_secret=secrets.token_hex(), - ) as client: - with pytest.raises( - expected_exception=ModelTargetOAuth2Error, - ) as exc: - await client.get_access_token() - - assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED - assert exc.value.error == "invalid_client" - - @staticmethod - @pytest.mark.asyncio - @pytest.mark.parametrize( - argnames=("status_code", "body", "expected_exception"), - argvalues=[ - pytest.param( - HTTPStatus.UNAUTHORIZED, - '{"error":{"code":"AUTHENTICATION_ERROR","message":"No"}}', - ModelTargetAuthenticationError, - id="authentication", - ), - pytest.param( - HTTPStatus.FORBIDDEN, - '{"error":{"code":"FORBIDDEN","message":"Denied"}}', - ModelTargetError, - id="generic-json", - ), - pytest.param( - HTTPStatus.CONFLICT, - "not json", - ModelTargetError, - id="generic-non-json", - ), - pytest.param( - HTTPStatus.TOO_MANY_REQUESTS, - "rate limited", - TooManyRequestsError, - id="rate-limit", - ), - pytest.param( - HTTPStatus.BAD_GATEWAY, - "server error", - ServerError, - id="server-error", - ), - ], - ) - async def test_dataset_error_response( - *, - model_target_model: ModelTargetModel, - status_code: HTTPStatus, - body: str, - expected_exception: ( - type[ModelTargetError | TooManyRequestsError | ServerError] - ), - ) -> None: - """Dataset failures map to exceptions through the mock.""" - failure = ModelTargetFailureResponse( - status_code=status_code, - body=body, - ) - - with MockVWS(model_target_failure_response=failure): - await _assert_dataset_error_response( - model_target_model=model_target_model, - status_code=status_code, - body=body, - expected_exception=expected_exception, - ) - - -class TestDatasetLifecycle: - """Tests for the dataset lifecycle.""" - - @staticmethod - @pytest.mark.asyncio - @pytest.mark.parametrize( - argnames="dataset_type", - argvalues=_DATASET_TYPES, - ) - async def test_create_wait_download_delete( - *, - async_model_target_client: AsyncModelTargetService, - model_target_model: ModelTargetModel, - dataset_type: ModelTargetDatasetType, - ) -> None: - """A dataset can be created, downloaded and then deleted.""" - dataset_uuid = await async_model_target_client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model], - dataset_type=dataset_type, - ) - - report = await async_model_target_client.wait_for_dataset_generated( - dataset_uuid=dataset_uuid, - dataset_type=dataset_type, - ) - - assert report.status == ModelTargetDatasetStatuses.DONE - assert report.dataset_uuid == dataset_uuid - assert report.completed_at is not None - assert report.eta is None - assert report.error is None - assert report.warning is None - - dataset = await async_model_target_client.download_dataset( - dataset_uuid=dataset_uuid, - dataset_type=dataset_type, - ) - - with zipfile.ZipFile( - file=io.BytesIO(initial_bytes=dataset) - ) as archive: - assert archive.namelist() == ["MTDataset.dat", "MTDataset.xml"] - - await async_model_target_client.delete_dataset( - dataset_uuid=dataset_uuid, - dataset_type=dataset_type, - ) - - with pytest.raises(expected_exception=UnknownModelTargetDatasetError): - await async_model_target_client.get_dataset_status( - dataset_uuid=dataset_uuid, - dataset_type=dataset_type, - ) - - @staticmethod - @pytest.mark.asyncio - async def test_status_while_processing( - *, - async_model_target_client: AsyncModelTargetService, - model_target_model: ModelTargetModel, - ) -> None: - """A processing dataset has an estimated completion time.""" - dataset_uuid = await async_model_target_client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model], - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - report = await async_model_target_client.get_dataset_status( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - assert report.status == ModelTargetDatasetStatuses.PROCESSING - assert report.eta is not None - assert report.completed_at is None - - @staticmethod - @pytest.mark.asyncio - async def test_download_while_processing( - *, - async_model_target_client: AsyncModelTargetService, - model_target_model: ModelTargetModel, - ) -> None: - """A dataset cannot be downloaded before it is generated.""" - dataset_uuid = await async_model_target_client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model], - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - with pytest.raises( - expected_exception=ModelTargetDatasetNotDoneError, - ) as exc: - await async_model_target_client.download_dataset( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - assert ( - exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - ) - assert exc.value.code == "UNSUPPORTED_STATE" - - @staticmethod - @pytest.mark.asyncio - async def test_dataset_is_visible_to_other_type( - *, - async_model_target_client: AsyncModelTargetService, - model_target_model: ModelTargetModel, - ) -> None: - """Standard and advanced routes share datasets by UUID.""" - dataset_uuid = await async_model_target_client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model], - dataset_type=ModelTargetDatasetType.ADVANCED, - ) - - report = await async_model_target_client.get_dataset_status( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - assert report.dataset_uuid == dataset_uuid - - @staticmethod - @pytest.mark.asyncio - async def test_advanced_dataset_takes_multiple_models( - *, - async_model_target_client: AsyncModelTargetService, - model_target_model: ModelTargetModel, - ) -> None: - """An advanced dataset can be generated from multiple models.""" - other_model = ModelTargetModel( - name="other-model", - cad_data_blob="ZmFrZS1jYWQtZGF0YQ==", - cad_data_format=CadDataFormat.GLB, - realistic_appearance=RealisticAppearance.TRUE, - views=[], - ) - - assert bool( - await async_model_target_client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model, other_model], - dataset_type=ModelTargetDatasetType.ADVANCED, - ) - ) - - -class TestUnknownDataset: - """Tests for requests for datasets which do not exist.""" - - @staticmethod - @pytest.mark.asyncio - async def test_get_status( - *, - async_model_target_client: AsyncModelTargetService, - ) -> None: - """An exception is raised for an unknown dataset.""" - dataset_uuid = uuid.uuid4().hex - with pytest.raises( - expected_exception=UnknownModelTargetDatasetError, - ) as exc: - await async_model_target_client.get_dataset_status( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - assert exc.value.response.status_code == HTTPStatus.NOT_FOUND - assert dataset_uuid in exc.value.message - - @staticmethod - @pytest.mark.asyncio - async def test_download( - *, - async_model_target_client: AsyncModelTargetService, - ) -> None: - """An exception is raised for an unknown dataset.""" - with pytest.raises(expected_exception=UnknownModelTargetDatasetError): - await async_model_target_client.download_dataset( - dataset_uuid=uuid.uuid4().hex, - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - @staticmethod - @pytest.mark.asyncio - async def test_delete( - *, - async_model_target_client: AsyncModelTargetService, - ) -> None: - """An exception is raised for an unknown dataset.""" - with pytest.raises(expected_exception=UnknownModelTargetDatasetError): - await async_model_target_client.delete_dataset( - dataset_uuid=uuid.uuid4().hex, - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - -class TestValidation: - """Tests for requests which Vuforia rejects.""" - - @staticmethod - @pytest.mark.asyncio - async def test_no_cad_data( - *, - async_model_target_client: AsyncModelTargetService, - ) -> None: - """A model needs exactly one CAD data source.""" - with pytest.raises( - expected_exception=ModelTargetValidationError, - ) as exc: - await async_model_target_client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[ModelTargetModel(name="model")], - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST - (detail,) = exc.value.details - assert detail.code == "VALIDATION_ERROR" - - -class TestGenerationResult: - """Tests for datasets which Vuforia does not generate cleanly.""" - - @staticmethod - @pytest.mark.asyncio - async def test_generation_failure( - *, - model_target_model: ModelTargetModel, - ) -> None: - """A dataset which fails to generate reports the failure.""" - message = "Model Target dataset generation failed" - failure = ModelTargetGenerationFailure(message=message) - with MockVWS( - processing_time_seconds=0.2, - model_target_generation_failure=failure, - ): - async with AsyncModelTargetService( - client_id=_CLIENT_ID, - client_secret=_CLIENT_CREDENTIALS[1], - ) as client: - dataset_uuid = await client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model], - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - report = await client.wait_for_dataset_generated( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - assert report.status == ModelTargetDatasetStatuses.FAILED - assert report.error is not None - assert report.error.message == message - - @staticmethod - @pytest.mark.asyncio - async def test_generation_warning( - *, - model_target_model: ModelTargetModel, - ) -> None: - """A dataset which generates with a warning reports the - warning. - """ - warning = ModelTargetGenerationWarning() - with MockVWS( - processing_time_seconds=0.2, - model_target_generation_warning=warning, - ): - async with AsyncModelTargetService( - client_id=_CLIENT_ID, - client_secret=_CLIENT_CREDENTIALS[1], - ) as client: - dataset_uuid = await client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model], - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - report = await client.wait_for_dataset_generated( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - assert report.status == ModelTargetDatasetStatuses.DONE - assert report.warning is not None - assert report.warning.target == dataset_uuid - (detail,) = report.warning.details - assert detail.code == "LOW_RECOGNITION_QUALITY" - - -class TestWaitForDatasetGenerated: - """Tests for waiting for a dataset to be generated.""" - - @staticmethod - @pytest.mark.asyncio - async def test_timeout(*, model_target_model: ModelTargetModel) -> None: - """An exception is raised when the wait times out.""" - with MockVWS(processing_time_seconds=60): - async with AsyncModelTargetService( - client_id=_CLIENT_ID, - client_secret=_CLIENT_CREDENTIALS[1], - ) as client: - dataset_uuid = await client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model], - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - with pytest.raises( - expected_exception=ModelTargetDatasetTimeoutError, - ): - await client.wait_for_dataset_generated( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.STANDARD, - seconds_between_requests=0.01, - timeout_seconds=0.05, - ) - - report = await client.get_dataset_status( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - assert report.status == ModelTargetDatasetStatuses.PROCESSING diff --git a/tests/test_async_query.py b/tests/test_async_query.py index 9d9e1dbdc..200a4d4fa 100644 --- a/tests/test_async_query.py +++ b/tests/test_async_query.py @@ -17,6 +17,9 @@ from vws.include_target_data import CloudRecoIncludeTargetData from vws.response import Response +if TYPE_CHECKING: + import io + class _ServerErrorTransport: """An async transport which returns a server error.""" @@ -48,231 +51,207 @@ async def __call__( ) -if TYPE_CHECKING: - import io - - -class TestQuery: - """Tests for making async image queries.""" - - @staticmethod - @pytest.mark.asyncio - async def test_no_matches( - *, - async_cloud_reco_client: AsyncCloudRecoService, - image: io.BytesIO | BinaryIO, - ) -> None: - """An empty list is returned if there are no matches.""" - result = await async_cloud_reco_client.query( - image=image, - ) - assert result == [] - - @staticmethod - @pytest.mark.asyncio - async def test_match( - *, - async_vws_client: AsyncVWS, - async_cloud_reco_client: AsyncCloudRecoService, - image: io.BytesIO | BinaryIO, - ) -> None: - """Details of matching targets are returned.""" - target_id = await async_vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - await async_vws_client.wait_for_target_processed( - target_id=target_id, - ) - [matching_target] = await async_cloud_reco_client.query( - image=image, - ) - assert matching_target.target_id == target_id - - @staticmethod - @pytest.mark.asyncio - async def test_server_error(*, image: io.BytesIO | BinaryIO) -> None: - """Server errors are exposed through the public async client.""" - client = AsyncCloudRecoService( - client_access_key="access-key", - client_secret_key=secrets.token_hex(), - transport=_ServerErrorTransport(), - ) - - with pytest.raises(expected_exception=ServerError): - _ = await client.query(image=image) - - -class TestCustomBaseVWQURL: - """Tests for using a custom base VWQ URL.""" +@pytest.mark.asyncio +async def test_no_matches( + *, + async_cloud_reco_client: AsyncCloudRecoService, + image: io.BytesIO | BinaryIO, +) -> None: + """An empty list is returned if there are no matches.""" + result = await async_cloud_reco_client.query( + image=image, + ) + assert result == [] - @staticmethod - @pytest.mark.asyncio - async def test_custom_base_url( - image: io.BytesIO | BinaryIO, - ) -> None: - """It is possible to query a target in a database under - a custom VWQ URL. - """ - base_vwq_url = "http://example.com" - with MockVWS(base_vwq_url=base_vwq_url) as mock: - database = CloudDatabase() - mock.add_cloud_database(cloud_database=database) - async_vws_client = AsyncVWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - target_id = await async_vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) +@pytest.mark.asyncio +async def test_match( + *, + async_vws_client: AsyncVWS, + async_cloud_reco_client: AsyncCloudRecoService, + image: io.BytesIO | BinaryIO, +) -> None: + """Details of matching targets are returned.""" + target_id = await async_vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + await async_vws_client.wait_for_target_processed( + target_id=target_id, + ) + [matching_target] = await async_cloud_reco_client.query( + image=image, + ) + assert matching_target.target_id == target_id - await async_vws_client.wait_for_target_processed( - target_id=target_id, - ) - async_cloud_reco_client = AsyncCloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=database.client_secret_key, - base_vwq_url=base_vwq_url, - ) +@pytest.mark.asyncio +async def test_server_error(*, image: io.BytesIO | BinaryIO) -> None: + """Server errors are exposed through the public async client.""" + client = AsyncCloudRecoService( + client_access_key="access-key", + client_secret_key=secrets.token_hex(), + transport=_ServerErrorTransport(), + ) - matches = await async_cloud_reco_client.query( - image=image, - ) - assert len(matches) == 1 - match = matches[0] - assert match.target_id == target_id + with pytest.raises(expected_exception=ServerError): + _ = await client.query(image=image) -class TestMaxNumResults: - """Tests for the ``max_num_results`` parameter of - ``query``. +@pytest.mark.asyncio +async def test_custom_base_url( + image: io.BytesIO | BinaryIO, +) -> None: + """It is possible to query a target in a database under + a custom VWQ URL. """ + base_vwq_url = "http://example.com" + with MockVWS(base_vwq_url=base_vwq_url) as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + async_vws_client = AsyncVWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) - @staticmethod - @pytest.mark.asyncio - async def test_custom( - *, - async_vws_client: AsyncVWS, - async_cloud_reco_client: AsyncCloudRecoService, - image: io.BytesIO | BinaryIO, - ) -> None: - """It is possible to set a custom - ``max_num_results``. - """ target_id = await async_vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - target_id_2 = await async_vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - target_id_3 = await async_vws_client.add_target( - name=uuid.uuid4().hex, + name="x", width=1, image=image, active_flag=True, application_metadata=None, ) + await async_vws_client.wait_for_target_processed( target_id=target_id, ) - await async_vws_client.wait_for_target_processed( - target_id=target_id_2, - ) - await async_vws_client.wait_for_target_processed( - target_id=target_id_3, + + async_cloud_reco_client = AsyncCloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + base_vwq_url=base_vwq_url, ) - max_num_results = 2 + matches = await async_cloud_reco_client.query( image=image, - max_num_results=max_num_results, ) - assert len(matches) == max_num_results + assert len(matches) == 1 + match = matches[0] + assert match.target_id == target_id -class TestIncludeTargetData: - """Tests for the ``include_target_data`` parameter of - ``query``. +@pytest.mark.asyncio +async def test_custom( + *, + async_vws_client: AsyncVWS, + async_cloud_reco_client: AsyncCloudRecoService, + image: io.BytesIO | BinaryIO, +) -> None: + """It is possible to set a custom + ``max_num_results``. """ + target_id = await async_vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + target_id_2 = await async_vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + target_id_3 = await async_vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + await async_vws_client.wait_for_target_processed( + target_id=target_id, + ) + await async_vws_client.wait_for_target_processed( + target_id=target_id_2, + ) + await async_vws_client.wait_for_target_processed( + target_id=target_id_3, + ) + max_num_results = 2 + matches = await async_cloud_reco_client.query( + image=image, + max_num_results=max_num_results, + ) + assert len(matches) == max_num_results - @staticmethod - @pytest.mark.asyncio - async def test_none( - *, - async_vws_client: AsyncVWS, - async_cloud_reco_client: AsyncCloudRecoService, - image: io.BytesIO | BinaryIO, - ) -> None: - """When ``CloudRecoIncludeTargetData.NONE`` is given, - target data is not returned in any match. - """ - target_id = await async_vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - await async_vws_client.wait_for_target_processed( - target_id=target_id, - ) - [match] = await async_cloud_reco_client.query( - image=image, - include_target_data=(CloudRecoIncludeTargetData.NONE), - ) - assert match.target_data is None - @staticmethod - @pytest.mark.asyncio - async def test_all( - *, - async_vws_client: AsyncVWS, - async_cloud_reco_client: AsyncCloudRecoService, - image: io.BytesIO | BinaryIO, - ) -> None: - """When ``CloudRecoIncludeTargetData.ALL`` is given, - target data is returned in all matches. - """ - target_id = await async_vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - target_id_2 = await async_vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - await async_vws_client.wait_for_target_processed( - target_id=target_id, - ) - await async_vws_client.wait_for_target_processed( - target_id=target_id_2, - ) - top_match, second_match = await async_cloud_reco_client.query( - image=image, - max_num_results=2, - include_target_data=(CloudRecoIncludeTargetData.ALL), - ) - assert top_match.target_data is not None - assert second_match.target_data is not None +@pytest.mark.asyncio +async def test_none( + *, + async_vws_client: AsyncVWS, + async_cloud_reco_client: AsyncCloudRecoService, + image: io.BytesIO | BinaryIO, +) -> None: + """When ``CloudRecoIncludeTargetData.NONE`` is given, + target data is not returned in any match. + """ + target_id = await async_vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + await async_vws_client.wait_for_target_processed( + target_id=target_id, + ) + [match] = await async_cloud_reco_client.query( + image=image, + include_target_data=(CloudRecoIncludeTargetData.NONE), + ) + assert match.target_data is None + + +@pytest.mark.asyncio +async def test_all( + *, + async_vws_client: AsyncVWS, + async_cloud_reco_client: AsyncCloudRecoService, + image: io.BytesIO | BinaryIO, +) -> None: + """When ``CloudRecoIncludeTargetData.ALL`` is given, + target data is returned in all matches. + """ + target_id = await async_vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + target_id_2 = await async_vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + await async_vws_client.wait_for_target_processed( + target_id=target_id, + ) + await async_vws_client.wait_for_target_processed( + target_id=target_id_2, + ) + top_match, second_match = await async_cloud_reco_client.query( + image=image, + max_num_results=2, + include_target_data=(CloudRecoIncludeTargetData.ALL), + ) + assert top_match.target_data is not None + assert second_match.target_data is not None diff --git a/tests/test_async_vws.py b/tests/test_async_vws.py deleted file mode 100644 index 7b4e4fbb5..000000000 --- a/tests/test_async_vws.py +++ /dev/null @@ -1,735 +0,0 @@ -"""Tests for async helper functions for managing a Vuforia database.""" - -from __future__ import annotations - -import base64 -import calendar -import time -import uuid -from http import HTTPStatus -from typing import TYPE_CHECKING, BinaryIO - -import pytest -from mock_vws import MockVWS -from mock_vws.database import CloudDatabase - -from vws import AsyncCloudRecoService, AsyncVuMarkService, AsyncVWS -from vws.exceptions.custom_exceptions import ( - DatabaseIdNotSetError, - RecoCountsReportDownloadError, - RecoCountsReportNotReadyError, - RecoCountsReportTimeoutError, - TargetProcessingTimeoutError, -) -from vws.exceptions.vws_exceptions import ( - AuthenticationFailureError, - FailError, -) -from vws.reports import ( - DatabaseSummaryReport, - TargetRecord, - TargetStatuses, -) -from vws.response import Response -from vws.vumark_accept import VuMarkAccept - -if TYPE_CHECKING: - import datetime - import io - - -class TestAddTarget: - """Tests for adding a target.""" - - @staticmethod - @pytest.mark.asyncio - @pytest.mark.parametrize( - argnames="application_metadata", - argvalues=[None, b"a"], - ) - @pytest.mark.parametrize( - argnames="active_flag", - argvalues=[True, False], - ) - async def test_add_target( - *, - async_vws_client: AsyncVWS, - image: io.BytesIO | BinaryIO, - application_metadata: bytes | None, - async_cloud_reco_client: AsyncCloudRecoService, - active_flag: bool, - ) -> None: - """No exception is raised when adding one target.""" - name = "x" - width = 1 - if application_metadata is None: - encoded_metadata = None - else: - encoded_metadata_bytes = base64.b64encode( - s=application_metadata, - ) - encoded_metadata = encoded_metadata_bytes.decode( - encoding="utf-8", - ) - - target_id = await async_vws_client.add_target( - name=name, - width=width, - image=image, - application_metadata=encoded_metadata, - active_flag=active_flag, - ) - target_record = ( - await async_vws_client.get_target_record( - target_id=target_id, - ) - ).target_record - assert target_record.name == name - assert target_record.width == width - assert target_record.active_flag is active_flag - await async_vws_client.wait_for_target_processed( - target_id=target_id, - ) - matching_targets = await async_cloud_reco_client.query( - image=image, - ) - if active_flag: - [matching_target] = matching_targets - assert matching_target.target_id == target_id - assert matching_target.target_data is not None - query_metadata = matching_target.target_data.application_metadata - assert query_metadata == encoded_metadata - else: - assert matching_targets == [] - - @staticmethod - @pytest.mark.asyncio - async def test_add_two_targets( - *, - async_vws_client: AsyncVWS, - image: io.BytesIO | BinaryIO, - ) -> None: - """No exception is raised when adding two targets with - different names. - - This demonstrates that the image seek position is not - changed. - """ - for name in ("a", "b"): - await async_vws_client.add_target( - name=name, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - -class TestCustomBaseVWSURL: - """Tests for using a custom base VWS URL.""" - - @staticmethod - @pytest.mark.asyncio - async def test_custom_base_url( - image: io.BytesIO | BinaryIO, - ) -> None: - """It is possible to add a target to a database under a - custom VWS URL. - """ - base_vws_url = "http://example.com" - with MockVWS(base_vws_url=base_vws_url) as mock: - database = CloudDatabase() - mock.add_cloud_database(cloud_database=database) - async_vws_client = AsyncVWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - base_vws_url=base_vws_url, - ) - - await async_vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - -class TestListTargets: - """Tests for listing targets.""" - - @staticmethod - @pytest.mark.asyncio - async def test_list_targets( - *, - async_vws_client: AsyncVWS, - image: io.BytesIO | BinaryIO, - ) -> None: - """It is possible to get a list of target IDs.""" - id_1 = await async_vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - id_2 = await async_vws_client.add_target( - name="a", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - targets = await async_vws_client.list_targets() - assert sorted(targets) == sorted([id_1, id_2]) - - -class TestDelete: - """Test for deleting a target.""" - - @staticmethod - @pytest.mark.asyncio - async def test_delete_target( - *, - async_vws_client: AsyncVWS, - image: io.BytesIO | BinaryIO, - ) -> None: - """It is possible to delete a target.""" - target_id = await async_vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - await async_vws_client.wait_for_target_processed( - target_id=target_id, - ) - targets = await async_vws_client.list_targets() - assert targets == [target_id] - await async_vws_client.delete_target( - target_id=target_id, - ) - targets = await async_vws_client.list_targets() - assert targets == [] - - -class TestGetTargetSummaryReport: - """Tests for getting a summary report for a target.""" - - @staticmethod - @pytest.mark.asyncio - async def test_get_target_summary_report( - *, - async_vws_client: AsyncVWS, - image: io.BytesIO | BinaryIO, - ) -> None: - """Details of a target are returned by - ``get_target_summary_report``. - """ - target_name = uuid.uuid4().hex - target_id = await async_vws_client.add_target( - name=target_name, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - report = await async_vws_client.get_target_summary_report( - target_id=target_id, - ) - - assert report.target_name == target_name - assert report.active_flag is True - assert report.total_recos == 0 - - -class TestGetDatabaseSummaryReport: - """Tests for getting a summary report for a database.""" - - @staticmethod - @pytest.mark.asyncio - async def test_get_target( - async_vws_client: AsyncVWS, - ) -> None: - """Details of a database are returned by - ``get_database_summary_report``. - """ - report = await async_vws_client.get_database_summary_report() - - assert isinstance(report, DatabaseSummaryReport) - assert report.active_images == 0 - - -class TestGetTargetRecord: - """Tests for getting a record of a target.""" - - @staticmethod - @pytest.mark.asyncio - async def test_get_target_record( - *, - async_vws_client: AsyncVWS, - image: io.BytesIO | BinaryIO, - ) -> None: - """Details of a target are returned by - ``get_target_record``. - """ - target_id = await async_vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - result = await async_vws_client.get_target_record( - target_id=target_id, - ) - expected_target_record = TargetRecord( - target_id=target_id, - active_flag=True, - name="x", - width=1, - tracking_rating=-1, - reco_rating="", - ) - - assert result.target_record == expected_target_record - assert result.status == TargetStatuses.PROCESSING - - -class TestWaitForTargetProcessed: - """Tests for waiting for a target to be processed.""" - - @staticmethod - @pytest.mark.asyncio - async def test_wait_for_target_processed( - *, - async_vws_client: AsyncVWS, - image: io.BytesIO | BinaryIO, - ) -> None: - """It is possible to wait until a target is processed.""" - target_id = await async_vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - report = await async_vws_client.get_target_summary_report( - target_id=target_id, - ) - assert report.status == TargetStatuses.PROCESSING - await async_vws_client.wait_for_target_processed( - target_id=target_id, - ) - report = await async_vws_client.get_target_summary_report( - target_id=target_id, - ) - assert report.status != TargetStatuses.PROCESSING - - @staticmethod - @pytest.mark.asyncio - async def test_custom_timeout( - image: io.BytesIO | BinaryIO, - ) -> None: - """It is possible to set a maximum timeout.""" - with MockVWS(processing_time_seconds=0.5) as mock: - database = CloudDatabase() - mock.add_cloud_database(cloud_database=database) - async_vws_client = AsyncVWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - - target_id = await async_vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - with pytest.raises( - expected_exception=(TargetProcessingTimeoutError), - ): - await async_vws_client.wait_for_target_processed( - target_id=target_id, - timeout_seconds=0.1, - ) - - -class TestGetDuplicateTargets: - """Tests for getting duplicate targets.""" - - @staticmethod - @pytest.mark.asyncio - async def test_get_duplicate_targets( - *, - async_vws_client: AsyncVWS, - image: io.BytesIO | BinaryIO, - ) -> None: - """It is possible to get the IDs of similar targets.""" - target_id = await async_vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - similar_target_id = await async_vws_client.add_target( - name="a", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - await async_vws_client.wait_for_target_processed( - target_id=target_id, - ) - await async_vws_client.wait_for_target_processed( - target_id=similar_target_id, - ) - duplicates = await async_vws_client.get_duplicate_targets( - target_id=target_id, - ) - assert duplicates == [similar_target_id] - - -class TestUpdateTarget: - """Tests for updating a target.""" - - @staticmethod - @pytest.mark.asyncio - async def test_update_target( - *, - async_vws_client: AsyncVWS, - async_cloud_reco_client: AsyncCloudRecoService, - image: io.BytesIO | BinaryIO, - different_high_quality_image: io.BytesIO, - ) -> None: - """It is possible to update a target.""" - target_id = await async_vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - await async_vws_client.wait_for_target_processed( - target_id=target_id, - ) - [matching_target] = await async_cloud_reco_client.query( - image=image, - ) - assert matching_target.target_id == target_id - query_target_data = matching_target.target_data - assert query_target_data is not None - assert query_target_data.application_metadata is None - - new_name = uuid.uuid4().hex - new_width = 2.0 - new_application_metadata = base64.b64encode( - s=b"a", - ).decode(encoding="ascii") - await async_vws_client.update_target( - target_id=target_id, - name=new_name, - width=new_width, - active_flag=True, - image=different_high_quality_image, - application_metadata=new_application_metadata, - ) - - await async_vws_client.wait_for_target_processed( - target_id=target_id, - ) - target_details = await async_vws_client.get_target_record( - target_id=target_id, - ) - assert target_details.target_record.name == new_name - assert target_details.target_record.active_flag - - @staticmethod - @pytest.mark.asyncio - async def test_no_fields_given( - *, - async_vws_client: AsyncVWS, - image: io.BytesIO | BinaryIO, - ) -> None: - """It is possible to give no update fields.""" - target_id = await async_vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - await async_vws_client.wait_for_target_processed( - target_id=target_id, - ) - await async_vws_client.update_target( - target_id=target_id, - ) - - -class _ForbiddenDownloadTransport: - """An async transport which refuses to serve a report, as an expired - URL would. - """ - - async def aclose(self) -> None: - """Close the transport.""" - - async def __call__( - self, - *, - method: str, - url: str, - headers: dict[str, str], - data: bytes, - request_timeout: float | tuple[float, float], - ) -> Response: - """Return a "forbidden" response.""" - del method, headers, request_timeout - body = "AccessDenied" - return Response( - text=body, - url=url, - status_code=HTTPStatus.FORBIDDEN, - headers={}, - request_body=data, - tell_position=0, - content=body.encode(encoding="utf-8"), - ) - - -class TestRecoCountsReport: - """Tests for database reco counts reports.""" - - @staticmethod - @pytest.mark.asyncio - async def test_reco_counts_report( - *, - async_vws_client: AsyncVWS, - report_month: datetime.date, - ) -> None: - """A report can be requested, waited for and downloaded.""" - client = async_vws_client - report_request = await client.request_database_reco_counts_report( - year=report_month.year, - month=calendar.Month(value=report_month.month), - ) - assert bool(report_request.transaction_id) - assert bool(report_request.presigned_url) - - report = await client.wait_for_reco_counts_report( - presigned_url=report_request.presigned_url, - ) - - # No targets have been recognized, so the report has no rows. - assert not bool(report.reco_counts) - assert report.raw_csv.startswith(b"target_id,reco_count") - - @staticmethod - @pytest.mark.asyncio - async def test_not_ready(*, current_month: datetime.date) -> None: - """Downloading a report before Vuforia has generated it raises an - error. - """ - with MockVWS(processing_time_seconds=60) as mock: - database = CloudDatabase() - mock.add_cloud_database(cloud_database=database) - async with AsyncVWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - database_id=database.database_id, - ) as client: - report_request = ( - await client.request_database_reco_counts_report( - year=current_month.year, - month=calendar.Month(value=current_month.month), - ) - ) - - with pytest.raises( - expected_exception=RecoCountsReportNotReadyError, - ) as exc: - await client.download_reco_counts_report( - presigned_url=report_request.presigned_url, - ) - - assert exc.value.response.status_code == HTTPStatus.NOT_FOUND - - @staticmethod - @pytest.mark.asyncio - async def test_wait_timeout(*, current_month: datetime.date) -> None: - """Waiting for a report which is not generated in time raises an - error. - """ - with MockVWS(processing_time_seconds=60) as mock: - database = CloudDatabase() - mock.add_cloud_database(cloud_database=database) - async with AsyncVWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - database_id=database.database_id, - ) as client: - report_request = ( - await client.request_database_reco_counts_report( - year=current_month.year, - month=calendar.Month(value=current_month.month), - ) - ) - - maximum_wait_seconds = 5 - start_time = time.monotonic() - - with pytest.raises( - expected_exception=RecoCountsReportTimeoutError, - ): - await client.wait_for_reco_counts_report( - presigned_url=report_request.presigned_url, - seconds_between_requests=0.01, - timeout_seconds=0.05, - ) - - elapsed_time = time.monotonic() - start_time - assert elapsed_time < maximum_wait_seconds - - @staticmethod - @pytest.mark.asyncio - @pytest.mark.parametrize( - argnames=("year", "month"), - argvalues=[ - pytest.param(1999, calendar.Month.JANUARY, id="year-in-the-past"), - pytest.param( - 1999, - calendar.Month.DECEMBER, - id="year-in-the-past-december", - ), - ], - ) - async def test_month_not_accepted( - *, - async_vws_client: AsyncVWS, - year: int, - month: calendar.Month, - ) -> None: - """Months other than the current and previous month are - rejected. - """ - with pytest.raises(expected_exception=FailError) as exc: - await async_vws_client.request_database_reco_counts_report( - year=year, - month=month, - ) - - assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST - - @staticmethod - @pytest.mark.asyncio - async def test_database_id_does_not_match_keys( - *, - current_month: datetime.date, - ) -> None: - """A database ID which does not match the given keys is - rejected. - """ - with MockVWS() as mock: - database = CloudDatabase() - mock.add_cloud_database(cloud_database=database) - async with AsyncVWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - database_id=uuid.uuid4().hex, - ) as client: - with pytest.raises( - expected_exception=AuthenticationFailureError, - ) as exc: - await client.request_database_reco_counts_report( - year=current_month.year, - month=calendar.Month(value=current_month.month), - ) - - assert ( - exc.value.response.status_code == HTTPStatus.UNAUTHORIZED - ) - - @staticmethod - @pytest.mark.asyncio - async def test_download_error() -> None: - """An error response from the report's URL raises an error.""" - async with AsyncVWS( - server_access_key=uuid.uuid4().hex, - server_secret_key=uuid.uuid4().hex, - transport=_ForbiddenDownloadTransport(), - ) as client: - with pytest.raises( - expected_exception=RecoCountsReportDownloadError, - ) as exc: - await client.download_reco_counts_report( - presigned_url="https://example.com/reports/recoCounts/x", - ) - - assert exc.value.response.status_code == HTTPStatus.FORBIDDEN - - @staticmethod - @pytest.mark.asyncio - async def test_no_database_id(*, current_month: datetime.date) -> None: - """A client which was given no database ID cannot request a - report. - """ - async with AsyncVWS( - server_access_key=uuid.uuid4().hex, - server_secret_key=uuid.uuid4().hex, - ) as client: - with pytest.raises(expected_exception=DatabaseIdNotSetError): - await client.request_database_reco_counts_report( - year=current_month.year, - month=calendar.Month(value=current_month.month), - ) - - -class TestGenerateVumarkInstance: - """Tests for generating VuMark instances.""" - - @staticmethod - @pytest.mark.asyncio - @pytest.mark.parametrize( - argnames=("accept", "expected_prefix"), - argvalues=[ - pytest.param( - VuMarkAccept.PNG, - b"\x89PNG\r\n\x1a\n", - id="png", - ), - pytest.param( - VuMarkAccept.SVG, - b"<", - id="svg", - ), - pytest.param( - VuMarkAccept.PDF, - b"%PDF", - id="pdf", - ), - ], - ) - async def test_generate_vumark_instance( - *, - async_vumark_service_client: AsyncVuMarkService, - vumark_target_id: str, - accept: VuMarkAccept, - expected_prefix: bytes, - ) -> None: - """The returned bytes match the requested format.""" - result = await async_vumark_service_client.generate_vumark_instance( - target_id=vumark_target_id, - instance_id="12345", - accept=accept, - ) - assert result.startswith(expected_prefix) diff --git a/tests/test_model_targets.py b/tests/test_model_targets.py deleted file mode 100644 index 4c55e40dc..000000000 --- a/tests/test_model_targets.py +++ /dev/null @@ -1,831 +0,0 @@ -"""Tests for the Model Target Web API client.""" - -import io -import json -import secrets -import uuid -import zipfile -from http import HTTPStatus - -import pytest -from beartype import beartype -from freezegun import freeze_time -from mock_vws import ( - MockVWS, - ModelTargetFailureResponse, - ModelTargetGenerationFailure, - ModelTargetGenerationWarning, -) - -from vws import ModelTargetService -from vws._model_targets import access_token_from_response -from vws.exceptions.custom_exceptions import ServerError -from vws.exceptions.model_target_exceptions import ( - ModelTargetAuthenticationError, - ModelTargetDatasetNotDoneError, - ModelTargetDatasetTimeoutError, - ModelTargetError, - ModelTargetOAuth2Error, - ModelTargetValidationError, - UnknownModelTargetDatasetError, -) -from vws.exceptions.vws_exceptions import TooManyRequestsError -from vws.model_target_datasets import ( - CadDataFormat, - GuideViewPosition, - ModelTargetDatasetType, - ModelTargetModel, - ModelTargetView, - RealisticAppearance, -) -from vws.reports import ModelTargetDatasetStatuses -from vws.response import Response -from vws.transports import RequestsTransport, Transport - -# The mock accepts one hard-coded pair of Model Target Web API OAuth2 -# credentials, which it does not expose. -_CLIENT_ID = "client-id" -_CLIENT_CREDENTIALS = ("client-id", "client-secret") - -_DATASET_TYPES = [ - ModelTargetDatasetType.STANDARD, - ModelTargetDatasetType.ADVANCED, -] - - -@beartype -def _response_with_status( - *, - text: str, - status_code: HTTPStatus, -) -> Response: - """Get a response with a given body. - - Args: - text: The body of the response. - status_code: The response status code. - - Returns: - A response with the given body. - """ - content = text.encode(encoding="utf-8") - return Response( - text=text, - url="https://vws.vuforia.com/modeltargets/datasets", - status_code=status_code, - headers={}, - request_body=None, - tell_position=len(content), - content=content, - ) - - -@beartype -def _response(*, text: str) -> Response: - """Get a bad-request response with a given body.""" - return _response_with_status( - text=text, - status_code=HTTPStatus.BAD_REQUEST, - ) - - -@beartype -class _CountingTransport: - """A transport which counts the requests made to each path.""" - - def __init__(self, *, transport: Transport) -> None: - """ - Args: - transport: The transport to make requests with. - """ - self._transport = transport - self.urls: list[str] = [] - - def close(self) -> None: - """Close the wrapped transport.""" - self._transport.close() - - def __call__( - self, - *, - method: str, - url: str, - headers: dict[str, str], - data: bytes, - request_timeout: float | tuple[float, float], - ) -> Response: - """Make a request, recording the URL. - - Args: - method: The HTTP method. - url: The full URL. - headers: Request headers. - data: The request body. - request_timeout: The request timeout. - - Returns: - A Response populated from the HTTP response. - """ - self.urls.append(url) - return self._transport( - method=method, - url=url, - headers=headers, - data=data, - request_timeout=request_timeout, - ) - - -class TestAccessToken: - """Tests for getting an access token.""" - - @staticmethod - @pytest.mark.usefixtures("_mock_model_targets") - def test_token_is_a_bearer_token() -> None: - """An access token is given for valid credentials.""" - client = ModelTargetService( - client_id=_CLIENT_ID, - client_secret=_CLIENT_CREDENTIALS[1], - ) - - assert bool(client.get_access_token()) - - @staticmethod - @pytest.mark.usefixtures("_mock_model_targets") - def test_token_is_reused( - *, - model_target_model: ModelTargetModel, - ) -> None: - """One access token is used for multiple requests.""" - transport = _CountingTransport(transport=RequestsTransport()) - client = ModelTargetService( - client_id=_CLIENT_ID, - client_secret=_CLIENT_CREDENTIALS[1], - transport=transport, - ) - - for _ in range(2): - _ = client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model], - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - token_urls = [url for url in transport.urls if "oauth2" in url] - assert len(token_urls) == 1 - transport.close() - - @staticmethod - @pytest.mark.usefixtures("_mock_model_targets") - def test_expired_token_is_replaced( - *, - model_target_model: ModelTargetModel, - ) -> None: - """A new access token is requested once the old one expires.""" - transport = _CountingTransport(transport=RequestsTransport()) - client = ModelTargetService( - client_id=_CLIENT_ID, - client_secret=_CLIENT_CREDENTIALS[1], - transport=transport, - ) - - with freeze_time(time_to_freeze="2026-01-01") as frozen_time: - _ = client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model], - dataset_type=ModelTargetDatasetType.STANDARD, - ) - # Mock tokens last an hour. - _ = frozen_time.tick(delta=60 * 60 + 1) - _ = client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model], - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - token_urls = [url for url in transport.urls if "oauth2" in url] - expected_token_request_count = 2 - assert len(token_urls) == expected_token_request_count - - @staticmethod - @pytest.mark.usefixtures("_mock_model_targets") - def test_invalid_credentials() -> None: - """An exception is raised when the credentials are not known.""" - client = ModelTargetService( - client_id="not-a-client-id", - client_secret=secrets.token_hex(), - ) - - with pytest.raises( - expected_exception=ModelTargetOAuth2Error, - ) as exc: - _ = client.get_access_token() - - assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED - assert exc.value.error == "invalid_client" - assert not bool(exc.value.error_description) - - @staticmethod - @pytest.mark.parametrize( - argnames=("status_code", "body", "expected_exception"), - argvalues=[ - pytest.param( - HTTPStatus.UNAUTHORIZED, - '{"error":{"code":"AUTHENTICATION_ERROR","message":"No"}}', - ModelTargetAuthenticationError, - id="authentication", - ), - pytest.param( - HTTPStatus.FORBIDDEN, - '{"error":{"code":"FORBIDDEN","message":"Denied"}}', - ModelTargetError, - id="generic-json", - ), - pytest.param( - HTTPStatus.CONFLICT, - "not json", - ModelTargetError, - id="generic-non-json", - ), - pytest.param( - HTTPStatus.TOO_MANY_REQUESTS, - "rate limited", - TooManyRequestsError, - id="rate-limit", - ), - pytest.param( - HTTPStatus.BAD_GATEWAY, - "server error", - ServerError, - id="server-error", - ), - ], - ) - def test_dataset_error_response( - *, - model_target_model: ModelTargetModel, - status_code: HTTPStatus, - body: str, - expected_exception: ( - type[ModelTargetError | TooManyRequestsError | ServerError] - ), - ) -> None: - """Dataset failures map to exceptions through the mock.""" - failure = ModelTargetFailureResponse( - status_code=status_code, - body=body, - ) - client = ModelTargetService( - client_id=_CLIENT_ID, - client_secret=_CLIENT_CREDENTIALS[1], - ) - - with ( - MockVWS(model_target_failure_response=failure), - pytest.raises(expected_exception=expected_exception) as exc, - ): - _ = client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model], - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - assert isinstance(exc.value, expected_exception) - assert exc.value.response.status_code == status_code - assert exc.value.response.text == body - - -class TestDatasetLifecycle: - """Tests for the dataset lifecycle.""" - - @staticmethod - @pytest.mark.parametrize( - argnames="dataset_type", - argvalues=_DATASET_TYPES, - ) - def test_create_wait_download_delete( - *, - model_target_client: ModelTargetService, - model_target_model: ModelTargetModel, - dataset_type: ModelTargetDatasetType, - ) -> None: - """A dataset can be created, downloaded and then deleted.""" - dataset_uuid = model_target_client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model], - dataset_type=dataset_type, - ) - - report = model_target_client.wait_for_dataset_generated( - dataset_uuid=dataset_uuid, - dataset_type=dataset_type, - ) - - assert report.status == ModelTargetDatasetStatuses.DONE - assert report.dataset_uuid == dataset_uuid - assert report.completed_at is not None - assert report.completed_at >= report.created_at - assert report.eta is None - assert report.error is None - assert report.warning is None - - dataset = model_target_client.download_dataset( - dataset_uuid=dataset_uuid, - dataset_type=dataset_type, - ) - - with zipfile.ZipFile( - file=io.BytesIO(initial_bytes=dataset) - ) as archive: - assert archive.namelist() == ["MTDataset.dat", "MTDataset.xml"] - - model_target_client.delete_dataset( - dataset_uuid=dataset_uuid, - dataset_type=dataset_type, - ) - - with pytest.raises(expected_exception=UnknownModelTargetDatasetError): - _ = model_target_client.get_dataset_status( - dataset_uuid=dataset_uuid, - dataset_type=dataset_type, - ) - - @staticmethod - def test_status_while_processing( - *, - model_target_client: ModelTargetService, - model_target_model: ModelTargetModel, - ) -> None: - """A processing dataset has an estimated completion time.""" - dataset_uuid = model_target_client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model], - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - report = model_target_client.get_dataset_status( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - assert report.status == ModelTargetDatasetStatuses.PROCESSING - assert report.eta is not None - assert report.eta >= report.created_at - assert report.completed_at is None - - @staticmethod - def test_download_while_processing( - *, - model_target_client: ModelTargetService, - model_target_model: ModelTargetModel, - ) -> None: - """A dataset cannot be downloaded before it is generated.""" - dataset_uuid = model_target_client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model], - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - with pytest.raises( - expected_exception=ModelTargetDatasetNotDoneError, - ) as exc: - _ = model_target_client.download_dataset( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - assert ( - exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - ) - assert exc.value.code == "UNSUPPORTED_STATE" - assert exc.value.target == dataset_uuid - - @staticmethod - def test_dataset_is_visible_to_other_type( - *, - model_target_client: ModelTargetService, - model_target_model: ModelTargetModel, - ) -> None: - """Standard and advanced routes share datasets by UUID.""" - dataset_uuid = model_target_client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model], - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - report = model_target_client.get_dataset_status( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.ADVANCED, - ) - - assert report.dataset_uuid == dataset_uuid - - @staticmethod - def test_advanced_dataset_takes_multiple_models( - *, - model_target_client: ModelTargetService, - model_target_model: ModelTargetModel, - ) -> None: - """An advanced dataset can be generated from multiple models.""" - other_model = ModelTargetModel( - name="other-model", - cad_data_blob="ZmFrZS1jYWQtZGF0YQ==", - cad_data_format=CadDataFormat.GLB, - realistic_appearance=RealisticAppearance.TRUE, - views=[], - ) - - dataset_uuid = model_target_client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model, other_model], - dataset_type=ModelTargetDatasetType.ADVANCED, - ) - - assert bool(dataset_uuid) - - @staticmethod - def test_state_based_model( - *, - model_target_client: ModelTargetService, - ) -> None: - """A State-Based Model Target dataset can be created.""" - configuration = json.dumps(obj={"states": {"open": {}, "closed": {}}}) - model = ModelTargetModel( - name="model", - cad_data_url="https://example.com/model.zip", - cad_data_format=CadDataFormat.ZIP, - state_based_configuration_json_string=configuration, - views=[ - ModelTargetView( - name="front", - guide_view_position=GuideViewPosition( - rotation=[0.0, 0.0, 0.0, 1.0], - translation=[0.0, 0.0, 1.0], - ), - states=["open"], - ), - ], - ) - - assert bool( - model_target_client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model], - dataset_type=ModelTargetDatasetType.STANDARD, - ) - ) - - -class TestUnknownDataset: - """Tests for requests for datasets which do not exist.""" - - @staticmethod - def test_get_status(*, model_target_client: ModelTargetService) -> None: - """An exception is raised for an unknown dataset.""" - dataset_uuid = uuid.uuid4().hex - with pytest.raises( - expected_exception=UnknownModelTargetDatasetError, - ) as exc: - _ = model_target_client.get_dataset_status( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - assert exc.value.response.status_code == HTTPStatus.NOT_FOUND - assert exc.value.code == "NOT_FOUND" - assert dataset_uuid in exc.value.message - - @staticmethod - def test_download(*, model_target_client: ModelTargetService) -> None: - """An exception is raised for an unknown dataset.""" - with pytest.raises(expected_exception=UnknownModelTargetDatasetError): - _ = model_target_client.download_dataset( - dataset_uuid=uuid.uuid4().hex, - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - @staticmethod - def test_delete(*, model_target_client: ModelTargetService) -> None: - """An exception is raised for an unknown dataset.""" - with pytest.raises(expected_exception=UnknownModelTargetDatasetError): - model_target_client.delete_dataset( - dataset_uuid=uuid.uuid4().hex, - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - -class TestValidation: - """Tests for requests which Vuforia rejects.""" - - @staticmethod - def test_no_cad_data( - *, - model_target_client: ModelTargetService, - ) -> None: - """A model needs exactly one CAD data source.""" - with pytest.raises( - expected_exception=ModelTargetValidationError, - ) as exc: - _ = model_target_client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[ModelTargetModel(name="model", views=[])], - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST - assert exc.value.code == "BAD_REQUEST" - (detail,) = exc.value.details - assert detail.code == "VALIDATION_ERROR" - assert "cadDataUrl" in detail.message - - @staticmethod - def test_two_cad_data_sources( - *, - model_target_client: ModelTargetService, - ) -> None: - """A model cannot give two CAD data sources.""" - model = ModelTargetModel( - name="model", - cad_data_url="https://example.com/model.zip", - cad_data_blob="ZmFrZS1jYWQtZGF0YQ==", - ) - - with pytest.raises(expected_exception=ModelTargetValidationError): - _ = model_target_client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model], - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - @staticmethod - def test_two_models_in_a_standard_dataset( - *, - model_target_client: ModelTargetService, - model_target_model: ModelTargetModel, - ) -> None: - """A standard dataset takes exactly one model.""" - with pytest.raises( - expected_exception=ModelTargetValidationError, - ) as exc: - _ = model_target_client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model, model_target_model], - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - (detail,) = exc.value.details - assert detail.message == "exactly one model should be provided" - - -class TestGenerationResult: - """Tests for datasets which Vuforia does not generate cleanly.""" - - @staticmethod - def test_generation_failure( - *, - model_target_model: ModelTargetModel, - ) -> None: - """A dataset which fails to generate reports the failure.""" - message = "Model Target dataset generation failed" - failure = ModelTargetGenerationFailure(message=message) - with MockVWS( - processing_time_seconds=0.2, - model_target_generation_failure=failure, - ): - client = ModelTargetService( - client_id=_CLIENT_ID, - client_secret=_CLIENT_CREDENTIALS[1], - ) - dataset_uuid = client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model], - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - report = client.wait_for_dataset_generated( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - assert report.status == ModelTargetDatasetStatuses.FAILED - assert report.error is not None - assert report.error.message == message - assert report.warning is None - - with pytest.raises( - expected_exception=ModelTargetDatasetNotDoneError, - ): - _ = client.download_dataset( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - @staticmethod - def test_generation_warning( - *, - model_target_model: ModelTargetModel, - ) -> None: - """A dataset which generates with a warning reports the - warning. - """ - warning = ModelTargetGenerationWarning() - with MockVWS( - processing_time_seconds=0.2, - model_target_generation_warning=warning, - ): - client = ModelTargetService( - client_id=_CLIENT_ID, - client_secret=_CLIENT_CREDENTIALS[1], - ) - dataset_uuid = client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model], - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - report = client.wait_for_dataset_generated( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - assert report.status == ModelTargetDatasetStatuses.DONE - assert report.error is None - assert report.warning is not None - assert report.warning.message == warning.message - assert report.warning.target == dataset_uuid - (detail,) = report.warning.details - assert detail.code == "LOW_RECOGNITION_QUALITY" - - assert bool( - client.download_dataset( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.STANDARD, - ) - ) - - -class TestWaitForDatasetGenerated: - """Tests for waiting for a dataset to be generated.""" - - @staticmethod - def test_timeout(*, model_target_model: ModelTargetModel) -> None: - """An exception is raised when the wait times out.""" - with MockVWS(processing_time_seconds=60): - client = ModelTargetService( - client_id=_CLIENT_ID, - client_secret=_CLIENT_CREDENTIALS[1], - ) - dataset_uuid = client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model], - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - with pytest.raises( - expected_exception=ModelTargetDatasetTimeoutError, - ): - _ = client.wait_for_dataset_generated( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.STANDARD, - seconds_between_requests=0.01, - timeout_seconds=0.05, - ) - - -class TestErrorEnvelope: - """Tests for reading responses which are not shaped like Model Target - Web API errors. - """ - - @staticmethod - @pytest.mark.parametrize( - argnames="text", - argvalues=[ - "", - "Not JSON", - "[]", - "{}", - '{"error": "not-an-object"}', - '{"transaction_id": "abc", "result_code": "Fail"}', - ], - ) - def test_unknown_error_shape(*, text: str) -> None: - """An error without a Model Target error object gives empty - values. - """ - error = ModelTargetError(response=_response(text=text)) - - assert not bool(error.code) - assert not bool(error.message) - assert not bool(error.target) - assert not bool(error.details) - - @staticmethod - def test_error_without_details() -> None: - """An error which gives no details has no details.""" - text = json.dumps(obj={"error": {"code": "ERROR", "message": "No"}}) - error = ModelTargetError(response=_response(text=text)) - - assert error.code == "ERROR" - assert error.message == "No" - assert not bool(error.target) - assert not bool(error.details) - - @staticmethod - @pytest.mark.parametrize( - argnames="text", - argvalues=["Not JSON", "[]", "{}"], - ) - def test_unknown_oauth2_error_shape(*, text: str) -> None: - """An OAuth2 error without an error code gives empty values.""" - error = ModelTargetOAuth2Error(response=_response(text=text)) - - assert not bool(error.error) - assert not bool(error.error_description) - - @staticmethod - def test_oauth2_error_description() -> None: - """An OAuth2 error description is given when Vuforia gives one.""" - description = "Missing or invalid authorization header" - text = json.dumps( - obj={ - "error": "invalid_request", - "error_description": description, - }, - ) - error = ModelTargetOAuth2Error(response=_response(text=text)) - - assert error.error == "invalid_request" - assert error.error_description == description - - -@pytest.mark.parametrize( - argnames="payload", - argvalues=[ - {"access_token": 1, "expires_in": 3600}, - {"access_token": "token", "expires_in": []}, - ], -) -def test_invalid_oauth2_token_response_values( - *, payload: dict[str, object] -) -> None: - """OAuth token responses must contain correctly typed values.""" - response = _response_with_status( - text=json.dumps(obj=payload), - status_code=HTTPStatus.OK, - ) - - with pytest.raises(expected_exception=ModelTargetOAuth2Error): - _ = access_token_from_response(response=response) - - -class TestBaseVWSURL: - """Tests for using a custom base URL.""" - - @staticmethod - def test_custom_base_url( - *, - model_target_model: ModelTargetModel, - ) -> None: - """The Model Target Web API can be served from a URL with a - path. - """ - base_vws_url = "https://example.com/vws" - with MockVWS( - base_vws_url=base_vws_url, - processing_time_seconds=0.2, - ): - client = ModelTargetService( - client_id=_CLIENT_ID, - client_secret=_CLIENT_CREDENTIALS[1], - base_vws_url=base_vws_url, - ) - dataset_uuid = client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model], - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - report = client.get_dataset_status( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - assert report.dataset_uuid == dataset_uuid diff --git a/tests/test_query.py b/tests/test_query.py index 9d0ac2d73..e0a4309bd 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -63,455 +63,431 @@ def __call__( ) -class TestQuery: - """Tests for making image queries.""" - - @staticmethod - def test_no_matches( - *, - cloud_reco_client: CloudRecoService, - image: io.BytesIO | BinaryIO, - ) -> None: - """An empty list is returned if there are no matches.""" - result = cloud_reco_client.query(image=image) - assert result == [] - - @staticmethod - def test_match( - *, - vws_client: VWS, - cloud_reco_client: CloudRecoService, - image: io.BytesIO | BinaryIO, - ) -> None: - """Details of matching targets are returned.""" - target_id = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - [matching_target] = cloud_reco_client.query(image=image) - assert matching_target.target_id == target_id - - @staticmethod - def test_invalid_results(*, image: io.BytesIO | BinaryIO) -> None: - """Query results in responses must be a list of objects.""" - transport = _JSONResponseTransport( - body={"result_code": "Success", "results": 1}, - status_code=HTTPStatus.OK, - ) - client = CloudRecoService( - client_access_key="access-key", - client_secret_key=secrets.token_hex(), - transport=transport, - ) +def test_no_matches( + *, + cloud_reco_client: CloudRecoService, + image: io.BytesIO | BinaryIO, +) -> None: + """An empty list is returned if there are no matches.""" + result = cloud_reco_client.query(image=image) + assert result == [] + + +def test_match( + *, + vws_client: VWS, + cloud_reco_client: CloudRecoService, + image: io.BytesIO | BinaryIO, +) -> None: + """Details of matching targets are returned.""" + target_id = vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id) + [matching_target] = cloud_reco_client.query(image=image) + assert matching_target.target_id == target_id - with pytest.raises(expected_exception=TypeError): - _ = client.query(image=image) - @staticmethod - def test_server_error(*, image: io.BytesIO | BinaryIO) -> None: - """Server errors are exposed through the public query client.""" - transport = _JSONResponseTransport( - body={}, - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - client = CloudRecoService( - client_access_key="access-key", - client_secret_key=secrets.token_hex(), - transport=transport, - ) - - with pytest.raises(expected_exception=ServerError): - _ = client.query(image=image) +def test_invalid_results(*, image: io.BytesIO | BinaryIO) -> None: + """Query results in responses must be a list of objects.""" + transport = _JSONResponseTransport( + body={"result_code": "Success", "results": 1}, + status_code=HTTPStatus.OK, + ) + client = CloudRecoService( + client_access_key="access-key", + client_secret_key=secrets.token_hex(), + transport=transport, + ) + with pytest.raises(expected_exception=TypeError): + _ = client.query(image=image) -class TestDefaultRequestTimeout: - """Tests for the default request timeout.""" - @staticmethod - @pytest.mark.parametrize( - argnames=("response_delay_seconds", "expect_timeout"), - argvalues=[(29, False), (31, True)], +def test_server_error(*, image: io.BytesIO | BinaryIO) -> None: + """Server errors are exposed through the public query client.""" + transport = _JSONResponseTransport( + body={}, + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, ) - def test_default_timeout( - *, - image: io.BytesIO | BinaryIO, - response_delay_seconds: int, - expect_timeout: bool, - ) -> None: - """At 29 seconds there is no error; at 31 seconds there is a - timeout. - """ - with ( - freeze_time() as frozen_datetime, - MockVWS( - response_delay_seconds=response_delay_seconds, - sleep_fn=lambda seconds: ( - frozen_datetime.tick( - delta=datetime.timedelta(seconds=seconds), - ), - None, - )[1], - ) as mock, - ): - database = CloudDatabase() - mock.add_cloud_database(cloud_database=database) - cloud_reco_client = CloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=database.client_secret_key, - ) - - if expect_timeout: - with pytest.raises( - expected_exception=requests.exceptions.Timeout, - ): - _ = cloud_reco_client.query(image=image) - else: - matches = cloud_reco_client.query(image=image) - assert not bool(matches) - - -class TestCustomRequestTimeout: - """Tests for custom request timeout values.""" - - @staticmethod - @pytest.mark.parametrize( - argnames=( - "custom_timeout", - "response_delay_seconds", - "expect_timeout", - ), - argvalues=[ - (0.1, 0.09, False), - (0.1, 0.11, True), - ((5.0, 0.1), 0.09, False), - ((5.0, 0.1), 0.11, True), - ], + client = CloudRecoService( + client_access_key="access-key", + client_secret_key=secrets.token_hex(), + transport=transport, ) - def test_custom_timeout( - *, - image: io.BytesIO | BinaryIO, - custom_timeout: float | tuple[float, float], - response_delay_seconds: float, - expect_timeout: bool, - ) -> None: - """Custom timeouts are honored for both float and tuple forms.""" - with ( - freeze_time() as frozen_datetime, - MockVWS( - response_delay_seconds=response_delay_seconds, - sleep_fn=lambda seconds: ( - frozen_datetime.tick( - delta=datetime.timedelta(seconds=seconds), - ), - None, - )[1], - ) as mock, - ): - database = CloudDatabase() - mock.add_cloud_database(cloud_database=database) - cloud_reco_client = CloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=database.client_secret_key, - request_timeout_seconds=custom_timeout, - ) - - if expect_timeout: - with pytest.raises( - expected_exception=requests.exceptions.Timeout, - ): - _ = cloud_reco_client.query(image=image) - else: - matches = cloud_reco_client.query(image=image) - assert not bool(matches) - - -class TestCustomBaseVWQURL: - """Tests for using a custom base VWQ URL.""" - - @staticmethod - def test_custom_base_url(image: io.BytesIO | BinaryIO) -> None: - """ - It is possible to use query a target to a database under a - custom - VWQ - URL. - """ - base_vwq_url = "http://example.com" - with MockVWS(base_vwq_url=base_vwq_url) as mock: - database = CloudDatabase() - mock.add_cloud_database(cloud_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="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - - cloud_reco_client = CloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=database.client_secret_key, - base_vwq_url=base_vwq_url, - ) - - matches = cloud_reco_client.query(image=image) - assert len(matches) == 1 - match = matches[0] - assert match.target_id == target_id - @staticmethod - def test_custom_base_url_with_path_prefix( - image: io.BytesIO | BinaryIO, - ) -> None: - """ - A base VWQ URL with a path prefix is used as-is, without the - prefix being dropped. - """ - base_vwq_url = "http://example.com/prefix" - with MockVWS(base_vwq_url=base_vwq_url) as mock: - database = CloudDatabase() - mock.add_cloud_database(cloud_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="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - cloud_reco_client = CloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=database.client_secret_key, - base_vwq_url=base_vwq_url, - ) + with pytest.raises(expected_exception=ServerError): + _ = client.query(image=image) + + +@pytest.mark.parametrize( + argnames=("response_delay_seconds", "expect_timeout"), + argvalues=[(29, False), (31, True)], +) +def test_default_timeout( + *, + image: io.BytesIO | BinaryIO, + response_delay_seconds: int, + expect_timeout: bool, +) -> None: + """At 29 seconds there is no error; at 31 seconds there is a + timeout. + """ + with ( + freeze_time() as frozen_datetime, + MockVWS( + response_delay_seconds=response_delay_seconds, + sleep_fn=lambda seconds: ( + frozen_datetime.tick( + delta=datetime.timedelta(seconds=seconds), + ), + None, + )[1], + ) as mock, + ): + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + ) + if expect_timeout: + with pytest.raises( + expected_exception=requests.exceptions.Timeout, + ): + _ = cloud_reco_client.query(image=image) + else: matches = cloud_reco_client.query(image=image) - assert len(matches) == 1 - - -class TestMaxNumResults: - """Tests for the ``max_num_results`` parameter of ``query``.""" - - @staticmethod - def test_default( - *, - vws_client: VWS, - cloud_reco_client: CloudRecoService, - image: io.BytesIO | BinaryIO, - ) -> None: - """By default the maximum number of results is 1.""" - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - target_id_2 = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, + assert not bool(matches) + + +@pytest.mark.parametrize( + argnames=( + "custom_timeout", + "response_delay_seconds", + "expect_timeout", + ), + argvalues=[ + (0.1, 0.09, False), + (0.1, 0.11, True), + ((5.0, 0.1), 0.09, False), + ((5.0, 0.1), 0.11, True), + ], +) +def test_custom_timeout( + *, + image: io.BytesIO | BinaryIO, + custom_timeout: float | tuple[float, float], + response_delay_seconds: float, + expect_timeout: bool, +) -> None: + """Custom timeouts are honored for both float and tuple forms.""" + with ( + freeze_time() as frozen_datetime, + MockVWS( + response_delay_seconds=response_delay_seconds, + sleep_fn=lambda seconds: ( + frozen_datetime.tick( + delta=datetime.timedelta(seconds=seconds), + ), + None, + )[1], + ) as mock, + ): + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + request_timeout_seconds=custom_timeout, ) - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.wait_for_target_processed(target_id=target_id_2) - matches = cloud_reco_client.query(image=image) - assert len(matches) == 1 - @staticmethod - def test_custom( - *, - vws_client: VWS, - cloud_reco_client: CloudRecoService, - image: io.BytesIO | BinaryIO, - ) -> None: - """It is possible to set a custom ``max_num_results``.""" - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - target_id_2 = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - target_id_3 = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.wait_for_target_processed(target_id=target_id_2) - vws_client.wait_for_target_processed(target_id=target_id_3) - max_num_results = 2 - matches = cloud_reco_client.query( - image=image, - max_num_results=max_num_results, + if expect_timeout: + with pytest.raises( + expected_exception=requests.exceptions.Timeout, + ): + _ = cloud_reco_client.query(image=image) + else: + matches = cloud_reco_client.query(image=image) + assert not bool(matches) + + +def test_custom_base_url(image: io.BytesIO | BinaryIO) -> None: + """ + It is possible to use query a target to a database under a + custom + VWQ + URL. + """ + base_vwq_url = "http://example.com" + with MockVWS(base_vwq_url=base_vwq_url) as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, ) - assert len(matches) == max_num_results - -class TestIncludeTargetData: - """Tests for the ``include_target_data`` parameter of ``query``.""" - - @staticmethod - def test_default( - *, - vws_client: VWS, - cloud_reco_client: CloudRecoService, - image: io.BytesIO | BinaryIO, - ) -> None: - """By default, target data is only returned in the top match.""" target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - target_id_2 = vws_client.add_target( - name=uuid.uuid4().hex, + name="x", width=1, image=image, active_flag=True, application_metadata=None, ) - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.wait_for_target_processed(target_id=target_id_2) - top_match, second_match = cloud_reco_client.query( - image=image, - max_num_results=2, - ) - assert top_match.target_data is not None - assert second_match.target_data is None - @staticmethod - def test_top( - *, - vws_client: VWS, - cloud_reco_client: CloudRecoService, - image: io.BytesIO | BinaryIO, - ) -> None: - """ - When ``CloudRecoIncludeTargetData.TOP`` is given, target data is - only - returned in the top match. - """ - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - target_id_2 = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) vws_client.wait_for_target_processed(target_id=target_id) - vws_client.wait_for_target_processed(target_id=target_id_2) - top_match, second_match = cloud_reco_client.query( - image=image, - max_num_results=2, - include_target_data=CloudRecoIncludeTargetData.TOP, - ) - assert top_match.target_data is not None - assert second_match.target_data is None - @staticmethod - def test_none( - *, - vws_client: VWS, - cloud_reco_client: CloudRecoService, - image: io.BytesIO | BinaryIO, - ) -> None: - """ - When ``CloudRecoIncludeTargetData.NONE`` is given, target data - is - not - returned in any match. - """ - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + base_vwq_url=base_vwq_url, ) - target_id_2 = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.wait_for_target_processed(target_id=target_id_2) - top_match, second_match = cloud_reco_client.query( - image=image, - max_num_results=2, - include_target_data=CloudRecoIncludeTargetData.NONE, - ) - assert top_match.target_data is None - assert second_match.target_data is None - @staticmethod - def test_all( - *, - vws_client: VWS, - cloud_reco_client: CloudRecoService, - image: io.BytesIO | BinaryIO, - ) -> None: - """ - When ``CloudRecoIncludeTargetData.ALL`` is given, target data is - returned in all matches. - """ - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image, - active_flag=True, - application_metadata=None, + matches = cloud_reco_client.query(image=image) + assert len(matches) == 1 + match = matches[0] + assert match.target_id == target_id + + +def test_custom_base_url_with_path_prefix( + image: io.BytesIO | BinaryIO, +) -> None: + """ + A base VWQ URL with a path prefix is used as-is, without the + prefix being dropped. + """ + base_vwq_url = "http://example.com/prefix" + with MockVWS(base_vwq_url=base_vwq_url) as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, ) - target_id_2 = vws_client.add_target( - name=uuid.uuid4().hex, + target_id = vws_client.add_target( + name="x", width=1, image=image, active_flag=True, application_metadata=None, ) vws_client.wait_for_target_processed(target_id=target_id) - vws_client.wait_for_target_processed(target_id=target_id_2) - top_match, second_match = cloud_reco_client.query( - image=image, - max_num_results=2, - include_target_data=CloudRecoIncludeTargetData.ALL, + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + base_vwq_url=base_vwq_url, ) - assert top_match.target_data is not None - assert second_match.target_data is not None + + matches = cloud_reco_client.query(image=image) + assert len(matches) == 1 + + +def test_max_num_results_default( + *, + vws_client: VWS, + cloud_reco_client: CloudRecoService, + image: io.BytesIO | BinaryIO, +) -> None: + """By default the maximum number of results is 1.""" + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + target_id_2 = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.wait_for_target_processed(target_id=target_id_2) + matches = cloud_reco_client.query(image=image) + assert len(matches) == 1 + + +def test_custom( + *, + vws_client: VWS, + cloud_reco_client: CloudRecoService, + image: io.BytesIO | BinaryIO, +) -> None: + """It is possible to set a custom ``max_num_results``.""" + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + target_id_2 = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + target_id_3 = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.wait_for_target_processed(target_id=target_id_2) + vws_client.wait_for_target_processed(target_id=target_id_3) + max_num_results = 2 + matches = cloud_reco_client.query( + image=image, + max_num_results=max_num_results, + ) + assert len(matches) == max_num_results + + +def test_include_target_data_default( + *, + vws_client: VWS, + cloud_reco_client: CloudRecoService, + image: io.BytesIO | BinaryIO, +) -> None: + """By default, target data is only returned in the top match.""" + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + target_id_2 = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.wait_for_target_processed(target_id=target_id_2) + top_match, second_match = cloud_reco_client.query( + image=image, + max_num_results=2, + ) + assert top_match.target_data is not None + assert second_match.target_data is None + + +def test_top( + *, + vws_client: VWS, + cloud_reco_client: CloudRecoService, + image: io.BytesIO | BinaryIO, +) -> None: + """ + When ``CloudRecoIncludeTargetData.TOP`` is given, target data is + only + returned in the top match. + """ + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + target_id_2 = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.wait_for_target_processed(target_id=target_id_2) + top_match, second_match = cloud_reco_client.query( + image=image, + max_num_results=2, + include_target_data=CloudRecoIncludeTargetData.TOP, + ) + assert top_match.target_data is not None + assert second_match.target_data is None + + +def test_none( + *, + vws_client: VWS, + cloud_reco_client: CloudRecoService, + image: io.BytesIO | BinaryIO, +) -> None: + """ + When ``CloudRecoIncludeTargetData.NONE`` is given, target data + is + not + returned in any match. + """ + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + target_id_2 = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.wait_for_target_processed(target_id=target_id_2) + top_match, second_match = cloud_reco_client.query( + image=image, + max_num_results=2, + include_target_data=CloudRecoIncludeTargetData.NONE, + ) + assert top_match.target_data is None + assert second_match.target_data is None + + +def test_all( + *, + vws_client: VWS, + cloud_reco_client: CloudRecoService, + image: io.BytesIO | BinaryIO, +) -> None: + """ + When ``CloudRecoIncludeTargetData.ALL`` is given, target data is + returned in all matches. + """ + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + target_id_2 = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.wait_for_target_processed(target_id=target_id_2) + top_match, second_match = cloud_reco_client.query( + image=image, + max_num_results=2, + include_target_data=CloudRecoIncludeTargetData.ALL, + ) + assert top_match.target_data is not None + assert second_match.target_data is not None diff --git a/tests/test_transports.py b/tests/test_transports.py deleted file mode 100644 index 82b9db76d..000000000 --- a/tests/test_transports.py +++ /dev/null @@ -1,1232 +0,0 @@ -"""Tests for HTTP transport implementations.""" - -from __future__ import annotations - -import uuid -from http import HTTPStatus -from typing import TYPE_CHECKING - -import httpx -import httpx2 -import pytest -import respx -from mock_vws import MockVWS -from mock_vws.database import CloudDatabase, VuMarkDatabase -from mock_vws.target import VuMarkTarget - -from vws import ( - VWS, - AsyncCloudRecoService, - AsyncModelTargetService, - AsyncVuMarkService, - AsyncVWS, - CloudRecoService, - ModelTargetService, - VuMarkService, -) -from vws.exceptions.custom_exceptions import ServerError -from vws.exceptions.vws_exceptions import TooManyRequestsError -from vws.model_target_datasets import ( - ModelTargetDatasetType, - ModelTargetModel, -) -from vws.reports import ModelTargetDatasetStatuses, TargetStatuses -from vws.response import Response -from vws.transports import ( - AsyncHTTPX2Transport, - AsyncHTTPXTransport, - HTTPX2Transport, - HTTPXTransport, -) -from vws.vumark_accept import VuMarkAccept - -if TYPE_CHECKING: - import io - - -class TestHTTPXTransport: - """Tests for ``HTTPXTransport``.""" - - @staticmethod - @respx.mock - def test_float_timeout() -> None: - """``HTTPXTransport`` works with a float timeout.""" - route = respx.post(url="https://example.com/test").mock( - return_value=httpx.Response( - status_code=HTTPStatus.OK, - text="OK", - ), - ) - transport = HTTPXTransport() - response = transport( - method="POST", - url="https://example.com/test", - headers={"Content-Type": "text/plain"}, - data=b"hello", - request_timeout=30.0, - ) - assert route.called - assert isinstance(response, Response) - assert response.status_code == HTTPStatus.OK - assert response.text == "OK" - assert response.tell_position == len(b"OK") - - @staticmethod - @respx.mock - def test_tuple_timeout() -> None: - """``HTTPXTransport`` works with a (connect, read) timeout - tuple. - """ - route = respx.post(url="https://example.com/test").mock( - return_value=httpx.Response( - status_code=HTTPStatus.OK, - text="OK", - ), - ) - transport = HTTPXTransport() - response = transport( - method="POST", - url="https://example.com/test", - headers={"Content-Type": "text/plain"}, - data=b"hello", - request_timeout=(5.0, 30.0), - ) - assert route.called - assert isinstance(response, Response) - assert response.status_code == HTTPStatus.OK - - @staticmethod - @respx.mock - def test_int_timeout() -> None: - """``HTTPXTransport`` works with an int timeout.""" - route = respx.post(url="https://example.com/test").mock( - return_value=httpx.Response( - status_code=HTTPStatus.OK, - text="OK", - ), - ) - transport = HTTPXTransport() - response = transport( - method="POST", - url="https://example.com/test", - headers={"Content-Type": "text/plain"}, - data=b"hello", - request_timeout=30, - ) - assert route.called - assert isinstance(response, Response) - assert response.status_code == HTTPStatus.OK - - @staticmethod - @respx.mock - def test_context_manager() -> None: - """``HTTPXTransport`` can be used as a context manager.""" - route = respx.post(url="https://example.com/test").mock( - return_value=httpx.Response( - status_code=HTTPStatus.OK, - text="OK", - ), - ) - with HTTPXTransport() as transport: - response = transport( - method="POST", - url="https://example.com/test", - headers={"Content-Type": "text/plain"}, - data=b"hello", - request_timeout=30.0, - ) - assert route.called - assert isinstance(response, Response) - assert response.status_code == HTTPStatus.OK - - -class TestAsyncHTTPXTransport: - """Tests for ``AsyncHTTPXTransport``.""" - - @staticmethod - @pytest.mark.asyncio - @respx.mock - async def test_float_timeout() -> None: - """``AsyncHTTPXTransport`` works with a float timeout.""" - route = respx.post(url="https://example.com/test").mock( - return_value=httpx.Response( - status_code=HTTPStatus.OK, - text="OK", - ), - ) - transport = AsyncHTTPXTransport() - response = await transport( - method="POST", - url="https://example.com/test", - headers={"Content-Type": "text/plain"}, - data=b"hello", - request_timeout=30.0, - ) - assert route.called - assert isinstance(response, Response) - assert response.status_code == HTTPStatus.OK - assert response.text == "OK" - assert response.tell_position == len(b"OK") - - @staticmethod - @pytest.mark.asyncio - @respx.mock - async def test_tuple_timeout() -> None: - """``AsyncHTTPXTransport`` works with a (connect, read) - timeout tuple. - """ - route = respx.post(url="https://example.com/test").mock( - return_value=httpx.Response( - status_code=HTTPStatus.OK, - text="OK", - ), - ) - transport = AsyncHTTPXTransport() - response = await transport( - method="POST", - url="https://example.com/test", - headers={"Content-Type": "text/plain"}, - data=b"hello", - request_timeout=(5.0, 30.0), - ) - assert route.called - assert isinstance(response, Response) - assert response.status_code == HTTPStatus.OK - - @staticmethod - @pytest.mark.asyncio - @respx.mock - async def test_int_timeout() -> None: - """``AsyncHTTPXTransport`` works with an int timeout.""" - route = respx.post(url="https://example.com/test").mock( - return_value=httpx.Response( - status_code=HTTPStatus.OK, - text="OK", - ), - ) - transport = AsyncHTTPXTransport() - response = await transport( - method="POST", - url="https://example.com/test", - headers={"Content-Type": "text/plain"}, - data=b"hello", - request_timeout=30, - ) - assert route.called - assert isinstance(response, Response) - assert response.status_code == HTTPStatus.OK - - @staticmethod - @pytest.mark.asyncio - @respx.mock - async def test_context_manager() -> None: - """``AsyncHTTPXTransport`` can be used as an async context - manager. - """ - route = respx.post(url="https://example.com/test").mock( - return_value=httpx.Response( - status_code=HTTPStatus.OK, - text="OK", - ), - ) - async with AsyncHTTPXTransport() as transport: - response = await transport( - method="POST", - url="https://example.com/test", - headers={"Content-Type": "text/plain"}, - data=b"hello", - request_timeout=30.0, - ) - assert route.called - assert isinstance(response, Response) - assert response.status_code == HTTPStatus.OK - - -class _FalsyTransport: - """A sync transport that is falsy but protocol-conforming.""" - - def __bool__(self) -> bool: - """Return ``False`` so truthiness checks would skip this - transport. - """ - return False - - def close(self) -> None: - """Close the transport.""" - - def __call__( - self, - *, - method: str, - url: str, - headers: dict[str, str], - data: bytes, - request_timeout: float | tuple[float, float], - ) -> Response: - """Return a successful API response for the requested URL.""" - del method, headers, request_timeout - if url.endswith("/query"): - body = '{"result_code":"Success","results":[]}' - return Response( - text=body, - url=url, - status_code=HTTPStatus.OK, - headers={}, - request_body=data, - tell_position=0, - content=body.encode(), - ) - if "/instances" in url: - content = b"vumark-bytes" - return Response( - text="", - url=url, - status_code=HTTPStatus.OK, - headers={}, - request_body=data, - tell_position=0, - content=content, - ) - body = '{"result_code":"Success","results":[]}' - return Response( - text=body, - url=url, - status_code=HTTPStatus.OK, - headers={}, - request_body=data, - tell_position=0, - content=body.encode(), - ) - - -class _FalsyAsyncTransport: - """An async transport that is falsy but protocol-conforming.""" - - def __bool__(self) -> bool: - """Return ``False`` so truthiness checks would skip this - transport. - """ - return False - - async def aclose(self) -> None: - """Close the transport.""" - - async def __call__( - self, - *, - method: str, - url: str, - headers: dict[str, str], - data: bytes, - request_timeout: float | tuple[float, float], - ) -> Response: - """Return a successful API response for the requested URL.""" - del method, headers, request_timeout - if url.endswith("/query"): - body = '{"result_code":"Success","results":[]}' - return Response( - text=body, - url=url, - status_code=HTTPStatus.OK, - headers={}, - request_body=data, - tell_position=0, - content=body.encode(), - ) - if "/instances" in url: - content = b"vumark-bytes" - return Response( - text="", - url=url, - status_code=HTTPStatus.OK, - headers={}, - request_body=data, - tell_position=0, - content=content, - ) - body = '{"result_code":"Success","results":[]}' - return Response( - text=body, - url=url, - status_code=HTTPStatus.OK, - headers={}, - request_body=data, - tell_position=0, - content=body.encode(), - ) - - -def test_falsy_sync_transport_is_retained( - high_quality_image: io.BytesIO, -) -> None: - """Falsy custom sync transports are not replaced by the default.""" - access_key = uuid.uuid4().hex - secret_key = uuid.uuid4().hex - transport = _FalsyTransport() - assert not bool(transport) - - targets = VWS( - server_access_key=access_key, - server_secret_key=secret_key, - transport=transport, - ).list_targets() - assert not bool(targets) - - query_results = CloudRecoService( - client_access_key=access_key, - client_secret_key=secret_key, - transport=transport, - ).query(image=high_quality_image) - assert not bool(query_results) - - vumark_bytes = VuMarkService( - server_access_key=access_key, - server_secret_key=secret_key, - transport=transport, - ).generate_vumark_instance( - target_id="target", - instance_id="instance", - accept=VuMarkAccept.PNG, - ) - assert vumark_bytes == b"vumark-bytes" - - -@pytest.mark.asyncio -async def test_falsy_async_transport_is_retained( - high_quality_image: io.BytesIO, -) -> None: - """Falsy custom async transports are not replaced by the default.""" - access_key = uuid.uuid4().hex - secret_key = uuid.uuid4().hex - transport = _FalsyAsyncTransport() - assert not bool(transport) - - async with AsyncVWS( - server_access_key=access_key, - server_secret_key=secret_key, - transport=transport, - ) as vws_client: - assert not bool(await vws_client.list_targets()) - - async with AsyncCloudRecoService( - client_access_key=access_key, - client_secret_key=secret_key, - transport=transport, - ) as cloud_reco_client: - assert not bool( - await cloud_reco_client.query(image=high_quality_image) - ) - - async with AsyncVuMarkService( - server_access_key=access_key, - server_secret_key=secret_key, - transport=transport, - ) as vumark_client: - assert ( - await vumark_client.generate_vumark_instance( - target_id="target", - instance_id="instance", - accept=VuMarkAccept.PNG, - ) - == b"vumark-bytes" - ) - - -@pytest.mark.parametrize( - argnames=("status_code", "exception_type"), - argvalues=[ - (HTTPStatus.TOO_MANY_REQUESTS, TooManyRequestsError), - (HTTPStatus.INTERNAL_SERVER_ERROR, ServerError), - ], -) -@respx.mock -def test_vumark_service_handles_bodyless_http_errors( - *, - status_code: HTTPStatus, - exception_type: type[ServerError | TooManyRequestsError], -) -> None: - """VuMark HTTP errors which have no JSON body remain meaningful.""" - route = respx.post( - url="https://example.com/targets/target/instances" - ).mock(return_value=httpx.Response(status_code=status_code)) - - with HTTPXTransport() as transport: - service = VuMarkService( - server_access_key="access-key", - server_secret_key=uuid.uuid4().hex, - base_vws_url="https://example.com", - transport=transport, - ) - with pytest.raises(expected_exception=exception_type): - _ = service.generate_vumark_instance( - target_id="target", - instance_id="instance", - accept=VuMarkAccept.PNG, - ) - - assert route.called - - -@respx.mock -def test_vws_handles_bodyless_rate_limit_response() -> None: - """A body-less target-manager rate limit raises its specific error.""" - route = respx.get(url="https://example.com/targets").mock( - return_value=httpx.Response(status_code=HTTPStatus.TOO_MANY_REQUESTS) - ) - - with HTTPXTransport() as transport: - service = VWS( - server_access_key="access-key", - server_secret_key=uuid.uuid4().hex, - base_vws_url="https://example.com", - transport=transport, - ) - with pytest.raises(expected_exception=TooManyRequestsError): - _ = service.list_targets() - - assert route.called - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - argnames=("status_code", "exception_type"), - argvalues=[ - (HTTPStatus.TOO_MANY_REQUESTS, TooManyRequestsError), - (HTTPStatus.INTERNAL_SERVER_ERROR, ServerError), - ], -) -@respx.mock -async def test_async_vumark_service_handles_bodyless_http_errors( - *, - status_code: HTTPStatus, - exception_type: type[ServerError | TooManyRequestsError], -) -> None: - """Async VuMark HTTP errors without JSON remain meaningful.""" - route = respx.post( - url="https://example.com/targets/target/instances" - ).mock(return_value=httpx.Response(status_code=status_code)) - - async with AsyncHTTPXTransport() as transport: - service = AsyncVuMarkService( - server_access_key="access-key", - server_secret_key=uuid.uuid4().hex, - base_vws_url="https://example.com", - transport=transport, - ) - with pytest.raises(expected_exception=exception_type): - _ = await service.generate_vumark_instance( - target_id="target", - instance_id="instance", - accept=VuMarkAccept.PNG, - ) - - assert route.called - - -@pytest.mark.asyncio -@respx.mock -async def test_async_vws_handles_bodyless_rate_limit_response() -> None: - """An async target-manager rate limit raises its specific error.""" - route = respx.get(url="https://example.com/targets").mock( - return_value=httpx.Response(status_code=HTTPStatus.TOO_MANY_REQUESTS) - ) - - async with AsyncHTTPXTransport() as transport: - service = AsyncVWS( - server_access_key="access-key", - server_secret_key=uuid.uuid4().hex, - base_vws_url="https://example.com", - transport=transport, - ) - with pytest.raises(expected_exception=TooManyRequestsError): - _ = await service.list_targets() - - assert route.called - - -# The mock accepts one hard-coded pair of Model Target Web API OAuth2 -# credentials, which it does not expose. -_MODEL_TARGET_CLIENT_ID = "client-id" -_MODEL_TARGET_CLIENT_CREDENTIALS = ("client-id", "client-secret") - -_HTTPX2_URL = "https://example.com/test" -_HTTPX2_REFUSED_URL = "https://example.com/refused" - - -@pytest.fixture(name="httpx2_requests") -def fixture_httpx2_requests( - *, - monkeypatch: pytest.MonkeyPatch, -) -> list[httpx2.Request]: - """Answer every ``httpx2`` request with a fake server, and collect the - requests which it sees. - - The fake server answers ``OK`` to every request except those for - ``_HTTPX2_REFUSED_URL``, which it refuses to connect to. - """ - requests_seen: list[httpx2.Request] = [] - - def handler(request: httpx2.Request) -> httpx2.Response: - """Answer an ``httpx2`` request. - - Args: - request: The request to answer. - - Returns: - An ``OK`` response. - - Raises: - httpx2.ConnectError: The request is for the refused URL. - """ - requests_seen.append(request) - if str(object=request.url) == _HTTPX2_REFUSED_URL: - raise httpx2.ConnectError( - message="Connection refused", - request=request, - ) - return httpx2.Response( - status_code=HTTPStatus.OK, - text="OK", - headers={"X-Example": "example"}, - ) - - mock_transport = httpx2.MockTransport(handler=handler) - - class _Client(httpx2.Client): - """A synchronous client which uses the fake server.""" - - def __init__(self) -> None: - """Create a client which uses the fake server.""" - super().__init__(transport=mock_transport) - - class _AsyncClient(httpx2.AsyncClient): - """An asynchronous client which uses the fake server.""" - - def __init__(self) -> None: - """Create a client which uses the fake server.""" - super().__init__(transport=mock_transport) - - monkeypatch.setattr(target=httpx2, name="Client", value=_Client) - monkeypatch.setattr(target=httpx2, name="AsyncClient", value=_AsyncClient) - return requests_seen - - -class TestHTTPX2Transport: - """Tests for ``HTTPX2Transport``.""" - - @staticmethod - def test_float_timeout(httpx2_requests: list[httpx2.Request]) -> None: - """``HTTPX2Transport`` works with a float timeout. - - A float sets both the connect and read timeouts, and the response - is converted in full. - """ - transport = HTTPX2Transport() - response = transport( - method="POST", - url=_HTTPX2_URL, - headers={"Content-Type": "text/plain"}, - data=b"hello", - request_timeout=30.0, - ) - (request,) = httpx2_requests - assert request.extensions["timeout"] == { - "connect": 30.0, - "read": 30.0, - "write": None, - "pool": None, - } - assert request.headers["Content-Type"] == "text/plain" - assert isinstance(response, Response) - assert response.status_code == HTTPStatus.OK - assert response.text == "OK" - assert response.url == _HTTPX2_URL - assert response.headers["x-example"] == "example" - assert response.request_body == b"hello" - assert response.content == b"OK" - assert response.tell_position == len(b"OK") - - @staticmethod - def test_tuple_timeout(httpx2_requests: list[httpx2.Request]) -> None: - """``HTTPX2Transport`` works with a (connect, read) timeout - tuple. - """ - transport = HTTPX2Transport() - response = transport( - method="POST", - url=_HTTPX2_URL, - headers={"Content-Type": "text/plain"}, - data=b"hello", - request_timeout=(5.0, 30.0), - ) - (request,) = httpx2_requests - assert request.extensions["timeout"] == { - "connect": 5.0, - "read": 30.0, - "write": None, - "pool": None, - } - assert isinstance(response, Response) - assert response.status_code == HTTPStatus.OK - - @staticmethod - def test_int_timeout(httpx2_requests: list[httpx2.Request]) -> None: - """``HTTPX2Transport`` works with an int timeout.""" - transport = HTTPX2Transport() - response = transport( - method="POST", - url=_HTTPX2_URL, - headers={"Content-Type": "text/plain"}, - data=b"hello", - request_timeout=30, - ) - (request,) = httpx2_requests - assert request.extensions["timeout"] == { - "connect": 30, - "read": 30, - "write": None, - "pool": None, - } - assert isinstance(response, Response) - assert response.status_code == HTTPStatus.OK - - @staticmethod - def test_empty_body(httpx2_requests: list[httpx2.Request]) -> None: - """An empty request body is reported as ``None``, as it is for - the ``requests`` and ``httpx`` transports. - """ - transport = HTTPX2Transport() - response = transport( - method="GET", - url=_HTTPX2_URL, - headers={}, - data=b"", - request_timeout=30.0, - ) - assert len(httpx2_requests) == 1 - assert response.request_body is None - - @staticmethod - def test_context_manager(httpx2_requests: list[httpx2.Request]) -> None: - """``HTTPX2Transport`` can be used as a context manager, and - leaving the context closes the client. - """ - with HTTPX2Transport() as transport: - response = transport( - method="POST", - url=_HTTPX2_URL, - headers={"Content-Type": "text/plain"}, - data=b"hello", - request_timeout=30.0, - ) - assert len(httpx2_requests) == 1 - assert isinstance(response, Response) - assert response.status_code == HTTPStatus.OK - - with pytest.raises( - expected_exception=RuntimeError, - match="client has been closed", - ): - _ = transport( - method="POST", - url=_HTTPX2_URL, - headers={"Content-Type": "text/plain"}, - data=b"hello", - request_timeout=30.0, - ) - - @staticmethod - def test_close(httpx2_requests: list[httpx2.Request]) -> None: - """Closing the transport closes the client.""" - transport = HTTPX2Transport() - transport.close() - with pytest.raises( - expected_exception=RuntimeError, - match="client has been closed", - ): - _ = transport( - method="POST", - url=_HTTPX2_URL, - headers={"Content-Type": "text/plain"}, - data=b"hello", - request_timeout=30.0, - ) - assert not bool(httpx2_requests) - - @staticmethod - def test_httpx2_exceptions(httpx2_requests: list[httpx2.Request]) -> None: - """Errors are raised as ``httpx2`` exceptions, which are not - ``httpx`` exceptions. - """ - transport = HTTPX2Transport() - with pytest.raises(expected_exception=httpx2.ConnectError) as exc: - _ = transport( - method="GET", - url=_HTTPX2_REFUSED_URL, - headers={}, - data=b"", - request_timeout=30.0, - ) - assert len(httpx2_requests) == 1 - assert not isinstance(exc.value, httpx.HTTPError) - - -class TestAsyncHTTPX2Transport: - """Tests for ``AsyncHTTPX2Transport``.""" - - @staticmethod - @pytest.mark.asyncio - async def test_float_timeout( - httpx2_requests: list[httpx2.Request], - ) -> None: - """``AsyncHTTPX2Transport`` works with a float timeout. - - A float sets both the connect and read timeouts, and the response - is converted in full. - """ - transport = AsyncHTTPX2Transport() - response = await transport( - method="POST", - url=_HTTPX2_URL, - headers={"Content-Type": "text/plain"}, - data=b"hello", - request_timeout=30.0, - ) - (request,) = httpx2_requests - assert request.extensions["timeout"] == { - "connect": 30.0, - "read": 30.0, - "write": None, - "pool": None, - } - assert request.headers["Content-Type"] == "text/plain" - assert isinstance(response, Response) - assert response.status_code == HTTPStatus.OK - assert response.text == "OK" - assert response.url == _HTTPX2_URL - assert response.headers["x-example"] == "example" - assert response.request_body == b"hello" - assert response.content == b"OK" - assert response.tell_position == len(b"OK") - - @staticmethod - @pytest.mark.asyncio - async def test_tuple_timeout( - httpx2_requests: list[httpx2.Request], - ) -> None: - """``AsyncHTTPX2Transport`` works with a (connect, read) - timeout tuple. - """ - transport = AsyncHTTPX2Transport() - response = await transport( - method="POST", - url=_HTTPX2_URL, - headers={"Content-Type": "text/plain"}, - data=b"hello", - request_timeout=(5.0, 30.0), - ) - (request,) = httpx2_requests - assert request.extensions["timeout"] == { - "connect": 5.0, - "read": 30.0, - "write": None, - "pool": None, - } - assert isinstance(response, Response) - assert response.status_code == HTTPStatus.OK - - @staticmethod - @pytest.mark.asyncio - async def test_int_timeout( - httpx2_requests: list[httpx2.Request], - ) -> None: - """``AsyncHTTPX2Transport`` works with an int timeout.""" - transport = AsyncHTTPX2Transport() - response = await transport( - method="POST", - url=_HTTPX2_URL, - headers={"Content-Type": "text/plain"}, - data=b"hello", - request_timeout=30, - ) - (request,) = httpx2_requests - assert request.extensions["timeout"] == { - "connect": 30, - "read": 30, - "write": None, - "pool": None, - } - assert isinstance(response, Response) - assert response.status_code == HTTPStatus.OK - - @staticmethod - @pytest.mark.asyncio - async def test_empty_body( - httpx2_requests: list[httpx2.Request], - ) -> None: - """An empty request body is reported as ``None``, as it is for - the ``requests`` and ``httpx`` transports. - """ - transport = AsyncHTTPX2Transport() - response = await transport( - method="GET", - url=_HTTPX2_URL, - headers={}, - data=b"", - request_timeout=30.0, - ) - assert len(httpx2_requests) == 1 - assert response.request_body is None - - @staticmethod - @pytest.mark.asyncio - async def test_context_manager( - httpx2_requests: list[httpx2.Request], - ) -> None: - """``AsyncHTTPX2Transport`` can be used as an async context - manager, and leaving the context closes the client. - """ - async with AsyncHTTPX2Transport() as transport: - response = await transport( - method="POST", - url=_HTTPX2_URL, - headers={"Content-Type": "text/plain"}, - data=b"hello", - request_timeout=30.0, - ) - assert len(httpx2_requests) == 1 - assert isinstance(response, Response) - assert response.status_code == HTTPStatus.OK - - with pytest.raises( - expected_exception=RuntimeError, - match="client has been closed", - ): - await transport( - method="POST", - url=_HTTPX2_URL, - headers={"Content-Type": "text/plain"}, - data=b"hello", - request_timeout=30.0, - ) - - @staticmethod - @pytest.mark.asyncio - async def test_aclose(httpx2_requests: list[httpx2.Request]) -> None: - """Closing the transport closes the client.""" - transport = AsyncHTTPX2Transport() - await transport.aclose() - with pytest.raises( - expected_exception=RuntimeError, - match="client has been closed", - ): - await transport( - method="POST", - url=_HTTPX2_URL, - headers={"Content-Type": "text/plain"}, - data=b"hello", - request_timeout=30.0, - ) - assert not bool(httpx2_requests) - - @staticmethod - @pytest.mark.asyncio - async def test_httpx2_exceptions( - httpx2_requests: list[httpx2.Request], - ) -> None: - """Errors are raised as ``httpx2`` exceptions, which are not - ``httpx`` exceptions. - """ - transport = AsyncHTTPX2Transport() - with pytest.raises(expected_exception=httpx2.ConnectError) as exc: - await transport( - method="GET", - url=_HTTPX2_REFUSED_URL, - headers={}, - data=b"", - request_timeout=30.0, - ) - assert len(httpx2_requests) == 1 - assert not isinstance(exc.value, httpx.HTTPError) - - -class TestHTTPX2TransportWithMock: - """Tests for synchronous clients using ``HTTPX2Transport`` against - the mock. - """ - - @staticmethod - def test_vws_and_cloud_reco(high_quality_image: io.BytesIO) -> None: - """A target can be added with ``VWS`` and found with - ``CloudRecoService``. - """ - database = CloudDatabase() - with ( - MockVWS(processing_time_seconds=0.2) as mock, - HTTPX2Transport() as transport, - ): - mock.add_cloud_database(cloud_database=database) - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - transport=transport, - ) - cloud_reco_client = CloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=database.client_secret_key, - transport=transport, - ) - target_id = vws_client.add_target( - name="example", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - target_record = vws_client.get_target_record(target_id=target_id) - assert target_record.status == TargetStatuses.SUCCESS - - (match,) = cloud_reco_client.query(image=high_quality_image) - assert match.target_id == target_id - - @staticmethod - def test_vumark() -> None: - """A VuMark instance can be generated with ``VuMarkService``.""" - vumark_target = VuMarkTarget(name="vumark-template") - database = VuMarkDatabase(vumark_targets={vumark_target}) - with MockVWS() as mock, HTTPX2Transport() as transport: - mock.add_vumark_database(vumark_database=database) - vumark_client = VuMarkService( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - transport=transport, - ) - vumark_bytes = vumark_client.generate_vumark_instance( - target_id=vumark_target.target_id, - instance_id="instance", - accept=VuMarkAccept.PNG, - ) - assert vumark_bytes.startswith(b"\x89PNG") - - @staticmethod - def test_model_targets(model_target_model: ModelTargetModel) -> None: - """A Model Target dataset can be generated with - ``ModelTargetService``. - """ - with ( - MockVWS(processing_time_seconds=0.2), - HTTPX2Transport() as transport, - ): - model_target_client = ModelTargetService( - client_id=_MODEL_TARGET_CLIENT_ID, - client_secret=_MODEL_TARGET_CLIENT_CREDENTIALS[1], - transport=transport, - ) - dataset_uuid = model_target_client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model], - dataset_type=ModelTargetDatasetType.STANDARD, - ) - report = model_target_client.wait_for_dataset_generated( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.STANDARD, - ) - assert report.status == ModelTargetDatasetStatuses.DONE - model_target_client.delete_dataset( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - @staticmethod - @pytest.mark.parametrize( - argnames="custom_timeout", - argvalues=[0.1, (5.0, 0.1)], - ids=["float", "tuple"], - ) - def test_timeout(custom_timeout: float | tuple[float, float]) -> None: - """A response which takes longer than the read timeout raises an - ``httpx2`` timeout. - """ - database = CloudDatabase() - sleeps: list[float] = [] - with MockVWS( - response_delay_seconds=0.11, - sleep_fn=sleeps.append, - ) as mock: - mock.add_cloud_database(cloud_database=database) - with HTTPX2Transport() as transport: - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - request_timeout_seconds=custom_timeout, - transport=transport, - ) - with pytest.raises(expected_exception=httpx2.ReadTimeout): - _ = vws_client.list_targets() - # The mock sleeps for the read timeout before raising. - assert sleeps == [0.1] - - -async def _add_and_query_target( - *, - database: CloudDatabase, - transport: AsyncHTTPX2Transport, - image: io.BytesIO, -) -> None: - """Add a target with ``AsyncVWS`` and find it with - ``AsyncCloudRecoService``. - - Args: - database: The mock database to add the target to. - transport: The transport for the clients to use. - image: The image to add as a target and then query with. - """ - vws_client = AsyncVWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - transport=transport, - ) - cloud_reco_client = AsyncCloudRecoService( - client_access_key=database.client_access_key, - client_secret_key=database.client_secret_key, - transport=transport, - ) - target_id = await vws_client.add_target( - name="example", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - await vws_client.wait_for_target_processed(target_id=target_id) - target_record = await vws_client.get_target_record(target_id=target_id) - assert target_record.status == TargetStatuses.SUCCESS - - (match,) = await cloud_reco_client.query(image=image) - assert match.target_id == target_id - - -async def _generate_dataset( - *, - transport: AsyncHTTPX2Transport, - model_target_model: ModelTargetModel, -) -> None: - """Generate, wait for and delete a Model Target dataset with - ``AsyncModelTargetService``. - - Args: - transport: The transport for the client to use. - model_target_model: The model to generate a dataset from. - """ - model_target_client = AsyncModelTargetService( - client_id=_MODEL_TARGET_CLIENT_ID, - client_secret=_MODEL_TARGET_CLIENT_CREDENTIALS[1], - transport=transport, - ) - dataset_uuid = await model_target_client.create_dataset( - name="dataset", - target_sdk="11.0", - models=[model_target_model], - dataset_type=ModelTargetDatasetType.STANDARD, - ) - report = await model_target_client.wait_for_dataset_generated( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.STANDARD, - ) - assert report.status == ModelTargetDatasetStatuses.DONE - await model_target_client.delete_dataset( - dataset_uuid=dataset_uuid, - dataset_type=ModelTargetDatasetType.STANDARD, - ) - - -class TestAsyncHTTPX2TransportWithMock: - """Tests for asynchronous clients using ``AsyncHTTPX2Transport`` - against the mock. - """ - - @staticmethod - @pytest.mark.asyncio - async def test_vws_and_cloud_reco(high_quality_image: io.BytesIO) -> None: - """A target can be added with ``AsyncVWS`` and found with - ``AsyncCloudRecoService``. - """ - database = CloudDatabase() - with MockVWS(processing_time_seconds=0.2) as mock: - mock.add_cloud_database(cloud_database=database) - async with AsyncHTTPX2Transport() as transport: - await _add_and_query_target( - database=database, - transport=transport, - image=high_quality_image, - ) - - @staticmethod - @pytest.mark.asyncio - async def test_vumark() -> None: - """A VuMark instance can be generated with - ``AsyncVuMarkService``. - """ - vumark_target = VuMarkTarget(name="vumark-template") - database = VuMarkDatabase(vumark_targets={vumark_target}) - with MockVWS() as mock: - mock.add_vumark_database(vumark_database=database) - async with AsyncHTTPX2Transport() as transport: - vumark_client = AsyncVuMarkService( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - transport=transport, - ) - vumark_bytes = await vumark_client.generate_vumark_instance( - target_id=vumark_target.target_id, - instance_id="instance", - accept=VuMarkAccept.PNG, - ) - assert vumark_bytes.startswith(b"\x89PNG") - - @staticmethod - @pytest.mark.asyncio - async def test_model_targets(model_target_model: ModelTargetModel) -> None: - """A Model Target dataset can be generated with - ``AsyncModelTargetService``. - """ - with MockVWS(processing_time_seconds=0.2): - async with AsyncHTTPX2Transport() as transport: - await _generate_dataset( - transport=transport, - model_target_model=model_target_model, - ) - - @staticmethod - @pytest.mark.asyncio - @pytest.mark.parametrize( - argnames="custom_timeout", - argvalues=[0.1, (5.0, 0.1)], - ids=["float", "tuple"], - ) - async def test_timeout( - custom_timeout: float | tuple[float, float], - ) -> None: - """A response which takes longer than the read timeout raises an - ``httpx2`` timeout. - """ - database = CloudDatabase() - sleeps: list[float] = [] - with MockVWS( - response_delay_seconds=0.11, - sleep_fn=sleeps.append, - ) as mock: - mock.add_cloud_database(cloud_database=database) - async with AsyncHTTPX2Transport() as transport: - vws_client = AsyncVWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - request_timeout_seconds=custom_timeout, - transport=transport, - ) - with pytest.raises(expected_exception=httpx2.ReadTimeout): - await vws_client.list_targets() - # The mock sleeps for the read timeout before raising. - assert sleeps == [0.1] diff --git a/tests/test_vws.py b/tests/test_vws.py deleted file mode 100644 index 06a17cd90..000000000 --- a/tests/test_vws.py +++ /dev/null @@ -1,1124 +0,0 @@ -"""Tests for helper functions for managing a Vuforia database.""" - -from __future__ import annotations - -import base64 -import calendar -import datetime -import json -import secrets -import time -import uuid -from http import HTTPStatus -from typing import TYPE_CHECKING, BinaryIO - -import pytest -import requests -from freezegun import freeze_time -from mock_vws import MockVWS -from mock_vws.database import CloudDatabase - -from vws import VWS, CloudRecoService, VuMarkService -from vws.exceptions.custom_exceptions import ( - DatabaseIdNotSetError, - RecoCountsReportDownloadError, - RecoCountsReportNotReadyError, - RecoCountsReportTimeoutError, - TargetProcessingTimeoutError, -) -from vws.exceptions.vws_exceptions import ( - AuthenticationFailureError, - FailError, -) -from vws.reports import ( - DatabaseSummaryReport, - RecoCount, - RecoCountsReport, - TargetRecord, - TargetStatuses, - TargetSummaryReport, -) -from vws.response import Response -from vws.vumark_accept import VuMarkAccept - -if TYPE_CHECKING: - import io - - -class _JSONResponseTransport: - """A transport which returns one JSON response body.""" - - def __init__(self, *, body: object) -> None: - """Create a transport for the given JSON body.""" - self._text = json.dumps(obj=body) - - def close(self) -> None: - """Close the transport.""" - - def __call__( - self, - *, - method: str, - url: str, - headers: dict[str, str], - data: bytes, - request_timeout: float | tuple[float, float], - ) -> Response: - """Return the configured response body.""" - del method, headers, data, request_timeout - content = self._text.encode() - return Response( - text=self._text, - url=url, - status_code=HTTPStatus.OK, - headers={"Content-Type": "application/json"}, - request_body=None, - tell_position=len(content), - content=content, - ) - - -class TestAddTarget: - """Tests for adding a target.""" - - @staticmethod - @pytest.mark.parametrize( - argnames="application_metadata", - argvalues=[None, b"a"], - ) - @pytest.mark.parametrize(argnames="active_flag", argvalues=[True, False]) - def test_add_target( - *, - vws_client: VWS, - image: io.BytesIO | BinaryIO, - application_metadata: bytes | None, - cloud_reco_client: CloudRecoService, - active_flag: bool, - ) -> None: - """No exception is raised when adding one target.""" - name = "x" - width = 1 - if application_metadata is None: - encoded_metadata = None - else: - encoded_metadata_bytes = base64.b64encode(s=application_metadata) - encoded_metadata = encoded_metadata_bytes.decode(encoding="utf-8") - - target_id = vws_client.add_target( - name=name, - width=width, - image=image, - application_metadata=encoded_metadata, - active_flag=active_flag, - ) - target_record = vws_client.get_target_record( - target_id=target_id, - ).target_record - assert target_record.name == name - assert target_record.width == width - assert target_record.active_flag is active_flag - vws_client.wait_for_target_processed(target_id=target_id) - matching_targets = cloud_reco_client.query(image=image) - if active_flag: - [matching_target] = matching_targets - assert matching_target.target_id == target_id - assert matching_target.target_data is not None - query_metadata = matching_target.target_data.application_metadata - assert query_metadata == encoded_metadata - else: - assert matching_targets == [] - - @staticmethod - def test_add_two_targets( - *, - vws_client: VWS, - image: io.BytesIO | BinaryIO, - ) -> None: - """No exception is raised when adding two targets with different - names. - - This demonstrates that the image seek position is not changed. - """ - for name in ("a", "b"): - _ = vws_client.add_target( - name=name, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - -class TestDefaultRequestTimeout: - """Tests for the default request timeout.""" - - @staticmethod - @pytest.mark.parametrize( - argnames=("response_delay_seconds", "expect_timeout"), - argvalues=[(29, False), (31, True)], - ) - def test_default_timeout( - *, - image: io.BytesIO | BinaryIO, - response_delay_seconds: int, - expect_timeout: bool, - ) -> None: - """At 29 seconds there is no error; at 31 seconds there is a - timeout. - """ - with ( - freeze_time() as frozen_datetime, - MockVWS( - response_delay_seconds=response_delay_seconds, - sleep_fn=lambda seconds: ( - frozen_datetime.tick( - delta=datetime.timedelta(seconds=seconds), - ), - None, - )[1], - ) as mock, - ): - database = CloudDatabase() - mock.add_cloud_database(cloud_database=database) - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - ) - - if expect_timeout: - with pytest.raises( - expected_exception=requests.exceptions.Timeout, - ): - _ = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - else: - _ = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - -class TestCustomRequestTimeout: - """Tests for custom request timeout values.""" - - @staticmethod - @pytest.mark.parametrize( - argnames=( - "custom_timeout", - "response_delay_seconds", - "expect_timeout", - ), - argvalues=[ - (0.1, 0.09, False), - (0.1, 0.11, True), - ((5.0, 0.1), 0.09, False), - ((5.0, 0.1), 0.11, True), - ], - ) - def test_custom_timeout( - *, - image: io.BytesIO | BinaryIO, - custom_timeout: float | tuple[float, float], - response_delay_seconds: float, - expect_timeout: bool, - ) -> None: - """Custom timeouts are honored for both float and tuple forms.""" - with ( - freeze_time() as frozen_datetime, - MockVWS( - response_delay_seconds=response_delay_seconds, - sleep_fn=lambda seconds: ( - frozen_datetime.tick( - delta=datetime.timedelta(seconds=seconds), - ), - None, - )[1], - ) as mock, - ): - database = CloudDatabase() - mock.add_cloud_database(cloud_database=database) - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - request_timeout_seconds=custom_timeout, - ) - - if expect_timeout: - with pytest.raises( - expected_exception=requests.exceptions.Timeout, - ): - _ = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - else: - _ = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - -class TestCustomBaseVWSURL: - """Tests for using a custom base VWS URL.""" - - @staticmethod - def test_custom_base_url(image: io.BytesIO | BinaryIO) -> None: - """ - It is possible to use add a target to a database under a custom - VWS - URL. - """ - base_vws_url = "http://example.com" - with MockVWS(base_vws_url=base_vws_url) as mock: - database = CloudDatabase() - mock.add_cloud_database(cloud_database=database) - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - base_vws_url=base_vws_url, - ) - - _ = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - @staticmethod - def test_custom_base_url_with_path_prefix() -> None: - """ - A base VWS URL with a path prefix is used as-is, without the - prefix being dropped. - """ - base_vws_url = "http://example.com/prefix" - with MockVWS(base_vws_url=base_vws_url) as mock: - database = CloudDatabase() - mock.add_cloud_database(cloud_database=database) - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - base_vws_url=base_vws_url, - ) - - assert not bool(vws_client.list_targets()) - - -class TestListTargets: - """Tests for listing targets.""" - - @staticmethod - def test_list_targets( - *, - vws_client: VWS, - image: io.BytesIO | BinaryIO, - ) -> None: - """It is possible to get a list of target IDs.""" - id_1 = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - id_2 = vws_client.add_target( - name="a", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - assert sorted(vws_client.list_targets()) == sorted([id_1, id_2]) - - -class TestDelete: - """Test for deleting a target.""" - - @staticmethod - def test_delete_target( - *, - vws_client: VWS, - image: io.BytesIO | BinaryIO, - ) -> None: - """It is possible to delete a target.""" - target_id = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - assert vws_client.list_targets() == [target_id] - vws_client.delete_target(target_id=target_id) - assert vws_client.list_targets() == [] - - -class TestGetTargetSummaryReport: - """Tests for getting a summary report for a target.""" - - @staticmethod - def test_get_target_summary_report( - *, - vws_client: VWS, - image: io.BytesIO | BinaryIO, - ) -> None: - """Details of a target are returned by - ``get_target_summary_report``. - """ - date = "2018-04-25" - target_name = uuid.uuid4().hex - with freeze_time(time_to_freeze=date): - target_id = vws_client.add_target( - name=target_name, - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - report = vws_client.get_target_summary_report(target_id=target_id) - - expected_report = TargetSummaryReport( - status=TargetStatuses.SUCCESS, - database_name=report.database_name, - target_name=target_name, - upload_date=datetime.date(year=2018, month=4, day=25), - active_flag=True, - tracking_rating=report.tracking_rating, - total_recos=0, - current_month_recos=0, - previous_month_recos=0, - ) - - assert report.status == expected_report.status - assert report.database_name == expected_report.database_name - assert report.target_name == expected_report.target_name - assert report.upload_date == expected_report.upload_date - assert report.active_flag == expected_report.active_flag - assert report.tracking_rating == expected_report.tracking_rating - assert report.total_recos == expected_report.total_recos - assert ( - report.current_month_recos == expected_report.current_month_recos - ) - assert ( - report.previous_month_recos == expected_report.previous_month_recos - ) - - assert report == expected_report - - -class TestGetDatabaseSummaryReport: - """Tests for getting a summary report for a database.""" - - @staticmethod - def test_get_target(vws_client: VWS) -> None: - """Details of a database are returned by - ``get_database_summary_report``. - """ - report = vws_client.get_database_summary_report() - - expected_report = DatabaseSummaryReport( - active_images=0, - current_month_recos=0, - failed_images=0, - inactive_images=0, - name=report.name, - previous_month_recos=0, - processing_images=0, - reco_threshold=1000, - request_quota=100000, - request_usage=0, - target_quota=1000, - total_recos=0, - ) - - assert report.active_images == expected_report.active_images - assert ( - report.current_month_recos == expected_report.current_month_recos - ) - assert report.failed_images == expected_report.failed_images - assert report.inactive_images == expected_report.inactive_images - assert report.name == expected_report.name - assert ( - report.previous_month_recos == expected_report.previous_month_recos - ) - assert report.processing_images == expected_report.processing_images - assert report.reco_threshold == expected_report.reco_threshold - assert report.request_quota == expected_report.request_quota - assert report.request_usage == expected_report.request_usage - assert report.target_quota == expected_report.target_quota - assert report.total_recos == expected_report.total_recos - - assert report == expected_report - - -class TestGetTargetRecord: - """Tests for getting a record of a target.""" - - @staticmethod - def test_get_target_record( - *, - vws_client: VWS, - image: io.BytesIO | BinaryIO, - ) -> None: - """Details of a target are returned by ``get_target_record``.""" - target_id = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - result = vws_client.get_target_record(target_id=target_id) - expected_target_record = TargetRecord( - target_id=target_id, - active_flag=True, - name="x", - width=1, - tracking_rating=-1, - reco_rating="", - ) - - assert result.target_record == expected_target_record - - assert ( - result.target_record.target_id == expected_target_record.target_id - ) - assert ( - result.target_record.active_flag - == expected_target_record.active_flag - ) - assert result.target_record.name == expected_target_record.name - assert result.target_record.width == expected_target_record.width - assert ( - result.target_record.tracking_rating - == expected_target_record.tracking_rating - ) - assert ( - result.target_record.reco_rating - == expected_target_record.reco_rating - ) - - assert result.status == TargetStatuses.PROCESSING - - @staticmethod - def test_get_failed( - *, - vws_client: VWS, - image_file_failed_state: io.BytesIO, - ) -> None: - """Check that the report works with a failed target.""" - target_id = vws_client.add_target( - name="x", - width=1, - image=image_file_failed_state, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - result = vws_client.get_target_record(target_id=target_id) - - assert result.status == TargetStatuses.FAILED - - -class TestWaitForTargetProcessed: - """Tests for waiting for a target to be processed.""" - - @staticmethod - def test_wait_for_target_processed( - *, - vws_client: VWS, - image: io.BytesIO | BinaryIO, - ) -> None: - """It is possible to wait until a target is processed.""" - target_id = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - report = vws_client.get_target_summary_report(target_id=target_id) - assert report.status == TargetStatuses.PROCESSING - vws_client.wait_for_target_processed(target_id=target_id) - report = vws_client.get_target_summary_report(target_id=target_id) - assert report.status != TargetStatuses.PROCESSING - - @staticmethod - def test_default_seconds_between_requests( - image: io.BytesIO | BinaryIO, - ) -> None: - """By default, 0.2 seconds are waited between polling requests.""" - with MockVWS(processing_time_seconds=0.5) as mock: - database = CloudDatabase() - mock.add_cloud_database(cloud_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="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - report = vws_client.get_database_summary_report() - expected_requests = ( - # Add target request - 1 - + - # Database summary request - 1 - + - # Initial request - 1 - + - # Request after 0.2 seconds - not processed - 1 - + - # Request after 0.4 seconds - not processed - # This assumes that there is less than 0.1 seconds taken - # between the start of the target processing and the start of - # waiting for the target to be processed. - 1 - + - # Request after 0.6 seconds - processed - 1 - ) - # At the time of writing there is a bug which prevents request - # usage from being tracked so we cannot track this. - expected_requests = 0 - assert report.request_usage == expected_requests - - @staticmethod - def test_custom_seconds_between_requests( - image: io.BytesIO | BinaryIO, - ) -> None: - """It is possible to customize the time waited between polling - requests. - """ - with MockVWS(processing_time_seconds=0.5) as mock: - database = CloudDatabase() - mock.add_cloud_database(cloud_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="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed( - target_id=target_id, - seconds_between_requests=0.3, - ) - report = vws_client.get_database_summary_report() - expected_requests = ( - # Add target request - 1 - + - # Database summary request - 1 - + - # Initial request - 1 - + - # Request after 0.3 seconds - not processed - # This assumes that there is less than 0.2 seconds taken - # between the start of the target processing and the start of - # waiting for the target to be processed. - 1 - + - # Request after 0.6 seconds - processed - 1 - ) - # At the time of writing there is a bug which prevents request - # usage from being tracked so we cannot track this. - expected_requests = 0 - assert report.request_usage == expected_requests - - @staticmethod - def test_custom_timeout(image: io.BytesIO | BinaryIO) -> None: - """It is possible to set a maximum timeout.""" - with MockVWS(processing_time_seconds=0.5) as mock: - database = CloudDatabase() - mock.add_cloud_database(cloud_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="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - report = vws_client.get_target_summary_report(target_id=target_id) - assert report.status == TargetStatuses.PROCESSING - with pytest.raises( - expected_exception=TargetProcessingTimeoutError - ): - vws_client.wait_for_target_processed( - target_id=target_id, - timeout_seconds=0.1, - ) - - vws_client.wait_for_target_processed( - target_id=target_id, - timeout_seconds=0.5, - ) - report = vws_client.get_target_summary_report(target_id=target_id) - assert report.status != TargetStatuses.PROCESSING - - -class TestGetDuplicateTargets: - """Tests for getting duplicate targets.""" - - @staticmethod - def test_get_duplicate_targets( - *, - vws_client: VWS, - image: io.BytesIO | BinaryIO, - ) -> None: - """It is possible to get the IDs of similar targets.""" - target_id = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - similar_target_id = vws_client.add_target( - name="a", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.wait_for_target_processed(target_id=similar_target_id) - duplicates = vws_client.get_duplicate_targets(target_id=target_id) - assert duplicates == [similar_target_id] - - @staticmethod - @pytest.mark.parametrize( - argnames="similar_targets", - argvalues=[1, ["target-id", 1]], - ) - def test_invalid_duplicate_target_response( - *, similar_targets: object - ) -> None: - """Duplicate target IDs in responses must be a list of strings.""" - transport = _JSONResponseTransport( - body={ - "result_code": "Success", - "similar_targets": similar_targets, - } - ) - client = VWS( - server_access_key="access-key", - server_secret_key=secrets.token_hex(), - transport=transport, - ) - - with pytest.raises(expected_exception=TypeError): - _ = client.get_duplicate_targets(target_id="target-id") - - -@pytest.mark.parametrize( - argnames="body", - argvalues=[[], {"result_code": 1}], -) -def test_invalid_vws_response_envelope(*, body: object) -> None: - """VWS responses must be objects with a string result code.""" - transport = _JSONResponseTransport(body=body) - client = VWS( - server_access_key="access-key", - server_secret_key=secrets.token_hex(), - transport=transport, - ) - - with pytest.raises(expected_exception=TypeError): - client.delete_target(target_id="target-id") - - -class TestUpdateTarget: - """Tests for updating a target.""" - - @staticmethod - def test_update_target( - *, - vws_client: VWS, - image: io.BytesIO | BinaryIO, - different_high_quality_image: io.BytesIO, - cloud_reco_client: CloudRecoService, - ) -> None: - """It is possible to update a target.""" - old_name = uuid.uuid4().hex - old_width = secrets.choice(seq=range(1, 5000)) / 100 - target_id = vws_client.add_target( - name=old_name, - width=old_width, - image=image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - [matching_target] = cloud_reco_client.query(image=image) - assert matching_target.target_id == target_id - query_target_data = matching_target.target_data - assert query_target_data is not None - query_metadata = query_target_data.application_metadata - assert query_metadata is None - - new_name = uuid.uuid4().hex - new_width = secrets.choice(seq=range(1, 5000)) / 100 - new_application_metadata = base64.b64encode(s=b"a").decode( - encoding="ascii", - ) - vws_client.update_target( - target_id=target_id, - name=new_name, - width=new_width, - active_flag=True, - image=different_high_quality_image, - application_metadata=new_application_metadata, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - [ - matching_target, - ] = cloud_reco_client.query(image=different_high_quality_image) - assert matching_target.target_id == target_id - query_target_data = matching_target.target_data - assert query_target_data is not None - query_metadata = query_target_data.application_metadata - assert query_metadata == new_application_metadata - - vws_client.update_target( - target_id=target_id, - active_flag=False, - ) - - target_details = vws_client.get_target_record(target_id=target_id) - assert target_details.target_record.name == new_name - assert target_details.target_record.width == new_width - assert not target_details.target_record.active_flag - - @staticmethod - def test_no_fields_given( - *, - vws_client: VWS, - image: io.BytesIO | BinaryIO, - ) -> None: - """It is possible to give no update fields.""" - target_id = vws_client.add_target( - name="x", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.update_target(target_id=target_id) - - -class _ForbiddenDownloadTransport: - """A transport which refuses to serve a report, as an expired URL - would. - """ - - def close(self) -> None: - """Close the transport.""" - - def __call__( - self, - *, - method: str, - url: str, - headers: dict[str, str], - data: bytes, - request_timeout: float | tuple[float, float], - ) -> Response: - """Return a "forbidden" response.""" - del method, headers, request_timeout - body = "AccessDenied" - return Response( - text=body, - url=url, - status_code=HTTPStatus.FORBIDDEN, - headers={}, - request_body=data, - tell_position=0, - content=body.encode(encoding="utf-8"), - ) - - -class TestRecoCountsReport: - """Tests for database reco counts reports.""" - - @staticmethod - def test_reco_counts_report( - *, - vws_client: VWS, - report_month: datetime.date, - ) -> None: - """A report can be requested, waited for and downloaded.""" - report_request = vws_client.request_database_reco_counts_report( - year=report_month.year, - month=calendar.Month(value=report_month.month), - ) - assert bool(report_request.transaction_id) - assert bool(report_request.presigned_url) - - report = vws_client.wait_for_reco_counts_report( - presigned_url=report_request.presigned_url, - ) - - # No targets have been recognized, so the report has no rows. - assert not bool(report.reco_counts) - assert report.raw_csv.startswith(b"target_id,reco_count") - - @staticmethod - def test_not_ready(*, current_month: datetime.date) -> None: - """Downloading a report before Vuforia has generated it raises an - error. - """ - with MockVWS(processing_time_seconds=60) as mock: - database = CloudDatabase() - mock.add_cloud_database(cloud_database=database) - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - database_id=database.database_id, - ) - report_request = vws_client.request_database_reco_counts_report( - year=current_month.year, - month=calendar.Month(value=current_month.month), - ) - - with pytest.raises( - expected_exception=RecoCountsReportNotReadyError, - ) as exc: - _ = vws_client.download_reco_counts_report( - presigned_url=report_request.presigned_url, - ) - - assert exc.value.response.status_code == HTTPStatus.NOT_FOUND - - @staticmethod - def test_wait_timeout(*, current_month: datetime.date) -> None: - """Waiting for a report which is not generated in time raises an - error. - """ - with MockVWS(processing_time_seconds=60) as mock: - database = CloudDatabase() - mock.add_cloud_database(cloud_database=database) - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - database_id=database.database_id, - ) - report_request = vws_client.request_database_reco_counts_report( - year=current_month.year, - month=calendar.Month(value=current_month.month), - ) - - maximum_wait_seconds = 5 - start_time = time.monotonic() - - with pytest.raises( - expected_exception=RecoCountsReportTimeoutError, - ): - _ = vws_client.wait_for_reco_counts_report( - presigned_url=report_request.presigned_url, - seconds_between_requests=0.01, - timeout_seconds=0.05, - ) - - elapsed_time = time.monotonic() - start_time - assert elapsed_time < maximum_wait_seconds - - @staticmethod - @pytest.mark.parametrize( - argnames=("year", "month"), - argvalues=[ - pytest.param(1999, calendar.Month.JANUARY, id="year-in-the-past"), - pytest.param( - 1999, - calendar.Month.DECEMBER, - id="year-in-the-past-december", - ), - ], - ) - def test_month_not_accepted( - *, - vws_client: VWS, - year: int, - month: calendar.Month, - ) -> None: - """Months other than the current and previous month are - rejected. - """ - with pytest.raises(expected_exception=FailError) as exc: - _ = vws_client.request_database_reco_counts_report( - year=year, - month=month, - ) - - assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST - - @staticmethod - def test_database_id_does_not_match_keys( - *, - current_month: datetime.date, - ) -> None: - """A database ID which does not match the given keys is - rejected. - """ - with MockVWS() as mock: - database = CloudDatabase() - mock.add_cloud_database(cloud_database=database) - vws_client = VWS( - server_access_key=database.server_access_key, - server_secret_key=database.server_secret_key, - database_id=uuid.uuid4().hex, - ) - - with pytest.raises( - expected_exception=AuthenticationFailureError, - ) as exc: - _ = vws_client.request_database_reco_counts_report( - year=current_month.year, - month=calendar.Month(value=current_month.month), - ) - - assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED - - @staticmethod - def test_download_error() -> None: - """An error response from the report's URL raises an error.""" - vws_client = VWS( - server_access_key=uuid.uuid4().hex, - server_secret_key=uuid.uuid4().hex, - transport=_ForbiddenDownloadTransport(), - ) - - with pytest.raises( - expected_exception=RecoCountsReportDownloadError, - ) as exc: - _ = vws_client.download_reco_counts_report( - presigned_url="https://example.com/reports/recoCounts/x", - ) - - assert exc.value.response.status_code == HTTPStatus.FORBIDDEN - - @staticmethod - def test_no_database_id(*, current_month: datetime.date) -> None: - """A client which was given no database ID cannot request a - report. - """ - vws_client = VWS( - server_access_key=uuid.uuid4().hex, - server_secret_key=uuid.uuid4().hex, - ) - - with pytest.raises(expected_exception=DatabaseIdNotSetError): - _ = vws_client.request_database_reco_counts_report( - year=current_month.year, - month=calendar.Month(value=current_month.month), - ) - - -class TestRecoCountsReportParsing: - """Tests for parsing downloaded reco counts reports.""" - - @staticmethod - def test_rows() -> None: - """Each row of the CSV becomes a ``RecoCount``.""" - csv_bytes = b"target_id,reco_count\r\nabc,3\r\ndef,0\r\n" - - report = RecoCountsReport.from_csv(csv_bytes=csv_bytes) - - assert report.reco_counts == [ - RecoCount(target_id="abc", reco_count=3), - RecoCount(target_id="def", reco_count=0), - ] - assert report.raw_csv == csv_bytes - - @staticmethod - def test_unknown_columns_ignored() -> None: - """Columns which are not known are not exposed in - ``reco_counts``. - """ - expected_reco_count = 3 - header = "target_id,reco_count,new_column\r\n" - csv_text = f"{header}abc,{expected_reco_count},x\r\n" - - report = RecoCountsReport.from_csv( - csv_bytes=csv_text.encode(encoding="utf-8"), - ) - - (item,) = report.reco_counts - assert item.target_id == "abc" - assert item.reco_count == expected_reco_count - - -class TestGenerateVumarkInstance: - """Tests for generating VuMark instances.""" - - @staticmethod - @pytest.mark.parametrize( - argnames=("accept", "expected_prefix"), - argvalues=[ - pytest.param(VuMarkAccept.PNG, b"\x89PNG\r\n\x1a\n", id="png"), - pytest.param(VuMarkAccept.SVG, b"<", id="svg"), - pytest.param(VuMarkAccept.PDF, b"%PDF", id="pdf"), - ], - ) - def test_generate_vumark_instance( - *, - vumark_service_client: VuMarkService, - vumark_target_id: str, - accept: VuMarkAccept, - expected_prefix: bytes, - ) -> None: - """The returned bytes match the requested format.""" - result = vumark_service_client.generate_vumark_instance( - target_id=vumark_target_id, - instance_id="12345", - accept=accept, - ) - assert result.startswith(expected_prefix) diff --git a/tests/transports/__init__.py b/tests/transports/__init__.py new file mode 100644 index 000000000..a4b58aceb --- /dev/null +++ b/tests/transports/__init__.py @@ -0,0 +1 @@ +"""Transports tests.""" diff --git a/tests/transports/test_httpx.py b/tests/transports/test_httpx.py new file mode 100644 index 000000000..8088daca0 --- /dev/null +++ b/tests/transports/test_httpx.py @@ -0,0 +1,523 @@ +"""Tests for `httpx` transports.""" + +from __future__ import annotations + +import uuid +from http import HTTPStatus +from typing import TYPE_CHECKING + +import httpx +import pytest +import respx + +from vws import ( + VWS, + AsyncCloudRecoService, + AsyncVuMarkService, + AsyncVWS, + CloudRecoService, + VuMarkService, +) +from vws.exceptions.custom_exceptions import ServerError +from vws.exceptions.vws_exceptions import TooManyRequestsError +from vws.response import Response +from vws.transports import ( + AsyncHTTPXTransport, + HTTPXTransport, +) +from vws.vumark_accept import VuMarkAccept + +if TYPE_CHECKING: + import io + + +class _FalsyTransport: + """A sync transport that is falsy but protocol-conforming.""" + + def __bool__(self) -> bool: + """Return ``False`` so truthiness checks would skip this + transport. + """ + return False + + def close(self) -> None: + """Close the transport.""" + + def __call__( + self, + *, + method: str, + url: str, + headers: dict[str, str], + data: bytes, + request_timeout: float | tuple[float, float], + ) -> Response: + """Return a successful API response for the requested URL.""" + del method, headers, request_timeout + if url.endswith("/query"): + body = '{"result_code":"Success","results":[]}' + return Response( + text=body, + url=url, + status_code=HTTPStatus.OK, + headers={}, + request_body=data, + tell_position=0, + content=body.encode(), + ) + if "/instances" in url: + content = b"vumark-bytes" + return Response( + text="", + url=url, + status_code=HTTPStatus.OK, + headers={}, + request_body=data, + tell_position=0, + content=content, + ) + body = '{"result_code":"Success","results":[]}' + return Response( + text=body, + url=url, + status_code=HTTPStatus.OK, + headers={}, + request_body=data, + tell_position=0, + content=body.encode(), + ) + + +class _FalsyAsyncTransport: + """An async transport that is falsy but protocol-conforming.""" + + def __bool__(self) -> bool: + """Return ``False`` so truthiness checks would skip this + transport. + """ + return False + + async def aclose(self) -> None: + """Close the transport.""" + + async def __call__( + self, + *, + method: str, + url: str, + headers: dict[str, str], + data: bytes, + request_timeout: float | tuple[float, float], + ) -> Response: + """Return a successful API response for the requested URL.""" + del method, headers, request_timeout + if url.endswith("/query"): + body = '{"result_code":"Success","results":[]}' + return Response( + text=body, + url=url, + status_code=HTTPStatus.OK, + headers={}, + request_body=data, + tell_position=0, + content=body.encode(), + ) + if "/instances" in url: + content = b"vumark-bytes" + return Response( + text="", + url=url, + status_code=HTTPStatus.OK, + headers={}, + request_body=data, + tell_position=0, + content=content, + ) + body = '{"result_code":"Success","results":[]}' + return Response( + text=body, + url=url, + status_code=HTTPStatus.OK, + headers={}, + request_body=data, + tell_position=0, + content=body.encode(), + ) + + +@respx.mock +def test_httpx_transport_float_timeout() -> None: + """``HTTPXTransport`` works with a float timeout.""" + route = respx.post(url="https://example.com/test").mock( + return_value=httpx.Response( + status_code=HTTPStatus.OK, + text="OK", + ), + ) + transport = HTTPXTransport() + response = transport( + method="POST", + url="https://example.com/test", + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30.0, + ) + assert route.called + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + assert response.text == "OK" + assert response.tell_position == len(b"OK") + + +@respx.mock +def test_httpx_transport_tuple_timeout() -> None: + """``HTTPXTransport`` works with a (connect, read) timeout + tuple. + """ + route = respx.post(url="https://example.com/test").mock( + return_value=httpx.Response( + status_code=HTTPStatus.OK, + text="OK", + ), + ) + transport = HTTPXTransport() + response = transport( + method="POST", + url="https://example.com/test", + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=(5.0, 30.0), + ) + assert route.called + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + + +@respx.mock +def test_httpx_transport_int_timeout() -> None: + """``HTTPXTransport`` works with an int timeout.""" + route = respx.post(url="https://example.com/test").mock( + return_value=httpx.Response( + status_code=HTTPStatus.OK, + text="OK", + ), + ) + transport = HTTPXTransport() + response = transport( + method="POST", + url="https://example.com/test", + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30, + ) + assert route.called + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + + +@respx.mock +def test_httpx_transport_context_manager() -> None: + """``HTTPXTransport`` can be used as a context manager.""" + route = respx.post(url="https://example.com/test").mock( + return_value=httpx.Response( + status_code=HTTPStatus.OK, + text="OK", + ), + ) + with HTTPXTransport() as transport: + response = transport( + method="POST", + url="https://example.com/test", + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30.0, + ) + assert route.called + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + + +@pytest.mark.asyncio +@respx.mock +async def test_async_httpx_transport_float_timeout() -> None: + """``AsyncHTTPXTransport`` works with a float timeout.""" + route = respx.post(url="https://example.com/test").mock( + return_value=httpx.Response( + status_code=HTTPStatus.OK, + text="OK", + ), + ) + transport = AsyncHTTPXTransport() + response = await transport( + method="POST", + url="https://example.com/test", + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30.0, + ) + assert route.called + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + assert response.text == "OK" + assert response.tell_position == len(b"OK") + + +@pytest.mark.asyncio +@respx.mock +async def test_async_httpx_transport_tuple_timeout() -> None: + """``AsyncHTTPXTransport`` works with a (connect, read) + timeout tuple. + """ + route = respx.post(url="https://example.com/test").mock( + return_value=httpx.Response( + status_code=HTTPStatus.OK, + text="OK", + ), + ) + transport = AsyncHTTPXTransport() + response = await transport( + method="POST", + url="https://example.com/test", + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=(5.0, 30.0), + ) + assert route.called + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + + +@pytest.mark.asyncio +@respx.mock +async def test_async_httpx_transport_int_timeout() -> None: + """``AsyncHTTPXTransport`` works with an int timeout.""" + route = respx.post(url="https://example.com/test").mock( + return_value=httpx.Response( + status_code=HTTPStatus.OK, + text="OK", + ), + ) + transport = AsyncHTTPXTransport() + response = await transport( + method="POST", + url="https://example.com/test", + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30, + ) + assert route.called + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + + +@pytest.mark.asyncio +@respx.mock +async def test_async_httpx_transport_context_manager() -> None: + """``AsyncHTTPXTransport`` can be used as an async context + manager. + """ + route = respx.post(url="https://example.com/test").mock( + return_value=httpx.Response( + status_code=HTTPStatus.OK, + text="OK", + ), + ) + async with AsyncHTTPXTransport() as transport: + response = await transport( + method="POST", + url="https://example.com/test", + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30.0, + ) + assert route.called + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + + +def test_falsy_sync_transport_is_retained( + high_quality_image: io.BytesIO, +) -> None: + """Falsy custom sync transports are not replaced by the default.""" + access_key = uuid.uuid4().hex + secret_key = uuid.uuid4().hex + transport = _FalsyTransport() + assert not bool(transport) + + targets = VWS( + server_access_key=access_key, + server_secret_key=secret_key, + transport=transport, + ).list_targets() + assert not bool(targets) + + query_results = CloudRecoService( + client_access_key=access_key, + client_secret_key=secret_key, + transport=transport, + ).query(image=high_quality_image) + assert not bool(query_results) + + vumark_bytes = VuMarkService( + server_access_key=access_key, + server_secret_key=secret_key, + transport=transport, + ).generate_vumark_instance( + target_id="target", + instance_id="instance", + accept=VuMarkAccept.PNG, + ) + assert vumark_bytes == b"vumark-bytes" + + +@pytest.mark.asyncio +async def test_falsy_async_transport_is_retained( + high_quality_image: io.BytesIO, +) -> None: + """Falsy custom async transports are not replaced by the default.""" + access_key = uuid.uuid4().hex + secret_key = uuid.uuid4().hex + transport = _FalsyAsyncTransport() + assert not bool(transport) + + async with AsyncVWS( + server_access_key=access_key, + server_secret_key=secret_key, + transport=transport, + ) as vws_client: + assert not bool(await vws_client.list_targets()) + + async with AsyncCloudRecoService( + client_access_key=access_key, + client_secret_key=secret_key, + transport=transport, + ) as cloud_reco_client: + assert not bool( + await cloud_reco_client.query(image=high_quality_image) + ) + + async with AsyncVuMarkService( + server_access_key=access_key, + server_secret_key=secret_key, + transport=transport, + ) as vumark_client: + assert ( + await vumark_client.generate_vumark_instance( + target_id="target", + instance_id="instance", + accept=VuMarkAccept.PNG, + ) + == b"vumark-bytes" + ) + + +@pytest.mark.parametrize( + argnames=("status_code", "exception_type"), + argvalues=[ + (HTTPStatus.TOO_MANY_REQUESTS, TooManyRequestsError), + (HTTPStatus.INTERNAL_SERVER_ERROR, ServerError), + ], +) +@respx.mock +def test_vumark_service_handles_bodyless_http_errors( + *, + status_code: HTTPStatus, + exception_type: type[ServerError | TooManyRequestsError], +) -> None: + """VuMark HTTP errors which have no JSON body remain meaningful.""" + route = respx.post( + url="https://example.com/targets/target/instances" + ).mock(return_value=httpx.Response(status_code=status_code)) + + with HTTPXTransport() as transport: + service = VuMarkService( + server_access_key="access-key", + server_secret_key=uuid.uuid4().hex, + base_vws_url="https://example.com", + transport=transport, + ) + with pytest.raises(expected_exception=exception_type): + _ = service.generate_vumark_instance( + target_id="target", + instance_id="instance", + accept=VuMarkAccept.PNG, + ) + + assert route.called + + +@respx.mock +def test_vws_handles_bodyless_rate_limit_response() -> None: + """A body-less target-manager rate limit raises its specific error.""" + route = respx.get(url="https://example.com/targets").mock( + return_value=httpx.Response(status_code=HTTPStatus.TOO_MANY_REQUESTS) + ) + + with HTTPXTransport() as transport: + service = VWS( + server_access_key="access-key", + server_secret_key=uuid.uuid4().hex, + base_vws_url="https://example.com", + transport=transport, + ) + with pytest.raises(expected_exception=TooManyRequestsError): + _ = service.list_targets() + + assert route.called + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + argnames=("status_code", "exception_type"), + argvalues=[ + (HTTPStatus.TOO_MANY_REQUESTS, TooManyRequestsError), + (HTTPStatus.INTERNAL_SERVER_ERROR, ServerError), + ], +) +@respx.mock +async def test_async_vumark_service_handles_bodyless_http_errors( + *, + status_code: HTTPStatus, + exception_type: type[ServerError | TooManyRequestsError], +) -> None: + """Async VuMark HTTP errors without JSON remain meaningful.""" + route = respx.post( + url="https://example.com/targets/target/instances" + ).mock(return_value=httpx.Response(status_code=status_code)) + + async with AsyncHTTPXTransport() as transport: + service = AsyncVuMarkService( + server_access_key="access-key", + server_secret_key=uuid.uuid4().hex, + base_vws_url="https://example.com", + transport=transport, + ) + with pytest.raises(expected_exception=exception_type): + _ = await service.generate_vumark_instance( + target_id="target", + instance_id="instance", + accept=VuMarkAccept.PNG, + ) + + assert route.called + + +@pytest.mark.asyncio +@respx.mock +async def test_async_vws_handles_bodyless_rate_limit_response() -> None: + """An async target-manager rate limit raises its specific error.""" + route = respx.get(url="https://example.com/targets").mock( + return_value=httpx.Response(status_code=HTTPStatus.TOO_MANY_REQUESTS) + ) + + async with AsyncHTTPXTransport() as transport: + service = AsyncVWS( + server_access_key="access-key", + server_secret_key=uuid.uuid4().hex, + base_vws_url="https://example.com", + transport=transport, + ) + with pytest.raises(expected_exception=TooManyRequestsError): + _ = await service.list_targets() + + assert route.called diff --git a/tests/transports/test_httpx2.py b/tests/transports/test_httpx2.py new file mode 100644 index 000000000..57ebde9ac --- /dev/null +++ b/tests/transports/test_httpx2.py @@ -0,0 +1,728 @@ +"""Tests for `httpx2` transports.""" + +from __future__ import annotations + +from http import HTTPStatus +from typing import TYPE_CHECKING + +import httpx +import httpx2 +import pytest +from mock_vws import MockVWS +from mock_vws.database import CloudDatabase, VuMarkDatabase +from mock_vws.target import VuMarkTarget + +from vws import ( + VWS, + AsyncCloudRecoService, + AsyncModelTargetService, + AsyncVuMarkService, + AsyncVWS, + CloudRecoService, + ModelTargetService, + VuMarkService, +) +from vws.model_target_datasets import ( + ModelTargetDatasetType, + ModelTargetModel, +) +from vws.reports import ModelTargetDatasetStatuses, TargetStatuses +from vws.response import Response +from vws.transports import ( + AsyncHTTPX2Transport, + HTTPX2Transport, +) +from vws.vumark_accept import VuMarkAccept + +if TYPE_CHECKING: + import io + + +# The mock accepts one hard-coded pair of Model Target Web API OAuth2 +# credentials, which it does not expose. +_MODEL_TARGET_CLIENT_ID = "client-id" +_MODEL_TARGET_CLIENT_CREDENTIALS = ("client-id", "client-secret") + +_HTTPX2_URL = "https://example.com/test" +_HTTPX2_REFUSED_URL = "https://example.com/refused" + + +@pytest.fixture(name="httpx2_requests") +def fixture_httpx2_requests( + *, + monkeypatch: pytest.MonkeyPatch, +) -> list[httpx2.Request]: + """Answer every ``httpx2`` request with a fake server, and collect the + requests which it sees. + + The fake server answers ``OK`` to every request except those for + ``_HTTPX2_REFUSED_URL``, which it refuses to connect to. + """ + requests_seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + """Answer an ``httpx2`` request. + + Args: + request: The request to answer. + + Returns: + An ``OK`` response. + + Raises: + httpx2.ConnectError: The request is for the refused URL. + """ + requests_seen.append(request) + if str(object=request.url) == _HTTPX2_REFUSED_URL: + raise httpx2.ConnectError( + message="Connection refused", + request=request, + ) + return httpx2.Response( + status_code=HTTPStatus.OK, + text="OK", + headers={"X-Example": "example"}, + ) + + mock_transport = httpx2.MockTransport(handler=handler) + + class _Client(httpx2.Client): + """A synchronous client which uses the fake server.""" + + def __init__(self) -> None: + """Create a client which uses the fake server.""" + super().__init__(transport=mock_transport) + + class _AsyncClient(httpx2.AsyncClient): + """An asynchronous client which uses the fake server.""" + + def __init__(self) -> None: + """Create a client which uses the fake server.""" + super().__init__(transport=mock_transport) + + monkeypatch.setattr(target=httpx2, name="Client", value=_Client) + monkeypatch.setattr(target=httpx2, name="AsyncClient", value=_AsyncClient) + return requests_seen + + +async def _add_and_query_target( + *, + database: CloudDatabase, + transport: AsyncHTTPX2Transport, + image: io.BytesIO, +) -> None: + """Add a target with ``AsyncVWS`` and find it with + ``AsyncCloudRecoService``. + + Args: + database: The mock database to add the target to. + transport: The transport for the clients to use. + image: The image to add as a target and then query with. + """ + vws_client = AsyncVWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + transport=transport, + ) + cloud_reco_client = AsyncCloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + transport=transport, + ) + target_id = await vws_client.add_target( + name="example", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + await vws_client.wait_for_target_processed(target_id=target_id) + target_record = await vws_client.get_target_record(target_id=target_id) + assert target_record.status == TargetStatuses.SUCCESS + + (match,) = await cloud_reco_client.query(image=image) + assert match.target_id == target_id + + +async def _generate_dataset( + *, + transport: AsyncHTTPX2Transport, + model_target_model: ModelTargetModel, +) -> None: + """Generate, wait for and delete a Model Target dataset with + ``AsyncModelTargetService``. + + Args: + transport: The transport for the client to use. + model_target_model: The model to generate a dataset from. + """ + model_target_client = AsyncModelTargetService( + client_id=_MODEL_TARGET_CLIENT_ID, + client_secret=_MODEL_TARGET_CLIENT_CREDENTIALS[1], + transport=transport, + ) + dataset_uuid = await model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + report = await model_target_client.wait_for_dataset_generated( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + assert report.status == ModelTargetDatasetStatuses.DONE + await model_target_client.delete_dataset( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + +def test_sync_float_timeout( + httpx2_requests: list[httpx2.Request], +) -> None: + """``HTTPX2Transport`` works with a float timeout. + + A float sets both the connect and read timeouts, and the response + is converted in full. + """ + transport = HTTPX2Transport() + response = transport( + method="POST", + url=_HTTPX2_URL, + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30.0, + ) + (request,) = httpx2_requests + assert request.extensions["timeout"] == { + "connect": 30.0, + "read": 30.0, + "write": None, + "pool": None, + } + assert request.headers["Content-Type"] == "text/plain" + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + assert response.text == "OK" + assert response.url == _HTTPX2_URL + assert response.headers["x-example"] == "example" + assert response.request_body == b"hello" + assert response.content == b"OK" + assert response.tell_position == len(b"OK") + + +def test_sync_tuple_timeout( + httpx2_requests: list[httpx2.Request], +) -> None: + """``HTTPX2Transport`` works with a (connect, read) timeout + tuple. + """ + transport = HTTPX2Transport() + response = transport( + method="POST", + url=_HTTPX2_URL, + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=(5.0, 30.0), + ) + (request,) = httpx2_requests + assert request.extensions["timeout"] == { + "connect": 5.0, + "read": 30.0, + "write": None, + "pool": None, + } + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + + +def test_sync_int_timeout( + httpx2_requests: list[httpx2.Request], +) -> None: + """``HTTPX2Transport`` works with an int timeout.""" + transport = HTTPX2Transport() + response = transport( + method="POST", + url=_HTTPX2_URL, + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30, + ) + (request,) = httpx2_requests + assert request.extensions["timeout"] == { + "connect": 30, + "read": 30, + "write": None, + "pool": None, + } + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + + +def test_sync_empty_body( + httpx2_requests: list[httpx2.Request], +) -> None: + """An empty request body is reported as ``None``, as it is for + the ``requests`` and ``httpx`` transports. + """ + transport = HTTPX2Transport() + response = transport( + method="GET", + url=_HTTPX2_URL, + headers={}, + data=b"", + request_timeout=30.0, + ) + assert len(httpx2_requests) == 1 + assert response.request_body is None + + +def test_sync_context_manager( + httpx2_requests: list[httpx2.Request], +) -> None: + """``HTTPX2Transport`` can be used as a context manager, and + leaving the context closes the client. + """ + with HTTPX2Transport() as transport: + response = transport( + method="POST", + url=_HTTPX2_URL, + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30.0, + ) + assert len(httpx2_requests) == 1 + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + + with pytest.raises( + expected_exception=RuntimeError, + match="client has been closed", + ): + _ = transport( + method="POST", + url=_HTTPX2_URL, + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30.0, + ) + + +def test_close(httpx2_requests: list[httpx2.Request]) -> None: + """Closing the transport closes the client.""" + transport = HTTPX2Transport() + transport.close() + with pytest.raises( + expected_exception=RuntimeError, + match="client has been closed", + ): + _ = transport( + method="POST", + url=_HTTPX2_URL, + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30.0, + ) + assert not bool(httpx2_requests) + + +def test_sync_httpx2_exceptions( + httpx2_requests: list[httpx2.Request], +) -> None: + """Errors are raised as ``httpx2`` exceptions, which are not + ``httpx`` exceptions. + """ + transport = HTTPX2Transport() + with pytest.raises(expected_exception=httpx2.ConnectError) as exc: + _ = transport( + method="GET", + url=_HTTPX2_REFUSED_URL, + headers={}, + data=b"", + request_timeout=30.0, + ) + assert len(httpx2_requests) == 1 + assert not isinstance(exc.value, httpx.HTTPError) + + +@pytest.mark.asyncio +async def test_async_float_timeout( + httpx2_requests: list[httpx2.Request], +) -> None: + """``AsyncHTTPX2Transport`` works with a float timeout. + + A float sets both the connect and read timeouts, and the response + is converted in full. + """ + transport = AsyncHTTPX2Transport() + response = await transport( + method="POST", + url=_HTTPX2_URL, + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30.0, + ) + (request,) = httpx2_requests + assert request.extensions["timeout"] == { + "connect": 30.0, + "read": 30.0, + "write": None, + "pool": None, + } + assert request.headers["Content-Type"] == "text/plain" + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + assert response.text == "OK" + assert response.url == _HTTPX2_URL + assert response.headers["x-example"] == "example" + assert response.request_body == b"hello" + assert response.content == b"OK" + assert response.tell_position == len(b"OK") + + +@pytest.mark.asyncio +async def test_async_tuple_timeout( + httpx2_requests: list[httpx2.Request], +) -> None: + """``AsyncHTTPX2Transport`` works with a (connect, read) + timeout tuple. + """ + transport = AsyncHTTPX2Transport() + response = await transport( + method="POST", + url=_HTTPX2_URL, + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=(5.0, 30.0), + ) + (request,) = httpx2_requests + assert request.extensions["timeout"] == { + "connect": 5.0, + "read": 30.0, + "write": None, + "pool": None, + } + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + + +@pytest.mark.asyncio +async def test_async_int_timeout( + httpx2_requests: list[httpx2.Request], +) -> None: + """``AsyncHTTPX2Transport`` works with an int timeout.""" + transport = AsyncHTTPX2Transport() + response = await transport( + method="POST", + url=_HTTPX2_URL, + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30, + ) + (request,) = httpx2_requests + assert request.extensions["timeout"] == { + "connect": 30, + "read": 30, + "write": None, + "pool": None, + } + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + + +@pytest.mark.asyncio +async def test_async_empty_body( + httpx2_requests: list[httpx2.Request], +) -> None: + """An empty request body is reported as ``None``, as it is for + the ``requests`` and ``httpx`` transports. + """ + transport = AsyncHTTPX2Transport() + response = await transport( + method="GET", + url=_HTTPX2_URL, + headers={}, + data=b"", + request_timeout=30.0, + ) + assert len(httpx2_requests) == 1 + assert response.request_body is None + + +@pytest.mark.asyncio +async def test_async_context_manager( + httpx2_requests: list[httpx2.Request], +) -> None: + """``AsyncHTTPX2Transport`` can be used as an async context + manager, and leaving the context closes the client. + """ + async with AsyncHTTPX2Transport() as transport: + response = await transport( + method="POST", + url=_HTTPX2_URL, + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30.0, + ) + assert len(httpx2_requests) == 1 + assert isinstance(response, Response) + assert response.status_code == HTTPStatus.OK + + with pytest.raises( + expected_exception=RuntimeError, + match="client has been closed", + ): + await transport( + method="POST", + url=_HTTPX2_URL, + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30.0, + ) + + +@pytest.mark.asyncio +async def test_aclose(httpx2_requests: list[httpx2.Request]) -> None: + """Closing the transport closes the client.""" + transport = AsyncHTTPX2Transport() + await transport.aclose() + with pytest.raises( + expected_exception=RuntimeError, + match="client has been closed", + ): + await transport( + method="POST", + url=_HTTPX2_URL, + headers={"Content-Type": "text/plain"}, + data=b"hello", + request_timeout=30.0, + ) + assert not bool(httpx2_requests) + + +@pytest.mark.asyncio +async def test_async_httpx2_exceptions( + httpx2_requests: list[httpx2.Request], +) -> None: + """Errors are raised as ``httpx2`` exceptions, which are not + ``httpx`` exceptions. + """ + transport = AsyncHTTPX2Transport() + with pytest.raises(expected_exception=httpx2.ConnectError) as exc: + await transport( + method="GET", + url=_HTTPX2_REFUSED_URL, + headers={}, + data=b"", + request_timeout=30.0, + ) + assert len(httpx2_requests) == 1 + assert not isinstance(exc.value, httpx.HTTPError) + + +def test_sync_mock_vws_and_cloud_reco( + high_quality_image: io.BytesIO, +) -> None: + """A target can be added with ``VWS`` and found with + ``CloudRecoService``. + """ + database = CloudDatabase() + with ( + MockVWS(processing_time_seconds=0.2) as mock, + HTTPX2Transport() as transport, + ): + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + transport=transport, + ) + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + transport=transport, + ) + target_id = vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id) + target_record = vws_client.get_target_record(target_id=target_id) + assert target_record.status == TargetStatuses.SUCCESS + + (match,) = cloud_reco_client.query(image=high_quality_image) + assert match.target_id == target_id + + +def test_sync_mock_vumark() -> None: + """A VuMark instance can be generated with ``VuMarkService``.""" + vumark_target = VuMarkTarget(name="vumark-template") + database = VuMarkDatabase(vumark_targets={vumark_target}) + with MockVWS() as mock, HTTPX2Transport() as transport: + mock.add_vumark_database(vumark_database=database) + vumark_client = VuMarkService( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + transport=transport, + ) + vumark_bytes = vumark_client.generate_vumark_instance( + target_id=vumark_target.target_id, + instance_id="instance", + accept=VuMarkAccept.PNG, + ) + assert vumark_bytes.startswith(b"\x89PNG") + + +def test_sync_mock_model_targets( + model_target_model: ModelTargetModel, +) -> None: + """A Model Target dataset can be generated with + ``ModelTargetService``. + """ + with ( + MockVWS(processing_time_seconds=0.2), + HTTPX2Transport() as transport, + ): + model_target_client = ModelTargetService( + client_id=_MODEL_TARGET_CLIENT_ID, + client_secret=_MODEL_TARGET_CLIENT_CREDENTIALS[1], + transport=transport, + ) + dataset_uuid = model_target_client.create_dataset( + name="dataset", + target_sdk="11.0", + models=[model_target_model], + dataset_type=ModelTargetDatasetType.STANDARD, + ) + report = model_target_client.wait_for_dataset_generated( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + assert report.status == ModelTargetDatasetStatuses.DONE + model_target_client.delete_dataset( + dataset_uuid=dataset_uuid, + dataset_type=ModelTargetDatasetType.STANDARD, + ) + + +@pytest.mark.parametrize( + argnames="custom_timeout", + argvalues=[0.1, (5.0, 0.1)], + ids=["float", "tuple"], +) +def test_sync_mock_timeout( + custom_timeout: float | tuple[float, float], +) -> None: + """A response which takes longer than the read timeout raises an + ``httpx2`` timeout. + """ + database = CloudDatabase() + sleeps: list[float] = [] + with MockVWS( + response_delay_seconds=0.11, + sleep_fn=sleeps.append, + ) as mock: + mock.add_cloud_database(cloud_database=database) + with HTTPX2Transport() as transport: + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + request_timeout_seconds=custom_timeout, + transport=transport, + ) + with pytest.raises(expected_exception=httpx2.ReadTimeout): + _ = vws_client.list_targets() + # The mock sleeps for the read timeout before raising. + assert sleeps == [0.1] + + +@pytest.mark.asyncio +async def test_async_mock_vws_and_cloud_reco( + high_quality_image: io.BytesIO, +) -> None: + """A target can be added with ``AsyncVWS`` and found with + ``AsyncCloudRecoService``. + """ + database = CloudDatabase() + with MockVWS(processing_time_seconds=0.2) as mock: + mock.add_cloud_database(cloud_database=database) + async with AsyncHTTPX2Transport() as transport: + await _add_and_query_target( + database=database, + transport=transport, + image=high_quality_image, + ) + + +@pytest.mark.asyncio +async def test_async_mock_vumark() -> None: + """A VuMark instance can be generated with + ``AsyncVuMarkService``. + """ + vumark_target = VuMarkTarget(name="vumark-template") + database = VuMarkDatabase(vumark_targets={vumark_target}) + with MockVWS() as mock: + mock.add_vumark_database(vumark_database=database) + async with AsyncHTTPX2Transport() as transport: + vumark_client = AsyncVuMarkService( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + transport=transport, + ) + vumark_bytes = await vumark_client.generate_vumark_instance( + target_id=vumark_target.target_id, + instance_id="instance", + accept=VuMarkAccept.PNG, + ) + assert vumark_bytes.startswith(b"\x89PNG") + + +@pytest.mark.asyncio +async def test_async_mock_model_targets( + model_target_model: ModelTargetModel, +) -> None: + """A Model Target dataset can be generated with + ``AsyncModelTargetService``. + """ + with MockVWS(processing_time_seconds=0.2): + async with AsyncHTTPX2Transport() as transport: + await _generate_dataset( + transport=transport, + model_target_model=model_target_model, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + argnames="custom_timeout", + argvalues=[0.1, (5.0, 0.1)], + ids=["float", "tuple"], +) +async def test_async_mock_timeout( + custom_timeout: float | tuple[float, float], +) -> None: + """A response which takes longer than the read timeout raises an + ``httpx2`` timeout. + """ + database = CloudDatabase() + sleeps: list[float] = [] + with MockVWS( + response_delay_seconds=0.11, + sleep_fn=sleeps.append, + ) as mock: + mock.add_cloud_database(cloud_database=database) + async with AsyncHTTPX2Transport() as transport: + vws_client = AsyncVWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + request_timeout_seconds=custom_timeout, + transport=transport, + ) + with pytest.raises(expected_exception=httpx2.ReadTimeout): + await vws_client.list_targets() + # The mock sleeps for the read timeout before raising. + assert sleeps == [0.1] diff --git a/tests/vws/__init__.py b/tests/vws/__init__.py new file mode 100644 index 000000000..d56bfb0c6 --- /dev/null +++ b/tests/vws/__init__.py @@ -0,0 +1 @@ +"""Vws tests.""" diff --git a/tests/vws/test_configuration.py b/tests/vws/test_configuration.py new file mode 100644 index 000000000..b510e61da --- /dev/null +++ b/tests/vws/test_configuration.py @@ -0,0 +1,175 @@ +"""Tests for `VWS` configuration.""" + +from __future__ import annotations + +import datetime +from typing import TYPE_CHECKING, BinaryIO + +import pytest +import requests +from freezegun import freeze_time +from mock_vws import MockVWS +from mock_vws.database import CloudDatabase + +from vws import VWS + +if TYPE_CHECKING: + import io + + +@pytest.mark.parametrize( + argnames=("response_delay_seconds", "expect_timeout"), + argvalues=[(29, False), (31, True)], +) +def test_default_timeout( + *, + image: io.BytesIO | BinaryIO, + response_delay_seconds: int, + expect_timeout: bool, +) -> None: + """At 29 seconds there is no error; at 31 seconds there is a + timeout. + """ + with ( + freeze_time() as frozen_datetime, + MockVWS( + response_delay_seconds=response_delay_seconds, + sleep_fn=lambda seconds: ( + frozen_datetime.tick( + delta=datetime.timedelta(seconds=seconds), + ), + None, + )[1], + ) as mock, + ): + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + ) + + if expect_timeout: + with pytest.raises( + expected_exception=requests.exceptions.Timeout, + ): + _ = vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + else: + _ = vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + +@pytest.mark.parametrize( + argnames=( + "custom_timeout", + "response_delay_seconds", + "expect_timeout", + ), + argvalues=[ + (0.1, 0.09, False), + (0.1, 0.11, True), + ((5.0, 0.1), 0.09, False), + ((5.0, 0.1), 0.11, True), + ], +) +def test_custom_timeout( + *, + image: io.BytesIO | BinaryIO, + custom_timeout: float | tuple[float, float], + response_delay_seconds: float, + expect_timeout: bool, +) -> None: + """Custom timeouts are honored for both float and tuple forms.""" + with ( + freeze_time() as frozen_datetime, + MockVWS( + response_delay_seconds=response_delay_seconds, + sleep_fn=lambda seconds: ( + frozen_datetime.tick( + delta=datetime.timedelta(seconds=seconds), + ), + None, + )[1], + ) as mock, + ): + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + request_timeout_seconds=custom_timeout, + ) + + if expect_timeout: + with pytest.raises( + expected_exception=requests.exceptions.Timeout, + ): + _ = vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + else: + _ = vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + +def test_custom_base_url(image: io.BytesIO | BinaryIO) -> None: + """ + It is possible to use add a target to a database under a custom + VWS + URL. + """ + base_vws_url = "http://example.com" + with MockVWS(base_vws_url=base_vws_url) as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + base_vws_url=base_vws_url, + ) + + _ = vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + +def test_custom_base_url_with_path_prefix() -> None: + """ + A base VWS URL with a path prefix is used as-is, without the + prefix being dropped. + """ + base_vws_url = "http://example.com/prefix" + with MockVWS(base_vws_url=base_vws_url) as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + base_vws_url=base_vws_url, + ) + + assert not bool(vws_client.list_targets()) diff --git a/tests/vws/test_processing.py b/tests/vws/test_processing.py new file mode 100644 index 000000000..0c30fdc1d --- /dev/null +++ b/tests/vws/test_processing.py @@ -0,0 +1,176 @@ +"""Tests for `VWS` processing.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, BinaryIO + +import pytest +from mock_vws import MockVWS +from mock_vws.database import CloudDatabase + +from vws import VWS +from vws.exceptions.custom_exceptions import ( + TargetProcessingTimeoutError, +) +from vws.reports import ( + TargetStatuses, +) + +if TYPE_CHECKING: + import io + + +def test_wait_for_target_processed( + *, + vws_client: VWS, + image: io.BytesIO | BinaryIO, +) -> None: + """It is possible to wait until a target is processed.""" + target_id = vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + report = vws_client.get_target_summary_report(target_id=target_id) + assert report.status == TargetStatuses.PROCESSING + vws_client.wait_for_target_processed(target_id=target_id) + report = vws_client.get_target_summary_report(target_id=target_id) + assert report.status != TargetStatuses.PROCESSING + + +def test_default_seconds_between_requests( + image: io.BytesIO | BinaryIO, +) -> None: + """By default, 0.2 seconds are waited between polling requests.""" + with MockVWS(processing_time_seconds=0.5) as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_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="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + report = vws_client.get_database_summary_report() + expected_requests = ( + # Add target request + 1 + + + # Database summary request + 1 + + + # Initial request + 1 + + + # Request after 0.2 seconds - not processed + 1 + + + # Request after 0.4 seconds - not processed + # This assumes that there is less than 0.1 seconds taken + # between the start of the target processing and the start of + # waiting for the target to be processed. + 1 + + + # Request after 0.6 seconds - processed + 1 + ) + # At the time of writing there is a bug which prevents request + # usage from being tracked so we cannot track this. + expected_requests = 0 + assert report.request_usage == expected_requests + + +def test_custom_seconds_between_requests( + image: io.BytesIO | BinaryIO, +) -> None: + """It is possible to customize the time waited between polling + requests. + """ + with MockVWS(processing_time_seconds=0.5) as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_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="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed( + target_id=target_id, + seconds_between_requests=0.3, + ) + report = vws_client.get_database_summary_report() + expected_requests = ( + # Add target request + 1 + + + # Database summary request + 1 + + + # Initial request + 1 + + + # Request after 0.3 seconds - not processed + # This assumes that there is less than 0.2 seconds taken + # between the start of the target processing and the start of + # waiting for the target to be processed. + 1 + + + # Request after 0.6 seconds - processed + 1 + ) + # At the time of writing there is a bug which prevents request + # usage from being tracked so we cannot track this. + expected_requests = 0 + assert report.request_usage == expected_requests + + +def test_custom_timeout(image: io.BytesIO | BinaryIO) -> None: + """It is possible to set a maximum timeout.""" + with MockVWS(processing_time_seconds=0.5) as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_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="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + report = vws_client.get_target_summary_report(target_id=target_id) + assert report.status == TargetStatuses.PROCESSING + with pytest.raises(expected_exception=TargetProcessingTimeoutError): + vws_client.wait_for_target_processed( + target_id=target_id, + timeout_seconds=0.1, + ) + + vws_client.wait_for_target_processed( + target_id=target_id, + timeout_seconds=0.5, + ) + report = vws_client.get_target_summary_report(target_id=target_id) + assert report.status != TargetStatuses.PROCESSING diff --git a/tests/vws/test_reports.py b/tests/vws/test_reports.py new file mode 100644 index 000000000..87885efa5 --- /dev/null +++ b/tests/vws/test_reports.py @@ -0,0 +1,354 @@ +"""Tests for `VWS` reports.""" + +from __future__ import annotations + +import calendar +import datetime +import time +import uuid +from http import HTTPStatus +from typing import TYPE_CHECKING, BinaryIO + +import pytest +from freezegun import freeze_time +from mock_vws import MockVWS +from mock_vws.database import CloudDatabase + +from vws import VWS +from vws.exceptions.custom_exceptions import ( + DatabaseIdNotSetError, + RecoCountsReportDownloadError, + RecoCountsReportNotReadyError, + RecoCountsReportTimeoutError, +) +from vws.exceptions.vws_exceptions import ( + AuthenticationFailureError, + FailError, +) +from vws.reports import ( + DatabaseSummaryReport, + RecoCount, + RecoCountsReport, + TargetStatuses, + TargetSummaryReport, +) +from vws.response import Response + +if TYPE_CHECKING: + import io + + +class _ForbiddenDownloadTransport: + """A transport which refuses to serve a report, as an expired URL + would. + """ + + def close(self) -> None: + """Close the transport.""" + + def __call__( + self, + *, + method: str, + url: str, + headers: dict[str, str], + data: bytes, + request_timeout: float | tuple[float, float], + ) -> Response: + """Return a "forbidden" response.""" + del method, headers, request_timeout + body = "AccessDenied" + return Response( + text=body, + url=url, + status_code=HTTPStatus.FORBIDDEN, + headers={}, + request_body=data, + tell_position=0, + content=body.encode(encoding="utf-8"), + ) + + +def test_get_target_summary_report( + *, + vws_client: VWS, + image: io.BytesIO | BinaryIO, +) -> None: + """Details of a target are returned by + ``get_target_summary_report``. + """ + date = "2018-04-25" + target_name = uuid.uuid4().hex + with freeze_time(time_to_freeze=date): + target_id = vws_client.add_target( + name=target_name, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + report = vws_client.get_target_summary_report(target_id=target_id) + + expected_report = TargetSummaryReport( + status=TargetStatuses.SUCCESS, + database_name=report.database_name, + target_name=target_name, + upload_date=datetime.date(year=2018, month=4, day=25), + active_flag=True, + tracking_rating=report.tracking_rating, + total_recos=0, + current_month_recos=0, + previous_month_recos=0, + ) + + assert report.status == expected_report.status + assert report.database_name == expected_report.database_name + assert report.target_name == expected_report.target_name + assert report.upload_date == expected_report.upload_date + assert report.active_flag == expected_report.active_flag + assert report.tracking_rating == expected_report.tracking_rating + assert report.total_recos == expected_report.total_recos + assert report.current_month_recos == expected_report.current_month_recos + assert report.previous_month_recos == expected_report.previous_month_recos + + assert report == expected_report + + +def test_get_target(vws_client: VWS) -> None: + """Details of a database are returned by + ``get_database_summary_report``. + """ + report = vws_client.get_database_summary_report() + + expected_report = DatabaseSummaryReport( + active_images=0, + current_month_recos=0, + failed_images=0, + inactive_images=0, + name=report.name, + previous_month_recos=0, + processing_images=0, + reco_threshold=1000, + request_quota=100000, + request_usage=0, + target_quota=1000, + total_recos=0, + ) + + assert report.active_images == expected_report.active_images + assert report.current_month_recos == expected_report.current_month_recos + assert report.failed_images == expected_report.failed_images + assert report.inactive_images == expected_report.inactive_images + assert report.name == expected_report.name + assert report.previous_month_recos == expected_report.previous_month_recos + assert report.processing_images == expected_report.processing_images + assert report.reco_threshold == expected_report.reco_threshold + assert report.request_quota == expected_report.request_quota + assert report.request_usage == expected_report.request_usage + assert report.target_quota == expected_report.target_quota + assert report.total_recos == expected_report.total_recos + + assert report == expected_report + + +def test_reco_counts_report( + *, + vws_client: VWS, + report_month: datetime.date, +) -> None: + """A report can be requested, waited for and downloaded.""" + report_request = vws_client.request_database_reco_counts_report( + year=report_month.year, + month=calendar.Month(value=report_month.month), + ) + assert bool(report_request.transaction_id) + assert bool(report_request.presigned_url) + + report = vws_client.wait_for_reco_counts_report( + presigned_url=report_request.presigned_url, + ) + + # No targets have been recognized, so the report has no rows. + assert not bool(report.reco_counts) + assert report.raw_csv.startswith(b"target_id,reco_count") + + +def test_not_ready(*, current_month: datetime.date) -> None: + """Downloading a report before Vuforia has generated it raises an + error. + """ + with MockVWS(processing_time_seconds=60) as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + database_id=database.database_id, + ) + report_request = vws_client.request_database_reco_counts_report( + year=current_month.year, + month=calendar.Month(value=current_month.month), + ) + + with pytest.raises( + expected_exception=RecoCountsReportNotReadyError, + ) as exc: + _ = vws_client.download_reco_counts_report( + presigned_url=report_request.presigned_url, + ) + + assert exc.value.response.status_code == HTTPStatus.NOT_FOUND + + +def test_wait_timeout(*, current_month: datetime.date) -> None: + """Waiting for a report which is not generated in time raises an + error. + """ + with MockVWS(processing_time_seconds=60) as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + database_id=database.database_id, + ) + report_request = vws_client.request_database_reco_counts_report( + year=current_month.year, + month=calendar.Month(value=current_month.month), + ) + + maximum_wait_seconds = 5 + start_time = time.monotonic() + + with pytest.raises( + expected_exception=RecoCountsReportTimeoutError, + ): + _ = vws_client.wait_for_reco_counts_report( + presigned_url=report_request.presigned_url, + seconds_between_requests=0.01, + timeout_seconds=0.05, + ) + + elapsed_time = time.monotonic() - start_time + assert elapsed_time < maximum_wait_seconds + + +@pytest.mark.parametrize( + argnames=("year", "month"), + argvalues=[ + pytest.param(1999, calendar.Month.JANUARY, id="year-in-the-past"), + pytest.param( + 1999, + calendar.Month.DECEMBER, + id="year-in-the-past-december", + ), + ], +) +def test_month_not_accepted( + *, + vws_client: VWS, + year: int, + month: calendar.Month, +) -> None: + """Months other than the current and previous month are + rejected. + """ + with pytest.raises(expected_exception=FailError) as exc: + _ = vws_client.request_database_reco_counts_report( + year=year, + month=month, + ) + + assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST + + +def test_database_id_does_not_match_keys( + *, + current_month: datetime.date, +) -> None: + """A database ID which does not match the given keys is + rejected. + """ + with MockVWS() as mock: + database = CloudDatabase() + mock.add_cloud_database(cloud_database=database) + vws_client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, + database_id=uuid.uuid4().hex, + ) + + with pytest.raises( + expected_exception=AuthenticationFailureError, + ) as exc: + _ = vws_client.request_database_reco_counts_report( + year=current_month.year, + month=calendar.Month(value=current_month.month), + ) + + assert exc.value.response.status_code == HTTPStatus.UNAUTHORIZED + + +def test_download_error() -> None: + """An error response from the report's URL raises an error.""" + vws_client = VWS( + server_access_key=uuid.uuid4().hex, + server_secret_key=uuid.uuid4().hex, + transport=_ForbiddenDownloadTransport(), + ) + + with pytest.raises( + expected_exception=RecoCountsReportDownloadError, + ) as exc: + _ = vws_client.download_reco_counts_report( + presigned_url="https://example.com/reports/recoCounts/x", + ) + + assert exc.value.response.status_code == HTTPStatus.FORBIDDEN + + +def test_no_database_id(*, current_month: datetime.date) -> None: + """A client which was given no database ID cannot request a + report. + """ + vws_client = VWS( + server_access_key=uuid.uuid4().hex, + server_secret_key=uuid.uuid4().hex, + ) + + with pytest.raises(expected_exception=DatabaseIdNotSetError): + _ = vws_client.request_database_reco_counts_report( + year=current_month.year, + month=calendar.Month(value=current_month.month), + ) + + +def test_rows() -> None: + """Each row of the CSV becomes a ``RecoCount``.""" + csv_bytes = b"target_id,reco_count\r\nabc,3\r\ndef,0\r\n" + + report = RecoCountsReport.from_csv(csv_bytes=csv_bytes) + + assert report.reco_counts == [ + RecoCount(target_id="abc", reco_count=3), + RecoCount(target_id="def", reco_count=0), + ] + assert report.raw_csv == csv_bytes + + +def test_unknown_columns_ignored() -> None: + """Columns which are not known are not exposed in + ``reco_counts``. + """ + expected_reco_count = 3 + header = "target_id,reco_count,new_column\r\n" + csv_text = f"{header}abc,{expected_reco_count},x\r\n" + + report = RecoCountsReport.from_csv( + csv_bytes=csv_text.encode(encoding="utf-8"), + ) + + (item,) = report.reco_counts + assert item.target_id == "abc" + assert item.reco_count == expected_reco_count diff --git a/tests/vws/test_targets.py b/tests/vws/test_targets.py new file mode 100644 index 000000000..58190c65d --- /dev/null +++ b/tests/vws/test_targets.py @@ -0,0 +1,374 @@ +"""Tests for `VWS` targets. + +Test for deleting a target. +""" + +from __future__ import annotations + +import base64 +import json +import secrets +import uuid +from http import HTTPStatus +from typing import TYPE_CHECKING, BinaryIO + +import pytest + +from vws import VWS, CloudRecoService +from vws.reports import ( + TargetRecord, + TargetStatuses, +) +from vws.response import Response + +if TYPE_CHECKING: + import io + + +class _JSONResponseTransport: + """A transport which returns one JSON response body.""" + + def __init__(self, *, body: object) -> None: + """Create a transport for the given JSON body.""" + self._text = json.dumps(obj=body) + + def close(self) -> None: + """Close the transport.""" + + def __call__( + self, + *, + method: str, + url: str, + headers: dict[str, str], + data: bytes, + request_timeout: float | tuple[float, float], + ) -> Response: + """Return the configured response body.""" + del method, headers, data, request_timeout + content = self._text.encode() + return Response( + text=self._text, + url=url, + status_code=HTTPStatus.OK, + headers={"Content-Type": "application/json"}, + request_body=None, + tell_position=len(content), + content=content, + ) + + +@pytest.mark.parametrize( + argnames="application_metadata", + argvalues=[None, b"a"], +) +@pytest.mark.parametrize(argnames="active_flag", argvalues=[True, False]) +def test_add_target( + *, + vws_client: VWS, + image: io.BytesIO | BinaryIO, + application_metadata: bytes | None, + cloud_reco_client: CloudRecoService, + active_flag: bool, +) -> None: + """No exception is raised when adding one target.""" + name = "x" + width = 1 + if application_metadata is None: + encoded_metadata = None + else: + encoded_metadata_bytes = base64.b64encode(s=application_metadata) + encoded_metadata = encoded_metadata_bytes.decode(encoding="utf-8") + + target_id = vws_client.add_target( + name=name, + width=width, + image=image, + application_metadata=encoded_metadata, + active_flag=active_flag, + ) + target_record = vws_client.get_target_record( + target_id=target_id, + ).target_record + assert target_record.name == name + assert target_record.width == width + assert target_record.active_flag is active_flag + vws_client.wait_for_target_processed(target_id=target_id) + matching_targets = cloud_reco_client.query(image=image) + if active_flag: + [matching_target] = matching_targets + assert matching_target.target_id == target_id + assert matching_target.target_data is not None + query_metadata = matching_target.target_data.application_metadata + assert query_metadata == encoded_metadata + else: + assert matching_targets == [] + + +def test_add_two_targets( + *, + vws_client: VWS, + image: io.BytesIO | BinaryIO, +) -> None: + """No exception is raised when adding two targets with different + names. + + This demonstrates that the image seek position is not changed. + """ + for name in ("a", "b"): + _ = vws_client.add_target( + name=name, + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + +def test_list_targets( + *, + vws_client: VWS, + image: io.BytesIO | BinaryIO, +) -> None: + """It is possible to get a list of target IDs.""" + id_1 = vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + id_2 = vws_client.add_target( + name="a", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + assert sorted(vws_client.list_targets()) == sorted([id_1, id_2]) + + +def test_delete_target( + *, + vws_client: VWS, + image: io.BytesIO | BinaryIO, +) -> None: + """It is possible to delete a target.""" + target_id = vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + assert vws_client.list_targets() == [target_id] + vws_client.delete_target(target_id=target_id) + assert vws_client.list_targets() == [] + + +def test_get_target_record( + *, + vws_client: VWS, + image: io.BytesIO | BinaryIO, +) -> None: + """Details of a target are returned by ``get_target_record``.""" + target_id = vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + result = vws_client.get_target_record(target_id=target_id) + expected_target_record = TargetRecord( + target_id=target_id, + active_flag=True, + name="x", + width=1, + tracking_rating=-1, + reco_rating="", + ) + + assert result.target_record == expected_target_record + + assert result.target_record.target_id == expected_target_record.target_id + assert ( + result.target_record.active_flag == expected_target_record.active_flag + ) + assert result.target_record.name == expected_target_record.name + assert result.target_record.width == expected_target_record.width + assert ( + result.target_record.tracking_rating + == expected_target_record.tracking_rating + ) + assert ( + result.target_record.reco_rating == expected_target_record.reco_rating + ) + + assert result.status == TargetStatuses.PROCESSING + + +def test_get_failed( + *, + vws_client: VWS, + image_file_failed_state: io.BytesIO, +) -> None: + """Check that the report works with a failed target.""" + target_id = vws_client.add_target( + name="x", + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + result = vws_client.get_target_record(target_id=target_id) + + assert result.status == TargetStatuses.FAILED + + +def test_get_duplicate_targets( + *, + vws_client: VWS, + image: io.BytesIO | BinaryIO, +) -> None: + """It is possible to get the IDs of similar targets.""" + target_id = vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + similar_target_id = vws_client.add_target( + name="a", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.wait_for_target_processed(target_id=similar_target_id) + duplicates = vws_client.get_duplicate_targets(target_id=target_id) + assert duplicates == [similar_target_id] + + +@pytest.mark.parametrize( + argnames="similar_targets", + argvalues=[1, ["target-id", 1]], +) +def test_invalid_duplicate_target_response(*, similar_targets: object) -> None: + """Duplicate target IDs in responses must be a list of strings.""" + transport = _JSONResponseTransport( + body={ + "result_code": "Success", + "similar_targets": similar_targets, + } + ) + client = VWS( + server_access_key="access-key", + server_secret_key=secrets.token_hex(), + transport=transport, + ) + + with pytest.raises(expected_exception=TypeError): + _ = client.get_duplicate_targets(target_id="target-id") + + +@pytest.mark.parametrize( + argnames="body", + argvalues=[[], {"result_code": 1}], +) +def test_invalid_vws_response_envelope(*, body: object) -> None: + """VWS responses must be objects with a string result code.""" + transport = _JSONResponseTransport(body=body) + client = VWS( + server_access_key="access-key", + server_secret_key=secrets.token_hex(), + transport=transport, + ) + + with pytest.raises(expected_exception=TypeError): + client.delete_target(target_id="target-id") + + +def test_update_target( + *, + vws_client: VWS, + image: io.BytesIO | BinaryIO, + different_high_quality_image: io.BytesIO, + cloud_reco_client: CloudRecoService, +) -> None: + """It is possible to update a target.""" + old_name = uuid.uuid4().hex + old_width = secrets.choice(seq=range(1, 5000)) / 100 + target_id = vws_client.add_target( + name=old_name, + width=old_width, + image=image, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id) + [matching_target] = cloud_reco_client.query(image=image) + assert matching_target.target_id == target_id + query_target_data = matching_target.target_data + assert query_target_data is not None + query_metadata = query_target_data.application_metadata + assert query_metadata is None + + new_name = uuid.uuid4().hex + new_width = secrets.choice(seq=range(1, 5000)) / 100 + new_application_metadata = base64.b64encode(s=b"a").decode( + encoding="ascii", + ) + vws_client.update_target( + target_id=target_id, + name=new_name, + width=new_width, + active_flag=True, + image=different_high_quality_image, + application_metadata=new_application_metadata, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + [ + matching_target, + ] = cloud_reco_client.query(image=different_high_quality_image) + assert matching_target.target_id == target_id + query_target_data = matching_target.target_data + assert query_target_data is not None + query_metadata = query_target_data.application_metadata + assert query_metadata == new_application_metadata + + vws_client.update_target( + target_id=target_id, + active_flag=False, + ) + + target_details = vws_client.get_target_record(target_id=target_id) + assert target_details.target_record.name == new_name + assert target_details.target_record.width == new_width + assert not target_details.target_record.active_flag + + +def test_no_fields_given( + *, + vws_client: VWS, + image: io.BytesIO | BinaryIO, +) -> None: + """It is possible to give no update fields.""" + target_id = vws_client.add_target( + name="x", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.update_target(target_id=target_id) diff --git a/tests/vws/test_vumark.py b/tests/vws/test_vumark.py new file mode 100644 index 000000000..c1e08a915 --- /dev/null +++ b/tests/vws/test_vumark.py @@ -0,0 +1,33 @@ +"""Tests for `VWS` vumark.""" + +from __future__ import annotations + +import pytest + +# pytest-beartype resolves fixture annotations at runtime. +from vws import VuMarkService # noqa: TC001 +from vws.vumark_accept import VuMarkAccept + + +@pytest.mark.parametrize( + argnames=("accept", "expected_prefix"), + argvalues=[ + pytest.param(VuMarkAccept.PNG, b"\x89PNG\r\n\x1a\n", id="png"), + pytest.param(VuMarkAccept.SVG, b"<", id="svg"), + pytest.param(VuMarkAccept.PDF, b"%PDF", id="pdf"), + ], +) +def test_generate_vumark_instance( + *, + vumark_service_client: VuMarkService, + vumark_target_id: str, + accept: VuMarkAccept, + expected_prefix: bytes, +) -> None: + """The returned bytes match the requested format.""" + result = vumark_service_client.generate_vumark_instance( + target_id=vumark_target_id, + instance_id="12345", + accept=accept, + ) + assert result.startswith(expected_prefix)