From 95173b97ae7deef47da0163a59091ddd6593f821 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 28 Oct 2026 17:31:05 +0000 Subject: [PATCH 01/10] Package shared fixtures and database-summary contracts separately --- .github/workflows/contract-package.yml | 25 + MANIFEST.in | 4 + ci/check_contract_package.sh | 60 + conftest.py | 28 - contract-tests/LICENSE | 21 + contract-tests/README.rst | 44 + contract-tests/pyproject.toml | 38 + .../src/vws_mock_contract_tests/__init__.py | 1 + .../database_summary/__init__.py | 1 + .../database_summary/contract/__init__.py | 1 + .../database_summary/contract/conftest.py | 12 + .../contract/test_database_summary.py | 154 +++ .../contract/test_inactive_project.py | 10 + .../database_summary/contract/test_quotas.py | 17 + .../database_summary/contract/test_recos.py | 37 + .../contract/test_request_usage.py | 71 + .../database_summary/helpers.py | 2 +- .../database_summary/test_database_summary.py | 39 + .../fixtures/__init__.py | 1 + .../fixtures/api_contract.py | 8 +- .../fixtures/credentials.py | 81 ++ .../model_target_prepared_requests.py | 197 +++ .../fixtures/plugin.py | 36 + .../fixtures/prepared_requests.py | 4 +- .../model_target_web_api/__init__.py | 1 + .../model_target_web_api/assertions.py | 50 + .../src/vws_mock_contract_tests/py.typed | 0 .../vws_mock_contract_tests/utils/__init__.py | 261 ++++ .../utils/assertions.py | 378 ++++++ .../utils/backend_capabilities.py | 29 + .../utils/cloud_databases.py | 20 + .../utils/model_target_response_policy.py | 49 + .../utils/model_target_retries.py | 84 ++ .../utils/recognition_counts.py | 35 + .../utils/request_timeout_policy.py | 53 + .../vws_mock_contract_tests/utils/retries.py | 114 ++ .../utils/too_many_requests.py | 35 + .../utils/unrouted_requests.py | 55 + docs/source/contributing.rst | 15 +- prek.toml | 4 +- pyproject.toml | 37 +- test-backends/LICENSE | 21 + test-backends/README.rst | 15 + test-backends/pyproject.toml | 37 + .../src/vws_mock_test_backends/__init__.py | 1 + .../src/vws_mock_test_backends/py.typed | 0 .../real_credentials.py | 164 +++ .../real_model_target_retries.py | 84 ++ .../real_vws_retries.py | 2 +- .../vuforia_backends.py | 1151 +++++++++++++++++ tests/conftest.py | 4 +- tests/mock_vws/database_summary/__init__.py | 2 +- .../database_summary/contract/__init__.py | 2 +- .../database_summary/contract/conftest.py | 15 +- .../contract/test_database_summary.py | 167 +-- .../contract/test_inactive_project.py | 13 +- .../database_summary/contract/test_quotas.py | 20 +- .../database_summary/contract/test_recos.py | 40 +- .../contract/test_request_usage.py | 80 +- .../database_summary/test_database_summary.py | 40 +- tests/mock_vws/fixtures/__init__.py | 2 +- tests/mock_vws/fixtures/credentials.py | 98 +- .../model_target_prepared_requests.py | 199 +-- tests/mock_vws/fixtures/plugin.py | 8 +- tests/mock_vws/fixtures/real_credentials.py | 165 +-- .../fixtures/real_model_target_retries.py | 86 +- tests/mock_vws/fixtures/vuforia_backends.py | 1148 +--------------- .../model_target_web_api/assertions.py | 54 +- tests/mock_vws/utils/__init__.py | 276 +--- tests/mock_vws/utils/assertions.py | 397 +----- tests/mock_vws/utils/backend_capabilities.py | 32 +- tests/mock_vws/utils/cloud_databases.py | 21 +- .../utils/model_target_response_policy.py | 52 +- tests/mock_vws/utils/model_target_retries.py | 97 +- tests/mock_vws/utils/recognition_counts.py | 38 +- .../mock_vws/utils/request_timeout_policy.py | 57 +- tests/mock_vws/utils/retries.py | 115 +- tests/mock_vws/utils/too_many_requests.py | 38 +- tests/mock_vws/utils/unrouted_requests.py | 60 +- uv.lock | 87 ++ 80 files changed, 3829 insertions(+), 3171 deletions(-) create mode 100644 .github/workflows/contract-package.yml create mode 100755 ci/check_contract_package.sh create mode 100644 contract-tests/LICENSE create mode 100644 contract-tests/README.rst create mode 100644 contract-tests/pyproject.toml create mode 100644 contract-tests/src/vws_mock_contract_tests/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/database_summary/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/database_summary/contract/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/database_summary/contract/conftest.py create mode 100644 contract-tests/src/vws_mock_contract_tests/database_summary/contract/test_database_summary.py create mode 100644 contract-tests/src/vws_mock_contract_tests/database_summary/contract/test_inactive_project.py create mode 100644 contract-tests/src/vws_mock_contract_tests/database_summary/contract/test_quotas.py create mode 100644 contract-tests/src/vws_mock_contract_tests/database_summary/contract/test_recos.py create mode 100644 contract-tests/src/vws_mock_contract_tests/database_summary/contract/test_request_usage.py rename {tests/mock_vws => contract-tests/src/vws_mock_contract_tests}/database_summary/helpers.py (97%) create mode 100644 contract-tests/src/vws_mock_contract_tests/database_summary/test_database_summary.py create mode 100644 contract-tests/src/vws_mock_contract_tests/fixtures/__init__.py rename {tests/mock_vws => contract-tests/src/vws_mock_contract_tests}/fixtures/api_contract.py (98%) create mode 100644 contract-tests/src/vws_mock_contract_tests/fixtures/credentials.py create mode 100644 contract-tests/src/vws_mock_contract_tests/fixtures/model_target_prepared_requests.py create mode 100644 contract-tests/src/vws_mock_contract_tests/fixtures/plugin.py rename {tests/mock_vws => contract-tests/src/vws_mock_contract_tests}/fixtures/prepared_requests.py (99%) create mode 100644 contract-tests/src/vws_mock_contract_tests/model_target_web_api/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/model_target_web_api/assertions.py create mode 100644 contract-tests/src/vws_mock_contract_tests/py.typed create mode 100644 contract-tests/src/vws_mock_contract_tests/utils/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/utils/assertions.py create mode 100644 contract-tests/src/vws_mock_contract_tests/utils/backend_capabilities.py create mode 100644 contract-tests/src/vws_mock_contract_tests/utils/cloud_databases.py create mode 100644 contract-tests/src/vws_mock_contract_tests/utils/model_target_response_policy.py create mode 100644 contract-tests/src/vws_mock_contract_tests/utils/model_target_retries.py create mode 100644 contract-tests/src/vws_mock_contract_tests/utils/recognition_counts.py create mode 100644 contract-tests/src/vws_mock_contract_tests/utils/request_timeout_policy.py create mode 100644 contract-tests/src/vws_mock_contract_tests/utils/retries.py create mode 100644 contract-tests/src/vws_mock_contract_tests/utils/too_many_requests.py create mode 100644 contract-tests/src/vws_mock_contract_tests/utils/unrouted_requests.py create mode 100644 test-backends/LICENSE create mode 100644 test-backends/README.rst create mode 100644 test-backends/pyproject.toml create mode 100644 test-backends/src/vws_mock_test_backends/__init__.py create mode 100644 test-backends/src/vws_mock_test_backends/py.typed create mode 100644 test-backends/src/vws_mock_test_backends/real_credentials.py create mode 100644 test-backends/src/vws_mock_test_backends/real_model_target_retries.py rename {tests/mock_vws/fixtures => test-backends/src/vws_mock_test_backends}/real_vws_retries.py (98%) create mode 100644 test-backends/src/vws_mock_test_backends/vuforia_backends.py diff --git a/.github/workflows/contract-package.yml b/.github/workflows/contract-package.yml new file mode 100644 index 000000000..5a0a11c86 --- /dev/null +++ b/.github/workflows/contract-package.yml @@ -0,0 +1,25 @@ +--- +name: Contract package + +on: + push: + branches: [main] + pull_request: {} + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + consumer: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@v10.3.2 + - name: Verify installed contracts through the Flask adapter + run: ci/check_contract_package.sh + env: + UV_PYTHON: '3.15' diff --git a/MANIFEST.in b/MANIFEST.in index fc2bedff2..8fb921484 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,5 @@ recursive-include docs *.md +include contract-tests/pyproject.toml contract-tests/README.rst contract-tests/LICENSE +recursive-include contract-tests/src *.py py.typed +include test-backends/pyproject.toml test-backends/README.rst test-backends/LICENSE +recursive-include test-backends/src *.py py.typed diff --git a/ci/check_contract_package.sh b/ci/check_contract_package.sh new file mode 100755 index 000000000..1690d8bfd --- /dev/null +++ b/ci/check_contract_package.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +CHECK_DIR="$(mktemp -d)" +trap 'rm -rf "${CHECK_DIR}"' EXIT + +uv build --all-packages --wheel --out-dir "${CHECK_DIR}/dist" +uv venv --python 3.15 "${CHECK_DIR}/consumer" +CONSUMER_PYTHON="${CHECK_DIR}/consumer/bin/python" +uv pip install --python "${CONSUMER_PYTHON}" "${CHECK_DIR}"/dist/vws_mock_contract_tests-*.whl + +cd "${CHECK_DIR}" +unset PYTHONPATH +printf '[pytest]\n' > pytest.ini + +"${CONSUMER_PYTHON}" - << 'PY' +import importlib.util + +assert importlib.util.find_spec(name="mock_vws") is None +assert importlib.util.find_spec(name="flask") is None +PY + +"${CONSUMER_PYTHON}" -m pytest \ + -c pytest.ini \ + -p vws_mock_contract_tests.fixtures.plugin \ + --pyargs vws_mock_contract_tests --collect-only -q + +uv pip install --python "${CONSUMER_PYTHON}" \ + "${CHECK_DIR}"/dist/vws_python_mock-*.whl \ + "${CHECK_DIR}"/dist/vws_mock_test_backends-*.whl + +cat > consumer_contract.py << 'PY' +"""Check the lifecycle boundary of the explicitly selected adapter.""" + +import sys + +import pytest + + +@pytest.fixture(autouse=True) +def ready(request: pytest.FixtureRequest) -> None: + """Check that the adapter's public setup fixture yields None.""" + for name in ("verify_mock_vuforia", "mock_only_vuforia"): + if name in request.fixturenames: + assert request.getfixturevalue(argname=name) is None + + +def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: + """Keep the core repository's tests outside the consumer process.""" + del session, exitstatus + assert not any(name.startswith("tests.mock_vws") for name in sys.modules) +PY + +"${CONSUMER_PYTHON}" -m pytest \ + -c pytest.ini \ + -p consumer_contract \ + -p vws_mock_contract_tests.fixtures.plugin \ + -p vws_mock_test_backends.vuforia_backends \ + --pyargs "${CONTRACT_TEST_TARGET:-vws_mock_contract_tests.database_summary}" \ + --skip-backend=real --skip-backend=mock -q diff --git a/conftest.py b/conftest.py index 7ea027625..972f875ba 100644 --- a/conftest.py +++ b/conftest.py @@ -2,16 +2,12 @@ from doctest import ELLIPSIS -import pytest -from beartype import beartype from sybil import Sybil from sybil.parsers.rest import ( DocTestParser, PythonCodeBlockParser, ) -from tests.mock_vws.utils.retries import TRANSIENT_VWS_EXCEPTIONS - pytest_collect_file = Sybil( parsers=[ DocTestParser(optionflags=ELLIPSIS), @@ -19,27 +15,3 @@ ], patterns=["*.rst", "*.py"], ).pytest() - - -@beartype -@pytest.hookimpl(optionalhook=True) -def pytest_set_filtered_exceptions() -> tuple[type[Exception], ...]: - """Return exceptions to retry on. - - This is for ``pytest-retry``. - The configuration for retries is in ``pyproject.toml``. - - ``pytest-retry`` treats this as an allowlist: a test which fails - with anything else is not retried, whatever ``retries`` is set to. - That is deliberate, because widening it to ``AssertionError`` would - retry every failing assertion in the suite ten times. - - The consequence is that a test which turns a response into an - ``AssertionError`` itself, rather than letting an exception out of - the client, is never retried here. The Model Target Web API tests - assert on ``requests.Response`` objects and so are in that - position; they get their own, narrower policy, applied before the - assertion, in - :py:mod:`tests.mock_vws.utils.model_target_retries`. - """ - return TRANSIENT_VWS_EXCEPTIONS diff --git a/contract-tests/LICENSE b/contract-tests/LICENSE new file mode 100644 index 000000000..c9f18d1a3 --- /dev/null +++ b/contract-tests/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Adam Dangoor + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/contract-tests/README.rst b/contract-tests/README.rst new file mode 100644 index 000000000..e532ccef0 --- /dev/null +++ b/contract-tests/README.rst @@ -0,0 +1,44 @@ +VWS mock contract tests +======================= + +This project contains the shared API fixtures, helpers and database-summary tests. +Further API groups are migrated separately. +It is a development dependency for projects which implement the Vuforia APIs. + +Build and install +----------------- + +Build all workspace wheels from the repository root: + +.. code-block:: console + + $ uv build --all-packages --wheel --out-dir dist + $ uv pip install dist/*.whl + +The initial package version is ``0.0.0``. +Install the suite, core library and built-in adapter package from the same repository commit. +For Git installations, use the ``contract-tests`` and ``test-backends`` subdirectories at that commit. + +Run the installed suite from outside the source checkout: + +.. code-block:: console + + $ python -m pytest \ + -p vws_mock_contract_tests.fixtures.plugin \ + -p vws_mock_test_backends.vuforia_backends \ + --pyargs vws_mock_contract_tests.database_summary \ + --skip-backend=real + +The shared plugin provides clients, prepared requests and the retry allowlist. +The adapter plugin supplies service URLs, credentials, capabilities and setup fixtures. +Public setup fixtures yield ``None`` once the service is ready and keep backend selection internal. +Another repository can supply its own explicitly loaded adapter plugin. +The shared package has no dependency on the mock implementation or Flask. + +Core CI compatibility +--------------------- + +Import-only modules under ``tests/mock_vws`` preserve the existing CI selectors. +The assertions have one source in ``vws_mock_contract_tests``. +Core coverage measures the packaged source and omits the forwarding modules. +The root test configuration avoids collecting the packaged tests a second time. diff --git a/contract-tests/pyproject.toml b/contract-tests/pyproject.toml new file mode 100644 index 000000000..3b053ed04 --- /dev/null +++ b/contract-tests/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +build-backend = "setuptools.build_meta" +requires = [ "setuptools" ] + +[project] +name = "vws-mock-contract-tests" +version = "0.0.0" +description = "Shared Vuforia Web Services API contract tests." +readme = "README.rst" +license = "MIT" +license-files = [ "LICENSE" ] +authors = [ { name = "Adam Dangoor", email = "adamdangoor@gmail.com" } ] +requires-python = ">=3.15" +classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.15", +] +dependencies = [ + "beartype>=0.23.1", + "dirty-equals>=0.11", + "freezegun>=1.5.5", + "pillow>=12.2", + "pydantic>=2.6", + "pytest>=9.1.1", + "pytest-retry>=1.7", + "requests>=2.32.3", + "tenacity>=9.2.1", + "tzdata; sys_platform>='win32'", + "urllib3>=2.8", + "vws-auth-tools>=2024.7.12", + "vws-python>=2026.10.9", + "vws-test-fixtures>=2026.10.8", +] +urls.Source = "https://github.com/VWS-Python/vws-python-mock" + +[tool.setuptools] +packages.find.where = [ "src" ] +package-data.vws_mock_contract_tests = [ "py.typed" ] diff --git a/contract-tests/src/vws_mock_contract_tests/__init__.py b/contract-tests/src/vws_mock_contract_tests/__init__.py new file mode 100644 index 000000000..1adc44e80 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/__init__.py @@ -0,0 +1 @@ +"""Support for shared Vuforia API contract tests.""" diff --git a/contract-tests/src/vws_mock_contract_tests/database_summary/__init__.py b/contract-tests/src/vws_mock_contract_tests/database_summary/__init__.py new file mode 100644 index 000000000..266ddde2b --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/database_summary/__init__.py @@ -0,0 +1 @@ +"""Database summary tests.""" diff --git a/contract-tests/src/vws_mock_contract_tests/database_summary/contract/__init__.py b/contract-tests/src/vws_mock_contract_tests/database_summary/contract/__init__.py new file mode 100644 index 000000000..2dbb3bed8 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/database_summary/contract/__init__.py @@ -0,0 +1 @@ +"""Tests using verify mock vuforia.""" diff --git a/contract-tests/src/vws_mock_contract_tests/database_summary/contract/conftest.py b/contract-tests/src/vws_mock_contract_tests/database_summary/contract/conftest.py new file mode 100644 index 000000000..d409c8ebf --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/database_summary/contract/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures scoped to this group of tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def fixture_verify_backend( + *, + verify_mock_vuforia: None, +) -> None: + """Set up the backend for every contract test in this directory.""" + del verify_mock_vuforia diff --git a/contract-tests/src/vws_mock_contract_tests/database_summary/contract/test_database_summary.py b/contract-tests/src/vws_mock_contract_tests/database_summary/contract/test_database_summary.py new file mode 100644 index 000000000..a885228e1 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/database_summary/contract/test_database_summary.py @@ -0,0 +1,154 @@ +""" +Tests for the mock of the database summary endpoint at `GET +/summary`. +""" + +import io +import uuid + +import pytest +from vws import VWS + +from vws_mock_contract_tests.database_summary.helpers import ( + wait_for_image_numbers, +) +from vws_mock_contract_tests.fixtures.credentials import ( + NamedCloudDatabaseCredentials, +) + + +def test_success( + *, + vuforia_database: NamedCloudDatabaseCredentials, + vws_client: VWS, +) -> None: + """It is possible to get a success response.""" + report = vws_client.get_database_summary_report() + assert report.name == vuforia_database.database_name + + wait_for_image_numbers( + vws_client=vws_client, + active_images=0, + inactive_images=0, + failed_images=0, + processing_images=0, + ) + + +# ``verify_mock_vuforia`` is given here as well as on the class so +# that the backend is set up before ``target_id`` adds a target. +@pytest.mark.usefixtures("verify_mock_vuforia", "target_id") +def test_active_images(*, vws_client: VWS) -> None: + """The number of images in the active state is returned.""" + wait_for_image_numbers( + vws_client=vws_client, + active_images=1, + inactive_images=0, + failed_images=0, + processing_images=0, + ) + + +def test_failed_images( + *, + image_file_failed_state: io.BytesIO, + vws_client: VWS, +) -> None: + """The number of images with a 'failed' status is returned.""" + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + + wait_for_image_numbers( + vws_client=vws_client, + active_images=0, + inactive_images=0, + failed_images=1, + processing_images=0, + ) + + +def test_inactive_images( + *, + vws_client: VWS, + image_file_success_state_low_rating: io.BytesIO, +) -> None: + """ + The number of images with a False active_flag and a 'success' + status is + returned. + """ + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_success_state_low_rating, + active_flag=False, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + + wait_for_image_numbers( + vws_client=vws_client, + active_images=0, + inactive_images=1, + failed_images=0, + processing_images=0, + ) + + +def test_inactive_failed( + *, + image_file_failed_state: io.BytesIO, + vws_client: VWS, +) -> None: + """An image with a 'failed' status does not show as inactive.""" + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_failed_state, + active_flag=False, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + + wait_for_image_numbers( + vws_client=vws_client, + active_images=0, + inactive_images=0, + failed_images=1, + processing_images=0, + ) + + +def test_deleted( + *, + image_file_failed_state: io.BytesIO, + vws_client: VWS, +) -> None: + """Deleted targets are not shown in the summary.""" + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.delete_target(target_id=target_id) + + wait_for_image_numbers( + vws_client=vws_client, + active_images=0, + inactive_images=0, + failed_images=0, + processing_images=0, + ) diff --git a/contract-tests/src/vws_mock_contract_tests/database_summary/contract/test_inactive_project.py b/contract-tests/src/vws_mock_contract_tests/database_summary/contract/test_inactive_project.py new file mode 100644 index 000000000..e42cf4534 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/database_summary/contract/test_inactive_project.py @@ -0,0 +1,10 @@ +"""Tests for inactive projects.""" + +from vws import VWS + + +def test_inactive_project( + inactive_vws_client: VWS, +) -> None: + """The project's active state does not affect the database summary.""" + _ = inactive_vws_client.get_database_summary_report() diff --git a/contract-tests/src/vws_mock_contract_tests/database_summary/contract/test_quotas.py b/contract-tests/src/vws_mock_contract_tests/database_summary/contract/test_quotas.py new file mode 100644 index 000000000..a77b891d2 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/database_summary/contract/test_quotas.py @@ -0,0 +1,17 @@ +"""Tests for quotas and thresholds.""" + +from vws import VWS + + +def test_quotas(vws_client: VWS) -> None: + """Quotas are included in the database summary. + + These match the quotas given for a free license. + """ + report = vws_client.get_database_summary_report() + expected_target_quota = 1000 + expected_request_quota = 100000 + expected_reco_threshold = 1000 + assert report.target_quota == expected_target_quota + assert report.request_quota == expected_request_quota + assert report.reco_threshold == expected_reco_threshold diff --git a/contract-tests/src/vws_mock_contract_tests/database_summary/contract/test_recos.py b/contract-tests/src/vws_mock_contract_tests/database_summary/contract/test_recos.py new file mode 100644 index 000000000..18c661687 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/database_summary/contract/test_recos.py @@ -0,0 +1,37 @@ +"""Tests for the recognition count fields.""" + +import io +import uuid + +from vws import VWS, CloudRecoService + + +def test_query_request( + *, + cloud_reco_client: CloudRecoService, + high_quality_image: io.BytesIO, + vws_client: VWS, +) -> None: + """Recognition counts update asynchronously after a query.""" + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id) + + report_before = vws_client.get_database_summary_report() + _ = cloud_reco_client.query(image=high_quality_image) + + report_after = vws_client.get_database_summary_report() + total_recos_change = report_after.total_recos - report_before.total_recos + current_month_recos_change = ( + report_after.current_month_recos - report_before.current_month_recos + ) + assert total_recos_change in {0, 1} + assert current_month_recos_change == total_recos_change + assert ( + report_before.previous_month_recos == report_after.previous_month_recos + ) diff --git a/contract-tests/src/vws_mock_contract_tests/database_summary/contract/test_request_usage.py b/contract-tests/src/vws_mock_contract_tests/database_summary/contract/test_request_usage.py new file mode 100644 index 000000000..bc9cc8671 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/database_summary/contract/test_request_usage.py @@ -0,0 +1,71 @@ +"""Tests for the ``request_usage`` field.""" + +import io +from http import HTTPStatus + +import pytest +from vws import VWS, CloudRecoService +from vws.exceptions.vws_exceptions import FailError + + +def test_target_request(vws_client: VWS) -> None: + """ + The ``request_usage`` count does not increase with each request + to the + target API. + """ + report = vws_client.get_database_summary_report() + original_request_usage = report.request_usage + + report = vws_client.get_database_summary_report() + new_request_usage = report.request_usage + assert new_request_usage == original_request_usage + + +def test_bad_target_request( + *, + high_quality_image: io.BytesIO, + vws_client: VWS, +) -> None: + """ + The ``request_usage`` count does not increase with each request + to the + target API, even if it is a bad request. + """ + report = vws_client.get_database_summary_report() + original_request_usage = report.request_usage + + with pytest.raises(expected_exception=FailError) as exc: + _ = vws_client.add_target( + name="example", + width=-1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + + assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST + + report = vws_client.get_database_summary_report() + new_request_usage = report.request_usage + assert new_request_usage == original_request_usage + + +def test_query_request( + *, + cloud_reco_client: CloudRecoService, + high_quality_image: io.BytesIO, + vws_client: VWS, +) -> None: + """ + The ``request_usage`` count does not increase with each + query. + """ + report = vws_client.get_database_summary_report() + original_request_usage = report.request_usage + _ = cloud_reco_client.query(image=high_quality_image) + report = vws_client.get_database_summary_report() + new_request_usage = report.request_usage + # The request usage goes up for the database summary request, not the + # query. + assert new_request_usage == original_request_usage diff --git a/tests/mock_vws/database_summary/helpers.py b/contract-tests/src/vws_mock_contract_tests/database_summary/helpers.py similarity index 97% rename from tests/mock_vws/database_summary/helpers.py rename to contract-tests/src/vws_mock_contract_tests/database_summary/helpers.py index 034a567ee..4574984fc 100644 --- a/tests/mock_vws/database_summary/helpers.py +++ b/contract-tests/src/vws_mock_contract_tests/database_summary/helpers.py @@ -9,7 +9,7 @@ from tenacity.wait import wait_fixed from vws import VWS -from tests.mock_vws.utils.retries import TRANSIENT_VWS_EXCEPTIONS +from vws_mock_contract_tests.utils.retries import TRANSIENT_VWS_EXCEPTIONS LOGGER = logging.getLogger(name=__name__) diff --git a/contract-tests/src/vws_mock_contract_tests/database_summary/test_database_summary.py b/contract-tests/src/vws_mock_contract_tests/database_summary/test_database_summary.py new file mode 100644 index 000000000..df7442342 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/database_summary/test_database_summary.py @@ -0,0 +1,39 @@ +"""Additional database summary tests.""" + +import io +import uuid + +import pytest +from vws import VWS + +from vws_mock_contract_tests.database_summary.helpers import ( + wait_for_image_numbers, +) + + +@pytest.mark.usefixtures("mock_only_vuforia") +def test_processing_images( + *, + vws_client: VWS, + image_file_success_state_low_rating: io.BytesIO, +) -> None: + """The number of images in the processing state is returned. + + The mock adapters report processing images immediately. Real Vuforia's + summaries are not reliable while targets are processing. + """ + _ = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_success_state_low_rating, + active_flag=True, + application_metadata=None, + ) + + wait_for_image_numbers( + vws_client=vws_client, + active_images=0, + inactive_images=0, + failed_images=0, + processing_images=1, + ) diff --git a/contract-tests/src/vws_mock_contract_tests/fixtures/__init__.py b/contract-tests/src/vws_mock_contract_tests/fixtures/__init__.py new file mode 100644 index 000000000..3ab52191a --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/fixtures/__init__.py @@ -0,0 +1 @@ +"""Common fixtures.""" diff --git a/tests/mock_vws/fixtures/api_contract.py b/contract-tests/src/vws_mock_contract_tests/fixtures/api_contract.py similarity index 98% rename from tests/mock_vws/fixtures/api_contract.py rename to contract-tests/src/vws_mock_contract_tests/fixtures/api_contract.py index 186e54994..f81d9600d 100644 --- a/tests/mock_vws/fixtures/api_contract.py +++ b/contract-tests/src/vws_mock_contract_tests/fixtures/api_contract.py @@ -20,9 +20,11 @@ ) from vws.reports import TargetStatusAndRecord, TargetStatuses -from tests.mock_vws.fixtures.credentials import CloudDatabaseCredentials -from tests.mock_vws.utils import Endpoint, ModelTargetEndpoint -from tests.mock_vws.utils.retries import ( +from vws_mock_contract_tests.fixtures.credentials import ( + CloudDatabaseCredentials, +) +from vws_mock_contract_tests.utils import Endpoint, ModelTargetEndpoint +from vws_mock_contract_tests.utils.retries import ( RETRY_ON_TARGET_NOT_YET_VISIBLE, TRANSIENT_VWS_EXCEPTIONS, retry_transient_vws_failures, diff --git a/contract-tests/src/vws_mock_contract_tests/fixtures/credentials.py b/contract-tests/src/vws_mock_contract_tests/fixtures/credentials.py new file mode 100644 index 000000000..3de02c7ac --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/fixtures/credentials.py @@ -0,0 +1,81 @@ +"""Credential data shared by API tests and backend adapters.""" + +from dataclasses import dataclass, field +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class CloudDatabaseCredentials(Protocol): + """Read-only cloud database access supplied by a backend adapter. + + Shared HTTP helpers need credentials and the database ID, without + depending on the mock's database state or storage implementation. + """ + + @property + def database_id(self) -> str: + """The identifier used in recognition-report URLs.""" + ... + + @property + def server_access_key(self) -> str: + """The access key used to sign VWS requests.""" + ... + + @property + def server_secret_key(self) -> str: + """The secret key used to sign VWS requests.""" + ... + + @property + def client_access_key(self) -> str: + """The access key used to sign VWQ requests.""" + ... + + @property + def client_secret_key(self) -> str: + """The secret key used to sign VWQ requests.""" + ... + + +@runtime_checkable +class NamedCloudDatabaseCredentials(CloudDatabaseCredentials, Protocol): + """Read-only credentials and a database name supplied by an + adapter. + """ + + @property + def database_name(self) -> str: + """The name used in summaries and backend recognition controls.""" + ... + + +@dataclass(frozen=True, kw_only=True) +class InactiveVuMarkCloudDatabase: + """Credentials for an inactive VuMark database.""" + + target_manager_database_name: str = field(repr=False) + server_access_key: str = field(repr=False) + server_secret_key: str = field(repr=False) + + +@dataclass(frozen=True, kw_only=True) +class VuMarkCloudDatabase: + """Credentials for the VuMark generation API.""" + + target_manager_database_name: str = field(repr=False) + server_access_key: str = field(repr=False) + server_secret_key: str = field(repr=False) + target_id: str = field(repr=False) + processing_target_id: str = field(repr=False) + + +@dataclass(frozen=True, kw_only=True) +class ModelTargetCredentials: + """Credentials and input data for the Model Target Web API.""" + + client_id: str = field(repr=False) + client_secret: str = field(repr=False) + cad_data_url: str = field(repr=False) + username: str = field(repr=False) + password: str = field(repr=False) diff --git a/contract-tests/src/vws_mock_contract_tests/fixtures/model_target_prepared_requests.py b/contract-tests/src/vws_mock_contract_tests/fixtures/model_target_prepared_requests.py new file mode 100644 index 000000000..f6fe31744 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/fixtures/model_target_prepared_requests.py @@ -0,0 +1,197 @@ +"""Fixtures which prepare Model Target Web API requests.""" + +import json +from http import HTTPMethod, HTTPStatus + +import pytest +import requests +from beartype import beartype +from pydantic import JsonValue, TypeAdapter + +from vws_mock_contract_tests.fixtures.credentials import ( + ModelTargetCredentials, +) +from vws_mock_contract_tests.utils import ModelTargetEndpoint +from vws_mock_contract_tests.utils.assertions import assert_model_target_status + +MODEL_TARGET_DATASET_UUID = "0b12466eee5d49409a440927006ff5d8" + +_DATASET_REQUEST: dict[str, JsonValue] = { + "name": "dataset-name", + "targetSdk": "10.18", + "models": [ + { + "name": "model-name", + "cadDataUrl": "https://example.com/model.glb", + "views": [ + { + "name": "view-name", + "guideViewPosition": { + "translation": [0, 0, 5], + "rotation": [0, 0, 0, 1], + }, + }, + ], + }, + ], +} + + +@beartype +def get_access_token( + *, + base_vws_url: str, + credentials: ModelTargetCredentials, +) -> str: + """Return an OAuth2 access token.""" + response = requests.post( + url=f"{base_vws_url}/oauth2/token", + auth=(credentials.client_id, credentials.client_secret), + data={"grant_type": "client_credentials"}, + timeout=30, + ) + + assert_model_target_status( + response=response, + status_codes=HTTPStatus.OK, + ) + response_json = TypeAdapter(type=dict[str, JsonValue]).validate_json( + response.text, + ) + access_token = response_json["access_token"] + assert isinstance(access_token, str) + assert response_json["token_type"] == "bearer" + return access_token + + +@beartype +def _create_dataset_endpoint( + *, base_vws_url: str, request_path: str +) -> ModelTargetEndpoint: + """Return details of a dataset creation endpoint.""" + content = json.dumps(obj=_DATASET_REQUEST).encode(encoding="utf-8") + headers = { + "Content-Length": str(object=len(content)), + "Content-Type": "application/json", + } + return ModelTargetEndpoint( + base_url=base_vws_url, + path_url=request_path, + method=HTTPMethod.POST, + headers=headers, + data=content, + takes_json_body=True, + ) + + +@beartype +def _get_endpoint( + *, base_vws_url: str, request_path: str +) -> ModelTargetEndpoint: + """Return details of a body-less ``GET`` endpoint.""" + return ModelTargetEndpoint( + base_url=base_vws_url, + path_url=request_path, + method=HTTPMethod.GET, + headers={}, + data=b"", + takes_json_body=False, + ) + + +@beartype +def _delete_endpoint( + *, base_vws_url: str, request_path: str +) -> ModelTargetEndpoint: + """Return details of a body-less ``DELETE`` endpoint.""" + return ModelTargetEndpoint( + base_url=base_vws_url, + path_url=request_path, + method=HTTPMethod.DELETE, + headers={"Content-Length": "0"}, + data=b"", + takes_json_body=False, + ) + + +@pytest.fixture +def create_standard_dataset(*, base_vws_url: str) -> ModelTargetEndpoint: + """Return details of the endpoint for creating a standard dataset.""" + return _create_dataset_endpoint( + base_vws_url=base_vws_url, request_path="/modeltargets/datasets" + ) + + +@pytest.fixture +def create_advanced_dataset(*, base_vws_url: str) -> ModelTargetEndpoint: + """Return details of the endpoint for creating an advanced dataset.""" + return _create_dataset_endpoint( + base_vws_url=base_vws_url, + request_path="/modeltargets/advancedDatasets", + ) + + +@pytest.fixture +def standard_dataset_status(*, base_vws_url: str) -> ModelTargetEndpoint: + """Return details of the standard dataset status endpoint.""" + return _get_endpoint( + base_vws_url=base_vws_url, + request_path=( + f"/modeltargets/datasets/{MODEL_TARGET_DATASET_UUID}/status" + ), + ) + + +@pytest.fixture +def advanced_dataset_status(*, base_vws_url: str) -> ModelTargetEndpoint: + """Return details of the advanced dataset status endpoint.""" + return _get_endpoint( + base_vws_url=base_vws_url, + request_path=( + f"/modeltargets/advancedDatasets/{MODEL_TARGET_DATASET_UUID}" + "/status" + ), + ) + + +@pytest.fixture +def download_standard_dataset(*, base_vws_url: str) -> ModelTargetEndpoint: + """Return details of the standard dataset download endpoint.""" + return _get_endpoint( + base_vws_url=base_vws_url, + request_path=( + f"/modeltargets/datasets/{MODEL_TARGET_DATASET_UUID}/dataset" + ), + ) + + +@pytest.fixture +def download_advanced_dataset(*, base_vws_url: str) -> ModelTargetEndpoint: + """Return details of the advanced dataset download endpoint.""" + return _get_endpoint( + base_vws_url=base_vws_url, + request_path=( + f"/modeltargets/advancedDatasets/{MODEL_TARGET_DATASET_UUID}" + "/dataset" + ), + ) + + +@pytest.fixture +def delete_standard_dataset(*, base_vws_url: str) -> ModelTargetEndpoint: + """Return details of the standard dataset deletion endpoint.""" + return _delete_endpoint( + base_vws_url=base_vws_url, + request_path=f"/modeltargets/datasets/{MODEL_TARGET_DATASET_UUID}", + ) + + +@pytest.fixture +def delete_advanced_dataset(*, base_vws_url: str) -> ModelTargetEndpoint: + """Return details of the advanced dataset deletion endpoint.""" + return _delete_endpoint( + base_vws_url=base_vws_url, + request_path=( + f"/modeltargets/advancedDatasets/{MODEL_TARGET_DATASET_UUID}" + ), + ) diff --git a/contract-tests/src/vws_mock_contract_tests/fixtures/plugin.py b/contract-tests/src/vws_mock_contract_tests/fixtures/plugin.py new file mode 100644 index 000000000..b755b3145 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/fixtures/plugin.py @@ -0,0 +1,36 @@ +"""Load shared API fixtures independently of backend adapters.""" + +import pytest +from beartype import beartype + +from vws_mock_contract_tests.utils.retries import TRANSIENT_VWS_EXCEPTIONS + +pytest_plugins = [ + "vws_mock_contract_tests.fixtures.prepared_requests", + "vws_mock_contract_tests.fixtures.model_target_prepared_requests", + "vws_mock_contract_tests.fixtures.api_contract", +] + + +@beartype +@pytest.hookimpl(optionalhook=True) +def pytest_set_filtered_exceptions() -> tuple[type[Exception], ...]: + """Return exceptions to retry on. + + This is for ``pytest-retry``. + The configuration for retries is in ``pyproject.toml``. + + ``pytest-retry`` treats this as an allowlist: a test which fails + with anything else is not retried, whatever ``retries`` is set to. + That is deliberate, because widening it to ``AssertionError`` would + retry every failing assertion in the suite ten times. + + The consequence is that a test which turns a response into an + ``AssertionError`` itself, rather than letting an exception out of + the client, is never retried here. The Model Target Web API tests + assert on ``requests.Response`` objects and so are in that + position; they get their own, narrower policy, applied before the + assertion, in + :py:mod:`vws_mock_contract_tests.utils.model_target_retries`. + """ + return TRANSIENT_VWS_EXCEPTIONS diff --git a/tests/mock_vws/fixtures/prepared_requests.py b/contract-tests/src/vws_mock_contract_tests/fixtures/prepared_requests.py similarity index 99% rename from tests/mock_vws/fixtures/prepared_requests.py rename to contract-tests/src/vws_mock_contract_tests/fixtures/prepared_requests.py index 785d90811..368f3258d 100644 --- a/tests/mock_vws/fixtures/prepared_requests.py +++ b/contract-tests/src/vws_mock_contract_tests/fixtures/prepared_requests.py @@ -12,11 +12,11 @@ from urllib3.filepost import encode_multipart_formdata from vws_auth_tools import authorization_header, rfc_1123_date -from tests.mock_vws.fixtures.credentials import ( +from vws_mock_contract_tests.fixtures.credentials import ( CloudDatabaseCredentials, VuMarkCloudDatabase, ) -from tests.mock_vws.utils import Endpoint +from vws_mock_contract_tests.utils import Endpoint @pytest.fixture diff --git a/contract-tests/src/vws_mock_contract_tests/model_target_web_api/__init__.py b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/__init__.py new file mode 100644 index 000000000..1adc44e80 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/__init__.py @@ -0,0 +1 @@ +"""Support for shared Vuforia API contract tests.""" diff --git a/contract-tests/src/vws_mock_contract_tests/model_target_web_api/assertions.py b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/assertions.py new file mode 100644 index 000000000..5ba4ae6d0 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/assertions.py @@ -0,0 +1,50 @@ +"""Assertions shared by Model Target Web API tests.""" + +import json +from http import HTTPStatus + +import requests +from beartype import beartype +from vws.response import Response + +from vws_mock_contract_tests.utils.assertions import assert_model_target_status + + +@beartype +def assert_oauth2_error( + *, + response: requests.Response, + status_code: HTTPStatus, + body: dict[str, str], +) -> None: + """Assert an OAuth2 error response.""" + assert_model_target_status( + response=response, + status_codes=status_code, + ) + assert response.json() == body + + +@beartype +def assert_model_target_error( + *, + response: Response, + status_code: HTTPStatus, + code: str, + message: str, + target: str, +) -> None: + """Assert a Model Target Web API error response with the legacy + shape. + """ + assert_model_target_status( + response=response, + status_codes=status_code, + ) + assert json.loads(s=response.text) == { + "error": { + "code": code, + "message": message, + "target": target, + }, + } diff --git a/contract-tests/src/vws_mock_contract_tests/py.typed b/contract-tests/src/vws_mock_contract_tests/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/contract-tests/src/vws_mock_contract_tests/utils/__init__.py b/contract-tests/src/vws_mock_contract_tests/utils/__init__.py new file mode 100644 index 000000000..dbf625820 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/utils/__init__.py @@ -0,0 +1,261 @@ +"""Utilities for tests.""" + +import io +import secrets +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Literal +from urllib.parse import urljoin + +import requests +from beartype import beartype +from PIL import Image +from requests.structures import CaseInsensitiveDict +from vws.response import Response + +from vws_mock_contract_tests.utils.model_target_retries import ( + send_with_transient_retries, +) +from vws_mock_contract_tests.utils.request_timeout_policy import ( + propagating_request_timeouts, +) + +_REQUEST_TIMEOUT_SECONDS = 5 + + +@beartype +def _send_request( + *, + method: str, + url: str, + headers: Mapping[str, str], + data: bytes | str, +) -> Response: + """Send a request with exactly the given headers.""" + request = requests.Request( + method=method, + url=url, + headers=headers, + data=data, + ) + prepared_request = request.prepare() + prepared_request.headers = CaseInsensitiveDict(data=headers) + session = requests.Session() + with propagating_request_timeouts(url=url): + requests_response = session.send( + request=prepared_request, + timeout=_REQUEST_TIMEOUT_SECONDS, + ) + return Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + + +@dataclass(frozen=True, kw_only=True) +class Endpoint: + """Details of endpoints to be called in tests. + + Args: + prepared_request: A request to make which would be successful. + successful_headers_result_code: The expected result code if the + example path is requested with the method. + successful_headers_status_code: The expected status code if the + example path is requested with the method. + access_key: The access key used in the prepared request. + secret_key: The secret key used in the prepared request. + path_url: The path of the endpoint. + base_url: The base URL of the endpoint. + + Attributes: + prepared_request: A request to make which would be successful. + successful_headers_result_code: The expected result code if the + example path is requested with the method. + successful_headers_status_code: The expected status code if the + example path is requested with the method. + access_key: The access key used in the prepared request. + secret_key: The secret key used in the prepared request. + path_url: The path of the endpoint. + base_url: The base URL of the endpoint. + """ + + base_url: str + path_url: str + method: str + headers: Mapping[str, str] + data: bytes | str + successful_headers_result_code: str | None + successful_headers_status_code: int + access_key: str + secret_key: str + + @beartype + def send(self) -> Response: + """Send the request.""" + return _send_request( + method=self.method, + url=urljoin(base=self.base_url, url=self.path_url), + headers=self.headers, + data=self.data, + ) + + @property + def auth_header_content_type(self) -> str: + """The content type to use for the `Authorization` header.""" + full_content_type = dict(self.headers).get("Content-Type", "") + return full_content_type.split(sep=";")[0] + + +@dataclass(frozen=True, kw_only=True) +class ModelTargetEndpoint: + """Details of Model Target Web API endpoints to be called in tests. + + Args: + base_url: The base URL of the endpoint. + path_url: The path of the endpoint. + method: The HTTP method of the endpoint. + headers: Headers to send to the endpoint. These do not include an + ``Authorization`` header; tests add a valid or invalid bearer + token themselves. + data: The body to send to the endpoint. + takes_json_body: Whether the endpoint reads a JSON request body. + + Attributes: + base_url: The base URL of the endpoint. + path_url: The path of the endpoint. + method: The HTTP method of the endpoint. + headers: Headers to send to the endpoint. These do not include an + ``Authorization`` header; tests add a valid or invalid bearer + token themselves. + data: The body to send to the endpoint. + takes_json_body: Whether the endpoint reads a JSON request body. + """ + + base_url: str + path_url: str + method: str + headers: Mapping[str, str] + data: bytes + takes_json_body: bool + + @beartype + def send(self) -> Response: + """Send the request. + + Safe requests use the retry policy supplied by the backend + adapter. See + :py:mod:`vws_mock_contract_tests.utils.model_target_retries`. + """ + return send_with_transient_retries( + method=self.method, + send=lambda: _send_request( + method=self.method, + url=urljoin(base=self.base_url, url=self.path_url), + headers=self.headers, + data=self.data, + ), + ) + + +@beartype +def make_image_file( + *, + file_format: str, + color_space: Literal["RGB", "CMYK"], + width: int, + height: int, +) -> io.BytesIO: + """Return an image file in the given format and color space. + + The image file is filled with randomly colored pixels. + + Args: + file_format: See + https://pillow.readthedocs.io/en/3.1.x/handbook/image-file-formats.html + color_space: One of "RGB", or "CMYK". + width: The width, in pixels of the image. + height: The width, in pixels of the image. + + Returns: + An image file in the given format and color space. + """ + image_buffer = io.BytesIO() + image = Image.new(mode=color_space, size=(width, height)) + for row_index in range(height): + for column_index in range(width): + red = secrets.choice(seq=range(255)) + green = secrets.choice(seq=range(255)) + blue = secrets.choice(seq=range(255)) + image.putpixel( + xy=(column_index, row_index), + value=(red, green, blue), + ) + + image.save(fp=image_buffer, format=file_format) + _ = image_buffer.seek(0) + return image_buffer + + +@beartype +def make_single_color_image_file(*, width: int, height: int) -> io.BytesIO: + """Return a greyscale PNG file of one color. + + A single color image compresses to a tiny file whatever its dimensions, so + this is a way to make an image with many pixels but a small file size. + + Args: + width: The width, in pixels, of the image. + height: The height, in pixels, of the image. + + Returns: + A greyscale PNG file of one color. + """ + image_buffer = io.BytesIO() + image = Image.new(mode="L", size=(width, height)) + image.save(fp=image_buffer, format="PNG") + _ = image_buffer.seek(0) + return image_buffer + + +@beartype +def make_truncated_png_file() -> io.BytesIO: + """Return a PNG file whose image data is cut off before the end. + + The file keeps a valid signature and header, so Pillow opens it, but the + ``IEND`` chunk which marks the end of the image is missing, so decoding + the image data fails. + + Returns: + A PNG file which is truncated before its ``IEND`` chunk. + """ + image = Image.frombytes(mode="RGB", size=(1, 1), data=b"\x01\x02\x03") + image_buffer = io.BytesIO() + image.save(fp=image_buffer, format="PNG", optimize=True) + image_bytes = image_buffer.getvalue() + # Remove the ``IEND`` chunk: its four length bytes, its four type bytes + # and its four checksum bytes. + truncated = image_bytes[: image_bytes.rindex(b"IEND") - 4] + return io.BytesIO(initial_bytes=truncated) + + +@beartype +def make_decompression_bomb_image_file() -> io.BytesIO: + """Return a PNG file which is tiny on disk but huge when decoded. + + The dimensions are within the maximum width and height accepted by the + Query API, and the file is well within the maximum file size, but the + pixel count is above the point at which Pillow refuses to open an image. + + Returns: + A PNG file which is a decompression bomb. + """ + # Pillow raises ``Image.DecompressionBombError`` for images with more than + # twice ``Image.MAX_IMAGE_PIXELS`` pixels, which is 178956970 pixels by + # default. + width = height = 15_000 + return make_single_color_image_file(width=width, height=height) diff --git a/contract-tests/src/vws_mock_contract_tests/utils/assertions.py b/contract-tests/src/vws_mock_contract_tests/utils/assertions.py new file mode 100644 index 000000000..191a466d5 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/utils/assertions.py @@ -0,0 +1,378 @@ +"""Assertion helpers.""" + +import copy +import datetime as dt +import email.utils +import json +from collections.abc import Set as AbstractSet +from http import HTTPStatus +from string import hexdigits +from zoneinfo import ZoneInfo + +import requests +from beartype import beartype +from vws.response import Response + +from vws_mock_contract_tests.utils.model_target_response_policy import ( + handle_unexpected_model_target_response, +) + + +@beartype +def assert_vws_failure( + *, + response: Response, + status_code: int, + result_code: str, +) -> None: + """Assert that a VWS failure response is as expected. + + Args: + response: The response returned by a request to VWS. + status_code: The expected status code of the response. + result_code: The expected result code of the response. + + Raises: + AssertionError: The response is not in the expected VWS error format + for the given codes. + """ + assert json.loads(s=response.text).keys() == { + "transaction_id", + "result_code", + } + assert_vws_response( + response=response, + status_code=status_code, + result_code=result_code, + ) + + +@beartype +def assert_vws_too_many_requests(*, response: Response) -> None: + """Assert that a response is the rate-limited response which real + Vuforia's Envoy layer gives. + + The response has no body and no ``Content-Type`` header. Real Vuforia + has an Envoy layer at its edge and another in front of the application, + and either may reject the request. Only a rejection by the inner layer + carries an ``x-envoy-upstream-service-time`` header. This was observed + against real Vuforia on 2026-09-08. + + Args: + response: The response returned by a request to VWS. + + Raises: + AssertionError: The response is not the rate-limited response. + """ + assert response.status_code == HTTPStatus.TOO_MANY_REQUESTS + assert response.text == "" + required_header_keys = { + "connection", + "content-length", + "date", + "server", + "strict-transport-security", + "x-aws-region", + "x-content-type-options", + "x-envoy-ratelimited", + } + optional_header_keys = {"x-envoy-upstream-service-time"} + response_header_keys = {str.lower(key) for key in response.headers} + assert required_header_keys <= response_header_keys + assert response_header_keys <= required_header_keys | optional_header_keys + assert response.headers["Content-Length"] == "0" + assert response.headers["server"] == "envoy" + assert response.headers["x-envoy-ratelimited"] == "true" + assert response.headers["x-content-type-options"] == "nosniff" + assert "-" in response.headers["x-aws-region"] + assert response.headers["strict-transport-security"] == "max-age=31536000" + assert_valid_date_header(response=response) + + +@beartype +def assert_valid_date_header( + *, + response: Response, +) -> None: + """Assert that a response includes a `Date` header which is within two + minutes of "now". + + Args: + response: The response returned by a request to a Vuforia service. + + Raises: + AssertionError: The response does not include a `Date` header which is + within one minute of "now". + """ + date_response = response.headers["Date"] + date_from_response = email.utils.parsedate(data=date_response) + assert date_from_response is not None + year, month, day, hour, minute, second, _, _, _ = date_from_response + gmt = ZoneInfo(key="GMT") + datetime_from_response = dt.datetime( + year=year, + month=month, + day=day, + hour=hour, + minute=minute, + second=second, + tzinfo=gmt, + ) + current_date = dt.datetime.now(tz=gmt) + time_difference = abs(current_date - datetime_from_response) + assert time_difference < dt.timedelta(minutes=2) + + +@beartype +def assert_valid_transaction_id( + *, + response: Response, +) -> None: + """Assert that a response includes a valid transaction ID. + + Args: + response: The response returned by a request to a Vuforia service. + + Raises: + AssertionError: The response does not include a valid transaction ID. + """ + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert isinstance(response_json["transaction_id"], str) + transaction_id: str = response_json["transaction_id"] + expected_transaction_id_length = 32 + assert len(transaction_id) == expected_transaction_id_length + assert all(char in hexdigits for char in transaction_id) + + +@beartype +def assert_json_separators(*, response: Response) -> None: + """Assert that a JSON response is formatted correctly. + + Args: + response: The response returned by a request to a Vuforia service. + + Raises: + AssertionError: The response JSON is not formatted correctly. + """ + assert response.text == json.dumps( + obj=json.loads(s=response.text), + separators=(",", ":"), + ) + + +@beartype +def assert_vws_response( + *, + response: Response, + status_code: int, + result_code: str, +) -> None: + """Assert that a VWS response is as expected, at least in part. + + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#result-codes + implies that the expected status code can be worked out from the result + code. However, this is not the case as the real results differ from the + documentation. + + For example, it is possible to get a "Fail" result code and a 400 error. + + Args: + response: The response returned by a request to VWS. + status_code: The expected status code of the response. + result_code: The expected result code of the response. + + Raises: + AssertionError: The response is not in the expected VWS format for the + given codes. + """ + assert response.status_code == status_code + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert isinstance(response_json["result_code"], str) + response_result_code: str = response_json["result_code"] + assert response_result_code == result_code + response_header_keys = { + "connection", + "content-length", + "content-type", + "date", + "server", + "strict-transport-security", + "x-aws-region", + "x-content-type-options", + "x-envoy-upstream-service-time", + } + assert {str.lower(key) for key in response.headers} == response_header_keys + assert response.headers["Content-Length"] == str(object=len(response.text)) + assert response.headers["Content-Type"] == "application/json" + assert response.headers["server"] == "envoy" + assert response.headers["x-content-type-options"] == "nosniff" + assert "-" in response.headers["x-aws-region"] + assert response.headers["strict-transport-security"] == "max-age=31536000" + assert int(response.headers["x-envoy-upstream-service-time"]) > 1 + assert response.headers["Connection"] == "keep-alive" + assert_json_separators(response=response) + assert_valid_transaction_id(response=response) + assert_valid_date_header(response=response) + + +@beartype +def assert_model_target_status( + *, + response: Response | requests.Response, + status_codes: HTTPStatus | AbstractSet[HTTPStatus], +) -> None: + """Assert the status code of a Model Target Web API response. + + An unexpected status reports the URL and body. A backend may supply + a response handler for restrictions of the service it runs against. + + Args: + response: The response returned by a request to the Model Target Web + API. + status_codes: The expected status code, or the status codes any of + which is expected. + + Raises: + AssertionError: The response does not have one of the expected status + codes and the backend handler has not handled the rejection. + """ + expected = ( + frozenset({status_codes}) + if isinstance(status_codes, HTTPStatus) + else frozenset(status_codes) + ) + if response.status_code in expected: + return + + handle_unexpected_model_target_response(response=response) + expected_description = " or ".join( + sorted(f"{item} {item.name}" for item in expected) + ) + message = ( + f"Expected {expected_description} from {response.url}, " + f"got {response.status_code}.\n\nResponse body:\n{response.text}" + ) + raise AssertionError(message) + + +@beartype +def assert_query_success(*, response: Response) -> None: + """Assert that the given response is a success response for performing + an + image recognition query. + + Raises: + AssertionError: The given response is not a valid success response + for performing an image recognition query. + """ + assert response.status_code == HTTPStatus.OK + assert json.loads(s=response.text).keys() == { + "result_code", + "results", + "query_id", + } + + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert isinstance(response_json["query_id"], str) + query_id: str = response_json["query_id"] + expected_query_id_length = 32 + assert len(query_id) == expected_query_id_length + assert all(char in hexdigits for char in query_id) + + assert json.loads(s=response.text)["result_code"] == "Success" + assert_valid_date_header(response=response) + copied_response_headers = response.headers.copy() + _ = copied_response_headers.pop("Date") + + # In the mock, all responses have the ``Content-Encoding`` ``gzip``. + # In the real Vuforia, some do and some do not. + # We are not sure why. + content_encoding = copied_response_headers.pop("Content-Encoding", None) + assert content_encoding in {None, "gzip"} + + expected_response_header_not_chunked = { + "Connection": "keep-alive", + "Content-Length": str(object=response.tell_position), + "Content-Type": "application/json", + "Server": "nginx", + } + + # The mock does not send chunked responses. + expected_response_header_chunked = { + "Connection": "keep-alive", + "Content-Type": "application/json", + "Server": "nginx", + "transfer-encoding": "chunked", + } + + assert copied_response_headers in ( + expected_response_header_chunked, + expected_response_header_not_chunked, + ) + + +@beartype +def assert_vwq_failure( + *, + response: Response, + status_code: int, + content_type: str | None, + cache_control: str | None, + www_authenticate: str | None, + connection: str, +) -> None: + """Assert that a VWQ failure response is as expected. + + Args: + response: The response returned by a request to VWQ. + content_type: The expected Content-Type header. + status_code: The expected status code. + cache_control: The expected Cache-Control header. + www_authenticate: The expected WWW-Authenticate header. + connection: The expected Connection header. + + Raises: + AssertionError: The response is not in the expected VWQ error format + for the given codes. + """ + assert response.status_code == status_code + response_header_keys = { + "Connection", + "Content-Length", + "Date", + "Server", + } + + if cache_control is not None: + response_header_keys.add("Cache-Control") + assert response.headers["Cache-Control"] == cache_control + + if content_type is not None: + response_header_keys.add("Content-Type") + assert response.headers["Content-Type"] == content_type + + if www_authenticate is not None: + response_header_keys.add("WWW-Authenticate") + assert response.headers["WWW-Authenticate"] == www_authenticate + + # Sometimes the "transfer-encoding" is given. + # It is not given by the mock. + response_header_keys_chunked = copy.copy(x=response_header_keys) + response_header_keys_chunked.remove("Content-Length") + response_header_keys_chunked.add("transfer-encoding") + + assert response.headers.keys() in ( + response_header_keys, + response_header_keys_chunked, + ) + assert response.headers.get("transfer-encoding", "chunked") == "chunked" + assert response.headers["Connection"] == connection + content_length = response.headers.get("Content-Length") + assert content_length is None or content_length == str( + object=len(response.text) + ) + assert_valid_date_header(response=response) + assert response.headers["Server"] == "nginx" diff --git a/contract-tests/src/vws_mock_contract_tests/utils/backend_capabilities.py b/contract-tests/src/vws_mock_contract_tests/utils/backend_capabilities.py new file mode 100644 index 000000000..efb3c2e4c --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/utils/backend_capabilities.py @@ -0,0 +1,29 @@ +"""Behavior declared by an adapter for the shared API tests.""" + +from dataclasses import dataclass + +from beartype import beartype + + +@beartype +@dataclass(frozen=True, kw_only=True) +class BackendCapabilities: + """The behavior and checks supported by the running service. + + ``deterministic_target_order`` covers upload-date ordering for target + lists and duplicate lists. ``deterministic_query_order`` covers that + ordering for matches to identical images. + + ``fast_oversized_content_length_response`` enables requests whose + declared body length exceeds their actual length. Services which wait + too long for the missing bytes cannot run that check. + + ``rolling_rate_limit_window`` enables the exact request-sequence + assertion for a fresh database. Services with a fixed clock window + still run the common rate-limit assertions. + """ + + deterministic_target_order: bool + deterministic_query_order: bool + fast_oversized_content_length_response: bool + rolling_rate_limit_window: bool diff --git a/contract-tests/src/vws_mock_contract_tests/utils/cloud_databases.py b/contract-tests/src/vws_mock_contract_tests/utils/cloud_databases.py new file mode 100644 index 000000000..d1bbff79b --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/utils/cloud_databases.py @@ -0,0 +1,20 @@ +"""The database creation operation supplied by a mock backend.""" + +from typing import Protocol, runtime_checkable + +from vws_mock_contract_tests.fixtures.credentials import ( + NamedCloudDatabaseCredentials, +) + + +@runtime_checkable +class CreateCloudDatabase(Protocol): + """A backend operation which creates a cloud database.""" + + def __call__(self) -> NamedCloudDatabaseCredentials: + """Create a database and return its name and credentials. + + Returns: + The name and credentials of the new database. + """ + ... diff --git a/contract-tests/src/vws_mock_contract_tests/utils/model_target_response_policy.py b/contract-tests/src/vws_mock_contract_tests/utils/model_target_response_policy.py new file mode 100644 index 000000000..caa013ec7 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/utils/model_target_response_policy.py @@ -0,0 +1,49 @@ +"""Backend-supplied handling of unexpected Model Target responses.""" + +import contextlib +from collections.abc import Generator +from typing import Protocol, runtime_checkable + +import requests +from beartype import beartype +from vws.response import Response + + +@runtime_checkable +class ModelTargetResponseHandler(Protocol): + """A backend operation for an unexpected Model Target response.""" + + def __call__(self, *, response: Response | requests.Response) -> None: + """Handle the response before the shared assertion reports it.""" + ... + + +_RESPONSE_HANDLERS: list[ModelTargetResponseHandler] = [] + + +@contextlib.contextmanager +@beartype +def handling_unexpected_model_target_responses( + *, + handler: ModelTargetResponseHandler, +) -> Generator[None]: + """Use the supplied response handler within this context. + + Yields: + ``None``, with the supplied handler active. + """ + _RESPONSE_HANDLERS.append(handler) + try: + yield + finally: + _ = _RESPONSE_HANDLERS.pop() + + +@beartype +def handle_unexpected_model_target_response( + *, + response: Response | requests.Response, +) -> None: + """Notify the backend before asserting an unexpected status.""" + if bool(_RESPONSE_HANDLERS): + _RESPONSE_HANDLERS[-1](response=response) diff --git a/contract-tests/src/vws_mock_contract_tests/utils/model_target_retries.py b/contract-tests/src/vws_mock_contract_tests/utils/model_target_retries.py new file mode 100644 index 000000000..62e6c80ce --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/utils/model_target_retries.py @@ -0,0 +1,84 @@ +"""Apply retry policies supplied by Model Target backend adapters. + +Requests are sent once by default. An adapter can install a scoped retry +policy for GET requests; mutating requests are always sent exactly once. +""" + +import contextlib +from collections.abc import Callable, Generator, Mapping +from http import HTTPMethod + +import requests +from beartype import beartype +from tenacity import Retrying +from vws.response import Response + +_RETRY_POLICIES: list[Retrying] = [] + + +@contextlib.contextmanager +@beartype +def retrying_model_target_requests(*, retrying: Retrying) -> Generator[None]: + """Use the supplied retry policy for safe requests within this context. + + Yields: + ``None``, with the supplied policy active. + """ + _RETRY_POLICIES.append(retrying) + try: + yield + finally: + _ = _RETRY_POLICIES.pop() + + +@beartype +def send_with_transient_retries[ResponseT: (Response, requests.Response)]( + *, + method: str, + send: Callable[[], ResponseT], +) -> ResponseT: + """Send a Model Target request, retrying transient failures. + + The request is sent exactly once unless it is a ``GET`` sent while + :py:func:`retrying_model_target_requests` is active. + + Args: + method: The HTTP method of the request. + send: A callable which sends the request and returns the + response. + + Returns: + The response to the last attempt. + """ + retryable = bool(_RETRY_POLICIES) and method == HTTPMethod.GET + if not retryable: + return send() + + return _RETRY_POLICIES[-1](send) + + +@beartype +def model_target_get( + *, + url: str, + headers: Mapping[str, str], + timeout: int, +) -> requests.Response: + """Send a ``GET`` request to the Model Target Web API. + + Args: + url: The URL to request. + headers: The headers to send. + timeout: The request timeout, in seconds. + + Returns: + The response to the last attempt. + """ + return send_with_transient_retries( + method=HTTPMethod.GET, + send=lambda: requests.get( + url=url, + headers=dict(headers), + timeout=timeout, + ), + ) diff --git a/contract-tests/src/vws_mock_contract_tests/utils/recognition_counts.py b/contract-tests/src/vws_mock_contract_tests/utils/recognition_counts.py new file mode 100644 index 000000000..42c67256e --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/utils/recognition_counts.py @@ -0,0 +1,35 @@ +"""The recognition-count operation supplied by a mock backend. + +Neither mock counts recognitions, because real Vuforia's counts lag behind +its queries by longer than a test runs. Each mock instead has its own way of +being told what the counts are. +""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class SeedRecognitionCounts(Protocol): + """A backend operation which sets recognition counts on a target.""" + + def __call__( + self, + *, + database_name: str, + target_id: str, + current_month_recos: int, + previous_month_recos: int, + total_recos: int, + ) -> None: + """Set the target's recognition counts on the running mock backend. + + Args: + database_name: The name of the database containing the target. + target_id: The ID of the target to set recognition counts of. + current_month_recos: The number of recognitions in the current + month. + previous_month_recos: The number of recognitions in the previous + month. + total_recos: The total number of recognitions of the target. + """ + ... diff --git a/contract-tests/src/vws_mock_contract_tests/utils/request_timeout_policy.py b/contract-tests/src/vws_mock_contract_tests/utils/request_timeout_policy.py new file mode 100644 index 000000000..a7fd6f317 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/utils/request_timeout_policy.py @@ -0,0 +1,53 @@ +"""Backend-supplied handling of request timeouts.""" + +import contextlib +from collections.abc import Generator +from typing import Protocol, runtime_checkable + +from beartype import beartype +from requests.exceptions import Timeout as RequestsTimeout + + +@runtime_checkable +class RequestTimeoutHandler(Protocol): + """A backend operation for a request which timed out.""" + + def __call__(self, *, url: str, error: RequestsTimeout) -> None: + """Handle a timeout before the shared sender propagates it.""" + ... + + +_TIMEOUT_HANDLERS: list[RequestTimeoutHandler] = [] + + +@contextlib.contextmanager +@beartype +def handling_request_timeouts( + *, handler: RequestTimeoutHandler +) -> Generator[None]: + """Use the supplied timeout handler within this context. + + Yields: + ``None``, with the supplied handler active. + """ + _TIMEOUT_HANDLERS.append(handler) + try: + yield + finally: + _ = _TIMEOUT_HANDLERS.pop() + + +@contextlib.contextmanager +@beartype +def propagating_request_timeouts(*, url: str) -> Generator[None]: + """Apply the active policy to timeouts raised by a request. + + Yields: + ``None``, while the request runs. + """ + try: + yield + except RequestsTimeout as error: + if bool(_TIMEOUT_HANDLERS): + _TIMEOUT_HANDLERS[-1](url=url, error=error) + raise diff --git a/contract-tests/src/vws_mock_contract_tests/utils/retries.py b/contract-tests/src/vws_mock_contract_tests/utils/retries.py new file mode 100644 index 000000000..2ebd3d5a7 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/utils/retries.py @@ -0,0 +1,114 @@ +"""Helpers for retrying requests to VWS.""" + +import contextlib +from collections.abc import Callable, Generator +from contextvars import ContextVar +from functools import wraps + +from beartype import beartype +from requests.exceptions import ConnectionError as RequestsConnectionError +from requests.exceptions import ConnectTimeout as RequestsConnectTimeout +from requests.exceptions import ReadTimeout as RequestsReadTimeout +from requests.exceptions import Timeout as RequestsTimeout +from tenacity import Retrying, retry +from tenacity.retry import retry_if_exception_type +from tenacity.stop import stop_after_attempt +from tenacity.wait import wait_fixed +from vws.exceptions.custom_exceptions import ServerError +from vws.exceptions.vws_exceptions import ( + TargetStatusProcessingError, + TooManyRequestsError, + UnknownTargetError, +) + +# ``pytest-retry`` checks whether the type of the exception which failed a +# test is *in* this tuple, so a subclass of a listed type is not retried. +# ``requests`` raises the derived ``Timeout`` exceptions below, never +# ``Timeout`` +# itself, so each one is listed. ``Timeout`` and ``ConnectionError`` stay +# for the ``tenacity`` retries, which do use ``isinstance``. +TRANSIENT_VWS_EXCEPTIONS = ( + TooManyRequestsError, + ServerError, + RequestsTimeout, + RequestsReadTimeout, + RequestsConnectTimeout, + RequestsConnectionError, +) +TRANSIENT_VWS_RETRY_ATTEMPTS = 10 + +# Shared helpers send once unless an adapter supplies a retry policy. +# ContextVar requires a positional name at runtime. +_VWS_RETRYING: ContextVar[Retrying | None] = ContextVar( # type: ignore[call-arg] + "vws_retrying", + default=None, +) + + +@contextlib.contextmanager +@beartype +def retrying_vws_requests(*, retrying: Retrying) -> Generator[None]: + """Install an adapter's fixture retry policy for this scope. + + Yields: + ``None``, with the policy active and its predecessor restored on exit. + """ + token = _VWS_RETRYING.set(retrying) + try: + yield + finally: + _VWS_RETRYING.reset(token) + + +@beartype +def retry_transient_vws_failures[**P, T]( + operation: Callable[P, T], +) -> Callable[P, T]: + """Apply the adapter's policy without multiplying nested retry budgets.""" + + @wraps(wrapped=operation) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> T: + """Run an operation under at most one transient retry loop.""" + retrying = _VWS_RETRYING.get() + if retrying is None: + return operation(*args, **kwargs) + token = _VWS_RETRYING.set(None) + try: + return retrying.copy()(operation, *args, **kwargs) + finally: + _VWS_RETRYING.reset(token) + + return wrapped + + +# Real Vuforia sometimes reports a target which it has just created as +# unknown, because the target is not yet visible to the read path. +# +# This is deliberately kept out of ``TRANSIENT_VWS_EXCEPTIONS``. That tuple +# also decides which failures ``pytest-retry`` retries during tests, and an +# ``UnknownTargetError`` which a test did not expect is a result worth +# seeing rather than retrying away. Only code which has just been given a +# target's ID by Vuforia, and so knows the target exists, may use this. +RETRY_ON_TARGET_NOT_YET_VISIBLE = retry( + retry=retry_if_exception_type(exception_types=UnknownTargetError), + stop=stop_after_attempt(max_attempt_number=TRANSIENT_VWS_RETRY_ATTEMPTS), + wait=wait_fixed(wait=2), + reraise=True, +) + + +# Real Vuforia sometimes reports a target which has just been updated as +# still having its old status for a moment, so waiting for the target to be +# processed can return before the update has put the target back into +# processing. A request which then needs the target to be processed fails. +# +# Like ``RETRY_ON_TARGET_NOT_YET_VISIBLE``, this is kept out of +# ``TRANSIENT_VWS_EXCEPTIONS`` so that a test which does not expect a +# processing target sees the failure rather than retrying it away. Only code +# which has just waited for the target to be processed may use this. +RETRY_ON_TARGET_STILL_PROCESSING = retry( + retry=retry_if_exception_type(exception_types=TargetStatusProcessingError), + stop=stop_after_attempt(max_attempt_number=TRANSIENT_VWS_RETRY_ATTEMPTS), + wait=wait_fixed(wait=2), + reraise=True, +) diff --git a/contract-tests/src/vws_mock_contract_tests/utils/too_many_requests.py b/contract-tests/src/vws_mock_contract_tests/utils/too_many_requests.py new file mode 100644 index 000000000..c35cb54d1 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/utils/too_many_requests.py @@ -0,0 +1,35 @@ +"""Helpers for handling too many requests errors.""" + +from http import HTTPStatus + +from beartype import beartype +from vws.exceptions.custom_exceptions import ServerError +from vws.exceptions.vws_exceptions import TooManyRequestsError +from vws.response import Response + + +@beartype +def handle_server_errors(*, response: Response) -> None: + """Raise errors if the response is a 429 or 5xx. This is useful for + retrying tests based on the exceptions they raise. + + Raises: + vws.exceptions.vws_exceptions.TooManyRequestsError: The response is a + 429. + vws.exceptions.custom_exceptions.ServerError: The response is a 5xx. + """ + # We do not cover this because in some test runs we will not hit the + # error. + if ( + response.status_code == HTTPStatus.TOO_MANY_REQUESTS + ): # pragma: no cover + # The Vuforia API returns a 429 response with no JSON body. + # We raise this here to prompt a retry at a higher level. + raise TooManyRequestsError(response=response) + + # We do not cover this because in some test runs we will not hit the + # error. + if ( + response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR + ): # pragma: no cover + raise ServerError(response=response) diff --git a/contract-tests/src/vws_mock_contract_tests/utils/unrouted_requests.py b/contract-tests/src/vws_mock_contract_tests/utils/unrouted_requests.py new file mode 100644 index 000000000..09a7dec9f --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/utils/unrouted_requests.py @@ -0,0 +1,55 @@ +"""The transport for requests which no VWS route serves.""" + +from dataclasses import dataclass +from http import HTTPMethod +from typing import Protocol, runtime_checkable + +from beartype import beartype + + +@beartype +@dataclass(frozen=True, kw_only=True) +class UnroutedResponse: + """The parts of a response to a request which no route serves.""" + + status_code: int + body: bytes + content_type: str | None + content_encoding: str | None + upstream_service_time: str | None + + +@runtime_checkable +class SendUnroutedRequest(Protocol): + """A backend operation which sends a request outside its routes.""" + + def __call__( + self, + *, + method: HTTPMethod, + request_path: str, + headers: dict[str, str], + ) -> UnroutedResponse | None: + """Send the request and return its response, or ``None`` if + the connection is refused. + + Args: + method: The HTTP method to send. + request_path: The path to request on the VWS service. + headers: The request headers, including its authorization. + """ + ... + + +@beartype +@dataclass(frozen=True, kw_only=True) +class UnroutedRequests: + """The request operation and expected transport behavior. + + ``returns_response`` says whether the transport serves unmatched + requests. A selective HTTP mock refuses them; a server returns a + response. Shared tests assert the declared behavior in either case. + """ + + send: SendUnroutedRequest + returns_response: bool diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst index 19913d8b3..a938274d0 100644 --- a/docs/source/contributing.rst +++ b/docs/source/contributing.rst @@ -133,7 +133,7 @@ Give an option once per backend or marker to skip. Shared database credentials --------------------------- -The shared API client fixtures, raw query helpers and recognition-report helpers accept ``CloudDatabaseCredentials`` from ``tests.mock_vws.fixtures.credentials``. +The shared API client fixtures, raw query helpers and recognition-report helpers accept ``CloudDatabaseCredentials`` from ``vws_mock_contract_tests.fixtures.credentials``. An adapter supplies an object with read-only string attributes ``server_access_key``, ``server_secret_key``, ``client_access_key``, ``client_secret_key`` and ``database_id``. The helpers use those values for signing HTTP requests and constructing report URLs. They do not require a ``mock_vws.database.CloudDatabase`` object or access to its targets and storage state. @@ -160,12 +160,19 @@ The adapter handles cleanup after the test. Shared fixtures depend on these public setup fixtures with a ``None`` annotation. Backend selection values stay inside the adapter. -Adapters declare supported behavior through ``BackendCapabilities`` from ``tests.mock_vws.utils.backend_capabilities``. +Adapters declare supported behavior through ``BackendCapabilities`` from ``vws_mock_contract_tests.utils.backend_capabilities``. Shared tests use those values for ordering, rate-limit windows and transport checks. -The ``seed_recognition_counts`` operation accepts a database name, a target ID and recognition counts through ``SeedRecognitionCounts`` from ``tests.mock_vws.utils.recognition_counts``. -The ``unrouted_requests`` fixture supplies ``UnroutedRequests`` from ``tests.mock_vws.utils.unrouted_requests``, including whether unmatched requests produce an HTTP response. +The ``seed_recognition_counts`` operation accepts a database name, a target ID and recognition counts through ``SeedRecognitionCounts`` from ``vws_mock_contract_tests.utils.recognition_counts``. +The ``unrouted_requests`` fixture supplies ``UnroutedRequests`` from ``vws_mock_contract_tests.utils.unrouted_requests``, including whether unmatched requests produce an HTTP response. These controls keep backend operations and transport differences behind the adapter's fixture contract. +Installed contract tests +------------------------ + +The ``contract-tests`` subproject builds ``vws-mock-contract-tests`` with an explicit shared fixture plugin. +The ``test-backends`` subproject builds the core repository's adapter plugin separately. +See :file:`contract-tests/README.rst` for installation, invocation and revision compatibility. + Container deployment tests -------------------------- diff --git a/prek.toml b/prek.toml index 61c1559cf..4d4af51cb 100644 --- a/prek.toml +++ b/prek.toml @@ -318,7 +318,7 @@ hooks = [ { id = "pylint", name = "pylint", - entry = "uv run --group=dev -m pylint *.py src/ tests/ docs/ ci/ admin/", + entry = "uv run --group=dev -m pylint *.py src/ tests/ contract-tests/src/ test-backends/src/ docs/ ci/ admin/", language = "python", stages = ["manual"], pass_filenames = false, @@ -380,7 +380,7 @@ hooks = [ { id = "strict-kwargs-fix", name = "strict-kwargs", - entry = "uv run --group=dev strict-kwargs check --fix", + entry = "uv run --group=dev strict-kwargs check --fix --project-root .", language = "python", types_or = ["python"], additional_dependencies = ["uv==0.13.0"], diff --git a/pyproject.toml b/pyproject.toml index 4b28522fa..1e1ee2541 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -131,6 +131,8 @@ dev = [ "urllib3==2.8.0", "vale==3.22.0.0", "vulture==2.16", + "vws-mock-contract-tests==0.0.0", + "vws-mock-test-backends==0.0.0", "vws-python==2026.10.9", "vws-test-fixtures==2026.10.8", "vws-web-tools==2026.8.27", @@ -162,8 +164,15 @@ version_scheme = "post-release" # This must be a PEP 440 compliant version. fallback_version = "0.0.0" +[tool.uv] +sources.vws-mock-contract-tests = { workspace = true } +sources.vws-mock-test-backends = { workspace = true } +sources.vws-python-mock = { workspace = true } +workspace.members = [ "contract-tests", "test-backends" ] + [tool.ruff] line-length = 79 +src = [ "contract-tests/src", "src", "test-backends/src" ] lint.select = [ "ALL", ] @@ -191,6 +200,7 @@ lint.ignore = [ "TC003", ] lint.explicit-preview-rules = true +lint.per-file-ignores."contract-tests/src/**" = [ "S101", "S105", "S106" ] lint.per-file-ignores."doccmd_*.py" = [ # Allow our chosen docstring line-style - pydocstringformatter handles # formatting but docstrings in docs may not match this style. @@ -198,6 +208,7 @@ lint.per-file-ignores."doccmd_*.py" = [ # Allow asserts in docs. "S101", ] +lint.per-file-ignores."test-backends/src/**" = [ "S101", "S105", "S106" ] lint.per-file-ignores."tests/**" = [ # Allow asserts in tests. "S101", @@ -500,13 +511,37 @@ log_cli = true markers = [ "requires_docker_build", ] +# Legacy import modules keep the existing CI partitions stable. +norecursedirs = [ "contract-tests", "test-backends" ] # Options for pytest-retry. retries = "10" retry_delay = "10" xfail_strict = true [tool.coverage] -run.source = [ "ci/", "src/", "tests/" ] +run.source = [ "ci/", "contract-tests/src/", "src/", "test-backends/src/", "tests/" ] +# Import-only compatibility modules contain no assertions or backend logic. +# Their authoritative implementations remain subject to full coverage. +run.omit = [ + "tests/mock_vws/fixtures/credentials.py", + "tests/mock_vws/fixtures/model_target_prepared_requests.py", + "tests/mock_vws/fixtures/plugin.py", + "tests/mock_vws/fixtures/real_credentials.py", + "tests/mock_vws/fixtures/real_model_target_retries.py", + "tests/mock_vws/fixtures/vuforia_backends.py", + "tests/mock_vws/model_target_web_api/assertions.py", + "tests/mock_vws/utils/__init__.py", + "tests/mock_vws/utils/assertions.py", + "tests/mock_vws/utils/backend_capabilities.py", + "tests/mock_vws/utils/cloud_databases.py", + "tests/mock_vws/utils/model_target_response_policy.py", + "tests/mock_vws/utils/model_target_retries.py", + "tests/mock_vws/utils/recognition_counts.py", + "tests/mock_vws/utils/request_timeout_policy.py", + "tests/mock_vws/utils/retries.py", + "tests/mock_vws/utils/too_many_requests.py", + "tests/mock_vws/utils/unrouted_requests.py", +] run.branch = true run.parallel = true run.relative_files = true diff --git a/test-backends/LICENSE b/test-backends/LICENSE new file mode 100644 index 000000000..c9f18d1a3 --- /dev/null +++ b/test-backends/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Adam Dangoor + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/test-backends/README.rst b/test-backends/README.rst new file mode 100644 index 000000000..f21412161 --- /dev/null +++ b/test-backends/README.rst @@ -0,0 +1,15 @@ +VWS mock test backends +====================== + +This development package supplies the core repository's explicit pytest adapter plugin. +It supports the in-memory mock, Flask in process and real Vuforia. +The shared contract package does not load it automatically. + +Load ``vws_mock_test_backends.vuforia_backends`` with pytest's ``-p`` option. +Use ``--skip-backend=real`` for runs without Vuforia credentials. +The mock adapters generate synthetic credentials. +Real credentials are read from the environment or ``vuforia_secrets.env`` in the working directory. + +Install this package, ``vws-mock-contract-tests`` and the core library from the same repository commit. +The initial adapter package version is ``0.0.0``. +Server repositories can provide their own adapter plugin instead of installing this package. diff --git a/test-backends/pyproject.toml b/test-backends/pyproject.toml new file mode 100644 index 000000000..bd25570d5 --- /dev/null +++ b/test-backends/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +build-backend = "setuptools.build_meta" +requires = [ "setuptools" ] + +[project] +name = "vws-mock-test-backends" +version = "0.0.0" +description = "Explicit pytest adapters for the VWS mock contract tests." +readme = "README.rst" +license = "MIT" +license-files = [ "LICENSE" ] +authors = [ { name = "Adam Dangoor", email = "adamdangoor@gmail.com" } ] +requires-python = ">=3.15" +classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.15", +] +dependencies = [ + "beartype>=0.23.1", + "pydantic>=2.6", + "pydantic-settings>=2.6.1", + "pytest>=9.1.1", + "pytest-multi-backend>=2026.9.7", + "requests>=2.32.3", + "requests-mock-flask>=2026.10.9", + "responses>=0.25.3", + "tenacity>=9.2.1", + "urllib3>=2.8", + "vws-mock-contract-tests==0", + "vws-python>=2026.10.9", + "vws-python-mock", +] +urls.Source = "https://github.com/VWS-Python/vws-python-mock" + +[tool.setuptools] +packages.find.where = [ "src" ] +package-data.vws_mock_test_backends = [ "py.typed" ] diff --git a/test-backends/src/vws_mock_test_backends/__init__.py b/test-backends/src/vws_mock_test_backends/__init__.py new file mode 100644 index 000000000..1adc44e80 --- /dev/null +++ b/test-backends/src/vws_mock_test_backends/__init__.py @@ -0,0 +1 @@ +"""Support for shared Vuforia API contract tests.""" diff --git a/test-backends/src/vws_mock_test_backends/py.typed b/test-backends/src/vws_mock_test_backends/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/test-backends/src/vws_mock_test_backends/real_credentials.py b/test-backends/src/vws_mock_test_backends/real_credentials.py new file mode 100644 index 000000000..09d1d2620 --- /dev/null +++ b/test-backends/src/vws_mock_test_backends/real_credentials.py @@ -0,0 +1,164 @@ +"""Load credentials for the explicit real-Vuforia backend.""" + +from pathlib import Path +from uuid import uuid4 + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +from mock_vws.database import CloudDatabase +from mock_vws.states import States +from vws_mock_contract_tests.fixtures.credentials import ( + InactiveVuMarkCloudDatabase, + ModelTargetCredentials, + VuMarkCloudDatabase, +) + + +class _CloudDatabaseSettings(BaseSettings): + """Settings for a Vuforia database.""" + + target_manager_database_name: str + server_access_key: str + server_secret_key: str + client_access_key: str + client_secret_key: str + + model_config = SettingsConfigDict( + env_prefix="VUFORIA_", + env_file=Path("vuforia_secrets.env"), + extra="allow", + ) + + +class _WorkingCloudDatabaseSettings(_CloudDatabaseSettings): + """Settings for the working Vuforia database. + + Only the working database has an ID, because only endpoints which name + a database in their path need one. + """ + + database_id: str + + +class _InactiveCloudDatabaseSettings(_CloudDatabaseSettings): + """Settings for an inactive Vuforia database.""" + + model_config = SettingsConfigDict( + env_prefix="INACTIVE_VUFORIA_", + env_file=Path("vuforia_secrets.env"), + extra="allow", + ) + + +class _InactiveVuMarkDatabaseSettings(BaseSettings): + """Settings for an inactive VuMark database.""" + + target_manager_database_name: str + server_access_key: str + server_secret_key: str + + model_config = SettingsConfigDict( + env_prefix="INACTIVE_VUMARK_VUFORIA_", + env_file=Path("vuforia_secrets.env"), + extra="allow", + ) + + +class _VuMarkCloudDatabaseSettings(BaseSettings): + """Settings for a VuMark Vuforia database.""" + + target_manager_database_name: str + server_access_key: str + server_secret_key: str + target_id: str + processing_target_id: str = Field(default_factory=lambda: uuid4().hex) + + model_config = SettingsConfigDict( + env_prefix="VUMARK_VUFORIA_", + env_file=Path("vuforia_secrets.env"), + extra="allow", + ) + + +class _ModelTargetSettings(BaseSettings): + """Settings for the Model Target Web API.""" + + client_id: str + client_secret: str + cad_data_url: str + username: str + password: str + + model_config = SettingsConfigDict( + env_prefix="MODEL_TARGET_VUFORIA_", + env_file=Path("vuforia_secrets.env"), + extra="allow", + ) + + +def get_model_target_credentials() -> ModelTargetCredentials: + """Return Model Target Web API credentials from environment + variables. + """ + settings = _ModelTargetSettings.model_validate(obj={}) + return ModelTargetCredentials( + client_id=settings.client_id, + client_secret=settings.client_secret, + cad_data_url=settings.cad_data_url, + username=settings.username, + password=settings.password, + ) + + +def get_real_cloud_database() -> CloudDatabase: + """Return VWS credentials from environment variables.""" + settings = _WorkingCloudDatabaseSettings.model_validate(obj={}) + return CloudDatabase( + database_id=settings.database_id, + database_name=settings.target_manager_database_name, + server_access_key=settings.server_access_key, + server_secret_key=settings.server_secret_key, + client_access_key=settings.client_access_key, + client_secret_key=settings.client_secret_key, + state=States.WORKING, + ) + + +def get_real_inactive_cloud_database() -> CloudDatabase: + """ + Return VWS credentials for an inactive project from environment + variables. + """ + settings = _InactiveCloudDatabaseSettings.model_validate(obj={}) + return CloudDatabase( + database_name=settings.target_manager_database_name, + server_access_key=settings.server_access_key, + server_secret_key=settings.server_secret_key, + client_access_key=settings.client_access_key, + client_secret_key=settings.client_secret_key, + state=States.PROJECT_INACTIVE, + ) + + +def get_real_inactive_vumark_database() -> InactiveVuMarkCloudDatabase: + """Return inactive VuMark credentials from environment variables.""" + settings = _InactiveVuMarkDatabaseSettings.model_validate(obj={}) + return InactiveVuMarkCloudDatabase( + target_manager_database_name=settings.target_manager_database_name, + server_access_key=settings.server_access_key, + server_secret_key=settings.server_secret_key, + ) + + +def get_real_vumark_database() -> VuMarkCloudDatabase: + """Return VuMark VWS credentials from environment variables.""" + settings = _VuMarkCloudDatabaseSettings.model_validate(obj={}) + + return VuMarkCloudDatabase( + target_manager_database_name=settings.target_manager_database_name, + server_access_key=settings.server_access_key, + server_secret_key=settings.server_secret_key, + target_id=settings.target_id, + processing_target_id=settings.processing_target_id, + ) diff --git a/test-backends/src/vws_mock_test_backends/real_model_target_retries.py b/test-backends/src/vws_mock_test_backends/real_model_target_retries.py new file mode 100644 index 000000000..8271f423a --- /dev/null +++ b/test-backends/src/vws_mock_test_backends/real_model_target_retries.py @@ -0,0 +1,84 @@ +"""Retrying transient failures from the real Model Target Web API. + +The real Model Target Web API sits behind a load balancer which +occasionally answers a perfectly good request with a gateway error, or +drops the connection. That has nothing to do with the contract under +test, so a bounded number of retries for requests which are safe to +repeat keeps a one-off gateway error from failing CI. + +Only requests to the real backend are retried. The mock backends +answer immediately, and some tests configure a mock to return a 5xx +deliberately, so retrying those would be both pointless and wrong. +The policy is therefore switched on for the duration of a test against +the real Model Target backend and is off at all other times. + +Only ``GET`` requests are retried. Repeating a dataset creation +``POST`` can create a second dataset and can consume the account's +Model Target training allowance, and the outcome of a repeat is +ambiguous, so mutating requests are sent exactly once. +""" + +from http import HTTPStatus + +import requests +from beartype import beartype +from tenacity import RetryCallState, Retrying +from tenacity.retry import retry_if_exception_type, retry_if_result +from tenacity.stop import stop_after_attempt +from tenacity.wait import wait_exponential_jitter +from vws.response import Response + +# Status codes which mean "the gateway could not get an answer from +# Vuforia", rather than "Vuforia answered, and this is the answer". +TRANSIENT_MODEL_TARGET_STATUS_CODES = frozenset( + { + HTTPStatus.BAD_GATEWAY, + HTTPStatus.SERVICE_UNAVAILABLE, + HTTPStatus.GATEWAY_TIMEOUT, + }, +) + +# Transport failures which mean the request may never have been +# answered. +TRANSIENT_MODEL_TARGET_EXCEPTIONS = ( + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, +) + +# The total number of times a safe request is sent, including the first +# attempt. This is deliberately small: a persistent upstream failure +# must still fail the test quickly. +MODEL_TARGET_RETRY_ATTEMPTS = 3 + + +@beartype +def _is_transient(response: Response | requests.Response) -> bool: + """Whether a response is a transient gateway failure.""" + return response.status_code in TRANSIENT_MODEL_TARGET_STATUS_CODES + + +@beartype +def _last_outcome(retry_state: RetryCallState) -> Response | requests.Response: + """Give up with the outcome of the last attempt. + + A transient failure on the last attempt is not hidden: a response is + returned as it is, so that the caller's assertion reports the real + status and body, and a transport failure propagates. + """ + assert retry_state.outcome is not None + outcome = retry_state.outcome.result() + assert isinstance(outcome, Response | requests.Response) + return outcome + + +REAL_MODEL_TARGET_RETRY_POLICY = Retrying( + retry=( + retry_if_exception_type( + exception_types=TRANSIENT_MODEL_TARGET_EXCEPTIONS, + ) + | retry_if_result(predicate=_is_transient) + ), + stop=stop_after_attempt(max_attempt_number=MODEL_TARGET_RETRY_ATTEMPTS), + wait=wait_exponential_jitter(initial=1, max=10), + retry_error_callback=_last_outcome, +) diff --git a/tests/mock_vws/fixtures/real_vws_retries.py b/test-backends/src/vws_mock_test_backends/real_vws_retries.py similarity index 98% rename from tests/mock_vws/fixtures/real_vws_retries.py rename to test-backends/src/vws_mock_test_backends/real_vws_retries.py index 9ebe1c649..cce308fa9 100644 --- a/tests/mock_vws/fixtures/real_vws_retries.py +++ b/test-backends/src/vws_mock_test_backends/real_vws_retries.py @@ -20,7 +20,7 @@ from urllib3.util import Retry from vws.exceptions.vws_exceptions import TooManyRequestsError -from tests.mock_vws.utils.retries import ( +from vws_mock_contract_tests.utils.retries import ( TRANSIENT_VWS_EXCEPTIONS, TRANSIENT_VWS_RETRY_ATTEMPTS, ) diff --git a/test-backends/src/vws_mock_test_backends/vuforia_backends.py b/test-backends/src/vws_mock_test_backends/vuforia_backends.py new file mode 100644 index 000000000..596fc56d3 --- /dev/null +++ b/test-backends/src/vws_mock_test_backends/vuforia_backends.py @@ -0,0 +1,1151 @@ +"""Choose which backends to use for the tests.""" + +import contextlib +import gzip +import logging +import os +from collections.abc import Generator +from enum import Enum +from http import HTTPMethod +from typing import override +from uuid import uuid4 + +import pytest +import requests +import responses +from beartype import BeartypeConf, beartype +from pytest_multi_backend import backend_fixture +from requests.exceptions import Timeout as RequestsTimeout +from requests_mock_flask import add_flask_app_to_mock +from vws import VWS +from vws.exceptions.vws_exceptions import ( + TargetStatusNotSuccessError, +) +from vws.response import Response + +from mock_vws import MockVWS +from mock_vws._flask_server.target_manager import TARGET_MANAGER_FLASK_APP +from mock_vws._flask_server.vwq import CLOUDRECO_FLASK_APP +from mock_vws._flask_server.vws import VWS_FLASK_APP +from mock_vws.database import CloudDatabase, VuMarkDatabase +from mock_vws.request_rate_limits import DOCUMENTED_REQUEST_RATE_LIMITS +from mock_vws.states import States +from mock_vws.target import VuMarkTarget +from vws_mock_contract_tests.fixtures.credentials import ( + InactiveVuMarkCloudDatabase, + ModelTargetCredentials, + NamedCloudDatabaseCredentials, + VuMarkCloudDatabase, +) +from vws_mock_contract_tests.utils.backend_capabilities import ( + BackendCapabilities, +) +from vws_mock_contract_tests.utils.cloud_databases import CreateCloudDatabase +from vws_mock_contract_tests.utils.model_target_response_policy import ( + handling_unexpected_model_target_responses, +) +from vws_mock_contract_tests.utils.model_target_retries import ( + retrying_model_target_requests, +) +from vws_mock_contract_tests.utils.recognition_counts import ( + SeedRecognitionCounts, +) +from vws_mock_contract_tests.utils.request_timeout_policy import ( + handling_request_timeouts, +) +from vws_mock_contract_tests.utils.retries import ( + RETRY_ON_TARGET_STILL_PROCESSING, + retry_transient_vws_failures, + retrying_vws_requests, +) +from vws_mock_contract_tests.utils.unrouted_requests import ( + UnroutedRequests, + UnroutedResponse, +) +from vws_mock_test_backends.real_credentials import ( + get_model_target_credentials, + get_real_cloud_database, + get_real_inactive_cloud_database, + get_real_inactive_vumark_database, + get_real_vumark_database, +) +from vws_mock_test_backends.real_model_target_retries import ( + REAL_MODEL_TARGET_RETRY_POLICY, +) +from vws_mock_test_backends.real_vws_retries import REAL_VWS_RETRYING + +LOGGER = logging.getLogger(name=__name__) +LOGGER.setLevel(level=logging.DEBUG) + + +@pytest.fixture(name="base_vws_url") +def fixture_base_vws_url() -> str: + """The Vuforia Web Services API URL for the backend.""" + return "https://vws.vuforia.com" + + +@pytest.fixture(name="base_vwq_url") +def fixture_base_vwq_url() -> str: + """The Vuforia Web Query API URL for the backend.""" + return "https://cloudreco.vuforia.com" + + +# The in-memory mock which the ``MOCK`` backend is running, if any. +# +# Tests which use APIs of the mock itself, such as seeding recognition counts, +# need the mock which the fixture created. This is a list so that the fixture +# can set and unset it without a ``global`` statement. +_RUNNING_IN_MEMORY_MOCKS: list[MockVWS] = [] + + +@beartype +def running_in_memory_mock() -> MockVWS: + """The in-memory mock which the ``MOCK`` backend is running. + + Returns: + The mock which the ``MOCK`` backend set up for the running test. + Indexing fails if the ``MOCK`` backend is not running. + """ + return _RUNNING_IN_MEMORY_MOCKS[0] + + +@beartype +@retry_transient_vws_failures +def _delete_all_targets( + *, database_keys: CloudDatabase, base_vws_url: str +) -> None: + """Delete all targets. + + Args: + database_keys: The credentials to the Vuforia target database to delete + all targets in. + base_vws_url: The base URL of the services API for the backend. + """ + vws_client = VWS( + server_access_key=database_keys.server_access_key, + server_secret_key=database_keys.server_secret_key, + base_vws_url=base_vws_url, + ) + + targets = vws_client.list_targets() + + for target in targets: + vws_client.wait_for_target_processed( + target_id=target, + # Setting this to 2 is an attempt to avoid 429 Too Many Requests + # errors. + seconds_between_requests=2, + ) + # Even deleted targets can be matched by a query for a few seconds so + # we change the target to inactive before deleting it. + with contextlib.suppress(TargetStatusNotSuccessError): + vws_client.update_target(target_id=target, active_flag=False) + vws_client.wait_for_target_processed( + target_id=target, seconds_between_requests=2 + ) + _delete_processed_target(vws_client=vws_client, target_id=target) + + +@beartype +@RETRY_ON_TARGET_STILL_PROCESSING +def _delete_processed_target(*, vws_client: VWS, target_id: str) -> None: + """Delete a target which has just been waited on to be processed. + + Args: + vws_client: A client for the database the target is in. + target_id: The ID of the target to delete. + """ + vws_client.delete_target(target_id=target_id) + + +@beartype +def _vumark_database( + *, + vumark_vuforia_database: VuMarkCloudDatabase, +) -> VuMarkDatabase: + """Return a database with a VuMark target for VuMark instance + generation. + """ + vumark_target = VuMarkTarget( + name="mock-vumark-target", + target_id=vumark_vuforia_database.target_id, + ) + processing_target = VuMarkTarget( + name="mock-processing-vumark-target", + target_id=vumark_vuforia_database.processing_target_id, + processing_time_seconds=9999, + ) + return VuMarkDatabase( + database_name=vumark_vuforia_database.target_manager_database_name, + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + vumark_targets={vumark_target, processing_target}, + ) + + +@beartype +def _handle_real_request_timeout(*, url: str, error: RequestsTimeout) -> None: + """Skip a shared request which timed out against the real service.""" + del url, error + pytest.skip(reason="The real Vuforia service timed out.") + + +@beartype +def _enable_use_real_vuforia( + *, + working_database: CloudDatabase, + inactive_cloud_database: CloudDatabase, + vumark_vuforia_database: VuMarkCloudDatabase, + inactive_vumark_database: InactiveVuMarkCloudDatabase, + monkeypatch: pytest.MonkeyPatch, + base_vws_url: str, + base_vwq_url: str, +) -> Generator[None]: + """Test against the real Vuforia.""" + assert bool(monkeypatch) + assert bool(inactive_cloud_database) + assert bool(vumark_vuforia_database) + assert bool(inactive_vumark_database) + del base_vwq_url + with ( + retrying_vws_requests(retrying=REAL_VWS_RETRYING), + handling_request_timeouts(handler=_handle_real_request_timeout), + ): + _delete_all_targets( + database_keys=working_database, base_vws_url=base_vws_url + ) + yield + + +@beartype +def _enable_use_mock_vuforia( + *, + working_database: CloudDatabase, + inactive_cloud_database: CloudDatabase, + vumark_vuforia_database: VuMarkCloudDatabase, + inactive_vumark_database: InactiveVuMarkCloudDatabase, + monkeypatch: pytest.MonkeyPatch, + base_vws_url: str, + base_vwq_url: str, +) -> Generator[None]: + """Test against the in-memory mock Vuforia.""" + assert bool(monkeypatch) + working_database = CloudDatabase( + database_id=working_database.database_id, + database_name=working_database.database_name, + server_access_key=working_database.server_access_key, + server_secret_key=working_database.server_secret_key, + client_access_key=working_database.client_access_key, + client_secret_key=working_database.client_secret_key, + ) + + inactive_cloud_database = CloudDatabase( + state=States.PROJECT_INACTIVE, + database_name=inactive_cloud_database.database_name, + server_access_key=inactive_cloud_database.server_access_key, + server_secret_key=inactive_cloud_database.server_secret_key, + client_access_key=inactive_cloud_database.client_access_key, + client_secret_key=inactive_cloud_database.client_secret_key, + ) + vumark_database = _vumark_database( + vumark_vuforia_database=vumark_vuforia_database, + ) + inactive_vumark_db = VuMarkDatabase( + state=States.PROJECT_INACTIVE, + database_name=inactive_vumark_database.target_manager_database_name, + server_access_key=inactive_vumark_database.server_access_key, + server_secret_key=inactive_vumark_database.server_secret_key, + ) + + with MockVWS(base_vws_url=base_vws_url, base_vwq_url=base_vwq_url) as mock: + mock.add_cloud_database(cloud_database=working_database) + mock.add_cloud_database(cloud_database=inactive_cloud_database) + mock.add_vumark_database(vumark_database=vumark_database) + mock.add_vumark_database(vumark_database=inactive_vumark_db) + _RUNNING_IN_MEMORY_MOCKS.append(mock) + try: + yield + finally: + _RUNNING_IN_MEMORY_MOCKS.remove(mock) + + +@beartype +def _enable_use_flask_in_process( + *, + working_database: CloudDatabase, + inactive_cloud_database: CloudDatabase, + vumark_vuforia_database: VuMarkCloudDatabase, + inactive_vumark_database: InactiveVuMarkCloudDatabase, + monkeypatch: pytest.MonkeyPatch, + base_vws_url: str, + base_vwq_url: str, +) -> Generator[None]: + """Test against the Flask applications of the mock, in this process. + + This exercises the handlers of the Flask applications which the + Docker deployment runs, but not the deployment itself: all three + applications are served from one process, so state which the + deployment keeps in separate containers is shared here. + ``tests/mock_vws/test_docker.py`` covers the container split. + """ + # We set ``wsgi.input_terminated`` to ``True`` so that when going through + # ``requests`` in our tests, the Flask applications + # have the given ``Content-Length`` headers and the given data in + # ``request.headers`` and ``request.data``. + # + # We do not set these in the Flask application itself. + # This is because when running the Flask application, if this is set, + # reading ``request.data`` hangs. + # + # Therefore, when running the real Flask application, the behavior is not + # the same as the real Vuforia. + # This is documented as a difference in the documentation for this package. + VWS_FLASK_APP.config["VWS_MOCK_TERMINATE_WSGI_INPUT"] = True + CLOUDRECO_FLASK_APP.config["VWS_MOCK_TERMINATE_WSGI_INPUT"] = True + + monkeypatch.setenv(name="VWS_BASE_URL", value=base_vws_url) + target_manager_base_url = "http://example.com" + monkeypatch.setenv( + name="TARGET_MANAGER_BASE_URL", + value=target_manager_base_url, + ) + vumark_database = _vumark_database( + vumark_vuforia_database=vumark_vuforia_database, + ) + inactive_vumark_db = VuMarkDatabase( + state=States.PROJECT_INACTIVE, + database_name=inactive_vumark_database.target_manager_database_name, + server_access_key=inactive_vumark_database.server_access_key, + server_secret_key=inactive_vumark_database.server_secret_key, + ) + + with responses.RequestsMock(assert_all_requests_are_fired=False) as mock: + add_flask_app_to_mock( + mock_obj=mock, + flask_app=VWS_FLASK_APP, + base_url=base_vws_url, + ) + + add_flask_app_to_mock( + mock_obj=mock, + flask_app=CLOUDRECO_FLASK_APP, + base_url=base_vwq_url, + ) + + add_flask_app_to_mock( + mock_obj=mock, + flask_app=TARGET_MANAGER_FLASK_APP, + base_url=target_manager_base_url, + ) + + cloud_databases_url = target_manager_base_url + "/cloud_databases" + vumark_databases_url = target_manager_base_url + "/vumark_databases" + + for database in requests.get( + url=cloud_databases_url, timeout=30 + ).json(): + database_name: object = database["database_name"] + assert isinstance(database_name, str) + _ = requests.delete( + url=cloud_databases_url + "/" + database_name, + timeout=30, + ) + for database in requests.get( + url=vumark_databases_url, timeout=30 + ).json(): + database_name = database["database_name"] + assert isinstance(database_name, str) + _ = requests.delete( + url=vumark_databases_url + "/" + database_name, + timeout=30, + ) + + _ = requests.post( + url=cloud_databases_url, + json=working_database.to_dict(), + timeout=30, + ) + _ = requests.post( + url=cloud_databases_url, + json=inactive_cloud_database.to_dict(), + timeout=30, + ) + _ = requests.post( + url=vumark_databases_url, + json=vumark_database.to_dict(), + timeout=30, + ) + _ = requests.post( + url=vumark_databases_url, + json=inactive_vumark_db.to_dict(), + timeout=30, + ) + for vumark_target in vumark_database.vumark_targets: + _ = requests.post( + url=( + f"{vumark_databases_url}" + f"/{vumark_database.database_name}/vumark_targets" + ), + json=vumark_target.to_dict(), + timeout=30, + ) + + yield + + +@beartype +def _handle_real_model_target_response( + *, + response: Response | requests.Response, +) -> None: + """Skip an unexpected rejection by the shared real account's allowance.""" + if "TRAINING_ALLOWANCE_EXCEEDED" in response.text: + pytest.skip( + reason=( + "The Vuforia account is out of Model Target training " + "allowance - this is not a failure of the code under test." + ), + ) + + +@beartype +def _enable_use_real_model_target_vuforia( + *, + monkeypatch: pytest.MonkeyPatch, + base_vws_url: str, +) -> Generator[None]: + """Test against the real Model Target Web API. + + Safe requests are retried past a transient gateway failure, which + the load balancer in front of the real Model Target Web API + occasionally returns for a request which is not at fault. + """ + del base_vws_url + assert bool(monkeypatch) + with ( + retrying_model_target_requests( + retrying=REAL_MODEL_TARGET_RETRY_POLICY, + ), + handling_request_timeouts(handler=_handle_real_request_timeout), + handling_unexpected_model_target_responses( + handler=_handle_real_model_target_response, + ), + ): + yield + + +@beartype +def _enable_use_mock_model_target_vuforia( + *, + monkeypatch: pytest.MonkeyPatch, + base_vws_url: str, +) -> Generator[None]: + """Test against the in-memory mock Model Target Web API.""" + assert bool(monkeypatch) + with MockVWS(base_vws_url=base_vws_url): + yield + + +@beartype +def _enable_use_flask_in_process_model_target_vuforia( + *, + monkeypatch: pytest.MonkeyPatch, + base_vws_url: str, +) -> Generator[None]: + """Test against the Flask-backed mock Model Target Web API.""" + assert bool(monkeypatch) + VWS_FLASK_APP.config["VWS_MOCK_TERMINATE_WSGI_INPUT"] = True + target_manager_base_url = "http://example.com" + monkeypatch.setenv( + name="TARGET_MANAGER_BASE_URL", + value=target_manager_base_url, + ) + + with responses.RequestsMock(assert_all_requests_are_fired=False) as mock: + add_flask_app_to_mock( + mock_obj=mock, + flask_app=VWS_FLASK_APP, + base_url=base_vws_url, + ) + + # The VWS app stores Model Target datasets in the target manager + # service, just as it does cloud databases. + add_flask_app_to_mock( + mock_obj=mock, + flask_app=TARGET_MANAGER_FLASK_APP, + base_url=target_manager_base_url, + ) + + yield + + +class VuforiaBackend(Enum): + """Backends for tests. + + The names of the members give the ``--skip-backend`` options, and + the values give the test IDs. + """ + + REAL = "Real Vuforia" + MOCK = "In Memory Mock Vuforia" + FLASK_IN_PROCESS = "In Process Flask Applications" + + +_ALL_BACKENDS = list(VuforiaBackend) +# The real Vuforia cannot be set up for tests which need to control the +# state of the service. +_MOCK_BACKENDS = [ + backend for backend in _ALL_BACKENDS if backend != VuforiaBackend.REAL +] + + +@beartype +def _credential_backend(*, request: pytest.FixtureRequest) -> VuforiaBackend: + """Read the active VWS selector without adding another backend matrix. + + Implementation tests which manage their own mock have no selector and + receive mock credentials. + """ + for fixture_name in ("_vws_backend", "_mock_vws_backend"): + if fixture_name in request.fixturenames: + backend = request.getfixturevalue(argname=fixture_name) + assert isinstance(backend, VuforiaBackend) + return backend + return VuforiaBackend.MOCK + + +@beartype(conf=BeartypeConf(is_pep484_tower=True)) +class _LiveVWS(VWS): + """A client with a polling default suited to the live contract + tests. + """ + + # Preserve the optional parameters of the upstream VWS API. + @override + def wait_for_target_processed( + self, + *, + target_id: str, + seconds_between_requests: float = 2.0, # noqa: NOD001 + timeout_seconds: float = 60 * 5, # noqa: NOD001 + ) -> None: + """Poll at the cleanup interval while retaining explicit overrides.""" + super().wait_for_target_processed( + target_id=target_id, + seconds_between_requests=seconds_between_requests, + timeout_seconds=timeout_seconds, + ) + + +@pytest.fixture(name="vws_client_class") +def fixture_vws_client_class(*, request: pytest.FixtureRequest) -> type[VWS]: + """Use slower polling only for clients of the live Vuforia service.""" + if _credential_backend(request=request) == VuforiaBackend.REAL: + return _LiveVWS + return VWS + + +@pytest.fixture(name="vuforia_database") +def fixture_vuforia_database( + *, request: pytest.FixtureRequest +) -> CloudDatabase: + """An active database with credentials supplied by the backend.""" + if _credential_backend(request=request) == VuforiaBackend.REAL: + return get_real_cloud_database() + return CloudDatabase() + + +@pytest.fixture(name="inactive_cloud_database") +def fixture_inactive_cloud_database( + *, request: pytest.FixtureRequest +) -> CloudDatabase: + """An inactive database with credentials supplied by the backend.""" + if _credential_backend(request=request) == VuforiaBackend.REAL: + return get_real_inactive_cloud_database() + return CloudDatabase(state=States.PROJECT_INACTIVE) + + +@pytest.fixture(name="inactive_vumark_database") +def fixture_inactive_vumark_database( + *, request: pytest.FixtureRequest +) -> InactiveVuMarkCloudDatabase: + """An inactive VuMark database supplied by the backend.""" + if _credential_backend(request=request) == VuforiaBackend.REAL: + return get_real_inactive_vumark_database() + return InactiveVuMarkCloudDatabase( + target_manager_database_name=uuid4().hex, + server_access_key=uuid4().hex, + server_secret_key=uuid4().hex, + ) + + +@pytest.fixture(name="vumark_vuforia_database") +def fixture_vumark_vuforia_database( + *, request: pytest.FixtureRequest +) -> VuMarkCloudDatabase: + """A VuMark database with credentials supplied by the backend.""" + if _credential_backend(request=request) == VuforiaBackend.REAL: + return get_real_vumark_database() + return VuMarkCloudDatabase( + target_manager_database_name=uuid4().hex, + server_access_key=uuid4().hex, + server_secret_key=uuid4().hex, + target_id=uuid4().hex, + processing_target_id=uuid4().hex, + ) + + +# These deliberately have no type annotation, so that the keyword +# arguments of the setup functions are still checked where they are +# bound. +_SETUP_FUNCTIONS = { + VuforiaBackend.REAL: _enable_use_real_vuforia, + VuforiaBackend.MOCK: _enable_use_mock_vuforia, + VuforiaBackend.FLASK_IN_PROCESS: _enable_use_flask_in_process, +} + +_MODEL_TARGET_SETUP_FUNCTIONS = { + VuforiaBackend.REAL: _enable_use_real_model_target_vuforia, + VuforiaBackend.MOCK: _enable_use_mock_model_target_vuforia, + VuforiaBackend.FLASK_IN_PROCESS: ( + _enable_use_flask_in_process_model_target_vuforia + ), +} + + +# Signed Model Target requests, including state-based advanced datasets and +# standard datasets with inline CAD data, consume the Vuforia account's Model +# Target training allowance. The allowance is small, shared across all CI +# jobs, and cannot be raised or reset, so signed requests run against the real +# Vuforia only when this option is given. +VERIFY_MODEL_TARGET_SIGNING_OPTION = "--verify-model-target-signing" +_INVALID_JSON_REAL_NODE_ID_PREFIXES = tuple( + f"tests/mock_vws/invalid_json/test_invalid_json.py::{test_name}[" + "Real Vuforia-" + for test_name in ("test_not_json", "test_not_an_object", "test_not_utf_8") +) +_INVALID_JSON_REAL_SKIP_REASON = ( + "Real Vuforia can leave malformed-body requests open indefinitely; the " + "mock backends still verify this contract." +) +_RECO_COUNTS_REAL_NODE_ID_PART = "[Real Vuforia-reco_counts_report" +_RECO_COUNTS_REAL_SKIP_REASON = ( + "The reco counts report endpoint is unavailable to the real-Vuforia " + "test account; the mock backends still verify this contract." +) + + +@beartype +def pytest_addoption(parser: pytest.Parser) -> None: + """Add the option which opts in to signed Model Target requests. + + The options which skip backends and the Docker build tests come + from ``pytest-multi-backend``. + """ + parser.addoption( + VERIFY_MODEL_TARGET_SIGNING_OPTION, + action="store_true", + default=False, + help=( + "Run signed Model Target dataset tests against the real " + "Vuforia. These consume the account's small, shared, " + "non-resettable Model Target training allowance, so they " + "run against the mock backends only by default." + ), + ) + + +@beartype +def pytest_collection_modifyitems( + config: pytest.Config, + items: list[pytest.Item], +) -> None: + """Apply infrastructure-specific test skips.""" + del config + invalid_json_real_marker = pytest.mark.skip( + reason=_INVALID_JSON_REAL_SKIP_REASON, + ) + reco_counts_real_marker = pytest.mark.skip( + reason=_RECO_COUNTS_REAL_SKIP_REASON, + ) + for item in items: + if item.nodeid.startswith(_INVALID_JSON_REAL_NODE_ID_PREFIXES): + item.add_marker(marker=invalid_json_real_marker) + if _RECO_COUNTS_REAL_NODE_ID_PART in item.nodeid: + item.add_marker(marker=reco_counts_real_marker) + + +@beartype +def _setup_backend( + *, + backend: VuforiaBackend, + request: pytest.FixtureRequest, +) -> Generator[None]: + """Set a backend up with the databases which the tests use. + + Yields: + ``None``, once the backend is set up. + """ + yield from _SETUP_FUNCTIONS[backend]( + working_database=request.getfixturevalue(argname="vuforia_database"), + inactive_cloud_database=request.getfixturevalue( + argname="inactive_cloud_database", + ), + vumark_vuforia_database=request.getfixturevalue( + argname="vumark_vuforia_database", + ), + inactive_vumark_database=request.getfixturevalue( + argname="inactive_vumark_database", + ), + monkeypatch=request.getfixturevalue(argname="monkeypatch"), + base_vws_url=request.getfixturevalue(argname="base_vws_url"), + base_vwq_url=request.getfixturevalue(argname="base_vwq_url"), + ) + + +@beartype +def _setup_model_target_backend( + *, + backend: VuforiaBackend, + request: pytest.FixtureRequest, +) -> Generator[None]: + """Set a backend up for the Model Target Web API tests. + + Yields: + ``None``, once the backend is set up. + """ + yield from _MODEL_TARGET_SETUP_FUNCTIONS[backend]( + monkeypatch=request.getfixturevalue(argname="monkeypatch"), + base_vws_url=request.getfixturevalue(argname="base_vws_url"), + ) + + +@beartype +def _defer_backend_setup( + *, + backend: VuforiaBackend, + request: pytest.FixtureRequest, +) -> Generator[None]: + """Leave setup to the shared fixture after backend selection. + + Yields: + ``None``, without setting up the selected backend. + """ + del backend, request + yield + + +# These adapter-internal fixtures select and apply skip policy for a backend. +# Public fixtures below set up and tear down the selected backend without +# exposing that selection to the shared suite. +fixture_vws_backend = backend_fixture( + name="_vws_backend", + backends=_ALL_BACKENDS, + setup_for=_defer_backend_setup, +) +fixture_model_target_backend = backend_fixture( + name="_model_target_backend", + backends=_ALL_BACKENDS, + setup_for=_defer_backend_setup, +) +fixture_model_target_mock_backend = backend_fixture( + name="_model_target_mock_backend", + backends=_MOCK_BACKENDS, + setup_for=_defer_backend_setup, +) +fixture_mock_vws_backend = backend_fixture( + name="_mock_vws_backend", + backends=_MOCK_BACKENDS, + setup_for=_defer_backend_setup, +) + + +@pytest.fixture(name="verify_mock_vuforia") +def fixture_verify_mock_vuforia( + *, + _vws_backend: VuforiaBackend, + request: pytest.FixtureRequest, +) -> Generator[None]: + """Set up and tear down the selected VWS/query backend. + + Yields: + ``None``, once the backend is ready for shared tests. + """ + yield from _setup_backend(backend=_vws_backend, request=request) + + +@pytest.fixture(name="verify_model_target_mock_vuforia") +def fixture_verify_model_target_mock_vuforia( + *, + _model_target_backend: VuforiaBackend, + request: pytest.FixtureRequest, +) -> Generator[None]: + """Set up and tear down the selected Model Target backend. + + Yields: + ``None``, once the backend is ready for shared tests. + """ + yield from _setup_model_target_backend( + backend=_model_target_backend, + request=request, + ) + + +@pytest.fixture(name="model_target_mock_only_vuforia") +def fixture_model_target_mock_only_vuforia( + *, + _model_target_mock_backend: VuforiaBackend, + request: pytest.FixtureRequest, +) -> Generator[None]: + """Set up and tear down a Model Target mock backend. + + Yields: + ``None``, once the backend is ready for mock-only tests. + """ + yield from _setup_model_target_backend( + backend=_model_target_mock_backend, + request=request, + ) + + +@pytest.fixture(name="mock_only_vuforia") +def fixture_mock_only_vuforia( + *, + _mock_vws_backend: VuforiaBackend, + request: pytest.FixtureRequest, +) -> Generator[None]: + """Set up and tear down a VWS/query mock backend. + + Yields: + ``None``, once the backend is ready for mock-only tests. + """ + yield from _setup_backend(backend=_mock_vws_backend, request=request) + + +@beartype +def _add_cloud_database( + *, + backend: VuforiaBackend, + cloud_database: CloudDatabase, +) -> None: + """Add a database through the running mock backend.""" + if backend == VuforiaBackend.MOCK: + running_in_memory_mock().add_cloud_database( + cloud_database=cloud_database, + ) + return + + target_manager_base_url = os.environ["TARGET_MANAGER_BASE_URL"] + response = requests.post( + url=f"{target_manager_base_url}/cloud_databases", + json=cloud_database.to_dict(), + timeout=30, + ) + response.raise_for_status() + + +@pytest.fixture(name="create_cloud_database") +def fixture_create_cloud_database( + *, + mock_only_vuforia: None, + _mock_vws_backend: VuforiaBackend, +) -> CreateCloudDatabase: + """The database creation operation for the running mock backend.""" + del mock_only_vuforia + + @beartype + def create_database() -> NamedCloudDatabaseCredentials: + """Create a fresh database through the selected backend.""" + database = CloudDatabase() + _add_cloud_database( + backend=_mock_vws_backend, + cloud_database=database, + ) + return database + + return create_database + + +@pytest.fixture(name="rate_limited_database") +def fixture_rate_limited_database( + *, + verify_mock_vuforia: None, + _vws_backend: VuforiaBackend, + vuforia_database: CloudDatabase, +) -> CloudDatabase: + """Return a database which applies real Vuforia's request rate limits. + + The real database applies them itself. Each mock is given a fresh + database with the limits, so that the shared database which the other + tests use is not limited. + """ + del verify_mock_vuforia + if _vws_backend == VuforiaBackend.REAL: + return vuforia_database + + database = CloudDatabase( + request_rate_limits=DOCUMENTED_REQUEST_RATE_LIMITS + ) + _add_cloud_database(backend=_vws_backend, cloud_database=database) + return database + + +@pytest.fixture(name="seed_recognition_counts") +def fixture_seed_recognition_counts( + *, + mock_only_vuforia: None, + _mock_vws_backend: VuforiaBackend, +) -> SeedRecognitionCounts: + """The recognition-count operation for the running mock backend.""" + del mock_only_vuforia + if _mock_vws_backend == VuforiaBackend.MOCK: + + @beartype + def seed_in_memory_counts( + *, + database_name: str, + target_id: str, + current_month_recos: int, + previous_month_recos: int, + total_recos: int, + ) -> None: + """Set recognition counts through the running in-memory + mock. + """ + del database_name + running_in_memory_mock().set_target_recognition_counts( + target_id=target_id, + current_month_recos=current_month_recos, + previous_month_recos=previous_month_recos, + total_recos=total_recos, + ) + + return seed_in_memory_counts + + @beartype + def seed_flask_counts( + *, + database_name: str, + target_id: str, + current_month_recos: int, + previous_month_recos: int, + total_recos: int, + ) -> None: + """Set recognition counts through the target manager service.""" + target_manager_base_url = os.environ["TARGET_MANAGER_BASE_URL"] + response = requests.post( + url=( + f"{target_manager_base_url}/cloud_databases/" + f"{database_name}/targets/{target_id}/recognition_counts" + ), + json={ + "current_month_recos": current_month_recos, + "previous_month_recos": previous_month_recos, + "total_recos": total_recos, + }, + timeout=30, + ) + response.raise_for_status() + + return seed_flask_counts + + +@pytest.fixture(name="unrouted_requests") +def fixture_unrouted_requests( + *, + verify_mock_vuforia: None, + _vws_backend: VuforiaBackend, + base_vws_url: str, +) -> UnroutedRequests: + """The transport for unmatched requests on the running backend.""" + del verify_mock_vuforia + if _vws_backend == VuforiaBackend.FLASK_IN_PROCESS: + return UnroutedRequests( + send=_send_flask_unrouted_request, + returns_response=True, + ) + + @beartype + def send_http_request( + *, + method: HTTPMethod, + request_path: str, + headers: dict[str, str], + ) -> UnroutedResponse | None: + """Send an unmatched request to the configured VWS URL.""" + try: + response = requests.request( + method=method, + url=base_vws_url + request_path, + headers=headers, + timeout=30, + ) + except requests.exceptions.ConnectionError: + return None + + return UnroutedResponse( + status_code=response.status_code, + body=response.content, + content_type=response.headers.get("Content-Type"), + content_encoding=response.headers.get("Content-Encoding"), + upstream_service_time=response.headers.get( + "x-envoy-upstream-service-time", + ), + ) + + return UnroutedRequests( + send=send_http_request, + returns_response=_vws_backend != VuforiaBackend.MOCK, + ) + + +@beartype +def _send_flask_unrouted_request( + *, + method: HTTPMethod, + request_path: str, + headers: dict[str, str], +) -> UnroutedResponse: + """Send an unmatched request through the in-process Flask client.""" + # The ``responses`` library intercepts only the paths and methods + # which the Flask app routes, so requests to any other path never + # reach the app. A running container serves every path, so we drive + # the app with its own test client. + test_client_response = VWS_FLASK_APP.test_client().open( + request_path, + method=method, + headers=headers, + ) + response_body = test_client_response.data + content_encoding = test_client_response.headers.get( + key="Content-Encoding", + ) + if content_encoding == "gzip": + response_body = gzip.decompress(data=response_body) + return UnroutedResponse( + status_code=test_client_response.status_code, + body=response_body, + content_type=test_client_response.headers.get( + key="Content-Type", + ), + content_encoding=content_encoding, + upstream_service_time=test_client_response.headers.get( + key="x-envoy-upstream-service-time", + ), + ) + + +@beartype +def _model_target_credentials_for_backend( + *, + backend: VuforiaBackend, +) -> ModelTargetCredentials: + """Return Model Target credentials for the chosen backend.""" + if backend == VuforiaBackend.REAL: + return get_model_target_credentials() + + return ModelTargetCredentials( + client_id="client-id", + client_secret="client-secret", + cad_data_url="https://example.com/model.glb", + username="user@example.com", + password="password", + ) + + +@pytest.fixture(name="model_target_credentials") +def fixture_model_target_credentials( + *, + verify_model_target_mock_vuforia: None, + _model_target_backend: VuforiaBackend, +) -> ModelTargetCredentials: + """Model Target credentials supplied by the running backend.""" + del verify_model_target_mock_vuforia + return _model_target_credentials_for_backend( + backend=_model_target_backend, + ) + + +@pytest.fixture(name="mock_model_target_credentials") +def fixture_mock_model_target_credentials( + *, + model_target_mock_only_vuforia: None, + _model_target_mock_backend: VuforiaBackend, +) -> ModelTargetCredentials: + """Model Target credentials for tests which require a mock.""" + del model_target_mock_only_vuforia + return _model_target_credentials_for_backend( + backend=_model_target_mock_backend, + ) + + +# Creating an advanced dataset with a state-based configuration or a standard +# dataset with inline CAD data is a "signed" request: the real Vuforia signs +# the trained dataset, and each signing consumes the account's allowance. The +# allowance is tiny (roughly 20 signings, under ten CI runs' worth), it +# is shared by every CI job and every concurrent run, and it cannot be +# raised or reset by us. Verifying this behavior on every run therefore +# burns the whole allowance within hours and then turns every CI run red +# with ``TRAINING_ALLOWANCE_EXCEEDED`` - which is exactly what happened +# when it ran unconditionally. The equivalent unsigned requests (a +# standard dataset, or an advanced dataset without a state-based +# configuration) are far cheaper and stay enabled, though with enough +# traffic they can also hit the allowance; an unexpected allowance +# rejection is skipped by the real adapter rather than failing the run. +_SIGNED_REQUEST_SKIP_REASON = ( + "Signed Model Target requests consume the real Vuforia account's " + "small, shared, non-resettable training allowance, so they are not " + "verified against the real Vuforia by default. Pass " + f"{VERIFY_MODEL_TARGET_SIGNING_OPTION} to verify them, for example " + "after the allowance has recovered. The mock backends always run " + "this test." +) + + +def _skip_unrequested_real_signing( + *, + request: pytest.FixtureRequest, + backend: VuforiaBackend, +) -> None: + """Skip a signing request against real Vuforia unless opted in.""" + if backend is VuforiaBackend.REAL and not request.config.getoption( + name=VERIFY_MODEL_TARGET_SIGNING_OPTION, + ): + pytest.skip(reason=_SIGNED_REQUEST_SKIP_REASON) + + +@pytest.fixture(name="signed_model_target_credentials") +def fixture_signed_model_target_credentials( + *, + verify_model_target_mock_vuforia: None, + _model_target_backend: VuforiaBackend, + request: pytest.FixtureRequest, +) -> ModelTargetCredentials: + """Credentials for signing tests, after applying the real-service + opt-in policy. + """ + del verify_model_target_mock_vuforia + _skip_unrequested_real_signing( + request=request, + backend=_model_target_backend, + ) + return _model_target_credentials_for_backend( + backend=_model_target_backend, + ) + + +@pytest.fixture(name="backend_capabilities") +def fixture_backend_capabilities( + *, + verify_mock_vuforia: None, + _vws_backend: VuforiaBackend, +) -> BackendCapabilities: + """The behavior supported by the running VWS and query backend.""" + del verify_mock_vuforia + is_mock = _vws_backend != VuforiaBackend.REAL + return BackendCapabilities( + deterministic_target_order=is_mock, + deterministic_query_order=is_mock, + fast_oversized_content_length_response=is_mock, + rolling_rate_limit_window=is_mock, + ) diff --git a/tests/conftest.py b/tests/conftest.py index 5ba7e3671..32c992040 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,6 @@ """ pytest_plugins = [ - "tests.mock_vws.fixtures.plugin", - "tests.mock_vws.fixtures.vuforia_backends", + "vws_mock_contract_tests.fixtures.plugin", + "vws_mock_test_backends.vuforia_backends", ] diff --git a/tests/mock_vws/database_summary/__init__.py b/tests/mock_vws/database_summary/__init__.py index 266ddde2b..fa867c1a5 100644 --- a/tests/mock_vws/database_summary/__init__.py +++ b/tests/mock_vws/database_summary/__init__.py @@ -1 +1 @@ -"""Database summary tests.""" +"""Compatibility imports for contract test fixtures.""" diff --git a/tests/mock_vws/database_summary/contract/__init__.py b/tests/mock_vws/database_summary/contract/__init__.py index 2dbb3bed8..fa867c1a5 100644 --- a/tests/mock_vws/database_summary/contract/__init__.py +++ b/tests/mock_vws/database_summary/contract/__init__.py @@ -1 +1 @@ -"""Tests using verify mock vuforia.""" +"""Compatibility imports for contract test fixtures.""" diff --git a/tests/mock_vws/database_summary/contract/conftest.py b/tests/mock_vws/database_summary/contract/conftest.py index d409c8ebf..4808f5094 100644 --- a/tests/mock_vws/database_summary/contract/conftest.py +++ b/tests/mock_vws/database_summary/contract/conftest.py @@ -1,12 +1,7 @@ -"""Fixtures scoped to this group of tests.""" +"""Compatibility imports for the packaged contract suite.""" -import pytest +from vws_mock_contract_tests.database_summary.contract.conftest import ( + fixture_verify_backend, +) - -@pytest.fixture(autouse=True) -def fixture_verify_backend( - *, - verify_mock_vuforia: None, -) -> None: - """Set up the backend for every contract test in this directory.""" - del verify_mock_vuforia +__all__ = ["fixture_verify_backend"] diff --git a/tests/mock_vws/database_summary/contract/test_database_summary.py b/tests/mock_vws/database_summary/contract/test_database_summary.py index 257600d88..1578e8f78 100644 --- a/tests/mock_vws/database_summary/contract/test_database_summary.py +++ b/tests/mock_vws/database_summary/contract/test_database_summary.py @@ -1,152 +1,21 @@ -""" -Tests for the mock of the database summary endpoint at `GET -/summary`. -""" +"""Compatibility imports for the packaged contract suite.""" -import io -import uuid - -import pytest -from vws import VWS - -from tests.mock_vws.database_summary.helpers import ( - wait_for_image_numbers, +from vws_mock_contract_tests.database_summary.contract import ( + test_database_summary as _contract_tests, ) -from tests.mock_vws.fixtures.credentials import NamedCloudDatabaseCredentials - - -def test_success( - *, - vuforia_database: NamedCloudDatabaseCredentials, - vws_client: VWS, -) -> None: - """It is possible to get a success response.""" - report = vws_client.get_database_summary_report() - assert report.name == vuforia_database.database_name - - wait_for_image_numbers( - vws_client=vws_client, - active_images=0, - inactive_images=0, - failed_images=0, - processing_images=0, - ) - - -# ``verify_mock_vuforia`` is given here as well as on the class so -# that the backend is set up before ``target_id`` adds a target. -@pytest.mark.usefixtures("verify_mock_vuforia", "target_id") -def test_active_images(*, vws_client: VWS) -> None: - """The number of images in the active state is returned.""" - wait_for_image_numbers( - vws_client=vws_client, - active_images=1, - inactive_images=0, - failed_images=0, - processing_images=0, - ) - - -def test_failed_images( - *, - image_file_failed_state: io.BytesIO, - vws_client: VWS, -) -> None: - """The number of images with a 'failed' status is returned.""" - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image_file_failed_state, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - - wait_for_image_numbers( - vws_client=vws_client, - active_images=0, - inactive_images=0, - failed_images=1, - processing_images=0, - ) - - -def test_inactive_images( - *, - vws_client: VWS, - image_file_success_state_low_rating: io.BytesIO, -) -> None: - """ - The number of images with a False active_flag and a 'success' - status is - returned. - """ - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image_file_success_state_low_rating, - active_flag=False, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - - wait_for_image_numbers( - vws_client=vws_client, - active_images=0, - inactive_images=1, - failed_images=0, - processing_images=0, - ) - - -def test_inactive_failed( - *, - image_file_failed_state: io.BytesIO, - vws_client: VWS, -) -> None: - """An image with a 'failed' status does not show as inactive.""" - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image_file_failed_state, - active_flag=False, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - - wait_for_image_numbers( - vws_client=vws_client, - active_images=0, - inactive_images=0, - failed_images=1, - processing_images=0, - ) - - -def test_deleted( - *, - image_file_failed_state: io.BytesIO, - vws_client: VWS, -) -> None: - """Deleted targets are not shown in the summary.""" - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image_file_failed_state, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.delete_target(target_id=target_id) - wait_for_image_numbers( - vws_client=vws_client, - active_images=0, - inactive_images=0, - failed_images=0, - processing_images=0, - ) +test_active_images = _contract_tests.test_active_images +test_deleted = _contract_tests.test_deleted +test_failed_images = _contract_tests.test_failed_images +test_inactive_failed = _contract_tests.test_inactive_failed +test_inactive_images = _contract_tests.test_inactive_images +test_success = _contract_tests.test_success + +__all__ = [ + "test_active_images", + "test_deleted", + "test_failed_images", + "test_inactive_failed", + "test_inactive_images", + "test_success", +] diff --git a/tests/mock_vws/database_summary/contract/test_inactive_project.py b/tests/mock_vws/database_summary/contract/test_inactive_project.py index e42cf4534..e45cf1bff 100644 --- a/tests/mock_vws/database_summary/contract/test_inactive_project.py +++ b/tests/mock_vws/database_summary/contract/test_inactive_project.py @@ -1,10 +1,9 @@ -"""Tests for inactive projects.""" +"""Compatibility imports for the packaged contract suite.""" -from vws import VWS +from vws_mock_contract_tests.database_summary.contract import ( + test_inactive_project as _contract_tests, +) +test_inactive_project = _contract_tests.test_inactive_project -def test_inactive_project( - inactive_vws_client: VWS, -) -> None: - """The project's active state does not affect the database summary.""" - _ = inactive_vws_client.get_database_summary_report() +__all__ = ["test_inactive_project"] diff --git a/tests/mock_vws/database_summary/contract/test_quotas.py b/tests/mock_vws/database_summary/contract/test_quotas.py index a77b891d2..d980d1681 100644 --- a/tests/mock_vws/database_summary/contract/test_quotas.py +++ b/tests/mock_vws/database_summary/contract/test_quotas.py @@ -1,17 +1,7 @@ -"""Tests for quotas and thresholds.""" +"""Compatibility imports for the packaged contract suite.""" -from vws import VWS +from vws_mock_contract_tests.database_summary.contract.test_quotas import ( + test_quotas, +) - -def test_quotas(vws_client: VWS) -> None: - """Quotas are included in the database summary. - - These match the quotas given for a free license. - """ - report = vws_client.get_database_summary_report() - expected_target_quota = 1000 - expected_request_quota = 100000 - expected_reco_threshold = 1000 - assert report.target_quota == expected_target_quota - assert report.request_quota == expected_request_quota - assert report.reco_threshold == expected_reco_threshold +__all__ = ["test_quotas"] diff --git a/tests/mock_vws/database_summary/contract/test_recos.py b/tests/mock_vws/database_summary/contract/test_recos.py index 18c661687..d57cc15ee 100644 --- a/tests/mock_vws/database_summary/contract/test_recos.py +++ b/tests/mock_vws/database_summary/contract/test_recos.py @@ -1,37 +1,7 @@ -"""Tests for the recognition count fields.""" +"""Compatibility imports for the packaged contract suite.""" -import io -import uuid +from vws_mock_contract_tests.database_summary.contract.test_recos import ( + test_query_request, +) -from vws import VWS, CloudRecoService - - -def test_query_request( - *, - cloud_reco_client: CloudRecoService, - high_quality_image: io.BytesIO, - vws_client: VWS, -) -> None: - """Recognition counts update asynchronously after a query.""" - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - - report_before = vws_client.get_database_summary_report() - _ = cloud_reco_client.query(image=high_quality_image) - - report_after = vws_client.get_database_summary_report() - total_recos_change = report_after.total_recos - report_before.total_recos - current_month_recos_change = ( - report_after.current_month_recos - report_before.current_month_recos - ) - assert total_recos_change in {0, 1} - assert current_month_recos_change == total_recos_change - assert ( - report_before.previous_month_recos == report_after.previous_month_recos - ) +__all__ = ["test_query_request"] diff --git a/tests/mock_vws/database_summary/contract/test_request_usage.py b/tests/mock_vws/database_summary/contract/test_request_usage.py index bc9cc8671..b2b5f2c1f 100644 --- a/tests/mock_vws/database_summary/contract/test_request_usage.py +++ b/tests/mock_vws/database_summary/contract/test_request_usage.py @@ -1,71 +1,15 @@ -"""Tests for the ``request_usage`` field.""" +"""Compatibility imports for the packaged contract suite.""" -import io -from http import HTTPStatus +from vws_mock_contract_tests.database_summary.contract import ( + test_request_usage as _contract_tests, +) -import pytest -from vws import VWS, CloudRecoService -from vws.exceptions.vws_exceptions import FailError +test_bad_target_request = _contract_tests.test_bad_target_request +test_query_request = _contract_tests.test_query_request +test_target_request = _contract_tests.test_target_request - -def test_target_request(vws_client: VWS) -> None: - """ - The ``request_usage`` count does not increase with each request - to the - target API. - """ - report = vws_client.get_database_summary_report() - original_request_usage = report.request_usage - - report = vws_client.get_database_summary_report() - new_request_usage = report.request_usage - assert new_request_usage == original_request_usage - - -def test_bad_target_request( - *, - high_quality_image: io.BytesIO, - vws_client: VWS, -) -> None: - """ - The ``request_usage`` count does not increase with each request - to the - target API, even if it is a bad request. - """ - report = vws_client.get_database_summary_report() - original_request_usage = report.request_usage - - with pytest.raises(expected_exception=FailError) as exc: - _ = vws_client.add_target( - name="example", - width=-1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - - assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST - - report = vws_client.get_database_summary_report() - new_request_usage = report.request_usage - assert new_request_usage == original_request_usage - - -def test_query_request( - *, - cloud_reco_client: CloudRecoService, - high_quality_image: io.BytesIO, - vws_client: VWS, -) -> None: - """ - The ``request_usage`` count does not increase with each - query. - """ - report = vws_client.get_database_summary_report() - original_request_usage = report.request_usage - _ = cloud_reco_client.query(image=high_quality_image) - report = vws_client.get_database_summary_report() - new_request_usage = report.request_usage - # The request usage goes up for the database summary request, not the - # query. - assert new_request_usage == original_request_usage +__all__ = [ + "test_bad_target_request", + "test_query_request", + "test_target_request", +] diff --git a/tests/mock_vws/database_summary/test_database_summary.py b/tests/mock_vws/database_summary/test_database_summary.py index 57f2147be..52464367e 100644 --- a/tests/mock_vws/database_summary/test_database_summary.py +++ b/tests/mock_vws/database_summary/test_database_summary.py @@ -1,39 +1,7 @@ -"""Additional database summary tests.""" +"""Compatibility imports for the packaged contract suite.""" -import io -import uuid - -import pytest -from vws import VWS - -from tests.mock_vws.database_summary.helpers import ( - wait_for_image_numbers, +from vws_mock_contract_tests.database_summary.test_database_summary import ( + test_processing_images, ) - -@pytest.mark.usefixtures("mock_only_vuforia") -def test_processing_images( - *, - vws_client: VWS, - image_file_success_state_low_rating: io.BytesIO, -) -> None: - """The number of images in the processing state is returned. - - The mock adapters report processing images immediately. Real Vuforia's - summaries are not reliable while targets are processing. - """ - _ = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image_file_success_state_low_rating, - active_flag=True, - application_metadata=None, - ) - - wait_for_image_numbers( - vws_client=vws_client, - active_images=0, - inactive_images=0, - failed_images=0, - processing_images=1, - ) +__all__ = ["test_processing_images"] diff --git a/tests/mock_vws/fixtures/__init__.py b/tests/mock_vws/fixtures/__init__.py index 3ab52191a..fa867c1a5 100644 --- a/tests/mock_vws/fixtures/__init__.py +++ b/tests/mock_vws/fixtures/__init__.py @@ -1 +1 @@ -"""Common fixtures.""" +"""Compatibility imports for contract test fixtures.""" diff --git a/tests/mock_vws/fixtures/credentials.py b/tests/mock_vws/fixtures/credentials.py index 3de02c7ac..70e51026c 100644 --- a/tests/mock_vws/fixtures/credentials.py +++ b/tests/mock_vws/fixtures/credentials.py @@ -1,81 +1,17 @@ -"""Credential data shared by API tests and backend adapters.""" - -from dataclasses import dataclass, field -from typing import Protocol, runtime_checkable - - -@runtime_checkable -class CloudDatabaseCredentials(Protocol): - """Read-only cloud database access supplied by a backend adapter. - - Shared HTTP helpers need credentials and the database ID, without - depending on the mock's database state or storage implementation. - """ - - @property - def database_id(self) -> str: - """The identifier used in recognition-report URLs.""" - ... - - @property - def server_access_key(self) -> str: - """The access key used to sign VWS requests.""" - ... - - @property - def server_secret_key(self) -> str: - """The secret key used to sign VWS requests.""" - ... - - @property - def client_access_key(self) -> str: - """The access key used to sign VWQ requests.""" - ... - - @property - def client_secret_key(self) -> str: - """The secret key used to sign VWQ requests.""" - ... - - -@runtime_checkable -class NamedCloudDatabaseCredentials(CloudDatabaseCredentials, Protocol): - """Read-only credentials and a database name supplied by an - adapter. - """ - - @property - def database_name(self) -> str: - """The name used in summaries and backend recognition controls.""" - ... - - -@dataclass(frozen=True, kw_only=True) -class InactiveVuMarkCloudDatabase: - """Credentials for an inactive VuMark database.""" - - target_manager_database_name: str = field(repr=False) - server_access_key: str = field(repr=False) - server_secret_key: str = field(repr=False) - - -@dataclass(frozen=True, kw_only=True) -class VuMarkCloudDatabase: - """Credentials for the VuMark generation API.""" - - target_manager_database_name: str = field(repr=False) - server_access_key: str = field(repr=False) - server_secret_key: str = field(repr=False) - target_id: str = field(repr=False) - processing_target_id: str = field(repr=False) - - -@dataclass(frozen=True, kw_only=True) -class ModelTargetCredentials: - """Credentials and input data for the Model Target Web API.""" - - client_id: str = field(repr=False) - client_secret: str = field(repr=False) - cad_data_url: str = field(repr=False) - username: str = field(repr=False) - password: str = field(repr=False) +"""Compatibility imports for the packaged contract suite.""" + +from vws_mock_contract_tests.fixtures.credentials import ( + CloudDatabaseCredentials, + InactiveVuMarkCloudDatabase, + ModelTargetCredentials, + NamedCloudDatabaseCredentials, + VuMarkCloudDatabase, +) + +__all__ = [ + "CloudDatabaseCredentials", + "InactiveVuMarkCloudDatabase", + "ModelTargetCredentials", + "NamedCloudDatabaseCredentials", + "VuMarkCloudDatabase", +] diff --git a/tests/mock_vws/fixtures/model_target_prepared_requests.py b/tests/mock_vws/fixtures/model_target_prepared_requests.py index cf0019127..54f275e93 100644 --- a/tests/mock_vws/fixtures/model_target_prepared_requests.py +++ b/tests/mock_vws/fixtures/model_target_prepared_requests.py @@ -1,197 +1,8 @@ -"""Fixtures which prepare Model Target Web API requests.""" +"""Compatibility imports for the packaged contract suite.""" -import json -from http import HTTPMethod, HTTPStatus - -import pytest -import requests -from beartype import beartype -from pydantic import JsonValue, TypeAdapter - -from tests.mock_vws.fixtures.credentials import ( - ModelTargetCredentials, +from vws_mock_contract_tests.fixtures.model_target_prepared_requests import ( + MODEL_TARGET_DATASET_UUID, + get_access_token, ) -from tests.mock_vws.utils import ModelTargetEndpoint -from tests.mock_vws.utils.assertions import assert_model_target_status - -MODEL_TARGET_DATASET_UUID = "0b12466eee5d49409a440927006ff5d8" - -_DATASET_REQUEST: dict[str, JsonValue] = { - "name": "dataset-name", - "targetSdk": "10.18", - "models": [ - { - "name": "model-name", - "cadDataUrl": "https://example.com/model.glb", - "views": [ - { - "name": "view-name", - "guideViewPosition": { - "translation": [0, 0, 5], - "rotation": [0, 0, 0, 1], - }, - }, - ], - }, - ], -} - - -@beartype -def get_access_token( - *, - base_vws_url: str, - credentials: ModelTargetCredentials, -) -> str: - """Return an OAuth2 access token.""" - response = requests.post( - url=f"{base_vws_url}/oauth2/token", - auth=(credentials.client_id, credentials.client_secret), - data={"grant_type": "client_credentials"}, - timeout=30, - ) - - assert_model_target_status( - response=response, - status_codes=HTTPStatus.OK, - ) - response_json = TypeAdapter(type=dict[str, JsonValue]).validate_json( - response.text, - ) - access_token = response_json["access_token"] - assert isinstance(access_token, str) - assert response_json["token_type"] == "bearer" - return access_token - - -@beartype -def _create_dataset_endpoint( - *, base_vws_url: str, request_path: str -) -> ModelTargetEndpoint: - """Return details of a dataset creation endpoint.""" - content = json.dumps(obj=_DATASET_REQUEST).encode(encoding="utf-8") - headers = { - "Content-Length": str(object=len(content)), - "Content-Type": "application/json", - } - return ModelTargetEndpoint( - base_url=base_vws_url, - path_url=request_path, - method=HTTPMethod.POST, - headers=headers, - data=content, - takes_json_body=True, - ) - - -@beartype -def _get_endpoint( - *, base_vws_url: str, request_path: str -) -> ModelTargetEndpoint: - """Return details of a body-less ``GET`` endpoint.""" - return ModelTargetEndpoint( - base_url=base_vws_url, - path_url=request_path, - method=HTTPMethod.GET, - headers={}, - data=b"", - takes_json_body=False, - ) - - -@beartype -def _delete_endpoint( - *, base_vws_url: str, request_path: str -) -> ModelTargetEndpoint: - """Return details of a body-less ``DELETE`` endpoint.""" - return ModelTargetEndpoint( - base_url=base_vws_url, - path_url=request_path, - method=HTTPMethod.DELETE, - headers={"Content-Length": "0"}, - data=b"", - takes_json_body=False, - ) - - -@pytest.fixture -def create_standard_dataset(*, base_vws_url: str) -> ModelTargetEndpoint: - """Return details of the endpoint for creating a standard dataset.""" - return _create_dataset_endpoint( - base_vws_url=base_vws_url, request_path="/modeltargets/datasets" - ) - - -@pytest.fixture -def create_advanced_dataset(*, base_vws_url: str) -> ModelTargetEndpoint: - """Return details of the endpoint for creating an advanced dataset.""" - return _create_dataset_endpoint( - base_vws_url=base_vws_url, - request_path="/modeltargets/advancedDatasets", - ) - - -@pytest.fixture -def standard_dataset_status(*, base_vws_url: str) -> ModelTargetEndpoint: - """Return details of the standard dataset status endpoint.""" - return _get_endpoint( - base_vws_url=base_vws_url, - request_path=( - f"/modeltargets/datasets/{MODEL_TARGET_DATASET_UUID}/status" - ), - ) - - -@pytest.fixture -def advanced_dataset_status(*, base_vws_url: str) -> ModelTargetEndpoint: - """Return details of the advanced dataset status endpoint.""" - return _get_endpoint( - base_vws_url=base_vws_url, - request_path=( - f"/modeltargets/advancedDatasets/{MODEL_TARGET_DATASET_UUID}" - "/status" - ), - ) - - -@pytest.fixture -def download_standard_dataset(*, base_vws_url: str) -> ModelTargetEndpoint: - """Return details of the standard dataset download endpoint.""" - return _get_endpoint( - base_vws_url=base_vws_url, - request_path=( - f"/modeltargets/datasets/{MODEL_TARGET_DATASET_UUID}/dataset" - ), - ) - - -@pytest.fixture -def download_advanced_dataset(*, base_vws_url: str) -> ModelTargetEndpoint: - """Return details of the advanced dataset download endpoint.""" - return _get_endpoint( - base_vws_url=base_vws_url, - request_path=( - f"/modeltargets/advancedDatasets/{MODEL_TARGET_DATASET_UUID}" - "/dataset" - ), - ) - - -@pytest.fixture -def delete_standard_dataset(*, base_vws_url: str) -> ModelTargetEndpoint: - """Return details of the standard dataset deletion endpoint.""" - return _delete_endpoint( - base_vws_url=base_vws_url, - request_path=f"/modeltargets/datasets/{MODEL_TARGET_DATASET_UUID}", - ) - -@pytest.fixture -def delete_advanced_dataset(*, base_vws_url: str) -> ModelTargetEndpoint: - """Return details of the advanced dataset deletion endpoint.""" - return _delete_endpoint( - base_vws_url=base_vws_url, - request_path=( - f"/modeltargets/advancedDatasets/{MODEL_TARGET_DATASET_UUID}" - ), - ) +__all__ = ["MODEL_TARGET_DATASET_UUID", "get_access_token"] diff --git a/tests/mock_vws/fixtures/plugin.py b/tests/mock_vws/fixtures/plugin.py index 199c7e6cf..d9ef8aef9 100644 --- a/tests/mock_vws/fixtures/plugin.py +++ b/tests/mock_vws/fixtures/plugin.py @@ -1,7 +1,3 @@ -"""Load shared API fixtures independently of backend adapters.""" +"""Compatibility entry point for explicit pytest plugins.""" -pytest_plugins = [ - "tests.mock_vws.fixtures.prepared_requests", - "tests.mock_vws.fixtures.model_target_prepared_requests", - "tests.mock_vws.fixtures.api_contract", -] +pytest_plugins = ["vws_mock_contract_tests.fixtures.plugin"] diff --git a/tests/mock_vws/fixtures/real_credentials.py b/tests/mock_vws/fixtures/real_credentials.py index cff23ed2f..314bb9d82 100644 --- a/tests/mock_vws/fixtures/real_credentials.py +++ b/tests/mock_vws/fixtures/real_credentials.py @@ -1,164 +1,7 @@ -"""Load credentials for the explicit real-Vuforia backend.""" +"""Compatibility imports for the packaged contract suite.""" -from pathlib import Path -from uuid import uuid4 - -from pydantic import Field -from pydantic_settings import BaseSettings, SettingsConfigDict - -from mock_vws.database import CloudDatabase -from mock_vws.states import States -from tests.mock_vws.fixtures.credentials import ( - InactiveVuMarkCloudDatabase, - ModelTargetCredentials, - VuMarkCloudDatabase, +from vws_mock_test_backends.real_credentials import ( + get_model_target_credentials, ) - -class _CloudDatabaseSettings(BaseSettings): - """Settings for a Vuforia database.""" - - target_manager_database_name: str - server_access_key: str - server_secret_key: str - client_access_key: str - client_secret_key: str - - model_config = SettingsConfigDict( - env_prefix="VUFORIA_", - env_file=Path("vuforia_secrets.env"), - extra="allow", - ) - - -class _WorkingCloudDatabaseSettings(_CloudDatabaseSettings): - """Settings for the working Vuforia database. - - Only the working database has an ID, because only endpoints which name - a database in their path need one. - """ - - database_id: str - - -class _InactiveCloudDatabaseSettings(_CloudDatabaseSettings): - """Settings for an inactive Vuforia database.""" - - model_config = SettingsConfigDict( - env_prefix="INACTIVE_VUFORIA_", - env_file=Path("vuforia_secrets.env"), - extra="allow", - ) - - -class _InactiveVuMarkDatabaseSettings(BaseSettings): - """Settings for an inactive VuMark database.""" - - target_manager_database_name: str - server_access_key: str - server_secret_key: str - - model_config = SettingsConfigDict( - env_prefix="INACTIVE_VUMARK_VUFORIA_", - env_file=Path("vuforia_secrets.env"), - extra="allow", - ) - - -class _VuMarkCloudDatabaseSettings(BaseSettings): - """Settings for a VuMark Vuforia database.""" - - target_manager_database_name: str - server_access_key: str - server_secret_key: str - target_id: str - processing_target_id: str = Field(default_factory=lambda: uuid4().hex) - - model_config = SettingsConfigDict( - env_prefix="VUMARK_VUFORIA_", - env_file=Path("vuforia_secrets.env"), - extra="allow", - ) - - -class _ModelTargetSettings(BaseSettings): - """Settings for the Model Target Web API.""" - - client_id: str - client_secret: str - cad_data_url: str - username: str - password: str - - model_config = SettingsConfigDict( - env_prefix="MODEL_TARGET_VUFORIA_", - env_file=Path("vuforia_secrets.env"), - extra="allow", - ) - - -def get_model_target_credentials() -> ModelTargetCredentials: - """Return Model Target Web API credentials from environment - variables. - """ - settings = _ModelTargetSettings.model_validate(obj={}) - return ModelTargetCredentials( - client_id=settings.client_id, - client_secret=settings.client_secret, - cad_data_url=settings.cad_data_url, - username=settings.username, - password=settings.password, - ) - - -def get_real_cloud_database() -> CloudDatabase: - """Return VWS credentials from environment variables.""" - settings = _WorkingCloudDatabaseSettings.model_validate(obj={}) - return CloudDatabase( - database_id=settings.database_id, - database_name=settings.target_manager_database_name, - server_access_key=settings.server_access_key, - server_secret_key=settings.server_secret_key, - client_access_key=settings.client_access_key, - client_secret_key=settings.client_secret_key, - state=States.WORKING, - ) - - -def get_real_inactive_cloud_database() -> CloudDatabase: - """ - Return VWS credentials for an inactive project from environment - variables. - """ - settings = _InactiveCloudDatabaseSettings.model_validate(obj={}) - return CloudDatabase( - database_name=settings.target_manager_database_name, - server_access_key=settings.server_access_key, - server_secret_key=settings.server_secret_key, - client_access_key=settings.client_access_key, - client_secret_key=settings.client_secret_key, - state=States.PROJECT_INACTIVE, - ) - - -def get_real_inactive_vumark_database() -> InactiveVuMarkCloudDatabase: - """Return inactive VuMark credentials from environment variables.""" - settings = _InactiveVuMarkDatabaseSettings.model_validate(obj={}) - return InactiveVuMarkCloudDatabase( - target_manager_database_name=settings.target_manager_database_name, - server_access_key=settings.server_access_key, - server_secret_key=settings.server_secret_key, - ) - - -def get_real_vumark_database() -> VuMarkCloudDatabase: - """Return VuMark VWS credentials from environment variables.""" - settings = _VuMarkCloudDatabaseSettings.model_validate(obj={}) - - return VuMarkCloudDatabase( - target_manager_database_name=settings.target_manager_database_name, - server_access_key=settings.server_access_key, - server_secret_key=settings.server_secret_key, - target_id=settings.target_id, - processing_target_id=settings.processing_target_id, - ) +__all__ = ["get_model_target_credentials"] diff --git a/tests/mock_vws/fixtures/real_model_target_retries.py b/tests/mock_vws/fixtures/real_model_target_retries.py index 8271f423a..d257b5165 100644 --- a/tests/mock_vws/fixtures/real_model_target_retries.py +++ b/tests/mock_vws/fixtures/real_model_target_retries.py @@ -1,84 +1,8 @@ -"""Retrying transient failures from the real Model Target Web API. +"""Compatibility imports for the packaged contract suite.""" -The real Model Target Web API sits behind a load balancer which -occasionally answers a perfectly good request with a gateway error, or -drops the connection. That has nothing to do with the contract under -test, so a bounded number of retries for requests which are safe to -repeat keeps a one-off gateway error from failing CI. - -Only requests to the real backend are retried. The mock backends -answer immediately, and some tests configure a mock to return a 5xx -deliberately, so retrying those would be both pointless and wrong. -The policy is therefore switched on for the duration of a test against -the real Model Target backend and is off at all other times. - -Only ``GET`` requests are retried. Repeating a dataset creation -``POST`` can create a second dataset and can consume the account's -Model Target training allowance, and the outcome of a repeat is -ambiguous, so mutating requests are sent exactly once. -""" - -from http import HTTPStatus - -import requests -from beartype import beartype -from tenacity import RetryCallState, Retrying -from tenacity.retry import retry_if_exception_type, retry_if_result -from tenacity.stop import stop_after_attempt -from tenacity.wait import wait_exponential_jitter -from vws.response import Response - -# Status codes which mean "the gateway could not get an answer from -# Vuforia", rather than "Vuforia answered, and this is the answer". -TRANSIENT_MODEL_TARGET_STATUS_CODES = frozenset( - { - HTTPStatus.BAD_GATEWAY, - HTTPStatus.SERVICE_UNAVAILABLE, - HTTPStatus.GATEWAY_TIMEOUT, - }, -) - -# Transport failures which mean the request may never have been -# answered. -TRANSIENT_MODEL_TARGET_EXCEPTIONS = ( - requests.exceptions.ConnectionError, - requests.exceptions.Timeout, +from vws_mock_test_backends.real_model_target_retries import ( + MODEL_TARGET_RETRY_ATTEMPTS, + REAL_MODEL_TARGET_RETRY_POLICY, ) -# The total number of times a safe request is sent, including the first -# attempt. This is deliberately small: a persistent upstream failure -# must still fail the test quickly. -MODEL_TARGET_RETRY_ATTEMPTS = 3 - - -@beartype -def _is_transient(response: Response | requests.Response) -> bool: - """Whether a response is a transient gateway failure.""" - return response.status_code in TRANSIENT_MODEL_TARGET_STATUS_CODES - - -@beartype -def _last_outcome(retry_state: RetryCallState) -> Response | requests.Response: - """Give up with the outcome of the last attempt. - - A transient failure on the last attempt is not hidden: a response is - returned as it is, so that the caller's assertion reports the real - status and body, and a transport failure propagates. - """ - assert retry_state.outcome is not None - outcome = retry_state.outcome.result() - assert isinstance(outcome, Response | requests.Response) - return outcome - - -REAL_MODEL_TARGET_RETRY_POLICY = Retrying( - retry=( - retry_if_exception_type( - exception_types=TRANSIENT_MODEL_TARGET_EXCEPTIONS, - ) - | retry_if_result(predicate=_is_transient) - ), - stop=stop_after_attempt(max_attempt_number=MODEL_TARGET_RETRY_ATTEMPTS), - wait=wait_exponential_jitter(initial=1, max=10), - retry_error_callback=_last_outcome, -) +__all__ = ["MODEL_TARGET_RETRY_ATTEMPTS", "REAL_MODEL_TARGET_RETRY_POLICY"] diff --git a/tests/mock_vws/fixtures/vuforia_backends.py b/tests/mock_vws/fixtures/vuforia_backends.py index 4e958e0fe..c263bf742 100644 --- a/tests/mock_vws/fixtures/vuforia_backends.py +++ b/tests/mock_vws/fixtures/vuforia_backends.py @@ -1,1147 +1,9 @@ -"""Choose which backends to use for the tests.""" +"""Compatibility entry point for explicit pytest plugins.""" -import contextlib -import gzip -import logging -import os -from collections.abc import Generator -from enum import Enum -from http import HTTPMethod -from typing import override -from uuid import uuid4 - -import pytest -import requests -import responses -from beartype import BeartypeConf, beartype -from pytest_multi_backend import backend_fixture -from requests.exceptions import Timeout as RequestsTimeout -from requests_mock_flask import add_flask_app_to_mock -from vws import VWS -from vws.exceptions.vws_exceptions import ( - TargetStatusNotSuccessError, -) -from vws.response import Response - -from mock_vws import MockVWS -from mock_vws._flask_server.target_manager import TARGET_MANAGER_FLASK_APP -from mock_vws._flask_server.vwq import CLOUDRECO_FLASK_APP -from mock_vws._flask_server.vws import VWS_FLASK_APP -from mock_vws.database import CloudDatabase, VuMarkDatabase -from mock_vws.request_rate_limits import DOCUMENTED_REQUEST_RATE_LIMITS -from mock_vws.states import States -from mock_vws.target import VuMarkTarget -from tests.mock_vws.fixtures.credentials import ( - InactiveVuMarkCloudDatabase, - ModelTargetCredentials, - NamedCloudDatabaseCredentials, - VuMarkCloudDatabase, -) -from tests.mock_vws.fixtures.real_credentials import ( - get_model_target_credentials, - get_real_cloud_database, - get_real_inactive_cloud_database, - get_real_inactive_vumark_database, - get_real_vumark_database, -) -from tests.mock_vws.fixtures.real_model_target_retries import ( - REAL_MODEL_TARGET_RETRY_POLICY, -) -from tests.mock_vws.fixtures.real_vws_retries import REAL_VWS_RETRYING -from tests.mock_vws.utils.backend_capabilities import BackendCapabilities -from tests.mock_vws.utils.cloud_databases import CreateCloudDatabase -from tests.mock_vws.utils.model_target_response_policy import ( - handling_unexpected_model_target_responses, +from vws_mock_test_backends.vuforia_backends import ( + VuforiaBackend, ) -from tests.mock_vws.utils.model_target_retries import ( - retrying_model_target_requests, -) -from tests.mock_vws.utils.recognition_counts import SeedRecognitionCounts -from tests.mock_vws.utils.request_timeout_policy import ( - handling_request_timeouts, -) -from tests.mock_vws.utils.retries import ( - RETRY_ON_TARGET_STILL_PROCESSING, - retry_transient_vws_failures, - retrying_vws_requests, -) -from tests.mock_vws.utils.unrouted_requests import ( - UnroutedRequests, - UnroutedResponse, -) - -LOGGER = logging.getLogger(name=__name__) -LOGGER.setLevel(level=logging.DEBUG) - - -@pytest.fixture(name="base_vws_url") -def fixture_base_vws_url() -> str: - """The Vuforia Web Services API URL for the backend.""" - return "https://vws.vuforia.com" - - -@pytest.fixture(name="base_vwq_url") -def fixture_base_vwq_url() -> str: - """The Vuforia Web Query API URL for the backend.""" - return "https://cloudreco.vuforia.com" - - -# The in-memory mock which the ``MOCK`` backend is running, if any. -# -# Tests which use APIs of the mock itself, such as seeding recognition counts, -# need the mock which the fixture created. This is a list so that the fixture -# can set and unset it without a ``global`` statement. -_RUNNING_IN_MEMORY_MOCKS: list[MockVWS] = [] - - -@beartype -def running_in_memory_mock() -> MockVWS: - """The in-memory mock which the ``MOCK`` backend is running. - - Returns: - The mock which the ``MOCK`` backend set up for the running test. - Indexing fails if the ``MOCK`` backend is not running. - """ - return _RUNNING_IN_MEMORY_MOCKS[0] - - -@beartype -@retry_transient_vws_failures -def _delete_all_targets( - *, database_keys: CloudDatabase, base_vws_url: str -) -> None: - """Delete all targets. - - Args: - database_keys: The credentials to the Vuforia target database to delete - all targets in. - base_vws_url: The base URL of the services API for the backend. - """ - vws_client = VWS( - server_access_key=database_keys.server_access_key, - server_secret_key=database_keys.server_secret_key, - base_vws_url=base_vws_url, - ) - - targets = vws_client.list_targets() - - for target in targets: - vws_client.wait_for_target_processed( - target_id=target, - # Setting this to 2 is an attempt to avoid 429 Too Many Requests - # errors. - seconds_between_requests=2, - ) - # Even deleted targets can be matched by a query for a few seconds so - # we change the target to inactive before deleting it. - with contextlib.suppress(TargetStatusNotSuccessError): - vws_client.update_target(target_id=target, active_flag=False) - vws_client.wait_for_target_processed( - target_id=target, seconds_between_requests=2 - ) - _delete_processed_target(vws_client=vws_client, target_id=target) - - -@beartype -@RETRY_ON_TARGET_STILL_PROCESSING -def _delete_processed_target(*, vws_client: VWS, target_id: str) -> None: - """Delete a target which has just been waited on to be processed. - - Args: - vws_client: A client for the database the target is in. - target_id: The ID of the target to delete. - """ - vws_client.delete_target(target_id=target_id) - - -@beartype -def _vumark_database( - *, - vumark_vuforia_database: VuMarkCloudDatabase, -) -> VuMarkDatabase: - """Return a database with a VuMark target for VuMark instance - generation. - """ - vumark_target = VuMarkTarget( - name="mock-vumark-target", - target_id=vumark_vuforia_database.target_id, - ) - processing_target = VuMarkTarget( - name="mock-processing-vumark-target", - target_id=vumark_vuforia_database.processing_target_id, - processing_time_seconds=9999, - ) - return VuMarkDatabase( - database_name=vumark_vuforia_database.target_manager_database_name, - server_access_key=vumark_vuforia_database.server_access_key, - server_secret_key=vumark_vuforia_database.server_secret_key, - vumark_targets={vumark_target, processing_target}, - ) - - -@beartype -def _handle_real_request_timeout(*, url: str, error: RequestsTimeout) -> None: - """Skip a shared request which timed out against the real service.""" - del url, error - pytest.skip(reason="The real Vuforia service timed out.") - - -@beartype -def _enable_use_real_vuforia( - *, - working_database: CloudDatabase, - inactive_cloud_database: CloudDatabase, - vumark_vuforia_database: VuMarkCloudDatabase, - inactive_vumark_database: InactiveVuMarkCloudDatabase, - monkeypatch: pytest.MonkeyPatch, - base_vws_url: str, - base_vwq_url: str, -) -> Generator[None]: - """Test against the real Vuforia.""" - assert bool(monkeypatch) - assert bool(inactive_cloud_database) - assert bool(vumark_vuforia_database) - assert bool(inactive_vumark_database) - del base_vwq_url - with ( - retrying_vws_requests(retrying=REAL_VWS_RETRYING), - handling_request_timeouts(handler=_handle_real_request_timeout), - ): - _delete_all_targets( - database_keys=working_database, base_vws_url=base_vws_url - ) - yield - - -@beartype -def _enable_use_mock_vuforia( - *, - working_database: CloudDatabase, - inactive_cloud_database: CloudDatabase, - vumark_vuforia_database: VuMarkCloudDatabase, - inactive_vumark_database: InactiveVuMarkCloudDatabase, - monkeypatch: pytest.MonkeyPatch, - base_vws_url: str, - base_vwq_url: str, -) -> Generator[None]: - """Test against the in-memory mock Vuforia.""" - assert bool(monkeypatch) - working_database = CloudDatabase( - database_id=working_database.database_id, - database_name=working_database.database_name, - server_access_key=working_database.server_access_key, - server_secret_key=working_database.server_secret_key, - client_access_key=working_database.client_access_key, - client_secret_key=working_database.client_secret_key, - ) - - inactive_cloud_database = CloudDatabase( - state=States.PROJECT_INACTIVE, - database_name=inactive_cloud_database.database_name, - server_access_key=inactive_cloud_database.server_access_key, - server_secret_key=inactive_cloud_database.server_secret_key, - client_access_key=inactive_cloud_database.client_access_key, - client_secret_key=inactive_cloud_database.client_secret_key, - ) - vumark_database = _vumark_database( - vumark_vuforia_database=vumark_vuforia_database, - ) - inactive_vumark_db = VuMarkDatabase( - state=States.PROJECT_INACTIVE, - database_name=inactive_vumark_database.target_manager_database_name, - server_access_key=inactive_vumark_database.server_access_key, - server_secret_key=inactive_vumark_database.server_secret_key, - ) - - with MockVWS(base_vws_url=base_vws_url, base_vwq_url=base_vwq_url) as mock: - mock.add_cloud_database(cloud_database=working_database) - mock.add_cloud_database(cloud_database=inactive_cloud_database) - mock.add_vumark_database(vumark_database=vumark_database) - mock.add_vumark_database(vumark_database=inactive_vumark_db) - _RUNNING_IN_MEMORY_MOCKS.append(mock) - try: - yield - finally: - _RUNNING_IN_MEMORY_MOCKS.remove(mock) - - -@beartype -def _enable_use_flask_in_process( - *, - working_database: CloudDatabase, - inactive_cloud_database: CloudDatabase, - vumark_vuforia_database: VuMarkCloudDatabase, - inactive_vumark_database: InactiveVuMarkCloudDatabase, - monkeypatch: pytest.MonkeyPatch, - base_vws_url: str, - base_vwq_url: str, -) -> Generator[None]: - """Test against the Flask applications of the mock, in this process. - - This exercises the handlers of the Flask applications which the - Docker deployment runs, but not the deployment itself: all three - applications are served from one process, so state which the - deployment keeps in separate containers is shared here. - ``tests/mock_vws/test_docker.py`` covers the container split. - """ - # We set ``wsgi.input_terminated`` to ``True`` so that when going through - # ``requests`` in our tests, the Flask applications - # have the given ``Content-Length`` headers and the given data in - # ``request.headers`` and ``request.data``. - # - # We do not set these in the Flask application itself. - # This is because when running the Flask application, if this is set, - # reading ``request.data`` hangs. - # - # Therefore, when running the real Flask application, the behavior is not - # the same as the real Vuforia. - # This is documented as a difference in the documentation for this package. - VWS_FLASK_APP.config["VWS_MOCK_TERMINATE_WSGI_INPUT"] = True - CLOUDRECO_FLASK_APP.config["VWS_MOCK_TERMINATE_WSGI_INPUT"] = True - - monkeypatch.setenv(name="VWS_BASE_URL", value=base_vws_url) - target_manager_base_url = "http://example.com" - monkeypatch.setenv( - name="TARGET_MANAGER_BASE_URL", - value=target_manager_base_url, - ) - vumark_database = _vumark_database( - vumark_vuforia_database=vumark_vuforia_database, - ) - inactive_vumark_db = VuMarkDatabase( - state=States.PROJECT_INACTIVE, - database_name=inactive_vumark_database.target_manager_database_name, - server_access_key=inactive_vumark_database.server_access_key, - server_secret_key=inactive_vumark_database.server_secret_key, - ) - - with responses.RequestsMock(assert_all_requests_are_fired=False) as mock: - add_flask_app_to_mock( - mock_obj=mock, - flask_app=VWS_FLASK_APP, - base_url=base_vws_url, - ) - - add_flask_app_to_mock( - mock_obj=mock, - flask_app=CLOUDRECO_FLASK_APP, - base_url=base_vwq_url, - ) - - add_flask_app_to_mock( - mock_obj=mock, - flask_app=TARGET_MANAGER_FLASK_APP, - base_url=target_manager_base_url, - ) - - cloud_databases_url = target_manager_base_url + "/cloud_databases" - vumark_databases_url = target_manager_base_url + "/vumark_databases" - - for database in requests.get( - url=cloud_databases_url, timeout=30 - ).json(): - database_name: object = database["database_name"] - assert isinstance(database_name, str) - _ = requests.delete( - url=cloud_databases_url + "/" + database_name, - timeout=30, - ) - for database in requests.get( - url=vumark_databases_url, timeout=30 - ).json(): - database_name = database["database_name"] - assert isinstance(database_name, str) - _ = requests.delete( - url=vumark_databases_url + "/" + database_name, - timeout=30, - ) - - _ = requests.post( - url=cloud_databases_url, - json=working_database.to_dict(), - timeout=30, - ) - _ = requests.post( - url=cloud_databases_url, - json=inactive_cloud_database.to_dict(), - timeout=30, - ) - _ = requests.post( - url=vumark_databases_url, - json=vumark_database.to_dict(), - timeout=30, - ) - _ = requests.post( - url=vumark_databases_url, - json=inactive_vumark_db.to_dict(), - timeout=30, - ) - for vumark_target in vumark_database.vumark_targets: - _ = requests.post( - url=( - f"{vumark_databases_url}" - f"/{vumark_database.database_name}/vumark_targets" - ), - json=vumark_target.to_dict(), - timeout=30, - ) - - yield - - -@beartype -def _handle_real_model_target_response( - *, - response: Response | requests.Response, -) -> None: - """Skip an unexpected rejection by the shared real account's allowance.""" - if "TRAINING_ALLOWANCE_EXCEEDED" in response.text: - pytest.skip( - reason=( - "The Vuforia account is out of Model Target training " - "allowance - this is not a failure of the code under test." - ), - ) - - -@beartype -def _enable_use_real_model_target_vuforia( - *, - monkeypatch: pytest.MonkeyPatch, - base_vws_url: str, -) -> Generator[None]: - """Test against the real Model Target Web API. - - Safe requests are retried past a transient gateway failure, which - the load balancer in front of the real Model Target Web API - occasionally returns for a request which is not at fault. - """ - del base_vws_url - assert bool(monkeypatch) - with ( - retrying_model_target_requests( - retrying=REAL_MODEL_TARGET_RETRY_POLICY, - ), - handling_request_timeouts(handler=_handle_real_request_timeout), - handling_unexpected_model_target_responses( - handler=_handle_real_model_target_response, - ), - ): - yield - - -@beartype -def _enable_use_mock_model_target_vuforia( - *, - monkeypatch: pytest.MonkeyPatch, - base_vws_url: str, -) -> Generator[None]: - """Test against the in-memory mock Model Target Web API.""" - assert bool(monkeypatch) - with MockVWS(base_vws_url=base_vws_url): - yield - - -@beartype -def _enable_use_flask_in_process_model_target_vuforia( - *, - monkeypatch: pytest.MonkeyPatch, - base_vws_url: str, -) -> Generator[None]: - """Test against the Flask-backed mock Model Target Web API.""" - assert bool(monkeypatch) - VWS_FLASK_APP.config["VWS_MOCK_TERMINATE_WSGI_INPUT"] = True - target_manager_base_url = "http://example.com" - monkeypatch.setenv( - name="TARGET_MANAGER_BASE_URL", - value=target_manager_base_url, - ) - - with responses.RequestsMock(assert_all_requests_are_fired=False) as mock: - add_flask_app_to_mock( - mock_obj=mock, - flask_app=VWS_FLASK_APP, - base_url=base_vws_url, - ) - - # The VWS app stores Model Target datasets in the target manager - # service, just as it does cloud databases. - add_flask_app_to_mock( - mock_obj=mock, - flask_app=TARGET_MANAGER_FLASK_APP, - base_url=target_manager_base_url, - ) - - yield - - -class VuforiaBackend(Enum): - """Backends for tests. - - The names of the members give the ``--skip-backend`` options, and - the values give the test IDs. - """ - - REAL = "Real Vuforia" - MOCK = "In Memory Mock Vuforia" - FLASK_IN_PROCESS = "In Process Flask Applications" - - -_ALL_BACKENDS = list(VuforiaBackend) -# The real Vuforia cannot be set up for tests which need to control the -# state of the service. -_MOCK_BACKENDS = [ - backend for backend in _ALL_BACKENDS if backend != VuforiaBackend.REAL -] - - -@beartype -def _credential_backend(*, request: pytest.FixtureRequest) -> VuforiaBackend: - """Read the active VWS selector without adding another backend matrix. - - Implementation tests which manage their own mock have no selector and - receive mock credentials. - """ - for fixture_name in ("_vws_backend", "_mock_vws_backend"): - if fixture_name in request.fixturenames: - backend = request.getfixturevalue(argname=fixture_name) - assert isinstance(backend, VuforiaBackend) - return backend - return VuforiaBackend.MOCK - - -@beartype(conf=BeartypeConf(is_pep484_tower=True)) -class _LiveVWS(VWS): - """A client with a polling default suited to the live contract - tests. - """ - - # Preserve the optional parameters of the upstream VWS API. - @override - def wait_for_target_processed( - self, - *, - target_id: str, - seconds_between_requests: float = 2.0, # noqa: NOD001 - timeout_seconds: float = 60 * 5, # noqa: NOD001 - ) -> None: - """Poll at the cleanup interval while retaining explicit overrides.""" - super().wait_for_target_processed( - target_id=target_id, - seconds_between_requests=seconds_between_requests, - timeout_seconds=timeout_seconds, - ) - - -@pytest.fixture(name="vws_client_class") -def fixture_vws_client_class(*, request: pytest.FixtureRequest) -> type[VWS]: - """Use slower polling only for clients of the live Vuforia service.""" - if _credential_backend(request=request) == VuforiaBackend.REAL: - return _LiveVWS - return VWS - - -@pytest.fixture(name="vuforia_database") -def fixture_vuforia_database( - *, request: pytest.FixtureRequest -) -> CloudDatabase: - """An active database with credentials supplied by the backend.""" - if _credential_backend(request=request) == VuforiaBackend.REAL: - return get_real_cloud_database() - return CloudDatabase() - - -@pytest.fixture(name="inactive_cloud_database") -def fixture_inactive_cloud_database( - *, request: pytest.FixtureRequest -) -> CloudDatabase: - """An inactive database with credentials supplied by the backend.""" - if _credential_backend(request=request) == VuforiaBackend.REAL: - return get_real_inactive_cloud_database() - return CloudDatabase(state=States.PROJECT_INACTIVE) - - -@pytest.fixture(name="inactive_vumark_database") -def fixture_inactive_vumark_database( - *, request: pytest.FixtureRequest -) -> InactiveVuMarkCloudDatabase: - """An inactive VuMark database supplied by the backend.""" - if _credential_backend(request=request) == VuforiaBackend.REAL: - return get_real_inactive_vumark_database() - return InactiveVuMarkCloudDatabase( - target_manager_database_name=uuid4().hex, - server_access_key=uuid4().hex, - server_secret_key=uuid4().hex, - ) - - -@pytest.fixture(name="vumark_vuforia_database") -def fixture_vumark_vuforia_database( - *, request: pytest.FixtureRequest -) -> VuMarkCloudDatabase: - """A VuMark database with credentials supplied by the backend.""" - if _credential_backend(request=request) == VuforiaBackend.REAL: - return get_real_vumark_database() - return VuMarkCloudDatabase( - target_manager_database_name=uuid4().hex, - server_access_key=uuid4().hex, - server_secret_key=uuid4().hex, - target_id=uuid4().hex, - processing_target_id=uuid4().hex, - ) - - -# These deliberately have no type annotation, so that the keyword -# arguments of the setup functions are still checked where they are -# bound. -_SETUP_FUNCTIONS = { - VuforiaBackend.REAL: _enable_use_real_vuforia, - VuforiaBackend.MOCK: _enable_use_mock_vuforia, - VuforiaBackend.FLASK_IN_PROCESS: _enable_use_flask_in_process, -} - -_MODEL_TARGET_SETUP_FUNCTIONS = { - VuforiaBackend.REAL: _enable_use_real_model_target_vuforia, - VuforiaBackend.MOCK: _enable_use_mock_model_target_vuforia, - VuforiaBackend.FLASK_IN_PROCESS: ( - _enable_use_flask_in_process_model_target_vuforia - ), -} - - -# Signed Model Target requests, including state-based advanced datasets and -# standard datasets with inline CAD data, consume the Vuforia account's Model -# Target training allowance. The allowance is small, shared across all CI -# jobs, and cannot be raised or reset, so signed requests run against the real -# Vuforia only when this option is given. -VERIFY_MODEL_TARGET_SIGNING_OPTION = "--verify-model-target-signing" -_INVALID_JSON_REAL_NODE_ID_PREFIXES = tuple( - f"tests/mock_vws/invalid_json/test_invalid_json.py::{test_name}[" - "Real Vuforia-" - for test_name in ("test_not_json", "test_not_an_object", "test_not_utf_8") -) -_INVALID_JSON_REAL_SKIP_REASON = ( - "Real Vuforia can leave malformed-body requests open indefinitely; the " - "mock backends still verify this contract." -) -_RECO_COUNTS_REAL_NODE_ID_PART = "[Real Vuforia-reco_counts_report" -_RECO_COUNTS_REAL_SKIP_REASON = ( - "The reco counts report endpoint is unavailable to the real-Vuforia " - "test account; the mock backends still verify this contract." -) - - -@beartype -def pytest_addoption(parser: pytest.Parser) -> None: - """Add the option which opts in to signed Model Target requests. - - The options which skip backends and the Docker build tests come - from ``pytest-multi-backend``. - """ - parser.addoption( - VERIFY_MODEL_TARGET_SIGNING_OPTION, - action="store_true", - default=False, - help=( - "Run signed Model Target dataset tests against the real " - "Vuforia. These consume the account's small, shared, " - "non-resettable Model Target training allowance, so they " - "run against the mock backends only by default." - ), - ) - - -@beartype -def pytest_collection_modifyitems( - config: pytest.Config, - items: list[pytest.Item], -) -> None: - """Apply infrastructure-specific test skips.""" - del config - invalid_json_real_marker = pytest.mark.skip( - reason=_INVALID_JSON_REAL_SKIP_REASON, - ) - reco_counts_real_marker = pytest.mark.skip( - reason=_RECO_COUNTS_REAL_SKIP_REASON, - ) - for item in items: - if item.nodeid.startswith(_INVALID_JSON_REAL_NODE_ID_PREFIXES): - item.add_marker(marker=invalid_json_real_marker) - if _RECO_COUNTS_REAL_NODE_ID_PART in item.nodeid: - item.add_marker(marker=reco_counts_real_marker) - - -@beartype -def _setup_backend( - *, - backend: VuforiaBackend, - request: pytest.FixtureRequest, -) -> Generator[None]: - """Set a backend up with the databases which the tests use. - - Yields: - ``None``, once the backend is set up. - """ - yield from _SETUP_FUNCTIONS[backend]( - working_database=request.getfixturevalue(argname="vuforia_database"), - inactive_cloud_database=request.getfixturevalue( - argname="inactive_cloud_database", - ), - vumark_vuforia_database=request.getfixturevalue( - argname="vumark_vuforia_database", - ), - inactive_vumark_database=request.getfixturevalue( - argname="inactive_vumark_database", - ), - monkeypatch=request.getfixturevalue(argname="monkeypatch"), - base_vws_url=request.getfixturevalue(argname="base_vws_url"), - base_vwq_url=request.getfixturevalue(argname="base_vwq_url"), - ) - - -@beartype -def _setup_model_target_backend( - *, - backend: VuforiaBackend, - request: pytest.FixtureRequest, -) -> Generator[None]: - """Set a backend up for the Model Target Web API tests. - - Yields: - ``None``, once the backend is set up. - """ - yield from _MODEL_TARGET_SETUP_FUNCTIONS[backend]( - monkeypatch=request.getfixturevalue(argname="monkeypatch"), - base_vws_url=request.getfixturevalue(argname="base_vws_url"), - ) - - -@beartype -def _defer_backend_setup( - *, - backend: VuforiaBackend, - request: pytest.FixtureRequest, -) -> Generator[None]: - """Leave setup to the shared fixture after backend selection. - - Yields: - ``None``, without setting up the selected backend. - """ - del backend, request - yield - - -# These adapter-internal fixtures select and apply skip policy for a backend. -# Public fixtures below set up and tear down the selected backend without -# exposing that selection to the shared suite. -fixture_vws_backend = backend_fixture( - name="_vws_backend", - backends=_ALL_BACKENDS, - setup_for=_defer_backend_setup, -) -fixture_model_target_backend = backend_fixture( - name="_model_target_backend", - backends=_ALL_BACKENDS, - setup_for=_defer_backend_setup, -) -fixture_model_target_mock_backend = backend_fixture( - name="_model_target_mock_backend", - backends=_MOCK_BACKENDS, - setup_for=_defer_backend_setup, -) -fixture_mock_vws_backend = backend_fixture( - name="_mock_vws_backend", - backends=_MOCK_BACKENDS, - setup_for=_defer_backend_setup, -) - - -@pytest.fixture(name="verify_mock_vuforia") -def fixture_verify_mock_vuforia( - *, - _vws_backend: VuforiaBackend, - request: pytest.FixtureRequest, -) -> Generator[None]: - """Set up and tear down the selected VWS/query backend. - - Yields: - ``None``, once the backend is ready for shared tests. - """ - yield from _setup_backend(backend=_vws_backend, request=request) - - -@pytest.fixture(name="verify_model_target_mock_vuforia") -def fixture_verify_model_target_mock_vuforia( - *, - _model_target_backend: VuforiaBackend, - request: pytest.FixtureRequest, -) -> Generator[None]: - """Set up and tear down the selected Model Target backend. - - Yields: - ``None``, once the backend is ready for shared tests. - """ - yield from _setup_model_target_backend( - backend=_model_target_backend, - request=request, - ) - - -@pytest.fixture(name="model_target_mock_only_vuforia") -def fixture_model_target_mock_only_vuforia( - *, - _model_target_mock_backend: VuforiaBackend, - request: pytest.FixtureRequest, -) -> Generator[None]: - """Set up and tear down a Model Target mock backend. - - Yields: - ``None``, once the backend is ready for mock-only tests. - """ - yield from _setup_model_target_backend( - backend=_model_target_mock_backend, - request=request, - ) - - -@pytest.fixture(name="mock_only_vuforia") -def fixture_mock_only_vuforia( - *, - _mock_vws_backend: VuforiaBackend, - request: pytest.FixtureRequest, -) -> Generator[None]: - """Set up and tear down a VWS/query mock backend. - - Yields: - ``None``, once the backend is ready for mock-only tests. - """ - yield from _setup_backend(backend=_mock_vws_backend, request=request) - - -@beartype -def _add_cloud_database( - *, - backend: VuforiaBackend, - cloud_database: CloudDatabase, -) -> None: - """Add a database through the running mock backend.""" - if backend == VuforiaBackend.MOCK: - running_in_memory_mock().add_cloud_database( - cloud_database=cloud_database, - ) - return - - target_manager_base_url = os.environ["TARGET_MANAGER_BASE_URL"] - response = requests.post( - url=f"{target_manager_base_url}/cloud_databases", - json=cloud_database.to_dict(), - timeout=30, - ) - response.raise_for_status() - - -@pytest.fixture(name="create_cloud_database") -def fixture_create_cloud_database( - *, - mock_only_vuforia: None, - _mock_vws_backend: VuforiaBackend, -) -> CreateCloudDatabase: - """The database creation operation for the running mock backend.""" - del mock_only_vuforia - - @beartype - def create_database() -> NamedCloudDatabaseCredentials: - """Create a fresh database through the selected backend.""" - database = CloudDatabase() - _add_cloud_database( - backend=_mock_vws_backend, - cloud_database=database, - ) - return database - - return create_database - - -@pytest.fixture(name="rate_limited_database") -def fixture_rate_limited_database( - *, - verify_mock_vuforia: None, - _vws_backend: VuforiaBackend, - vuforia_database: CloudDatabase, -) -> CloudDatabase: - """Return a database which applies real Vuforia's request rate limits. - - The real database applies them itself. Each mock is given a fresh - database with the limits, so that the shared database which the other - tests use is not limited. - """ - del verify_mock_vuforia - if _vws_backend == VuforiaBackend.REAL: - return vuforia_database - - database = CloudDatabase( - request_rate_limits=DOCUMENTED_REQUEST_RATE_LIMITS - ) - _add_cloud_database(backend=_vws_backend, cloud_database=database) - return database - - -@pytest.fixture(name="seed_recognition_counts") -def fixture_seed_recognition_counts( - *, - mock_only_vuforia: None, - _mock_vws_backend: VuforiaBackend, -) -> SeedRecognitionCounts: - """The recognition-count operation for the running mock backend.""" - del mock_only_vuforia - if _mock_vws_backend == VuforiaBackend.MOCK: - - @beartype - def seed_in_memory_counts( - *, - database_name: str, - target_id: str, - current_month_recos: int, - previous_month_recos: int, - total_recos: int, - ) -> None: - """Set recognition counts through the running in-memory - mock. - """ - del database_name - running_in_memory_mock().set_target_recognition_counts( - target_id=target_id, - current_month_recos=current_month_recos, - previous_month_recos=previous_month_recos, - total_recos=total_recos, - ) - - return seed_in_memory_counts - - @beartype - def seed_flask_counts( - *, - database_name: str, - target_id: str, - current_month_recos: int, - previous_month_recos: int, - total_recos: int, - ) -> None: - """Set recognition counts through the target manager service.""" - target_manager_base_url = os.environ["TARGET_MANAGER_BASE_URL"] - response = requests.post( - url=( - f"{target_manager_base_url}/cloud_databases/" - f"{database_name}/targets/{target_id}/recognition_counts" - ), - json={ - "current_month_recos": current_month_recos, - "previous_month_recos": previous_month_recos, - "total_recos": total_recos, - }, - timeout=30, - ) - response.raise_for_status() - - return seed_flask_counts - - -@pytest.fixture(name="unrouted_requests") -def fixture_unrouted_requests( - *, - verify_mock_vuforia: None, - _vws_backend: VuforiaBackend, - base_vws_url: str, -) -> UnroutedRequests: - """The transport for unmatched requests on the running backend.""" - del verify_mock_vuforia - if _vws_backend == VuforiaBackend.FLASK_IN_PROCESS: - return UnroutedRequests( - send=_send_flask_unrouted_request, - returns_response=True, - ) - - @beartype - def send_http_request( - *, - method: HTTPMethod, - request_path: str, - headers: dict[str, str], - ) -> UnroutedResponse | None: - """Send an unmatched request to the configured VWS URL.""" - try: - response = requests.request( - method=method, - url=base_vws_url + request_path, - headers=headers, - timeout=30, - ) - except requests.exceptions.ConnectionError: - return None - - return UnroutedResponse( - status_code=response.status_code, - body=response.content, - content_type=response.headers.get("Content-Type"), - content_encoding=response.headers.get("Content-Encoding"), - upstream_service_time=response.headers.get( - "x-envoy-upstream-service-time", - ), - ) - - return UnroutedRequests( - send=send_http_request, - returns_response=_vws_backend != VuforiaBackend.MOCK, - ) - - -@beartype -def _send_flask_unrouted_request( - *, - method: HTTPMethod, - request_path: str, - headers: dict[str, str], -) -> UnroutedResponse: - """Send an unmatched request through the in-process Flask client.""" - # The ``responses`` library intercepts only the paths and methods - # which the Flask app routes, so requests to any other path never - # reach the app. A running container serves every path, so we drive - # the app with its own test client. - test_client_response = VWS_FLASK_APP.test_client().open( - request_path, - method=method, - headers=headers, - ) - response_body = test_client_response.data - content_encoding = test_client_response.headers.get( - key="Content-Encoding", - ) - if content_encoding == "gzip": - response_body = gzip.decompress(data=response_body) - return UnroutedResponse( - status_code=test_client_response.status_code, - body=response_body, - content_type=test_client_response.headers.get( - key="Content-Type", - ), - content_encoding=content_encoding, - upstream_service_time=test_client_response.headers.get( - key="x-envoy-upstream-service-time", - ), - ) - - -@beartype -def _model_target_credentials_for_backend( - *, - backend: VuforiaBackend, -) -> ModelTargetCredentials: - """Return Model Target credentials for the chosen backend.""" - if backend == VuforiaBackend.REAL: - return get_model_target_credentials() - - return ModelTargetCredentials( - client_id="client-id", - client_secret="client-secret", - cad_data_url="https://example.com/model.glb", - username="user@example.com", - password="password", - ) - - -@pytest.fixture(name="model_target_credentials") -def fixture_model_target_credentials( - *, - verify_model_target_mock_vuforia: None, - _model_target_backend: VuforiaBackend, -) -> ModelTargetCredentials: - """Model Target credentials supplied by the running backend.""" - del verify_model_target_mock_vuforia - return _model_target_credentials_for_backend( - backend=_model_target_backend, - ) - - -@pytest.fixture(name="mock_model_target_credentials") -def fixture_mock_model_target_credentials( - *, - model_target_mock_only_vuforia: None, - _model_target_mock_backend: VuforiaBackend, -) -> ModelTargetCredentials: - """Model Target credentials for tests which require a mock.""" - del model_target_mock_only_vuforia - return _model_target_credentials_for_backend( - backend=_model_target_mock_backend, - ) - - -# Creating an advanced dataset with a state-based configuration or a standard -# dataset with inline CAD data is a "signed" request: the real Vuforia signs -# the trained dataset, and each signing consumes the account's allowance. The -# allowance is tiny (roughly 20 signings, under ten CI runs' worth), it -# is shared by every CI job and every concurrent run, and it cannot be -# raised or reset by us. Verifying this behavior on every run therefore -# burns the whole allowance within hours and then turns every CI run red -# with ``TRAINING_ALLOWANCE_EXCEEDED`` - which is exactly what happened -# when it ran unconditionally. The equivalent unsigned requests (a -# standard dataset, or an advanced dataset without a state-based -# configuration) are far cheaper and stay enabled, though with enough -# traffic they can also hit the allowance; an unexpected allowance -# rejection is skipped by the real adapter rather than failing the run. -_SIGNED_REQUEST_SKIP_REASON = ( - "Signed Model Target requests consume the real Vuforia account's " - "small, shared, non-resettable training allowance, so they are not " - "verified against the real Vuforia by default. Pass " - f"{VERIFY_MODEL_TARGET_SIGNING_OPTION} to verify them, for example " - "after the allowance has recovered. The mock backends always run " - "this test." -) - - -def _skip_unrequested_real_signing( - *, - request: pytest.FixtureRequest, - backend: VuforiaBackend, -) -> None: - """Skip a signing request against real Vuforia unless opted in.""" - if backend is VuforiaBackend.REAL and not request.config.getoption( - name=VERIFY_MODEL_TARGET_SIGNING_OPTION, - ): - pytest.skip(reason=_SIGNED_REQUEST_SKIP_REASON) - - -@pytest.fixture(name="signed_model_target_credentials") -def fixture_signed_model_target_credentials( - *, - verify_model_target_mock_vuforia: None, - _model_target_backend: VuforiaBackend, - request: pytest.FixtureRequest, -) -> ModelTargetCredentials: - """Credentials for signing tests, after applying the real-service - opt-in policy. - """ - del verify_model_target_mock_vuforia - _skip_unrequested_real_signing( - request=request, - backend=_model_target_backend, - ) - return _model_target_credentials_for_backend( - backend=_model_target_backend, - ) +__all__ = ["VuforiaBackend"] -@pytest.fixture(name="backend_capabilities") -def fixture_backend_capabilities( - *, - verify_mock_vuforia: None, - _vws_backend: VuforiaBackend, -) -> BackendCapabilities: - """The behavior supported by the running VWS and query backend.""" - del verify_mock_vuforia - is_mock = _vws_backend != VuforiaBackend.REAL - return BackendCapabilities( - deterministic_target_order=is_mock, - deterministic_query_order=is_mock, - fast_oversized_content_length_response=is_mock, - rolling_rate_limit_window=is_mock, - ) +pytest_plugins = ["vws_mock_test_backends.vuforia_backends"] diff --git a/tests/mock_vws/model_target_web_api/assertions.py b/tests/mock_vws/model_target_web_api/assertions.py index 2d5d989ae..c40e832af 100644 --- a/tests/mock_vws/model_target_web_api/assertions.py +++ b/tests/mock_vws/model_target_web_api/assertions.py @@ -1,50 +1,8 @@ -"""Assertions shared by Model Target Web API tests.""" +"""Compatibility imports for the packaged contract suite.""" -import json -from http import HTTPStatus +from vws_mock_contract_tests.model_target_web_api.assertions import ( + assert_model_target_error, + assert_oauth2_error, +) -import requests -from beartype import beartype -from vws.response import Response - -from tests.mock_vws.utils.assertions import assert_model_target_status - - -@beartype -def assert_oauth2_error( - *, - response: requests.Response, - status_code: HTTPStatus, - body: dict[str, str], -) -> None: - """Assert an OAuth2 error response.""" - assert_model_target_status( - response=response, - status_codes=status_code, - ) - assert response.json() == body - - -@beartype -def assert_model_target_error( - *, - response: Response, - status_code: HTTPStatus, - code: str, - message: str, - target: str, -) -> None: - """Assert a Model Target Web API error response with the legacy - shape. - """ - assert_model_target_status( - response=response, - status_codes=status_code, - ) - assert json.loads(s=response.text) == { - "error": { - "code": code, - "message": message, - "target": target, - }, - } +__all__ = ["assert_model_target_error", "assert_oauth2_error"] diff --git a/tests/mock_vws/utils/__init__.py b/tests/mock_vws/utils/__init__.py index 6b3769dec..83fdb473e 100644 --- a/tests/mock_vws/utils/__init__.py +++ b/tests/mock_vws/utils/__init__.py @@ -1,261 +1,19 @@ -"""Utilities for tests.""" - -import io -import secrets -from collections.abc import Mapping -from dataclasses import dataclass -from typing import Literal -from urllib.parse import urljoin - -import requests -from beartype import beartype -from PIL import Image -from requests.structures import CaseInsensitiveDict -from vws.response import Response - -from tests.mock_vws.utils.model_target_retries import ( - send_with_transient_retries, +"""Compatibility imports for the packaged contract suite.""" + +from vws_mock_contract_tests.utils import ( + Endpoint, + ModelTargetEndpoint, + make_decompression_bomb_image_file, + make_image_file, + make_single_color_image_file, + make_truncated_png_file, ) -from tests.mock_vws.utils.request_timeout_policy import ( - propagating_request_timeouts, -) - -_REQUEST_TIMEOUT_SECONDS = 5 - - -@beartype -def _send_request( - *, - method: str, - url: str, - headers: Mapping[str, str], - data: bytes | str, -) -> Response: - """Send a request with exactly the given headers.""" - request = requests.Request( - method=method, - url=url, - headers=headers, - data=data, - ) - prepared_request = request.prepare() - prepared_request.headers = CaseInsensitiveDict(data=headers) - session = requests.Session() - with propagating_request_timeouts(url=url): - requests_response = session.send( - request=prepared_request, - timeout=_REQUEST_TIMEOUT_SECONDS, - ) - return Response( - text=requests_response.text, - url=requests_response.url, - status_code=requests_response.status_code, - headers=dict(requests_response.headers), - request_body=requests_response.request.body, - tell_position=requests_response.raw.tell(), - content=requests_response.content, - ) - - -@dataclass(frozen=True, kw_only=True) -class Endpoint: - """Details of endpoints to be called in tests. - - Args: - prepared_request: A request to make which would be successful. - successful_headers_result_code: The expected result code if the - example path is requested with the method. - successful_headers_status_code: The expected status code if the - example path is requested with the method. - access_key: The access key used in the prepared request. - secret_key: The secret key used in the prepared request. - path_url: The path of the endpoint. - base_url: The base URL of the endpoint. - - Attributes: - prepared_request: A request to make which would be successful. - successful_headers_result_code: The expected result code if the - example path is requested with the method. - successful_headers_status_code: The expected status code if the - example path is requested with the method. - access_key: The access key used in the prepared request. - secret_key: The secret key used in the prepared request. - path_url: The path of the endpoint. - base_url: The base URL of the endpoint. - """ - - base_url: str - path_url: str - method: str - headers: Mapping[str, str] - data: bytes | str - successful_headers_result_code: str | None - successful_headers_status_code: int - access_key: str - secret_key: str - - @beartype - def send(self) -> Response: - """Send the request.""" - return _send_request( - method=self.method, - url=urljoin(base=self.base_url, url=self.path_url), - headers=self.headers, - data=self.data, - ) - - @property - def auth_header_content_type(self) -> str: - """The content type to use for the `Authorization` header.""" - full_content_type = dict(self.headers).get("Content-Type", "") - return full_content_type.split(sep=";")[0] - - -@dataclass(frozen=True, kw_only=True) -class ModelTargetEndpoint: - """Details of Model Target Web API endpoints to be called in tests. - - Args: - base_url: The base URL of the endpoint. - path_url: The path of the endpoint. - method: The HTTP method of the endpoint. - headers: Headers to send to the endpoint. These do not include an - ``Authorization`` header; tests add a valid or invalid bearer - token themselves. - data: The body to send to the endpoint. - takes_json_body: Whether the endpoint reads a JSON request body. - - Attributes: - base_url: The base URL of the endpoint. - path_url: The path of the endpoint. - method: The HTTP method of the endpoint. - headers: Headers to send to the endpoint. These do not include an - ``Authorization`` header; tests add a valid or invalid bearer - token themselves. - data: The body to send to the endpoint. - takes_json_body: Whether the endpoint reads a JSON request body. - """ - - base_url: str - path_url: str - method: str - headers: Mapping[str, str] - data: bytes - takes_json_body: bool - - @beartype - def send(self) -> Response: - """Send the request. - - Safe requests use the retry policy supplied by the backend - adapter. See - :py:mod:`tests.mock_vws.utils.model_target_retries`. - """ - return send_with_transient_retries( - method=self.method, - send=lambda: _send_request( - method=self.method, - url=urljoin(base=self.base_url, url=self.path_url), - headers=self.headers, - data=self.data, - ), - ) - - -@beartype -def make_image_file( - *, - file_format: str, - color_space: Literal["RGB", "CMYK"], - width: int, - height: int, -) -> io.BytesIO: - """Return an image file in the given format and color space. - - The image file is filled with randomly colored pixels. - - Args: - file_format: See - https://pillow.readthedocs.io/en/3.1.x/handbook/image-file-formats.html - color_space: One of "RGB", or "CMYK". - width: The width, in pixels of the image. - height: The width, in pixels of the image. - - Returns: - An image file in the given format and color space. - """ - image_buffer = io.BytesIO() - image = Image.new(mode=color_space, size=(width, height)) - for row_index in range(height): - for column_index in range(width): - red = secrets.choice(seq=range(255)) - green = secrets.choice(seq=range(255)) - blue = secrets.choice(seq=range(255)) - image.putpixel( - xy=(column_index, row_index), - value=(red, green, blue), - ) - - image.save(fp=image_buffer, format=file_format) - _ = image_buffer.seek(0) - return image_buffer - - -@beartype -def make_single_color_image_file(*, width: int, height: int) -> io.BytesIO: - """Return a greyscale PNG file of one color. - - A single color image compresses to a tiny file whatever its dimensions, so - this is a way to make an image with many pixels but a small file size. - - Args: - width: The width, in pixels, of the image. - height: The height, in pixels, of the image. - - Returns: - A greyscale PNG file of one color. - """ - image_buffer = io.BytesIO() - image = Image.new(mode="L", size=(width, height)) - image.save(fp=image_buffer, format="PNG") - _ = image_buffer.seek(0) - return image_buffer - - -@beartype -def make_truncated_png_file() -> io.BytesIO: - """Return a PNG file whose image data is cut off before the end. - - The file keeps a valid signature and header, so Pillow opens it, but the - ``IEND`` chunk which marks the end of the image is missing, so decoding - the image data fails. - - Returns: - A PNG file which is truncated before its ``IEND`` chunk. - """ - image = Image.frombytes(mode="RGB", size=(1, 1), data=b"\x01\x02\x03") - image_buffer = io.BytesIO() - image.save(fp=image_buffer, format="PNG", optimize=True) - image_bytes = image_buffer.getvalue() - # Remove the ``IEND`` chunk: its four length bytes, its four type bytes - # and its four checksum bytes. - truncated = image_bytes[: image_bytes.rindex(b"IEND") - 4] - return io.BytesIO(initial_bytes=truncated) - - -@beartype -def make_decompression_bomb_image_file() -> io.BytesIO: - """Return a PNG file which is tiny on disk but huge when decoded. - - The dimensions are within the maximum width and height accepted by the - Query API, and the file is well within the maximum file size, but the - pixel count is above the point at which Pillow refuses to open an image. - Returns: - A PNG file which is a decompression bomb. - """ - # Pillow raises ``Image.DecompressionBombError`` for images with more than - # twice ``Image.MAX_IMAGE_PIXELS`` pixels, which is 178956970 pixels by - # default. - width = height = 15_000 - return make_single_color_image_file(width=width, height=height) +__all__ = [ + "Endpoint", + "ModelTargetEndpoint", + "make_decompression_bomb_image_file", + "make_image_file", + "make_single_color_image_file", + "make_truncated_png_file", +] diff --git a/tests/mock_vws/utils/assertions.py b/tests/mock_vws/utils/assertions.py index e5ec3d205..3620df5ad 100644 --- a/tests/mock_vws/utils/assertions.py +++ b/tests/mock_vws/utils/assertions.py @@ -1,378 +1,23 @@ -"""Assertion helpers.""" - -import copy -import datetime as dt -import email.utils -import json -from collections.abc import Set as AbstractSet -from http import HTTPStatus -from string import hexdigits -from zoneinfo import ZoneInfo - -import requests -from beartype import beartype -from vws.response import Response - -from tests.mock_vws.utils.model_target_response_policy import ( - handle_unexpected_model_target_response, +"""Compatibility imports for the packaged contract suite.""" + +from vws_mock_contract_tests.utils.assertions import ( + assert_model_target_status, + assert_query_success, + assert_valid_date_header, + assert_valid_transaction_id, + assert_vwq_failure, + assert_vws_failure, + assert_vws_response, + assert_vws_too_many_requests, ) - -@beartype -def assert_vws_failure( - *, - response: Response, - status_code: int, - result_code: str, -) -> None: - """Assert that a VWS failure response is as expected. - - Args: - response: The response returned by a request to VWS. - status_code: The expected status code of the response. - result_code: The expected result code of the response. - - Raises: - AssertionError: The response is not in the expected VWS error format - for the given codes. - """ - assert json.loads(s=response.text).keys() == { - "transaction_id", - "result_code", - } - assert_vws_response( - response=response, - status_code=status_code, - result_code=result_code, - ) - - -@beartype -def assert_vws_too_many_requests(*, response: Response) -> None: - """Assert that a response is the rate-limited response which real - Vuforia's Envoy layer gives. - - The response has no body and no ``Content-Type`` header. Real Vuforia - has an Envoy layer at its edge and another in front of the application, - and either may reject the request. Only a rejection by the inner layer - carries an ``x-envoy-upstream-service-time`` header. This was observed - against real Vuforia on 2026-09-08. - - Args: - response: The response returned by a request to VWS. - - Raises: - AssertionError: The response is not the rate-limited response. - """ - assert response.status_code == HTTPStatus.TOO_MANY_REQUESTS - assert response.text == "" - required_header_keys = { - "connection", - "content-length", - "date", - "server", - "strict-transport-security", - "x-aws-region", - "x-content-type-options", - "x-envoy-ratelimited", - } - optional_header_keys = {"x-envoy-upstream-service-time"} - response_header_keys = {str.lower(key) for key in response.headers} - assert required_header_keys <= response_header_keys - assert response_header_keys <= required_header_keys | optional_header_keys - assert response.headers["Content-Length"] == "0" - assert response.headers["server"] == "envoy" - assert response.headers["x-envoy-ratelimited"] == "true" - assert response.headers["x-content-type-options"] == "nosniff" - assert "-" in response.headers["x-aws-region"] - assert response.headers["strict-transport-security"] == "max-age=31536000" - assert_valid_date_header(response=response) - - -@beartype -def assert_valid_date_header( - *, - response: Response, -) -> None: - """Assert that a response includes a `Date` header which is within two - minutes of "now". - - Args: - response: The response returned by a request to a Vuforia service. - - Raises: - AssertionError: The response does not include a `Date` header which is - within one minute of "now". - """ - date_response = response.headers["Date"] - date_from_response = email.utils.parsedate(data=date_response) - assert date_from_response is not None - year, month, day, hour, minute, second, _, _, _ = date_from_response - gmt = ZoneInfo(key="GMT") - datetime_from_response = dt.datetime( - year=year, - month=month, - day=day, - hour=hour, - minute=minute, - second=second, - tzinfo=gmt, - ) - current_date = dt.datetime.now(tz=gmt) - time_difference = abs(current_date - datetime_from_response) - assert time_difference < dt.timedelta(minutes=2) - - -@beartype -def assert_valid_transaction_id( - *, - response: Response, -) -> None: - """Assert that a response includes a valid transaction ID. - - Args: - response: The response returned by a request to a Vuforia service. - - Raises: - AssertionError: The response does not include a valid transaction ID. - """ - response_json = json.loads(s=response.text) - assert isinstance(response_json, dict) - assert isinstance(response_json["transaction_id"], str) - transaction_id: str = response_json["transaction_id"] - expected_transaction_id_length = 32 - assert len(transaction_id) == expected_transaction_id_length - assert all(char in hexdigits for char in transaction_id) - - -@beartype -def assert_json_separators(*, response: Response) -> None: - """Assert that a JSON response is formatted correctly. - - Args: - response: The response returned by a request to a Vuforia service. - - Raises: - AssertionError: The response JSON is not formatted correctly. - """ - assert response.text == json.dumps( - obj=json.loads(s=response.text), - separators=(",", ":"), - ) - - -@beartype -def assert_vws_response( - *, - response: Response, - status_code: int, - result_code: str, -) -> None: - """Assert that a VWS response is as expected, at least in part. - - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#result-codes - implies that the expected status code can be worked out from the result - code. However, this is not the case as the real results differ from the - documentation. - - For example, it is possible to get a "Fail" result code and a 400 error. - - Args: - response: The response returned by a request to VWS. - status_code: The expected status code of the response. - result_code: The expected result code of the response. - - Raises: - AssertionError: The response is not in the expected VWS format for the - given codes. - """ - assert response.status_code == status_code - response_json = json.loads(s=response.text) - assert isinstance(response_json, dict) - assert isinstance(response_json["result_code"], str) - response_result_code: str = response_json["result_code"] - assert response_result_code == result_code - response_header_keys = { - "connection", - "content-length", - "content-type", - "date", - "server", - "strict-transport-security", - "x-aws-region", - "x-content-type-options", - "x-envoy-upstream-service-time", - } - assert {str.lower(key) for key in response.headers} == response_header_keys - assert response.headers["Content-Length"] == str(object=len(response.text)) - assert response.headers["Content-Type"] == "application/json" - assert response.headers["server"] == "envoy" - assert response.headers["x-content-type-options"] == "nosniff" - assert "-" in response.headers["x-aws-region"] - assert response.headers["strict-transport-security"] == "max-age=31536000" - assert int(response.headers["x-envoy-upstream-service-time"]) > 1 - assert response.headers["Connection"] == "keep-alive" - assert_json_separators(response=response) - assert_valid_transaction_id(response=response) - assert_valid_date_header(response=response) - - -@beartype -def assert_model_target_status( - *, - response: Response | requests.Response, - status_codes: HTTPStatus | AbstractSet[HTTPStatus], -) -> None: - """Assert the status code of a Model Target Web API response. - - An unexpected status reports the URL and body. A backend may supply - a response handler for restrictions of the service it runs against. - - Args: - response: The response returned by a request to the Model Target Web - API. - status_codes: The expected status code, or the status codes any of - which is expected. - - Raises: - AssertionError: The response does not have one of the expected status - codes and the backend handler has not handled the rejection. - """ - expected = ( - frozenset({status_codes}) - if isinstance(status_codes, HTTPStatus) - else frozenset(status_codes) - ) - if response.status_code in expected: - return - - handle_unexpected_model_target_response(response=response) - expected_description = " or ".join( - sorted(f"{item} {item.name}" for item in expected) - ) - message = ( - f"Expected {expected_description} from {response.url}, " - f"got {response.status_code}.\n\nResponse body:\n{response.text}" - ) - raise AssertionError(message) - - -@beartype -def assert_query_success(*, response: Response) -> None: - """Assert that the given response is a success response for performing - an - image recognition query. - - Raises: - AssertionError: The given response is not a valid success response - for performing an image recognition query. - """ - assert response.status_code == HTTPStatus.OK - assert json.loads(s=response.text).keys() == { - "result_code", - "results", - "query_id", - } - - response_json = json.loads(s=response.text) - assert isinstance(response_json, dict) - assert isinstance(response_json["query_id"], str) - query_id: str = response_json["query_id"] - expected_query_id_length = 32 - assert len(query_id) == expected_query_id_length - assert all(char in hexdigits for char in query_id) - - assert json.loads(s=response.text)["result_code"] == "Success" - assert_valid_date_header(response=response) - copied_response_headers = response.headers.copy() - _ = copied_response_headers.pop("Date") - - # In the mock, all responses have the ``Content-Encoding`` ``gzip``. - # In the real Vuforia, some do and some do not. - # We are not sure why. - content_encoding = copied_response_headers.pop("Content-Encoding", None) - assert content_encoding in {None, "gzip"} - - expected_response_header_not_chunked = { - "Connection": "keep-alive", - "Content-Length": str(object=response.tell_position), - "Content-Type": "application/json", - "Server": "nginx", - } - - # The mock does not send chunked responses. - expected_response_header_chunked = { - "Connection": "keep-alive", - "Content-Type": "application/json", - "Server": "nginx", - "transfer-encoding": "chunked", - } - - assert copied_response_headers in ( - expected_response_header_chunked, - expected_response_header_not_chunked, - ) - - -@beartype -def assert_vwq_failure( - *, - response: Response, - status_code: int, - content_type: str | None, - cache_control: str | None, - www_authenticate: str | None, - connection: str, -) -> None: - """Assert that a VWQ failure response is as expected. - - Args: - response: The response returned by a request to VWQ. - content_type: The expected Content-Type header. - status_code: The expected status code. - cache_control: The expected Cache-Control header. - www_authenticate: The expected WWW-Authenticate header. - connection: The expected Connection header. - - Raises: - AssertionError: The response is not in the expected VWQ error format - for the given codes. - """ - assert response.status_code == status_code - response_header_keys = { - "Connection", - "Content-Length", - "Date", - "Server", - } - - if cache_control is not None: - response_header_keys.add("Cache-Control") - assert response.headers["Cache-Control"] == cache_control - - if content_type is not None: - response_header_keys.add("Content-Type") - assert response.headers["Content-Type"] == content_type - - if www_authenticate is not None: - response_header_keys.add("WWW-Authenticate") - assert response.headers["WWW-Authenticate"] == www_authenticate - - # Sometimes the "transfer-encoding" is given. - # It is not given by the mock. - response_header_keys_chunked = copy.copy(x=response_header_keys) - response_header_keys_chunked.remove("Content-Length") - response_header_keys_chunked.add("transfer-encoding") - - assert response.headers.keys() in ( - response_header_keys, - response_header_keys_chunked, - ) - assert response.headers.get("transfer-encoding", "chunked") == "chunked" - assert response.headers["Connection"] == connection - content_length = response.headers.get("Content-Length") - assert content_length is None or content_length == str( - object=len(response.text) - ) - assert_valid_date_header(response=response) - assert response.headers["Server"] == "nginx" +__all__ = [ + "assert_model_target_status", + "assert_query_success", + "assert_valid_date_header", + "assert_valid_transaction_id", + "assert_vwq_failure", + "assert_vws_failure", + "assert_vws_response", + "assert_vws_too_many_requests", +] diff --git a/tests/mock_vws/utils/backend_capabilities.py b/tests/mock_vws/utils/backend_capabilities.py index efb3c2e4c..a47bd219b 100644 --- a/tests/mock_vws/utils/backend_capabilities.py +++ b/tests/mock_vws/utils/backend_capabilities.py @@ -1,29 +1,7 @@ -"""Behavior declared by an adapter for the shared API tests.""" +"""Compatibility imports for the packaged contract suite.""" -from dataclasses import dataclass +from vws_mock_contract_tests.utils.backend_capabilities import ( + BackendCapabilities, +) -from beartype import beartype - - -@beartype -@dataclass(frozen=True, kw_only=True) -class BackendCapabilities: - """The behavior and checks supported by the running service. - - ``deterministic_target_order`` covers upload-date ordering for target - lists and duplicate lists. ``deterministic_query_order`` covers that - ordering for matches to identical images. - - ``fast_oversized_content_length_response`` enables requests whose - declared body length exceeds their actual length. Services which wait - too long for the missing bytes cannot run that check. - - ``rolling_rate_limit_window`` enables the exact request-sequence - assertion for a fresh database. Services with a fixed clock window - still run the common rate-limit assertions. - """ - - deterministic_target_order: bool - deterministic_query_order: bool - fast_oversized_content_length_response: bool - rolling_rate_limit_window: bool +__all__ = ["BackendCapabilities"] diff --git a/tests/mock_vws/utils/cloud_databases.py b/tests/mock_vws/utils/cloud_databases.py index 6d373f7e6..0c03a6ceb 100644 --- a/tests/mock_vws/utils/cloud_databases.py +++ b/tests/mock_vws/utils/cloud_databases.py @@ -1,18 +1,7 @@ -"""The database creation operation supplied by a mock backend.""" +"""Compatibility imports for the packaged contract suite.""" -from typing import Protocol, runtime_checkable +from vws_mock_contract_tests.utils.cloud_databases import ( + CreateCloudDatabase, +) -from tests.mock_vws.fixtures.credentials import NamedCloudDatabaseCredentials - - -@runtime_checkable -class CreateCloudDatabase(Protocol): - """A backend operation which creates a cloud database.""" - - def __call__(self) -> NamedCloudDatabaseCredentials: - """Create a database and return its name and credentials. - - Returns: - The name and credentials of the new database. - """ - ... +__all__ = ["CreateCloudDatabase"] diff --git a/tests/mock_vws/utils/model_target_response_policy.py b/tests/mock_vws/utils/model_target_response_policy.py index caa013ec7..20a20e84e 100644 --- a/tests/mock_vws/utils/model_target_response_policy.py +++ b/tests/mock_vws/utils/model_target_response_policy.py @@ -1,49 +1,7 @@ -"""Backend-supplied handling of unexpected Model Target responses.""" +"""Compatibility imports for the packaged contract suite.""" -import contextlib -from collections.abc import Generator -from typing import Protocol, runtime_checkable +from vws_mock_contract_tests.utils.model_target_response_policy import ( + handling_unexpected_model_target_responses, +) -import requests -from beartype import beartype -from vws.response import Response - - -@runtime_checkable -class ModelTargetResponseHandler(Protocol): - """A backend operation for an unexpected Model Target response.""" - - def __call__(self, *, response: Response | requests.Response) -> None: - """Handle the response before the shared assertion reports it.""" - ... - - -_RESPONSE_HANDLERS: list[ModelTargetResponseHandler] = [] - - -@contextlib.contextmanager -@beartype -def handling_unexpected_model_target_responses( - *, - handler: ModelTargetResponseHandler, -) -> Generator[None]: - """Use the supplied response handler within this context. - - Yields: - ``None``, with the supplied handler active. - """ - _RESPONSE_HANDLERS.append(handler) - try: - yield - finally: - _ = _RESPONSE_HANDLERS.pop() - - -@beartype -def handle_unexpected_model_target_response( - *, - response: Response | requests.Response, -) -> None: - """Notify the backend before asserting an unexpected status.""" - if bool(_RESPONSE_HANDLERS): - _RESPONSE_HANDLERS[-1](response=response) +__all__ = ["handling_unexpected_model_target_responses"] diff --git a/tests/mock_vws/utils/model_target_retries.py b/tests/mock_vws/utils/model_target_retries.py index 62e6c80ce..df2020886 100644 --- a/tests/mock_vws/utils/model_target_retries.py +++ b/tests/mock_vws/utils/model_target_retries.py @@ -1,84 +1,13 @@ -"""Apply retry policies supplied by Model Target backend adapters. - -Requests are sent once by default. An adapter can install a scoped retry -policy for GET requests; mutating requests are always sent exactly once. -""" - -import contextlib -from collections.abc import Callable, Generator, Mapping -from http import HTTPMethod - -import requests -from beartype import beartype -from tenacity import Retrying -from vws.response import Response - -_RETRY_POLICIES: list[Retrying] = [] - - -@contextlib.contextmanager -@beartype -def retrying_model_target_requests(*, retrying: Retrying) -> Generator[None]: - """Use the supplied retry policy for safe requests within this context. - - Yields: - ``None``, with the supplied policy active. - """ - _RETRY_POLICIES.append(retrying) - try: - yield - finally: - _ = _RETRY_POLICIES.pop() - - -@beartype -def send_with_transient_retries[ResponseT: (Response, requests.Response)]( - *, - method: str, - send: Callable[[], ResponseT], -) -> ResponseT: - """Send a Model Target request, retrying transient failures. - - The request is sent exactly once unless it is a ``GET`` sent while - :py:func:`retrying_model_target_requests` is active. - - Args: - method: The HTTP method of the request. - send: A callable which sends the request and returns the - response. - - Returns: - The response to the last attempt. - """ - retryable = bool(_RETRY_POLICIES) and method == HTTPMethod.GET - if not retryable: - return send() - - return _RETRY_POLICIES[-1](send) - - -@beartype -def model_target_get( - *, - url: str, - headers: Mapping[str, str], - timeout: int, -) -> requests.Response: - """Send a ``GET`` request to the Model Target Web API. - - Args: - url: The URL to request. - headers: The headers to send. - timeout: The request timeout, in seconds. - - Returns: - The response to the last attempt. - """ - return send_with_transient_retries( - method=HTTPMethod.GET, - send=lambda: requests.get( - url=url, - headers=dict(headers), - timeout=timeout, - ), - ) +"""Compatibility imports for the packaged contract suite.""" + +from vws_mock_contract_tests.utils.model_target_retries import ( + model_target_get, + retrying_model_target_requests, + send_with_transient_retries, +) + +__all__ = [ + "model_target_get", + "retrying_model_target_requests", + "send_with_transient_retries", +] diff --git a/tests/mock_vws/utils/recognition_counts.py b/tests/mock_vws/utils/recognition_counts.py index 42c67256e..b238fe179 100644 --- a/tests/mock_vws/utils/recognition_counts.py +++ b/tests/mock_vws/utils/recognition_counts.py @@ -1,35 +1,7 @@ -"""The recognition-count operation supplied by a mock backend. +"""Compatibility imports for the packaged contract suite.""" -Neither mock counts recognitions, because real Vuforia's counts lag behind -its queries by longer than a test runs. Each mock instead has its own way of -being told what the counts are. -""" +from vws_mock_contract_tests.utils.recognition_counts import ( + SeedRecognitionCounts, +) -from typing import Protocol, runtime_checkable - - -@runtime_checkable -class SeedRecognitionCounts(Protocol): - """A backend operation which sets recognition counts on a target.""" - - def __call__( - self, - *, - database_name: str, - target_id: str, - current_month_recos: int, - previous_month_recos: int, - total_recos: int, - ) -> None: - """Set the target's recognition counts on the running mock backend. - - Args: - database_name: The name of the database containing the target. - target_id: The ID of the target to set recognition counts of. - current_month_recos: The number of recognitions in the current - month. - previous_month_recos: The number of recognitions in the previous - month. - total_recos: The total number of recognitions of the target. - """ - ... +__all__ = ["SeedRecognitionCounts"] diff --git a/tests/mock_vws/utils/request_timeout_policy.py b/tests/mock_vws/utils/request_timeout_policy.py index a7fd6f317..d4a267d43 100644 --- a/tests/mock_vws/utils/request_timeout_policy.py +++ b/tests/mock_vws/utils/request_timeout_policy.py @@ -1,53 +1,8 @@ -"""Backend-supplied handling of request timeouts.""" +"""Compatibility imports for the packaged contract suite.""" -import contextlib -from collections.abc import Generator -from typing import Protocol, runtime_checkable +from vws_mock_contract_tests.utils.request_timeout_policy import ( + handling_request_timeouts, + propagating_request_timeouts, +) -from beartype import beartype -from requests.exceptions import Timeout as RequestsTimeout - - -@runtime_checkable -class RequestTimeoutHandler(Protocol): - """A backend operation for a request which timed out.""" - - def __call__(self, *, url: str, error: RequestsTimeout) -> None: - """Handle a timeout before the shared sender propagates it.""" - ... - - -_TIMEOUT_HANDLERS: list[RequestTimeoutHandler] = [] - - -@contextlib.contextmanager -@beartype -def handling_request_timeouts( - *, handler: RequestTimeoutHandler -) -> Generator[None]: - """Use the supplied timeout handler within this context. - - Yields: - ``None``, with the supplied handler active. - """ - _TIMEOUT_HANDLERS.append(handler) - try: - yield - finally: - _ = _TIMEOUT_HANDLERS.pop() - - -@contextlib.contextmanager -@beartype -def propagating_request_timeouts(*, url: str) -> Generator[None]: - """Apply the active policy to timeouts raised by a request. - - Yields: - ``None``, while the request runs. - """ - try: - yield - except RequestsTimeout as error: - if bool(_TIMEOUT_HANDLERS): - _TIMEOUT_HANDLERS[-1](url=url, error=error) - raise +__all__ = ["handling_request_timeouts", "propagating_request_timeouts"] diff --git a/tests/mock_vws/utils/retries.py b/tests/mock_vws/utils/retries.py index 2ebd3d5a7..8eeccc694 100644 --- a/tests/mock_vws/utils/retries.py +++ b/tests/mock_vws/utils/retries.py @@ -1,114 +1,7 @@ -"""Helpers for retrying requests to VWS.""" +"""Compatibility imports for the packaged contract suite.""" -import contextlib -from collections.abc import Callable, Generator -from contextvars import ContextVar -from functools import wraps - -from beartype import beartype -from requests.exceptions import ConnectionError as RequestsConnectionError -from requests.exceptions import ConnectTimeout as RequestsConnectTimeout -from requests.exceptions import ReadTimeout as RequestsReadTimeout -from requests.exceptions import Timeout as RequestsTimeout -from tenacity import Retrying, retry -from tenacity.retry import retry_if_exception_type -from tenacity.stop import stop_after_attempt -from tenacity.wait import wait_fixed -from vws.exceptions.custom_exceptions import ServerError -from vws.exceptions.vws_exceptions import ( - TargetStatusProcessingError, - TooManyRequestsError, - UnknownTargetError, -) - -# ``pytest-retry`` checks whether the type of the exception which failed a -# test is *in* this tuple, so a subclass of a listed type is not retried. -# ``requests`` raises the derived ``Timeout`` exceptions below, never -# ``Timeout`` -# itself, so each one is listed. ``Timeout`` and ``ConnectionError`` stay -# for the ``tenacity`` retries, which do use ``isinstance``. -TRANSIENT_VWS_EXCEPTIONS = ( - TooManyRequestsError, - ServerError, - RequestsTimeout, - RequestsReadTimeout, - RequestsConnectTimeout, - RequestsConnectionError, -) -TRANSIENT_VWS_RETRY_ATTEMPTS = 10 - -# Shared helpers send once unless an adapter supplies a retry policy. -# ContextVar requires a positional name at runtime. -_VWS_RETRYING: ContextVar[Retrying | None] = ContextVar( # type: ignore[call-arg] - "vws_retrying", - default=None, +from vws_mock_contract_tests.utils.retries import ( + TRANSIENT_VWS_EXCEPTIONS, ) - -@contextlib.contextmanager -@beartype -def retrying_vws_requests(*, retrying: Retrying) -> Generator[None]: - """Install an adapter's fixture retry policy for this scope. - - Yields: - ``None``, with the policy active and its predecessor restored on exit. - """ - token = _VWS_RETRYING.set(retrying) - try: - yield - finally: - _VWS_RETRYING.reset(token) - - -@beartype -def retry_transient_vws_failures[**P, T]( - operation: Callable[P, T], -) -> Callable[P, T]: - """Apply the adapter's policy without multiplying nested retry budgets.""" - - @wraps(wrapped=operation) - def wrapped(*args: P.args, **kwargs: P.kwargs) -> T: - """Run an operation under at most one transient retry loop.""" - retrying = _VWS_RETRYING.get() - if retrying is None: - return operation(*args, **kwargs) - token = _VWS_RETRYING.set(None) - try: - return retrying.copy()(operation, *args, **kwargs) - finally: - _VWS_RETRYING.reset(token) - - return wrapped - - -# Real Vuforia sometimes reports a target which it has just created as -# unknown, because the target is not yet visible to the read path. -# -# This is deliberately kept out of ``TRANSIENT_VWS_EXCEPTIONS``. That tuple -# also decides which failures ``pytest-retry`` retries during tests, and an -# ``UnknownTargetError`` which a test did not expect is a result worth -# seeing rather than retrying away. Only code which has just been given a -# target's ID by Vuforia, and so knows the target exists, may use this. -RETRY_ON_TARGET_NOT_YET_VISIBLE = retry( - retry=retry_if_exception_type(exception_types=UnknownTargetError), - stop=stop_after_attempt(max_attempt_number=TRANSIENT_VWS_RETRY_ATTEMPTS), - wait=wait_fixed(wait=2), - reraise=True, -) - - -# Real Vuforia sometimes reports a target which has just been updated as -# still having its old status for a moment, so waiting for the target to be -# processed can return before the update has put the target back into -# processing. A request which then needs the target to be processed fails. -# -# Like ``RETRY_ON_TARGET_NOT_YET_VISIBLE``, this is kept out of -# ``TRANSIENT_VWS_EXCEPTIONS`` so that a test which does not expect a -# processing target sees the failure rather than retrying it away. Only code -# which has just waited for the target to be processed may use this. -RETRY_ON_TARGET_STILL_PROCESSING = retry( - retry=retry_if_exception_type(exception_types=TargetStatusProcessingError), - stop=stop_after_attempt(max_attempt_number=TRANSIENT_VWS_RETRY_ATTEMPTS), - wait=wait_fixed(wait=2), - reraise=True, -) +__all__ = ["TRANSIENT_VWS_EXCEPTIONS"] diff --git a/tests/mock_vws/utils/too_many_requests.py b/tests/mock_vws/utils/too_many_requests.py index c35cb54d1..df57763a0 100644 --- a/tests/mock_vws/utils/too_many_requests.py +++ b/tests/mock_vws/utils/too_many_requests.py @@ -1,35 +1,7 @@ -"""Helpers for handling too many requests errors.""" +"""Compatibility imports for the packaged contract suite.""" -from http import HTTPStatus +from vws_mock_contract_tests.utils.too_many_requests import ( + handle_server_errors, +) -from beartype import beartype -from vws.exceptions.custom_exceptions import ServerError -from vws.exceptions.vws_exceptions import TooManyRequestsError -from vws.response import Response - - -@beartype -def handle_server_errors(*, response: Response) -> None: - """Raise errors if the response is a 429 or 5xx. This is useful for - retrying tests based on the exceptions they raise. - - Raises: - vws.exceptions.vws_exceptions.TooManyRequestsError: The response is a - 429. - vws.exceptions.custom_exceptions.ServerError: The response is a 5xx. - """ - # We do not cover this because in some test runs we will not hit the - # error. - if ( - response.status_code == HTTPStatus.TOO_MANY_REQUESTS - ): # pragma: no cover - # The Vuforia API returns a 429 response with no JSON body. - # We raise this here to prompt a retry at a higher level. - raise TooManyRequestsError(response=response) - - # We do not cover this because in some test runs we will not hit the - # error. - if ( - response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR - ): # pragma: no cover - raise ServerError(response=response) +__all__ = ["handle_server_errors"] diff --git a/tests/mock_vws/utils/unrouted_requests.py b/tests/mock_vws/utils/unrouted_requests.py index 09a7dec9f..5303ec4e6 100644 --- a/tests/mock_vws/utils/unrouted_requests.py +++ b/tests/mock_vws/utils/unrouted_requests.py @@ -1,55 +1,9 @@ -"""The transport for requests which no VWS route serves.""" +"""Compatibility imports for the packaged contract suite.""" -from dataclasses import dataclass -from http import HTTPMethod -from typing import Protocol, runtime_checkable +from vws_mock_contract_tests.utils.unrouted_requests import ( + SendUnroutedRequest, + UnroutedRequests, + UnroutedResponse, +) -from beartype import beartype - - -@beartype -@dataclass(frozen=True, kw_only=True) -class UnroutedResponse: - """The parts of a response to a request which no route serves.""" - - status_code: int - body: bytes - content_type: str | None - content_encoding: str | None - upstream_service_time: str | None - - -@runtime_checkable -class SendUnroutedRequest(Protocol): - """A backend operation which sends a request outside its routes.""" - - def __call__( - self, - *, - method: HTTPMethod, - request_path: str, - headers: dict[str, str], - ) -> UnroutedResponse | None: - """Send the request and return its response, or ``None`` if - the connection is refused. - - Args: - method: The HTTP method to send. - request_path: The path to request on the VWS service. - headers: The request headers, including its authorization. - """ - ... - - -@beartype -@dataclass(frozen=True, kw_only=True) -class UnroutedRequests: - """The request operation and expected transport behavior. - - ``returns_response`` says whether the transport serves unmatched - requests. A selective HTTP mock refuses them; a server returns a - response. Shared tests assert the declared behavior in either case. - """ - - send: SendUnroutedRequest - returns_response: bool +__all__ = ["SendUnroutedRequest", "UnroutedRequests", "UnroutedResponse"] diff --git a/uv.lock b/uv.lock index 3a0b94ea2..7b5cbf505 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,13 @@ version = 1 revision = 5 requires-python = ">=3.15" +[manifest] +members = [ + "vws-mock-contract-tests", + "vws-mock-test-backends", + "vws-python-mock", +] + [[package]] name = "accessible-pygments" version = "0.0.5" @@ -2637,6 +2644,82 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4c/8e/778694f54dd9cdb208db54fa649a50b117e78b9d7bcb81597dd084672328/vws_auth_tools-2026.10.9-py3-none-any.whl", hash = "sha256:0a9e27c49ac5841d870c7f94c35eaaea91f78dcb5f2f4cc8a3417fcab71241b1", size = 5340, upload-time = "2026-10-09T16:13:21.725Z" }, ] +[[package]] +name = "vws-mock-contract-tests" +version = "0.0.0" +source = { editable = "contract-tests" } +dependencies = [ + { name = "beartype" }, + { name = "dirty-equals" }, + { name = "freezegun" }, + { name = "pillow" }, + { name = "pydantic" }, + { name = "pytest" }, + { name = "pytest-retry" }, + { name = "requests" }, + { name = "tenacity" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "urllib3" }, + { name = "vws-auth-tools" }, + { name = "vws-python" }, + { name = "vws-test-fixtures" }, +] + +[package.metadata] +requires-dist = [ + { name = "beartype", specifier = ">=0.23.1" }, + { name = "dirty-equals", specifier = ">=0.11" }, + { name = "freezegun", specifier = ">=1.5.5" }, + { name = "pillow", specifier = ">=12.2" }, + { name = "pydantic", specifier = ">=2.6" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-retry", specifier = ">=1.7" }, + { name = "requests", specifier = ">=2.32.3" }, + { name = "tenacity", specifier = ">=9.2.1" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "urllib3", specifier = ">=2.8" }, + { name = "vws-auth-tools", specifier = ">=2024.7.12" }, + { name = "vws-python", specifier = ">=2026.10.9" }, + { name = "vws-test-fixtures", specifier = ">=2026.10.8" }, +] + +[[package]] +name = "vws-mock-test-backends" +version = "0.0.0" +source = { editable = "test-backends" } +dependencies = [ + { name = "beartype" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pytest" }, + { name = "pytest-multi-backend" }, + { name = "requests" }, + { name = "requests-mock-flask" }, + { name = "responses" }, + { name = "tenacity" }, + { name = "urllib3" }, + { name = "vws-mock-contract-tests" }, + { name = "vws-python" }, + { name = "vws-python-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "beartype", specifier = ">=0.23.1" }, + { name = "pydantic", specifier = ">=2.6" }, + { name = "pydantic-settings", specifier = ">=2.6.1" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-multi-backend", specifier = ">=2026.9.7" }, + { name = "requests", specifier = ">=2.32.3" }, + { name = "requests-mock-flask", specifier = ">=2026.10.9" }, + { name = "responses", specifier = ">=0.25.3" }, + { name = "tenacity", specifier = ">=9.2.1" }, + { name = "urllib3", specifier = ">=2.8" }, + { name = "vws-mock-contract-tests", editable = "contract-tests" }, + { name = "vws-python", specifier = ">=2026.10.9" }, + { name = "vws-python-mock", editable = "." }, +] + [[package]] name = "vws-python" version = "2026.10.9" @@ -2742,6 +2825,8 @@ dev = [ { name = "urllib3" }, { name = "vale" }, { name = "vulture" }, + { name = "vws-mock-contract-tests" }, + { name = "vws-mock-test-backends" }, { name = "vws-python" }, { name = "vws-test-fixtures" }, { name = "vws-web-tools" }, @@ -2839,6 +2924,8 @@ dev = [ { name = "urllib3", specifier = "==2.8.0" }, { name = "vale", specifier = "==3.22.0.0" }, { name = "vulture", specifier = "==2.16" }, + { name = "vws-mock-contract-tests", editable = "contract-tests" }, + { name = "vws-mock-test-backends", editable = "test-backends" }, { name = "vws-python", specifier = "==2026.10.9" }, { name = "vws-test-fixtures", specifier = "==2026.10.8" }, { name = "vws-web-tools", specifier = "==2026.8.27" }, From 0aa33b03f204356060cc1579dcce14ea27f4394b Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 28 Oct 2026 17:45:21 +0000 Subject: [PATCH 02/10] Package target-management API contracts --- .../add_target/__init__.py | 1 + .../add_target/conftest.py | 12 + .../add_target/helpers.py | 2 +- .../add_target/test_active_flag.py | 148 +++++++ .../add_target/test_application_metadata.py | 190 +++++++++ .../add_target/test_content_types.py | 96 +++++ .../add_target/test_image.py | 348 ++++++++++++++++ .../add_target/test_inactive_project.py | 39 ++ .../add_target/test_missing_data.py | 55 +++ .../add_target/test_target_name.py | 178 +++++++++ .../add_target/test_unexpected_data.py | 56 +++ .../add_target/test_width.py | 70 ++++ .../delete_target/__init__.py | 1 + .../delete_target/conftest.py | 12 + .../delete_target/test_delete.py | 44 +++ .../delete_target/test_inactive_project.py | 27 ++ .../get_duplicates/__init__.py | 1 + .../get_duplicates/conftest.py | 12 + .../get_duplicates/test_active_flag.py | 55 +++ .../get_duplicates/test_duplicates.py | 173 ++++++++ .../get_duplicates/test_inactive_project.py | 18 + .../get_duplicates/test_processing.py | 54 +++ .../get_target/__init__.py | 1 + .../get_target/conftest.py | 12 + .../get_target/helpers.py | 0 .../get_target/test_get_record.py | 103 +++++ .../get_target/test_inactive_project.py | 13 + .../get_target/test_target_tracking_rating.py | 32 ++ .../target_list/__init__.py | 1 + .../target_list/conftest.py | 12 + .../target_list/test_inactive_project.py | 9 + .../target_list/test_rate_limit.py | 78 ++++ .../target_list/test_target_list.py | 60 +++ .../target_summary/__init__.py | 1 + .../target_summary/contract/__init__.py | 1 + .../target_summary/contract/conftest.py | 12 + .../contract/test_inactive_project.py | 15 + .../contract/test_recognition_counts.py | 34 ++ .../contract/test_target_summary.py | 112 ++++++ .../target_summary/helpers.py | 0 .../test_seeded_recognition_counts.py | 56 +++ .../update_target/__init__.py | 1 + .../update_target/conftest.py | 12 + .../update_target/helpers.py | 0 .../update_target/test_active_flag.py | 81 ++++ .../test_application_metadata.py | 136 +++++++ .../update_target/test_image.py | 322 +++++++++++++++ .../update_target/test_inactive_project.py | 18 + .../update_target/test_target_name.py | 167 ++++++++ .../update_target/test_unexpected_data.py | 40 ++ .../update_target/test_update.py | 132 +++++++ .../update_target/test_width.py | 57 +++ tests/mock_vws/add_target/conftest.py | 15 +- tests/mock_vws/add_target/test_active_flag.py | 152 +------ .../add_target/test_application_metadata.py | 205 +--------- .../mock_vws/add_target/test_content_types.py | 98 +---- tests/mock_vws/add_target/test_image.py | 373 ++---------------- .../add_target/test_inactive_project.py | 40 +- .../mock_vws/add_target/test_missing_data.py | 56 +-- tests/mock_vws/add_target/test_target_name.py | 187 +-------- .../add_target/test_unexpected_data.py | 57 +-- tests/mock_vws/add_target/test_width.py | 72 +--- tests/mock_vws/delete_target/conftest.py | 15 +- tests/mock_vws/delete_target/test_delete.py | 46 +-- .../delete_target/test_inactive_project.py | 28 +- tests/mock_vws/get_duplicates/conftest.py | 15 +- .../get_duplicates/test_active_flag.py | 58 +-- .../get_duplicates/test_duplicates.py | 186 +-------- .../get_duplicates/test_inactive_project.py | 21 +- .../get_duplicates/test_processing.py | 57 +-- tests/mock_vws/get_target/conftest.py | 15 +- tests/mock_vws/get_target/test_get_record.py | 108 +---- .../get_target/test_inactive_project.py | 16 +- .../get_target/test_target_tracking_rating.py | 33 +- tests/mock_vws/target_list/conftest.py | 15 +- .../target_list/test_inactive_project.py | 12 +- tests/mock_vws/target_list/test_rate_limit.py | 75 +--- .../mock_vws/target_list/test_target_list.py | 71 +--- .../target_summary/contract/conftest.py | 15 +- .../contract/test_inactive_project.py | 18 +- .../contract/test_recognition_counts.py | 37 +- .../contract/test_target_summary.py | 112 +----- .../test_seeded_recognition_counts.py | 55 +-- tests/mock_vws/update_target/conftest.py | 15 +- .../update_target/test_active_flag.py | 83 +--- .../test_application_metadata.py | 149 +------ tests/mock_vws/update_target/test_image.py | 347 ++-------------- .../update_target/test_inactive_project.py | 19 +- .../update_target/test_target_name.py | 176 +-------- .../update_target/test_unexpected_data.py | 41 +- tests/mock_vws/update_target/test_update.py | 139 +------ tests/mock_vws/update_target/test_width.py | 59 +-- 92 files changed, 3409 insertions(+), 2992 deletions(-) create mode 100644 contract-tests/src/vws_mock_contract_tests/add_target/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/add_target/conftest.py rename {tests/mock_vws => contract-tests/src/vws_mock_contract_tests}/add_target/helpers.py (97%) create mode 100644 contract-tests/src/vws_mock_contract_tests/add_target/test_active_flag.py create mode 100644 contract-tests/src/vws_mock_contract_tests/add_target/test_application_metadata.py create mode 100644 contract-tests/src/vws_mock_contract_tests/add_target/test_content_types.py create mode 100644 contract-tests/src/vws_mock_contract_tests/add_target/test_image.py create mode 100644 contract-tests/src/vws_mock_contract_tests/add_target/test_inactive_project.py create mode 100644 contract-tests/src/vws_mock_contract_tests/add_target/test_missing_data.py create mode 100644 contract-tests/src/vws_mock_contract_tests/add_target/test_target_name.py create mode 100644 contract-tests/src/vws_mock_contract_tests/add_target/test_unexpected_data.py create mode 100644 contract-tests/src/vws_mock_contract_tests/add_target/test_width.py create mode 100644 contract-tests/src/vws_mock_contract_tests/delete_target/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/delete_target/conftest.py create mode 100644 contract-tests/src/vws_mock_contract_tests/delete_target/test_delete.py create mode 100644 contract-tests/src/vws_mock_contract_tests/delete_target/test_inactive_project.py create mode 100644 contract-tests/src/vws_mock_contract_tests/get_duplicates/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/get_duplicates/conftest.py create mode 100644 contract-tests/src/vws_mock_contract_tests/get_duplicates/test_active_flag.py create mode 100644 contract-tests/src/vws_mock_contract_tests/get_duplicates/test_duplicates.py create mode 100644 contract-tests/src/vws_mock_contract_tests/get_duplicates/test_inactive_project.py create mode 100644 contract-tests/src/vws_mock_contract_tests/get_duplicates/test_processing.py create mode 100644 contract-tests/src/vws_mock_contract_tests/get_target/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/get_target/conftest.py rename {tests/mock_vws => contract-tests/src/vws_mock_contract_tests}/get_target/helpers.py (100%) create mode 100644 contract-tests/src/vws_mock_contract_tests/get_target/test_get_record.py create mode 100644 contract-tests/src/vws_mock_contract_tests/get_target/test_inactive_project.py create mode 100644 contract-tests/src/vws_mock_contract_tests/get_target/test_target_tracking_rating.py create mode 100644 contract-tests/src/vws_mock_contract_tests/target_list/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/target_list/conftest.py create mode 100644 contract-tests/src/vws_mock_contract_tests/target_list/test_inactive_project.py create mode 100644 contract-tests/src/vws_mock_contract_tests/target_list/test_rate_limit.py create mode 100644 contract-tests/src/vws_mock_contract_tests/target_list/test_target_list.py create mode 100644 contract-tests/src/vws_mock_contract_tests/target_summary/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/target_summary/contract/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/target_summary/contract/conftest.py create mode 100644 contract-tests/src/vws_mock_contract_tests/target_summary/contract/test_inactive_project.py create mode 100644 contract-tests/src/vws_mock_contract_tests/target_summary/contract/test_recognition_counts.py create mode 100644 contract-tests/src/vws_mock_contract_tests/target_summary/contract/test_target_summary.py rename {tests/mock_vws => contract-tests/src/vws_mock_contract_tests}/target_summary/helpers.py (100%) create mode 100644 contract-tests/src/vws_mock_contract_tests/target_summary/test_seeded_recognition_counts.py create mode 100644 contract-tests/src/vws_mock_contract_tests/update_target/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/update_target/conftest.py rename {tests/mock_vws => contract-tests/src/vws_mock_contract_tests}/update_target/helpers.py (100%) create mode 100644 contract-tests/src/vws_mock_contract_tests/update_target/test_active_flag.py create mode 100644 contract-tests/src/vws_mock_contract_tests/update_target/test_application_metadata.py create mode 100644 contract-tests/src/vws_mock_contract_tests/update_target/test_image.py create mode 100644 contract-tests/src/vws_mock_contract_tests/update_target/test_inactive_project.py create mode 100644 contract-tests/src/vws_mock_contract_tests/update_target/test_target_name.py create mode 100644 contract-tests/src/vws_mock_contract_tests/update_target/test_unexpected_data.py create mode 100644 contract-tests/src/vws_mock_contract_tests/update_target/test_update.py create mode 100644 contract-tests/src/vws_mock_contract_tests/update_target/test_width.py diff --git a/contract-tests/src/vws_mock_contract_tests/add_target/__init__.py b/contract-tests/src/vws_mock_contract_tests/add_target/__init__.py new file mode 100644 index 000000000..dcb6ee43e --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/add_target/__init__.py @@ -0,0 +1 @@ +"""Add target tests.""" diff --git a/contract-tests/src/vws_mock_contract_tests/add_target/conftest.py b/contract-tests/src/vws_mock_contract_tests/add_target/conftest.py new file mode 100644 index 000000000..d409c8ebf --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/add_target/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures scoped to this group of tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def fixture_verify_backend( + *, + verify_mock_vuforia: None, +) -> None: + """Set up the backend for every contract test in this directory.""" + del verify_mock_vuforia diff --git a/tests/mock_vws/add_target/helpers.py b/contract-tests/src/vws_mock_contract_tests/add_target/helpers.py similarity index 97% rename from tests/mock_vws/add_target/helpers.py rename to contract-tests/src/vws_mock_contract_tests/add_target/helpers.py index 3ae665989..7c00cfe1d 100644 --- a/tests/mock_vws/add_target/helpers.py +++ b/contract-tests/src/vws_mock_contract_tests/add_target/helpers.py @@ -10,7 +10,7 @@ from vws import VWS from vws.response import Response -from tests.mock_vws.utils.assertions import ( +from vws_mock_contract_tests.utils.assertions import ( assert_vws_response, ) diff --git a/contract-tests/src/vws_mock_contract_tests/add_target/test_active_flag.py b/contract-tests/src/vws_mock_contract_tests/add_target/test_active_flag.py new file mode 100644 index 000000000..49c7acc82 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/add_target/test_active_flag.py @@ -0,0 +1,148 @@ +"""Tests for the active flag parameter.""" + +import base64 +import io +import json +from http import HTTPStatus + +import pytest +from vws import VWS +from vws.exceptions.vws_exceptions import ( + FailError, +) + +from vws_mock_contract_tests.add_target.helpers import ( + add_target_to_vws, + assert_success, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_vws_failure, +) + + +@pytest.mark.parametrize( + argnames="active_flag", + argvalues=[True, False, None], +) +def test_valid( + *, + active_flag: bool | None, + image_file_failed_state: io.BytesIO, + vws_client: VWS, +) -> None: + """Boolean values and NULL are valid active flags.""" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii", + ) + content_type = "application/json" + + data = { + "name": "example", + "width": 1, + "image": image_data_encoded, + "active_flag": active_flag, + } + + response = add_target_to_vws( + vws_client=vws_client, + data=data, + content_type=content_type, + ) + + assert_success(response=response) + + +def test_invalid( + *, + image_file_failed_state: io.BytesIO, + vws_client: VWS, +) -> None: + """ + Values which are not Boolean values or NULL are not valid active + flags. + """ + active_flag = "string" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) + content_type = "application/json" + + data = { + "name": "example", + "width": 1, + "image": image_data_encoded, + "active_flag": active_flag, + } + + with pytest.raises(expected_exception=FailError) as exc: + _ = add_target_to_vws( + vws_client=vws_client, + data=data, + content_type=content_type, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.BAD_REQUEST, + result_code="Fail", + ) + + +def test_not_set( + *, + vws_client: VWS, + image_file_failed_state: io.BytesIO, +) -> None: + """The active flag defaults to True if it is not set.""" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) + + data = { + "name": "my_example_name", + "width": 1234, + "image": image_data_encoded, + } + + response = add_target_to_vws( + vws_client=vws_client, data=data, content_type="application/json" + ) + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert isinstance(response_json["target_id"], str) + target_id: str = response_json["target_id"] + target_details = vws_client.get_target_record(target_id=target_id) + assert target_details.target_record.active_flag is True + + +def test_set_to_none( + *, + vws_client: VWS, + image_file_failed_state: io.BytesIO, +) -> None: + """The active flag defaults to True if it is set to NULL.""" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) + + data = { + "name": "my_example_name", + "width": 1234, + "image": image_data_encoded, + "active_flag": None, + } + + response = add_target_to_vws( + vws_client=vws_client, data=data, content_type="application/json" + ) + + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert isinstance(response_json["target_id"], str) + target_id: str = response_json["target_id"] + target_details = vws_client.get_target_record(target_id=target_id) + assert target_details.target_record.active_flag is True diff --git a/contract-tests/src/vws_mock_contract_tests/add_target/test_application_metadata.py b/contract-tests/src/vws_mock_contract_tests/add_target/test_application_metadata.py new file mode 100644 index 000000000..beffbbe92 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/add_target/test_application_metadata.py @@ -0,0 +1,190 @@ +"""Tests for the application metadata parameter.""" + +import base64 +import io +from http import HTTPStatus + +import pytest +from vws import VWS +from vws.exceptions.vws_exceptions import ( + FailError, + MetadataTooLargeError, +) + +from vws_mock_contract_tests.add_target.helpers import ( + MAX_METADATA_BYTES, + add_target_to_vws, + assert_success, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_vws_failure, +) + + +@pytest.mark.parametrize( + argnames="metadata", + argvalues=[ + b"a", + b"a" * MAX_METADATA_BYTES, + ], + ids=["Short", "Max length"], +) +def test_base64_encoded( + *, + image_file_failed_state: io.BytesIO, + metadata: bytes, + vws_client: VWS, +) -> None: + """A base64 encoded string is valid application metadata.""" + metadata_encoded = base64.b64encode(s=metadata).decode(encoding="ascii") + + _ = vws_client.add_target( + name="example", + width=1, + image=image_file_failed_state, + application_metadata=metadata_encoded, + active_flag=True, + ) + + +def test_null( + *, + vws_client: VWS, + image_file_failed_state: io.BytesIO, +) -> None: + """NULL is valid application metadata.""" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) + + request_data = { + "name": "example_name", + "width": 1, + "image": image_data_encoded, + "application_metadata": None, + } + + response = add_target_to_vws( + vws_client=vws_client, + data=request_data, + content_type="application/json", + ) + + assert_success(response=response) + + +def test_invalid_type( + *, + vws_client: VWS, + image_file_failed_state: io.BytesIO, +) -> None: + """ + Values which are not a string or NULL are not valid application + metadata. + """ + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) + + data = { + "name": "example_name", + "width": 1, + "image": image_data_encoded, + "application_metadata": 1, + } + + with pytest.raises(expected_exception=FailError) as exc: + _ = add_target_to_vws( + vws_client=vws_client, + data=data, + content_type="application/json", + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.BAD_REQUEST, + result_code="Fail", + ) + + +def test_not_base64_encoded_processable( + *, + high_quality_image: io.BytesIO, + not_base64_encoded_processable: str, + vws_client: VWS, +) -> None: + """ + Some strings which are not valid base64 encoded strings are + allowed as + application metadata. + """ + _ = vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + application_metadata=not_base64_encoded_processable, + active_flag=True, + ) + + +def test_not_base64_encoded_not_processable( + *, + high_quality_image: io.BytesIO, + not_base64_encoded_not_processable: str, + vws_client: VWS, +) -> None: + """ + Some strings which are not valid base64 encoded strings are not + allowed + as application metadata. + """ + with pytest.raises(expected_exception=FailError) as exc: + _ = vws_client.add_target( + name="example", + width=1, + image=high_quality_image, + application_metadata=not_base64_encoded_not_processable, + active_flag=True, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code="Fail", + ) + + +def test_metadata_too_large( + *, + image_file_failed_state: io.BytesIO, + vws_client: VWS, + application_metadata_near_size_limit: str, +) -> None: + """ + A base64 encoded string of greater than 1024 * 1024 bytes is too + large + for application metadata. + """ + decoded_metadata = base64.b64decode( + s=application_metadata_near_size_limit.encode(encoding="ascii"), + ) + metadata_encoded = base64.b64encode( + s=decoded_metadata + b"x", + ).decode(encoding="ascii") + + with pytest.raises(expected_exception=MetadataTooLargeError) as exc: + _ = vws_client.add_target( + name="example", + width=1, + image=image_file_failed_state, + application_metadata=metadata_encoded, + active_flag=True, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code="MetadataTooLarge", + ) diff --git a/contract-tests/src/vws_mock_contract_tests/add_target/test_content_types.py b/contract-tests/src/vws_mock_contract_tests/add_target/test_content_types.py new file mode 100644 index 000000000..cedef902c --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/add_target/test_content_types.py @@ -0,0 +1,96 @@ +"""Tests for the `Content-Type` header.""" + +import base64 +import io +from http import HTTPStatus + +import pytest +from vws import VWS +from vws.exceptions.vws_exceptions import ( + AuthenticationFailureError, +) + +from vws_mock_contract_tests.add_target.helpers import ( + add_target_to_vws, + assert_success, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_vws_failure, +) + + +@pytest.mark.parametrize( + argnames="content_type", + argvalues=[ + # This is the documented required content type: + "application/json", + # Other content types also work. + "other/content_type", + ], + ids=[ + "Documented Content-Type", + "Undocumented Content-Type", + ], +) +def test_content_types( + *, + vws_client: VWS, + image_file_failed_state: io.BytesIO, + content_type: str, +) -> None: + """Any non-empty ``Content-Type`` header is allowed.""" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) + + data = { + "name": "example", + "width": 1, + "image": image_data_encoded, + } + + response = add_target_to_vws( + vws_client=vws_client, + data=data, + content_type=content_type, + ) + + assert_success(response=response) + + +def test_empty_content_type( + *, + vws_client: VWS, + image_file_failed_state: io.BytesIO, +) -> None: + """ + An ``UNAUTHORIZED`` response is given if an empty ``Content- + Type`` + header is given. + """ + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) + + data = { + "name": "example", + "width": 1, + "image": image_data_encoded, + } + + with pytest.raises( + expected_exception=AuthenticationFailureError, + ) as exc: + _ = add_target_to_vws( + vws_client=vws_client, + data=data, + content_type="", + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNAUTHORIZED, + result_code="AuthenticationFailure", + ) diff --git a/contract-tests/src/vws_mock_contract_tests/add_target/test_image.py b/contract-tests/src/vws_mock_contract_tests/add_target/test_image.py new file mode 100644 index 000000000..b45e6105d --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/add_target/test_image.py @@ -0,0 +1,348 @@ +"""Tests for the image parameter. + +The specification for images is documented at +https://library.vuforia.com/features/images/image-targets.html. +""" + +import io +from http import HTTPStatus + +import pytest +from vws import VWS +from vws.exceptions.vws_exceptions import ( + BadImageError, + FailError, + ImageTooLargeError, +) +from vws_test_fixtures.images import VWS_MAX_IMAGE_FILE_SIZE + +from vws_mock_contract_tests.add_target.helpers import ( + add_target_to_vws, +) +from vws_mock_contract_tests.utils import ( + make_decompression_bomb_image_file, + make_single_color_image_file, + make_truncated_png_file, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_vws_failure, +) + + +def test_image_valid( + *, + vws_client: VWS, + image_files_failed_state: io.BytesIO, +) -> None: + """ + JPEG and PNG files in the RGB and greyscale color spaces are + allowed. + """ + _ = vws_client.add_target( + name="example_name", + width=1, + image=image_files_failed_state, + application_metadata=None, + active_flag=True, + ) + + +def test_bad_image_format_or_color_space( + *, + bad_image_file: io.BytesIO, + vws_client: VWS, +) -> None: + """ + An `UNPROCESSABLE_ENTITY` response is returned if an image which + is not + a JPEG or PNG file is given, or if the given image is not in the + greyscale or RGB color space. + """ + with pytest.raises(expected_exception=BadImageError) as exc: + _ = vws_client.add_target( + name="example_name", + width=1, + image=bad_image_file, + application_metadata=None, + active_flag=True, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code="BadImage", + ) + + +def test_corrupted( + *, + corrupted_image_file: io.BytesIO, + vws_client: VWS, +) -> None: + """An error is returned when the given image is corrupted.""" + with pytest.raises(expected_exception=BadImageError) as exc: + _ = vws_client.add_target( + name="example_name", + width=1, + image=corrupted_image_file, + application_metadata=None, + active_flag=True, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code="BadImage", + ) + + +def test_truncated(vws_client: VWS) -> None: + """ + An error is returned when the given image is truncated before + the + end of its image data. + """ + image_file = make_truncated_png_file() + + with pytest.raises(expected_exception=BadImageError) as exc: + _ = vws_client.add_target( + name="example_name", + width=1, + image=image_file, + application_metadata=None, + active_flag=True, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code="BadImage", + ) + + +def test_decompression_bomb(vws_client: VWS) -> None: + """ + An ``ImageTooLargeError`` result is returned when the given + image has a small file size but a huge number of pixels. + """ + max_bytes = 2.3 * 1024 * 1024 + image_file = make_decompression_bomb_image_file() + assert len(image_file.getvalue()) < max_bytes + + with pytest.raises(expected_exception=ImageTooLargeError) as exc: + _ = vws_client.add_target( + name="example_name", + width=1, + image=image_file, + application_metadata=None, + active_flag=True, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code="ImageTooLarge", + ) + + +def test_image_pixel_count_too_large( + *, + vws_client: VWS, + pixel_count_too_large: io.BytesIO, +) -> None: + """ + An ``ImageTooLargeError`` result is returned if the image has + more than 37748736 pixels, whatever its file size. + + This limit is not documented. + """ + max_allowed_pixels = 37_748_736 + width = height = 6144 + assert width * height == max_allowed_pixels + + image_not_too_many_pixels = make_single_color_image_file( + width=width, + height=height, + ) + + _ = vws_client.add_target( + name="example_name", + width=1, + image=image_not_too_many_pixels, + application_metadata=None, + active_flag=True, + ) + + with pytest.raises(expected_exception=ImageTooLargeError) as exc: + _ = vws_client.add_target( + name="example_name_2", + width=1, + image=pixel_count_too_large, + application_metadata=None, + active_flag=True, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code="ImageTooLarge", + ) + + +def test_image_file_size_too_large( + *, + vws_client: VWS, + png_just_under_max_size: io.BytesIO, + png_too_large: io.BytesIO, +) -> None: + """ + An ``ImageTooLargeError`` result is returned if the image file + size is + above a certain threshold. + """ + max_bytes = VWS_MAX_IMAGE_FILE_SIZE + + image_content_size = len(png_just_under_max_size.getvalue()) + assert image_content_size < max_bytes + assert (image_content_size * 1.05) > max_bytes + + _ = vws_client.add_target( + name="example_name", + width=1, + image=png_just_under_max_size, + application_metadata=None, + active_flag=True, + ) + + image_content_size = len(png_too_large.getvalue()) + assert image_content_size > max_bytes + + with pytest.raises(expected_exception=ImageTooLargeError) as exc: + _ = vws_client.add_target( + name="example_name_2", + width=1, + image=png_too_large, + application_metadata=None, + active_flag=True, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code="ImageTooLarge", + ) + + +def test_not_base64_encoded_processable( + *, + vws_client: VWS, + not_base64_encoded_processable: str, +) -> None: + """Some strings which are not valid base64 encoded strings are + allowed + as an image without getting a "Fail" response. + + This is because Vuforia treats them as valid base64, but then + not a valid image. + """ + data = { + "name": "example_name", + "width": 1, + "image": not_base64_encoded_processable, + } + + with pytest.raises(expected_exception=BadImageError) as exc: + _ = add_target_to_vws( + vws_client=vws_client, + data=data, + content_type="application/json", + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code="BadImage", + ) + + +def test_not_base64_encoded_not_processable( + *, + vws_client: VWS, + not_base64_encoded_not_processable: str, +) -> None: + """ + Some strings which are not valid base64 encoded strings are not + processable by Vuforia, and then when given as an image Vuforia + returns + a "Fail" response. + """ + data = { + "name": "example_name", + "width": 1, + "image": not_base64_encoded_not_processable, + } + + with pytest.raises(expected_exception=FailError) as exc: + _ = add_target_to_vws( + vws_client=vws_client, + data=data, + content_type="application/json", + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code="Fail", + ) + + +def test_not_image(vws_client: VWS) -> None: + """ + If the given image is not an image file then a `BadImageError` + result + is returned. + """ + with pytest.raises(expected_exception=BadImageError) as exc: + _ = vws_client.add_target( + name="example_name", + width=1, + image=io.BytesIO(initial_bytes=b"not_image_data"), + application_metadata=None, + active_flag=True, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code="BadImage", + ) + + +@pytest.mark.parametrize( + argnames="invalid_type_image", + argvalues=[1, None], +) +def test_invalid_type( + *, + invalid_type_image: int | None, + vws_client: VWS, +) -> None: + """If the given image is not a string, a `Fail` result is returned.""" + data = { + "name": "example_name", + "width": 1, + "image": invalid_type_image, + } + + with pytest.raises(expected_exception=FailError) as exc: + _ = add_target_to_vws( + vws_client=vws_client, + data=data, + content_type="application/json", + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.BAD_REQUEST, + result_code="Fail", + ) diff --git a/contract-tests/src/vws_mock_contract_tests/add_target/test_inactive_project.py b/contract-tests/src/vws_mock_contract_tests/add_target/test_inactive_project.py new file mode 100644 index 000000000..dfee8cafe --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/add_target/test_inactive_project.py @@ -0,0 +1,39 @@ +"""Tests for inactive projects.""" + +import io +from http import HTTPStatus + +import pytest +from vws import VWS +from vws.exceptions.vws_exceptions import ( + ProjectInactiveError, +) + +from vws_mock_contract_tests.utils.assertions import ( + assert_vws_failure, +) + + +def test_inactive_project( + *, + image_file_failed_state: io.BytesIO, + inactive_vws_client: VWS, +) -> None: + """ + If the project is inactive, a FORBIDDEN response is + returned. + """ + with pytest.raises(expected_exception=ProjectInactiveError) as exc: + _ = inactive_vws_client.add_target( + name="example", + width=1, + image=image_file_failed_state, + application_metadata=None, + active_flag=True, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.FORBIDDEN, + result_code="ProjectInactive", + ) diff --git a/contract-tests/src/vws_mock_contract_tests/add_target/test_missing_data.py b/contract-tests/src/vws_mock_contract_tests/add_target/test_missing_data.py new file mode 100644 index 000000000..62c60bfe3 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/add_target/test_missing_data.py @@ -0,0 +1,55 @@ +"""Tests for giving incomplete data.""" + +import base64 +import io +from http import HTTPStatus + +import pytest +from vws import VWS +from vws.exceptions.vws_exceptions import ( + FailError, +) + +from vws_mock_contract_tests.add_target.helpers import ( + add_target_to_vws, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_vws_failure, +) + + +@pytest.mark.parametrize( + argnames="data_to_remove", + argvalues=["name", "width", "image"], +) +def test_missing_data( + *, + vws_client: VWS, + image_file_failed_state: io.BytesIO, + data_to_remove: str, +) -> None: + """`name`, `width` and `image` are all required.""" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii", + ) + + data = { + "name": "example_name", + "width": 1, + "image": image_data_encoded, + } + _ = data.pop(data_to_remove) + + with pytest.raises(expected_exception=FailError) as exc: + _ = add_target_to_vws( + vws_client=vws_client, + data=data, + content_type="application/json", + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.BAD_REQUEST, + result_code="Fail", + ) diff --git a/contract-tests/src/vws_mock_contract_tests/add_target/test_target_name.py b/contract-tests/src/vws_mock_contract_tests/add_target/test_target_name.py new file mode 100644 index 000000000..0fd758c7a --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/add_target/test_target_name.py @@ -0,0 +1,178 @@ +"""Tests for the target name field.""" + +import base64 +import io +from http import HTTPStatus + +import pytest +from vws import VWS +from vws.exceptions.custom_exceptions import ( + ServerError, +) +from vws.exceptions.vws_exceptions import ( + FailError, + TargetNameExistError, +) + +from vws_mock_contract_tests.add_target.helpers import ( + add_target_to_vws, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_vws_failure, +) + +_MAX_CHAR_VALUE = 65535 +_MAX_NAME_LENGTH = 64 + + +@pytest.mark.parametrize( + argnames="name", + argvalues=[ + "á", + # We test just below the max character value. + # This is because targets with the max character value in their + # names get stuck in the processing stage. + chr(_MAX_CHAR_VALUE - 2), + "a" * _MAX_NAME_LENGTH, + ], + ids=["Short name", "Max char value", "Long name"], +) +def test_name_valid( + *, + name: str, + image_file_failed_state: io.BytesIO, + vws_client: VWS, +) -> None: + """Names between 1 and 64 characters in length are valid.""" + _ = vws_client.add_target( + name=name, + width=1, + image=image_file_failed_state, + application_metadata=None, + active_flag=True, + ) + + +@pytest.mark.parametrize( + argnames=("name", "status_code"), + argvalues=[ + (1, HTTPStatus.BAD_REQUEST), + ("", HTTPStatus.BAD_REQUEST), + ("a" * (_MAX_NAME_LENGTH + 1), HTTPStatus.BAD_REQUEST), + (None, HTTPStatus.BAD_REQUEST), + (chr(_MAX_CHAR_VALUE + 1), HTTPStatus.INTERNAL_SERVER_ERROR), + ( + chr(_MAX_CHAR_VALUE + 1) * (_MAX_NAME_LENGTH + 1), + HTTPStatus.BAD_REQUEST, + ), + ], + ids=[ + "Wrong Type", + "Empty", + "Too Long", + "None", + "Bad char", + "Bad char too long", + ], +) +def test_name_invalid( + *, + name: str | int | None, + image_file_failed_state: io.BytesIO, + status_code: int, + vws_client: VWS, +) -> None: + """ + A target's name must be a string of length 0 < N < 65, with + characters + in a particular range. + """ + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii", + ) + data = { + "name": name, + "width": 1, + "image": image_data_encoded, + "application_metadata": None, + "active_flag": True, + } + + exc: pytest.ExceptionInfo[FailError | ServerError] + + if status_code == HTTPStatus.INTERNAL_SERVER_ERROR: + with pytest.raises(expected_exception=ServerError) as exc: + _ = add_target_to_vws( + vws_client=vws_client, + data=data, + content_type="application/json", + ) + else: + with pytest.raises(expected_exception=FailError) as exc: + _ = add_target_to_vws( + vws_client=vws_client, + data=data, + content_type="application/json", + ) + + assert_vws_failure( + response=exc.value.response, + status_code=status_code, + result_code="Fail", + ) + + +def test_existing_target_name( + *, + image_file_failed_state: io.BytesIO, + vws_client: VWS, +) -> None: + """Only one target can have a given name.""" + _ = vws_client.add_target( + name="example_name", + width=1, + image=image_file_failed_state, + application_metadata=None, + active_flag=True, + ) + + with pytest.raises(expected_exception=TargetNameExistError) as exc: + _ = vws_client.add_target( + name="example_name", + width=1, + image=image_file_failed_state, + application_metadata=None, + active_flag=True, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.FORBIDDEN, + result_code="TargetNameExist", + ) + + +def test_deleted_existing_target_name( + *, + image_file_failed_state: io.BytesIO, + vws_client: VWS, +) -> None: + """A target can be added with the name of a deleted target.""" + target_id = vws_client.add_target( + name="example_name", + width=1, + image=image_file_failed_state, + application_metadata=None, + active_flag=True, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.delete_target(target_id=target_id) + _ = vws_client.add_target( + name="example_name", + width=1, + image=image_file_failed_state, + application_metadata=None, + active_flag=True, + ) diff --git a/contract-tests/src/vws_mock_contract_tests/add_target/test_unexpected_data.py b/contract-tests/src/vws_mock_contract_tests/add_target/test_unexpected_data.py new file mode 100644 index 000000000..c6b85f07c --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/add_target/test_unexpected_data.py @@ -0,0 +1,56 @@ +""" +Tests for passing data which is not mandatory or allowed to the +endpoint. +""" + +import base64 +import io +from http import HTTPStatus + +import pytest +from vws import VWS +from vws.exceptions.vws_exceptions import ( + FailError, +) + +from vws_mock_contract_tests.add_target.helpers import ( + add_target_to_vws, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_vws_failure, +) + + +def test_invalid_extra_data( + *, + vws_client: VWS, + image_file_failed_state: io.BytesIO, +) -> None: + """ + A `BAD_REQUEST` response is returned when unexpected data is + given. + """ + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) + + data = { + "name": "example_name", + "width": 1, + "image": image_data_encoded, + "extra_thing": 1, + } + + with pytest.raises(expected_exception=FailError) as exc: + _ = add_target_to_vws( + vws_client=vws_client, + data=data, + content_type="application/json", + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.BAD_REQUEST, + result_code="Fail", + ) diff --git a/contract-tests/src/vws_mock_contract_tests/add_target/test_width.py b/contract-tests/src/vws_mock_contract_tests/add_target/test_width.py new file mode 100644 index 000000000..a96862ad2 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/add_target/test_width.py @@ -0,0 +1,70 @@ +"""Tests for the target width field.""" + +import base64 +import io +from http import HTTPStatus + +import pytest +from vws import VWS +from vws.exceptions.vws_exceptions import ( + FailError, +) + +from vws_mock_contract_tests.add_target.helpers import ( + add_target_to_vws, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_vws_failure, +) + + +@pytest.mark.parametrize( + argnames="width", + argvalues=[-1, "10", None, 0], + ids=["Negative", "Wrong Type", "None", "Zero"], +) +def test_width_invalid( + *, + vws_client: VWS, + image_file_failed_state: io.BytesIO, + width: int | str | None, +) -> None: + """The width must be a number greater than zero.""" + image_data = image_file_failed_state.getvalue() + image_data_encoded = base64.b64encode(s=image_data).decode( + encoding="ascii" + ) + + data = { + "name": "example_name", + "width": width, + "image": image_data_encoded, + } + + with pytest.raises(expected_exception=FailError) as exc: + _ = add_target_to_vws( + vws_client=vws_client, + data=data, + content_type="application/json", + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.BAD_REQUEST, + result_code="Fail", + ) + + +def test_width_valid( + *, + vws_client: VWS, + image_file_failed_state: io.BytesIO, +) -> None: + """Positive numbers are valid widths.""" + _ = vws_client.add_target( + name="example", + width=0.01, + image=image_file_failed_state, + application_metadata=None, + active_flag=True, + ) diff --git a/contract-tests/src/vws_mock_contract_tests/delete_target/__init__.py b/contract-tests/src/vws_mock_contract_tests/delete_target/__init__.py new file mode 100644 index 000000000..83b56e04d --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/delete_target/__init__.py @@ -0,0 +1 @@ +"""Delete target tests.""" diff --git a/contract-tests/src/vws_mock_contract_tests/delete_target/conftest.py b/contract-tests/src/vws_mock_contract_tests/delete_target/conftest.py new file mode 100644 index 000000000..d409c8ebf --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/delete_target/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures scoped to this group of tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def fixture_verify_backend( + *, + verify_mock_vuforia: None, +) -> None: + """Set up the backend for every contract test in this directory.""" + del verify_mock_vuforia diff --git a/contract-tests/src/vws_mock_contract_tests/delete_target/test_delete.py b/contract-tests/src/vws_mock_contract_tests/delete_target/test_delete.py new file mode 100644 index 000000000..155846601 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/delete_target/test_delete.py @@ -0,0 +1,44 @@ +"""Tests for deleting targets.""" + +from http import HTTPStatus + +import pytest +from vws import VWS +from vws.exceptions.vws_exceptions import ( + TargetStatusProcessingError, + UnknownTargetError, +) + +from vws_mock_contract_tests.utils.assertions import assert_vws_failure + + +def test_no_wait( + *, + unprocessed_target_id: str, + vws_client: VWS, +) -> None: + """When attempting to delete a target immediately after creating + it, a + `FORBIDDEN` response is returned. + + This is because the target goes into a processing state. + + There is a race condition here - if the target goes into a success or + fail state before the deletion attempt. + """ + with pytest.raises(expected_exception=TargetStatusProcessingError) as exc: + vws_client.delete_target(target_id=unprocessed_target_id) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.FORBIDDEN, + result_code="TargetStatusProcessing", + ) + + +def test_processed(*, target_id: str, vws_client: VWS) -> None: + """When a target has finished processing, it can be deleted.""" + vws_client.delete_target(target_id=target_id) + + with pytest.raises(expected_exception=UnknownTargetError): + _ = vws_client.get_target_record(target_id=target_id) diff --git a/contract-tests/src/vws_mock_contract_tests/delete_target/test_inactive_project.py b/contract-tests/src/vws_mock_contract_tests/delete_target/test_inactive_project.py new file mode 100644 index 000000000..efe59c580 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/delete_target/test_inactive_project.py @@ -0,0 +1,27 @@ +"""Tests for inactive projects.""" + +from http import HTTPStatus + +import pytest +from vws import VWS +from vws.exceptions.vws_exceptions import ( + ProjectInactiveError, +) + +from vws_mock_contract_tests.utils.assertions import assert_vws_failure + + +def test_inactive_project(inactive_vws_client: VWS) -> None: + """ + If the project is inactive, a FORBIDDEN response is + returned. + """ + target_id = "abc12345a" + with pytest.raises(expected_exception=ProjectInactiveError) as exc: + inactive_vws_client.delete_target(target_id=target_id) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.FORBIDDEN, + result_code="ProjectInactive", + ) diff --git a/contract-tests/src/vws_mock_contract_tests/get_duplicates/__init__.py b/contract-tests/src/vws_mock_contract_tests/get_duplicates/__init__.py new file mode 100644 index 000000000..54346e3f4 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/get_duplicates/__init__.py @@ -0,0 +1 @@ +"""Get duplicates tests.""" diff --git a/contract-tests/src/vws_mock_contract_tests/get_duplicates/conftest.py b/contract-tests/src/vws_mock_contract_tests/get_duplicates/conftest.py new file mode 100644 index 000000000..d409c8ebf --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/get_duplicates/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures scoped to this group of tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def fixture_verify_backend( + *, + verify_mock_vuforia: None, +) -> None: + """Set up the backend for every contract test in this directory.""" + del verify_mock_vuforia diff --git a/contract-tests/src/vws_mock_contract_tests/get_duplicates/test_active_flag.py b/contract-tests/src/vws_mock_contract_tests/get_duplicates/test_active_flag.py new file mode 100644 index 000000000..59fa34851 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/get_duplicates/test_active_flag.py @@ -0,0 +1,55 @@ +"""Tests for the effects of the active flag on duplicate matching.""" + +import io +import uuid + +from vws import VWS + + +def test_active_flag( + *, + high_quality_image: io.BytesIO, + vws_client: VWS, +) -> None: + """Targets with `active_flag` set to `False` can have duplicates. + Targets with `active_flag` set to `False` are not found as + duplicates. + + https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#check + says: + + ''' + If a target is explicitly inactivated through the VWS API (or through + the Target Manager), then this target is no longer taken into account + for the duplicate target check. + ''' + """ + original_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + active_flag=False, + application_metadata=None, + ) + similar_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=original_target_id) + vws_client.wait_for_target_processed(target_id=similar_target_id) + + duplicates = vws_client.get_duplicate_targets( + target_id=original_target_id, + ) + + assert duplicates == [similar_target_id] + + duplicates = vws_client.get_duplicate_targets( + target_id=similar_target_id, + ) + + assert duplicates == [] diff --git a/contract-tests/src/vws_mock_contract_tests/get_duplicates/test_duplicates.py b/contract-tests/src/vws_mock_contract_tests/get_duplicates/test_duplicates.py new file mode 100644 index 000000000..a974f5c28 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/get_duplicates/test_duplicates.py @@ -0,0 +1,173 @@ +"""Tests for the mock of the target duplicates endpoint.""" + +import copy +import io +import uuid + +import pytest +from PIL import Image +from vws import VWS +from vws.reports import TargetStatuses + +from vws_mock_contract_tests.utils.backend_capabilities import ( + BackendCapabilities, +) + + +def test_duplicates( + *, + high_quality_image: io.BytesIO, + image_file_success_state_low_rating: io.BytesIO, + vws_client: VWS, +) -> None: + """Target IDs of the exact same targets are returned.""" + image_data = high_quality_image + different_image_data = image_file_success_state_low_rating + + original_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_data, + active_flag=True, + application_metadata=None, + ) + + similar_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_data, + active_flag=True, + application_metadata=None, + ) + + different_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=different_image_data, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=original_target_id) + vws_client.wait_for_target_processed(target_id=similar_target_id) + vws_client.wait_for_target_processed(target_id=different_target_id) + + duplicates = vws_client.get_duplicate_targets( + target_id=original_target_id, + ) + + assert duplicates == [similar_target_id] + + +def test_duplicates_not_same( + *, + high_quality_image: io.BytesIO, + vws_client: VWS, +) -> None: + """Target IDs of similar targets are returned.""" + image_data = high_quality_image + similar_image_data = copy.copy(x=image_data) + similar_image_buffer = io.BytesIO() + pil_similar_image = Image.open(fp=similar_image_data) + # Re-save means similar but not identical. + pil_similar_image.save(fp=similar_image_buffer, format="JPEG") + assert similar_image_buffer.getvalue() != image_data.getvalue() + + original_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_data, + active_flag=True, + application_metadata=None, + ) + + similar_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=similar_image_buffer, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=original_target_id) + vws_client.wait_for_target_processed(target_id=similar_target_id) + + duplicates = vws_client.get_duplicate_targets( + target_id=original_target_id, + ) + + assert duplicates == [similar_target_id] + + +def test_status( + *, + image_file_failed_state: io.BytesIO, + vws_client: VWS, +) -> None: + """Targets are not duplicates if the status is not 'success'.""" + original_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + similar_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=original_target_id) + vws_client.wait_for_target_processed(target_id=similar_target_id) + + target_details = vws_client.get_target_record( + target_id=original_target_id, + ) + assert target_details.status == TargetStatuses.FAILED + + duplicates = vws_client.get_duplicate_targets( + target_id=original_target_id, + ) + + assert duplicates == [] + + +def test_order_is_upload_date_then_target_id( + *, + backend_capabilities: BackendCapabilities, + high_quality_image: io.BytesIO, + vws_client: VWS, +) -> None: + """The mock returns duplicates ordered by upload date. + + The real Vuforia Web Services do not document an order, so we do + not verify this against them. + """ + if not backend_capabilities.deterministic_target_order: + pytest.skip( + reason="This backend does not guarantee target upload-date order." + ) + + target_ids = [ + vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + for _ in range(3) + ] + + for target_id in target_ids: + vws_client.wait_for_target_processed(target_id=target_id) + + duplicates = vws_client.get_duplicate_targets( + target_id=target_ids[0], + ) + + assert duplicates == target_ids[1:] diff --git a/contract-tests/src/vws_mock_contract_tests/get_duplicates/test_inactive_project.py b/contract-tests/src/vws_mock_contract_tests/get_duplicates/test_inactive_project.py new file mode 100644 index 000000000..f74f997e9 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/get_duplicates/test_inactive_project.py @@ -0,0 +1,18 @@ +"""Tests for inactive projects.""" + +import uuid + +import pytest +from vws import VWS +from vws.exceptions.vws_exceptions import ProjectInactiveError + + +def test_inactive_project(inactive_vws_client: VWS) -> None: + """ + If the project is inactive, a FORBIDDEN response is + returned. + """ + with pytest.raises(expected_exception=ProjectInactiveError): + _ = inactive_vws_client.get_duplicate_targets( + target_id=uuid.uuid4().hex, + ) diff --git a/contract-tests/src/vws_mock_contract_tests/get_duplicates/test_processing.py b/contract-tests/src/vws_mock_contract_tests/get_duplicates/test_processing.py new file mode 100644 index 000000000..40b519ad8 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/get_duplicates/test_processing.py @@ -0,0 +1,54 @@ +"""Tests for targets in the processing stage.""" + +import io +import uuid + +from vws import VWS +from vws.reports import TargetStatuses + + +def test_processing( + *, + high_quality_image: io.BytesIO, + vws_client: VWS, +) -> None: + """If a target is in the processing state, it can have duplicates. + + Targets can have duplicates in the processing state. + """ + processed_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=processed_target_id) + + processing_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + + duplicates = vws_client.get_duplicate_targets( + target_id=processed_target_id, + ) + + assert duplicates == [] + + duplicates = vws_client.get_duplicate_targets( + target_id=processing_target_id, + ) + + target_details = vws_client.get_target_record( + target_id=processing_target_id, + ) + + # There is a race condition here. + # If getting the target details and getting the duplicates takes longer + # than the processing time, the target will be in the success state. + assert target_details.status == TargetStatuses.PROCESSING + assert duplicates == [processed_target_id] diff --git a/contract-tests/src/vws_mock_contract_tests/get_target/__init__.py b/contract-tests/src/vws_mock_contract_tests/get_target/__init__.py new file mode 100644 index 000000000..36ab5c93c --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/get_target/__init__.py @@ -0,0 +1 @@ +"""Get target tests.""" diff --git a/contract-tests/src/vws_mock_contract_tests/get_target/conftest.py b/contract-tests/src/vws_mock_contract_tests/get_target/conftest.py new file mode 100644 index 000000000..d409c8ebf --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/get_target/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures scoped to this group of tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def fixture_verify_backend( + *, + verify_mock_vuforia: None, +) -> None: + """Set up the backend for every contract test in this directory.""" + del verify_mock_vuforia diff --git a/tests/mock_vws/get_target/helpers.py b/contract-tests/src/vws_mock_contract_tests/get_target/helpers.py similarity index 100% rename from tests/mock_vws/get_target/helpers.py rename to contract-tests/src/vws_mock_contract_tests/get_target/helpers.py diff --git a/contract-tests/src/vws_mock_contract_tests/get_target/test_get_record.py b/contract-tests/src/vws_mock_contract_tests/get_target/test_get_record.py new file mode 100644 index 000000000..9f36a7ad5 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/get_target/test_get_record.py @@ -0,0 +1,103 @@ +"""Tests for getting a target record.""" + +import io + +from vws import VWS +from vws.reports import TargetRecord, TargetStatuses + + +def test_get_vws_target( + *, + vws_client: VWS, + image_file_failed_state: io.BytesIO, +) -> None: + """Details of a target are returned.""" + name = "my_example_name" + width = 1234 + + target_id = vws_client.add_target( + name=name, + width=width, + image=image_file_failed_state, + active_flag=False, + application_metadata=None, + ) + target_details = vws_client.get_target_record(target_id=target_id) + target_record = target_details.target_record + tracking_rating = target_record.tracking_rating + + # Tracking rating may be -1 while processing. + assert tracking_rating in range(-1, 6) + target_id = target_record.target_id + + expected_target_record = TargetRecord( + target_id=target_id, + active_flag=False, + name=name, + width=width, + tracking_rating=tracking_rating, + reco_rating="", + ) + + assert target_record == expected_target_record + + +def test_fail_status( + *, + vws_client: VWS, + image_file_failed_state: io.BytesIO, +) -> None: + """ + When a 1x1 image is given, the status changes from 'processing' + to + 'failed' after some time. + """ + target_id = vws_client.add_target( + name="my_example_name", + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + target_details = vws_client.get_target_record(target_id=target_id) + assert target_details.status == TargetStatuses.FAILED + # Tracking rating is 0 when status is 'failed' + assert target_details.target_record.tracking_rating == 0 + + +def test_success_status( + *, + image_file_success_state_low_rating: io.BytesIO, + vws_client: VWS, +) -> None: + """When a random, large enough image is given, the status changes + from + 'processing' to 'success' after some time. + + The mock is much more lenient than the real implementation of + VWS. The test image does not prove that what is counted as a + success in the mock will be counted as a success in the real + implementation. + """ + target_id = vws_client.add_target( + name="example", + width=1, + image=image_file_success_state_low_rating, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + + target_details = vws_client.get_target_record(target_id=target_id) + assert target_details.status == TargetStatuses.SUCCESS + # Tracking rating is between 0 and 5 when status is 'success' + tracking_rating = target_details.target_record.tracking_rating + assert tracking_rating in range(6) + + # The tracking rating stays stable across requests + target_details = vws_client.get_target_record(target_id=target_id) + new_tracking_rating = target_details.target_record.tracking_rating + assert new_tracking_rating == tracking_rating diff --git a/contract-tests/src/vws_mock_contract_tests/get_target/test_inactive_project.py b/contract-tests/src/vws_mock_contract_tests/get_target/test_inactive_project.py new file mode 100644 index 000000000..c6c1081c1 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/get_target/test_inactive_project.py @@ -0,0 +1,13 @@ +"""Tests for inactive projects.""" + +import uuid + +import pytest +from vws import VWS +from vws.exceptions.vws_exceptions import UnknownTargetError + + +def test_inactive_project(inactive_vws_client: VWS) -> None: + """The project's active state does not affect getting a target.""" + with pytest.raises(expected_exception=UnknownTargetError): + _ = inactive_vws_client.get_target_record(target_id=uuid.uuid4().hex) diff --git a/contract-tests/src/vws_mock_contract_tests/get_target/test_target_tracking_rating.py b/contract-tests/src/vws_mock_contract_tests/get_target/test_target_tracking_rating.py new file mode 100644 index 000000000..d699086cd --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/get_target/test_target_tracking_rating.py @@ -0,0 +1,32 @@ +""" +Tests which exercise the target tracking_rating, and check the image +fixtures we use. +""" + +import io + +from vws import VWS + +from vws_mock_contract_tests.get_target.helpers import ( + get_target_tracking_rating, +) + + +def test_target_quality( + *, + vws_client: VWS, + high_quality_image: io.BytesIO, + image_file_success_state_low_rating: io.BytesIO, +) -> None: + """The target tracking rating is as expected.""" + high_quality_image_tracking_rating = get_target_tracking_rating( + vws_client=vws_client, + image_file=high_quality_image, + ) + low_quality_image_tracking_rating = get_target_tracking_rating( + vws_client=vws_client, + image_file=image_file_success_state_low_rating, + ) + assert ( + high_quality_image_tracking_rating > low_quality_image_tracking_rating + ) diff --git a/contract-tests/src/vws_mock_contract_tests/target_list/__init__.py b/contract-tests/src/vws_mock_contract_tests/target_list/__init__.py new file mode 100644 index 000000000..68a8e8388 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/target_list/__init__.py @@ -0,0 +1 @@ +"""Target list tests.""" diff --git a/contract-tests/src/vws_mock_contract_tests/target_list/conftest.py b/contract-tests/src/vws_mock_contract_tests/target_list/conftest.py new file mode 100644 index 000000000..d409c8ebf --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/target_list/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures scoped to this group of tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def fixture_verify_backend( + *, + verify_mock_vuforia: None, +) -> None: + """Set up the backend for every contract test in this directory.""" + del verify_mock_vuforia diff --git a/contract-tests/src/vws_mock_contract_tests/target_list/test_inactive_project.py b/contract-tests/src/vws_mock_contract_tests/target_list/test_inactive_project.py new file mode 100644 index 000000000..03e00bf4d --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/target_list/test_inactive_project.py @@ -0,0 +1,9 @@ +"""Tests for inactive projects.""" + +from vws import VWS + + +def test_inactive_project(inactive_vws_client: VWS) -> None: + """The project's active state does not affect the target list.""" + # No exception is raised. + _ = inactive_vws_client.list_targets() diff --git a/contract-tests/src/vws_mock_contract_tests/target_list/test_rate_limit.py b/contract-tests/src/vws_mock_contract_tests/target_list/test_rate_limit.py new file mode 100644 index 000000000..a63d4cfda --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/target_list/test_rate_limit.py @@ -0,0 +1,78 @@ +"""Tests for the request rate limit of the target list endpoint.""" + +from http import HTTPMethod, HTTPStatus + +from vws.response import Response +from vws_auth_tools import authorization_header, rfc_1123_date + +from vws_mock_contract_tests.fixtures.credentials import ( + CloudDatabaseCredentials, +) +from vws_mock_contract_tests.utils import Endpoint +from vws_mock_contract_tests.utils.assertions import ( + assert_vws_too_many_requests, +) +from vws_mock_contract_tests.utils.backend_capabilities import ( + BackendCapabilities, +) + +_REQUESTS_PER_MINUTE = 2 + + +def test_two_requests_per_minute( + *, + base_vws_url: str, + backend_capabilities: BackendCapabilities, + rate_limited_database: CloudDatabaseCredentials, +) -> None: + """A third ``GET /targets`` request within a minute is rate + limited, + with the empty response which Envoy gives. + + Real Vuforia's window is a fixed clock minute, and the fixture which + empties the database before each test has already listed the + targets, so the real backend may reject the first, second or third + request. The mocks use a rolling window on a fresh database, so they + reject exactly the third. + """ + responses: list[Response] = [] + # Real Vuforia's fixed window means the rejection may come on any + # of the first three requests, so requests are sent until one is + # rejected, with a cap well above the limit. + while True: + date = rfc_1123_date() + authorization = authorization_header( + access_key=rate_limited_database.server_access_key, + secret_key=rate_limited_database.server_secret_key, + method=HTTPMethod.GET, + content=b"", + content_type="", + date=date, + request_path="/targets", + ) + endpoint = Endpoint( + base_url=base_vws_url, + path_url="/targets", + method=HTTPMethod.GET, + headers={"Authorization": authorization, "Date": date}, + data=b"", + successful_headers_result_code="Success", + successful_headers_status_code=HTTPStatus.OK, + access_key=rate_limited_database.server_access_key, + secret_key=rate_limited_database.server_secret_key, + ) + response = endpoint.send() + responses.append(response) + if response.status_code == HTTPStatus.TOO_MANY_REQUESTS: + break + assert len(responses) <= _REQUESTS_PER_MINUTE + 2 + + status_codes = [response.status_code for response in responses] + if backend_capabilities.rolling_rate_limit_window: + assert status_codes == [ + *[HTTPStatus.OK] * _REQUESTS_PER_MINUTE, + HTTPStatus.TOO_MANY_REQUESTS, + ] + assert status_codes[-1] == HTTPStatus.TOO_MANY_REQUESTS + assert status_codes.count(HTTPStatus.OK) <= _REQUESTS_PER_MINUTE + assert_vws_too_many_requests(response=responses[-1]) diff --git a/contract-tests/src/vws_mock_contract_tests/target_list/test_target_list.py b/contract-tests/src/vws_mock_contract_tests/target_list/test_target_list.py new file mode 100644 index 000000000..b16307111 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/target_list/test_target_list.py @@ -0,0 +1,60 @@ +"""Tests for the mock of the target list endpoint at `/targets`.""" + +import io +import uuid + +import pytest +from vws import VWS + +from vws_mock_contract_tests.utils.backend_capabilities import ( + BackendCapabilities, +) + + +def test_includes_targets( + *, + vws_client: VWS, + unprocessed_target_id: str, +) -> None: + """Targets in the database are returned in the list.""" + assert vws_client.list_targets() == [unprocessed_target_id] + + +def test_deleted( + *, + vws_client: VWS, + target_id: str, +) -> None: + """Deleted targets are not returned in the list.""" + vws_client.delete_target(target_id=target_id) + assert not bool(vws_client.list_targets()) + + +def test_order_is_upload_date_then_target_id( + *, + backend_capabilities: BackendCapabilities, + high_quality_image: io.BytesIO, + vws_client: VWS, +) -> None: + """The mock returns targets ordered by upload date. + + The real Vuforia Web Services do not document an order, so we do + not verify this against them. + """ + if not backend_capabilities.deterministic_target_order: + pytest.skip( + reason="This backend does not guarantee target upload-date order." + ) + + target_ids = [ + vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + for _ in range(3) + ] + + assert vws_client.list_targets() == target_ids diff --git a/contract-tests/src/vws_mock_contract_tests/target_summary/__init__.py b/contract-tests/src/vws_mock_contract_tests/target_summary/__init__.py new file mode 100644 index 000000000..ac0940d26 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/target_summary/__init__.py @@ -0,0 +1 @@ +"""Target summary tests.""" diff --git a/contract-tests/src/vws_mock_contract_tests/target_summary/contract/__init__.py b/contract-tests/src/vws_mock_contract_tests/target_summary/contract/__init__.py new file mode 100644 index 000000000..2dbb3bed8 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/target_summary/contract/__init__.py @@ -0,0 +1 @@ +"""Tests using verify mock vuforia.""" diff --git a/contract-tests/src/vws_mock_contract_tests/target_summary/contract/conftest.py b/contract-tests/src/vws_mock_contract_tests/target_summary/contract/conftest.py new file mode 100644 index 000000000..d409c8ebf --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/target_summary/contract/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures scoped to this group of tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def fixture_verify_backend( + *, + verify_mock_vuforia: None, +) -> None: + """Set up the backend for every contract test in this directory.""" + del verify_mock_vuforia diff --git a/contract-tests/src/vws_mock_contract_tests/target_summary/contract/test_inactive_project.py b/contract-tests/src/vws_mock_contract_tests/target_summary/contract/test_inactive_project.py new file mode 100644 index 000000000..cdae1bfe8 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/target_summary/contract/test_inactive_project.py @@ -0,0 +1,15 @@ +"""Tests for inactive projects.""" + +import uuid + +import pytest +from vws import VWS +from vws.exceptions.vws_exceptions import UnknownTargetError + + +def test_inactive_project(inactive_vws_client: VWS) -> None: + """The project's active state does not affect getting a target.""" + with pytest.raises(expected_exception=UnknownTargetError): + _ = inactive_vws_client.get_target_summary_report( + target_id=uuid.uuid4().hex, + ) diff --git a/contract-tests/src/vws_mock_contract_tests/target_summary/contract/test_recognition_counts.py b/contract-tests/src/vws_mock_contract_tests/target_summary/contract/test_recognition_counts.py new file mode 100644 index 000000000..ea8b67d9d --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/target_summary/contract/test_recognition_counts.py @@ -0,0 +1,34 @@ +"""Tests for the recognition counts in the summary.""" + +import io + +from vws import VWS, CloudRecoService +from vws.reports import TargetStatuses + + +def test_recognition( + *, + vws_client: VWS, + cloud_reco_client: CloudRecoService, + high_quality_image: io.BytesIO, +) -> None: + """The recognition counts stay at 0 even after recognitions.""" + 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) + + results = cloud_reco_client.query(image=high_quality_image) + (result,) = results + assert result.target_id == target_id + + report = vws_client.get_target_summary_report(target_id=target_id) + assert report.status == TargetStatuses.SUCCESS + assert report.total_recos == 0 + assert report.current_month_recos == 0 + assert report.previous_month_recos == 0 diff --git a/contract-tests/src/vws_mock_contract_tests/target_summary/contract/test_target_summary.py b/contract-tests/src/vws_mock_contract_tests/target_summary/contract/test_target_summary.py new file mode 100644 index 000000000..3b62ea648 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/target_summary/contract/test_target_summary.py @@ -0,0 +1,112 @@ +"""Tests for the target summary endpoint.""" + +import datetime as dt +import io +import uuid +from zoneinfo import ZoneInfo + +import pytest +from vws import VWS +from vws.reports import TargetStatuses + +from vws_mock_contract_tests.fixtures.credentials import ( + NamedCloudDatabaseCredentials, +) +from vws_mock_contract_tests.target_summary.helpers import ( + add_target_and_get_pre_rating_summary, +) + + +@pytest.mark.parametrize(argnames="active_flag", argvalues=[True, False]) +def test_target_summary( + *, + vws_client: VWS, + vuforia_database: NamedCloudDatabaseCredentials, + image_file_failed_state: io.BytesIO, + active_flag: bool, +) -> None: + """A target summary is returned.""" + name = uuid.uuid4().hex + gmt = ZoneInfo(key="GMT") + date_before_add_target = dt.datetime.now(tz=gmt).date() + + report = add_target_and_get_pre_rating_summary( + vws_client=vws_client, + name=name, + image_file=image_file_failed_state, + active_flag=active_flag, + ) + + date_after_add_target = dt.datetime.now(tz=gmt).date() + + assert report.status == TargetStatuses.PROCESSING + assert report.database_name == vuforia_database.database_name + assert report.target_name == name + assert report.active_flag == active_flag + + # In case the date changes while adding a target + # we allow the date before and after adding the target. + + assert report.upload_date in { + date_before_add_target, + date_after_add_target, + } + + assert report.tracking_rating == -1 + assert report.total_recos == 0 + assert report.current_month_recos == 0 + assert report.previous_month_recos == 0 + + +@pytest.mark.parametrize( + argnames=("image_fixture_name", "expected_status"), + argvalues=[ + ("high_quality_image", TargetStatuses.SUCCESS), + ("image_file_failed_state", TargetStatuses.FAILED), + ], +) +def test_after_processing( + *, + vws_client: VWS, + request: pytest.FixtureRequest, + image_fixture_name: str, + expected_status: TargetStatuses, +) -> None: + """After processing is completed, the tracking rating is in the + range + of 0 to 5. + + The documentation says: + + > Note: tracking_rating and reco_rating are provided only when + > status = success. + + However, this shows that ``tracking_rating`` is given when the status + is not success. + It also shows that ``reco_rating`` is not provided even when the status + is success. + """ + image_file = request.getfixturevalue(argname=image_fixture_name) + + target_id = vws_client.add_target( + name="example", + width=1, + image=image_file, + active_flag=True, + application_metadata=None, + ) + + # The tracking rating may change during processing. + # Therefore we wait until processing ends. + vws_client.wait_for_target_processed(target_id=target_id) + + report = vws_client.get_target_summary_report(target_id=target_id) + target_details = vws_client.get_target_record(target_id=target_id) + + tracking_rating = target_details.target_record.tracking_rating + assert report.tracking_rating == tracking_rating + assert report.tracking_rating in range(6) + assert report.status == expected_status + assert report.total_recos == 0 + assert report.current_month_recos == 0 + assert report.previous_month_recos == 0 diff --git a/tests/mock_vws/target_summary/helpers.py b/contract-tests/src/vws_mock_contract_tests/target_summary/helpers.py similarity index 100% rename from tests/mock_vws/target_summary/helpers.py rename to contract-tests/src/vws_mock_contract_tests/target_summary/helpers.py diff --git a/contract-tests/src/vws_mock_contract_tests/target_summary/test_seeded_recognition_counts.py b/contract-tests/src/vws_mock_contract_tests/target_summary/test_seeded_recognition_counts.py new file mode 100644 index 000000000..f75bfc2e2 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/target_summary/test_seeded_recognition_counts.py @@ -0,0 +1,56 @@ +"""Tests for the recognition counts which are set on a target. + +Real Vuforia's recognition counts lag behind its queries by longer than +a test runs, so the mocks let the counts be set rather than counting +recognitions. Nothing sets counts on real Vuforia, so these tests run +against the mocks only. +""" + +import io +import uuid + +from vws import VWS +from vws.reports import TargetStatuses + +from vws_mock_contract_tests.fixtures.credentials import ( + NamedCloudDatabaseCredentials, +) +from vws_mock_contract_tests.utils.recognition_counts import ( + SeedRecognitionCounts, +) + +_CURRENT_MONTH_RECOS = 3 +_PREVIOUS_MONTH_RECOS = 5 +_TOTAL_RECOS = 8 + + +def test_seeded_counts( + *, + seed_recognition_counts: SeedRecognitionCounts, + vws_client: VWS, + vuforia_database: NamedCloudDatabaseCredentials, + high_quality_image: io.BytesIO, +) -> None: + """The summary shows the recognition counts set on the target.""" + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id) + + seed_recognition_counts( + database_name=vuforia_database.database_name, + target_id=target_id, + current_month_recos=_CURRENT_MONTH_RECOS, + previous_month_recos=_PREVIOUS_MONTH_RECOS, + total_recos=_TOTAL_RECOS, + ) + + report = vws_client.get_target_summary_report(target_id=target_id) + assert report.status == TargetStatuses.SUCCESS + assert report.total_recos == _TOTAL_RECOS + assert report.current_month_recos == _CURRENT_MONTH_RECOS + assert report.previous_month_recos == _PREVIOUS_MONTH_RECOS diff --git a/contract-tests/src/vws_mock_contract_tests/update_target/__init__.py b/contract-tests/src/vws_mock_contract_tests/update_target/__init__.py new file mode 100644 index 000000000..ecb16b2b6 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/update_target/__init__.py @@ -0,0 +1 @@ +"""Update target tests.""" diff --git a/contract-tests/src/vws_mock_contract_tests/update_target/conftest.py b/contract-tests/src/vws_mock_contract_tests/update_target/conftest.py new file mode 100644 index 000000000..d409c8ebf --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/update_target/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures scoped to this group of tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def fixture_verify_backend( + *, + verify_mock_vuforia: None, +) -> None: + """Set up the backend for every contract test in this directory.""" + del verify_mock_vuforia diff --git a/tests/mock_vws/update_target/helpers.py b/contract-tests/src/vws_mock_contract_tests/update_target/helpers.py similarity index 100% rename from tests/mock_vws/update_target/helpers.py rename to contract-tests/src/vws_mock_contract_tests/update_target/helpers.py diff --git a/contract-tests/src/vws_mock_contract_tests/update_target/test_active_flag.py b/contract-tests/src/vws_mock_contract_tests/update_target/test_active_flag.py new file mode 100644 index 000000000..85d91d507 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/update_target/test_active_flag.py @@ -0,0 +1,81 @@ +"""Tests for the active flag parameter.""" + +import io +import uuid +from http import HTTPStatus + +import pytest +from vws import VWS +from vws.exceptions.vws_exceptions import ( + FailError, +) + +from vws_mock_contract_tests.update_target.helpers import ( + update_target, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_vws_failure, +) + + +@pytest.mark.parametrize( + argnames="initial_active_flag", + argvalues=[True, False], +) +@pytest.mark.parametrize( + argnames="desired_active_flag", + argvalues=[True, False], +) +def test_active_flag( + *, + vws_client: VWS, + image_file_success_state_low_rating: io.BytesIO, + initial_active_flag: bool, + desired_active_flag: bool, +) -> None: + """Setting the active flag to a Boolean value changes it.""" + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_success_state_low_rating, + active_flag=initial_active_flag, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.update_target( + target_id=target_id, + active_flag=desired_active_flag, + ) + + target_details = vws_client.get_target_record(target_id=target_id) + assert target_details.target_record.active_flag == desired_active_flag + + +@pytest.mark.parametrize( + argnames="desired_active_flag", + argvalues=["string", None], +) +def test_invalid( + *, + vws_client: VWS, + target_id: str, + desired_active_flag: str | None, +) -> None: + """ + Values which are not Boolean values are not valid active + flags. + """ + with pytest.raises(expected_exception=FailError) as exc: + _ = update_target( + vws_client=vws_client, + data={"active_flag": desired_active_flag}, + target_id=target_id, + content_type="application/json", + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.BAD_REQUEST, + result_code="Fail", + ) diff --git a/contract-tests/src/vws_mock_contract_tests/update_target/test_application_metadata.py b/contract-tests/src/vws_mock_contract_tests/update_target/test_application_metadata.py new file mode 100644 index 000000000..2166e81a5 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/update_target/test_application_metadata.py @@ -0,0 +1,136 @@ +"""Tests for the application metadata parameter.""" + +import base64 +from http import HTTPStatus + +import pytest +from vws import VWS +from vws.exceptions.vws_exceptions import ( + FailError, + MetadataTooLargeError, +) + +from vws_mock_contract_tests.update_target.helpers import ( + MAX_METADATA_BYTES, + update_target, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_vws_failure, +) + + +@pytest.mark.parametrize( + argnames="metadata", + argvalues=[ + b"a", + b"a" * MAX_METADATA_BYTES, + ], + ids=["Short", "Max length"], +) +def test_base64_encoded( + *, + target_id: str, + metadata: bytes, + vws_client: VWS, +) -> None: + """A base64 encoded string is valid application metadata.""" + metadata_encoded = base64.b64encode(s=metadata).decode(encoding="ascii") + vws_client.update_target( + target_id=target_id, + application_metadata=metadata_encoded, + ) + + +@pytest.mark.parametrize(argnames="invalid_metadata", argvalues=[1, None]) +def test_invalid_type( + *, + vws_client: VWS, + target_id: str, + invalid_metadata: int | None, +) -> None: + """Non-string values cannot be given as valid application metadata.""" + with pytest.raises(expected_exception=FailError) as exc: + _ = update_target( + vws_client=vws_client, + data={"application_metadata": invalid_metadata}, + target_id=target_id, + content_type="application/json", + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.BAD_REQUEST, + result_code="Fail", + ) + + +def test_not_base64_encoded_processable( + *, + vws_client: VWS, + target_id: str, + not_base64_encoded_processable: str, +) -> None: + """ + Some strings which are not valid base64 encoded strings are + allowed as + application metadata. + """ + vws_client.update_target( + target_id=target_id, + application_metadata=not_base64_encoded_processable, + ) + + +def test_not_base64_encoded_not_processable( + *, + vws_client: VWS, + target_id: str, + not_base64_encoded_not_processable: str, +) -> None: + """ + Some strings which are not valid base64 encoded strings are not + allowed + as application metadata. + """ + with pytest.raises(expected_exception=FailError) as exc: + vws_client.update_target( + target_id=target_id, + application_metadata=not_base64_encoded_not_processable, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code="Fail", + ) + + +def test_metadata_too_large( + *, + vws_client: VWS, + target_id: str, + application_metadata_near_size_limit: str, +) -> None: + """ + A base64 encoded string of greater than 1024 * 1024 bytes is too + large + for application metadata. + """ + decoded_metadata = base64.b64decode( + s=application_metadata_near_size_limit.encode(encoding="ascii"), + ) + metadata_encoded = base64.b64encode( + s=decoded_metadata + b"x", + ).decode(encoding="ascii") + + with pytest.raises(expected_exception=MetadataTooLargeError) as exc: + vws_client.update_target( + target_id=target_id, + application_metadata=metadata_encoded, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code="MetadataTooLarge", + ) diff --git a/contract-tests/src/vws_mock_contract_tests/update_target/test_image.py b/contract-tests/src/vws_mock_contract_tests/update_target/test_image.py new file mode 100644 index 000000000..061ebb9fe --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/update_target/test_image.py @@ -0,0 +1,322 @@ +"""Tests for the image parameter. + +The specification for images is documented at +https://library.vuforia.com/features/images/image-targets.html. +""" + +import io +import uuid +from http import HTTPStatus + +import pytest +from vws import VWS +from vws.exceptions.vws_exceptions import ( + BadImageError, + FailError, + ImageTooLargeError, +) +from vws.reports import TargetStatuses +from vws_test_fixtures.images import VWS_MAX_IMAGE_FILE_SIZE + +from vws_mock_contract_tests.update_target.helpers import ( + update_target, +) +from vws_mock_contract_tests.utils import make_truncated_png_file +from vws_mock_contract_tests.utils.assertions import ( + assert_vws_failure, +) + + +def test_image_valid( + *, + image_files_failed_state: io.BytesIO, + target_id: str, + vws_client: VWS, +) -> None: + """ + JPEG and PNG files in the RGB and greyscale color spaces are + allowed. + """ + vws_client.update_target( + target_id=target_id, + image=image_files_failed_state, + ) + + +def test_bad_image_format_or_color_space( + *, + bad_image_file: io.BytesIO, + target_id: str, + vws_client: VWS, +) -> None: + """ + A `BAD_IMAGE` response is returned if an image which is not a + JPEG or + PNG file is given, or if the given image is not in the greyscale or + RGB + color space. + """ + with pytest.raises(expected_exception=BadImageError) as exc: + vws_client.update_target(target_id=target_id, image=bad_image_file) + + status_code = exc.value.response.status_code + assert status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +def test_corrupted( + *, + vws_client: VWS, + corrupted_image_file: io.BytesIO, + target_id: str, +) -> None: + """An error is returned when the given image is corrupted.""" + with pytest.raises(expected_exception=BadImageError) as exc: + vws_client.update_target( + target_id=target_id, + image=corrupted_image_file, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code="BadImage", + ) + + +def test_truncated( + *, + vws_client: VWS, + target_id: str, +) -> None: + """ + An error is returned when the given image is truncated before + the + end of its image data. + """ + image_file = make_truncated_png_file() + + with pytest.raises(expected_exception=BadImageError) as exc: + vws_client.update_target( + target_id=target_id, + image=image_file, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code="BadImage", + ) + + +def test_image_too_large( + *, + target_id: str, + vws_client: VWS, + png_just_under_max_size: io.BytesIO, + png_too_large: io.BytesIO, +) -> None: + """ + An `ImageTooLargeError` result is returned if the image is above + a + certain threshold. + """ + max_bytes = VWS_MAX_IMAGE_FILE_SIZE + + image_content_size = len(png_just_under_max_size.getvalue()) + assert image_content_size < max_bytes + assert (image_content_size * 1.05) > max_bytes + + vws_client.update_target( + target_id=target_id, + image=png_just_under_max_size, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + + image_content_size = len(png_too_large.getvalue()) + assert image_content_size > max_bytes + + with pytest.raises(expected_exception=ImageTooLargeError) as exc: + vws_client.update_target(target_id=target_id, image=png_too_large) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code="ImageTooLarge", + ) + + +def test_not_base64_encoded_processable( + *, + vws_client: VWS, + target_id: str, + not_base64_encoded_processable: str, +) -> None: + """Some strings which are not valid base64 encoded strings are + allowed + as an image without getting a "Fail" response. + + This is because Vuforia treats them as valid base64, but then + not a valid image. + """ + with pytest.raises(expected_exception=BadImageError) as exc: + _ = update_target( + vws_client=vws_client, + data={"image": not_base64_encoded_processable}, + target_id=target_id, + content_type="application/json", + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code="BadImage", + ) + + +def test_not_base64_encoded_not_processable( + *, + vws_client: VWS, + target_id: str, + not_base64_encoded_not_processable: str, +) -> None: + """ + Some strings which are not valid base64 encoded strings are not + processable by Vuforia, and then when given as an image Vuforia + returns + a "Fail" response. + """ + with pytest.raises(expected_exception=FailError) as exc: + _ = update_target( + vws_client=vws_client, + data={"image": not_base64_encoded_not_processable}, + target_id=target_id, + content_type="application/json", + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code="Fail", + ) + + +def test_not_image(*, target_id: str, vws_client: VWS) -> None: + """ + If the given image is not an image file then a `BadImageError` + result + is returned. + """ + with pytest.raises(expected_exception=BadImageError) as exc: + vws_client.update_target( + target_id=target_id, + image=io.BytesIO(initial_bytes=b"not_image_data"), + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code="BadImage", + ) + + +@pytest.mark.parametrize( + argnames="invalid_type_image", + argvalues=[1, None], +) +def test_invalid_type( + *, + invalid_type_image: int | None, + target_id: str, + vws_client: VWS, +) -> None: + """If the given image is not a string, a `Fail` result is returned.""" + with pytest.raises(expected_exception=FailError) as exc: + _ = update_target( + vws_client=vws_client, + data={"image": invalid_type_image}, + target_id=target_id, + content_type="application/json", + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.BAD_REQUEST, + result_code="Fail", + ) + + +def test_rating_does_not_return_to_minus_one( + *, + image_file_success_state_low_rating: io.BytesIO, + high_quality_image: io.BytesIO, + vws_client: VWS, +) -> None: + """An update does not start a new -1 tracking rating window. + + A target reports a rating of -1 for a short time after it is + uploaded, and then the image's rating, while it is still + processing. An update returns the target to the processing + state, but the rating does not go back to -1: the new image's + rating is reported straight away. + + Anyone who polls for an update to be processed by watching the + rating leave -1, rather than by watching the status, would wait + forever, so the mock must not offer them a window which the real + Vuforia does not have. + """ + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_success_state_low_rating, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + + vws_client.update_target(target_id=target_id, image=high_quality_image) + + target_details = vws_client.get_target_record(target_id=target_id) + assert target_details.target_record.tracking_rating in range(6) + + +def test_rating_can_change( + *, + image_file_success_state_low_rating: io.BytesIO, + high_quality_image: io.BytesIO, + vws_client: VWS, +) -> None: + """If the target is updated with an image of different quality, the + tracking rating can change. + + "quality" refers to Vuforia's internal rating system. The mock + randomly assigns a quality and makes sure that the new quality + is different to the old quality. + """ + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_success_state_low_rating, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + + target_details = vws_client.get_target_record(target_id=target_id) + assert target_details.status == TargetStatuses.SUCCESS + # Tracking rating is between 0 and 5 when status is 'success' + original_tracking_rating = target_details.target_record.tracking_rating + assert original_tracking_rating in range(6) + + vws_client.update_target(target_id=target_id, image=high_quality_image) + + vws_client.wait_for_target_processed(target_id=target_id) + target_details = vws_client.get_target_record(target_id=target_id) + assert target_details.status == TargetStatuses.SUCCESS + # Tracking rating is between 0 and 5 when status is 'success' + new_tracking_rating = target_details.target_record.tracking_rating + assert new_tracking_rating in range(6) + + assert original_tracking_rating != new_tracking_rating diff --git a/contract-tests/src/vws_mock_contract_tests/update_target/test_inactive_project.py b/contract-tests/src/vws_mock_contract_tests/update_target/test_inactive_project.py new file mode 100644 index 000000000..f4bedfc76 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/update_target/test_inactive_project.py @@ -0,0 +1,18 @@ +"""Tests for inactive projects.""" + +import uuid + +import pytest +from vws import VWS +from vws.exceptions.vws_exceptions import ( + ProjectInactiveError, +) + + +def test_inactive_project(inactive_vws_client: VWS) -> None: + """ + If the project is inactive, a FORBIDDEN response is + returned. + """ + with pytest.raises(expected_exception=ProjectInactiveError): + inactive_vws_client.update_target(target_id=uuid.uuid4().hex) diff --git a/contract-tests/src/vws_mock_contract_tests/update_target/test_target_name.py b/contract-tests/src/vws_mock_contract_tests/update_target/test_target_name.py new file mode 100644 index 000000000..197ef05c5 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/update_target/test_target_name.py @@ -0,0 +1,167 @@ +"""Tests for the target name field.""" + +import io +from http import HTTPStatus + +import pytest +from vws import VWS +from vws.exceptions.base_exceptions import VWSError +from vws.exceptions.vws_exceptions import ( + TargetNameExistError, +) + +from vws_mock_contract_tests.update_target.helpers import ( + update_target, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_vws_failure, +) + +_MAX_CHAR_VALUE = 65535 +_MAX_NAME_LENGTH = 64 + + +@pytest.mark.parametrize( + argnames="name", + argvalues=[ + "á", + # We test just below the max character value. + # This is because targets with the max character value in their + # names get stuck in the processing stage. + chr(_MAX_CHAR_VALUE - 2), + "a" * _MAX_NAME_LENGTH, + ], + ids=["Short name", "Max char value", "Long name"], +) +def test_name_valid( + *, + name: str, + target_id: str, + vws_client: VWS, +) -> None: + """A target's name must be a string of length 0 < N < 65. + + We test characters out of range in another test as that gives a + different error. + """ + vws_client.update_target(target_id=target_id, name=name) + target_details = vws_client.get_target_record(target_id=target_id) + assert target_details.target_record.name == name + + +@pytest.mark.parametrize( + argnames=("name", "status_code", "result_code"), + argvalues=[ + (1, HTTPStatus.BAD_REQUEST, "Fail"), + ("", HTTPStatus.BAD_REQUEST, "Fail"), + ( + "a" * (_MAX_NAME_LENGTH + 1), + HTTPStatus.BAD_REQUEST, + "Fail", + ), + (None, HTTPStatus.BAD_REQUEST, "Fail"), + ( + chr(_MAX_CHAR_VALUE + 1), + HTTPStatus.FORBIDDEN, + "TargetNameExist", + ), + ( + chr(_MAX_CHAR_VALUE + 1) * (_MAX_NAME_LENGTH + 1), + HTTPStatus.BAD_REQUEST, + "Fail", + ), + ], + ids=[ + "Wrong Type", + "Empty", + "Too Long", + "None", + "Bad char", + "Bad char too long", + ], +) +def test_name_invalid( + *, + name: str | int | None, + target_id: str, + vws_client: VWS, + status_code: int, + result_code: str, +) -> None: + """A target's name must be a string of length 0 < N < 65.""" + with pytest.raises(expected_exception=VWSError) as exc: + _ = update_target( + vws_client=vws_client, + data={"name": name}, + target_id=target_id, + content_type="application/json", + ) + + assert_vws_failure( + response=exc.value.response, + status_code=status_code, + result_code=result_code, + ) + + +def test_existing_target_name( + *, + image_file_success_state_low_rating: io.BytesIO, + vws_client: VWS, +) -> None: + """Only one target can have a given name.""" + first_target_name = "example_name" + second_target_name = "another_example_name" + + first_target_id = vws_client.add_target( + name=first_target_name, + width=1, + image=image_file_success_state_low_rating, + active_flag=True, + application_metadata=None, + ) + + second_target_id = vws_client.add_target( + name=second_target_name, + width=1, + image=image_file_success_state_low_rating, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=first_target_id) + vws_client.wait_for_target_processed(target_id=second_target_id) + + with pytest.raises(expected_exception=TargetNameExistError) as exc: + vws_client.update_target( + target_id=second_target_id, + name=first_target_name, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.FORBIDDEN, + result_code="TargetNameExist", + ) + + +def test_same_name_given( + *, + image_file_success_state_low_rating: io.BytesIO, + vws_client: VWS, +) -> None: + """Updating a target with its own name does not give an error.""" + name = "example" + + target_id = vws_client.add_target( + name=name, + width=1, + image=image_file_success_state_low_rating, + active_flag=True, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.update_target(target_id=target_id, name=name) + target_details = vws_client.get_target_record(target_id=target_id) + assert target_details.target_record.name == name diff --git a/contract-tests/src/vws_mock_contract_tests/update_target/test_unexpected_data.py b/contract-tests/src/vws_mock_contract_tests/update_target/test_unexpected_data.py new file mode 100644 index 000000000..e6aa5a186 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/update_target/test_unexpected_data.py @@ -0,0 +1,40 @@ +"""Tests for passing data which is not allowed to the endpoint.""" + +from http import HTTPStatus + +import pytest +from vws import VWS +from vws.exceptions.vws_exceptions import ( + FailError, +) + +from vws_mock_contract_tests.update_target.helpers import ( + update_target, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_vws_failure, +) + + +def test_invalid_extra_data( + *, + vws_client: VWS, + target_id: str, +) -> None: + """ + A `BAD_REQUEST` response is returned when unexpected data is + given. + """ + with pytest.raises(expected_exception=FailError) as exc: + _ = update_target( + vws_client=vws_client, + data={"extra_thing": 1}, + target_id=target_id, + content_type="application/json", + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.BAD_REQUEST, + result_code="Fail", + ) diff --git a/contract-tests/src/vws_mock_contract_tests/update_target/test_update.py b/contract-tests/src/vws_mock_contract_tests/update_target/test_update.py new file mode 100644 index 000000000..30ad68347 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/update_target/test_update.py @@ -0,0 +1,132 @@ +"""Tests for updating targets.""" + +import io +import json +from http import HTTPStatus + +import pytest +from vws import VWS +from vws.exceptions.vws_exceptions import ( + AuthenticationFailureError, + TargetStatusNotSuccessError, +) +from vws.reports import TargetStatuses + +from vws_mock_contract_tests.update_target.helpers import ( + update_target, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_vws_failure, + assert_vws_response, +) + + +@pytest.mark.parametrize( + argnames="content_type", + argvalues=[ + # This is the documented required content type: + "application/json", + # Other content types also work. + "other/content_type", + ], + ids=["Documented Content-Type", "Undocumented Content-Type"], +) +def test_content_types( + *, + vws_client: VWS, + image_file_failed_state: io.BytesIO, + content_type: str, +) -> None: + """ + The ``Content-Type`` header does not change the response as long + as it + is not empty. + """ + target_id = vws_client.add_target( + name="example", + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + with pytest.raises(expected_exception=TargetStatusNotSuccessError) as exc: + _ = update_target( + vws_client=vws_client, + data={"name": "Adam"}, + target_id=target_id, + content_type=content_type, + ) + + # Code is FORBIDDEN because the target is processing. + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.FORBIDDEN, + result_code="TargetStatusNotSuccess", + ) + + +def test_empty_content_type( + *, + vws_client: VWS, + image_file_failed_state: io.BytesIO, +) -> None: + """ + An ``UNAUTHORIZED`` response is given if an empty ``Content- + Type`` + header is given. + """ + target_id = vws_client.add_target( + name="example", + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + + with pytest.raises(expected_exception=AuthenticationFailureError) as exc: + _ = update_target( + vws_client=vws_client, + data={"name": "Adam"}, + target_id=target_id, + content_type="", + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNAUTHORIZED, + result_code="AuthenticationFailure", + ) + + +def test_no_fields_given( + *, + vws_client: VWS, + target_id: str, +) -> None: + """No data fields are required.""" + response = update_target( + vws_client=vws_client, + data={}, + target_id=target_id, + content_type="application/json", + ) + + assert_vws_response( + response=response, + status_code=HTTPStatus.OK, + result_code="Success", + ) + + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert response_json.keys() == {"result_code", "transaction_id"} + + target_details = vws_client.get_target_record(target_id=target_id) + # Targets go back to processing after being updated. + assert target_details.status == TargetStatuses.PROCESSING + + vws_client.wait_for_target_processed(target_id=target_id) + + target_details = vws_client.get_target_record(target_id=target_id) + assert target_details.status == TargetStatuses.SUCCESS diff --git a/contract-tests/src/vws_mock_contract_tests/update_target/test_width.py b/contract-tests/src/vws_mock_contract_tests/update_target/test_width.py new file mode 100644 index 000000000..e10bbfb36 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/update_target/test_width.py @@ -0,0 +1,57 @@ +"""Tests for the target width field.""" + +from http import HTTPStatus + +import pytest +from vws import VWS +from vws.exceptions.vws_exceptions import ( + FailError, +) + +from vws_mock_contract_tests.update_target.helpers import ( + update_target, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_vws_failure, +) + + +@pytest.mark.parametrize( + argnames="width", + argvalues=[-1, "10", None, 0], + ids=["Negative", "Wrong Type", "None", "Zero"], +) +def test_width_invalid( + *, + vws_client: VWS, + width: int | str | None, + target_id: str, +) -> None: + """The width must be a number greater than zero.""" + target_details = vws_client.get_target_record(target_id=target_id) + original_width = target_details.target_record.width + + with pytest.raises(expected_exception=FailError) as exc: + _ = update_target( + vws_client=vws_client, + data={"width": width}, + target_id=target_id, + content_type="application/json", + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.BAD_REQUEST, + result_code="Fail", + ) + + target_details = vws_client.get_target_record(target_id=target_id) + assert target_details.target_record.width == original_width + + +def test_width_valid(*, vws_client: VWS, target_id: str) -> None: + """Positive numbers are valid widths.""" + width = 0.01 + vws_client.update_target(target_id=target_id, width=width) + target_details = vws_client.get_target_record(target_id=target_id) + assert target_details.target_record.width == width diff --git a/tests/mock_vws/add_target/conftest.py b/tests/mock_vws/add_target/conftest.py index d409c8ebf..6a23c5f27 100644 --- a/tests/mock_vws/add_target/conftest.py +++ b/tests/mock_vws/add_target/conftest.py @@ -1,12 +1,7 @@ -"""Fixtures scoped to this group of tests.""" +"""Compatibility imports for the packaged contract suite.""" -import pytest +from vws_mock_contract_tests.add_target.conftest import ( + fixture_verify_backend, +) - -@pytest.fixture(autouse=True) -def fixture_verify_backend( - *, - verify_mock_vuforia: None, -) -> None: - """Set up the backend for every contract test in this directory.""" - del verify_mock_vuforia +__all__ = ["fixture_verify_backend"] diff --git a/tests/mock_vws/add_target/test_active_flag.py b/tests/mock_vws/add_target/test_active_flag.py index e8b0e73b7..6b3d5e191 100644 --- a/tests/mock_vws/add_target/test_active_flag.py +++ b/tests/mock_vws/add_target/test_active_flag.py @@ -1,148 +1,10 @@ -"""Tests for the active flag parameter.""" +"""Compatibility imports for the packaged contract suite.""" -import base64 -import io -import json -from http import HTTPStatus - -import pytest -from vws import VWS -from vws.exceptions.vws_exceptions import ( - FailError, -) - -from tests.mock_vws.add_target.helpers import ( - add_target_to_vws, - assert_success, -) -from tests.mock_vws.utils.assertions import ( - assert_vws_failure, -) - - -@pytest.mark.parametrize( - argnames="active_flag", - argvalues=[True, False, None], +from vws_mock_contract_tests.add_target.test_active_flag import ( + test_invalid, + test_not_set, + test_set_to_none, + test_valid, ) -def test_valid( - *, - active_flag: bool | None, - image_file_failed_state: io.BytesIO, - vws_client: VWS, -) -> None: - """Boolean values and NULL are valid active flags.""" - image_data = image_file_failed_state.getvalue() - image_data_encoded = base64.b64encode(s=image_data).decode( - encoding="ascii", - ) - content_type = "application/json" - - data = { - "name": "example", - "width": 1, - "image": image_data_encoded, - "active_flag": active_flag, - } - - response = add_target_to_vws( - vws_client=vws_client, - data=data, - content_type=content_type, - ) - - assert_success(response=response) - - -def test_invalid( - *, - image_file_failed_state: io.BytesIO, - vws_client: VWS, -) -> None: - """ - Values which are not Boolean values or NULL are not valid active - flags. - """ - active_flag = "string" - image_data = image_file_failed_state.getvalue() - image_data_encoded = base64.b64encode(s=image_data).decode( - encoding="ascii" - ) - content_type = "application/json" - - data = { - "name": "example", - "width": 1, - "image": image_data_encoded, - "active_flag": active_flag, - } - - with pytest.raises(expected_exception=FailError) as exc: - _ = add_target_to_vws( - vws_client=vws_client, - data=data, - content_type=content_type, - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.BAD_REQUEST, - result_code="Fail", - ) - - -def test_not_set( - *, - vws_client: VWS, - image_file_failed_state: io.BytesIO, -) -> None: - """The active flag defaults to True if it is not set.""" - image_data = image_file_failed_state.getvalue() - image_data_encoded = base64.b64encode(s=image_data).decode( - encoding="ascii" - ) - - data = { - "name": "my_example_name", - "width": 1234, - "image": image_data_encoded, - } - - response = add_target_to_vws( - vws_client=vws_client, data=data, content_type="application/json" - ) - response_json = json.loads(s=response.text) - assert isinstance(response_json, dict) - assert isinstance(response_json["target_id"], str) - target_id: str = response_json["target_id"] - target_details = vws_client.get_target_record(target_id=target_id) - assert target_details.target_record.active_flag is True - - -def test_set_to_none( - *, - vws_client: VWS, - image_file_failed_state: io.BytesIO, -) -> None: - """The active flag defaults to True if it is set to NULL.""" - image_data = image_file_failed_state.getvalue() - image_data_encoded = base64.b64encode(s=image_data).decode( - encoding="ascii" - ) - - data = { - "name": "my_example_name", - "width": 1234, - "image": image_data_encoded, - "active_flag": None, - } - - response = add_target_to_vws( - vws_client=vws_client, data=data, content_type="application/json" - ) - response_json = json.loads(s=response.text) - assert isinstance(response_json, dict) - assert isinstance(response_json["target_id"], str) - target_id: str = response_json["target_id"] - target_details = vws_client.get_target_record(target_id=target_id) - assert target_details.target_record.active_flag is True +__all__ = ["test_invalid", "test_not_set", "test_set_to_none", "test_valid"] diff --git a/tests/mock_vws/add_target/test_application_metadata.py b/tests/mock_vws/add_target/test_application_metadata.py index ef8b0decf..ec3be7605 100644 --- a/tests/mock_vws/add_target/test_application_metadata.py +++ b/tests/mock_vws/add_target/test_application_metadata.py @@ -1,190 +1,19 @@ -"""Tests for the application metadata parameter.""" - -import base64 -import io -from http import HTTPStatus - -import pytest -from vws import VWS -from vws.exceptions.vws_exceptions import ( - FailError, - MetadataTooLargeError, -) - -from tests.mock_vws.add_target.helpers import ( - MAX_METADATA_BYTES, - add_target_to_vws, - assert_success, -) -from tests.mock_vws.utils.assertions import ( - assert_vws_failure, -) - - -@pytest.mark.parametrize( - argnames="metadata", - argvalues=[ - b"a", - b"a" * MAX_METADATA_BYTES, - ], - ids=["Short", "Max length"], +"""Compatibility imports for the packaged contract suite.""" + +from vws_mock_contract_tests.add_target.test_application_metadata import ( + test_base64_encoded, + test_invalid_type, + test_metadata_too_large, + test_not_base64_encoded_not_processable, + test_not_base64_encoded_processable, + test_null, ) -def test_base64_encoded( - *, - image_file_failed_state: io.BytesIO, - metadata: bytes, - vws_client: VWS, -) -> None: - """A base64 encoded string is valid application metadata.""" - metadata_encoded = base64.b64encode(s=metadata).decode(encoding="ascii") - - _ = vws_client.add_target( - name="example", - width=1, - image=image_file_failed_state, - application_metadata=metadata_encoded, - active_flag=True, - ) - - -def test_null( - *, - vws_client: VWS, - image_file_failed_state: io.BytesIO, -) -> None: - """NULL is valid application metadata.""" - image_data = image_file_failed_state.getvalue() - image_data_encoded = base64.b64encode(s=image_data).decode( - encoding="ascii" - ) - - request_data = { - "name": "example_name", - "width": 1, - "image": image_data_encoded, - "application_metadata": None, - } - - response = add_target_to_vws( - vws_client=vws_client, - data=request_data, - content_type="application/json", - ) - - assert_success(response=response) - - -def test_invalid_type( - *, - vws_client: VWS, - image_file_failed_state: io.BytesIO, -) -> None: - """ - Values which are not a string or NULL are not valid application - metadata. - """ - image_data = image_file_failed_state.getvalue() - image_data_encoded = base64.b64encode(s=image_data).decode( - encoding="ascii" - ) - - data = { - "name": "example_name", - "width": 1, - "image": image_data_encoded, - "application_metadata": 1, - } - - with pytest.raises(expected_exception=FailError) as exc: - _ = add_target_to_vws( - vws_client=vws_client, - data=data, - content_type="application/json", - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.BAD_REQUEST, - result_code="Fail", - ) - - -def test_not_base64_encoded_processable( - *, - high_quality_image: io.BytesIO, - not_base64_encoded_processable: str, - vws_client: VWS, -) -> None: - """ - Some strings which are not valid base64 encoded strings are - allowed as - application metadata. - """ - _ = vws_client.add_target( - name="example", - width=1, - image=high_quality_image, - application_metadata=not_base64_encoded_processable, - active_flag=True, - ) - - -def test_not_base64_encoded_not_processable( - *, - high_quality_image: io.BytesIO, - not_base64_encoded_not_processable: str, - vws_client: VWS, -) -> None: - """ - Some strings which are not valid base64 encoded strings are not - allowed - as application metadata. - """ - with pytest.raises(expected_exception=FailError) as exc: - _ = vws_client.add_target( - name="example", - width=1, - image=high_quality_image, - application_metadata=not_base64_encoded_not_processable, - active_flag=True, - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - result_code="Fail", - ) - - -def test_metadata_too_large( - *, - image_file_failed_state: io.BytesIO, - vws_client: VWS, - application_metadata_near_size_limit: str, -) -> None: - """ - A base64 encoded string of greater than 1024 * 1024 bytes is too - large - for application metadata. - """ - decoded_metadata = base64.b64decode( - s=application_metadata_near_size_limit.encode(encoding="ascii"), - ) - metadata_encoded = base64.b64encode( - s=decoded_metadata + b"x", - ).decode(encoding="ascii") - - with pytest.raises(expected_exception=MetadataTooLargeError) as exc: - _ = vws_client.add_target( - name="example", - width=1, - image=image_file_failed_state, - application_metadata=metadata_encoded, - active_flag=True, - ) - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - result_code="MetadataTooLarge", - ) +__all__ = [ + "test_base64_encoded", + "test_invalid_type", + "test_metadata_too_large", + "test_not_base64_encoded_not_processable", + "test_not_base64_encoded_processable", + "test_null", +] diff --git a/tests/mock_vws/add_target/test_content_types.py b/tests/mock_vws/add_target/test_content_types.py index f49d042f3..0c6c39660 100644 --- a/tests/mock_vws/add_target/test_content_types.py +++ b/tests/mock_vws/add_target/test_content_types.py @@ -1,96 +1,8 @@ -"""Tests for the `Content-Type` header.""" +"""Compatibility imports for the packaged contract suite.""" -import base64 -import io -from http import HTTPStatus - -import pytest -from vws import VWS -from vws.exceptions.vws_exceptions import ( - AuthenticationFailureError, -) - -from tests.mock_vws.add_target.helpers import ( - add_target_to_vws, - assert_success, -) -from tests.mock_vws.utils.assertions import ( - assert_vws_failure, -) - - -@pytest.mark.parametrize( - argnames="content_type", - argvalues=[ - # This is the documented required content type: - "application/json", - # Other content types also work. - "other/content_type", - ], - ids=[ - "Documented Content-Type", - "Undocumented Content-Type", - ], +from vws_mock_contract_tests.add_target.test_content_types import ( + test_content_types, + test_empty_content_type, ) -def test_content_types( - *, - vws_client: VWS, - image_file_failed_state: io.BytesIO, - content_type: str, -) -> None: - """Any non-empty ``Content-Type`` header is allowed.""" - image_data = image_file_failed_state.getvalue() - image_data_encoded = base64.b64encode(s=image_data).decode( - encoding="ascii" - ) - - data = { - "name": "example", - "width": 1, - "image": image_data_encoded, - } - - response = add_target_to_vws( - vws_client=vws_client, - data=data, - content_type=content_type, - ) - - assert_success(response=response) - - -def test_empty_content_type( - *, - vws_client: VWS, - image_file_failed_state: io.BytesIO, -) -> None: - """ - An ``UNAUTHORIZED`` response is given if an empty ``Content- - Type`` - header is given. - """ - image_data = image_file_failed_state.getvalue() - image_data_encoded = base64.b64encode(s=image_data).decode( - encoding="ascii" - ) - - data = { - "name": "example", - "width": 1, - "image": image_data_encoded, - } - - with pytest.raises( - expected_exception=AuthenticationFailureError, - ) as exc: - _ = add_target_to_vws( - vws_client=vws_client, - data=data, - content_type="", - ) - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.UNAUTHORIZED, - result_code="AuthenticationFailure", - ) +__all__ = ["test_content_types", "test_empty_content_type"] diff --git a/tests/mock_vws/add_target/test_image.py b/tests/mock_vws/add_target/test_image.py index 0da39eee4..78a96ea0e 100644 --- a/tests/mock_vws/add_target/test_image.py +++ b/tests/mock_vws/add_target/test_image.py @@ -1,348 +1,29 @@ -"""Tests for the image parameter. - -The specification for images is documented at -https://library.vuforia.com/features/images/image-targets.html. -""" - -import io -from http import HTTPStatus - -import pytest -from vws import VWS -from vws.exceptions.vws_exceptions import ( - BadImageError, - FailError, - ImageTooLargeError, -) -from vws_test_fixtures.images import VWS_MAX_IMAGE_FILE_SIZE - -from tests.mock_vws.add_target.helpers import ( - add_target_to_vws, -) -from tests.mock_vws.utils import ( - make_decompression_bomb_image_file, - make_single_color_image_file, - make_truncated_png_file, -) -from tests.mock_vws.utils.assertions import ( - assert_vws_failure, +"""Compatibility imports for the packaged contract suite.""" + +from vws_mock_contract_tests.add_target.test_image import ( + test_bad_image_format_or_color_space, + test_corrupted, + test_decompression_bomb, + test_image_file_size_too_large, + test_image_pixel_count_too_large, + test_image_valid, + test_invalid_type, + test_not_base64_encoded_not_processable, + test_not_base64_encoded_processable, + test_not_image, + test_truncated, ) - -def test_image_valid( - *, - vws_client: VWS, - image_files_failed_state: io.BytesIO, -) -> None: - """ - JPEG and PNG files in the RGB and greyscale color spaces are - allowed. - """ - _ = vws_client.add_target( - name="example_name", - width=1, - image=image_files_failed_state, - application_metadata=None, - active_flag=True, - ) - - -def test_bad_image_format_or_color_space( - *, - bad_image_file: io.BytesIO, - vws_client: VWS, -) -> None: - """ - An `UNPROCESSABLE_ENTITY` response is returned if an image which - is not - a JPEG or PNG file is given, or if the given image is not in the - greyscale or RGB color space. - """ - with pytest.raises(expected_exception=BadImageError) as exc: - _ = vws_client.add_target( - name="example_name", - width=1, - image=bad_image_file, - application_metadata=None, - active_flag=True, - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - result_code="BadImage", - ) - - -def test_corrupted( - *, - corrupted_image_file: io.BytesIO, - vws_client: VWS, -) -> None: - """An error is returned when the given image is corrupted.""" - with pytest.raises(expected_exception=BadImageError) as exc: - _ = vws_client.add_target( - name="example_name", - width=1, - image=corrupted_image_file, - application_metadata=None, - active_flag=True, - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - result_code="BadImage", - ) - - -def test_truncated(vws_client: VWS) -> None: - """ - An error is returned when the given image is truncated before - the - end of its image data. - """ - image_file = make_truncated_png_file() - - with pytest.raises(expected_exception=BadImageError) as exc: - _ = vws_client.add_target( - name="example_name", - width=1, - image=image_file, - application_metadata=None, - active_flag=True, - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - result_code="BadImage", - ) - - -def test_decompression_bomb(vws_client: VWS) -> None: - """ - An ``ImageTooLargeError`` result is returned when the given - image has a small file size but a huge number of pixels. - """ - max_bytes = 2.3 * 1024 * 1024 - image_file = make_decompression_bomb_image_file() - assert len(image_file.getvalue()) < max_bytes - - with pytest.raises(expected_exception=ImageTooLargeError) as exc: - _ = vws_client.add_target( - name="example_name", - width=1, - image=image_file, - application_metadata=None, - active_flag=True, - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - result_code="ImageTooLarge", - ) - - -def test_image_pixel_count_too_large( - *, - vws_client: VWS, - pixel_count_too_large: io.BytesIO, -) -> None: - """ - An ``ImageTooLargeError`` result is returned if the image has - more than 37748736 pixels, whatever its file size. - - This limit is not documented. - """ - max_allowed_pixels = 37_748_736 - width = height = 6144 - assert width * height == max_allowed_pixels - - image_not_too_many_pixels = make_single_color_image_file( - width=width, - height=height, - ) - - _ = vws_client.add_target( - name="example_name", - width=1, - image=image_not_too_many_pixels, - application_metadata=None, - active_flag=True, - ) - - with pytest.raises(expected_exception=ImageTooLargeError) as exc: - _ = vws_client.add_target( - name="example_name_2", - width=1, - image=pixel_count_too_large, - application_metadata=None, - active_flag=True, - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - result_code="ImageTooLarge", - ) - - -def test_image_file_size_too_large( - *, - vws_client: VWS, - png_just_under_max_size: io.BytesIO, - png_too_large: io.BytesIO, -) -> None: - """ - An ``ImageTooLargeError`` result is returned if the image file - size is - above a certain threshold. - """ - max_bytes = VWS_MAX_IMAGE_FILE_SIZE - - image_content_size = len(png_just_under_max_size.getvalue()) - assert image_content_size < max_bytes - assert (image_content_size * 1.05) > max_bytes - - _ = vws_client.add_target( - name="example_name", - width=1, - image=png_just_under_max_size, - application_metadata=None, - active_flag=True, - ) - - image_content_size = len(png_too_large.getvalue()) - assert image_content_size > max_bytes - - with pytest.raises(expected_exception=ImageTooLargeError) as exc: - _ = vws_client.add_target( - name="example_name_2", - width=1, - image=png_too_large, - application_metadata=None, - active_flag=True, - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - result_code="ImageTooLarge", - ) - - -def test_not_base64_encoded_processable( - *, - vws_client: VWS, - not_base64_encoded_processable: str, -) -> None: - """Some strings which are not valid base64 encoded strings are - allowed - as an image without getting a "Fail" response. - - This is because Vuforia treats them as valid base64, but then - not a valid image. - """ - data = { - "name": "example_name", - "width": 1, - "image": not_base64_encoded_processable, - } - - with pytest.raises(expected_exception=BadImageError) as exc: - _ = add_target_to_vws( - vws_client=vws_client, - data=data, - content_type="application/json", - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - result_code="BadImage", - ) - - -def test_not_base64_encoded_not_processable( - *, - vws_client: VWS, - not_base64_encoded_not_processable: str, -) -> None: - """ - Some strings which are not valid base64 encoded strings are not - processable by Vuforia, and then when given as an image Vuforia - returns - a "Fail" response. - """ - data = { - "name": "example_name", - "width": 1, - "image": not_base64_encoded_not_processable, - } - - with pytest.raises(expected_exception=FailError) as exc: - _ = add_target_to_vws( - vws_client=vws_client, - data=data, - content_type="application/json", - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - result_code="Fail", - ) - - -def test_not_image(vws_client: VWS) -> None: - """ - If the given image is not an image file then a `BadImageError` - result - is returned. - """ - with pytest.raises(expected_exception=BadImageError) as exc: - _ = vws_client.add_target( - name="example_name", - width=1, - image=io.BytesIO(initial_bytes=b"not_image_data"), - application_metadata=None, - active_flag=True, - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - result_code="BadImage", - ) - - -@pytest.mark.parametrize( - argnames="invalid_type_image", - argvalues=[1, None], -) -def test_invalid_type( - *, - invalid_type_image: int | None, - vws_client: VWS, -) -> None: - """If the given image is not a string, a `Fail` result is returned.""" - data = { - "name": "example_name", - "width": 1, - "image": invalid_type_image, - } - - with pytest.raises(expected_exception=FailError) as exc: - _ = add_target_to_vws( - vws_client=vws_client, - data=data, - content_type="application/json", - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.BAD_REQUEST, - result_code="Fail", - ) +__all__ = [ + "test_bad_image_format_or_color_space", + "test_corrupted", + "test_decompression_bomb", + "test_image_file_size_too_large", + "test_image_pixel_count_too_large", + "test_image_valid", + "test_invalid_type", + "test_not_base64_encoded_not_processable", + "test_not_base64_encoded_processable", + "test_not_image", + "test_truncated", +] diff --git a/tests/mock_vws/add_target/test_inactive_project.py b/tests/mock_vws/add_target/test_inactive_project.py index 748c6a5c4..808e3df13 100644 --- a/tests/mock_vws/add_target/test_inactive_project.py +++ b/tests/mock_vws/add_target/test_inactive_project.py @@ -1,39 +1,7 @@ -"""Tests for inactive projects.""" +"""Compatibility imports for the packaged contract suite.""" -import io -from http import HTTPStatus - -import pytest -from vws import VWS -from vws.exceptions.vws_exceptions import ( - ProjectInactiveError, -) - -from tests.mock_vws.utils.assertions import ( - assert_vws_failure, +from vws_mock_contract_tests.add_target.test_inactive_project import ( + test_inactive_project, ) - -def test_inactive_project( - *, - image_file_failed_state: io.BytesIO, - inactive_vws_client: VWS, -) -> None: - """ - If the project is inactive, a FORBIDDEN response is - returned. - """ - with pytest.raises(expected_exception=ProjectInactiveError) as exc: - _ = inactive_vws_client.add_target( - name="example", - width=1, - image=image_file_failed_state, - application_metadata=None, - active_flag=True, - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.FORBIDDEN, - result_code="ProjectInactive", - ) +__all__ = ["test_inactive_project"] diff --git a/tests/mock_vws/add_target/test_missing_data.py b/tests/mock_vws/add_target/test_missing_data.py index 4f155cccf..c832744fe 100644 --- a/tests/mock_vws/add_target/test_missing_data.py +++ b/tests/mock_vws/add_target/test_missing_data.py @@ -1,55 +1,7 @@ -"""Tests for giving incomplete data.""" +"""Compatibility imports for the packaged contract suite.""" -import base64 -import io -from http import HTTPStatus - -import pytest -from vws import VWS -from vws.exceptions.vws_exceptions import ( - FailError, -) - -from tests.mock_vws.add_target.helpers import ( - add_target_to_vws, +from vws_mock_contract_tests.add_target.test_missing_data import ( + test_missing_data, ) -from tests.mock_vws.utils.assertions import ( - assert_vws_failure, -) - - -@pytest.mark.parametrize( - argnames="data_to_remove", - argvalues=["name", "width", "image"], -) -def test_missing_data( - *, - vws_client: VWS, - image_file_failed_state: io.BytesIO, - data_to_remove: str, -) -> None: - """`name`, `width` and `image` are all required.""" - image_data = image_file_failed_state.getvalue() - image_data_encoded = base64.b64encode(s=image_data).decode( - encoding="ascii", - ) - - data = { - "name": "example_name", - "width": 1, - "image": image_data_encoded, - } - _ = data.pop(data_to_remove) - - with pytest.raises(expected_exception=FailError) as exc: - _ = add_target_to_vws( - vws_client=vws_client, - data=data, - content_type="application/json", - ) - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.BAD_REQUEST, - result_code="Fail", - ) +__all__ = ["test_missing_data"] diff --git a/tests/mock_vws/add_target/test_target_name.py b/tests/mock_vws/add_target/test_target_name.py index f0159c002..e0d82bb64 100644 --- a/tests/mock_vws/add_target/test_target_name.py +++ b/tests/mock_vws/add_target/test_target_name.py @@ -1,178 +1,15 @@ -"""Tests for the target name field.""" +"""Compatibility imports for the packaged contract suite.""" -import base64 -import io -from http import HTTPStatus - -import pytest -from vws import VWS -from vws.exceptions.custom_exceptions import ( - ServerError, -) -from vws.exceptions.vws_exceptions import ( - FailError, - TargetNameExistError, -) - -from tests.mock_vws.add_target.helpers import ( - add_target_to_vws, -) -from tests.mock_vws.utils.assertions import ( - assert_vws_failure, +from vws_mock_contract_tests.add_target.test_target_name import ( + test_deleted_existing_target_name, + test_existing_target_name, + test_name_invalid, + test_name_valid, ) -_MAX_CHAR_VALUE = 65535 -_MAX_NAME_LENGTH = 64 - - -@pytest.mark.parametrize( - argnames="name", - argvalues=[ - "á", - # We test just below the max character value. - # This is because targets with the max character value in their - # names get stuck in the processing stage. - chr(_MAX_CHAR_VALUE - 2), - "a" * _MAX_NAME_LENGTH, - ], - ids=["Short name", "Max char value", "Long name"], -) -def test_name_valid( - *, - name: str, - image_file_failed_state: io.BytesIO, - vws_client: VWS, -) -> None: - """Names between 1 and 64 characters in length are valid.""" - _ = vws_client.add_target( - name=name, - width=1, - image=image_file_failed_state, - application_metadata=None, - active_flag=True, - ) - - -@pytest.mark.parametrize( - argnames=("name", "status_code"), - argvalues=[ - (1, HTTPStatus.BAD_REQUEST), - ("", HTTPStatus.BAD_REQUEST), - ("a" * (_MAX_NAME_LENGTH + 1), HTTPStatus.BAD_REQUEST), - (None, HTTPStatus.BAD_REQUEST), - (chr(_MAX_CHAR_VALUE + 1), HTTPStatus.INTERNAL_SERVER_ERROR), - ( - chr(_MAX_CHAR_VALUE + 1) * (_MAX_NAME_LENGTH + 1), - HTTPStatus.BAD_REQUEST, - ), - ], - ids=[ - "Wrong Type", - "Empty", - "Too Long", - "None", - "Bad char", - "Bad char too long", - ], -) -def test_name_invalid( - *, - name: str | int | None, - image_file_failed_state: io.BytesIO, - status_code: int, - vws_client: VWS, -) -> None: - """ - A target's name must be a string of length 0 < N < 65, with - characters - in a particular range. - """ - image_data = image_file_failed_state.getvalue() - image_data_encoded = base64.b64encode(s=image_data).decode( - encoding="ascii", - ) - data = { - "name": name, - "width": 1, - "image": image_data_encoded, - "application_metadata": None, - "active_flag": True, - } - - exc: pytest.ExceptionInfo[FailError | ServerError] - - if status_code == HTTPStatus.INTERNAL_SERVER_ERROR: - with pytest.raises(expected_exception=ServerError) as exc: - _ = add_target_to_vws( - vws_client=vws_client, - data=data, - content_type="application/json", - ) - else: - with pytest.raises(expected_exception=FailError) as exc: - _ = add_target_to_vws( - vws_client=vws_client, - data=data, - content_type="application/json", - ) - - assert_vws_failure( - response=exc.value.response, - status_code=status_code, - result_code="Fail", - ) - - -def test_existing_target_name( - *, - image_file_failed_state: io.BytesIO, - vws_client: VWS, -) -> None: - """Only one target can have a given name.""" - _ = vws_client.add_target( - name="example_name", - width=1, - image=image_file_failed_state, - application_metadata=None, - active_flag=True, - ) - - with pytest.raises(expected_exception=TargetNameExistError) as exc: - _ = vws_client.add_target( - name="example_name", - width=1, - image=image_file_failed_state, - application_metadata=None, - active_flag=True, - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.FORBIDDEN, - result_code="TargetNameExist", - ) - - -def test_deleted_existing_target_name( - *, - image_file_failed_state: io.BytesIO, - vws_client: VWS, -) -> None: - """A target can be added with the name of a deleted target.""" - target_id = vws_client.add_target( - name="example_name", - width=1, - image=image_file_failed_state, - application_metadata=None, - active_flag=True, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.delete_target(target_id=target_id) - _ = vws_client.add_target( - name="example_name", - width=1, - image=image_file_failed_state, - application_metadata=None, - active_flag=True, - ) +__all__ = [ + "test_deleted_existing_target_name", + "test_existing_target_name", + "test_name_invalid", + "test_name_valid", +] diff --git a/tests/mock_vws/add_target/test_unexpected_data.py b/tests/mock_vws/add_target/test_unexpected_data.py index 61589e23d..a1a139131 100644 --- a/tests/mock_vws/add_target/test_unexpected_data.py +++ b/tests/mock_vws/add_target/test_unexpected_data.py @@ -1,56 +1,7 @@ -""" -Tests for passing data which is not mandatory or allowed to the -endpoint. -""" +"""Compatibility imports for the packaged contract suite.""" -import base64 -import io -from http import HTTPStatus - -import pytest -from vws import VWS -from vws.exceptions.vws_exceptions import ( - FailError, -) - -from tests.mock_vws.add_target.helpers import ( - add_target_to_vws, -) -from tests.mock_vws.utils.assertions import ( - assert_vws_failure, +from vws_mock_contract_tests.add_target.test_unexpected_data import ( + test_invalid_extra_data, ) - -def test_invalid_extra_data( - *, - vws_client: VWS, - image_file_failed_state: io.BytesIO, -) -> None: - """ - A `BAD_REQUEST` response is returned when unexpected data is - given. - """ - image_data = image_file_failed_state.getvalue() - image_data_encoded = base64.b64encode(s=image_data).decode( - encoding="ascii" - ) - - data = { - "name": "example_name", - "width": 1, - "image": image_data_encoded, - "extra_thing": 1, - } - - with pytest.raises(expected_exception=FailError) as exc: - _ = add_target_to_vws( - vws_client=vws_client, - data=data, - content_type="application/json", - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.BAD_REQUEST, - result_code="Fail", - ) +__all__ = ["test_invalid_extra_data"] diff --git a/tests/mock_vws/add_target/test_width.py b/tests/mock_vws/add_target/test_width.py index 5d135ee1f..89961e2d0 100644 --- a/tests/mock_vws/add_target/test_width.py +++ b/tests/mock_vws/add_target/test_width.py @@ -1,70 +1,8 @@ -"""Tests for the target width field.""" +"""Compatibility imports for the packaged contract suite.""" -import base64 -import io -from http import HTTPStatus - -import pytest -from vws import VWS -from vws.exceptions.vws_exceptions import ( - FailError, -) - -from tests.mock_vws.add_target.helpers import ( - add_target_to_vws, +from vws_mock_contract_tests.add_target.test_width import ( + test_width_invalid, + test_width_valid, ) -from tests.mock_vws.utils.assertions import ( - assert_vws_failure, -) - - -@pytest.mark.parametrize( - argnames="width", - argvalues=[-1, "10", None, 0], - ids=["Negative", "Wrong Type", "None", "Zero"], -) -def test_width_invalid( - *, - vws_client: VWS, - image_file_failed_state: io.BytesIO, - width: int | str | None, -) -> None: - """The width must be a number greater than zero.""" - image_data = image_file_failed_state.getvalue() - image_data_encoded = base64.b64encode(s=image_data).decode( - encoding="ascii" - ) - - data = { - "name": "example_name", - "width": width, - "image": image_data_encoded, - } - - with pytest.raises(expected_exception=FailError) as exc: - _ = add_target_to_vws( - vws_client=vws_client, - data=data, - content_type="application/json", - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.BAD_REQUEST, - result_code="Fail", - ) - -def test_width_valid( - *, - vws_client: VWS, - image_file_failed_state: io.BytesIO, -) -> None: - """Positive numbers are valid widths.""" - _ = vws_client.add_target( - name="example", - width=0.01, - image=image_file_failed_state, - application_metadata=None, - active_flag=True, - ) +__all__ = ["test_width_invalid", "test_width_valid"] diff --git a/tests/mock_vws/delete_target/conftest.py b/tests/mock_vws/delete_target/conftest.py index d409c8ebf..ed7d82de6 100644 --- a/tests/mock_vws/delete_target/conftest.py +++ b/tests/mock_vws/delete_target/conftest.py @@ -1,12 +1,7 @@ -"""Fixtures scoped to this group of tests.""" +"""Compatibility imports for the packaged contract suite.""" -import pytest +from vws_mock_contract_tests.delete_target.conftest import ( + fixture_verify_backend, +) - -@pytest.fixture(autouse=True) -def fixture_verify_backend( - *, - verify_mock_vuforia: None, -) -> None: - """Set up the backend for every contract test in this directory.""" - del verify_mock_vuforia +__all__ = ["fixture_verify_backend"] diff --git a/tests/mock_vws/delete_target/test_delete.py b/tests/mock_vws/delete_target/test_delete.py index 11267b141..58ebbc3fb 100644 --- a/tests/mock_vws/delete_target/test_delete.py +++ b/tests/mock_vws/delete_target/test_delete.py @@ -1,44 +1,8 @@ -"""Tests for deleting targets.""" +"""Compatibility imports for the packaged contract suite.""" -from http import HTTPStatus - -import pytest -from vws import VWS -from vws.exceptions.vws_exceptions import ( - TargetStatusProcessingError, - UnknownTargetError, +from vws_mock_contract_tests.delete_target.test_delete import ( + test_no_wait, + test_processed, ) -from tests.mock_vws.utils.assertions import assert_vws_failure - - -def test_no_wait( - *, - unprocessed_target_id: str, - vws_client: VWS, -) -> None: - """When attempting to delete a target immediately after creating - it, a - `FORBIDDEN` response is returned. - - This is because the target goes into a processing state. - - There is a race condition here - if the target goes into a success or - fail state before the deletion attempt. - """ - with pytest.raises(expected_exception=TargetStatusProcessingError) as exc: - vws_client.delete_target(target_id=unprocessed_target_id) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.FORBIDDEN, - result_code="TargetStatusProcessing", - ) - - -def test_processed(*, target_id: str, vws_client: VWS) -> None: - """When a target has finished processing, it can be deleted.""" - vws_client.delete_target(target_id=target_id) - - with pytest.raises(expected_exception=UnknownTargetError): - _ = vws_client.get_target_record(target_id=target_id) +__all__ = ["test_no_wait", "test_processed"] diff --git a/tests/mock_vws/delete_target/test_inactive_project.py b/tests/mock_vws/delete_target/test_inactive_project.py index 7bf605137..9b0385835 100644 --- a/tests/mock_vws/delete_target/test_inactive_project.py +++ b/tests/mock_vws/delete_target/test_inactive_project.py @@ -1,27 +1,7 @@ -"""Tests for inactive projects.""" +"""Compatibility imports for the packaged contract suite.""" -from http import HTTPStatus - -import pytest -from vws import VWS -from vws.exceptions.vws_exceptions import ( - ProjectInactiveError, +from vws_mock_contract_tests.delete_target.test_inactive_project import ( + test_inactive_project, ) -from tests.mock_vws.utils.assertions import assert_vws_failure - - -def test_inactive_project(inactive_vws_client: VWS) -> None: - """ - If the project is inactive, a FORBIDDEN response is - returned. - """ - target_id = "abc12345a" - with pytest.raises(expected_exception=ProjectInactiveError) as exc: - inactive_vws_client.delete_target(target_id=target_id) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.FORBIDDEN, - result_code="ProjectInactive", - ) +__all__ = ["test_inactive_project"] diff --git a/tests/mock_vws/get_duplicates/conftest.py b/tests/mock_vws/get_duplicates/conftest.py index d409c8ebf..9f9cd5c38 100644 --- a/tests/mock_vws/get_duplicates/conftest.py +++ b/tests/mock_vws/get_duplicates/conftest.py @@ -1,12 +1,7 @@ -"""Fixtures scoped to this group of tests.""" +"""Compatibility imports for the packaged contract suite.""" -import pytest +from vws_mock_contract_tests.get_duplicates.conftest import ( + fixture_verify_backend, +) - -@pytest.fixture(autouse=True) -def fixture_verify_backend( - *, - verify_mock_vuforia: None, -) -> None: - """Set up the backend for every contract test in this directory.""" - del verify_mock_vuforia +__all__ = ["fixture_verify_backend"] diff --git a/tests/mock_vws/get_duplicates/test_active_flag.py b/tests/mock_vws/get_duplicates/test_active_flag.py index 59fa34851..432e36499 100644 --- a/tests/mock_vws/get_duplicates/test_active_flag.py +++ b/tests/mock_vws/get_duplicates/test_active_flag.py @@ -1,55 +1,7 @@ -"""Tests for the effects of the active flag on duplicate matching.""" +"""Compatibility imports for the packaged contract suite.""" -import io -import uuid +from vws_mock_contract_tests.get_duplicates.test_active_flag import ( + test_active_flag, +) -from vws import VWS - - -def test_active_flag( - *, - high_quality_image: io.BytesIO, - vws_client: VWS, -) -> None: - """Targets with `active_flag` set to `False` can have duplicates. - Targets with `active_flag` set to `False` are not found as - duplicates. - - https://developer.vuforia.com/library/web-api/cloud-targets-web-services-api#check - says: - - ''' - If a target is explicitly inactivated through the VWS API (or through - the Target Manager), then this target is no longer taken into account - for the duplicate target check. - ''' - """ - original_target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=high_quality_image, - active_flag=False, - application_metadata=None, - ) - similar_target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=original_target_id) - vws_client.wait_for_target_processed(target_id=similar_target_id) - - duplicates = vws_client.get_duplicate_targets( - target_id=original_target_id, - ) - - assert duplicates == [similar_target_id] - - duplicates = vws_client.get_duplicate_targets( - target_id=similar_target_id, - ) - - assert duplicates == [] +__all__ = ["test_active_flag"] diff --git a/tests/mock_vws/get_duplicates/test_duplicates.py b/tests/mock_vws/get_duplicates/test_duplicates.py index c0dbfd18f..2662d8aa2 100644 --- a/tests/mock_vws/get_duplicates/test_duplicates.py +++ b/tests/mock_vws/get_duplicates/test_duplicates.py @@ -1,171 +1,15 @@ -"""Tests for the mock of the target duplicates endpoint.""" - -import copy -import io -import uuid - -import pytest -from PIL import Image -from vws import VWS -from vws.reports import TargetStatuses - -from tests.mock_vws.utils.backend_capabilities import BackendCapabilities - - -def test_duplicates( - *, - high_quality_image: io.BytesIO, - image_file_success_state_low_rating: io.BytesIO, - vws_client: VWS, -) -> None: - """Target IDs of the exact same targets are returned.""" - image_data = high_quality_image - different_image_data = image_file_success_state_low_rating - - original_target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image_data, - active_flag=True, - application_metadata=None, - ) - - similar_target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image_data, - active_flag=True, - application_metadata=None, - ) - - different_target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=different_image_data, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=original_target_id) - vws_client.wait_for_target_processed(target_id=similar_target_id) - vws_client.wait_for_target_processed(target_id=different_target_id) - - duplicates = vws_client.get_duplicate_targets( - target_id=original_target_id, - ) - - assert duplicates == [similar_target_id] - - -def test_duplicates_not_same( - *, - high_quality_image: io.BytesIO, - vws_client: VWS, -) -> None: - """Target IDs of similar targets are returned.""" - image_data = high_quality_image - similar_image_data = copy.copy(x=image_data) - similar_image_buffer = io.BytesIO() - pil_similar_image = Image.open(fp=similar_image_data) - # Re-save means similar but not identical. - pil_similar_image.save(fp=similar_image_buffer, format="JPEG") - assert similar_image_buffer.getvalue() != image_data.getvalue() - - original_target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image_data, - active_flag=True, - application_metadata=None, - ) - - similar_target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=similar_image_buffer, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=original_target_id) - vws_client.wait_for_target_processed(target_id=similar_target_id) - - duplicates = vws_client.get_duplicate_targets( - target_id=original_target_id, - ) - - assert duplicates == [similar_target_id] - - -def test_status( - *, - image_file_failed_state: io.BytesIO, - vws_client: VWS, -) -> None: - """Targets are not duplicates if the status is not 'success'.""" - original_target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image_file_failed_state, - active_flag=True, - application_metadata=None, - ) - - similar_target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image_file_failed_state, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=original_target_id) - vws_client.wait_for_target_processed(target_id=similar_target_id) - - target_details = vws_client.get_target_record( - target_id=original_target_id, - ) - assert target_details.status == TargetStatuses.FAILED - - duplicates = vws_client.get_duplicate_targets( - target_id=original_target_id, - ) - - assert duplicates == [] - - -def test_order_is_upload_date_then_target_id( - *, - backend_capabilities: BackendCapabilities, - high_quality_image: io.BytesIO, - vws_client: VWS, -) -> None: - """The mock returns duplicates ordered by upload date. - - The real Vuforia Web Services do not document an order, so we do - not verify this against them. - """ - if not backend_capabilities.deterministic_target_order: - pytest.skip( - reason="This backend does not guarantee target upload-date order." - ) - - target_ids = [ - vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - for _ in range(3) - ] - - for target_id in target_ids: - vws_client.wait_for_target_processed(target_id=target_id) - - duplicates = vws_client.get_duplicate_targets( - target_id=target_ids[0], - ) - - assert duplicates == target_ids[1:] +"""Compatibility imports for the packaged contract suite.""" + +from vws_mock_contract_tests.get_duplicates.test_duplicates import ( + test_duplicates, + test_duplicates_not_same, + test_order_is_upload_date_then_target_id, + test_status, +) + +__all__ = [ + "test_duplicates", + "test_duplicates_not_same", + "test_order_is_upload_date_then_target_id", + "test_status", +] diff --git a/tests/mock_vws/get_duplicates/test_inactive_project.py b/tests/mock_vws/get_duplicates/test_inactive_project.py index f74f997e9..cf588c43f 100644 --- a/tests/mock_vws/get_duplicates/test_inactive_project.py +++ b/tests/mock_vws/get_duplicates/test_inactive_project.py @@ -1,18 +1,7 @@ -"""Tests for inactive projects.""" +"""Compatibility imports for the packaged contract suite.""" -import uuid +from vws_mock_contract_tests.get_duplicates.test_inactive_project import ( + test_inactive_project, +) -import pytest -from vws import VWS -from vws.exceptions.vws_exceptions import ProjectInactiveError - - -def test_inactive_project(inactive_vws_client: VWS) -> None: - """ - If the project is inactive, a FORBIDDEN response is - returned. - """ - with pytest.raises(expected_exception=ProjectInactiveError): - _ = inactive_vws_client.get_duplicate_targets( - target_id=uuid.uuid4().hex, - ) +__all__ = ["test_inactive_project"] diff --git a/tests/mock_vws/get_duplicates/test_processing.py b/tests/mock_vws/get_duplicates/test_processing.py index 40b519ad8..d9664f378 100644 --- a/tests/mock_vws/get_duplicates/test_processing.py +++ b/tests/mock_vws/get_duplicates/test_processing.py @@ -1,54 +1,7 @@ -"""Tests for targets in the processing stage.""" +"""Compatibility imports for the packaged contract suite.""" -import io -import uuid +from vws_mock_contract_tests.get_duplicates.test_processing import ( + test_processing, +) -from vws import VWS -from vws.reports import TargetStatuses - - -def test_processing( - *, - high_quality_image: io.BytesIO, - vws_client: VWS, -) -> None: - """If a target is in the processing state, it can have duplicates. - - Targets can have duplicates in the processing state. - """ - processed_target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=processed_target_id) - - processing_target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - - duplicates = vws_client.get_duplicate_targets( - target_id=processed_target_id, - ) - - assert duplicates == [] - - duplicates = vws_client.get_duplicate_targets( - target_id=processing_target_id, - ) - - target_details = vws_client.get_target_record( - target_id=processing_target_id, - ) - - # There is a race condition here. - # If getting the target details and getting the duplicates takes longer - # than the processing time, the target will be in the success state. - assert target_details.status == TargetStatuses.PROCESSING - assert duplicates == [processed_target_id] +__all__ = ["test_processing"] diff --git a/tests/mock_vws/get_target/conftest.py b/tests/mock_vws/get_target/conftest.py index d409c8ebf..069a15c08 100644 --- a/tests/mock_vws/get_target/conftest.py +++ b/tests/mock_vws/get_target/conftest.py @@ -1,12 +1,7 @@ -"""Fixtures scoped to this group of tests.""" +"""Compatibility imports for the packaged contract suite.""" -import pytest +from vws_mock_contract_tests.get_target.conftest import ( + fixture_verify_backend, +) - -@pytest.fixture(autouse=True) -def fixture_verify_backend( - *, - verify_mock_vuforia: None, -) -> None: - """Set up the backend for every contract test in this directory.""" - del verify_mock_vuforia +__all__ = ["fixture_verify_backend"] diff --git a/tests/mock_vws/get_target/test_get_record.py b/tests/mock_vws/get_target/test_get_record.py index 9f36a7ad5..7ce39c30d 100644 --- a/tests/mock_vws/get_target/test_get_record.py +++ b/tests/mock_vws/get_target/test_get_record.py @@ -1,103 +1,9 @@ -"""Tests for getting a target record.""" +"""Compatibility imports for the packaged contract suite.""" -import io +from vws_mock_contract_tests.get_target.test_get_record import ( + test_fail_status, + test_get_vws_target, + test_success_status, +) -from vws import VWS -from vws.reports import TargetRecord, TargetStatuses - - -def test_get_vws_target( - *, - vws_client: VWS, - image_file_failed_state: io.BytesIO, -) -> None: - """Details of a target are returned.""" - name = "my_example_name" - width = 1234 - - target_id = vws_client.add_target( - name=name, - width=width, - image=image_file_failed_state, - active_flag=False, - application_metadata=None, - ) - target_details = vws_client.get_target_record(target_id=target_id) - target_record = target_details.target_record - tracking_rating = target_record.tracking_rating - - # Tracking rating may be -1 while processing. - assert tracking_rating in range(-1, 6) - target_id = target_record.target_id - - expected_target_record = TargetRecord( - target_id=target_id, - active_flag=False, - name=name, - width=width, - tracking_rating=tracking_rating, - reco_rating="", - ) - - assert target_record == expected_target_record - - -def test_fail_status( - *, - vws_client: VWS, - image_file_failed_state: io.BytesIO, -) -> None: - """ - When a 1x1 image is given, the status changes from 'processing' - to - 'failed' after some time. - """ - target_id = vws_client.add_target( - name="my_example_name", - width=1, - image=image_file_failed_state, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - target_details = vws_client.get_target_record(target_id=target_id) - assert target_details.status == TargetStatuses.FAILED - # Tracking rating is 0 when status is 'failed' - assert target_details.target_record.tracking_rating == 0 - - -def test_success_status( - *, - image_file_success_state_low_rating: io.BytesIO, - vws_client: VWS, -) -> None: - """When a random, large enough image is given, the status changes - from - 'processing' to 'success' after some time. - - The mock is much more lenient than the real implementation of - VWS. The test image does not prove that what is counted as a - success in the mock will be counted as a success in the real - implementation. - """ - target_id = vws_client.add_target( - name="example", - width=1, - image=image_file_success_state_low_rating, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - - target_details = vws_client.get_target_record(target_id=target_id) - assert target_details.status == TargetStatuses.SUCCESS - # Tracking rating is between 0 and 5 when status is 'success' - tracking_rating = target_details.target_record.tracking_rating - assert tracking_rating in range(6) - - # The tracking rating stays stable across requests - target_details = vws_client.get_target_record(target_id=target_id) - new_tracking_rating = target_details.target_record.tracking_rating - assert new_tracking_rating == tracking_rating +__all__ = ["test_fail_status", "test_get_vws_target", "test_success_status"] diff --git a/tests/mock_vws/get_target/test_inactive_project.py b/tests/mock_vws/get_target/test_inactive_project.py index c6c1081c1..f49bae43a 100644 --- a/tests/mock_vws/get_target/test_inactive_project.py +++ b/tests/mock_vws/get_target/test_inactive_project.py @@ -1,13 +1,7 @@ -"""Tests for inactive projects.""" +"""Compatibility imports for the packaged contract suite.""" -import uuid +from vws_mock_contract_tests.get_target.test_inactive_project import ( + test_inactive_project, +) -import pytest -from vws import VWS -from vws.exceptions.vws_exceptions import UnknownTargetError - - -def test_inactive_project(inactive_vws_client: VWS) -> None: - """The project's active state does not affect getting a target.""" - with pytest.raises(expected_exception=UnknownTargetError): - _ = inactive_vws_client.get_target_record(target_id=uuid.uuid4().hex) +__all__ = ["test_inactive_project"] diff --git a/tests/mock_vws/get_target/test_target_tracking_rating.py b/tests/mock_vws/get_target/test_target_tracking_rating.py index f18b1a52e..61a62e54a 100644 --- a/tests/mock_vws/get_target/test_target_tracking_rating.py +++ b/tests/mock_vws/get_target/test_target_tracking_rating.py @@ -1,32 +1,7 @@ -""" -Tests which exercise the target tracking_rating, and check the image -fixtures we use. -""" +"""Compatibility imports for the packaged contract suite.""" -import io - -from vws import VWS - -from tests.mock_vws.get_target.helpers import ( - get_target_tracking_rating, +from vws_mock_contract_tests.get_target.test_target_tracking_rating import ( + test_target_quality, ) - -def test_target_quality( - *, - vws_client: VWS, - high_quality_image: io.BytesIO, - image_file_success_state_low_rating: io.BytesIO, -) -> None: - """The target tracking rating is as expected.""" - high_quality_image_tracking_rating = get_target_tracking_rating( - vws_client=vws_client, - image_file=high_quality_image, - ) - low_quality_image_tracking_rating = get_target_tracking_rating( - vws_client=vws_client, - image_file=image_file_success_state_low_rating, - ) - assert ( - high_quality_image_tracking_rating > low_quality_image_tracking_rating - ) +__all__ = ["test_target_quality"] diff --git a/tests/mock_vws/target_list/conftest.py b/tests/mock_vws/target_list/conftest.py index d409c8ebf..82332e866 100644 --- a/tests/mock_vws/target_list/conftest.py +++ b/tests/mock_vws/target_list/conftest.py @@ -1,12 +1,7 @@ -"""Fixtures scoped to this group of tests.""" +"""Compatibility imports for the packaged contract suite.""" -import pytest +from vws_mock_contract_tests.target_list.conftest import ( + fixture_verify_backend, +) - -@pytest.fixture(autouse=True) -def fixture_verify_backend( - *, - verify_mock_vuforia: None, -) -> None: - """Set up the backend for every contract test in this directory.""" - del verify_mock_vuforia +__all__ = ["fixture_verify_backend"] diff --git a/tests/mock_vws/target_list/test_inactive_project.py b/tests/mock_vws/target_list/test_inactive_project.py index 03e00bf4d..fc53d75ac 100644 --- a/tests/mock_vws/target_list/test_inactive_project.py +++ b/tests/mock_vws/target_list/test_inactive_project.py @@ -1,9 +1,7 @@ -"""Tests for inactive projects.""" +"""Compatibility imports for the packaged contract suite.""" -from vws import VWS +from vws_mock_contract_tests.target_list.test_inactive_project import ( + test_inactive_project, +) - -def test_inactive_project(inactive_vws_client: VWS) -> None: - """The project's active state does not affect the target list.""" - # No exception is raised. - _ = inactive_vws_client.list_targets() +__all__ = ["test_inactive_project"] diff --git a/tests/mock_vws/target_list/test_rate_limit.py b/tests/mock_vws/target_list/test_rate_limit.py index 6fb2d80fe..04ff7f92a 100644 --- a/tests/mock_vws/target_list/test_rate_limit.py +++ b/tests/mock_vws/target_list/test_rate_limit.py @@ -1,72 +1,7 @@ -"""Tests for the request rate limit of the target list endpoint.""" +"""Compatibility imports for the packaged contract suite.""" -from http import HTTPMethod, HTTPStatus +from vws_mock_contract_tests.target_list.test_rate_limit import ( + test_two_requests_per_minute, +) -from vws.response import Response -from vws_auth_tools import authorization_header, rfc_1123_date - -from tests.mock_vws.fixtures.credentials import CloudDatabaseCredentials -from tests.mock_vws.utils import Endpoint -from tests.mock_vws.utils.assertions import assert_vws_too_many_requests -from tests.mock_vws.utils.backend_capabilities import BackendCapabilities - -_REQUESTS_PER_MINUTE = 2 - - -def test_two_requests_per_minute( - *, - base_vws_url: str, - backend_capabilities: BackendCapabilities, - rate_limited_database: CloudDatabaseCredentials, -) -> None: - """A third ``GET /targets`` request within a minute is rate - limited, - with the empty response which Envoy gives. - - Real Vuforia's window is a fixed clock minute, and the fixture which - empties the database before each test has already listed the - targets, so the real backend may reject the first, second or third - request. The mocks use a rolling window on a fresh database, so they - reject exactly the third. - """ - responses: list[Response] = [] - # Real Vuforia's fixed window means the rejection may come on any - # of the first three requests, so requests are sent until one is - # rejected, with a cap well above the limit. - while True: - date = rfc_1123_date() - authorization = authorization_header( - access_key=rate_limited_database.server_access_key, - secret_key=rate_limited_database.server_secret_key, - method=HTTPMethod.GET, - content=b"", - content_type="", - date=date, - request_path="/targets", - ) - endpoint = Endpoint( - base_url=base_vws_url, - path_url="/targets", - method=HTTPMethod.GET, - headers={"Authorization": authorization, "Date": date}, - data=b"", - successful_headers_result_code="Success", - successful_headers_status_code=HTTPStatus.OK, - access_key=rate_limited_database.server_access_key, - secret_key=rate_limited_database.server_secret_key, - ) - response = endpoint.send() - responses.append(response) - if response.status_code == HTTPStatus.TOO_MANY_REQUESTS: - break - assert len(responses) <= _REQUESTS_PER_MINUTE + 2 - - status_codes = [response.status_code for response in responses] - if backend_capabilities.rolling_rate_limit_window: - assert status_codes == [ - *[HTTPStatus.OK] * _REQUESTS_PER_MINUTE, - HTTPStatus.TOO_MANY_REQUESTS, - ] - assert status_codes[-1] == HTTPStatus.TOO_MANY_REQUESTS - assert status_codes.count(HTTPStatus.OK) <= _REQUESTS_PER_MINUTE - assert_vws_too_many_requests(response=responses[-1]) +__all__ = ["test_two_requests_per_minute"] diff --git a/tests/mock_vws/target_list/test_target_list.py b/tests/mock_vws/target_list/test_target_list.py index 3e1843152..f31efe94c 100644 --- a/tests/mock_vws/target_list/test_target_list.py +++ b/tests/mock_vws/target_list/test_target_list.py @@ -1,58 +1,13 @@ -"""Tests for the mock of the target list endpoint at `/targets`.""" - -import io -import uuid - -import pytest -from vws import VWS - -from tests.mock_vws.utils.backend_capabilities import BackendCapabilities - - -def test_includes_targets( - *, - vws_client: VWS, - unprocessed_target_id: str, -) -> None: - """Targets in the database are returned in the list.""" - assert vws_client.list_targets() == [unprocessed_target_id] - - -def test_deleted( - *, - vws_client: VWS, - target_id: str, -) -> None: - """Deleted targets are not returned in the list.""" - vws_client.delete_target(target_id=target_id) - assert not bool(vws_client.list_targets()) - - -def test_order_is_upload_date_then_target_id( - *, - backend_capabilities: BackendCapabilities, - high_quality_image: io.BytesIO, - vws_client: VWS, -) -> None: - """The mock returns targets ordered by upload date. - - The real Vuforia Web Services do not document an order, so we do - not verify this against them. - """ - if not backend_capabilities.deterministic_target_order: - pytest.skip( - reason="This backend does not guarantee target upload-date order." - ) - - target_ids = [ - vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - for _ in range(3) - ] - - assert vws_client.list_targets() == target_ids +"""Compatibility imports for the packaged contract suite.""" + +from vws_mock_contract_tests.target_list.test_target_list import ( + test_deleted, + test_includes_targets, + test_order_is_upload_date_then_target_id, +) + +__all__ = [ + "test_deleted", + "test_includes_targets", + "test_order_is_upload_date_then_target_id", +] diff --git a/tests/mock_vws/target_summary/contract/conftest.py b/tests/mock_vws/target_summary/contract/conftest.py index d409c8ebf..e51fca5e9 100644 --- a/tests/mock_vws/target_summary/contract/conftest.py +++ b/tests/mock_vws/target_summary/contract/conftest.py @@ -1,12 +1,7 @@ -"""Fixtures scoped to this group of tests.""" +"""Compatibility imports for the packaged contract suite.""" -import pytest +from vws_mock_contract_tests.target_summary.contract.conftest import ( + fixture_verify_backend, +) - -@pytest.fixture(autouse=True) -def fixture_verify_backend( - *, - verify_mock_vuforia: None, -) -> None: - """Set up the backend for every contract test in this directory.""" - del verify_mock_vuforia +__all__ = ["fixture_verify_backend"] diff --git a/tests/mock_vws/target_summary/contract/test_inactive_project.py b/tests/mock_vws/target_summary/contract/test_inactive_project.py index cdae1bfe8..2790cc654 100644 --- a/tests/mock_vws/target_summary/contract/test_inactive_project.py +++ b/tests/mock_vws/target_summary/contract/test_inactive_project.py @@ -1,15 +1,7 @@ -"""Tests for inactive projects.""" +"""Compatibility imports for the packaged contract suite.""" -import uuid +from vws_mock_contract_tests.target_summary.contract.test_inactive_project import ( # noqa: E501 + test_inactive_project, +) -import pytest -from vws import VWS -from vws.exceptions.vws_exceptions import UnknownTargetError - - -def test_inactive_project(inactive_vws_client: VWS) -> None: - """The project's active state does not affect getting a target.""" - with pytest.raises(expected_exception=UnknownTargetError): - _ = inactive_vws_client.get_target_summary_report( - target_id=uuid.uuid4().hex, - ) +__all__ = ["test_inactive_project"] diff --git a/tests/mock_vws/target_summary/contract/test_recognition_counts.py b/tests/mock_vws/target_summary/contract/test_recognition_counts.py index ea8b67d9d..4678a3a96 100644 --- a/tests/mock_vws/target_summary/contract/test_recognition_counts.py +++ b/tests/mock_vws/target_summary/contract/test_recognition_counts.py @@ -1,34 +1,7 @@ -"""Tests for the recognition counts in the summary.""" +"""Compatibility imports for the packaged contract suite.""" -import io +from vws_mock_contract_tests.target_summary.contract.test_recognition_counts import ( # noqa: E501 + test_recognition, +) -from vws import VWS, CloudRecoService -from vws.reports import TargetStatuses - - -def test_recognition( - *, - vws_client: VWS, - cloud_reco_client: CloudRecoService, - high_quality_image: io.BytesIO, -) -> None: - """The recognition counts stay at 0 even after recognitions.""" - 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) - - results = cloud_reco_client.query(image=high_quality_image) - (result,) = results - assert result.target_id == target_id - - report = vws_client.get_target_summary_report(target_id=target_id) - assert report.status == TargetStatuses.SUCCESS - assert report.total_recos == 0 - assert report.current_month_recos == 0 - assert report.previous_month_recos == 0 +__all__ = ["test_recognition"] diff --git a/tests/mock_vws/target_summary/contract/test_target_summary.py b/tests/mock_vws/target_summary/contract/test_target_summary.py index 6ba035adc..745da21c4 100644 --- a/tests/mock_vws/target_summary/contract/test_target_summary.py +++ b/tests/mock_vws/target_summary/contract/test_target_summary.py @@ -1,110 +1,8 @@ -"""Tests for the target summary endpoint.""" +"""Compatibility imports for the packaged contract suite.""" -import datetime as dt -import io -import uuid -from zoneinfo import ZoneInfo - -import pytest -from vws import VWS -from vws.reports import TargetStatuses - -from tests.mock_vws.fixtures.credentials import NamedCloudDatabaseCredentials -from tests.mock_vws.target_summary.helpers import ( - add_target_and_get_pre_rating_summary, +from vws_mock_contract_tests.target_summary.contract.test_target_summary import ( # noqa: E501 + test_after_processing, + test_target_summary, ) - -@pytest.mark.parametrize(argnames="active_flag", argvalues=[True, False]) -def test_target_summary( - *, - vws_client: VWS, - vuforia_database: NamedCloudDatabaseCredentials, - image_file_failed_state: io.BytesIO, - active_flag: bool, -) -> None: - """A target summary is returned.""" - name = uuid.uuid4().hex - gmt = ZoneInfo(key="GMT") - date_before_add_target = dt.datetime.now(tz=gmt).date() - - report = add_target_and_get_pre_rating_summary( - vws_client=vws_client, - name=name, - image_file=image_file_failed_state, - active_flag=active_flag, - ) - - date_after_add_target = dt.datetime.now(tz=gmt).date() - - assert report.status == TargetStatuses.PROCESSING - assert report.database_name == vuforia_database.database_name - assert report.target_name == name - assert report.active_flag == active_flag - - # In case the date changes while adding a target - # we allow the date before and after adding the target. - - assert report.upload_date in { - date_before_add_target, - date_after_add_target, - } - - assert report.tracking_rating == -1 - assert report.total_recos == 0 - assert report.current_month_recos == 0 - assert report.previous_month_recos == 0 - - -@pytest.mark.parametrize( - argnames=("image_fixture_name", "expected_status"), - argvalues=[ - ("high_quality_image", TargetStatuses.SUCCESS), - ("image_file_failed_state", TargetStatuses.FAILED), - ], -) -def test_after_processing( - *, - vws_client: VWS, - request: pytest.FixtureRequest, - image_fixture_name: str, - expected_status: TargetStatuses, -) -> None: - """After processing is completed, the tracking rating is in the - range - of 0 to 5. - - The documentation says: - - > Note: tracking_rating and reco_rating are provided only when - > status = success. - - However, this shows that ``tracking_rating`` is given when the status - is not success. - It also shows that ``reco_rating`` is not provided even when the status - is success. - """ - image_file = request.getfixturevalue(argname=image_fixture_name) - - target_id = vws_client.add_target( - name="example", - width=1, - image=image_file, - active_flag=True, - application_metadata=None, - ) - - # The tracking rating may change during processing. - # Therefore we wait until processing ends. - vws_client.wait_for_target_processed(target_id=target_id) - - report = vws_client.get_target_summary_report(target_id=target_id) - target_details = vws_client.get_target_record(target_id=target_id) - - tracking_rating = target_details.target_record.tracking_rating - assert report.tracking_rating == tracking_rating - assert report.tracking_rating in range(6) - assert report.status == expected_status - assert report.total_recos == 0 - assert report.current_month_recos == 0 - assert report.previous_month_recos == 0 +__all__ = ["test_after_processing", "test_target_summary"] diff --git a/tests/mock_vws/target_summary/test_seeded_recognition_counts.py b/tests/mock_vws/target_summary/test_seeded_recognition_counts.py index d20c5da8c..380cac56f 100644 --- a/tests/mock_vws/target_summary/test_seeded_recognition_counts.py +++ b/tests/mock_vws/target_summary/test_seeded_recognition_counts.py @@ -1,52 +1,7 @@ -"""Tests for the recognition counts which are set on a target. +"""Compatibility imports for the packaged contract suite.""" -Real Vuforia's recognition counts lag behind its queries by longer than -a test runs, so the mocks let the counts be set rather than counting -recognitions. Nothing sets counts on real Vuforia, so these tests run -against the mocks only. -""" +from vws_mock_contract_tests.target_summary.test_seeded_recognition_counts import ( # noqa: E501 + test_seeded_counts, +) -import io -import uuid - -from vws import VWS -from vws.reports import TargetStatuses - -from tests.mock_vws.fixtures.credentials import NamedCloudDatabaseCredentials -from tests.mock_vws.utils.recognition_counts import SeedRecognitionCounts - -_CURRENT_MONTH_RECOS = 3 -_PREVIOUS_MONTH_RECOS = 5 -_TOTAL_RECOS = 8 - - -def test_seeded_counts( - *, - seed_recognition_counts: SeedRecognitionCounts, - vws_client: VWS, - vuforia_database: NamedCloudDatabaseCredentials, - high_quality_image: io.BytesIO, -) -> None: - """The summary shows the recognition counts set on the target.""" - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - - seed_recognition_counts( - database_name=vuforia_database.database_name, - target_id=target_id, - current_month_recos=_CURRENT_MONTH_RECOS, - previous_month_recos=_PREVIOUS_MONTH_RECOS, - total_recos=_TOTAL_RECOS, - ) - - report = vws_client.get_target_summary_report(target_id=target_id) - assert report.status == TargetStatuses.SUCCESS - assert report.total_recos == _TOTAL_RECOS - assert report.current_month_recos == _CURRENT_MONTH_RECOS - assert report.previous_month_recos == _PREVIOUS_MONTH_RECOS +__all__ = ["test_seeded_counts"] diff --git a/tests/mock_vws/update_target/conftest.py b/tests/mock_vws/update_target/conftest.py index d409c8ebf..bdd3e9927 100644 --- a/tests/mock_vws/update_target/conftest.py +++ b/tests/mock_vws/update_target/conftest.py @@ -1,12 +1,7 @@ -"""Fixtures scoped to this group of tests.""" +"""Compatibility imports for the packaged contract suite.""" -import pytest +from vws_mock_contract_tests.update_target.conftest import ( + fixture_verify_backend, +) - -@pytest.fixture(autouse=True) -def fixture_verify_backend( - *, - verify_mock_vuforia: None, -) -> None: - """Set up the backend for every contract test in this directory.""" - del verify_mock_vuforia +__all__ = ["fixture_verify_backend"] diff --git a/tests/mock_vws/update_target/test_active_flag.py b/tests/mock_vws/update_target/test_active_flag.py index 293e783d5..dd67937d4 100644 --- a/tests/mock_vws/update_target/test_active_flag.py +++ b/tests/mock_vws/update_target/test_active_flag.py @@ -1,81 +1,8 @@ -"""Tests for the active flag parameter.""" +"""Compatibility imports for the packaged contract suite.""" -import io -import uuid -from http import HTTPStatus - -import pytest -from vws import VWS -from vws.exceptions.vws_exceptions import ( - FailError, -) - -from tests.mock_vws.update_target.helpers import ( - update_target, -) -from tests.mock_vws.utils.assertions import ( - assert_vws_failure, -) - - -@pytest.mark.parametrize( - argnames="initial_active_flag", - argvalues=[True, False], -) -@pytest.mark.parametrize( - argnames="desired_active_flag", - argvalues=[True, False], -) -def test_active_flag( - *, - vws_client: VWS, - image_file_success_state_low_rating: io.BytesIO, - initial_active_flag: bool, - desired_active_flag: bool, -) -> None: - """Setting the active flag to a Boolean value changes it.""" - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image_file_success_state_low_rating, - active_flag=initial_active_flag, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.update_target( - target_id=target_id, - active_flag=desired_active_flag, - ) - - target_details = vws_client.get_target_record(target_id=target_id) - assert target_details.target_record.active_flag == desired_active_flag - - -@pytest.mark.parametrize( - argnames="desired_active_flag", - argvalues=["string", None], +from vws_mock_contract_tests.update_target.test_active_flag import ( + test_active_flag, + test_invalid, ) -def test_invalid( - *, - vws_client: VWS, - target_id: str, - desired_active_flag: str | None, -) -> None: - """ - Values which are not Boolean values are not valid active - flags. - """ - with pytest.raises(expected_exception=FailError) as exc: - _ = update_target( - vws_client=vws_client, - data={"active_flag": desired_active_flag}, - target_id=target_id, - content_type="application/json", - ) - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.BAD_REQUEST, - result_code="Fail", - ) +__all__ = ["test_active_flag", "test_invalid"] diff --git a/tests/mock_vws/update_target/test_application_metadata.py b/tests/mock_vws/update_target/test_application_metadata.py index 07d04baea..c1eb473be 100644 --- a/tests/mock_vws/update_target/test_application_metadata.py +++ b/tests/mock_vws/update_target/test_application_metadata.py @@ -1,136 +1,17 @@ -"""Tests for the application metadata parameter.""" - -import base64 -from http import HTTPStatus - -import pytest -from vws import VWS -from vws.exceptions.vws_exceptions import ( - FailError, - MetadataTooLargeError, -) - -from tests.mock_vws.update_target.helpers import ( - MAX_METADATA_BYTES, - update_target, -) -from tests.mock_vws.utils.assertions import ( - assert_vws_failure, +"""Compatibility imports for the packaged contract suite.""" + +from vws_mock_contract_tests.update_target.test_application_metadata import ( + test_base64_encoded, + test_invalid_type, + test_metadata_too_large, + test_not_base64_encoded_not_processable, + test_not_base64_encoded_processable, ) - -@pytest.mark.parametrize( - argnames="metadata", - argvalues=[ - b"a", - b"a" * MAX_METADATA_BYTES, - ], - ids=["Short", "Max length"], -) -def test_base64_encoded( - *, - target_id: str, - metadata: bytes, - vws_client: VWS, -) -> None: - """A base64 encoded string is valid application metadata.""" - metadata_encoded = base64.b64encode(s=metadata).decode(encoding="ascii") - vws_client.update_target( - target_id=target_id, - application_metadata=metadata_encoded, - ) - - -@pytest.mark.parametrize(argnames="invalid_metadata", argvalues=[1, None]) -def test_invalid_type( - *, - vws_client: VWS, - target_id: str, - invalid_metadata: int | None, -) -> None: - """Non-string values cannot be given as valid application metadata.""" - with pytest.raises(expected_exception=FailError) as exc: - _ = update_target( - vws_client=vws_client, - data={"application_metadata": invalid_metadata}, - target_id=target_id, - content_type="application/json", - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.BAD_REQUEST, - result_code="Fail", - ) - - -def test_not_base64_encoded_processable( - *, - vws_client: VWS, - target_id: str, - not_base64_encoded_processable: str, -) -> None: - """ - Some strings which are not valid base64 encoded strings are - allowed as - application metadata. - """ - vws_client.update_target( - target_id=target_id, - application_metadata=not_base64_encoded_processable, - ) - - -def test_not_base64_encoded_not_processable( - *, - vws_client: VWS, - target_id: str, - not_base64_encoded_not_processable: str, -) -> None: - """ - Some strings which are not valid base64 encoded strings are not - allowed - as application metadata. - """ - with pytest.raises(expected_exception=FailError) as exc: - vws_client.update_target( - target_id=target_id, - application_metadata=not_base64_encoded_not_processable, - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - result_code="Fail", - ) - - -def test_metadata_too_large( - *, - vws_client: VWS, - target_id: str, - application_metadata_near_size_limit: str, -) -> None: - """ - A base64 encoded string of greater than 1024 * 1024 bytes is too - large - for application metadata. - """ - decoded_metadata = base64.b64decode( - s=application_metadata_near_size_limit.encode(encoding="ascii"), - ) - metadata_encoded = base64.b64encode( - s=decoded_metadata + b"x", - ).decode(encoding="ascii") - - with pytest.raises(expected_exception=MetadataTooLargeError) as exc: - vws_client.update_target( - target_id=target_id, - application_metadata=metadata_encoded, - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - result_code="MetadataTooLarge", - ) +__all__ = [ + "test_base64_encoded", + "test_invalid_type", + "test_metadata_too_large", + "test_not_base64_encoded_not_processable", + "test_not_base64_encoded_processable", +] diff --git a/tests/mock_vws/update_target/test_image.py b/tests/mock_vws/update_target/test_image.py index 001405bc5..ebe481c21 100644 --- a/tests/mock_vws/update_target/test_image.py +++ b/tests/mock_vws/update_target/test_image.py @@ -1,322 +1,29 @@ -"""Tests for the image parameter. - -The specification for images is documented at -https://library.vuforia.com/features/images/image-targets.html. -""" - -import io -import uuid -from http import HTTPStatus - -import pytest -from vws import VWS -from vws.exceptions.vws_exceptions import ( - BadImageError, - FailError, - ImageTooLargeError, -) -from vws.reports import TargetStatuses -from vws_test_fixtures.images import VWS_MAX_IMAGE_FILE_SIZE - -from tests.mock_vws.update_target.helpers import ( - update_target, -) -from tests.mock_vws.utils import make_truncated_png_file -from tests.mock_vws.utils.assertions import ( - assert_vws_failure, -) - - -def test_image_valid( - *, - image_files_failed_state: io.BytesIO, - target_id: str, - vws_client: VWS, -) -> None: - """ - JPEG and PNG files in the RGB and greyscale color spaces are - allowed. - """ - vws_client.update_target( - target_id=target_id, - image=image_files_failed_state, - ) - - -def test_bad_image_format_or_color_space( - *, - bad_image_file: io.BytesIO, - target_id: str, - vws_client: VWS, -) -> None: - """ - A `BAD_IMAGE` response is returned if an image which is not a - JPEG or - PNG file is given, or if the given image is not in the greyscale or - RGB - color space. - """ - with pytest.raises(expected_exception=BadImageError) as exc: - vws_client.update_target(target_id=target_id, image=bad_image_file) - - status_code = exc.value.response.status_code - assert status_code == HTTPStatus.UNPROCESSABLE_ENTITY - - -def test_corrupted( - *, - vws_client: VWS, - corrupted_image_file: io.BytesIO, - target_id: str, -) -> None: - """An error is returned when the given image is corrupted.""" - with pytest.raises(expected_exception=BadImageError) as exc: - vws_client.update_target( - target_id=target_id, - image=corrupted_image_file, - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - result_code="BadImage", - ) - - -def test_truncated( - *, - vws_client: VWS, - target_id: str, -) -> None: - """ - An error is returned when the given image is truncated before - the - end of its image data. - """ - image_file = make_truncated_png_file() - - with pytest.raises(expected_exception=BadImageError) as exc: - vws_client.update_target( - target_id=target_id, - image=image_file, - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - result_code="BadImage", - ) - - -def test_image_too_large( - *, - target_id: str, - vws_client: VWS, - png_just_under_max_size: io.BytesIO, - png_too_large: io.BytesIO, -) -> None: - """ - An `ImageTooLargeError` result is returned if the image is above - a - certain threshold. - """ - max_bytes = VWS_MAX_IMAGE_FILE_SIZE - - image_content_size = len(png_just_under_max_size.getvalue()) - assert image_content_size < max_bytes - assert (image_content_size * 1.05) > max_bytes - - vws_client.update_target( - target_id=target_id, - image=png_just_under_max_size, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - - image_content_size = len(png_too_large.getvalue()) - assert image_content_size > max_bytes - - with pytest.raises(expected_exception=ImageTooLargeError) as exc: - vws_client.update_target(target_id=target_id, image=png_too_large) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - result_code="ImageTooLarge", - ) - - -def test_not_base64_encoded_processable( - *, - vws_client: VWS, - target_id: str, - not_base64_encoded_processable: str, -) -> None: - """Some strings which are not valid base64 encoded strings are - allowed - as an image without getting a "Fail" response. - - This is because Vuforia treats them as valid base64, but then - not a valid image. - """ - with pytest.raises(expected_exception=BadImageError) as exc: - _ = update_target( - vws_client=vws_client, - data={"image": not_base64_encoded_processable}, - target_id=target_id, - content_type="application/json", - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - result_code="BadImage", - ) - - -def test_not_base64_encoded_not_processable( - *, - vws_client: VWS, - target_id: str, - not_base64_encoded_not_processable: str, -) -> None: - """ - Some strings which are not valid base64 encoded strings are not - processable by Vuforia, and then when given as an image Vuforia - returns - a "Fail" response. - """ - with pytest.raises(expected_exception=FailError) as exc: - _ = update_target( - vws_client=vws_client, - data={"image": not_base64_encoded_not_processable}, - target_id=target_id, - content_type="application/json", - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - result_code="Fail", - ) - - -def test_not_image(*, target_id: str, vws_client: VWS) -> None: - """ - If the given image is not an image file then a `BadImageError` - result - is returned. - """ - with pytest.raises(expected_exception=BadImageError) as exc: - vws_client.update_target( - target_id=target_id, - image=io.BytesIO(initial_bytes=b"not_image_data"), - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - result_code="BadImage", - ) - - -@pytest.mark.parametrize( - argnames="invalid_type_image", - argvalues=[1, None], +"""Compatibility imports for the packaged contract suite.""" + +from vws_mock_contract_tests.update_target.test_image import ( + test_bad_image_format_or_color_space, + test_corrupted, + test_image_too_large, + test_image_valid, + test_invalid_type, + test_not_base64_encoded_not_processable, + test_not_base64_encoded_processable, + test_not_image, + test_rating_can_change, + test_rating_does_not_return_to_minus_one, + test_truncated, ) -def test_invalid_type( - *, - invalid_type_image: int | None, - target_id: str, - vws_client: VWS, -) -> None: - """If the given image is not a string, a `Fail` result is returned.""" - with pytest.raises(expected_exception=FailError) as exc: - _ = update_target( - vws_client=vws_client, - data={"image": invalid_type_image}, - target_id=target_id, - content_type="application/json", - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.BAD_REQUEST, - result_code="Fail", - ) - - -def test_rating_does_not_return_to_minus_one( - *, - image_file_success_state_low_rating: io.BytesIO, - high_quality_image: io.BytesIO, - vws_client: VWS, -) -> None: - """An update does not start a new -1 tracking rating window. - - A target reports a rating of -1 for a short time after it is - uploaded, and then the image's rating, while it is still - processing. An update returns the target to the processing - state, but the rating does not go back to -1: the new image's - rating is reported straight away. - - Anyone who polls for an update to be processed by watching the - rating leave -1, rather than by watching the status, would wait - forever, so the mock must not offer them a window which the real - Vuforia does not have. - """ - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image_file_success_state_low_rating, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - - vws_client.update_target(target_id=target_id, image=high_quality_image) - - target_details = vws_client.get_target_record(target_id=target_id) - assert target_details.target_record.tracking_rating in range(6) - - -def test_rating_can_change( - *, - image_file_success_state_low_rating: io.BytesIO, - high_quality_image: io.BytesIO, - vws_client: VWS, -) -> None: - """If the target is updated with an image of different quality, the - tracking rating can change. - - "quality" refers to Vuforia's internal rating system. The mock - randomly assigns a quality and makes sure that the new quality - is different to the old quality. - """ - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image_file_success_state_low_rating, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - - target_details = vws_client.get_target_record(target_id=target_id) - assert target_details.status == TargetStatuses.SUCCESS - # Tracking rating is between 0 and 5 when status is 'success' - original_tracking_rating = target_details.target_record.tracking_rating - assert original_tracking_rating in range(6) - - vws_client.update_target(target_id=target_id, image=high_quality_image) - - vws_client.wait_for_target_processed(target_id=target_id) - target_details = vws_client.get_target_record(target_id=target_id) - assert target_details.status == TargetStatuses.SUCCESS - # Tracking rating is between 0 and 5 when status is 'success' - new_tracking_rating = target_details.target_record.tracking_rating - assert new_tracking_rating in range(6) - assert original_tracking_rating != new_tracking_rating +__all__ = [ + "test_bad_image_format_or_color_space", + "test_corrupted", + "test_image_too_large", + "test_image_valid", + "test_invalid_type", + "test_not_base64_encoded_not_processable", + "test_not_base64_encoded_processable", + "test_not_image", + "test_rating_can_change", + "test_rating_does_not_return_to_minus_one", + "test_truncated", +] diff --git a/tests/mock_vws/update_target/test_inactive_project.py b/tests/mock_vws/update_target/test_inactive_project.py index f4bedfc76..ad0e3c0a1 100644 --- a/tests/mock_vws/update_target/test_inactive_project.py +++ b/tests/mock_vws/update_target/test_inactive_project.py @@ -1,18 +1,7 @@ -"""Tests for inactive projects.""" +"""Compatibility imports for the packaged contract suite.""" -import uuid - -import pytest -from vws import VWS -from vws.exceptions.vws_exceptions import ( - ProjectInactiveError, +from vws_mock_contract_tests.update_target.test_inactive_project import ( + test_inactive_project, ) - -def test_inactive_project(inactive_vws_client: VWS) -> None: - """ - If the project is inactive, a FORBIDDEN response is - returned. - """ - with pytest.raises(expected_exception=ProjectInactiveError): - inactive_vws_client.update_target(target_id=uuid.uuid4().hex) +__all__ = ["test_inactive_project"] diff --git a/tests/mock_vws/update_target/test_target_name.py b/tests/mock_vws/update_target/test_target_name.py index 4f3612c30..05ea50b87 100644 --- a/tests/mock_vws/update_target/test_target_name.py +++ b/tests/mock_vws/update_target/test_target_name.py @@ -1,167 +1,15 @@ -"""Tests for the target name field.""" +"""Compatibility imports for the packaged contract suite.""" -import io -from http import HTTPStatus - -import pytest -from vws import VWS -from vws.exceptions.base_exceptions import VWSError -from vws.exceptions.vws_exceptions import ( - TargetNameExistError, -) - -from tests.mock_vws.update_target.helpers import ( - update_target, -) -from tests.mock_vws.utils.assertions import ( - assert_vws_failure, -) - -_MAX_CHAR_VALUE = 65535 -_MAX_NAME_LENGTH = 64 - - -@pytest.mark.parametrize( - argnames="name", - argvalues=[ - "á", - # We test just below the max character value. - # This is because targets with the max character value in their - # names get stuck in the processing stage. - chr(_MAX_CHAR_VALUE - 2), - "a" * _MAX_NAME_LENGTH, - ], - ids=["Short name", "Max char value", "Long name"], -) -def test_name_valid( - *, - name: str, - target_id: str, - vws_client: VWS, -) -> None: - """A target's name must be a string of length 0 < N < 65. - - We test characters out of range in another test as that gives a - different error. - """ - vws_client.update_target(target_id=target_id, name=name) - target_details = vws_client.get_target_record(target_id=target_id) - assert target_details.target_record.name == name - - -@pytest.mark.parametrize( - argnames=("name", "status_code", "result_code"), - argvalues=[ - (1, HTTPStatus.BAD_REQUEST, "Fail"), - ("", HTTPStatus.BAD_REQUEST, "Fail"), - ( - "a" * (_MAX_NAME_LENGTH + 1), - HTTPStatus.BAD_REQUEST, - "Fail", - ), - (None, HTTPStatus.BAD_REQUEST, "Fail"), - ( - chr(_MAX_CHAR_VALUE + 1), - HTTPStatus.FORBIDDEN, - "TargetNameExist", - ), - ( - chr(_MAX_CHAR_VALUE + 1) * (_MAX_NAME_LENGTH + 1), - HTTPStatus.BAD_REQUEST, - "Fail", - ), - ], - ids=[ - "Wrong Type", - "Empty", - "Too Long", - "None", - "Bad char", - "Bad char too long", - ], +from vws_mock_contract_tests.update_target.test_target_name import ( + test_existing_target_name, + test_name_invalid, + test_name_valid, + test_same_name_given, ) -def test_name_invalid( - *, - name: str | int | None, - target_id: str, - vws_client: VWS, - status_code: int, - result_code: str, -) -> None: - """A target's name must be a string of length 0 < N < 65.""" - with pytest.raises(expected_exception=VWSError) as exc: - _ = update_target( - vws_client=vws_client, - data={"name": name}, - target_id=target_id, - content_type="application/json", - ) - - assert_vws_failure( - response=exc.value.response, - status_code=status_code, - result_code=result_code, - ) - - -def test_existing_target_name( - *, - image_file_success_state_low_rating: io.BytesIO, - vws_client: VWS, -) -> None: - """Only one target can have a given name.""" - first_target_name = "example_name" - second_target_name = "another_example_name" - - first_target_id = vws_client.add_target( - name=first_target_name, - width=1, - image=image_file_success_state_low_rating, - active_flag=True, - application_metadata=None, - ) - - second_target_id = vws_client.add_target( - name=second_target_name, - width=1, - image=image_file_success_state_low_rating, - active_flag=True, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=first_target_id) - vws_client.wait_for_target_processed(target_id=second_target_id) - - with pytest.raises(expected_exception=TargetNameExistError) as exc: - vws_client.update_target( - target_id=second_target_id, - name=first_target_name, - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.FORBIDDEN, - result_code="TargetNameExist", - ) - - -def test_same_name_given( - *, - image_file_success_state_low_rating: io.BytesIO, - vws_client: VWS, -) -> None: - """Updating a target with its own name does not give an error.""" - name = "example" - - target_id = vws_client.add_target( - name=name, - width=1, - image=image_file_success_state_low_rating, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.update_target(target_id=target_id, name=name) - target_details = vws_client.get_target_record(target_id=target_id) - assert target_details.target_record.name == name +__all__ = [ + "test_existing_target_name", + "test_name_invalid", + "test_name_valid", + "test_same_name_given", +] diff --git a/tests/mock_vws/update_target/test_unexpected_data.py b/tests/mock_vws/update_target/test_unexpected_data.py index 57abd980f..8c2143ec8 100644 --- a/tests/mock_vws/update_target/test_unexpected_data.py +++ b/tests/mock_vws/update_target/test_unexpected_data.py @@ -1,40 +1,7 @@ -"""Tests for passing data which is not allowed to the endpoint.""" +"""Compatibility imports for the packaged contract suite.""" -from http import HTTPStatus - -import pytest -from vws import VWS -from vws.exceptions.vws_exceptions import ( - FailError, -) - -from tests.mock_vws.update_target.helpers import ( - update_target, +from vws_mock_contract_tests.update_target.test_unexpected_data import ( + test_invalid_extra_data, ) -from tests.mock_vws.utils.assertions import ( - assert_vws_failure, -) - - -def test_invalid_extra_data( - *, - vws_client: VWS, - target_id: str, -) -> None: - """ - A `BAD_REQUEST` response is returned when unexpected data is - given. - """ - with pytest.raises(expected_exception=FailError) as exc: - _ = update_target( - vws_client=vws_client, - data={"extra_thing": 1}, - target_id=target_id, - content_type="application/json", - ) - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.BAD_REQUEST, - result_code="Fail", - ) +__all__ = ["test_invalid_extra_data"] diff --git a/tests/mock_vws/update_target/test_update.py b/tests/mock_vws/update_target/test_update.py index fd28435e2..20ee1fbcf 100644 --- a/tests/mock_vws/update_target/test_update.py +++ b/tests/mock_vws/update_target/test_update.py @@ -1,132 +1,13 @@ -"""Tests for updating targets.""" +"""Compatibility imports for the packaged contract suite.""" -import io -import json -from http import HTTPStatus - -import pytest -from vws import VWS -from vws.exceptions.vws_exceptions import ( - AuthenticationFailureError, - TargetStatusNotSuccessError, -) -from vws.reports import TargetStatuses - -from tests.mock_vws.update_target.helpers import ( - update_target, -) -from tests.mock_vws.utils.assertions import ( - assert_vws_failure, - assert_vws_response, -) - - -@pytest.mark.parametrize( - argnames="content_type", - argvalues=[ - # This is the documented required content type: - "application/json", - # Other content types also work. - "other/content_type", - ], - ids=["Documented Content-Type", "Undocumented Content-Type"], +from vws_mock_contract_tests.update_target.test_update import ( + test_content_types, + test_empty_content_type, + test_no_fields_given, ) -def test_content_types( - *, - vws_client: VWS, - image_file_failed_state: io.BytesIO, - content_type: str, -) -> None: - """ - The ``Content-Type`` header does not change the response as long - as it - is not empty. - """ - target_id = vws_client.add_target( - name="example", - width=1, - image=image_file_failed_state, - active_flag=True, - application_metadata=None, - ) - - with pytest.raises(expected_exception=TargetStatusNotSuccessError) as exc: - _ = update_target( - vws_client=vws_client, - data={"name": "Adam"}, - target_id=target_id, - content_type=content_type, - ) - - # Code is FORBIDDEN because the target is processing. - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.FORBIDDEN, - result_code="TargetStatusNotSuccess", - ) - - -def test_empty_content_type( - *, - vws_client: VWS, - image_file_failed_state: io.BytesIO, -) -> None: - """ - An ``UNAUTHORIZED`` response is given if an empty ``Content- - Type`` - header is given. - """ - target_id = vws_client.add_target( - name="example", - width=1, - image=image_file_failed_state, - active_flag=True, - application_metadata=None, - ) - - with pytest.raises(expected_exception=AuthenticationFailureError) as exc: - _ = update_target( - vws_client=vws_client, - data={"name": "Adam"}, - target_id=target_id, - content_type="", - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.UNAUTHORIZED, - result_code="AuthenticationFailure", - ) - - -def test_no_fields_given( - *, - vws_client: VWS, - target_id: str, -) -> None: - """No data fields are required.""" - response = update_target( - vws_client=vws_client, - data={}, - target_id=target_id, - content_type="application/json", - ) - - assert_vws_response( - response=response, - status_code=HTTPStatus.OK, - result_code="Success", - ) - - response_json = json.loads(s=response.text) - assert isinstance(response_json, dict) - assert response_json.keys() == {"result_code", "transaction_id"} - - target_details = vws_client.get_target_record(target_id=target_id) - # Targets go back to processing after being updated. - assert target_details.status == TargetStatuses.PROCESSING - - vws_client.wait_for_target_processed(target_id=target_id) - target_details = vws_client.get_target_record(target_id=target_id) - assert target_details.status == TargetStatuses.SUCCESS +__all__ = [ + "test_content_types", + "test_empty_content_type", + "test_no_fields_given", +] diff --git a/tests/mock_vws/update_target/test_width.py b/tests/mock_vws/update_target/test_width.py index aec2d3b3e..8c03f2478 100644 --- a/tests/mock_vws/update_target/test_width.py +++ b/tests/mock_vws/update_target/test_width.py @@ -1,57 +1,8 @@ -"""Tests for the target width field.""" +"""Compatibility imports for the packaged contract suite.""" -from http import HTTPStatus - -import pytest -from vws import VWS -from vws.exceptions.vws_exceptions import ( - FailError, -) - -from tests.mock_vws.update_target.helpers import ( - update_target, +from vws_mock_contract_tests.update_target.test_width import ( + test_width_invalid, + test_width_valid, ) -from tests.mock_vws.utils.assertions import ( - assert_vws_failure, -) - - -@pytest.mark.parametrize( - argnames="width", - argvalues=[-1, "10", None, 0], - ids=["Negative", "Wrong Type", "None", "Zero"], -) -def test_width_invalid( - *, - vws_client: VWS, - width: int | str | None, - target_id: str, -) -> None: - """The width must be a number greater than zero.""" - target_details = vws_client.get_target_record(target_id=target_id) - original_width = target_details.target_record.width - - with pytest.raises(expected_exception=FailError) as exc: - _ = update_target( - vws_client=vws_client, - data={"width": width}, - target_id=target_id, - content_type="application/json", - ) - - assert_vws_failure( - response=exc.value.response, - status_code=HTTPStatus.BAD_REQUEST, - result_code="Fail", - ) - - target_details = vws_client.get_target_record(target_id=target_id) - assert target_details.target_record.width == original_width - -def test_width_valid(*, vws_client: VWS, target_id: str) -> None: - """Positive numbers are valid widths.""" - width = 0.01 - vws_client.update_target(target_id=target_id, width=width) - target_details = vws_client.get_target_record(target_id=target_id) - assert target_details.target_record.width == width +__all__ = ["test_width_invalid", "test_width_valid"] From bf286dd8f6c47718c96b9747fc9f535f3051919c Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 28 Oct 2026 17:45:49 +0000 Subject: [PATCH 03/10] Package the shared image-query contract tests --- .../vws_mock_contract_tests/query/__init__.py | 1 + .../vws_mock_contract_tests/query/conftest.py | 12 + .../vws_mock_contract_tests}/query/helpers.py | 8 +- .../query/test_accept_header.py | 157 +++++++ .../query/test_active_flag.py | 26 ++ .../query/test_bad_image.py | 76 ++++ .../query/test_content_type.py | 412 +++++++++++++++++ .../query/test_date_formats.py | 112 +++++ .../query/test_deleted.py | 73 +++ .../query/test_image_formats.py | 76 ++++ .../query/test_inactive_project.py | 54 +++ .../query/test_include_target_data.py | 212 +++++++++ .../query/test_incorrect_fields.py | 98 ++++ .../query/test_max_num_results.py | 208 +++++++++ .../query/test_maximum_image_dimensions.py | 160 +++++++ .../query/test_maximum_image_file_size.py | 149 +++++++ .../query/test_processing.py | 48 ++ .../query/test_result_order.py | 123 +++++ .../query/test_success.py | 216 +++++++++ .../query/test_target_status_failed.py | 26 ++ .../query/test_truncated_body.py | 220 +++++++++ .../query/test_update.py | 79 ++++ tests/mock_vws/query/conftest.py | 15 +- tests/mock_vws/query/test_accept_header.py | 155 +------ tests/mock_vws/query/test_active_flag.py | 29 +- tests/mock_vws/query/test_bad_image.py | 78 +--- tests/mock_vws/query/test_content_type.py | 421 +----------------- tests/mock_vws/query/test_date_formats.py | 109 +---- tests/mock_vws/query/test_deleted.py | 77 +--- tests/mock_vws/query/test_image_formats.py | 78 +--- tests/mock_vws/query/test_inactive_project.py | 55 +-- .../query/test_include_target_data.py | 223 +--------- tests/mock_vws/query/test_incorrect_fields.py | 103 +---- tests/mock_vws/query/test_max_num_results.py | 219 +-------- .../query/test_maximum_image_dimensions.py | 169 +------ .../query/test_maximum_image_file_size.py | 151 +------ tests/mock_vws/query/test_processing.py | 51 +-- tests/mock_vws/query/test_result_order.py | 124 +----- tests/mock_vws/query/test_success.py | 227 +--------- .../query/test_target_status_failed.py | 29 +- tests/mock_vws/query/test_truncated_body.py | 233 +--------- tests/mock_vws/query/test_update.py | 82 +--- 42 files changed, 2710 insertions(+), 2464 deletions(-) create mode 100644 contract-tests/src/vws_mock_contract_tests/query/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/query/conftest.py rename {tests/mock_vws => contract-tests/src/vws_mock_contract_tests}/query/helpers.py (96%) create mode 100644 contract-tests/src/vws_mock_contract_tests/query/test_accept_header.py create mode 100644 contract-tests/src/vws_mock_contract_tests/query/test_active_flag.py create mode 100644 contract-tests/src/vws_mock_contract_tests/query/test_bad_image.py create mode 100644 contract-tests/src/vws_mock_contract_tests/query/test_content_type.py create mode 100644 contract-tests/src/vws_mock_contract_tests/query/test_date_formats.py create mode 100644 contract-tests/src/vws_mock_contract_tests/query/test_deleted.py create mode 100644 contract-tests/src/vws_mock_contract_tests/query/test_image_formats.py create mode 100644 contract-tests/src/vws_mock_contract_tests/query/test_inactive_project.py create mode 100644 contract-tests/src/vws_mock_contract_tests/query/test_include_target_data.py create mode 100644 contract-tests/src/vws_mock_contract_tests/query/test_incorrect_fields.py create mode 100644 contract-tests/src/vws_mock_contract_tests/query/test_max_num_results.py create mode 100644 contract-tests/src/vws_mock_contract_tests/query/test_maximum_image_dimensions.py create mode 100644 contract-tests/src/vws_mock_contract_tests/query/test_maximum_image_file_size.py create mode 100644 contract-tests/src/vws_mock_contract_tests/query/test_processing.py create mode 100644 contract-tests/src/vws_mock_contract_tests/query/test_result_order.py create mode 100644 contract-tests/src/vws_mock_contract_tests/query/test_success.py create mode 100644 contract-tests/src/vws_mock_contract_tests/query/test_target_status_failed.py create mode 100644 contract-tests/src/vws_mock_contract_tests/query/test_truncated_body.py create mode 100644 contract-tests/src/vws_mock_contract_tests/query/test_update.py diff --git a/contract-tests/src/vws_mock_contract_tests/query/__init__.py b/contract-tests/src/vws_mock_contract_tests/query/__init__.py new file mode 100644 index 000000000..75a879ba9 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/query/__init__.py @@ -0,0 +1 @@ +"""Query tests.""" diff --git a/contract-tests/src/vws_mock_contract_tests/query/conftest.py b/contract-tests/src/vws_mock_contract_tests/query/conftest.py new file mode 100644 index 000000000..d409c8ebf --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/query/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures scoped to this group of tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def fixture_verify_backend( + *, + verify_mock_vuforia: None, +) -> None: + """Set up the backend for every contract test in this directory.""" + del verify_mock_vuforia diff --git a/tests/mock_vws/query/helpers.py b/contract-tests/src/vws_mock_contract_tests/query/helpers.py similarity index 96% rename from tests/mock_vws/query/helpers.py rename to contract-tests/src/vws_mock_contract_tests/query/helpers.py index 3a6dde1f9..549bb4dd3 100644 --- a/tests/mock_vws/query/helpers.py +++ b/contract-tests/src/vws_mock_contract_tests/query/helpers.py @@ -16,8 +16,12 @@ from vws.response import Response from vws_auth_tools import authorization_header, rfc_1123_date -from tests.mock_vws.fixtures.credentials import CloudDatabaseCredentials -from tests.mock_vws.utils.too_many_requests import handle_server_errors +from vws_mock_contract_tests.fixtures.credentials import ( + CloudDatabaseCredentials, +) +from vws_mock_contract_tests.utils.too_many_requests import ( + handle_server_errors, +) JETTY_CONTENT_TYPE_ERROR = textwrap.dedent( text="""\ diff --git a/contract-tests/src/vws_mock_contract_tests/query/test_accept_header.py b/contract-tests/src/vws_mock_contract_tests/query/test_accept_header.py new file mode 100644 index 000000000..eb32e025f --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/query/test_accept_header.py @@ -0,0 +1,157 @@ +"""Tests for the ``Accept`` header.""" + +import io +import json +from http import HTTPMethod, HTTPStatus +from urllib.parse import urljoin + +import pytest +import requests +from urllib3.filepost import encode_multipart_formdata +from vws.response import Response +from vws_auth_tools import authorization_header, rfc_1123_date + +from vws_mock_contract_tests.fixtures.credentials import ( + CloudDatabaseCredentials, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_query_success, + assert_vwq_failure, +) +from vws_mock_contract_tests.utils.too_many_requests import ( + handle_server_errors, +) + + +@pytest.mark.parametrize( + argnames="extra_headers", + argvalues=[ + { + "Accept": "application/json", + }, + {}, + ], +) +def test_valid( + *, + high_quality_image: io.BytesIO, + vuforia_database: CloudDatabaseCredentials, + extra_headers: dict[str, str], + base_vwq_url: str, +) -> None: + """An ``Accept`` header can be given iff its value is + "application/json". + """ + image_content = high_quality_image.getvalue() + date = rfc_1123_date() + request_path = "/v1/query" + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, content_type_header = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST + + access_key = vuforia_database.client_access_key + secret_key = vuforia_database.client_secret_key + authorization_string = authorization_header( + access_key=access_key, + secret_key=secret_key, + method=method, + content=content, + # Note that this is not the actual Content-Type header value sent. + content_type="multipart/form-data", + date=date, + request_path=request_path, + ) + headers = { + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type_header, + } | extra_headers + + requests_response = requests.request( + method=method, + url=urljoin(base=base_vwq_url, url=request_path), + headers=headers, + data=content, + timeout=30, + ) + + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + handle_server_errors(response=vws_response) + assert_query_success(response=vws_response) + response_json = json.loads(s=requests_response.text) + assert response_json["results"] == [] + + +def test_invalid( + *, + high_quality_image: io.BytesIO, + vuforia_database: CloudDatabaseCredentials, + base_vwq_url: str, +) -> None: + """ + A NOT_ACCEPTABLE response is returned if an ``Accept`` header is + given + with a value which is not "application/json". + """ + image_content = high_quality_image.getvalue() + date = rfc_1123_date() + request_path = "/v1/query" + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, content_type_header = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST + + access_key = vuforia_database.client_access_key + secret_key = vuforia_database.client_secret_key + authorization_string = authorization_header( + access_key=access_key, + secret_key=secret_key, + method=method, + content=content, + # Note that this is not the actual Content-Type header value sent. + content_type="multipart/form-data", + date=date, + request_path=request_path, + ) + + headers = { + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type_header, + "Accept": "text/html", + } + + requests_response = requests.request( + method=method, + url=urljoin(base=base_vwq_url, url=request_path), + headers=headers, + data=content, + timeout=30, + ) + + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + handle_server_errors(response=vws_response) + + assert_vwq_failure( + response=vws_response, + status_code=HTTPStatus.NOT_ACCEPTABLE, + content_type=None, + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) diff --git a/contract-tests/src/vws_mock_contract_tests/query/test_active_flag.py b/contract-tests/src/vws_mock_contract_tests/query/test_active_flag.py new file mode 100644 index 000000000..0a49c538d --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/query/test_active_flag.py @@ -0,0 +1,26 @@ +"""Tests for active versus inactive targets.""" + +import io +import uuid + +from vws import VWS, CloudRecoService + + +def test_inactive( + *, + high_quality_image: io.BytesIO, + vws_client: VWS, + cloud_reco_client: CloudRecoService, +) -> None: + """Images which are not active are not matched.""" + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + active_flag=False, + application_metadata=None, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + results = cloud_reco_client.query(image=high_quality_image) + assert results == [] diff --git a/contract-tests/src/vws_mock_contract_tests/query/test_bad_image.py b/contract-tests/src/vws_mock_contract_tests/query/test_bad_image.py new file mode 100644 index 000000000..8658cce22 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/query/test_bad_image.py @@ -0,0 +1,76 @@ +"""Tests for bad images.""" + +import io +import json +from http import HTTPStatus + +import pytest +from vws import CloudRecoService +from vws.exceptions.cloud_reco_exceptions import ( + BadImageError, +) + +from vws_mock_contract_tests.utils.assertions import ( + assert_valid_transaction_id, + assert_vwq_failure, +) + + +def test_corrupted( + *, + corrupted_image_file: io.BytesIO, + cloud_reco_client: CloudRecoService, +) -> None: + """A ``BadImage`` response is returned when a corrupted image is + given. + """ + with pytest.raises(expected_exception=BadImageError) as exc_info: + _ = cloud_reco_client.query(image=corrupted_image_file) + + response = exc_info.value.response + + assert_vwq_failure( + response=response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + + +def test_not_image(cloud_reco_client: CloudRecoService) -> None: + """ + An ``UNPROCESSABLE_ENTITY`` response is returned when a non- + image is + given. + """ + not_image_data = b"not_image_data" + + with pytest.raises(expected_exception=BadImageError) as exc_info: + _ = cloud_reco_client.query( + image=io.BytesIO(initial_bytes=not_image_data) + ) + + response = exc_info.value.response + + assert_vwq_failure( + response=response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert response_json.keys() == {"transaction_id", "result_code"} + assert_valid_transaction_id(response=response) + # The separators are inconsistent and we test this. + expected_text = ( + '{"transaction_id": ' + f'"{response_json["transaction_id"]}",' + f'"result_code":"BadImage"' + "}" + ) + assert response.text == expected_text diff --git a/contract-tests/src/vws_mock_contract_tests/query/test_content_type.py b/contract-tests/src/vws_mock_contract_tests/query/test_content_type.py new file mode 100644 index 000000000..e0ba92b07 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/query/test_content_type.py @@ -0,0 +1,412 @@ +"""Tests for the Content-Type header.""" + +import io +import json +from http import HTTPMethod, HTTPStatus +from urllib.parse import urljoin + +import pytest +import requests +from urllib3.filepost import encode_multipart_formdata +from vws.response import Response +from vws_auth_tools import authorization_header, rfc_1123_date + +from vws_mock_contract_tests.fixtures.credentials import ( + CloudDatabaseCredentials, +) +from vws_mock_contract_tests.query.helpers import ( + JETTY_CONTENT_TYPE_ERROR, + JETTY_VERSION_RE, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_query_success, + assert_vwq_failure, +) +from vws_mock_contract_tests.utils.too_many_requests import ( + handle_server_errors, +) + + +@pytest.mark.parametrize( + argnames=( + "content_type", + "resp_status_code", + "resp_content_type", + "resp_cache_control", + "resp_text", + ), + argvalues=[ + ( + "text/html", + HTTPStatus.UNSUPPORTED_MEDIA_TYPE, + None, + None, + "", + ), + ( + "", + HTTPStatus.BAD_REQUEST, + "text/html;charset=iso-8859-1", + "must-revalidate,no-cache,no-store", + JETTY_CONTENT_TYPE_ERROR, + ), + ( + "*/*", + HTTPStatus.BAD_REQUEST, + "text/plain;charset=utf-8", + None, + "Unable to get boundary for multipart", + ), + ( + "text/*", + HTTPStatus.UNSUPPORTED_MEDIA_TYPE, + None, + None, + "", + ), + ( + "text/plain", + HTTPStatus.UNSUPPORTED_MEDIA_TYPE, + None, + None, + "", + ), + ], +) +def test_incorrect_no_boundary( + *, + high_quality_image: io.BytesIO, + vuforia_database: CloudDatabaseCredentials, + content_type: str, + resp_status_code: int, + resp_content_type: str | None, + resp_cache_control: str | None, + resp_text: str, + base_vwq_url: str, +) -> None: + """With bad Content-Type headers we get a variety of results.""" + image_content = high_quality_image.getvalue() + date = rfc_1123_date() + request_path = "/v1/query" + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, _ = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST + + access_key = vuforia_database.client_access_key + secret_key = vuforia_database.client_secret_key + authorization_string = authorization_header( + access_key=access_key, + secret_key=secret_key, + method=method, + content=content, + content_type=content_type, + date=date, + request_path=request_path, + ) + + headers = { + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type, + } + + requests_response = requests.request( + method=method, + url=urljoin(base=base_vwq_url, url=request_path), + headers=headers, + data=content, + timeout=30, + ) + + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + + handle_server_errors(response=vws_response) + + repl = "Powered by Jetty://" + sub = JETTY_VERSION_RE.sub + actual = sub(repl=repl, string=requests_response.text) + expected = sub(repl=repl, string=resp_text) + assert actual == expected + assert_vwq_failure( + response=vws_response, + status_code=resp_status_code, + content_type=resp_content_type, + cache_control=resp_cache_control, + www_authenticate=None, + connection="keep-alive", + ) + + +def test_incorrect_with_boundary( + *, + high_quality_image: io.BytesIO, + vuforia_database: CloudDatabaseCredentials, + base_vwq_url: str, +) -> None: + """ + If a Content-Type header which is not ``multipart/form-data`` is + given + with the correct boundary, an ``UNSUPPORTED_MEDIA_TYPE`` response + is + given. + """ + image_content = high_quality_image.getvalue() + date = rfc_1123_date() + request_path = "/v1/query" + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, content_type_header = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST + + content_type = "text/html" + + access_key = vuforia_database.client_access_key + secret_key = vuforia_database.client_secret_key + authorization_string = authorization_header( + access_key=access_key, + secret_key=secret_key, + method=method, + content=content, + content_type=content_type, + date=date, + request_path=request_path, + ) + + _, boundary = content_type_header.split(sep=";") + + content_type = "text/html; " + boundary + headers = { + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type, + } + + requests_response = requests.request( + method=method, + url=urljoin(base=base_vwq_url, url=request_path), + headers=headers, + data=content, + timeout=30, + ) + + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + handle_server_errors(response=vws_response) + assert not bool(requests_response.text) + assert_vwq_failure( + response=vws_response, + status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE, + content_type=None, + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + + +@pytest.mark.parametrize( + argnames="content_type", + argvalues=[ + "multipart/form-data", + "multipart/form-data; extra", + "multipart/form-data; extra=1", + ], +) +def test_no_boundary( + *, + high_quality_image: io.BytesIO, + vuforia_database: CloudDatabaseCredentials, + content_type: str, + base_vwq_url: str, +) -> None: + """If no boundary is given, a ``BAD_REQUEST`` is returned.""" + image_content = high_quality_image.getvalue() + date = rfc_1123_date() + request_path = "/v1/query" + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, _ = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST + + access_key = vuforia_database.client_access_key + secret_key = vuforia_database.client_secret_key + authorization_string = authorization_header( + access_key=access_key, + secret_key=secret_key, + method=method, + content=content, + # Note that this is not the actual Content-Type header value sent. + content_type="multipart/form-data", + date=date, + request_path=request_path, + ) + + headers = { + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type, + } + + requests_response = requests.request( + method=method, + url=urljoin(base=base_vwq_url, url=request_path), + headers=headers, + data=content, + timeout=30, + ) + + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + expected_text = "Unable to get boundary for multipart" + assert requests_response.text == expected_text + assert_vwq_failure( + response=vws_response, + status_code=HTTPStatus.BAD_REQUEST, + content_type="text/plain;charset=utf-8", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + + +def test_bogus_boundary( + *, + high_quality_image: io.BytesIO, + vuforia_database: CloudDatabaseCredentials, + base_vwq_url: str, +) -> None: + """If a bogus boundary is given, a ``BAD_REQUEST`` is returned.""" + image_content = high_quality_image.getvalue() + date = rfc_1123_date() + request_path = "/v1/query" + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, _ = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST + + access_key = vuforia_database.client_access_key + secret_key = vuforia_database.client_secret_key + authorization_string = authorization_header( + access_key=access_key, + secret_key=secret_key, + method=method, + content=content, + # Note that this is not the actual Content-Type header value sent. + content_type="multipart/form-data", + date=date, + request_path=request_path, + ) + + headers = { + "Authorization": authorization_string, + "Date": date, + "Content-Type": "multipart/form-data; boundary=example_boundary", + } + + requests_response = requests.request( + method=method, + url=urljoin(base=base_vwq_url, url=request_path), + headers=headers, + data=content, + timeout=30, + ) + + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + handle_server_errors(response=vws_response) + + expected_text = "No image." + assert requests_response.text == expected_text + assert_vwq_failure( + response=vws_response, + status_code=HTTPStatus.BAD_REQUEST, + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + + +def test_extra_section( + *, + high_quality_image: io.BytesIO, + vuforia_database: CloudDatabaseCredentials, + base_vwq_url: str, +) -> None: + """ + If sections that are not the boundary section are given in the + header, + that is fine. + """ + image_content = high_quality_image.getvalue() + date = rfc_1123_date() + request_path = "/v1/query" + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, content_type_header = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST + + access_key = vuforia_database.client_access_key + secret_key = vuforia_database.client_secret_key + authorization_string = authorization_header( + access_key=access_key, + secret_key=secret_key, + method=method, + content=content, + # Note that this is not the actual Content-Type header value sent. + content_type="multipart/form-data", + date=date, + request_path=request_path, + ) + + headers = { + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type_header + "; extra=1", + } + + requests_response = requests.request( + method=method, + url=urljoin(base=base_vwq_url, url=request_path), + headers=headers, + data=content, + timeout=30, + ) + + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + handle_server_errors(response=vws_response) + assert_query_success(response=vws_response) + response_json = json.loads(s=requests_response.text) + assert response_json["results"] == [] diff --git a/contract-tests/src/vws_mock_contract_tests/query/test_date_formats.py b/contract-tests/src/vws_mock_contract_tests/query/test_date_formats.py new file mode 100644 index 000000000..0f2864906 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/query/test_date_formats.py @@ -0,0 +1,112 @@ +"""Tests for various date formats. + +The date format for the VWS API as per +https://library.vuforia.com/articles/Training/Using-the-VWS-API.html must +be in the rfc1123-date format. + +However, for the query endpoint, the documentation does not mention the +format. It says: + +> The data format must exactly match the Date that is sent in the `Date` +> header. +""" + +import datetime as dt +import io +import json +from http import HTTPMethod +from urllib.parse import urljoin +from zoneinfo import ZoneInfo + +import pytest +import requests +from urllib3.filepost import encode_multipart_formdata +from vws.response import Response +from vws_auth_tools import authorization_header + +from vws_mock_contract_tests.fixtures.credentials import ( + CloudDatabaseCredentials, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_query_success, +) +from vws_mock_contract_tests.utils.too_many_requests import ( + handle_server_errors, +) + + +@pytest.mark.parametrize( + argnames="datetime_format", + argvalues=[ + "%a, %b %d %H:%M:%S %Y", + "%a %b %d %H:%M:%S %Y", + "%a, %d %b %Y %H:%M:%S", + "%a %d %b %Y %H:%M:%S", + ], +) +@pytest.mark.parametrize(argnames="include_tz", argvalues=[True, False]) +def test_date_formats( + *, + high_quality_image: io.BytesIO, + vuforia_database: CloudDatabaseCredentials, + datetime_format: str, + include_tz: bool, + base_vwq_url: str, +) -> None: + """Test various date formats which are known to be accepted. + + We expect that more formats than this will be accepted. These + are the accepted ones we know of at the time of writing. + """ + image_content = high_quality_image.getvalue() + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + + if include_tz: + datetime_format += " GMT" + + gmt = ZoneInfo(key="GMT") + now = dt.datetime.now(tz=gmt) + date = now.strftime(format=datetime_format) + request_path = "/v1/query" + content, content_type_header = encode_multipart_formdata(fields=body) + method = HTTPMethod.POST + + access_key = vuforia_database.client_access_key + secret_key = vuforia_database.client_secret_key + authorization_string = authorization_header( + access_key=access_key, + secret_key=secret_key, + method=method, + content=content, + content_type="multipart/form-data", + date=date, + request_path=request_path, + ) + + headers = { + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type_header, + } + + requests_response = requests.request( + method=method, + url=urljoin(base=base_vwq_url, url=request_path), + headers=headers, + data=content, + timeout=30, + ) + + vws_response = Response( + text=requests_response.text, + url=requests_response.url, + status_code=requests_response.status_code, + headers=dict(requests_response.headers), + request_body=requests_response.request.body, + tell_position=requests_response.raw.tell(), + content=requests_response.content, + ) + handle_server_errors(response=vws_response) + assert_query_success(response=vws_response) + response_json = json.loads(s=requests_response.text) + assert response_json["results"] == [] diff --git a/contract-tests/src/vws_mock_contract_tests/query/test_deleted.py b/contract-tests/src/vws_mock_contract_tests/query/test_deleted.py new file mode 100644 index 000000000..e1889eb5f --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/query/test_deleted.py @@ -0,0 +1,73 @@ +"""Tests for matching deleted targets.""" + +import io +import uuid + +from tenacity import Retrying +from tenacity.retry import retry_if_exception_type +from tenacity.stop import stop_after_delay +from tenacity.wait import wait_fixed +from vws import VWS, CloudRecoService + +from vws_mock_contract_tests.utils.retries import TRANSIENT_VWS_EXCEPTIONS + + +def test_deleted_active( + *, + high_quality_image: io.BytesIO, + vws_client: VWS, + cloud_reco_client: CloudRecoService, +) -> None: + """Deleted targets are not matched.""" + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.delete_target(target_id=target_id) + + # There is a race condition here. + # In the real Vuforia, it takes some time for the target deletion + # to be reflected in the query endpoint. + # + # This difference is documented in ``differences-to-vws.rst``. + # + # We retry to allow for this difference. + for attempt in Retrying( + wait=wait_fixed(wait=0.1), + stop=stop_after_delay(max_delay=3), + retry=retry_if_exception_type( + exception_types=(AssertionError, *TRANSIENT_VWS_EXCEPTIONS), + ), + reraise=True, + ): + with attempt: + results = cloud_reco_client.query(image=high_quality_image) + assert results == [] + + +def test_deleted_inactive( + *, + high_quality_image: io.BytesIO, + vws_client: VWS, + cloud_reco_client: CloudRecoService, +) -> None: + """ + No error is returned when querying for an image of recently + deleted, + inactive target. + """ + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + active_flag=False, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id) + vws_client.delete_target(target_id=target_id) + results = cloud_reco_client.query(image=high_quality_image) + assert results == [] diff --git a/contract-tests/src/vws_mock_contract_tests/query/test_image_formats.py b/contract-tests/src/vws_mock_contract_tests/query/test_image_formats.py new file mode 100644 index 000000000..5e9033a1a --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/query/test_image_formats.py @@ -0,0 +1,76 @@ +"""Tests for various image formats.""" + +import io +import json +from http import HTTPStatus + +import pytest +from PIL import Image +from vws import CloudRecoService +from vws.exceptions.cloud_reco_exceptions import ( + BadImageError, +) + +from vws_mock_contract_tests.utils.assertions import ( + assert_valid_transaction_id, + assert_vwq_failure, +) + + +@pytest.mark.parametrize(argnames="file_format", argvalues=["png", "jpeg"]) +def test_supported( + *, + high_quality_image: io.BytesIO, + file_format: str, + cloud_reco_client: CloudRecoService, +) -> None: + """PNG and JPEG formats are supported.""" + image_buffer = io.BytesIO() + pil_image = Image.open(fp=high_quality_image) + pil_image.save(fp=image_buffer, format=file_format) + image_content = image_buffer.getvalue() + results = cloud_reco_client.query( + image=io.BytesIO(initial_bytes=image_content) + ) + assert results == [] + + +def test_unsupported( + *, + high_quality_image: io.BytesIO, + cloud_reco_client: CloudRecoService, +) -> None: + """File formats which are not PNG or JPEG are not supported.""" + file_format = "tiff" + image_buffer = io.BytesIO() + pil_image = Image.open(fp=high_quality_image) + pil_image.save(fp=image_buffer, format=file_format) + image_content = image_buffer.getvalue() + + with pytest.raises(expected_exception=BadImageError) as exc_info: + _ = cloud_reco_client.query( + image=io.BytesIO(initial_bytes=image_content) + ) + + response = exc_info.value.response + + assert_vwq_failure( + response=response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert response_json.keys() == {"transaction_id", "result_code"} + assert_valid_transaction_id(response=response) + # The separators are inconsistent and we test this. + expected_text = ( + '{"transaction_id": ' + f'"{response_json["transaction_id"]}",' + f'"result_code":"BadImage"' + "}" + ) + assert response.text == expected_text diff --git a/contract-tests/src/vws_mock_contract_tests/query/test_inactive_project.py b/contract-tests/src/vws_mock_contract_tests/query/test_inactive_project.py new file mode 100644 index 000000000..68136e733 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/query/test_inactive_project.py @@ -0,0 +1,54 @@ +"""Tests for inactive projects.""" + +import io +import json +from http import HTTPStatus + +import pytest +from vws import CloudRecoService +from vws.exceptions.cloud_reco_exceptions import ( + InactiveProjectError, +) + +from vws_mock_contract_tests.utils.assertions import ( + assert_valid_transaction_id, + assert_vwq_failure, +) + + +def test_inactive_project( + *, + high_quality_image: io.BytesIO, + inactive_cloud_reco_client: CloudRecoService, +) -> None: + """ + If the project is inactive, a FORBIDDEN response is + returned. + """ + with pytest.raises(expected_exception=InactiveProjectError) as exc_info: + _ = inactive_cloud_reco_client.query(image=high_quality_image) + + response = exc_info.value.response + + assert_vwq_failure( + response=response, + status_code=HTTPStatus.FORBIDDEN, + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + + assert response_json.keys() == {"transaction_id", "result_code"} + assert_valid_transaction_id(response=response) + # The separators are inconsistent and we test this. + expected_text = ( + '{"transaction_id": ' + f'"{response_json["transaction_id"]}",' + f'"result_code":"InactiveProject"' + "}" + ) + assert response.text == expected_text diff --git a/contract-tests/src/vws_mock_contract_tests/query/test_include_target_data.py b/contract-tests/src/vws_mock_contract_tests/query/test_include_target_data.py new file mode 100644 index 000000000..34f9d05ea --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/query/test_include_target_data.py @@ -0,0 +1,212 @@ +"""Tests for the ``include_target_data`` parameter.""" + +import io +import json +from http import HTTPStatus + +import pytest +from vws import VWS + +from vws_mock_contract_tests.fixtures.credentials import ( + CloudDatabaseCredentials, +) +from vws_mock_contract_tests.query.helpers import ( + add_and_wait_for_targets, + query, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_query_success, + assert_vwq_failure, +) + + +def test_default( + *, + high_quality_image: io.BytesIO, + vws_client: VWS, + vuforia_database: CloudDatabaseCredentials, + base_vwq_url: str, +) -> None: + """The default ``include_target_data`` is 'top'.""" + add_and_wait_for_targets( + image=high_quality_image, + vws_client=vws_client, + num_targets=2, + ) + image_content = high_quality_image.getvalue() + body = { + "image": ("image.jpeg", image_content, "image/jpeg"), + "max_num_results": (None, 2, "text/plain"), + } + + response = query( + vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url + ) + + assert_query_success(response=response) + response_json = json.loads(s=response.text) + result_1, result_2 = response_json["results"] + assert "target_data" in result_1 + assert "target_data" not in result_2 + + +@pytest.mark.parametrize( + argnames="include_target_data", + argvalues=["top", "TOP"], +) +def test_top( + *, + high_quality_image: io.BytesIO, + vuforia_database: CloudDatabaseCredentials, + include_target_data: str, + vws_client: VWS, + base_vwq_url: str, +) -> None: + """ + When ``include_target_data`` is set to "top" (case insensitive), + only + the first result includes target data. + """ + add_and_wait_for_targets( + image=high_quality_image, + vws_client=vws_client, + num_targets=2, + ) + image_content = high_quality_image.getvalue() + body = { + "image": ("image.jpeg", image_content, "image/jpeg"), + "include_target_data": (None, include_target_data, "text/plain"), + "max_num_results": (None, 2, "text/plain"), + } + + response = query( + vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url + ) + + assert_query_success(response=response) + response_json = json.loads(s=response.text) + result_1, result_2 = response_json["results"] + assert "target_data" in result_1 + assert "target_data" not in result_2 + + +@pytest.mark.parametrize( + argnames="include_target_data", + argvalues=["none", "NONE"], +) +def test_none( + *, + high_quality_image: io.BytesIO, + vuforia_database: CloudDatabaseCredentials, + include_target_data: str, + vws_client: VWS, + base_vwq_url: str, +) -> None: + """ + When ``include_target_data`` is set to "none" (case + insensitive), no + results include target data. + """ + add_and_wait_for_targets( + image=high_quality_image, + vws_client=vws_client, + num_targets=2, + ) + image_content = high_quality_image.getvalue() + body = { + "image": ("image.jpeg", image_content, "image/jpeg"), + "include_target_data": (None, include_target_data, "text/plain"), + "max_num_results": (None, 2, "text/plain"), + } + + response = query( + vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url + ) + + assert_query_success(response=response) + response_json = json.loads(s=response.text) + result_1, result_2 = response_json["results"] + assert "target_data" not in result_1 + assert "target_data" not in result_2 + + +@pytest.mark.parametrize( + argnames="include_target_data", + argvalues=["all", "ALL"], +) +def test_all( + *, + high_quality_image: io.BytesIO, + vuforia_database: CloudDatabaseCredentials, + include_target_data: str, + vws_client: VWS, + base_vwq_url: str, +) -> None: + """ + When ``include_target_data`` is set to "all" (case insensitive), + all + results include target data. + """ + add_and_wait_for_targets( + image=high_quality_image, + vws_client=vws_client, + num_targets=2, + ) + image_content = high_quality_image.getvalue() + body = { + "image": ("image.jpeg", image_content, "image/jpeg"), + "include_target_data": (None, include_target_data, "text/plain"), + "max_num_results": (None, 2, "text/plain"), + } + + response = query( + vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url + ) + + assert_query_success(response=response) + response_json = json.loads(s=response.text) + result_1, result_2 = response_json["results"] + assert "target_data" in result_1 + assert "target_data" in result_2 + + +@pytest.mark.parametrize( + argnames="include_target_data", + argvalues=["a", True, 0], +) +def test_invalid_value( + *, + high_quality_image: io.BytesIO, + vuforia_database: CloudDatabaseCredentials, + include_target_data: str | bool | int, + base_vwq_url: str, +) -> None: + """ + A ``BAD_REQUEST`` error is given when a string that is not one + of + 'none', 'top' or 'all' (case insensitive). + """ + image_content = high_quality_image.getvalue() + body = { + "image": ("image.jpeg", image_content, "image/jpeg"), + "include_target_data": (None, include_target_data, "text/plain"), + } + response = query( + vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url + ) + + expected_text = ( + f"Invalid value '{str(object=include_target_data).lower()}' in " + "form data part 'include_target_data'. " + "Expecting one of the (unquoted) string values 'all', 'none' or " + "'top'." + ) + assert response.text == expected_text + assert_vwq_failure( + response=response, + status_code=HTTPStatus.BAD_REQUEST, + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) diff --git a/contract-tests/src/vws_mock_contract_tests/query/test_incorrect_fields.py b/contract-tests/src/vws_mock_contract_tests/query/test_incorrect_fields.py new file mode 100644 index 000000000..fee4e1621 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/query/test_incorrect_fields.py @@ -0,0 +1,98 @@ +"""Tests for incorrect and unexpected fields.""" + +import io +from http import HTTPStatus + +from vws_mock_contract_tests.fixtures.credentials import ( + CloudDatabaseCredentials, +) +from vws_mock_contract_tests.query.helpers import ( + query, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_vwq_failure, +) + + +def test_missing_image( + vuforia_database: CloudDatabaseCredentials, + base_vwq_url: str, +) -> None: + """ + If an image is not given, a ``BAD_REQUEST`` response is + returned. + """ + response = query( + vuforia_database=vuforia_database, body={}, base_vwq_url=base_vwq_url + ) + + assert response.text == "No image." + assert_vwq_failure( + response=response, + status_code=HTTPStatus.BAD_REQUEST, + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + + +def test_extra_fields( + *, + high_quality_image: io.BytesIO, + vuforia_database: CloudDatabaseCredentials, + base_vwq_url: str, +) -> None: + """If extra fields are given, a ``BAD_REQUEST`` response is + returned. + + The Query API documentation says that unknown fields are ignored, + but the real Query API rejects them. + """ + image_content = high_quality_image.getvalue() + body = { + "image": ("image.jpeg", image_content, "image/jpeg"), + "extra_field": (None, 1, "text/plain"), + } + + response = query( + vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url + ) + + assert response.text == "Unknown parameters in the request." + assert_vwq_failure( + response=response, + content_type="application/json", + status_code=HTTPStatus.BAD_REQUEST, + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + + +def test_missing_image_and_extra_fields( + vuforia_database: CloudDatabaseCredentials, + base_vwq_url: str, +) -> None: + """If extra fields are given and no image field is given, a + ``BAD_REQUEST`` response is returned. + + The extra field error takes precedence. + """ + body = { + "extra_field": (None, 1, "text/plain"), + } + + response = query( + vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url + ) + + assert response.text == "Unknown parameters in the request." + assert_vwq_failure( + response=response, + content_type="application/json", + status_code=HTTPStatus.BAD_REQUEST, + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) diff --git a/contract-tests/src/vws_mock_contract_tests/query/test_max_num_results.py b/contract-tests/src/vws_mock_contract_tests/query/test_max_num_results.py new file mode 100644 index 000000000..bfacbcea2 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/query/test_max_num_results.py @@ -0,0 +1,208 @@ +"""Tests for the ``max_num_results`` parameter.""" + +import io +import json +import uuid +from http import HTTPStatus + +import pytest +from pydantic import TypeAdapter +from vws import VWS, CloudRecoService +from vws.exceptions.cloud_reco_exceptions import ( + MaxNumResultsOutOfRangeError, +) + +from vws_mock_contract_tests.fixtures.credentials import ( + CloudDatabaseCredentials, +) +from vws_mock_contract_tests.query.helpers import ( + add_and_wait_for_targets, + query, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_query_success, + assert_vwq_failure, +) + + +def test_default( + *, + high_quality_image: io.BytesIO, + vuforia_database: CloudDatabaseCredentials, + vws_client: VWS, + base_vwq_url: str, +) -> None: + """The default ``max_num_results`` is 1.""" + image_content = high_quality_image.getvalue() + + target_id_1 = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + target_id_2 = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id_1) + vws_client.wait_for_target_processed(target_id=target_id_2) + + body = { + "image": ("image.jpeg", image_content, "image/jpeg"), + } + + response = query( + vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url + ) + + assert_query_success(response=response) + response_json = json.loads(s=response.text) + results_value: object = response_json["results"] + results = TypeAdapter(type=list[object]).validate_python( + results_value, + ) + assert len(results) == 1 + + +@pytest.mark.parametrize(argnames="num_results", argvalues=[1, b"1", 50]) +def test_valid_accepted( + *, + high_quality_image: io.BytesIO, + vuforia_database: CloudDatabaseCredentials, + num_results: int | bytes, + base_vwq_url: str, +) -> None: + """Numbers between 1 and 50 are valid inputs. + + We assert that the response is a success, but not that the + maximum number of results is enforced. + + This is because uploading 50 images would be very slow. + + The documentation at + https://developer.vuforia.com/library/web-api/vuforia-query-web-api + states that this must be between 1 and 10, but in practice, 50 is the + maximum. + """ + image_content = high_quality_image.getvalue() + body = { + "image": ("image.jpeg", image_content, "image/jpeg"), + "max_num_results": (None, num_results, "text/plain"), + } + + response = query( + vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url + ) + + assert_query_success(response=response) + response_json = json.loads(s=response.text) + assert response_json["results"] == [] + + +def test_valid_works( + *, + high_quality_image: io.BytesIO, + vws_client: VWS, + cloud_reco_client: CloudRecoService, +) -> None: + """A maximum of ``max_num_results`` results are returned.""" + add_and_wait_for_targets( + image=high_quality_image, + vws_client=vws_client, + num_targets=3, + ) + + max_num_results = 2 + + result = cloud_reco_client.query( + image=high_quality_image, + max_num_results=max_num_results, + ) + assert len(result) == max_num_results + + +@pytest.mark.parametrize(argnames="num_results", argvalues=[-1, 0, 51]) +def test_out_of_range( + *, + high_quality_image: io.BytesIO, + num_results: int, + cloud_reco_client: CloudRecoService, +) -> None: + """An error is returned if ``max_num_results`` is given as an + integer + out of the range (1, 50). + + The documentation at + https://developer.vuforia.com/library/web-api/vuforia-query-web-api. + states that this must be between 1 and 10, but in practice, 50 is the + maximum. + """ + with pytest.raises( + expected_exception=MaxNumResultsOutOfRangeError, + ) as exc_info: + _ = cloud_reco_client.query( + image=high_quality_image, + max_num_results=num_results, + ) + + expected_text = ( + f"Integer out of range ({num_results}) in form data part " + "'max_result'. Accepted range is from 1 to 50 (inclusive)." + ) + assert exc_info.value.response.text == expected_text + assert_vwq_failure( + response=exc_info.value.response, + content_type="application/json", + status_code=HTTPStatus.BAD_REQUEST, + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + + +@pytest.mark.parametrize( + argnames="num_results", + argvalues=[b"0.1", b"1.1", b"a", b"2147483648"], +) +def test_invalid_type( + *, + high_quality_image: io.BytesIO, + vuforia_database: CloudDatabaseCredentials, + num_results: bytes, + base_vwq_url: str, +) -> None: + """An error is returned if ``max_num_results`` is given as + something + other than an integer. + + Integers greater than 2147483647 are not considered integers + because they are bigger than Java's maximum integer. + """ + image_content = high_quality_image.getvalue() + body = { + "image": ("image.jpeg", image_content, "image/jpeg"), + "max_num_results": (None, num_results, "text/plain"), + } + response = query( + vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url + ) + + expected_text = ( + f"Invalid value '{num_results.decode()}' in form data part " + "'max_result'. " + "Expecting integer value in range from 1 to 50 (inclusive)." + ) + assert response.text == expected_text + assert_vwq_failure( + response=response, + content_type="application/json", + status_code=HTTPStatus.BAD_REQUEST, + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) diff --git a/contract-tests/src/vws_mock_contract_tests/query/test_maximum_image_dimensions.py b/contract-tests/src/vws_mock_contract_tests/query/test_maximum_image_dimensions.py new file mode 100644 index 000000000..22e876064 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/query/test_maximum_image_dimensions.py @@ -0,0 +1,160 @@ +"""Tests for maximum image dimensions.""" + +import json +from http import HTTPStatus + +import pytest +from vws import CloudRecoService +from vws.exceptions.cloud_reco_exceptions import ( + BadImageError, +) + +from vws_mock_contract_tests.utils import ( + make_decompression_bomb_image_file, + make_image_file, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_valid_transaction_id, + assert_vwq_failure, +) + + +def test_max_height( + cloud_reco_client: CloudRecoService, +) -> None: + """ + An error is returned when an image with a height greater than + 30000 is + given. + """ + width = 1 + max_height = 30000 + png_not_too_tall = make_image_file( + file_format="PNG", + color_space="RGB", + width=width, + height=max_height, + ) + + results = cloud_reco_client.query(image=png_not_too_tall) + assert results == [] + + png_too_tall = make_image_file( + file_format="PNG", + color_space="RGB", + width=width, + height=max_height + 1, + ) + + with pytest.raises(expected_exception=BadImageError) as exc_info: + _ = cloud_reco_client.query(image=png_too_tall) + + response = exc_info.value.response + + assert_vwq_failure( + response=response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert response_json.keys() == {"transaction_id", "result_code"} + assert_valid_transaction_id(response=response) + # The separators are inconsistent and we test this. + expected_text = ( + '{"transaction_id": ' + f'"{response_json["transaction_id"]}",' + f'"result_code":"BadImage"' + "}" + ) + assert response.text == expected_text + + +def test_max_width(cloud_reco_client: CloudRecoService) -> None: + """ + An error is returned when an image with a width greater than + 30000 is + given. + """ + height = 1 + max_width = 30000 + png_not_too_wide = make_image_file( + file_format="PNG", + color_space="RGB", + width=max_width, + height=height, + ) + + result = cloud_reco_client.query(image=png_not_too_wide) + assert result == [] + + png_too_wide = make_image_file( + file_format="PNG", + color_space="RGB", + width=max_width + 1, + height=height, + ) + + with pytest.raises(expected_exception=BadImageError) as exc_info: + result = cloud_reco_client.query(image=png_too_wide) + + response = exc_info.value.response + + assert_vwq_failure( + response=response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert response_json.keys() == {"transaction_id", "result_code"} + assert_valid_transaction_id(response=response) + # The separators are inconsistent and we test this. + expected_text = ( + '{"transaction_id": ' + f'"{response_json["transaction_id"]}",' + f'"result_code":"BadImage"' + "}" + ) + assert response.text == expected_text + + +def test_max_pixels(cloud_reco_client: CloudRecoService) -> None: + """No error is returned for an 835 x 835 image.""" + # If we make this 836 then we hit REQUEST_ENTITY_TOO_LARGE errors. + max_height = max_width = 835 + png_not_too_wide = make_image_file( + file_format="PNG", + color_space="RGB", + width=max_width, + height=max_height, + ) + + result = cloud_reco_client.query(image=png_not_too_wide) + assert result == [] + + +def test_small_file_many_pixels( + cloud_reco_client: CloudRecoService, +) -> None: + """ + No error is returned for an image with a small file size and a + huge number of pixels. + + Unlike ``POST /targets``, the Query API has no limit on the + number of pixels, only on the width and the height. + """ + max_bytes = 2 * 1024 * 1024 + image_file = make_decompression_bomb_image_file() + assert len(image_file.getvalue()) < max_bytes + + result = cloud_reco_client.query(image=image_file) + assert result == [] diff --git a/contract-tests/src/vws_mock_contract_tests/query/test_maximum_image_file_size.py b/contract-tests/src/vws_mock_contract_tests/query/test_maximum_image_file_size.py new file mode 100644 index 000000000..9582785c9 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/query/test_maximum_image_file_size.py @@ -0,0 +1,149 @@ +"""Tests for maximum image file sizes.""" + +import io +from http import HTTPStatus + +import pytest +from vws import CloudRecoService +from vws.exceptions.custom_exceptions import RequestEntityTooLargeError + +from vws_mock_contract_tests.query.helpers import ( + NGINX_REQUEST_ENTITY_TOO_LARGE_ERROR, +) +from vws_mock_contract_tests.utils import ( + make_image_file, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_vwq_failure, +) + + +def test_png(cloud_reco_client: CloudRecoService) -> None: + """ + According to + https://developer.vuforia.com/library/web-api/vuforia-query-web- + api. + the maximum file size is "2MiB for PNG". + + Above this limit, a ``REQUEST_ENTITY_TOO_LARGE`` response is returned. + We do not test exactly at this limit, but that may be beneficial in the + future. + """ + max_bytes = 2 * 1024 * 1024 + width = height = 835 + png_not_too_large = make_image_file( + file_format="PNG", + color_space="RGB", + width=width, + height=height, + ) + + image_content = png_not_too_large.getvalue() + + image_content_size = len(image_content) + # We check that the image we created is just slightly smaller than the + # maximum file size. + # + # This is just because of the implementation details of + # ``make_image_file``. + assert image_content_size < max_bytes + assert (image_content_size * 1.05) > max_bytes + + results = cloud_reco_client.query(image=png_not_too_large) + assert results == [] + + width += 1 + height += 1 + png_too_large = make_image_file( + file_format="PNG", + color_space="RGB", + width=width, + height=height, + ) + + image_content = png_too_large.getvalue() + image_content_size = len(image_content) + # We check that the image we created is just slightly larger than the + # maximum file size. + # + # This is just because of the implementation details of + # ``make_image_file``. + assert image_content_size > max_bytes + assert (image_content_size * 0.95) < max_bytes + + with pytest.raises( + expected_exception=RequestEntityTooLargeError + ) as exc_info: + _ = cloud_reco_client.query(image=png_too_large) + + response = exc_info.value.response + + assert_vwq_failure( + response=response, + status_code=HTTPStatus.REQUEST_ENTITY_TOO_LARGE, + content_type="text/html", + cache_control=None, + www_authenticate=None, + connection="Close", + ) + assert response.text == NGINX_REQUEST_ENTITY_TOO_LARGE_ERROR + + +def test_jpeg( + cloud_reco_client: CloudRecoService, + jpeg_too_large: io.BytesIO, +) -> None: + """ + According to + https://developer.vuforia.com/library/web-api/vuforia-query-web- + api. + the maximum file size is "512 KiB for JPEG". + However, this test shows that the maximum size for JPEG is 2 MiB. + + Above this limit, a ``REQUEST_ENTITY_TOO_LARGE`` response is returned. + We do not test exactly at this limit, but that may be beneficial in the + future. + """ + max_bytes = 2 * 1024 * 1024 + width = height = 1865 + jpeg_not_too_large = make_image_file( + file_format="JPEG", + color_space="RGB", + width=width, + height=height, + ) + + image_content = jpeg_not_too_large.getvalue() + + image_content_size = len(image_content) + # We check that the image we created is just slightly smaller than the + # maximum file size. + # + # This is just because of the implementation details of + # ``make_image_file``. + assert image_content_size < max_bytes + assert (image_content_size * 1.05) > max_bytes + + results = cloud_reco_client.query(image=jpeg_not_too_large) + assert results == [] + + image_content_size = len(jpeg_too_large.getvalue()) + assert image_content_size > max_bytes + + with pytest.raises( + expected_exception=RequestEntityTooLargeError + ) as exc_info: + _ = cloud_reco_client.query(image=jpeg_too_large) + + response = exc_info.value.response + + assert_vwq_failure( + response=response, + status_code=HTTPStatus.REQUEST_ENTITY_TOO_LARGE, + content_type="text/html", + cache_control=None, + www_authenticate=None, + connection="Close", + ) + + assert response.text == NGINX_REQUEST_ENTITY_TOO_LARGE_ERROR diff --git a/contract-tests/src/vws_mock_contract_tests/query/test_processing.py b/contract-tests/src/vws_mock_contract_tests/query/test_processing.py new file mode 100644 index 000000000..12dbef258 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/query/test_processing.py @@ -0,0 +1,48 @@ +"""Tests for targets in the processing state.""" + +import io +import uuid + +import pytest +from vws import VWS, CloudRecoService +from vws.reports import TargetStatuses + + +@pytest.mark.parametrize(argnames="active_flag", argvalues=[True, False]) +def test_processing( + *, + high_quality_image: io.BytesIO, + vws_client: VWS, + cloud_reco_client: CloudRecoService, + active_flag: bool, +) -> None: + """ + When a target with a matching image is in the processing state + it is + not matched. + """ + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + active_flag=active_flag, + application_metadata=None, + ) + matching_targets = cloud_reco_client.query(image=high_quality_image) + # We assert that after making a query, the target is in the processing + # state. + # + # There is a race condition here. + # + # This is really a check that the test is valid. + # + # If the target is no longer in the processing state here, it may be + # that the test was valid, but the target went into the processed + # state. + # + # If the target is no longer in the processing state here, that is a + # flaky test that is the test's fault and this must be rethought. + target_details = vws_client.get_target_record(target_id=target_id) + assert target_details.status == TargetStatuses.PROCESSING + + assert matching_targets == [] diff --git a/contract-tests/src/vws_mock_contract_tests/query/test_result_order.py b/contract-tests/src/vws_mock_contract_tests/query/test_result_order.py new file mode 100644 index 000000000..90b449044 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/query/test_result_order.py @@ -0,0 +1,123 @@ +"""Tests for the order of query results.""" + +import io +import json +import uuid + +import pytest +from vws import VWS + +from vws_mock_contract_tests.fixtures.credentials import ( + CloudDatabaseCredentials, +) +from vws_mock_contract_tests.query.helpers import ( + query, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_query_success, +) +from vws_mock_contract_tests.utils.backend_capabilities import ( + BackendCapabilities, +) + + +def test_order_is_upload_date_then_target_id( + *, + backend_capabilities: BackendCapabilities, + high_quality_image: io.BytesIO, + vws_client: VWS, + vuforia_database: CloudDatabaseCredentials, + base_vwq_url: str, +) -> None: + """Matches with the same score are ordered by upload date. + + Every target here has the same image, so the mock's image matcher + gives them all the same score and the tie-break decides the order. + The real Query API scores matches with Vuforia's own proprietary + matcher, so we do not verify this against the real Vuforia Web + Services. + """ + if not backend_capabilities.deterministic_query_order: + pytest.skip( + reason="This backend does not guarantee query upload-date order.", + ) + + target_ids = [ + vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + for _ in range(3) + ] + + for target_id in target_ids: + vws_client.wait_for_target_processed(target_id=target_id) + + image_content = high_quality_image.getvalue() + body = { + "image": ("image.jpeg", image_content, "image/jpeg"), + "max_num_results": (None, 3, "text/plain"), + } + + response = query( + vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url + ) + + assert_query_success(response=response) + response_json = json.loads(s=response.text) + result_target_ids = [ + result["target_id"] for result in response_json["results"] + ] + assert result_target_ids == target_ids + + +def test_max_num_results_keeps_the_first_results( + *, + backend_capabilities: BackendCapabilities, + high_quality_image: io.BytesIO, + vws_client: VWS, + vuforia_database: CloudDatabaseCredentials, + base_vwq_url: str, +) -> None: + """``max_num_results`` truncates a deterministically ordered + list. + + Which matches survive the truncation therefore does not vary + between runs. + """ + if not backend_capabilities.deterministic_query_order: + pytest.skip( + reason="This backend does not guarantee query upload-date order.", + ) + + target_ids = [ + vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + for _ in range(3) + ] + + for target_id in target_ids: + vws_client.wait_for_target_processed(target_id=target_id) + + image_content = high_quality_image.getvalue() + body = { + "image": ("image.jpeg", image_content, "image/jpeg"), + "max_num_results": (None, 1, "text/plain"), + } + + response = query( + vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url + ) + + assert_query_success(response=response) + response_json = json.loads(s=response.text) + (result,) = response_json["results"] + assert result["target_id"] == target_ids[0] diff --git a/contract-tests/src/vws_mock_contract_tests/query/test_success.py b/contract-tests/src/vws_mock_contract_tests/query/test_success.py new file mode 100644 index 000000000..a8ffc23e2 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/query/test_success.py @@ -0,0 +1,216 @@ +"""Tests for successful calls to the query endpoint.""" + +import base64 +import calendar +import copy +import io +import json +import time + +from dirty_equals import IsInstance +from PIL import Image +from vws import VWS, CloudRecoService + +from vws_mock_contract_tests.fixtures.credentials import ( + CloudDatabaseCredentials, +) +from vws_mock_contract_tests.query.helpers import ( + query, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_query_success, +) + + +def test_no_results( + *, + high_quality_image: io.BytesIO, + cloud_reco_client: CloudRecoService, +) -> None: + """ + When there are no matching images in the database, an empty list + of + results is returned. + """ + results = cloud_reco_client.query(image=high_quality_image) + assert results == [] + + +def test_match_exact( + *, + high_quality_image: io.BytesIO, + vuforia_database: CloudDatabaseCredentials, + vws_client: VWS, + base_vwq_url: str, +) -> None: + """ + If the exact high quality image that was added is queried for, + target + data is shown. + """ + image_file = high_quality_image + image_content = image_file.getvalue() + metadata_encoded = base64.b64encode(s=b"example").decode(encoding="ascii") + name = "example_name" + + target_id = vws_client.add_target( + name=name, + width=1, + image=image_file, + active_flag=True, + application_metadata=metadata_encoded, + ) + + approximate_target_created = calendar.timegm(tuple=time.gmtime()) + + vws_client.wait_for_target_processed(target_id=target_id) + + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + + response = query( + vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url + ) + + assert_query_success(response=response) + response_json = json.loads(s=response.text) + (result,) = response_json["results"] + assert result == { + "target_id": target_id, + "target_data": { + "application_metadata": metadata_encoded, + "name": name, + "target_timestamp": IsInstance(expected_type=int), + }, + } + target_timestamp_value: object = result["target_data"]["target_timestamp"] + assert isinstance(target_timestamp_value, int) + target_timestamp = target_timestamp_value + time_difference = abs(approximate_target_created - target_timestamp) + max_time_difference = 5 + assert time_difference < max_time_difference + + +def test_low_quality_image( + *, + image_file_success_state_low_rating: io.BytesIO, + cloud_reco_client: CloudRecoService, + vws_client: VWS, +) -> None: + """ + If the exact low quality image that was added is queried for, no + results are returned. + """ + image_file = image_file_success_state_low_rating + metadata_encoded = base64.b64encode(s=b"example").decode(encoding="ascii") + name = "example_name" + + target_id = vws_client.add_target( + name=name, + width=1, + image=image_file, + active_flag=True, + application_metadata=metadata_encoded, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + matching_targets = cloud_reco_client.query(image=image_file) + assert not bool(matching_targets) + + +def test_match_similar( + *, + high_quality_image: io.BytesIO, + different_high_quality_image: io.BytesIO, + vws_client: VWS, + cloud_reco_client: CloudRecoService, +) -> None: + """ + If a similar image to one that was added is queried for, target + data is + shown. + """ + metadata_encoded = base64.b64encode(s=b"example").decode(encoding="ascii") + name_matching = "example_name_matching" + name_not_matching = "example_name_not_matching" + + target_id_matching = vws_client.add_target( + name=name_matching, + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=metadata_encoded, + ) + + target_id_not_matching = vws_client.add_target( + name=name_not_matching, + width=1, + image=different_high_quality_image, + active_flag=True, + application_metadata=metadata_encoded, + ) + + vws_client.wait_for_target_processed(target_id=target_id_matching) + vws_client.wait_for_target_processed(target_id=target_id_not_matching) + + similar_image_buffer = io.BytesIO() + similar_image_data = copy.copy(x=high_quality_image) + pil_similar_image = Image.open(fp=similar_image_data) + # Re-save means similar but not identical. + pil_similar_image.save(fp=similar_image_buffer, format="JPEG") + + (matching_target,) = cloud_reco_client.query( + image=similar_image_buffer, + max_num_results=5, + ) + + assert matching_target.target_id == target_id_matching + + +def test_not_base64_encoded_processable( + *, + high_quality_image: io.BytesIO, + vws_client: VWS, + not_base64_encoded_processable: str, + cloud_reco_client: CloudRecoService, +) -> None: + """ + Vuforia accepts some metadata strings which are not valid + base64. + When a target with such a string is matched by a query, Vuforia + returns + an interesting result: + + * If the metadata string is a length one greater than a multiple of 4, + the last character is ignored. + * If the metadata is two greater than a multiple of 4, the result is + padded, then decoded, then encoded. + * If the metadata is three greater than a multiple of 4, the result is + padded, then decoded, then encoded. + """ + target_id = vws_client.add_target( + name="example_name", + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=not_base64_encoded_processable, + ) + + vws_client.wait_for_target_processed(target_id=target_id) + + query_results = cloud_reco_client.query(image=high_quality_image) + + (result,) = query_results + assert result.target_data is not None + query_metadata = result.target_data.application_metadata + mod_4_to_expected_metadata_original = { + 1: not_base64_encoded_processable[:-1], + 2: not_base64_encoded_processable + "==", + 3: not_base64_encoded_processable + "=", + } + expected_metadata_original = mod_4_to_expected_metadata_original[ + len(not_base64_encoded_processable) % 4 + ] + expected_metadata = base64.b64encode( + s=base64.b64decode(s=expected_metadata_original), + ) + assert query_metadata == expected_metadata.decode() diff --git a/contract-tests/src/vws_mock_contract_tests/query/test_target_status_failed.py b/contract-tests/src/vws_mock_contract_tests/query/test_target_status_failed.py new file mode 100644 index 000000000..4fb878872 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/query/test_target_status_failed.py @@ -0,0 +1,26 @@ +"""Tests for targets with the status "failed".""" + +import io +import uuid + +from vws import VWS, CloudRecoService + + +def test_status_failed( + *, + image_file_failed_state: io.BytesIO, + vws_client: VWS, + cloud_reco_client: CloudRecoService, +) -> None: + """Targets with the status "failed" are not found in query results.""" + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + vws_client.wait_for_target_processed(target_id=target_id) + + results = cloud_reco_client.query(image=image_file_failed_state) + assert results == [] diff --git a/contract-tests/src/vws_mock_contract_tests/query/test_truncated_body.py b/contract-tests/src/vws_mock_contract_tests/query/test_truncated_body.py new file mode 100644 index 000000000..d4e935ca3 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/query/test_truncated_body.py @@ -0,0 +1,220 @@ +"""Tests for bodies which end before the multipart body is complete. + +A client which is cut off mid-upload sends such a body. +""" + +import io +import json +from email.message import EmailMessage +from http import HTTPStatus + +from urllib3.filepost import encode_multipart_formdata + +from vws_mock_contract_tests.fixtures.credentials import ( + CloudDatabaseCredentials, +) +from vws_mock_contract_tests.query.helpers import ( + query_raw, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_query_success, + assert_vwq_failure, +) + + +def test_no_closing_boundary( + *, + high_quality_image: io.BytesIO, + vuforia_database: CloudDatabaseCredentials, + base_vwq_url: str, +) -> None: + """A body which has all of the image data but no closing boundary + is + treated as if the closing boundary were given. + """ + image_content = high_quality_image.getvalue() + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, content_type_header = encode_multipart_formdata(fields=body) + truncated = content[: content.rindex(b"\r\n--")] + + response = query_raw( + vuforia_database=vuforia_database, + content=truncated, + content_type_header=content_type_header, + base_vwq_url=base_vwq_url, + ) + + assert_query_success(response=response) + assert json.loads(s=response.text)["results"] == [] + + +def test_partial_closing_boundary( + *, + high_quality_image: io.BytesIO, + vuforia_database: CloudDatabaseCredentials, + base_vwq_url: str, +) -> None: + """A body which is cut off part-way through its closing boundary is + treated as if the closing boundary were given. + """ + image_content = high_quality_image.getvalue() + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, content_type_header = encode_multipart_formdata(fields=body) + truncated = content[: content.rindex(b"--")] + + response = query_raw( + vuforia_database=vuforia_database, + content=truncated, + content_type_header=content_type_header, + base_vwq_url=base_vwq_url, + ) + + assert_query_success(response=response) + assert json.loads(s=response.text)["results"] == [] + + +def test_truncated_at_end_of_header( + *, + high_quality_image: io.BytesIO, + vuforia_database: CloudDatabaseCredentials, + base_vwq_url: str, +) -> None: + """A body which is cut off at the end of a header of a part, before + the line ending of that header, gives a ``BadImage`` response, as + the + part has no image data. + """ + image_content = high_quality_image.getvalue() + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, content_type_header = encode_multipart_formdata(fields=body) + first_line_end = content.index(b"\r\n") + truncated = content[: content.index(b"\r\n", first_line_end + 2)] + + response = query_raw( + vuforia_database=vuforia_database, + content=truncated, + content_type_header=content_type_header, + base_vwq_url=base_vwq_url, + ) + + assert_vwq_failure( + response=response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + response_json = json.loads(s=response.text) + assert response_json["result_code"] == "BadImage" + + +def test_truncated_image_data( + *, + high_quality_image: io.BytesIO, + vuforia_database: CloudDatabaseCredentials, + base_vwq_url: str, +) -> None: + """A body which is cut off within the image data gives a + ``BadImage`` response. + """ + image_content = high_quality_image.getvalue() + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, content_type_header = encode_multipart_formdata(fields=body) + truncated = content[: content.index(b"\r\n\r\n") + 100] + + response = query_raw( + vuforia_database=vuforia_database, + content=truncated, + content_type_header=content_type_header, + base_vwq_url=base_vwq_url, + ) + + assert_vwq_failure( + response=response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + response_json = json.loads(s=response.text) + assert response_json["result_code"] == "BadImage" + + +def test_truncated_in_part_headers( + *, + high_quality_image: io.BytesIO, + vuforia_database: CloudDatabaseCredentials, + base_vwq_url: str, +) -> None: + """A body which is cut off within the headers of a part, so that no + ``Content-Disposition`` header is complete, gives a ``BAD_REQUEST`` + response. + """ + image_content = high_quality_image.getvalue() + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + content, content_type_header = encode_multipart_formdata(fields=body) + truncated = content[: content.index(b"\r\n") + 20] + + response = query_raw( + vuforia_database=vuforia_database, + content=truncated, + content_type_header=content_type_header, + base_vwq_url=base_vwq_url, + ) + + expected_text = "Could find no Content-Disposition header within part" + assert response.text == expected_text + assert_vwq_failure( + response=response, + status_code=HTTPStatus.BAD_REQUEST, + content_type="text/plain;charset=utf-8", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + + +def test_no_content_disposition_header( + *, + high_quality_image: io.BytesIO, + vuforia_database: CloudDatabaseCredentials, + base_vwq_url: str, +) -> None: + """A complete body with a part which has no ``Content-Disposition`` + header gives a ``BAD_REQUEST`` response. + """ + image_content = high_quality_image.getvalue() + body = {"image": ("image.jpeg", image_content, "image/jpeg")} + _, content_type_header = encode_multipart_formdata(fields=body) + email_message = EmailMessage() + email_message["Content-Type"] = content_type_header + boundary = email_message.get_boundary(failobj="").encode(encoding="utf-8") + without_content_disposition = ( + b"--" + + boundary + + b"\r\nContent-Type: image/jpeg\r\n\r\n" + + image_content + + b"\r\n--" + + boundary + + b"--\r\n" + ) + + response = query_raw( + vuforia_database=vuforia_database, + content=without_content_disposition, + content_type_header=content_type_header, + base_vwq_url=base_vwq_url, + ) + + expected_text = "Could find no Content-Disposition header within part" + assert response.text == expected_text + assert_vwq_failure( + response=response, + status_code=HTTPStatus.BAD_REQUEST, + content_type="text/plain;charset=utf-8", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) diff --git a/contract-tests/src/vws_mock_contract_tests/query/test_update.py b/contract-tests/src/vws_mock_contract_tests/query/test_update.py new file mode 100644 index 000000000..8ee5a35ff --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/query/test_update.py @@ -0,0 +1,79 @@ +"""Tests for updated targets.""" + +import base64 +import calendar +import io +import time + +from vws import VWS, CloudRecoService + + +def test_updated_target( + *, + high_quality_image: io.BytesIO, + different_high_quality_image: io.BytesIO, + vws_client: VWS, + cloud_reco_client: CloudRecoService, +) -> None: + """After a target is updated, only the new image can be matched. + + The match result includes the updated name, timestamp and + application metadata. + """ + metadata = b"example_metadata" + metadata_encoded = base64.b64encode(s=metadata).decode(encoding="ascii") + name = "example_name" + target_id = vws_client.add_target( + name=name, + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=metadata_encoded, + ) + + _ = calendar.timegm(tuple=time.gmtime()) + + vws_client.wait_for_target_processed(target_id=target_id) + + new_name = name + "2" + new_metadata = metadata + b"2" + new_metadata_encoded = base64.b64encode(s=new_metadata).decode( + encoding="ascii" + ) + + results = cloud_reco_client.query(image=high_quality_image) + (result,) = results + assert result.target_data is not None + original_target_timestamp = result.target_data.target_timestamp + + vws_client.update_target( + target_id=target_id, + name=new_name, + image=different_high_quality_image, + application_metadata=new_metadata_encoded, + ) + + approximate_target_updated = calendar.timegm(tuple=time.gmtime()) + + vws_client.wait_for_target_processed(target_id=target_id) + + results = cloud_reco_client.query(image=different_high_quality_image) + + (result,) = results + assert result.target_data is not None + assert result.target_data.application_metadata == new_metadata_encoded + assert result.target_data.name == new_name + target_timestamp = result.target_data.target_timestamp + # In the future we might want to test that + # target_timestamp > original_target_timestamp + # However, this requires us to set the mock processing time at > 1 + # second. + assert target_timestamp >= original_target_timestamp + time_difference = abs( + approximate_target_updated - target_timestamp.timestamp(), + ) + max_time_difference = 5 + assert time_difference < max_time_difference + + results = cloud_reco_client.query(image=high_quality_image) + assert results == [] diff --git a/tests/mock_vws/query/conftest.py b/tests/mock_vws/query/conftest.py index d409c8ebf..9cdf012c2 100644 --- a/tests/mock_vws/query/conftest.py +++ b/tests/mock_vws/query/conftest.py @@ -1,12 +1,7 @@ -"""Fixtures scoped to this group of tests.""" +"""Compatibility imports for the packaged contract suite.""" -import pytest +from vws_mock_contract_tests.query.conftest import ( + fixture_verify_backend, +) - -@pytest.fixture(autouse=True) -def fixture_verify_backend( - *, - verify_mock_vuforia: None, -) -> None: - """Set up the backend for every contract test in this directory.""" - del verify_mock_vuforia +__all__ = ["fixture_verify_backend"] diff --git a/tests/mock_vws/query/test_accept_header.py b/tests/mock_vws/query/test_accept_header.py index f6d79a771..10ffe8c92 100644 --- a/tests/mock_vws/query/test_accept_header.py +++ b/tests/mock_vws/query/test_accept_header.py @@ -1,153 +1,8 @@ -"""Tests for the ``Accept`` header.""" +"""Compatibility imports for the packaged contract suite.""" -import io -import json -from http import HTTPMethod, HTTPStatus -from urllib.parse import urljoin - -import pytest -import requests -from urllib3.filepost import encode_multipart_formdata -from vws.response import Response -from vws_auth_tools import authorization_header, rfc_1123_date - -from tests.mock_vws.fixtures.credentials import CloudDatabaseCredentials -from tests.mock_vws.utils.assertions import ( - assert_query_success, - assert_vwq_failure, +from vws_mock_contract_tests.query.test_accept_header import ( + test_invalid, + test_valid, ) -from tests.mock_vws.utils.too_many_requests import handle_server_errors - - -@pytest.mark.parametrize( - argnames="extra_headers", - argvalues=[ - { - "Accept": "application/json", - }, - {}, - ], -) -def test_valid( - *, - high_quality_image: io.BytesIO, - vuforia_database: CloudDatabaseCredentials, - extra_headers: dict[str, str], - base_vwq_url: str, -) -> None: - """An ``Accept`` header can be given iff its value is - "application/json". - """ - image_content = high_quality_image.getvalue() - date = rfc_1123_date() - request_path = "/v1/query" - body = {"image": ("image.jpeg", image_content, "image/jpeg")} - content, content_type_header = encode_multipart_formdata(fields=body) - method = HTTPMethod.POST - - access_key = vuforia_database.client_access_key - secret_key = vuforia_database.client_secret_key - authorization_string = authorization_header( - access_key=access_key, - secret_key=secret_key, - method=method, - content=content, - # Note that this is not the actual Content-Type header value sent. - content_type="multipart/form-data", - date=date, - request_path=request_path, - ) - headers = { - "Authorization": authorization_string, - "Date": date, - "Content-Type": content_type_header, - } | extra_headers - - requests_response = requests.request( - method=method, - url=urljoin(base=base_vwq_url, url=request_path), - headers=headers, - data=content, - timeout=30, - ) - - vws_response = Response( - text=requests_response.text, - url=requests_response.url, - status_code=requests_response.status_code, - headers=dict(requests_response.headers), - request_body=requests_response.request.body, - tell_position=requests_response.raw.tell(), - content=requests_response.content, - ) - handle_server_errors(response=vws_response) - assert_query_success(response=vws_response) - response_json = json.loads(s=requests_response.text) - assert response_json["results"] == [] - - -def test_invalid( - *, - high_quality_image: io.BytesIO, - vuforia_database: CloudDatabaseCredentials, - base_vwq_url: str, -) -> None: - """ - A NOT_ACCEPTABLE response is returned if an ``Accept`` header is - given - with a value which is not "application/json". - """ - image_content = high_quality_image.getvalue() - date = rfc_1123_date() - request_path = "/v1/query" - body = {"image": ("image.jpeg", image_content, "image/jpeg")} - content, content_type_header = encode_multipart_formdata(fields=body) - method = HTTPMethod.POST - - access_key = vuforia_database.client_access_key - secret_key = vuforia_database.client_secret_key - authorization_string = authorization_header( - access_key=access_key, - secret_key=secret_key, - method=method, - content=content, - # Note that this is not the actual Content-Type header value sent. - content_type="multipart/form-data", - date=date, - request_path=request_path, - ) - - headers = { - "Authorization": authorization_string, - "Date": date, - "Content-Type": content_type_header, - "Accept": "text/html", - } - - requests_response = requests.request( - method=method, - url=urljoin(base=base_vwq_url, url=request_path), - headers=headers, - data=content, - timeout=30, - ) - - vws_response = Response( - text=requests_response.text, - url=requests_response.url, - status_code=requests_response.status_code, - headers=dict(requests_response.headers), - request_body=requests_response.request.body, - tell_position=requests_response.raw.tell(), - content=requests_response.content, - ) - handle_server_errors(response=vws_response) - assert_vwq_failure( - response=vws_response, - status_code=HTTPStatus.NOT_ACCEPTABLE, - content_type=None, - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) +__all__ = ["test_invalid", "test_valid"] diff --git a/tests/mock_vws/query/test_active_flag.py b/tests/mock_vws/query/test_active_flag.py index 0a49c538d..6eb2b8ee5 100644 --- a/tests/mock_vws/query/test_active_flag.py +++ b/tests/mock_vws/query/test_active_flag.py @@ -1,26 +1,7 @@ -"""Tests for active versus inactive targets.""" +"""Compatibility imports for the packaged contract suite.""" -import io -import uuid +from vws_mock_contract_tests.query.test_active_flag import ( + test_inactive, +) -from vws import VWS, CloudRecoService - - -def test_inactive( - *, - high_quality_image: io.BytesIO, - vws_client: VWS, - cloud_reco_client: CloudRecoService, -) -> None: - """Images which are not active are not matched.""" - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=high_quality_image, - active_flag=False, - application_metadata=None, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - results = cloud_reco_client.query(image=high_quality_image) - assert results == [] +__all__ = ["test_inactive"] diff --git a/tests/mock_vws/query/test_bad_image.py b/tests/mock_vws/query/test_bad_image.py index 491671fe0..8e9c6ecd4 100644 --- a/tests/mock_vws/query/test_bad_image.py +++ b/tests/mock_vws/query/test_bad_image.py @@ -1,76 +1,8 @@ -"""Tests for bad images.""" +"""Compatibility imports for the packaged contract suite.""" -import io -import json -from http import HTTPStatus - -import pytest -from vws import CloudRecoService -from vws.exceptions.cloud_reco_exceptions import ( - BadImageError, -) - -from tests.mock_vws.utils.assertions import ( - assert_valid_transaction_id, - assert_vwq_failure, +from vws_mock_contract_tests.query.test_bad_image import ( + test_corrupted, + test_not_image, ) - -def test_corrupted( - *, - corrupted_image_file: io.BytesIO, - cloud_reco_client: CloudRecoService, -) -> None: - """A ``BadImage`` response is returned when a corrupted image is - given. - """ - with pytest.raises(expected_exception=BadImageError) as exc_info: - _ = cloud_reco_client.query(image=corrupted_image_file) - - response = exc_info.value.response - - assert_vwq_failure( - response=response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - content_type="application/json", - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) - - -def test_not_image(cloud_reco_client: CloudRecoService) -> None: - """ - An ``UNPROCESSABLE_ENTITY`` response is returned when a non- - image is - given. - """ - not_image_data = b"not_image_data" - - with pytest.raises(expected_exception=BadImageError) as exc_info: - _ = cloud_reco_client.query( - image=io.BytesIO(initial_bytes=not_image_data) - ) - - response = exc_info.value.response - - assert_vwq_failure( - response=response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - content_type="application/json", - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) - response_json = json.loads(s=response.text) - assert isinstance(response_json, dict) - assert response_json.keys() == {"transaction_id", "result_code"} - assert_valid_transaction_id(response=response) - # The separators are inconsistent and we test this. - expected_text = ( - '{"transaction_id": ' - f'"{response_json["transaction_id"]}",' - f'"result_code":"BadImage"' - "}" - ) - assert response.text == expected_text +__all__ = ["test_corrupted", "test_not_image"] diff --git a/tests/mock_vws/query/test_content_type.py b/tests/mock_vws/query/test_content_type.py index 69447c8ff..f4896c24d 100644 --- a/tests/mock_vws/query/test_content_type.py +++ b/tests/mock_vws/query/test_content_type.py @@ -1,408 +1,17 @@ -"""Tests for the Content-Type header.""" - -import io -import json -from http import HTTPMethod, HTTPStatus -from urllib.parse import urljoin - -import pytest -import requests -from urllib3.filepost import encode_multipart_formdata -from vws.response import Response -from vws_auth_tools import authorization_header, rfc_1123_date - -from tests.mock_vws.fixtures.credentials import CloudDatabaseCredentials -from tests.mock_vws.query.helpers import ( - JETTY_CONTENT_TYPE_ERROR, - JETTY_VERSION_RE, -) -from tests.mock_vws.utils.assertions import ( - assert_query_success, - assert_vwq_failure, -) -from tests.mock_vws.utils.too_many_requests import handle_server_errors - - -@pytest.mark.parametrize( - argnames=( - "content_type", - "resp_status_code", - "resp_content_type", - "resp_cache_control", - "resp_text", - ), - argvalues=[ - ( - "text/html", - HTTPStatus.UNSUPPORTED_MEDIA_TYPE, - None, - None, - "", - ), - ( - "", - HTTPStatus.BAD_REQUEST, - "text/html;charset=iso-8859-1", - "must-revalidate,no-cache,no-store", - JETTY_CONTENT_TYPE_ERROR, - ), - ( - "*/*", - HTTPStatus.BAD_REQUEST, - "text/plain;charset=utf-8", - None, - "Unable to get boundary for multipart", - ), - ( - "text/*", - HTTPStatus.UNSUPPORTED_MEDIA_TYPE, - None, - None, - "", - ), - ( - "text/plain", - HTTPStatus.UNSUPPORTED_MEDIA_TYPE, - None, - None, - "", - ), - ], -) -def test_incorrect_no_boundary( - *, - high_quality_image: io.BytesIO, - vuforia_database: CloudDatabaseCredentials, - content_type: str, - resp_status_code: int, - resp_content_type: str | None, - resp_cache_control: str | None, - resp_text: str, - base_vwq_url: str, -) -> None: - """With bad Content-Type headers we get a variety of results.""" - image_content = high_quality_image.getvalue() - date = rfc_1123_date() - request_path = "/v1/query" - body = {"image": ("image.jpeg", image_content, "image/jpeg")} - content, _ = encode_multipart_formdata(fields=body) - method = HTTPMethod.POST - - access_key = vuforia_database.client_access_key - secret_key = vuforia_database.client_secret_key - authorization_string = authorization_header( - access_key=access_key, - secret_key=secret_key, - method=method, - content=content, - content_type=content_type, - date=date, - request_path=request_path, - ) - - headers = { - "Authorization": authorization_string, - "Date": date, - "Content-Type": content_type, - } - - requests_response = requests.request( - method=method, - url=urljoin(base=base_vwq_url, url=request_path), - headers=headers, - data=content, - timeout=30, - ) - - vws_response = Response( - text=requests_response.text, - url=requests_response.url, - status_code=requests_response.status_code, - headers=dict(requests_response.headers), - request_body=requests_response.request.body, - tell_position=requests_response.raw.tell(), - content=requests_response.content, - ) - - handle_server_errors(response=vws_response) - - repl = "Powered by Jetty://" - sub = JETTY_VERSION_RE.sub - actual = sub(repl=repl, string=requests_response.text) - expected = sub(repl=repl, string=resp_text) - assert actual == expected - assert_vwq_failure( - response=vws_response, - status_code=resp_status_code, - content_type=resp_content_type, - cache_control=resp_cache_control, - www_authenticate=None, - connection="keep-alive", - ) - - -def test_incorrect_with_boundary( - *, - high_quality_image: io.BytesIO, - vuforia_database: CloudDatabaseCredentials, - base_vwq_url: str, -) -> None: - """ - If a Content-Type header which is not ``multipart/form-data`` is - given - with the correct boundary, an ``UNSUPPORTED_MEDIA_TYPE`` response - is - given. - """ - image_content = high_quality_image.getvalue() - date = rfc_1123_date() - request_path = "/v1/query" - body = {"image": ("image.jpeg", image_content, "image/jpeg")} - content, content_type_header = encode_multipart_formdata(fields=body) - method = HTTPMethod.POST - - content_type = "text/html" - - access_key = vuforia_database.client_access_key - secret_key = vuforia_database.client_secret_key - authorization_string = authorization_header( - access_key=access_key, - secret_key=secret_key, - method=method, - content=content, - content_type=content_type, - date=date, - request_path=request_path, - ) - - _, boundary = content_type_header.split(sep=";") - - content_type = "text/html; " + boundary - headers = { - "Authorization": authorization_string, - "Date": date, - "Content-Type": content_type, - } - - requests_response = requests.request( - method=method, - url=urljoin(base=base_vwq_url, url=request_path), - headers=headers, - data=content, - timeout=30, - ) - - vws_response = Response( - text=requests_response.text, - url=requests_response.url, - status_code=requests_response.status_code, - headers=dict(requests_response.headers), - request_body=requests_response.request.body, - tell_position=requests_response.raw.tell(), - content=requests_response.content, - ) - handle_server_errors(response=vws_response) - assert not bool(requests_response.text) - assert_vwq_failure( - response=vws_response, - status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE, - content_type=None, - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) - - -@pytest.mark.parametrize( - argnames="content_type", - argvalues=[ - "multipart/form-data", - "multipart/form-data; extra", - "multipart/form-data; extra=1", - ], +"""Compatibility imports for the packaged contract suite.""" + +from vws_mock_contract_tests.query.test_content_type import ( + test_bogus_boundary, + test_extra_section, + test_incorrect_no_boundary, + test_incorrect_with_boundary, + test_no_boundary, ) -def test_no_boundary( - *, - high_quality_image: io.BytesIO, - vuforia_database: CloudDatabaseCredentials, - content_type: str, - base_vwq_url: str, -) -> None: - """If no boundary is given, a ``BAD_REQUEST`` is returned.""" - image_content = high_quality_image.getvalue() - date = rfc_1123_date() - request_path = "/v1/query" - body = {"image": ("image.jpeg", image_content, "image/jpeg")} - content, _ = encode_multipart_formdata(fields=body) - method = HTTPMethod.POST - - access_key = vuforia_database.client_access_key - secret_key = vuforia_database.client_secret_key - authorization_string = authorization_header( - access_key=access_key, - secret_key=secret_key, - method=method, - content=content, - # Note that this is not the actual Content-Type header value sent. - content_type="multipart/form-data", - date=date, - request_path=request_path, - ) - - headers = { - "Authorization": authorization_string, - "Date": date, - "Content-Type": content_type, - } - - requests_response = requests.request( - method=method, - url=urljoin(base=base_vwq_url, url=request_path), - headers=headers, - data=content, - timeout=30, - ) - - vws_response = Response( - text=requests_response.text, - url=requests_response.url, - status_code=requests_response.status_code, - headers=dict(requests_response.headers), - request_body=requests_response.request.body, - tell_position=requests_response.raw.tell(), - content=requests_response.content, - ) - expected_text = "Unable to get boundary for multipart" - assert requests_response.text == expected_text - assert_vwq_failure( - response=vws_response, - status_code=HTTPStatus.BAD_REQUEST, - content_type="text/plain;charset=utf-8", - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) - - -def test_bogus_boundary( - *, - high_quality_image: io.BytesIO, - vuforia_database: CloudDatabaseCredentials, - base_vwq_url: str, -) -> None: - """If a bogus boundary is given, a ``BAD_REQUEST`` is returned.""" - image_content = high_quality_image.getvalue() - date = rfc_1123_date() - request_path = "/v1/query" - body = {"image": ("image.jpeg", image_content, "image/jpeg")} - content, _ = encode_multipart_formdata(fields=body) - method = HTTPMethod.POST - - access_key = vuforia_database.client_access_key - secret_key = vuforia_database.client_secret_key - authorization_string = authorization_header( - access_key=access_key, - secret_key=secret_key, - method=method, - content=content, - # Note that this is not the actual Content-Type header value sent. - content_type="multipart/form-data", - date=date, - request_path=request_path, - ) - - headers = { - "Authorization": authorization_string, - "Date": date, - "Content-Type": "multipart/form-data; boundary=example_boundary", - } - - requests_response = requests.request( - method=method, - url=urljoin(base=base_vwq_url, url=request_path), - headers=headers, - data=content, - timeout=30, - ) - - vws_response = Response( - text=requests_response.text, - url=requests_response.url, - status_code=requests_response.status_code, - headers=dict(requests_response.headers), - request_body=requests_response.request.body, - tell_position=requests_response.raw.tell(), - content=requests_response.content, - ) - handle_server_errors(response=vws_response) - - expected_text = "No image." - assert requests_response.text == expected_text - assert_vwq_failure( - response=vws_response, - status_code=HTTPStatus.BAD_REQUEST, - content_type="application/json", - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) - - -def test_extra_section( - *, - high_quality_image: io.BytesIO, - vuforia_database: CloudDatabaseCredentials, - base_vwq_url: str, -) -> None: - """ - If sections that are not the boundary section are given in the - header, - that is fine. - """ - image_content = high_quality_image.getvalue() - date = rfc_1123_date() - request_path = "/v1/query" - body = {"image": ("image.jpeg", image_content, "image/jpeg")} - content, content_type_header = encode_multipart_formdata(fields=body) - method = HTTPMethod.POST - - access_key = vuforia_database.client_access_key - secret_key = vuforia_database.client_secret_key - authorization_string = authorization_header( - access_key=access_key, - secret_key=secret_key, - method=method, - content=content, - # Note that this is not the actual Content-Type header value sent. - content_type="multipart/form-data", - date=date, - request_path=request_path, - ) - - headers = { - "Authorization": authorization_string, - "Date": date, - "Content-Type": content_type_header + "; extra=1", - } - - requests_response = requests.request( - method=method, - url=urljoin(base=base_vwq_url, url=request_path), - headers=headers, - data=content, - timeout=30, - ) - vws_response = Response( - text=requests_response.text, - url=requests_response.url, - status_code=requests_response.status_code, - headers=dict(requests_response.headers), - request_body=requests_response.request.body, - tell_position=requests_response.raw.tell(), - content=requests_response.content, - ) - handle_server_errors(response=vws_response) - assert_query_success(response=vws_response) - response_json = json.loads(s=requests_response.text) - assert response_json["results"] == [] +__all__ = [ + "test_bogus_boundary", + "test_extra_section", + "test_incorrect_no_boundary", + "test_incorrect_with_boundary", + "test_no_boundary", +] diff --git a/tests/mock_vws/query/test_date_formats.py b/tests/mock_vws/query/test_date_formats.py index aa1169162..ab58a3365 100644 --- a/tests/mock_vws/query/test_date_formats.py +++ b/tests/mock_vws/query/test_date_formats.py @@ -1,108 +1,7 @@ -"""Tests for various date formats. +"""Compatibility imports for the packaged contract suite.""" -The date format for the VWS API as per -https://library.vuforia.com/articles/Training/Using-the-VWS-API.html must -be in the rfc1123-date format. - -However, for the query endpoint, the documentation does not mention the -format. It says: - -> The data format must exactly match the Date that is sent in the `Date` -> header. -""" - -import datetime as dt -import io -import json -from http import HTTPMethod -from urllib.parse import urljoin -from zoneinfo import ZoneInfo - -import pytest -import requests -from urllib3.filepost import encode_multipart_formdata -from vws.response import Response -from vws_auth_tools import authorization_header - -from tests.mock_vws.fixtures.credentials import CloudDatabaseCredentials -from tests.mock_vws.utils.assertions import ( - assert_query_success, -) -from tests.mock_vws.utils.too_many_requests import handle_server_errors - - -@pytest.mark.parametrize( - argnames="datetime_format", - argvalues=[ - "%a, %b %d %H:%M:%S %Y", - "%a %b %d %H:%M:%S %Y", - "%a, %d %b %Y %H:%M:%S", - "%a %d %b %Y %H:%M:%S", - ], +from vws_mock_contract_tests.query.test_date_formats import ( + test_date_formats, ) -@pytest.mark.parametrize(argnames="include_tz", argvalues=[True, False]) -def test_date_formats( - *, - high_quality_image: io.BytesIO, - vuforia_database: CloudDatabaseCredentials, - datetime_format: str, - include_tz: bool, - base_vwq_url: str, -) -> None: - """Test various date formats which are known to be accepted. - - We expect that more formats than this will be accepted. These - are the accepted ones we know of at the time of writing. - """ - image_content = high_quality_image.getvalue() - body = {"image": ("image.jpeg", image_content, "image/jpeg")} - - if include_tz: - datetime_format += " GMT" - - gmt = ZoneInfo(key="GMT") - now = dt.datetime.now(tz=gmt) - date = now.strftime(format=datetime_format) - request_path = "/v1/query" - content, content_type_header = encode_multipart_formdata(fields=body) - method = HTTPMethod.POST - - access_key = vuforia_database.client_access_key - secret_key = vuforia_database.client_secret_key - authorization_string = authorization_header( - access_key=access_key, - secret_key=secret_key, - method=method, - content=content, - content_type="multipart/form-data", - date=date, - request_path=request_path, - ) - - headers = { - "Authorization": authorization_string, - "Date": date, - "Content-Type": content_type_header, - } - - requests_response = requests.request( - method=method, - url=urljoin(base=base_vwq_url, url=request_path), - headers=headers, - data=content, - timeout=30, - ) - vws_response = Response( - text=requests_response.text, - url=requests_response.url, - status_code=requests_response.status_code, - headers=dict(requests_response.headers), - request_body=requests_response.request.body, - tell_position=requests_response.raw.tell(), - content=requests_response.content, - ) - handle_server_errors(response=vws_response) - assert_query_success(response=vws_response) - response_json = json.loads(s=requests_response.text) - assert response_json["results"] == [] +__all__ = ["test_date_formats"] diff --git a/tests/mock_vws/query/test_deleted.py b/tests/mock_vws/query/test_deleted.py index b48890525..9b2197e9b 100644 --- a/tests/mock_vws/query/test_deleted.py +++ b/tests/mock_vws/query/test_deleted.py @@ -1,73 +1,8 @@ -"""Tests for matching deleted targets.""" +"""Compatibility imports for the packaged contract suite.""" -import io -import uuid +from vws_mock_contract_tests.query.test_deleted import ( + test_deleted_active, + test_deleted_inactive, +) -from tenacity import Retrying -from tenacity.retry import retry_if_exception_type -from tenacity.stop import stop_after_delay -from tenacity.wait import wait_fixed -from vws import VWS, CloudRecoService - -from tests.mock_vws.utils.retries import TRANSIENT_VWS_EXCEPTIONS - - -def test_deleted_active( - *, - high_quality_image: io.BytesIO, - vws_client: VWS, - cloud_reco_client: CloudRecoService, -) -> None: - """Deleted targets are not matched.""" - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.delete_target(target_id=target_id) - - # There is a race condition here. - # In the real Vuforia, it takes some time for the target deletion - # to be reflected in the query endpoint. - # - # This difference is documented in ``differences-to-vws.rst``. - # - # We retry to allow for this difference. - for attempt in Retrying( - wait=wait_fixed(wait=0.1), - stop=stop_after_delay(max_delay=3), - retry=retry_if_exception_type( - exception_types=(AssertionError, *TRANSIENT_VWS_EXCEPTIONS), - ), - reraise=True, - ): - with attempt: - results = cloud_reco_client.query(image=high_quality_image) - assert results == [] - - -def test_deleted_inactive( - *, - high_quality_image: io.BytesIO, - vws_client: VWS, - cloud_reco_client: CloudRecoService, -) -> None: - """ - No error is returned when querying for an image of recently - deleted, - inactive target. - """ - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=high_quality_image, - active_flag=False, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - vws_client.delete_target(target_id=target_id) - results = cloud_reco_client.query(image=high_quality_image) - assert results == [] +__all__ = ["test_deleted_active", "test_deleted_inactive"] diff --git a/tests/mock_vws/query/test_image_formats.py b/tests/mock_vws/query/test_image_formats.py index 26cac59ac..fd5061a5b 100644 --- a/tests/mock_vws/query/test_image_formats.py +++ b/tests/mock_vws/query/test_image_formats.py @@ -1,76 +1,8 @@ -"""Tests for various image formats.""" +"""Compatibility imports for the packaged contract suite.""" -import io -import json -from http import HTTPStatus - -import pytest -from PIL import Image -from vws import CloudRecoService -from vws.exceptions.cloud_reco_exceptions import ( - BadImageError, -) - -from tests.mock_vws.utils.assertions import ( - assert_valid_transaction_id, - assert_vwq_failure, +from vws_mock_contract_tests.query.test_image_formats import ( + test_supported, + test_unsupported, ) - -@pytest.mark.parametrize(argnames="file_format", argvalues=["png", "jpeg"]) -def test_supported( - *, - high_quality_image: io.BytesIO, - file_format: str, - cloud_reco_client: CloudRecoService, -) -> None: - """PNG and JPEG formats are supported.""" - image_buffer = io.BytesIO() - pil_image = Image.open(fp=high_quality_image) - pil_image.save(fp=image_buffer, format=file_format) - image_content = image_buffer.getvalue() - results = cloud_reco_client.query( - image=io.BytesIO(initial_bytes=image_content) - ) - assert results == [] - - -def test_unsupported( - *, - high_quality_image: io.BytesIO, - cloud_reco_client: CloudRecoService, -) -> None: - """File formats which are not PNG or JPEG are not supported.""" - file_format = "tiff" - image_buffer = io.BytesIO() - pil_image = Image.open(fp=high_quality_image) - pil_image.save(fp=image_buffer, format=file_format) - image_content = image_buffer.getvalue() - - with pytest.raises(expected_exception=BadImageError) as exc_info: - _ = cloud_reco_client.query( - image=io.BytesIO(initial_bytes=image_content) - ) - - response = exc_info.value.response - - assert_vwq_failure( - response=response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - content_type="application/json", - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) - response_json = json.loads(s=response.text) - assert isinstance(response_json, dict) - assert response_json.keys() == {"transaction_id", "result_code"} - assert_valid_transaction_id(response=response) - # The separators are inconsistent and we test this. - expected_text = ( - '{"transaction_id": ' - f'"{response_json["transaction_id"]}",' - f'"result_code":"BadImage"' - "}" - ) - assert response.text == expected_text +__all__ = ["test_supported", "test_unsupported"] diff --git a/tests/mock_vws/query/test_inactive_project.py b/tests/mock_vws/query/test_inactive_project.py index b433acfe0..e2cc25483 100644 --- a/tests/mock_vws/query/test_inactive_project.py +++ b/tests/mock_vws/query/test_inactive_project.py @@ -1,54 +1,7 @@ -"""Tests for inactive projects.""" +"""Compatibility imports for the packaged contract suite.""" -import io -import json -from http import HTTPStatus - -import pytest -from vws import CloudRecoService -from vws.exceptions.cloud_reco_exceptions import ( - InactiveProjectError, -) - -from tests.mock_vws.utils.assertions import ( - assert_valid_transaction_id, - assert_vwq_failure, +from vws_mock_contract_tests.query.test_inactive_project import ( + test_inactive_project, ) - -def test_inactive_project( - *, - high_quality_image: io.BytesIO, - inactive_cloud_reco_client: CloudRecoService, -) -> None: - """ - If the project is inactive, a FORBIDDEN response is - returned. - """ - with pytest.raises(expected_exception=InactiveProjectError) as exc_info: - _ = inactive_cloud_reco_client.query(image=high_quality_image) - - response = exc_info.value.response - - assert_vwq_failure( - response=response, - status_code=HTTPStatus.FORBIDDEN, - content_type="application/json", - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) - - response_json = json.loads(s=response.text) - assert isinstance(response_json, dict) - - assert response_json.keys() == {"transaction_id", "result_code"} - assert_valid_transaction_id(response=response) - # The separators are inconsistent and we test this. - expected_text = ( - '{"transaction_id": ' - f'"{response_json["transaction_id"]}",' - f'"result_code":"InactiveProject"' - "}" - ) - assert response.text == expected_text +__all__ = ["test_inactive_project"] diff --git a/tests/mock_vws/query/test_include_target_data.py b/tests/mock_vws/query/test_include_target_data.py index 4ccb95728..9a828730f 100644 --- a/tests/mock_vws/query/test_include_target_data.py +++ b/tests/mock_vws/query/test_include_target_data.py @@ -1,210 +1,17 @@ -"""Tests for the ``include_target_data`` parameter.""" - -import io -import json -from http import HTTPStatus - -import pytest -from vws import VWS - -from tests.mock_vws.fixtures.credentials import CloudDatabaseCredentials -from tests.mock_vws.query.helpers import ( - add_and_wait_for_targets, - query, -) -from tests.mock_vws.utils.assertions import ( - assert_query_success, - assert_vwq_failure, -) - - -def test_default( - *, - high_quality_image: io.BytesIO, - vws_client: VWS, - vuforia_database: CloudDatabaseCredentials, - base_vwq_url: str, -) -> None: - """The default ``include_target_data`` is 'top'.""" - add_and_wait_for_targets( - image=high_quality_image, - vws_client=vws_client, - num_targets=2, - ) - image_content = high_quality_image.getvalue() - body = { - "image": ("image.jpeg", image_content, "image/jpeg"), - "max_num_results": (None, 2, "text/plain"), - } - - response = query( - vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url - ) - - assert_query_success(response=response) - response_json = json.loads(s=response.text) - result_1, result_2 = response_json["results"] - assert "target_data" in result_1 - assert "target_data" not in result_2 - - -@pytest.mark.parametrize( - argnames="include_target_data", - argvalues=["top", "TOP"], -) -def test_top( - *, - high_quality_image: io.BytesIO, - vuforia_database: CloudDatabaseCredentials, - include_target_data: str, - vws_client: VWS, - base_vwq_url: str, -) -> None: - """ - When ``include_target_data`` is set to "top" (case insensitive), - only - the first result includes target data. - """ - add_and_wait_for_targets( - image=high_quality_image, - vws_client=vws_client, - num_targets=2, - ) - image_content = high_quality_image.getvalue() - body = { - "image": ("image.jpeg", image_content, "image/jpeg"), - "include_target_data": (None, include_target_data, "text/plain"), - "max_num_results": (None, 2, "text/plain"), - } - - response = query( - vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url - ) - - assert_query_success(response=response) - response_json = json.loads(s=response.text) - result_1, result_2 = response_json["results"] - assert "target_data" in result_1 - assert "target_data" not in result_2 - - -@pytest.mark.parametrize( - argnames="include_target_data", - argvalues=["none", "NONE"], -) -def test_none( - *, - high_quality_image: io.BytesIO, - vuforia_database: CloudDatabaseCredentials, - include_target_data: str, - vws_client: VWS, - base_vwq_url: str, -) -> None: - """ - When ``include_target_data`` is set to "none" (case - insensitive), no - results include target data. - """ - add_and_wait_for_targets( - image=high_quality_image, - vws_client=vws_client, - num_targets=2, - ) - image_content = high_quality_image.getvalue() - body = { - "image": ("image.jpeg", image_content, "image/jpeg"), - "include_target_data": (None, include_target_data, "text/plain"), - "max_num_results": (None, 2, "text/plain"), - } - - response = query( - vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url - ) - - assert_query_success(response=response) - response_json = json.loads(s=response.text) - result_1, result_2 = response_json["results"] - assert "target_data" not in result_1 - assert "target_data" not in result_2 - - -@pytest.mark.parametrize( - argnames="include_target_data", - argvalues=["all", "ALL"], -) -def test_all( - *, - high_quality_image: io.BytesIO, - vuforia_database: CloudDatabaseCredentials, - include_target_data: str, - vws_client: VWS, - base_vwq_url: str, -) -> None: - """ - When ``include_target_data`` is set to "all" (case insensitive), - all - results include target data. - """ - add_and_wait_for_targets( - image=high_quality_image, - vws_client=vws_client, - num_targets=2, - ) - image_content = high_quality_image.getvalue() - body = { - "image": ("image.jpeg", image_content, "image/jpeg"), - "include_target_data": (None, include_target_data, "text/plain"), - "max_num_results": (None, 2, "text/plain"), - } - - response = query( - vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url - ) - - assert_query_success(response=response) - response_json = json.loads(s=response.text) - result_1, result_2 = response_json["results"] - assert "target_data" in result_1 - assert "target_data" in result_2 - - -@pytest.mark.parametrize( - argnames="include_target_data", - argvalues=["a", True, 0], +"""Compatibility imports for the packaged contract suite.""" + +from vws_mock_contract_tests.query.test_include_target_data import ( + test_all, + test_default, + test_invalid_value, + test_none, + test_top, ) -def test_invalid_value( - *, - high_quality_image: io.BytesIO, - vuforia_database: CloudDatabaseCredentials, - include_target_data: str | bool | int, - base_vwq_url: str, -) -> None: - """ - A ``BAD_REQUEST`` error is given when a string that is not one - of - 'none', 'top' or 'all' (case insensitive). - """ - image_content = high_quality_image.getvalue() - body = { - "image": ("image.jpeg", image_content, "image/jpeg"), - "include_target_data": (None, include_target_data, "text/plain"), - } - response = query( - vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url - ) - expected_text = ( - f"Invalid value '{str(object=include_target_data).lower()}' in " - "form data part 'include_target_data'. " - "Expecting one of the (unquoted) string values 'all', 'none' or " - "'top'." - ) - assert response.text == expected_text - assert_vwq_failure( - response=response, - status_code=HTTPStatus.BAD_REQUEST, - content_type="application/json", - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) +__all__ = [ + "test_all", + "test_default", + "test_invalid_value", + "test_none", + "test_top", +] diff --git a/tests/mock_vws/query/test_incorrect_fields.py b/tests/mock_vws/query/test_incorrect_fields.py index 55ca85cf0..f8d38e808 100644 --- a/tests/mock_vws/query/test_incorrect_fields.py +++ b/tests/mock_vws/query/test_incorrect_fields.py @@ -1,96 +1,13 @@ -"""Tests for incorrect and unexpected fields.""" +"""Compatibility imports for the packaged contract suite.""" -import io -from http import HTTPStatus - -from tests.mock_vws.fixtures.credentials import CloudDatabaseCredentials -from tests.mock_vws.query.helpers import ( - query, -) -from tests.mock_vws.utils.assertions import ( - assert_vwq_failure, +from vws_mock_contract_tests.query.test_incorrect_fields import ( + test_extra_fields, + test_missing_image, + test_missing_image_and_extra_fields, ) - -def test_missing_image( - vuforia_database: CloudDatabaseCredentials, - base_vwq_url: str, -) -> None: - """ - If an image is not given, a ``BAD_REQUEST`` response is - returned. - """ - response = query( - vuforia_database=vuforia_database, body={}, base_vwq_url=base_vwq_url - ) - - assert response.text == "No image." - assert_vwq_failure( - response=response, - status_code=HTTPStatus.BAD_REQUEST, - content_type="application/json", - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) - - -def test_extra_fields( - *, - high_quality_image: io.BytesIO, - vuforia_database: CloudDatabaseCredentials, - base_vwq_url: str, -) -> None: - """If extra fields are given, a ``BAD_REQUEST`` response is - returned. - - The Query API documentation says that unknown fields are ignored, - but the real Query API rejects them. - """ - image_content = high_quality_image.getvalue() - body = { - "image": ("image.jpeg", image_content, "image/jpeg"), - "extra_field": (None, 1, "text/plain"), - } - - response = query( - vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url - ) - - assert response.text == "Unknown parameters in the request." - assert_vwq_failure( - response=response, - content_type="application/json", - status_code=HTTPStatus.BAD_REQUEST, - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) - - -def test_missing_image_and_extra_fields( - vuforia_database: CloudDatabaseCredentials, - base_vwq_url: str, -) -> None: - """If extra fields are given and no image field is given, a - ``BAD_REQUEST`` response is returned. - - The extra field error takes precedence. - """ - body = { - "extra_field": (None, 1, "text/plain"), - } - - response = query( - vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url - ) - - assert response.text == "Unknown parameters in the request." - assert_vwq_failure( - response=response, - content_type="application/json", - status_code=HTTPStatus.BAD_REQUEST, - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) +__all__ = [ + "test_extra_fields", + "test_missing_image", + "test_missing_image_and_extra_fields", +] diff --git a/tests/mock_vws/query/test_max_num_results.py b/tests/mock_vws/query/test_max_num_results.py index 7eafc1de2..137b2f8f3 100644 --- a/tests/mock_vws/query/test_max_num_results.py +++ b/tests/mock_vws/query/test_max_num_results.py @@ -1,206 +1,17 @@ -"""Tests for the ``max_num_results`` parameter.""" - -import io -import json -import uuid -from http import HTTPStatus - -import pytest -from pydantic import TypeAdapter -from vws import VWS, CloudRecoService -from vws.exceptions.cloud_reco_exceptions import ( - MaxNumResultsOutOfRangeError, -) - -from tests.mock_vws.fixtures.credentials import CloudDatabaseCredentials -from tests.mock_vws.query.helpers import ( - add_and_wait_for_targets, - query, -) -from tests.mock_vws.utils.assertions import ( - assert_query_success, - assert_vwq_failure, -) - - -def test_default( - *, - high_quality_image: io.BytesIO, - vuforia_database: CloudDatabaseCredentials, - vws_client: VWS, - base_vwq_url: str, -) -> None: - """The default ``max_num_results`` is 1.""" - image_content = high_quality_image.getvalue() - - target_id_1 = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - target_id_2 = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id_1) - vws_client.wait_for_target_processed(target_id=target_id_2) - - body = { - "image": ("image.jpeg", image_content, "image/jpeg"), - } - - response = query( - vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url - ) - - assert_query_success(response=response) - response_json = json.loads(s=response.text) - results_value: object = response_json["results"] - results = TypeAdapter(type=list[object]).validate_python( - results_value, - ) - assert len(results) == 1 - - -@pytest.mark.parametrize(argnames="num_results", argvalues=[1, b"1", 50]) -def test_valid_accepted( - *, - high_quality_image: io.BytesIO, - vuforia_database: CloudDatabaseCredentials, - num_results: int | bytes, - base_vwq_url: str, -) -> None: - """Numbers between 1 and 50 are valid inputs. - - We assert that the response is a success, but not that the - maximum number of results is enforced. - - This is because uploading 50 images would be very slow. - - The documentation at - https://developer.vuforia.com/library/web-api/vuforia-query-web-api - states that this must be between 1 and 10, but in practice, 50 is the - maximum. - """ - image_content = high_quality_image.getvalue() - body = { - "image": ("image.jpeg", image_content, "image/jpeg"), - "max_num_results": (None, num_results, "text/plain"), - } - - response = query( - vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url - ) - - assert_query_success(response=response) - response_json = json.loads(s=response.text) - assert response_json["results"] == [] - - -def test_valid_works( - *, - high_quality_image: io.BytesIO, - vws_client: VWS, - cloud_reco_client: CloudRecoService, -) -> None: - """A maximum of ``max_num_results`` results are returned.""" - add_and_wait_for_targets( - image=high_quality_image, - vws_client=vws_client, - num_targets=3, - ) - - max_num_results = 2 - - result = cloud_reco_client.query( - image=high_quality_image, - max_num_results=max_num_results, - ) - assert len(result) == max_num_results - - -@pytest.mark.parametrize(argnames="num_results", argvalues=[-1, 0, 51]) -def test_out_of_range( - *, - high_quality_image: io.BytesIO, - num_results: int, - cloud_reco_client: CloudRecoService, -) -> None: - """An error is returned if ``max_num_results`` is given as an - integer - out of the range (1, 50). - - The documentation at - https://developer.vuforia.com/library/web-api/vuforia-query-web-api. - states that this must be between 1 and 10, but in practice, 50 is the - maximum. - """ - with pytest.raises( - expected_exception=MaxNumResultsOutOfRangeError, - ) as exc_info: - _ = cloud_reco_client.query( - image=high_quality_image, - max_num_results=num_results, - ) - - expected_text = ( - f"Integer out of range ({num_results}) in form data part " - "'max_result'. Accepted range is from 1 to 50 (inclusive)." - ) - assert exc_info.value.response.text == expected_text - assert_vwq_failure( - response=exc_info.value.response, - content_type="application/json", - status_code=HTTPStatus.BAD_REQUEST, - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) - - -@pytest.mark.parametrize( - argnames="num_results", - argvalues=[b"0.1", b"1.1", b"a", b"2147483648"], +"""Compatibility imports for the packaged contract suite.""" + +from vws_mock_contract_tests.query.test_max_num_results import ( + test_default, + test_invalid_type, + test_out_of_range, + test_valid_accepted, + test_valid_works, ) -def test_invalid_type( - *, - high_quality_image: io.BytesIO, - vuforia_database: CloudDatabaseCredentials, - num_results: bytes, - base_vwq_url: str, -) -> None: - """An error is returned if ``max_num_results`` is given as - something - other than an integer. - - Integers greater than 2147483647 are not considered integers - because they are bigger than Java's maximum integer. - """ - image_content = high_quality_image.getvalue() - body = { - "image": ("image.jpeg", image_content, "image/jpeg"), - "max_num_results": (None, num_results, "text/plain"), - } - response = query( - vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url - ) - expected_text = ( - f"Invalid value '{num_results.decode()}' in form data part " - "'max_result'. " - "Expecting integer value in range from 1 to 50 (inclusive)." - ) - assert response.text == expected_text - assert_vwq_failure( - response=response, - content_type="application/json", - status_code=HTTPStatus.BAD_REQUEST, - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) +__all__ = [ + "test_default", + "test_invalid_type", + "test_out_of_range", + "test_valid_accepted", + "test_valid_works", +] diff --git a/tests/mock_vws/query/test_maximum_image_dimensions.py b/tests/mock_vws/query/test_maximum_image_dimensions.py index 8e22fedf0..d6d7c4c4b 100644 --- a/tests/mock_vws/query/test_maximum_image_dimensions.py +++ b/tests/mock_vws/query/test_maximum_image_dimensions.py @@ -1,160 +1,15 @@ -"""Tests for maximum image dimensions.""" +"""Compatibility imports for the packaged contract suite.""" -import json -from http import HTTPStatus - -import pytest -from vws import CloudRecoService -from vws.exceptions.cloud_reco_exceptions import ( - BadImageError, -) - -from tests.mock_vws.utils import ( - make_decompression_bomb_image_file, - make_image_file, -) -from tests.mock_vws.utils.assertions import ( - assert_valid_transaction_id, - assert_vwq_failure, +from vws_mock_contract_tests.query.test_maximum_image_dimensions import ( + test_max_height, + test_max_pixels, + test_max_width, + test_small_file_many_pixels, ) - -def test_max_height( - cloud_reco_client: CloudRecoService, -) -> None: - """ - An error is returned when an image with a height greater than - 30000 is - given. - """ - width = 1 - max_height = 30000 - png_not_too_tall = make_image_file( - file_format="PNG", - color_space="RGB", - width=width, - height=max_height, - ) - - results = cloud_reco_client.query(image=png_not_too_tall) - assert results == [] - - png_too_tall = make_image_file( - file_format="PNG", - color_space="RGB", - width=width, - height=max_height + 1, - ) - - with pytest.raises(expected_exception=BadImageError) as exc_info: - _ = cloud_reco_client.query(image=png_too_tall) - - response = exc_info.value.response - - assert_vwq_failure( - response=response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - content_type="application/json", - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) - - response_json = json.loads(s=response.text) - assert isinstance(response_json, dict) - assert response_json.keys() == {"transaction_id", "result_code"} - assert_valid_transaction_id(response=response) - # The separators are inconsistent and we test this. - expected_text = ( - '{"transaction_id": ' - f'"{response_json["transaction_id"]}",' - f'"result_code":"BadImage"' - "}" - ) - assert response.text == expected_text - - -def test_max_width(cloud_reco_client: CloudRecoService) -> None: - """ - An error is returned when an image with a width greater than - 30000 is - given. - """ - height = 1 - max_width = 30000 - png_not_too_wide = make_image_file( - file_format="PNG", - color_space="RGB", - width=max_width, - height=height, - ) - - result = cloud_reco_client.query(image=png_not_too_wide) - assert result == [] - - png_too_wide = make_image_file( - file_format="PNG", - color_space="RGB", - width=max_width + 1, - height=height, - ) - - with pytest.raises(expected_exception=BadImageError) as exc_info: - result = cloud_reco_client.query(image=png_too_wide) - - response = exc_info.value.response - - assert_vwq_failure( - response=response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - content_type="application/json", - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) - - response_json = json.loads(s=response.text) - assert isinstance(response_json, dict) - assert response_json.keys() == {"transaction_id", "result_code"} - assert_valid_transaction_id(response=response) - # The separators are inconsistent and we test this. - expected_text = ( - '{"transaction_id": ' - f'"{response_json["transaction_id"]}",' - f'"result_code":"BadImage"' - "}" - ) - assert response.text == expected_text - - -def test_max_pixels(cloud_reco_client: CloudRecoService) -> None: - """No error is returned for an 835 x 835 image.""" - # If we make this 836 then we hit REQUEST_ENTITY_TOO_LARGE errors. - max_height = max_width = 835 - png_not_too_wide = make_image_file( - file_format="PNG", - color_space="RGB", - width=max_width, - height=max_height, - ) - - result = cloud_reco_client.query(image=png_not_too_wide) - assert result == [] - - -def test_small_file_many_pixels( - cloud_reco_client: CloudRecoService, -) -> None: - """ - No error is returned for an image with a small file size and a - huge number of pixels. - - Unlike ``POST /targets``, the Query API has no limit on the - number of pixels, only on the width and the height. - """ - max_bytes = 2 * 1024 * 1024 - image_file = make_decompression_bomb_image_file() - assert len(image_file.getvalue()) < max_bytes - - result = cloud_reco_client.query(image=image_file) - assert result == [] +__all__ = [ + "test_max_height", + "test_max_pixels", + "test_max_width", + "test_small_file_many_pixels", +] diff --git a/tests/mock_vws/query/test_maximum_image_file_size.py b/tests/mock_vws/query/test_maximum_image_file_size.py index 7ee960b5b..81a664bc6 100644 --- a/tests/mock_vws/query/test_maximum_image_file_size.py +++ b/tests/mock_vws/query/test_maximum_image_file_size.py @@ -1,149 +1,8 @@ -"""Tests for maximum image file sizes.""" +"""Compatibility imports for the packaged contract suite.""" -import io -from http import HTTPStatus - -import pytest -from vws import CloudRecoService -from vws.exceptions.custom_exceptions import RequestEntityTooLargeError - -from tests.mock_vws.query.helpers import ( - NGINX_REQUEST_ENTITY_TOO_LARGE_ERROR, -) -from tests.mock_vws.utils import ( - make_image_file, -) -from tests.mock_vws.utils.assertions import ( - assert_vwq_failure, +from vws_mock_contract_tests.query.test_maximum_image_file_size import ( + test_jpeg, + test_png, ) - -def test_png(cloud_reco_client: CloudRecoService) -> None: - """ - According to - https://developer.vuforia.com/library/web-api/vuforia-query-web- - api. - the maximum file size is "2MiB for PNG". - - Above this limit, a ``REQUEST_ENTITY_TOO_LARGE`` response is returned. - We do not test exactly at this limit, but that may be beneficial in the - future. - """ - max_bytes = 2 * 1024 * 1024 - width = height = 835 - png_not_too_large = make_image_file( - file_format="PNG", - color_space="RGB", - width=width, - height=height, - ) - - image_content = png_not_too_large.getvalue() - - image_content_size = len(image_content) - # We check that the image we created is just slightly smaller than the - # maximum file size. - # - # This is just because of the implementation details of - # ``make_image_file``. - assert image_content_size < max_bytes - assert (image_content_size * 1.05) > max_bytes - - results = cloud_reco_client.query(image=png_not_too_large) - assert results == [] - - width += 1 - height += 1 - png_too_large = make_image_file( - file_format="PNG", - color_space="RGB", - width=width, - height=height, - ) - - image_content = png_too_large.getvalue() - image_content_size = len(image_content) - # We check that the image we created is just slightly larger than the - # maximum file size. - # - # This is just because of the implementation details of - # ``make_image_file``. - assert image_content_size > max_bytes - assert (image_content_size * 0.95) < max_bytes - - with pytest.raises( - expected_exception=RequestEntityTooLargeError - ) as exc_info: - _ = cloud_reco_client.query(image=png_too_large) - - response = exc_info.value.response - - assert_vwq_failure( - response=response, - status_code=HTTPStatus.REQUEST_ENTITY_TOO_LARGE, - content_type="text/html", - cache_control=None, - www_authenticate=None, - connection="Close", - ) - assert response.text == NGINX_REQUEST_ENTITY_TOO_LARGE_ERROR - - -def test_jpeg( - cloud_reco_client: CloudRecoService, - jpeg_too_large: io.BytesIO, -) -> None: - """ - According to - https://developer.vuforia.com/library/web-api/vuforia-query-web- - api. - the maximum file size is "512 KiB for JPEG". - However, this test shows that the maximum size for JPEG is 2 MiB. - - Above this limit, a ``REQUEST_ENTITY_TOO_LARGE`` response is returned. - We do not test exactly at this limit, but that may be beneficial in the - future. - """ - max_bytes = 2 * 1024 * 1024 - width = height = 1865 - jpeg_not_too_large = make_image_file( - file_format="JPEG", - color_space="RGB", - width=width, - height=height, - ) - - image_content = jpeg_not_too_large.getvalue() - - image_content_size = len(image_content) - # We check that the image we created is just slightly smaller than the - # maximum file size. - # - # This is just because of the implementation details of - # ``make_image_file``. - assert image_content_size < max_bytes - assert (image_content_size * 1.05) > max_bytes - - results = cloud_reco_client.query(image=jpeg_not_too_large) - assert results == [] - - image_content_size = len(jpeg_too_large.getvalue()) - assert image_content_size > max_bytes - - with pytest.raises( - expected_exception=RequestEntityTooLargeError - ) as exc_info: - _ = cloud_reco_client.query(image=jpeg_too_large) - - response = exc_info.value.response - - assert_vwq_failure( - response=response, - status_code=HTTPStatus.REQUEST_ENTITY_TOO_LARGE, - content_type="text/html", - cache_control=None, - www_authenticate=None, - connection="Close", - ) - - assert response.text == NGINX_REQUEST_ENTITY_TOO_LARGE_ERROR +__all__ = ["test_jpeg", "test_png"] diff --git a/tests/mock_vws/query/test_processing.py b/tests/mock_vws/query/test_processing.py index 12dbef258..2c3ef7609 100644 --- a/tests/mock_vws/query/test_processing.py +++ b/tests/mock_vws/query/test_processing.py @@ -1,48 +1,7 @@ -"""Tests for targets in the processing state.""" +"""Compatibility imports for the packaged contract suite.""" -import io -import uuid +from vws_mock_contract_tests.query.test_processing import ( + test_processing, +) -import pytest -from vws import VWS, CloudRecoService -from vws.reports import TargetStatuses - - -@pytest.mark.parametrize(argnames="active_flag", argvalues=[True, False]) -def test_processing( - *, - high_quality_image: io.BytesIO, - vws_client: VWS, - cloud_reco_client: CloudRecoService, - active_flag: bool, -) -> None: - """ - When a target with a matching image is in the processing state - it is - not matched. - """ - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=high_quality_image, - active_flag=active_flag, - application_metadata=None, - ) - matching_targets = cloud_reco_client.query(image=high_quality_image) - # We assert that after making a query, the target is in the processing - # state. - # - # There is a race condition here. - # - # This is really a check that the test is valid. - # - # If the target is no longer in the processing state here, it may be - # that the test was valid, but the target went into the processed - # state. - # - # If the target is no longer in the processing state here, that is a - # flaky test that is the test's fault and this must be rethought. - target_details = vws_client.get_target_record(target_id=target_id) - assert target_details.status == TargetStatuses.PROCESSING - - assert matching_targets == [] +__all__ = ["test_processing"] diff --git a/tests/mock_vws/query/test_result_order.py b/tests/mock_vws/query/test_result_order.py index 6145d6976..5c791be7b 100644 --- a/tests/mock_vws/query/test_result_order.py +++ b/tests/mock_vws/query/test_result_order.py @@ -1,119 +1,11 @@ -"""Tests for the order of query results.""" +"""Compatibility imports for the packaged contract suite.""" -import io -import json -import uuid - -import pytest -from vws import VWS - -from tests.mock_vws.fixtures.credentials import CloudDatabaseCredentials -from tests.mock_vws.query.helpers import ( - query, -) -from tests.mock_vws.utils.assertions import ( - assert_query_success, +from vws_mock_contract_tests.query.test_result_order import ( + test_max_num_results_keeps_the_first_results, + test_order_is_upload_date_then_target_id, ) -from tests.mock_vws.utils.backend_capabilities import BackendCapabilities - - -def test_order_is_upload_date_then_target_id( - *, - backend_capabilities: BackendCapabilities, - high_quality_image: io.BytesIO, - vws_client: VWS, - vuforia_database: CloudDatabaseCredentials, - base_vwq_url: str, -) -> None: - """Matches with the same score are ordered by upload date. - - Every target here has the same image, so the mock's image matcher - gives them all the same score and the tie-break decides the order. - The real Query API scores matches with Vuforia's own proprietary - matcher, so we do not verify this against the real Vuforia Web - Services. - """ - if not backend_capabilities.deterministic_query_order: - pytest.skip( - reason="This backend does not guarantee query upload-date order.", - ) - - target_ids = [ - vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - for _ in range(3) - ] - - for target_id in target_ids: - vws_client.wait_for_target_processed(target_id=target_id) - - image_content = high_quality_image.getvalue() - body = { - "image": ("image.jpeg", image_content, "image/jpeg"), - "max_num_results": (None, 3, "text/plain"), - } - - response = query( - vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url - ) - - assert_query_success(response=response) - response_json = json.loads(s=response.text) - result_target_ids = [ - result["target_id"] for result in response_json["results"] - ] - assert result_target_ids == target_ids - - -def test_max_num_results_keeps_the_first_results( - *, - backend_capabilities: BackendCapabilities, - high_quality_image: io.BytesIO, - vws_client: VWS, - vuforia_database: CloudDatabaseCredentials, - base_vwq_url: str, -) -> None: - """``max_num_results`` truncates a deterministically ordered - list. - - Which matches survive the truncation therefore does not vary - between runs. - """ - if not backend_capabilities.deterministic_query_order: - pytest.skip( - reason="This backend does not guarantee query upload-date order.", - ) - - target_ids = [ - vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - for _ in range(3) - ] - - for target_id in target_ids: - vws_client.wait_for_target_processed(target_id=target_id) - - image_content = high_quality_image.getvalue() - body = { - "image": ("image.jpeg", image_content, "image/jpeg"), - "max_num_results": (None, 1, "text/plain"), - } - - response = query( - vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url - ) - assert_query_success(response=response) - response_json = json.loads(s=response.text) - (result,) = response_json["results"] - assert result["target_id"] == target_ids[0] +__all__ = [ + "test_max_num_results_keeps_the_first_results", + "test_order_is_upload_date_then_target_id", +] diff --git a/tests/mock_vws/query/test_success.py b/tests/mock_vws/query/test_success.py index 432a58516..78c201e80 100644 --- a/tests/mock_vws/query/test_success.py +++ b/tests/mock_vws/query/test_success.py @@ -1,214 +1,17 @@ -"""Tests for successful calls to the query endpoint.""" - -import base64 -import calendar -import copy -import io -import json -import time - -from dirty_equals import IsInstance -from PIL import Image -from vws import VWS, CloudRecoService - -from tests.mock_vws.fixtures.credentials import CloudDatabaseCredentials -from tests.mock_vws.query.helpers import ( - query, -) -from tests.mock_vws.utils.assertions import ( - assert_query_success, +"""Compatibility imports for the packaged contract suite.""" + +from vws_mock_contract_tests.query.test_success import ( + test_low_quality_image, + test_match_exact, + test_match_similar, + test_no_results, + test_not_base64_encoded_processable, ) - -def test_no_results( - *, - high_quality_image: io.BytesIO, - cloud_reco_client: CloudRecoService, -) -> None: - """ - When there are no matching images in the database, an empty list - of - results is returned. - """ - results = cloud_reco_client.query(image=high_quality_image) - assert results == [] - - -def test_match_exact( - *, - high_quality_image: io.BytesIO, - vuforia_database: CloudDatabaseCredentials, - vws_client: VWS, - base_vwq_url: str, -) -> None: - """ - If the exact high quality image that was added is queried for, - target - data is shown. - """ - image_file = high_quality_image - image_content = image_file.getvalue() - metadata_encoded = base64.b64encode(s=b"example").decode(encoding="ascii") - name = "example_name" - - target_id = vws_client.add_target( - name=name, - width=1, - image=image_file, - active_flag=True, - application_metadata=metadata_encoded, - ) - - approximate_target_created = calendar.timegm(tuple=time.gmtime()) - - vws_client.wait_for_target_processed(target_id=target_id) - - body = {"image": ("image.jpeg", image_content, "image/jpeg")} - - response = query( - vuforia_database=vuforia_database, body=body, base_vwq_url=base_vwq_url - ) - - assert_query_success(response=response) - response_json = json.loads(s=response.text) - (result,) = response_json["results"] - assert result == { - "target_id": target_id, - "target_data": { - "application_metadata": metadata_encoded, - "name": name, - "target_timestamp": IsInstance(expected_type=int), - }, - } - target_timestamp_value: object = result["target_data"]["target_timestamp"] - assert isinstance(target_timestamp_value, int) - target_timestamp = target_timestamp_value - time_difference = abs(approximate_target_created - target_timestamp) - max_time_difference = 5 - assert time_difference < max_time_difference - - -def test_low_quality_image( - *, - image_file_success_state_low_rating: io.BytesIO, - cloud_reco_client: CloudRecoService, - vws_client: VWS, -) -> None: - """ - If the exact low quality image that was added is queried for, no - results are returned. - """ - image_file = image_file_success_state_low_rating - metadata_encoded = base64.b64encode(s=b"example").decode(encoding="ascii") - name = "example_name" - - target_id = vws_client.add_target( - name=name, - width=1, - image=image_file, - active_flag=True, - application_metadata=metadata_encoded, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - matching_targets = cloud_reco_client.query(image=image_file) - assert not bool(matching_targets) - - -def test_match_similar( - *, - high_quality_image: io.BytesIO, - different_high_quality_image: io.BytesIO, - vws_client: VWS, - cloud_reco_client: CloudRecoService, -) -> None: - """ - If a similar image to one that was added is queried for, target - data is - shown. - """ - metadata_encoded = base64.b64encode(s=b"example").decode(encoding="ascii") - name_matching = "example_name_matching" - name_not_matching = "example_name_not_matching" - - target_id_matching = vws_client.add_target( - name=name_matching, - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=metadata_encoded, - ) - - target_id_not_matching = vws_client.add_target( - name=name_not_matching, - width=1, - image=different_high_quality_image, - active_flag=True, - application_metadata=metadata_encoded, - ) - - vws_client.wait_for_target_processed(target_id=target_id_matching) - vws_client.wait_for_target_processed(target_id=target_id_not_matching) - - similar_image_buffer = io.BytesIO() - similar_image_data = copy.copy(x=high_quality_image) - pil_similar_image = Image.open(fp=similar_image_data) - # Re-save means similar but not identical. - pil_similar_image.save(fp=similar_image_buffer, format="JPEG") - - (matching_target,) = cloud_reco_client.query( - image=similar_image_buffer, - max_num_results=5, - ) - - assert matching_target.target_id == target_id_matching - - -def test_not_base64_encoded_processable( - *, - high_quality_image: io.BytesIO, - vws_client: VWS, - not_base64_encoded_processable: str, - cloud_reco_client: CloudRecoService, -) -> None: - """ - Vuforia accepts some metadata strings which are not valid - base64. - When a target with such a string is matched by a query, Vuforia - returns - an interesting result: - - * If the metadata string is a length one greater than a multiple of 4, - the last character is ignored. - * If the metadata is two greater than a multiple of 4, the result is - padded, then decoded, then encoded. - * If the metadata is three greater than a multiple of 4, the result is - padded, then decoded, then encoded. - """ - target_id = vws_client.add_target( - name="example_name", - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=not_base64_encoded_processable, - ) - - vws_client.wait_for_target_processed(target_id=target_id) - - query_results = cloud_reco_client.query(image=high_quality_image) - - (result,) = query_results - assert result.target_data is not None - query_metadata = result.target_data.application_metadata - mod_4_to_expected_metadata_original = { - 1: not_base64_encoded_processable[:-1], - 2: not_base64_encoded_processable + "==", - 3: not_base64_encoded_processable + "=", - } - expected_metadata_original = mod_4_to_expected_metadata_original[ - len(not_base64_encoded_processable) % 4 - ] - expected_metadata = base64.b64encode( - s=base64.b64decode(s=expected_metadata_original), - ) - assert query_metadata == expected_metadata.decode() +__all__ = [ + "test_low_quality_image", + "test_match_exact", + "test_match_similar", + "test_no_results", + "test_not_base64_encoded_processable", +] diff --git a/tests/mock_vws/query/test_target_status_failed.py b/tests/mock_vws/query/test_target_status_failed.py index 4fb878872..2ccc87569 100644 --- a/tests/mock_vws/query/test_target_status_failed.py +++ b/tests/mock_vws/query/test_target_status_failed.py @@ -1,26 +1,7 @@ -"""Tests for targets with the status "failed".""" +"""Compatibility imports for the packaged contract suite.""" -import io -import uuid +from vws_mock_contract_tests.query.test_target_status_failed import ( + test_status_failed, +) -from vws import VWS, CloudRecoService - - -def test_status_failed( - *, - image_file_failed_state: io.BytesIO, - vws_client: VWS, - cloud_reco_client: CloudRecoService, -) -> None: - """Targets with the status "failed" are not found in query results.""" - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image_file_failed_state, - active_flag=True, - application_metadata=None, - ) - vws_client.wait_for_target_processed(target_id=target_id) - - results = cloud_reco_client.query(image=image_file_failed_state) - assert results == [] +__all__ = ["test_status_failed"] diff --git a/tests/mock_vws/query/test_truncated_body.py b/tests/mock_vws/query/test_truncated_body.py index a62367b7c..c7fd39af5 100644 --- a/tests/mock_vws/query/test_truncated_body.py +++ b/tests/mock_vws/query/test_truncated_body.py @@ -1,218 +1,19 @@ -"""Tests for bodies which end before the multipart body is complete. - -A client which is cut off mid-upload sends such a body. -""" - -import io -import json -from email.message import EmailMessage -from http import HTTPStatus - -from urllib3.filepost import encode_multipart_formdata - -from tests.mock_vws.fixtures.credentials import CloudDatabaseCredentials -from tests.mock_vws.query.helpers import ( - query_raw, +"""Compatibility imports for the packaged contract suite.""" + +from vws_mock_contract_tests.query.test_truncated_body import ( + test_no_closing_boundary, + test_no_content_disposition_header, + test_partial_closing_boundary, + test_truncated_at_end_of_header, + test_truncated_image_data, + test_truncated_in_part_headers, ) -from tests.mock_vws.utils.assertions import ( - assert_query_success, - assert_vwq_failure, -) - - -def test_no_closing_boundary( - *, - high_quality_image: io.BytesIO, - vuforia_database: CloudDatabaseCredentials, - base_vwq_url: str, -) -> None: - """A body which has all of the image data but no closing boundary - is - treated as if the closing boundary were given. - """ - image_content = high_quality_image.getvalue() - body = {"image": ("image.jpeg", image_content, "image/jpeg")} - content, content_type_header = encode_multipart_formdata(fields=body) - truncated = content[: content.rindex(b"\r\n--")] - - response = query_raw( - vuforia_database=vuforia_database, - content=truncated, - content_type_header=content_type_header, - base_vwq_url=base_vwq_url, - ) - - assert_query_success(response=response) - assert json.loads(s=response.text)["results"] == [] - - -def test_partial_closing_boundary( - *, - high_quality_image: io.BytesIO, - vuforia_database: CloudDatabaseCredentials, - base_vwq_url: str, -) -> None: - """A body which is cut off part-way through its closing boundary is - treated as if the closing boundary were given. - """ - image_content = high_quality_image.getvalue() - body = {"image": ("image.jpeg", image_content, "image/jpeg")} - content, content_type_header = encode_multipart_formdata(fields=body) - truncated = content[: content.rindex(b"--")] - - response = query_raw( - vuforia_database=vuforia_database, - content=truncated, - content_type_header=content_type_header, - base_vwq_url=base_vwq_url, - ) - - assert_query_success(response=response) - assert json.loads(s=response.text)["results"] == [] - - -def test_truncated_at_end_of_header( - *, - high_quality_image: io.BytesIO, - vuforia_database: CloudDatabaseCredentials, - base_vwq_url: str, -) -> None: - """A body which is cut off at the end of a header of a part, before - the line ending of that header, gives a ``BadImage`` response, as - the - part has no image data. - """ - image_content = high_quality_image.getvalue() - body = {"image": ("image.jpeg", image_content, "image/jpeg")} - content, content_type_header = encode_multipart_formdata(fields=body) - first_line_end = content.index(b"\r\n") - truncated = content[: content.index(b"\r\n", first_line_end + 2)] - - response = query_raw( - vuforia_database=vuforia_database, - content=truncated, - content_type_header=content_type_header, - base_vwq_url=base_vwq_url, - ) - - assert_vwq_failure( - response=response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - content_type="application/json", - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) - response_json = json.loads(s=response.text) - assert response_json["result_code"] == "BadImage" - - -def test_truncated_image_data( - *, - high_quality_image: io.BytesIO, - vuforia_database: CloudDatabaseCredentials, - base_vwq_url: str, -) -> None: - """A body which is cut off within the image data gives a - ``BadImage`` response. - """ - image_content = high_quality_image.getvalue() - body = {"image": ("image.jpeg", image_content, "image/jpeg")} - content, content_type_header = encode_multipart_formdata(fields=body) - truncated = content[: content.index(b"\r\n\r\n") + 100] - - response = query_raw( - vuforia_database=vuforia_database, - content=truncated, - content_type_header=content_type_header, - base_vwq_url=base_vwq_url, - ) - - assert_vwq_failure( - response=response, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - content_type="application/json", - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) - response_json = json.loads(s=response.text) - assert response_json["result_code"] == "BadImage" - - -def test_truncated_in_part_headers( - *, - high_quality_image: io.BytesIO, - vuforia_database: CloudDatabaseCredentials, - base_vwq_url: str, -) -> None: - """A body which is cut off within the headers of a part, so that no - ``Content-Disposition`` header is complete, gives a ``BAD_REQUEST`` - response. - """ - image_content = high_quality_image.getvalue() - body = {"image": ("image.jpeg", image_content, "image/jpeg")} - content, content_type_header = encode_multipart_formdata(fields=body) - truncated = content[: content.index(b"\r\n") + 20] - - response = query_raw( - vuforia_database=vuforia_database, - content=truncated, - content_type_header=content_type_header, - base_vwq_url=base_vwq_url, - ) - - expected_text = "Could find no Content-Disposition header within part" - assert response.text == expected_text - assert_vwq_failure( - response=response, - status_code=HTTPStatus.BAD_REQUEST, - content_type="text/plain;charset=utf-8", - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) - - -def test_no_content_disposition_header( - *, - high_quality_image: io.BytesIO, - vuforia_database: CloudDatabaseCredentials, - base_vwq_url: str, -) -> None: - """A complete body with a part which has no ``Content-Disposition`` - header gives a ``BAD_REQUEST`` response. - """ - image_content = high_quality_image.getvalue() - body = {"image": ("image.jpeg", image_content, "image/jpeg")} - _, content_type_header = encode_multipart_formdata(fields=body) - email_message = EmailMessage() - email_message["Content-Type"] = content_type_header - boundary = email_message.get_boundary(failobj="").encode(encoding="utf-8") - without_content_disposition = ( - b"--" - + boundary - + b"\r\nContent-Type: image/jpeg\r\n\r\n" - + image_content - + b"\r\n--" - + boundary - + b"--\r\n" - ) - - response = query_raw( - vuforia_database=vuforia_database, - content=without_content_disposition, - content_type_header=content_type_header, - base_vwq_url=base_vwq_url, - ) - expected_text = "Could find no Content-Disposition header within part" - assert response.text == expected_text - assert_vwq_failure( - response=response, - status_code=HTTPStatus.BAD_REQUEST, - content_type="text/plain;charset=utf-8", - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) +__all__ = [ + "test_no_closing_boundary", + "test_no_content_disposition_header", + "test_partial_closing_boundary", + "test_truncated_at_end_of_header", + "test_truncated_image_data", + "test_truncated_in_part_headers", +] diff --git a/tests/mock_vws/query/test_update.py b/tests/mock_vws/query/test_update.py index 8ee5a35ff..bfb0bee0c 100644 --- a/tests/mock_vws/query/test_update.py +++ b/tests/mock_vws/query/test_update.py @@ -1,79 +1,7 @@ -"""Tests for updated targets.""" +"""Compatibility imports for the packaged contract suite.""" -import base64 -import calendar -import io -import time +from vws_mock_contract_tests.query.test_update import ( + test_updated_target, +) -from vws import VWS, CloudRecoService - - -def test_updated_target( - *, - high_quality_image: io.BytesIO, - different_high_quality_image: io.BytesIO, - vws_client: VWS, - cloud_reco_client: CloudRecoService, -) -> None: - """After a target is updated, only the new image can be matched. - - The match result includes the updated name, timestamp and - application metadata. - """ - metadata = b"example_metadata" - metadata_encoded = base64.b64encode(s=metadata).decode(encoding="ascii") - name = "example_name" - target_id = vws_client.add_target( - name=name, - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=metadata_encoded, - ) - - _ = calendar.timegm(tuple=time.gmtime()) - - vws_client.wait_for_target_processed(target_id=target_id) - - new_name = name + "2" - new_metadata = metadata + b"2" - new_metadata_encoded = base64.b64encode(s=new_metadata).decode( - encoding="ascii" - ) - - results = cloud_reco_client.query(image=high_quality_image) - (result,) = results - assert result.target_data is not None - original_target_timestamp = result.target_data.target_timestamp - - vws_client.update_target( - target_id=target_id, - name=new_name, - image=different_high_quality_image, - application_metadata=new_metadata_encoded, - ) - - approximate_target_updated = calendar.timegm(tuple=time.gmtime()) - - vws_client.wait_for_target_processed(target_id=target_id) - - results = cloud_reco_client.query(image=different_high_quality_image) - - (result,) = results - assert result.target_data is not None - assert result.target_data.application_metadata == new_metadata_encoded - assert result.target_data.name == new_name - target_timestamp = result.target_data.target_timestamp - # In the future we might want to test that - # target_timestamp > original_target_timestamp - # However, this requires us to set the mock processing time at > 1 - # second. - assert target_timestamp >= original_target_timestamp - time_difference = abs( - approximate_target_updated - target_timestamp.timestamp(), - ) - max_time_difference = 5 - assert time_difference < max_time_difference - - results = cloud_reco_client.query(image=high_quality_image) - assert results == [] +__all__ = ["test_updated_target"] From e8c23188686627519f0449289403d24e8202ede7 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 28 Oct 2026 17:47:36 +0000 Subject: [PATCH 04/10] Package recognition-report and VuMark API contracts --- .../reco_counts_report/__init__.py | 1 + .../reco_counts_report/conftest.py | 34 ++ .../reco_counts_report/helpers.py | 4 +- .../test_download_report.py | 209 +++++++++++++ .../test_download_report_mock_only.py | 184 +++++++++++ .../test_reco_counts_report.py | 275 ++++++++++++++++ .../vumark_generation_api/__init__.py | 1 + .../contract/__init__.py | 1 + .../contract/conftest.py | 12 + .../contract/test_generate_instance.py | 258 +++++++++++++++ .../contract/test_inactive_database.py | 32 ++ .../vumark_generation_api/helpers.py | 0 .../mock_only/__init__.py | 1 + .../mock_only/conftest.py | 12 + .../mock_only/test_processing_target.py | 70 +++++ tests/mock_vws/reco_counts_report/conftest.py | 36 +-- .../test_download_report.py | 220 +------------ .../test_download_report_mock_only.py | 189 +---------- .../test_reco_counts_report.py | 296 ++---------------- .../requests_mock/test_reco_counts_report.py | 2 +- .../contract/conftest.py | 15 +- .../contract/test_generate_instance.py | 277 ++-------------- .../contract/test_inactive_database.py | 33 +- .../mock_only/conftest.py | 15 +- .../mock_only/test_processing_target.py | 72 +---- 25 files changed, 1192 insertions(+), 1057 deletions(-) create mode 100644 contract-tests/src/vws_mock_contract_tests/reco_counts_report/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/reco_counts_report/conftest.py rename {tests/mock_vws => contract-tests/src/vws_mock_contract_tests}/reco_counts_report/helpers.py (98%) create mode 100644 contract-tests/src/vws_mock_contract_tests/reco_counts_report/test_download_report.py create mode 100644 contract-tests/src/vws_mock_contract_tests/reco_counts_report/test_download_report_mock_only.py create mode 100644 contract-tests/src/vws_mock_contract_tests/reco_counts_report/test_reco_counts_report.py create mode 100644 contract-tests/src/vws_mock_contract_tests/vumark_generation_api/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/vumark_generation_api/contract/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/vumark_generation_api/contract/conftest.py create mode 100644 contract-tests/src/vws_mock_contract_tests/vumark_generation_api/contract/test_generate_instance.py create mode 100644 contract-tests/src/vws_mock_contract_tests/vumark_generation_api/contract/test_inactive_database.py rename {tests/mock_vws => contract-tests/src/vws_mock_contract_tests}/vumark_generation_api/helpers.py (100%) create mode 100644 contract-tests/src/vws_mock_contract_tests/vumark_generation_api/mock_only/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/vumark_generation_api/mock_only/conftest.py create mode 100644 contract-tests/src/vws_mock_contract_tests/vumark_generation_api/mock_only/test_processing_target.py diff --git a/contract-tests/src/vws_mock_contract_tests/reco_counts_report/__init__.py b/contract-tests/src/vws_mock_contract_tests/reco_counts_report/__init__.py new file mode 100644 index 000000000..ca424d23c --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/reco_counts_report/__init__.py @@ -0,0 +1 @@ +"""Reco counts report tests.""" diff --git a/contract-tests/src/vws_mock_contract_tests/reco_counts_report/conftest.py b/contract-tests/src/vws_mock_contract_tests/reco_counts_report/conftest.py new file mode 100644 index 000000000..22ae8515d --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/reco_counts_report/conftest.py @@ -0,0 +1,34 @@ +"""Fixtures scoped to this group of tests.""" + +import pytest + +from vws_mock_contract_tests.fixtures.credentials import ( + NamedCloudDatabaseCredentials, +) +from vws_mock_contract_tests.utils.cloud_databases import CreateCloudDatabase + + +@pytest.fixture(name="fresh_database") +def fixture_fresh_database( + *, + create_cloud_database: CreateCloudDatabase, +) -> NamedCloudDatabaseCredentials: + """A database which no report has been requested for. + + The shared test database is one database for the whole test run, so a + backend whose report store outlives a test serves the report which an + earlier test generated for it, as real Vuforia does with the shared + real database. + Tests which need a report which they generated themselves use their own + database. + """ + return create_cloud_database() + + +@pytest.fixture(autouse=True) +def fixture_verify_backend( + *, + mock_only_vuforia: None, +) -> None: + """Set up the backend for every contract test in this directory.""" + del mock_only_vuforia diff --git a/tests/mock_vws/reco_counts_report/helpers.py b/contract-tests/src/vws_mock_contract_tests/reco_counts_report/helpers.py similarity index 98% rename from tests/mock_vws/reco_counts_report/helpers.py rename to contract-tests/src/vws_mock_contract_tests/reco_counts_report/helpers.py index 47a75ff4b..6374e34cf 100644 --- a/tests/mock_vws/reco_counts_report/helpers.py +++ b/contract-tests/src/vws_mock_contract_tests/reco_counts_report/helpers.py @@ -18,7 +18,9 @@ from vws import VWS from vws_auth_tools import authorization_header, rfc_1123_date -from tests.mock_vws.fixtures.credentials import CloudDatabaseCredentials +from vws_mock_contract_tests.fixtures.credentials import ( + CloudDatabaseCredentials, +) CURRENT_MONTH_RECOS = 3 diff --git a/contract-tests/src/vws_mock_contract_tests/reco_counts_report/test_download_report.py b/contract-tests/src/vws_mock_contract_tests/reco_counts_report/test_download_report.py new file mode 100644 index 000000000..c143dd979 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/reco_counts_report/test_download_report.py @@ -0,0 +1,209 @@ +"""Tests for downloading a generated reco counts report. + +The endpoint is unavailable to the real-Vuforia test account, so these +run against the mocks only. +""" + +import datetime as dt +import re +import time +from http import HTTPStatus +from urllib.parse import urlsplit, urlunsplit + +import requests + +from vws_mock_contract_tests.fixtures.credentials import ( + CloudDatabaseCredentials, +) +from vws_mock_contract_tests.reco_counts_report.helpers import ( + AMZ_DATE_FORMAT, + CSV_HEADER, + CSV_ROW_PATTERN, + REQUEST_IDS_PATTERN, + S3_TIME_FORMAT, + URL_EXPIRY_SECONDS, + XML_DECLARATION, + get_presigned_url, + month_offset_from_now, + utc_now, + wait_for_report, + with_query, +) + + +def test_download_report( + *, base_vws_url: str, vuforia_database: CloudDatabaseCredentials +) -> None: + """The report is available from the given URL once it is ready. + + Until then the URL gives the response which storage gives for a + missing object. + """ + presigned_url = get_presigned_url( + base_vws_url=base_vws_url, + vuforia_database=vuforia_database, + month=month_offset_from_now(months=0), + ) + + ready_response = wait_for_report(presigned_url=presigned_url) + + assert ready_response.status_code == HTTPStatus.OK + assert ready_response.headers["Content-Type"] == "text/plain" + # A real database may have recognitions this month. + assert ready_response.text.startswith(CSV_HEADER) + rows = ready_response.text.removeprefix(CSV_HEADER) + assert ( + re.fullmatch( + pattern=f"({CSV_ROW_PATTERN.pattern})*", + string=rows, + ) + is not None + ), rows + + +def test_second_request_serves_first_report( + *, + base_vws_url: str, + vuforia_database: CloudDatabaseCredentials, +) -> None: + """A second request in the same hour serves the report which the + first request generated, without generating it again. + """ + month = month_offset_from_now(months=0) + first_url = get_presigned_url( + base_vws_url=base_vws_url, + vuforia_database=vuforia_database, + month=month, + ) + first_response = wait_for_report(presigned_url=first_url) + second_url = get_presigned_url( + base_vws_url=base_vws_url, + vuforia_database=vuforia_database, + month=month, + ) + second_response = requests.get(url=second_url, timeout=30) + + # If the requests straddled an hour boundary, the second one names a + # new file, and nothing is claimed about that. + same_file = urlsplit(url=first_url).path == urlsplit(url=second_url).path + assert not same_file or ( + second_response.status_code == HTTPStatus.OK + and second_response.text == first_response.text + ) + + +def test_expired_url( + *, base_vws_url: str, vuforia_database: CloudDatabaseCredentials +) -> None: + """A URL which has expired gives a 403 response. + + Storage checks the expiry before the signature, so a URL whose + ``X-Amz-Date`` has been moved back gives the same response as a URL + which has expired, even against real Vuforia. + """ + presigned_url = get_presigned_url( + base_vws_url=base_vws_url, + vuforia_database=vuforia_database, + month=month_offset_from_now(months=0), + ) + signed_at = utc_now().replace(microsecond=0) - dt.timedelta( + days=8, + ) + expired_url = with_query( + url=presigned_url, + changes={ + "X-Amz-Date": signed_at.strftime(format=AMZ_DATE_FORMAT), + }, + ) + + response = requests.get(url=expired_url, timeout=30) + + expires_at = signed_at + dt.timedelta( + seconds=URL_EXPIRY_SECONDS, + ) + assert response.status_code == HTTPStatus.FORBIDDEN + assert response.headers["Content-Type"] == "application/xml" + expected_pattern = re.escape( + pattern=( + f"{XML_DECLARATION}AccessDenied" + "Request has expired" + f"{URL_EXPIRY_SECONDS}" + f"{expires_at.strftime(format=S3_TIME_FORMAT)}" + "" + ), + ) + server_time_pattern = ( + r"[0-9]{4}-[0-9]{2}-[0-9]{2}T" + r"[0-9]{2}:[0-9]{2}:[0-9]{2}Z" + ) + assert ( + re.fullmatch( + pattern=( + expected_pattern + + server_time_pattern + + REQUEST_IDS_PATTERN + + "" + ), + string=response.text, + ) + is not None + ), response.text + + +def test_shortened_expiry( + *, base_vws_url: str, vuforia_database: CloudDatabaseCredentials +) -> None: + """The URL expires ``X-Amz-Expires`` seconds after its + ``X-Amz-Date``, so shortening that puts it out of date. + """ + presigned_url = get_presigned_url( + base_vws_url=base_vws_url, + vuforia_database=vuforia_database, + month=month_offset_from_now(months=0), + ) + short_lived_url = with_query( + url=presigned_url, + changes={"X-Amz-Expires": "1"}, + ) + time.sleep(2) + + response = requests.get(url=short_lived_url, timeout=30) + + assert response.status_code == HTTPStatus.FORBIDDEN + assert "Request has expired" in response.text + assert "1" in response.text + + +def test_missing_query( + *, base_vws_url: str, vuforia_database: CloudDatabaseCredentials +) -> None: + """The query parameters of the URL are its authorization, so the + path + alone is refused. + """ + presigned_url = get_presigned_url( + base_vws_url=base_vws_url, + vuforia_database=vuforia_database, + month=month_offset_from_now(months=0), + ) + unsigned_url = urlunsplit( + components=urlsplit(url=presigned_url)._replace(query=""), + ) + + response = requests.get(url=unsigned_url, timeout=30) + + assert response.status_code == HTTPStatus.FORBIDDEN + assert response.headers["Content-Type"] == "application/xml" + expected_pattern = re.escape( + pattern=( + f"{XML_DECLARATION}AccessDenied" + "Access Denied" + ), + ) + assert ( + re.fullmatch( + pattern=expected_pattern + REQUEST_IDS_PATTERN + "", + string=response.text, + ) + is not None + ), response.text diff --git a/contract-tests/src/vws_mock_contract_tests/reco_counts_report/test_download_report_mock_only.py b/contract-tests/src/vws_mock_contract_tests/reco_counts_report/test_download_report_mock_only.py new file mode 100644 index 000000000..6d56ad5c6 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/reco_counts_report/test_download_report_mock_only.py @@ -0,0 +1,184 @@ +"""Tests for downloading a report which cannot run against real +Vuforia. + +Nothing sets recognition counts on real Vuforia, the mock's generation +time is known where real Vuforia's is not, and only real Vuforia can sign +a URL for its storage. +""" + +import io +import uuid +from urllib.parse import urlsplit, urlunsplit + +import pytest +import requests + +from vws_mock_contract_tests.fixtures.credentials import ( + NamedCloudDatabaseCredentials, +) +from vws_mock_contract_tests.reco_counts_report.helpers import ( + CSV_HEADER, + CURRENT_MONTH_RECOS, + PREVIOUS_MONTH_RECOS, + TOTAL_RECOS, + assert_not_ready, + get_presigned_url, + make_vws_client, + month_offset_from_now, + report_key, + wait_for_report, +) +from vws_mock_contract_tests.utils.recognition_counts import ( + SeedRecognitionCounts, +) + + +def test_not_ready( + *, base_vws_url: str, fresh_database: NamedCloudDatabaseCredentials +) -> None: + """Until the report is generated, the URL gives the response which + storage gives for a missing object. + """ + presigned_url = get_presigned_url( + base_vws_url=base_vws_url, + vuforia_database=fresh_database, + month=month_offset_from_now(months=0), + ) + + response = requests.get(url=presigned_url, timeout=30) + + assert_not_ready( + response=response, + key=report_key(url=presigned_url), + ) + + +def test_unknown_report( + *, base_vws_url: str, vuforia_database: NamedCloudDatabaseCredentials +) -> None: + """A file which no request generated gives the response which + storage gives for a missing object. + + Real Vuforia refuses such a URL because its signature does not match + the file, and the mock does not check signatures. + """ + presigned_url = get_presigned_url( + base_vws_url=base_vws_url, + vuforia_database=vuforia_database, + month=month_offset_from_now(months=0), + ) + split_url = urlsplit(url=presigned_url) + database_id = vuforia_database.database_id + unknown_path = f"/reports/{database_id}/1999-01.csv" + unknown_url = urlunsplit( + components=split_url._replace(path=unknown_path), + ) + + response = requests.get(url=unknown_url, timeout=30) + + assert_not_ready(response=response, key=unknown_path.lstrip("/")) + + +@pytest.mark.parametrize( + argnames=("months_ago", "expected_reco_count"), + argvalues=[ + (0, CURRENT_MONTH_RECOS), + (1, PREVIOUS_MONTH_RECOS), + ], + ids=["current_month", "previous_month"], +) +def test_seeded_recognition_counts( + *, + base_vws_url: str, + seed_recognition_counts: SeedRecognitionCounts, + fresh_database: NamedCloudDatabaseCredentials, + high_quality_image: io.BytesIO, + image_file_failed_state: io.BytesIO, + months_ago: int, + expected_reco_count: int, +) -> None: + """The report has a row for each target recognized in the + month. + """ + vws_client = make_vws_client( + base_vws_url=base_vws_url, database=fresh_database + ) + recognized_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + unrecognized_target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=image_file_failed_state, + active_flag=True, + application_metadata=None, + ) + seed_recognition_counts( + database_name=fresh_database.database_name, + target_id=recognized_target_id, + current_month_recos=CURRENT_MONTH_RECOS, + previous_month_recos=PREVIOUS_MONTH_RECOS, + total_recos=TOTAL_RECOS, + ) + presigned_url = get_presigned_url( + base_vws_url=base_vws_url, + vuforia_database=fresh_database, + month=month_offset_from_now(months=-months_ago), + ) + + ready_response = wait_for_report(presigned_url=presigned_url) + + assert ready_response.text == ( + f"{CSV_HEADER}{recognized_target_id},{expected_reco_count}\r\n" + ) + assert unrecognized_target_id not in ready_response.text + + +def test_counts_set_after_first_request( + *, + base_vws_url: str, + seed_recognition_counts: SeedRecognitionCounts, + fresh_database: NamedCloudDatabaseCredentials, + high_quality_image: io.BytesIO, +) -> None: + """A second request in the same hour does not generate the report + again, so counts set between the two requests are not in it. + """ + vws_client = make_vws_client( + base_vws_url=base_vws_url, database=fresh_database + ) + target_id = vws_client.add_target( + name=uuid.uuid4().hex, + width=1, + image=high_quality_image, + active_flag=True, + application_metadata=None, + ) + month = month_offset_from_now(months=0) + first_url = get_presigned_url( + base_vws_url=base_vws_url, + vuforia_database=fresh_database, + month=month, + ) + seed_recognition_counts( + database_name=fresh_database.database_name, + target_id=target_id, + current_month_recos=CURRENT_MONTH_RECOS, + previous_month_recos=PREVIOUS_MONTH_RECOS, + total_recos=TOTAL_RECOS, + ) + second_url = get_presigned_url( + base_vws_url=base_vws_url, + vuforia_database=fresh_database, + month=month, + ) + ready_response = wait_for_report(presigned_url=second_url) + + # If the requests straddled an hour boundary, the second one names a + # new file, and nothing is claimed about that. + same_file = urlsplit(url=first_url).path == urlsplit(url=second_url).path + assert not same_file or ready_response.text == CSV_HEADER diff --git a/contract-tests/src/vws_mock_contract_tests/reco_counts_report/test_reco_counts_report.py b/contract-tests/src/vws_mock_contract_tests/reco_counts_report/test_reco_counts_report.py new file mode 100644 index 000000000..f82363105 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/reco_counts_report/test_reco_counts_report.py @@ -0,0 +1,275 @@ +"""Tests for requesting a reco counts report.""" + +import datetime as dt +import json +import uuid +from http import HTTPStatus +from string import hexdigits +from urllib.parse import parse_qsl, urlsplit +from zoneinfo import ZoneInfo + +import pytest + +from vws_mock_contract_tests.fixtures.credentials import ( + NamedCloudDatabaseCredentials, +) +from vws_mock_contract_tests.reco_counts_report.helpers import ( + AMZ_DATE_FORMAT, + PRESIGNED_URL_PARAMETERS, + URL_EXPIRY_SECONDS, + get_presigned_url, + month_offset_from_now, + request_reco_counts_report, + request_reco_counts_report_json, + utc_now, +) + + +@pytest.mark.parametrize( + argnames="months_ago", + argvalues=[0, 1], + ids=["current_month", "previous_month"], +) +def test_reco_counts_report( + *, + base_vws_url: str, + vuforia_database: NamedCloudDatabaseCredentials, + months_ago: int, +) -> None: + """A report can be requested for the current and previous + month. + """ + response = request_reco_counts_report( + base_vws_url=base_vws_url, + vuforia_database=vuforia_database, + database_id=vuforia_database.database_id, + month=month_offset_from_now(months=-months_ago), + ) + + assert response.status_code == HTTPStatus.OK + response_json = json.loads(s=response.text) + assert response_json.keys() == { + "result_code", + "transaction_id", + "presigned_url", + } + assert response_json["result_code"] == "Success" + transaction_id: object = response_json["transaction_id"] + assert isinstance(transaction_id, str) + assert all(char in hexdigits for char in transaction_id) + presigned_url: object = response_json["presigned_url"] + assert isinstance(presigned_url, str) + download_url = urlsplit(url=presigned_url) + assert download_url.scheme in {"http", "https"} + assert download_url.netloc != "" + + +def test_presigned_url_query( + *, base_vws_url: str, vuforia_database: NamedCloudDatabaseCredentials +) -> None: + """The URL carries the query parameters of a presigned URL. + + It is signed for one second under seven days. + """ + before = utc_now().replace(microsecond=0) + presigned_url = get_presigned_url( + base_vws_url=base_vws_url, + vuforia_database=vuforia_database, + month=month_offset_from_now(months=0), + ) + after = utc_now() + + query = parse_qsl(qs=urlsplit(url=presigned_url).query) + assert [name for name, _ in query] == PRESIGNED_URL_PARAMETERS + parameters = dict(query) + assert parameters["X-Amz-Security-Token"] != "" + assert parameters["X-Amz-Algorithm"] == "AWS4-HMAC-SHA256" + assert parameters["X-Amz-SignedHeaders"] == "host" + assert parameters["X-Amz-Expires"] == str(object=URL_EXPIRY_SECONDS) + assert all(char in hexdigits for char in parameters["X-Amz-Signature"]) + signed_at = dt.datetime.strptime( + parameters["X-Amz-Date"], + AMZ_DATE_FORMAT, + ).replace(tzinfo=ZoneInfo(key="UTC")) + # Real Vuforia's clock is not the test's clock. + clock_skew = dt.timedelta(minutes=5) + assert before - clock_skew <= signed_at <= after + clock_skew + signed_date = signed_at.strftime(format="%Y%m%d") + assert parameters["X-Amz-Credential"].endswith( + f"/{signed_date}/us-west-1/s3/aws4_request", + ) + + +def test_current_month_file_name( + *, + base_vws_url: str, + vuforia_database: NamedCloudDatabaseCredentials, +) -> None: + """A report for the current month is named for the date and the + hour. + """ + hours_before = utc_now().strftime(format="%Y-%m-%d-%H") + presigned_url = get_presigned_url( + base_vws_url=base_vws_url, + vuforia_database=vuforia_database, + month=month_offset_from_now(months=0), + ) + hours_after = utc_now().strftime(format="%Y-%m-%d-%H") + + database_id = vuforia_database.database_id + expected_paths = { + f"/reports/{database_id}/{hour}.csv" + for hour in (hours_before, hours_after) + } + assert urlsplit(url=presigned_url).path in expected_paths + + +def test_previous_month_file_name( + *, + base_vws_url: str, + vuforia_database: NamedCloudDatabaseCredentials, +) -> None: + """A report for the previous month is named for the month.""" + month = month_offset_from_now(months=-1) + presigned_url = get_presigned_url( + base_vws_url=base_vws_url, + vuforia_database=vuforia_database, + month=month, + ) + + database_id = vuforia_database.database_id + expected_path = f"/reports/{database_id}/{month}.csv" + assert urlsplit(url=presigned_url).path == expected_path + + +def test_same_file_in_the_same_hour( + *, + base_vws_url: str, + vuforia_database: NamedCloudDatabaseCredentials, +) -> None: + """Two requests for the current month in the same hour name the + same file. + """ + month = month_offset_from_now(months=0) + first_url = get_presigned_url( + base_vws_url=base_vws_url, + vuforia_database=vuforia_database, + month=month, + ) + second_url = get_presigned_url( + base_vws_url=base_vws_url, + vuforia_database=vuforia_database, + month=month, + ) + hour_after = utc_now().strftime(format="%Y-%m-%d-%H") + + # If the requests straddled an hour boundary, the second one + # legitimately names the new hour's file instead. + database_id = vuforia_database.database_id + expected_paths = { + urlsplit(url=first_url).path, + f"/reports/{database_id}/{hour_after}.csv", + } + assert urlsplit(url=second_url).path in expected_paths + + +@pytest.mark.parametrize( + argnames="months_ago", + argvalues=[2, -1], + ids=["too_old", "in_the_future"], +) +def test_month_out_of_range( + *, + base_vws_url: str, + vuforia_database: NamedCloudDatabaseCredentials, + months_ago: int, +) -> None: + """Only the current and the previous month can be requested.""" + response = request_reco_counts_report( + base_vws_url=base_vws_url, + vuforia_database=vuforia_database, + database_id=vuforia_database.database_id, + month=month_offset_from_now(months=-months_ago), + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + response_json = json.loads(s=response.text) + assert response_json["result_code"] == "Fail" + + +@pytest.mark.parametrize( + argnames="month", + argvalues=["2020", "2020-1", "January", "2020-01-01", 202001], + ids=["year_only", "one_digit", "name", "date", "not_a_string"], +) +def test_malformed_month( + *, + base_vws_url: str, + vuforia_database: NamedCloudDatabaseCredentials, + month: str | int, +) -> None: + """The month must be given in the ``YYYY-mm`` form.""" + response = request_reco_counts_report( + base_vws_url=base_vws_url, + vuforia_database=vuforia_database, + database_id=vuforia_database.database_id, + month=month, + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + response_json = json.loads(s=response.text) + assert response_json["result_code"] == "Fail" + + +def test_body_is_not_an_object( + *, + base_vws_url: str, + vuforia_database: NamedCloudDatabaseCredentials, +) -> None: + """The request body must be a JSON object.""" + response = request_reco_counts_report_json( + base_vws_url=base_vws_url, + vuforia_database=vuforia_database, + database_id=vuforia_database.database_id, + request_json=[], + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + response_json = json.loads(s=response.text) + assert response_json["result_code"] == "Fail" + + +def test_unknown_database_id( + *, base_vws_url: str, vuforia_database: NamedCloudDatabaseCredentials +) -> None: + """The path must name the database which the request's server + keys belong to. + """ + response = request_reco_counts_report( + base_vws_url=base_vws_url, + vuforia_database=vuforia_database, + database_id=uuid.uuid4().hex, + month=month_offset_from_now(months=0), + ) + + assert response.status_code == HTTPStatus.UNAUTHORIZED + response_json = json.loads(s=response.text) + assert response_json["result_code"] == "AuthenticationFailure" + + +def test_database_name_in_path( + *, + base_vws_url: str, + vuforia_database: NamedCloudDatabaseCredentials, +) -> None: + """A database is named in the path by its ID, not by its name.""" + response = request_reco_counts_report( + base_vws_url=base_vws_url, + vuforia_database=vuforia_database, + database_id=vuforia_database.database_name, + month=month_offset_from_now(months=0), + ) + + assert response.status_code == HTTPStatus.UNAUTHORIZED + response_json = json.loads(s=response.text) + assert response_json["result_code"] == "AuthenticationFailure" diff --git a/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/__init__.py b/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/__init__.py new file mode 100644 index 000000000..4e6f31524 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/__init__.py @@ -0,0 +1 @@ +"""VuMark generation API tests.""" diff --git a/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/contract/__init__.py b/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/contract/__init__.py new file mode 100644 index 000000000..2dbb3bed8 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/contract/__init__.py @@ -0,0 +1 @@ +"""Tests using verify mock vuforia.""" diff --git a/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/contract/conftest.py b/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/contract/conftest.py new file mode 100644 index 000000000..d409c8ebf --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/contract/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures scoped to this group of tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def fixture_verify_backend( + *, + verify_mock_vuforia: None, +) -> None: + """Set up the backend for every contract test in this directory.""" + del verify_mock_vuforia diff --git a/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/contract/test_generate_instance.py b/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/contract/test_generate_instance.py new file mode 100644 index 000000000..556def45f --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/contract/test_generate_instance.py @@ -0,0 +1,258 @@ +"""Tests for the VuMark instance generation endpoint.""" + +import json +from http import HTTPStatus +from uuid import uuid4 + +import pytest +from vws import VWS +from vws.exceptions.vws_exceptions import ( + InvalidInstanceIdError, + InvalidTargetTypeError, +) +from vws.vumark_accept import VuMarkAccept + +from vws_mock_contract_tests.fixtures.credentials import ( + CloudDatabaseCredentials, + VuMarkCloudDatabase, +) +from vws_mock_contract_tests.utils import make_image_file +from vws_mock_contract_tests.vumark_generation_api.helpers import ( + PDF_SIGNATURE, + PNG_SIGNATURE, + SVG_START, + JsonValue, + make_vumark_request, + make_vumark_service, +) + + +@pytest.mark.parametrize( + argnames=("accept", "expected_signature"), + argvalues=[ + pytest.param(VuMarkAccept.PNG, PNG_SIGNATURE, id="png"), + pytest.param( + VuMarkAccept.SVG, + SVG_START, + id="svg", + ), + pytest.param( + VuMarkAccept.PDF, + PDF_SIGNATURE, + id="pdf", + ), + ], +) +def test_generate_instance_format( + *, + base_vws_url: str, + accept: VuMarkAccept, + expected_signature: bytes, + vumark_vuforia_database: VuMarkCloudDatabase, +) -> None: + """A VuMark instance can be generated in the requested format.""" + vumark_client = make_vumark_service( + base_vws_url=base_vws_url, + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + ) + vumark_bytes = vumark_client.generate_vumark_instance( + target_id=vumark_vuforia_database.target_id, + instance_id=uuid4().hex, + accept=accept, + ) + + assert vumark_bytes.strip().startswith(expected_signature) + assert len(vumark_bytes) > len(expected_signature) + + +@pytest.mark.parametrize( + argnames=("accept", "expected_content_type"), + argvalues=[ + pytest.param("image/png", "image/png", id="png"), + pytest.param("image/svg+xml", "image/svg+xml", id="svg"), + pytest.param("application/pdf", "application/pdf", id="pdf"), + ], +) +def test_generate_instance_content_type_header( + *, + base_vws_url: str, + accept: str, + expected_content_type: str, + vumark_vuforia_database: VuMarkCloudDatabase, +) -> None: + """VuMark image responses include the expected content type.""" + response = make_vumark_request( + base_vws_url=base_vws_url, + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + target_id=vumark_vuforia_database.target_id, + instance_id=uuid4().hex, + accept=accept, + ) + + assert response.status_code == HTTPStatus.OK + assert ( + response.headers["Content-Type"].split(sep=";")[0] + == expected_content_type + ) + + +def test_invalid_accept_header( + base_vws_url: str, + vumark_vuforia_database: VuMarkCloudDatabase, +) -> None: + """An unsupported Accept header returns an error.""" + response = make_vumark_request( + base_vws_url=base_vws_url, + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + target_id=vumark_vuforia_database.target_id, + instance_id=uuid4().hex, + accept="text/plain", + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + response_json = response.json() + assert response_json["result_code"] == "InvalidAcceptHeader" + + +def test_empty_instance_id( + base_vws_url: str, + vumark_vuforia_database: VuMarkCloudDatabase, +) -> None: + """An empty instance_id returns InvalidInstanceId.""" + vumark_client = make_vumark_service( + base_vws_url=base_vws_url, + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + ) + with pytest.raises(expected_exception=InvalidInstanceIdError) as exc: + _ = vumark_client.generate_vumark_instance( + target_id=vumark_vuforia_database.target_id, + instance_id="", + accept=VuMarkAccept.PNG, + ) + + assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + response_json = json.loads(s=exc.value.response.text) + assert response_json["result_code"] == "InvalidInstanceId" + + +@pytest.mark.parametrize( + argnames="instance_id", + argvalues=[ + pytest.param(5, id="int"), + pytest.param(0, id="zero"), + pytest.param(0.0, id="zero_float"), + pytest.param(1.5, id="float"), + pytest.param(True, id="true"), + pytest.param(False, id="false"), + pytest.param([1], id="array"), + pytest.param([], id="empty_array"), + pytest.param({"a": 1}, id="object"), + pytest.param({}, id="empty_object"), + pytest.param(None, id="null"), + ], +) +def test_non_string_instance_id( + *, + base_vws_url: str, + instance_id: JsonValue, + vumark_vuforia_database: VuMarkCloudDatabase, +) -> None: + """An instance_id which is not a string returns BadRequest.""" + response = make_vumark_request( + base_vws_url=base_vws_url, + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + target_id=vumark_vuforia_database.target_id, + instance_id=instance_id, + accept="image/png", + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + response_json = response.json() + assert response_json["result_code"] == "BadRequest" + + +def test_unknown_target( + base_vws_url: str, + vumark_vuforia_database: VuMarkCloudDatabase, +) -> None: + """An unknown target_id returns UnknownTarget.""" + response = make_vumark_request( + base_vws_url=base_vws_url, + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + target_id=uuid4().hex, + instance_id=uuid4().hex, + accept=VuMarkAccept.PNG, + ) + + assert response.status_code == HTTPStatus.NOT_FOUND + response_json = response.json() + assert response_json["result_code"] == "UnknownTarget" + + +def test_non_vumark_database( + base_vws_url: str, + vuforia_database: CloudDatabaseCredentials, +) -> None: + """Generating a VuMark instance for a target in a non-VuMark + database returns InvalidTargetType. + """ + server_access_key = vuforia_database.server_access_key + server_secret_key = vuforia_database.server_secret_key + vws_client = VWS( + base_vws_url=base_vws_url, + server_access_key=server_access_key, + server_secret_key=server_secret_key, + ) + vumark_client = make_vumark_service( + base_vws_url=base_vws_url, + server_access_key=server_access_key, + server_secret_key=server_secret_key, + ) + image = make_image_file( + file_format="PNG", + color_space="RGB", + width=8, + height=8, + ) + target_id = vws_client.add_target( + name="test", + width=1, + image=image, + active_flag=True, + application_metadata=None, + ) + with pytest.raises(expected_exception=InvalidTargetTypeError) as exc: + _ = vumark_client.generate_vumark_instance( + target_id=target_id, + instance_id=uuid4().hex, + accept=VuMarkAccept.PNG, + ) + + assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + response_json = json.loads(s=exc.value.response.text) + assert response_json["result_code"] == "InvalidTargetType" + + +def test_successful_target( + base_vws_url: str, + vumark_vuforia_database: VuMarkCloudDatabase, +) -> None: + """A VuMark target that has finished processing succeeds.""" + vumark_client = make_vumark_service( + base_vws_url=base_vws_url, + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + ) + vumark_bytes = vumark_client.generate_vumark_instance( + target_id=vumark_vuforia_database.target_id, + instance_id=uuid4().hex, + accept=VuMarkAccept.PNG, + ) + + assert vumark_bytes.strip().startswith(PNG_SIGNATURE) diff --git a/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/contract/test_inactive_database.py b/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/contract/test_inactive_database.py new file mode 100644 index 000000000..0511e76a4 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/contract/test_inactive_database.py @@ -0,0 +1,32 @@ +"""Tests for VuMark generation with an inactive database.""" + +from http import HTTPStatus +from uuid import uuid4 + +from vws_mock_contract_tests.fixtures.credentials import ( + InactiveVuMarkCloudDatabase, +) +from vws_mock_contract_tests.vumark_generation_api.helpers import ( + make_vumark_request, +) + + +def test_inactive_database( + base_vws_url: str, + inactive_vumark_database: InactiveVuMarkCloudDatabase, +) -> None: + """Calling the VuMark generation API with credentials for an + inactive database returns ProjectInactive. + """ + response = make_vumark_request( + base_vws_url=base_vws_url, + server_access_key=inactive_vumark_database.server_access_key, + server_secret_key=inactive_vumark_database.server_secret_key, + target_id=uuid4().hex, + instance_id=uuid4().hex, + accept="image/png", + ) + + assert response.status_code == HTTPStatus.NOT_FOUND + response_json = response.json() + assert response_json["result_code"] == "UnknownTarget" diff --git a/tests/mock_vws/vumark_generation_api/helpers.py b/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/helpers.py similarity index 100% rename from tests/mock_vws/vumark_generation_api/helpers.py rename to contract-tests/src/vws_mock_contract_tests/vumark_generation_api/helpers.py diff --git a/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/mock_only/__init__.py b/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/mock_only/__init__.py new file mode 100644 index 000000000..139671111 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/mock_only/__init__.py @@ -0,0 +1 @@ +"""Tests using mock only vuforia.""" diff --git a/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/mock_only/conftest.py b/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/mock_only/conftest.py new file mode 100644 index 000000000..73958c2e2 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/mock_only/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures scoped to this group of tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def fixture_verify_backend( + *, + mock_only_vuforia: None, +) -> None: + """Set up the backend for every contract test in this directory.""" + del mock_only_vuforia diff --git a/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/mock_only/test_processing_target.py b/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/mock_only/test_processing_target.py new file mode 100644 index 000000000..433175e7c --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/vumark_generation_api/mock_only/test_processing_target.py @@ -0,0 +1,70 @@ +"""Tests for VuMark generation when the target is still processing. + +These use ``mock_only_vuforia`` because there is no way to keep a +VuMark target in PROCESSING state indefinitely on real Vuforia. +""" + +import json +from http import HTTPStatus +from uuid import uuid4 + +import pytest +from vws.exceptions.vws_exceptions import ( + TargetStatusNotSuccessError, +) +from vws.vumark_accept import VuMarkAccept + +from vws_mock_contract_tests.fixtures.credentials import ( + VuMarkCloudDatabase, +) +from vws_mock_contract_tests.vumark_generation_api.helpers import ( + make_vumark_request, + make_vumark_service, +) + + +def test_processing_target( + base_vws_url: str, + vumark_vuforia_database: VuMarkCloudDatabase, +) -> None: + """A VuMark target still processing returns + TargetStatusNotSuccess. + """ + vumark_client = make_vumark_service( + base_vws_url=base_vws_url, + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + ) + with pytest.raises( + expected_exception=TargetStatusNotSuccessError, + ) as exc: + _ = vumark_client.generate_vumark_instance( + target_id=vumark_vuforia_database.processing_target_id, + instance_id=uuid4().hex, + accept=VuMarkAccept.PNG, + ) + + assert exc.value.response.status_code == HTTPStatus.FORBIDDEN + response_json = json.loads(s=exc.value.response.text) + assert response_json["result_code"] == "TargetStatusNotSuccess" + + +def test_processing_target_raw_response( + base_vws_url: str, + vumark_vuforia_database: VuMarkCloudDatabase, +) -> None: + """The raw HTTP response for a processing target has the expected + status code and result code. + """ + response = make_vumark_request( + base_vws_url=base_vws_url, + server_access_key=vumark_vuforia_database.server_access_key, + server_secret_key=vumark_vuforia_database.server_secret_key, + target_id=vumark_vuforia_database.processing_target_id, + instance_id=uuid4().hex, + accept="image/png", + ) + + assert response.status_code == HTTPStatus.FORBIDDEN + response_json = response.json() + assert response_json["result_code"] == "TargetStatusNotSuccess" diff --git a/tests/mock_vws/reco_counts_report/conftest.py b/tests/mock_vws/reco_counts_report/conftest.py index 6d482fd8d..f83bc3e04 100644 --- a/tests/mock_vws/reco_counts_report/conftest.py +++ b/tests/mock_vws/reco_counts_report/conftest.py @@ -1,32 +1,8 @@ -"""Fixtures scoped to this group of tests.""" +"""Compatibility imports for the packaged contract suite.""" -import pytest +from vws_mock_contract_tests.reco_counts_report.conftest import ( + fixture_fresh_database, + fixture_verify_backend, +) -from tests.mock_vws.fixtures.credentials import NamedCloudDatabaseCredentials -from tests.mock_vws.utils.cloud_databases import CreateCloudDatabase - - -@pytest.fixture(name="fresh_database") -def fixture_fresh_database( - *, - create_cloud_database: CreateCloudDatabase, -) -> NamedCloudDatabaseCredentials: - """A database which no report has been requested for. - - The shared test database is one database for the whole test run, so a - backend whose report store outlives a test serves the report which an - earlier test generated for it, as real Vuforia does with the shared - real database. - Tests which need a report which they generated themselves use their own - database. - """ - return create_cloud_database() - - -@pytest.fixture(autouse=True) -def fixture_verify_backend( - *, - mock_only_vuforia: None, -) -> None: - """Set up the backend for every contract test in this directory.""" - del mock_only_vuforia +__all__ = ["fixture_fresh_database", "fixture_verify_backend"] diff --git a/tests/mock_vws/reco_counts_report/test_download_report.py b/tests/mock_vws/reco_counts_report/test_download_report.py index c2b7edc68..dfb9c97fd 100644 --- a/tests/mock_vws/reco_counts_report/test_download_report.py +++ b/tests/mock_vws/reco_counts_report/test_download_report.py @@ -1,207 +1,17 @@ -"""Tests for downloading a generated reco counts report. - -The endpoint is unavailable to the real-Vuforia test account, so these -run against the mocks only. -""" - -import datetime as dt -import re -import time -from http import HTTPStatus -from urllib.parse import urlsplit, urlunsplit - -import requests - -from tests.mock_vws.fixtures.credentials import CloudDatabaseCredentials -from tests.mock_vws.reco_counts_report.helpers import ( - AMZ_DATE_FORMAT, - CSV_HEADER, - CSV_ROW_PATTERN, - REQUEST_IDS_PATTERN, - S3_TIME_FORMAT, - URL_EXPIRY_SECONDS, - XML_DECLARATION, - get_presigned_url, - month_offset_from_now, - utc_now, - wait_for_report, - with_query, +"""Compatibility imports for the packaged contract suite.""" + +from vws_mock_contract_tests.reco_counts_report.test_download_report import ( + test_download_report, + test_expired_url, + test_missing_query, + test_second_request_serves_first_report, + test_shortened_expiry, ) - -def test_download_report( - *, base_vws_url: str, vuforia_database: CloudDatabaseCredentials -) -> None: - """The report is available from the given URL once it is ready. - - Until then the URL gives the response which storage gives for a - missing object. - """ - presigned_url = get_presigned_url( - base_vws_url=base_vws_url, - vuforia_database=vuforia_database, - month=month_offset_from_now(months=0), - ) - - ready_response = wait_for_report(presigned_url=presigned_url) - - assert ready_response.status_code == HTTPStatus.OK - assert ready_response.headers["Content-Type"] == "text/plain" - # A real database may have recognitions this month. - assert ready_response.text.startswith(CSV_HEADER) - rows = ready_response.text.removeprefix(CSV_HEADER) - assert ( - re.fullmatch( - pattern=f"({CSV_ROW_PATTERN.pattern})*", - string=rows, - ) - is not None - ), rows - - -def test_second_request_serves_first_report( - *, - base_vws_url: str, - vuforia_database: CloudDatabaseCredentials, -) -> None: - """A second request in the same hour serves the report which the - first request generated, without generating it again. - """ - month = month_offset_from_now(months=0) - first_url = get_presigned_url( - base_vws_url=base_vws_url, - vuforia_database=vuforia_database, - month=month, - ) - first_response = wait_for_report(presigned_url=first_url) - second_url = get_presigned_url( - base_vws_url=base_vws_url, - vuforia_database=vuforia_database, - month=month, - ) - second_response = requests.get(url=second_url, timeout=30) - - # If the requests straddled an hour boundary, the second one names a - # new file, and nothing is claimed about that. - same_file = urlsplit(url=first_url).path == urlsplit(url=second_url).path - assert not same_file or ( - second_response.status_code == HTTPStatus.OK - and second_response.text == first_response.text - ) - - -def test_expired_url( - *, base_vws_url: str, vuforia_database: CloudDatabaseCredentials -) -> None: - """A URL which has expired gives a 403 response. - - Storage checks the expiry before the signature, so a URL whose - ``X-Amz-Date`` has been moved back gives the same response as a URL - which has expired, even against real Vuforia. - """ - presigned_url = get_presigned_url( - base_vws_url=base_vws_url, - vuforia_database=vuforia_database, - month=month_offset_from_now(months=0), - ) - signed_at = utc_now().replace(microsecond=0) - dt.timedelta( - days=8, - ) - expired_url = with_query( - url=presigned_url, - changes={ - "X-Amz-Date": signed_at.strftime(format=AMZ_DATE_FORMAT), - }, - ) - - response = requests.get(url=expired_url, timeout=30) - - expires_at = signed_at + dt.timedelta( - seconds=URL_EXPIRY_SECONDS, - ) - assert response.status_code == HTTPStatus.FORBIDDEN - assert response.headers["Content-Type"] == "application/xml" - expected_pattern = re.escape( - pattern=( - f"{XML_DECLARATION}AccessDenied" - "Request has expired" - f"{URL_EXPIRY_SECONDS}" - f"{expires_at.strftime(format=S3_TIME_FORMAT)}" - "" - ), - ) - server_time_pattern = ( - r"[0-9]{4}-[0-9]{2}-[0-9]{2}T" - r"[0-9]{2}:[0-9]{2}:[0-9]{2}Z" - ) - assert ( - re.fullmatch( - pattern=( - expected_pattern - + server_time_pattern - + REQUEST_IDS_PATTERN - + "" - ), - string=response.text, - ) - is not None - ), response.text - - -def test_shortened_expiry( - *, base_vws_url: str, vuforia_database: CloudDatabaseCredentials -) -> None: - """The URL expires ``X-Amz-Expires`` seconds after its - ``X-Amz-Date``, so shortening that puts it out of date. - """ - presigned_url = get_presigned_url( - base_vws_url=base_vws_url, - vuforia_database=vuforia_database, - month=month_offset_from_now(months=0), - ) - short_lived_url = with_query( - url=presigned_url, - changes={"X-Amz-Expires": "1"}, - ) - time.sleep(2) - - response = requests.get(url=short_lived_url, timeout=30) - - assert response.status_code == HTTPStatus.FORBIDDEN - assert "Request has expired" in response.text - assert "1" in response.text - - -def test_missing_query( - *, base_vws_url: str, vuforia_database: CloudDatabaseCredentials -) -> None: - """The query parameters of the URL are its authorization, so the - path - alone is refused. - """ - presigned_url = get_presigned_url( - base_vws_url=base_vws_url, - vuforia_database=vuforia_database, - month=month_offset_from_now(months=0), - ) - unsigned_url = urlunsplit( - components=urlsplit(url=presigned_url)._replace(query=""), - ) - - response = requests.get(url=unsigned_url, timeout=30) - - assert response.status_code == HTTPStatus.FORBIDDEN - assert response.headers["Content-Type"] == "application/xml" - expected_pattern = re.escape( - pattern=( - f"{XML_DECLARATION}AccessDenied" - "Access Denied" - ), - ) - assert ( - re.fullmatch( - pattern=expected_pattern + REQUEST_IDS_PATTERN + "", - string=response.text, - ) - is not None - ), response.text +__all__ = [ + "test_download_report", + "test_expired_url", + "test_missing_query", + "test_second_request_serves_first_report", + "test_shortened_expiry", +] diff --git a/tests/mock_vws/reco_counts_report/test_download_report_mock_only.py b/tests/mock_vws/reco_counts_report/test_download_report_mock_only.py index 19e7fe483..9fa613613 100644 --- a/tests/mock_vws/reco_counts_report/test_download_report_mock_only.py +++ b/tests/mock_vws/reco_counts_report/test_download_report_mock_only.py @@ -1,180 +1,15 @@ -"""Tests for downloading a report which cannot run against real -Vuforia. +"""Compatibility imports for the packaged contract suite.""" -Nothing sets recognition counts on real Vuforia, the mock's generation -time is known where real Vuforia's is not, and only real Vuforia can sign -a URL for its storage. -""" - -import io -import uuid -from urllib.parse import urlsplit, urlunsplit - -import pytest -import requests - -from tests.mock_vws.fixtures.credentials import NamedCloudDatabaseCredentials -from tests.mock_vws.reco_counts_report.helpers import ( - CSV_HEADER, - CURRENT_MONTH_RECOS, - PREVIOUS_MONTH_RECOS, - TOTAL_RECOS, - assert_not_ready, - get_presigned_url, - make_vws_client, - month_offset_from_now, - report_key, - wait_for_report, +from vws_mock_contract_tests.reco_counts_report.test_download_report_mock_only import ( # noqa: E501 + test_counts_set_after_first_request, + test_not_ready, + test_seeded_recognition_counts, + test_unknown_report, ) -from tests.mock_vws.utils.recognition_counts import SeedRecognitionCounts - - -def test_not_ready( - *, base_vws_url: str, fresh_database: NamedCloudDatabaseCredentials -) -> None: - """Until the report is generated, the URL gives the response which - storage gives for a missing object. - """ - presigned_url = get_presigned_url( - base_vws_url=base_vws_url, - vuforia_database=fresh_database, - month=month_offset_from_now(months=0), - ) - - response = requests.get(url=presigned_url, timeout=30) - - assert_not_ready( - response=response, - key=report_key(url=presigned_url), - ) - - -def test_unknown_report( - *, base_vws_url: str, vuforia_database: NamedCloudDatabaseCredentials -) -> None: - """A file which no request generated gives the response which - storage gives for a missing object. - - Real Vuforia refuses such a URL because its signature does not match - the file, and the mock does not check signatures. - """ - presigned_url = get_presigned_url( - base_vws_url=base_vws_url, - vuforia_database=vuforia_database, - month=month_offset_from_now(months=0), - ) - split_url = urlsplit(url=presigned_url) - database_id = vuforia_database.database_id - unknown_path = f"/reports/{database_id}/1999-01.csv" - unknown_url = urlunsplit( - components=split_url._replace(path=unknown_path), - ) - - response = requests.get(url=unknown_url, timeout=30) - - assert_not_ready(response=response, key=unknown_path.lstrip("/")) - - -@pytest.mark.parametrize( - argnames=("months_ago", "expected_reco_count"), - argvalues=[ - (0, CURRENT_MONTH_RECOS), - (1, PREVIOUS_MONTH_RECOS), - ], - ids=["current_month", "previous_month"], -) -def test_seeded_recognition_counts( - *, - base_vws_url: str, - seed_recognition_counts: SeedRecognitionCounts, - fresh_database: NamedCloudDatabaseCredentials, - high_quality_image: io.BytesIO, - image_file_failed_state: io.BytesIO, - months_ago: int, - expected_reco_count: int, -) -> None: - """The report has a row for each target recognized in the - month. - """ - vws_client = make_vws_client( - base_vws_url=base_vws_url, database=fresh_database - ) - recognized_target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - unrecognized_target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=image_file_failed_state, - active_flag=True, - application_metadata=None, - ) - seed_recognition_counts( - database_name=fresh_database.database_name, - target_id=recognized_target_id, - current_month_recos=CURRENT_MONTH_RECOS, - previous_month_recos=PREVIOUS_MONTH_RECOS, - total_recos=TOTAL_RECOS, - ) - presigned_url = get_presigned_url( - base_vws_url=base_vws_url, - vuforia_database=fresh_database, - month=month_offset_from_now(months=-months_ago), - ) - - ready_response = wait_for_report(presigned_url=presigned_url) - - assert ready_response.text == ( - f"{CSV_HEADER}{recognized_target_id},{expected_reco_count}\r\n" - ) - assert unrecognized_target_id not in ready_response.text - - -def test_counts_set_after_first_request( - *, - base_vws_url: str, - seed_recognition_counts: SeedRecognitionCounts, - fresh_database: NamedCloudDatabaseCredentials, - high_quality_image: io.BytesIO, -) -> None: - """A second request in the same hour does not generate the report - again, so counts set between the two requests are not in it. - """ - vws_client = make_vws_client( - base_vws_url=base_vws_url, database=fresh_database - ) - target_id = vws_client.add_target( - name=uuid.uuid4().hex, - width=1, - image=high_quality_image, - active_flag=True, - application_metadata=None, - ) - month = month_offset_from_now(months=0) - first_url = get_presigned_url( - base_vws_url=base_vws_url, - vuforia_database=fresh_database, - month=month, - ) - seed_recognition_counts( - database_name=fresh_database.database_name, - target_id=target_id, - current_month_recos=CURRENT_MONTH_RECOS, - previous_month_recos=PREVIOUS_MONTH_RECOS, - total_recos=TOTAL_RECOS, - ) - second_url = get_presigned_url( - base_vws_url=base_vws_url, - vuforia_database=fresh_database, - month=month, - ) - ready_response = wait_for_report(presigned_url=second_url) - # If the requests straddled an hour boundary, the second one names a - # new file, and nothing is claimed about that. - same_file = urlsplit(url=first_url).path == urlsplit(url=second_url).path - assert not same_file or ready_response.text == CSV_HEADER +__all__ = [ + "test_counts_set_after_first_request", + "test_not_ready", + "test_seeded_recognition_counts", + "test_unknown_report", +] diff --git a/tests/mock_vws/reco_counts_report/test_reco_counts_report.py b/tests/mock_vws/reco_counts_report/test_reco_counts_report.py index 0b74e81ab..2c2e61313 100644 --- a/tests/mock_vws/reco_counts_report/test_reco_counts_report.py +++ b/tests/mock_vws/reco_counts_report/test_reco_counts_report.py @@ -1,273 +1,27 @@ -"""Tests for requesting a reco counts report.""" - -import datetime as dt -import json -import uuid -from http import HTTPStatus -from string import hexdigits -from urllib.parse import parse_qsl, urlsplit -from zoneinfo import ZoneInfo - -import pytest - -from tests.mock_vws.fixtures.credentials import NamedCloudDatabaseCredentials -from tests.mock_vws.reco_counts_report.helpers import ( - AMZ_DATE_FORMAT, - PRESIGNED_URL_PARAMETERS, - URL_EXPIRY_SECONDS, - get_presigned_url, - month_offset_from_now, - request_reco_counts_report, - request_reco_counts_report_json, - utc_now, -) - - -@pytest.mark.parametrize( - argnames="months_ago", - argvalues=[0, 1], - ids=["current_month", "previous_month"], -) -def test_reco_counts_report( - *, - base_vws_url: str, - vuforia_database: NamedCloudDatabaseCredentials, - months_ago: int, -) -> None: - """A report can be requested for the current and previous - month. - """ - response = request_reco_counts_report( - base_vws_url=base_vws_url, - vuforia_database=vuforia_database, - database_id=vuforia_database.database_id, - month=month_offset_from_now(months=-months_ago), - ) - - assert response.status_code == HTTPStatus.OK - response_json = json.loads(s=response.text) - assert response_json.keys() == { - "result_code", - "transaction_id", - "presigned_url", - } - assert response_json["result_code"] == "Success" - transaction_id: object = response_json["transaction_id"] - assert isinstance(transaction_id, str) - assert all(char in hexdigits for char in transaction_id) - presigned_url: object = response_json["presigned_url"] - assert isinstance(presigned_url, str) - download_url = urlsplit(url=presigned_url) - assert download_url.scheme in {"http", "https"} - assert download_url.netloc != "" - - -def test_presigned_url_query( - *, base_vws_url: str, vuforia_database: NamedCloudDatabaseCredentials -) -> None: - """The URL carries the query parameters of a presigned URL. - - It is signed for one second under seven days. - """ - before = utc_now().replace(microsecond=0) - presigned_url = get_presigned_url( - base_vws_url=base_vws_url, - vuforia_database=vuforia_database, - month=month_offset_from_now(months=0), - ) - after = utc_now() - - query = parse_qsl(qs=urlsplit(url=presigned_url).query) - assert [name for name, _ in query] == PRESIGNED_URL_PARAMETERS - parameters = dict(query) - assert parameters["X-Amz-Security-Token"] != "" - assert parameters["X-Amz-Algorithm"] == "AWS4-HMAC-SHA256" - assert parameters["X-Amz-SignedHeaders"] == "host" - assert parameters["X-Amz-Expires"] == str(object=URL_EXPIRY_SECONDS) - assert all(char in hexdigits for char in parameters["X-Amz-Signature"]) - signed_at = dt.datetime.strptime( - parameters["X-Amz-Date"], - AMZ_DATE_FORMAT, - ).replace(tzinfo=ZoneInfo(key="UTC")) - # Real Vuforia's clock is not the test's clock. - clock_skew = dt.timedelta(minutes=5) - assert before - clock_skew <= signed_at <= after + clock_skew - signed_date = signed_at.strftime(format="%Y%m%d") - assert parameters["X-Amz-Credential"].endswith( - f"/{signed_date}/us-west-1/s3/aws4_request", - ) - - -def test_current_month_file_name( - *, - base_vws_url: str, - vuforia_database: NamedCloudDatabaseCredentials, -) -> None: - """A report for the current month is named for the date and the - hour. - """ - hours_before = utc_now().strftime(format="%Y-%m-%d-%H") - presigned_url = get_presigned_url( - base_vws_url=base_vws_url, - vuforia_database=vuforia_database, - month=month_offset_from_now(months=0), - ) - hours_after = utc_now().strftime(format="%Y-%m-%d-%H") - - database_id = vuforia_database.database_id - expected_paths = { - f"/reports/{database_id}/{hour}.csv" - for hour in (hours_before, hours_after) - } - assert urlsplit(url=presigned_url).path in expected_paths - - -def test_previous_month_file_name( - *, - base_vws_url: str, - vuforia_database: NamedCloudDatabaseCredentials, -) -> None: - """A report for the previous month is named for the month.""" - month = month_offset_from_now(months=-1) - presigned_url = get_presigned_url( - base_vws_url=base_vws_url, - vuforia_database=vuforia_database, - month=month, - ) - - database_id = vuforia_database.database_id - expected_path = f"/reports/{database_id}/{month}.csv" - assert urlsplit(url=presigned_url).path == expected_path - - -def test_same_file_in_the_same_hour( - *, - base_vws_url: str, - vuforia_database: NamedCloudDatabaseCredentials, -) -> None: - """Two requests for the current month in the same hour name the - same file. - """ - month = month_offset_from_now(months=0) - first_url = get_presigned_url( - base_vws_url=base_vws_url, - vuforia_database=vuforia_database, - month=month, - ) - second_url = get_presigned_url( - base_vws_url=base_vws_url, - vuforia_database=vuforia_database, - month=month, - ) - hour_after = utc_now().strftime(format="%Y-%m-%d-%H") - - # If the requests straddled an hour boundary, the second one - # legitimately names the new hour's file instead. - database_id = vuforia_database.database_id - expected_paths = { - urlsplit(url=first_url).path, - f"/reports/{database_id}/{hour_after}.csv", - } - assert urlsplit(url=second_url).path in expected_paths - - -@pytest.mark.parametrize( - argnames="months_ago", - argvalues=[2, -1], - ids=["too_old", "in_the_future"], -) -def test_month_out_of_range( - *, - base_vws_url: str, - vuforia_database: NamedCloudDatabaseCredentials, - months_ago: int, -) -> None: - """Only the current and the previous month can be requested.""" - response = request_reco_counts_report( - base_vws_url=base_vws_url, - vuforia_database=vuforia_database, - database_id=vuforia_database.database_id, - month=month_offset_from_now(months=-months_ago), - ) - - assert response.status_code == HTTPStatus.BAD_REQUEST - response_json = json.loads(s=response.text) - assert response_json["result_code"] == "Fail" - - -@pytest.mark.parametrize( - argnames="month", - argvalues=["2020", "2020-1", "January", "2020-01-01", 202001], - ids=["year_only", "one_digit", "name", "date", "not_a_string"], +"""Compatibility imports for the packaged contract suite.""" + +from vws_mock_contract_tests.reco_counts_report.test_reco_counts_report import ( # noqa: E501 + test_body_is_not_an_object, + test_current_month_file_name, + test_database_name_in_path, + test_malformed_month, + test_month_out_of_range, + test_presigned_url_query, + test_previous_month_file_name, + test_reco_counts_report, + test_same_file_in_the_same_hour, + test_unknown_database_id, ) -def test_malformed_month( - *, - base_vws_url: str, - vuforia_database: NamedCloudDatabaseCredentials, - month: str | int, -) -> None: - """The month must be given in the ``YYYY-mm`` form.""" - response = request_reco_counts_report( - base_vws_url=base_vws_url, - vuforia_database=vuforia_database, - database_id=vuforia_database.database_id, - month=month, - ) - - assert response.status_code == HTTPStatus.BAD_REQUEST - response_json = json.loads(s=response.text) - assert response_json["result_code"] == "Fail" - - -def test_body_is_not_an_object( - *, - base_vws_url: str, - vuforia_database: NamedCloudDatabaseCredentials, -) -> None: - """The request body must be a JSON object.""" - response = request_reco_counts_report_json( - base_vws_url=base_vws_url, - vuforia_database=vuforia_database, - database_id=vuforia_database.database_id, - request_json=[], - ) - - assert response.status_code == HTTPStatus.BAD_REQUEST - response_json = json.loads(s=response.text) - assert response_json["result_code"] == "Fail" - - -def test_unknown_database_id( - *, base_vws_url: str, vuforia_database: NamedCloudDatabaseCredentials -) -> None: - """The path must name the database which the request's server - keys belong to. - """ - response = request_reco_counts_report( - base_vws_url=base_vws_url, - vuforia_database=vuforia_database, - database_id=uuid.uuid4().hex, - month=month_offset_from_now(months=0), - ) - - assert response.status_code == HTTPStatus.UNAUTHORIZED - response_json = json.loads(s=response.text) - assert response_json["result_code"] == "AuthenticationFailure" - - -def test_database_name_in_path( - *, - base_vws_url: str, - vuforia_database: NamedCloudDatabaseCredentials, -) -> None: - """A database is named in the path by its ID, not by its name.""" - response = request_reco_counts_report( - base_vws_url=base_vws_url, - vuforia_database=vuforia_database, - database_id=vuforia_database.database_name, - month=month_offset_from_now(months=0), - ) - assert response.status_code == HTTPStatus.UNAUTHORIZED - response_json = json.loads(s=response.text) - assert response_json["result_code"] == "AuthenticationFailure" +__all__ = [ + "test_body_is_not_an_object", + "test_current_month_file_name", + "test_database_name_in_path", + "test_malformed_month", + "test_month_out_of_range", + "test_presigned_url_query", + "test_previous_month_file_name", + "test_reco_counts_report", + "test_same_file_in_the_same_hour", + "test_unknown_database_id", +] diff --git a/tests/mock_vws/requests_mock/test_reco_counts_report.py b/tests/mock_vws/requests_mock/test_reco_counts_report.py index 9a8407cf5..196c32b77 100644 --- a/tests/mock_vws/requests_mock/test_reco_counts_report.py +++ b/tests/mock_vws/requests_mock/test_reco_counts_report.py @@ -10,7 +10,7 @@ from mock_vws import MockVWS from mock_vws.database import CloudDatabase -from tests.mock_vws.reco_counts_report.helpers import ( +from vws_mock_contract_tests.reco_counts_report.helpers import ( CSV_HEADER, assert_not_ready, get_presigned_url, diff --git a/tests/mock_vws/vumark_generation_api/contract/conftest.py b/tests/mock_vws/vumark_generation_api/contract/conftest.py index d409c8ebf..94798704f 100644 --- a/tests/mock_vws/vumark_generation_api/contract/conftest.py +++ b/tests/mock_vws/vumark_generation_api/contract/conftest.py @@ -1,12 +1,7 @@ -"""Fixtures scoped to this group of tests.""" +"""Compatibility imports for the packaged contract suite.""" -import pytest +from vws_mock_contract_tests.vumark_generation_api.contract.conftest import ( + fixture_verify_backend, +) - -@pytest.fixture(autouse=True) -def fixture_verify_backend( - *, - verify_mock_vuforia: None, -) -> None: - """Set up the backend for every contract test in this directory.""" - del verify_mock_vuforia +__all__ = ["fixture_verify_backend"] diff --git a/tests/mock_vws/vumark_generation_api/contract/test_generate_instance.py b/tests/mock_vws/vumark_generation_api/contract/test_generate_instance.py index bbd34b9af..8729a502f 100644 --- a/tests/mock_vws/vumark_generation_api/contract/test_generate_instance.py +++ b/tests/mock_vws/vumark_generation_api/contract/test_generate_instance.py @@ -1,258 +1,23 @@ -"""Tests for the VuMark instance generation endpoint.""" - -import json -from http import HTTPStatus -from uuid import uuid4 - -import pytest -from vws import VWS -from vws.exceptions.vws_exceptions import ( - InvalidInstanceIdError, - InvalidTargetTypeError, -) -from vws.vumark_accept import VuMarkAccept - -from tests.mock_vws.fixtures.credentials import ( - CloudDatabaseCredentials, - VuMarkCloudDatabase, -) -from tests.mock_vws.utils import make_image_file -from tests.mock_vws.vumark_generation_api.helpers import ( - PDF_SIGNATURE, - PNG_SIGNATURE, - SVG_START, - JsonValue, - make_vumark_request, - make_vumark_service, -) - - -@pytest.mark.parametrize( - argnames=("accept", "expected_signature"), - argvalues=[ - pytest.param(VuMarkAccept.PNG, PNG_SIGNATURE, id="png"), - pytest.param( - VuMarkAccept.SVG, - SVG_START, - id="svg", - ), - pytest.param( - VuMarkAccept.PDF, - PDF_SIGNATURE, - id="pdf", - ), - ], +"""Compatibility imports for the packaged contract suite.""" + +from vws_mock_contract_tests.vumark_generation_api.contract.test_generate_instance import ( # noqa: E501 + test_empty_instance_id, + test_generate_instance_content_type_header, + test_generate_instance_format, + test_invalid_accept_header, + test_non_string_instance_id, + test_non_vumark_database, + test_successful_target, + test_unknown_target, ) -def test_generate_instance_format( - *, - base_vws_url: str, - accept: VuMarkAccept, - expected_signature: bytes, - vumark_vuforia_database: VuMarkCloudDatabase, -) -> None: - """A VuMark instance can be generated in the requested format.""" - vumark_client = make_vumark_service( - base_vws_url=base_vws_url, - server_access_key=vumark_vuforia_database.server_access_key, - server_secret_key=vumark_vuforia_database.server_secret_key, - ) - vumark_bytes = vumark_client.generate_vumark_instance( - target_id=vumark_vuforia_database.target_id, - instance_id=uuid4().hex, - accept=accept, - ) - - assert vumark_bytes.strip().startswith(expected_signature) - assert len(vumark_bytes) > len(expected_signature) - - -@pytest.mark.parametrize( - argnames=("accept", "expected_content_type"), - argvalues=[ - pytest.param("image/png", "image/png", id="png"), - pytest.param("image/svg+xml", "image/svg+xml", id="svg"), - pytest.param("application/pdf", "application/pdf", id="pdf"), - ], -) -def test_generate_instance_content_type_header( - *, - base_vws_url: str, - accept: str, - expected_content_type: str, - vumark_vuforia_database: VuMarkCloudDatabase, -) -> None: - """VuMark image responses include the expected content type.""" - response = make_vumark_request( - base_vws_url=base_vws_url, - server_access_key=vumark_vuforia_database.server_access_key, - server_secret_key=vumark_vuforia_database.server_secret_key, - target_id=vumark_vuforia_database.target_id, - instance_id=uuid4().hex, - accept=accept, - ) - - assert response.status_code == HTTPStatus.OK - assert ( - response.headers["Content-Type"].split(sep=";")[0] - == expected_content_type - ) - - -def test_invalid_accept_header( - base_vws_url: str, - vumark_vuforia_database: VuMarkCloudDatabase, -) -> None: - """An unsupported Accept header returns an error.""" - response = make_vumark_request( - base_vws_url=base_vws_url, - server_access_key=vumark_vuforia_database.server_access_key, - server_secret_key=vumark_vuforia_database.server_secret_key, - target_id=vumark_vuforia_database.target_id, - instance_id=uuid4().hex, - accept="text/plain", - ) - - assert response.status_code == HTTPStatus.BAD_REQUEST - response_json = response.json() - assert response_json["result_code"] == "InvalidAcceptHeader" - - -def test_empty_instance_id( - base_vws_url: str, - vumark_vuforia_database: VuMarkCloudDatabase, -) -> None: - """An empty instance_id returns InvalidInstanceId.""" - vumark_client = make_vumark_service( - base_vws_url=base_vws_url, - server_access_key=vumark_vuforia_database.server_access_key, - server_secret_key=vumark_vuforia_database.server_secret_key, - ) - with pytest.raises(expected_exception=InvalidInstanceIdError) as exc: - _ = vumark_client.generate_vumark_instance( - target_id=vumark_vuforia_database.target_id, - instance_id="", - accept=VuMarkAccept.PNG, - ) - - assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - response_json = json.loads(s=exc.value.response.text) - assert response_json["result_code"] == "InvalidInstanceId" - - -@pytest.mark.parametrize( - argnames="instance_id", - argvalues=[ - pytest.param(5, id="int"), - pytest.param(0, id="zero"), - pytest.param(0.0, id="zero_float"), - pytest.param(1.5, id="float"), - pytest.param(True, id="true"), - pytest.param(False, id="false"), - pytest.param([1], id="array"), - pytest.param([], id="empty_array"), - pytest.param({"a": 1}, id="object"), - pytest.param({}, id="empty_object"), - pytest.param(None, id="null"), - ], -) -def test_non_string_instance_id( - *, - base_vws_url: str, - instance_id: JsonValue, - vumark_vuforia_database: VuMarkCloudDatabase, -) -> None: - """An instance_id which is not a string returns BadRequest.""" - response = make_vumark_request( - base_vws_url=base_vws_url, - server_access_key=vumark_vuforia_database.server_access_key, - server_secret_key=vumark_vuforia_database.server_secret_key, - target_id=vumark_vuforia_database.target_id, - instance_id=instance_id, - accept="image/png", - ) - - assert response.status_code == HTTPStatus.BAD_REQUEST - response_json = response.json() - assert response_json["result_code"] == "BadRequest" - - -def test_unknown_target( - base_vws_url: str, - vumark_vuforia_database: VuMarkCloudDatabase, -) -> None: - """An unknown target_id returns UnknownTarget.""" - response = make_vumark_request( - base_vws_url=base_vws_url, - server_access_key=vumark_vuforia_database.server_access_key, - server_secret_key=vumark_vuforia_database.server_secret_key, - target_id=uuid4().hex, - instance_id=uuid4().hex, - accept=VuMarkAccept.PNG, - ) - - assert response.status_code == HTTPStatus.NOT_FOUND - response_json = response.json() - assert response_json["result_code"] == "UnknownTarget" - - -def test_non_vumark_database( - base_vws_url: str, - vuforia_database: CloudDatabaseCredentials, -) -> None: - """Generating a VuMark instance for a target in a non-VuMark - database returns InvalidTargetType. - """ - server_access_key = vuforia_database.server_access_key - server_secret_key = vuforia_database.server_secret_key - vws_client = VWS( - base_vws_url=base_vws_url, - server_access_key=server_access_key, - server_secret_key=server_secret_key, - ) - vumark_client = make_vumark_service( - base_vws_url=base_vws_url, - server_access_key=server_access_key, - server_secret_key=server_secret_key, - ) - image = make_image_file( - file_format="PNG", - color_space="RGB", - width=8, - height=8, - ) - target_id = vws_client.add_target( - name="test", - width=1, - image=image, - active_flag=True, - application_metadata=None, - ) - with pytest.raises(expected_exception=InvalidTargetTypeError) as exc: - _ = vumark_client.generate_vumark_instance( - target_id=target_id, - instance_id=uuid4().hex, - accept=VuMarkAccept.PNG, - ) - - assert exc.value.response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - response_json = json.loads(s=exc.value.response.text) - assert response_json["result_code"] == "InvalidTargetType" - - -def test_successful_target( - base_vws_url: str, - vumark_vuforia_database: VuMarkCloudDatabase, -) -> None: - """A VuMark target that has finished processing succeeds.""" - vumark_client = make_vumark_service( - base_vws_url=base_vws_url, - server_access_key=vumark_vuforia_database.server_access_key, - server_secret_key=vumark_vuforia_database.server_secret_key, - ) - vumark_bytes = vumark_client.generate_vumark_instance( - target_id=vumark_vuforia_database.target_id, - instance_id=uuid4().hex, - accept=VuMarkAccept.PNG, - ) - assert vumark_bytes.strip().startswith(PNG_SIGNATURE) +__all__ = [ + "test_empty_instance_id", + "test_generate_instance_content_type_header", + "test_generate_instance_format", + "test_invalid_accept_header", + "test_non_string_instance_id", + "test_non_vumark_database", + "test_successful_target", + "test_unknown_target", +] diff --git a/tests/mock_vws/vumark_generation_api/contract/test_inactive_database.py b/tests/mock_vws/vumark_generation_api/contract/test_inactive_database.py index e1d7c9bcd..84ea0d873 100644 --- a/tests/mock_vws/vumark_generation_api/contract/test_inactive_database.py +++ b/tests/mock_vws/vumark_generation_api/contract/test_inactive_database.py @@ -1,32 +1,7 @@ -"""Tests for VuMark generation with an inactive database.""" +"""Compatibility imports for the packaged contract suite.""" -from http import HTTPStatus -from uuid import uuid4 - -from tests.mock_vws.fixtures.credentials import ( - InactiveVuMarkCloudDatabase, -) -from tests.mock_vws.vumark_generation_api.helpers import ( - make_vumark_request, +from vws_mock_contract_tests.vumark_generation_api.contract.test_inactive_database import ( # noqa: E501 + test_inactive_database, ) - -def test_inactive_database( - base_vws_url: str, - inactive_vumark_database: InactiveVuMarkCloudDatabase, -) -> None: - """Calling the VuMark generation API with credentials for an - inactive database returns ProjectInactive. - """ - response = make_vumark_request( - base_vws_url=base_vws_url, - server_access_key=inactive_vumark_database.server_access_key, - server_secret_key=inactive_vumark_database.server_secret_key, - target_id=uuid4().hex, - instance_id=uuid4().hex, - accept="image/png", - ) - - assert response.status_code == HTTPStatus.NOT_FOUND - response_json = response.json() - assert response_json["result_code"] == "UnknownTarget" +__all__ = ["test_inactive_database"] diff --git a/tests/mock_vws/vumark_generation_api/mock_only/conftest.py b/tests/mock_vws/vumark_generation_api/mock_only/conftest.py index 73958c2e2..6006c6bd1 100644 --- a/tests/mock_vws/vumark_generation_api/mock_only/conftest.py +++ b/tests/mock_vws/vumark_generation_api/mock_only/conftest.py @@ -1,12 +1,7 @@ -"""Fixtures scoped to this group of tests.""" +"""Compatibility imports for the packaged contract suite.""" -import pytest +from vws_mock_contract_tests.vumark_generation_api.mock_only.conftest import ( + fixture_verify_backend, +) - -@pytest.fixture(autouse=True) -def fixture_verify_backend( - *, - mock_only_vuforia: None, -) -> None: - """Set up the backend for every contract test in this directory.""" - del mock_only_vuforia +__all__ = ["fixture_verify_backend"] diff --git a/tests/mock_vws/vumark_generation_api/mock_only/test_processing_target.py b/tests/mock_vws/vumark_generation_api/mock_only/test_processing_target.py index c1f28f04f..cd3a20726 100644 --- a/tests/mock_vws/vumark_generation_api/mock_only/test_processing_target.py +++ b/tests/mock_vws/vumark_generation_api/mock_only/test_processing_target.py @@ -1,70 +1,8 @@ -"""Tests for VuMark generation when the target is still processing. +"""Compatibility imports for the packaged contract suite.""" -These use ``mock_only_vuforia`` because there is no way to keep a -VuMark target in PROCESSING state indefinitely on real Vuforia. -""" - -import json -from http import HTTPStatus -from uuid import uuid4 - -import pytest -from vws.exceptions.vws_exceptions import ( - TargetStatusNotSuccessError, -) -from vws.vumark_accept import VuMarkAccept - -from tests.mock_vws.fixtures.credentials import ( - VuMarkCloudDatabase, -) -from tests.mock_vws.vumark_generation_api.helpers import ( - make_vumark_request, - make_vumark_service, +from vws_mock_contract_tests.vumark_generation_api.mock_only.test_processing_target import ( # noqa: E501 + test_processing_target, + test_processing_target_raw_response, ) - -def test_processing_target( - base_vws_url: str, - vumark_vuforia_database: VuMarkCloudDatabase, -) -> None: - """A VuMark target still processing returns - TargetStatusNotSuccess. - """ - vumark_client = make_vumark_service( - base_vws_url=base_vws_url, - server_access_key=vumark_vuforia_database.server_access_key, - server_secret_key=vumark_vuforia_database.server_secret_key, - ) - with pytest.raises( - expected_exception=TargetStatusNotSuccessError, - ) as exc: - _ = vumark_client.generate_vumark_instance( - target_id=vumark_vuforia_database.processing_target_id, - instance_id=uuid4().hex, - accept=VuMarkAccept.PNG, - ) - - assert exc.value.response.status_code == HTTPStatus.FORBIDDEN - response_json = json.loads(s=exc.value.response.text) - assert response_json["result_code"] == "TargetStatusNotSuccess" - - -def test_processing_target_raw_response( - base_vws_url: str, - vumark_vuforia_database: VuMarkCloudDatabase, -) -> None: - """The raw HTTP response for a processing target has the expected - status code and result code. - """ - response = make_vumark_request( - base_vws_url=base_vws_url, - server_access_key=vumark_vuforia_database.server_access_key, - server_secret_key=vumark_vuforia_database.server_secret_key, - target_id=vumark_vuforia_database.processing_target_id, - instance_id=uuid4().hex, - accept="image/png", - ) - - assert response.status_code == HTTPStatus.FORBIDDEN - response_json = response.json() - assert response_json["result_code"] == "TargetStatusNotSuccess" +__all__ = ["test_processing_target", "test_processing_target_raw_response"] From 0ac00ff545426e6f63bc104211ec68a6e91c6839 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 28 Oct 2026 17:47:43 +0000 Subject: [PATCH 05/10] Support workspace packages in Docker and documentation builds --- docs/requirements.txt | 4 ++++ src/mock_vws/_flask_server/Dockerfile | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 docs/requirements.txt diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 000000000..48e8c6d74 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,4 @@ +# Install local workspace projects before the pages action installs the dev group. +. +./contract-tests +./test-backends diff --git a/src/mock_vws/_flask_server/Dockerfile b/src/mock_vws/_flask_server/Dockerfile index 35b3b1f3b..b9a5b398b 100644 --- a/src/mock_vws/_flask_server/Dockerfile +++ b/src/mock_vws/_flask_server/Dockerfile @@ -21,7 +21,7 @@ WORKDIR /app # Install the locked dependencies before copying the source, so that # source edits do not invalidate the dependency layer. COPY --chown=10001:10001 pyproject.toml uv.lock /app/ -RUN uv sync --locked --no-cache --no-dev --no-install-project +RUN uv sync --frozen --no-cache --no-dev --no-install-workspace COPY --chown=10001:10001 . /app RUN uv sync --locked --no-cache --no-dev EXPOSE 5000 From eb1d93ed66ec0401bd3abe864e2b6af7199b51d4 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 28 Oct 2026 17:47:50 +0000 Subject: [PATCH 06/10] Package the shared Model Target API contracts --- .../model_target_web_api/authentication.py | 0 .../authentication_tests/__init__.py | 1 + .../authentication_tests/contract/__init__.py | 1 + .../authentication_tests/contract/conftest.py | 12 + .../contract/test_authentication.py | 335 +++++++++ .../contract/test_authorization_header.py | 118 ++++ .../model_target_web_api/errors/__init__.py | 1 + .../errors/contract/__init__.py | 1 + .../errors/contract/conftest.py | 12 + .../errors/contract/test_error_responses.py | 657 +++++++++++++++++ .../model_target_web_api/errors/helpers.py | 2 +- .../errors/test_errors.py | 105 +++ .../model_target_web_api/requests/__init__.py | 1 + .../requests/contract/__init__.py | 1 + .../requests/contract/conftest.py | 12 + .../requests/contract/test_content_length.py | 101 +++ .../model_target_web_api/requests/helpers.py | 6 +- .../requests/test_requests.py | 212 ++++++ .../model_target_web_api/responses.py | 0 .../model_target_web_api/sample_data.py | 0 .../model_target_web_api/test_datasets.py | 626 +++++++++++++++++ .../authentication_tests/contract/conftest.py | 15 +- .../contract/test_authentication.py | 346 +-------- .../contract/test_authorization_header.py | 120 +--- .../errors/contract/conftest.py | 15 +- .../errors/contract/test_error_responses.py | 664 +----------------- .../errors/test_errors.py | 114 +-- .../requests/contract/conftest.py | 15 +- .../requests/contract/test_content_length.py | 108 +-- .../requests/test_requests.py | 221 +----- .../model_target_web_api/test_datasets.py | 649 +---------------- .../test_mock_only_oauth2_edge_cases.py | 10 +- .../test_model_target_response_policy.py | 10 +- .../requests_mock/test_model_targets.py | 8 +- 34 files changed, 2320 insertions(+), 2179 deletions(-) rename {tests/mock_vws => contract-tests/src/vws_mock_contract_tests}/model_target_web_api/authentication.py (100%) create mode 100644 contract-tests/src/vws_mock_contract_tests/model_target_web_api/authentication_tests/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/model_target_web_api/authentication_tests/contract/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/model_target_web_api/authentication_tests/contract/conftest.py create mode 100644 contract-tests/src/vws_mock_contract_tests/model_target_web_api/authentication_tests/contract/test_authentication.py create mode 100644 contract-tests/src/vws_mock_contract_tests/model_target_web_api/authentication_tests/contract/test_authorization_header.py create mode 100644 contract-tests/src/vws_mock_contract_tests/model_target_web_api/errors/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/model_target_web_api/errors/contract/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/model_target_web_api/errors/contract/conftest.py create mode 100644 contract-tests/src/vws_mock_contract_tests/model_target_web_api/errors/contract/test_error_responses.py rename {tests/mock_vws => contract-tests/src/vws_mock_contract_tests}/model_target_web_api/errors/helpers.py (92%) create mode 100644 contract-tests/src/vws_mock_contract_tests/model_target_web_api/errors/test_errors.py create mode 100644 contract-tests/src/vws_mock_contract_tests/model_target_web_api/requests/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/model_target_web_api/requests/contract/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/model_target_web_api/requests/contract/conftest.py create mode 100644 contract-tests/src/vws_mock_contract_tests/model_target_web_api/requests/contract/test_content_length.py rename {tests/mock_vws => contract-tests/src/vws_mock_contract_tests}/model_target_web_api/requests/helpers.py (92%) create mode 100644 contract-tests/src/vws_mock_contract_tests/model_target_web_api/requests/test_requests.py rename {tests/mock_vws => contract-tests/src/vws_mock_contract_tests}/model_target_web_api/responses.py (100%) rename {tests/mock_vws => contract-tests/src/vws_mock_contract_tests}/model_target_web_api/sample_data.py (100%) create mode 100644 contract-tests/src/vws_mock_contract_tests/model_target_web_api/test_datasets.py diff --git a/tests/mock_vws/model_target_web_api/authentication.py b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/authentication.py similarity index 100% rename from tests/mock_vws/model_target_web_api/authentication.py rename to contract-tests/src/vws_mock_contract_tests/model_target_web_api/authentication.py diff --git a/contract-tests/src/vws_mock_contract_tests/model_target_web_api/authentication_tests/__init__.py b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/authentication_tests/__init__.py new file mode 100644 index 000000000..44b59114a --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/authentication_tests/__init__.py @@ -0,0 +1 @@ +"""Authentication tests.""" diff --git a/contract-tests/src/vws_mock_contract_tests/model_target_web_api/authentication_tests/contract/__init__.py b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/authentication_tests/contract/__init__.py new file mode 100644 index 000000000..7c8ab3fe3 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/authentication_tests/contract/__init__.py @@ -0,0 +1 @@ +"""Tests using verify model target mock vuforia.""" diff --git a/contract-tests/src/vws_mock_contract_tests/model_target_web_api/authentication_tests/contract/conftest.py b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/authentication_tests/contract/conftest.py new file mode 100644 index 000000000..a4643c00c --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/authentication_tests/contract/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures scoped to this group of tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def fixture_verify_backend( + *, + verify_model_target_mock_vuforia: None, +) -> None: + """Set up the backend for every contract test in this directory.""" + del verify_model_target_mock_vuforia diff --git a/contract-tests/src/vws_mock_contract_tests/model_target_web_api/authentication_tests/contract/test_authentication.py b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/authentication_tests/contract/test_authentication.py new file mode 100644 index 000000000..c1f3a3826 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/authentication_tests/contract/test_authentication.py @@ -0,0 +1,335 @@ +"""Tests for Model Target Web API authentication. + +Bearer token concerns which apply to every Model Target endpoint are +covered by the authorization header tests, via the +``model_target_endpoint`` fixture. +""" + +from http import HTTPStatus + +import pytest +import requests + +from vws_mock_contract_tests.fixtures.credentials import ModelTargetCredentials +from vws_mock_contract_tests.model_target_web_api.assertions import ( + assert_oauth2_error, +) +from vws_mock_contract_tests.model_target_web_api.responses import ( + parse_response_json, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_model_target_status, +) +from vws_mock_contract_tests.utils.model_target_retries import model_target_get + + +@pytest.mark.parametrize( + argnames=("auth", "data", "status_code", "body"), + argvalues=[ + pytest.param( + None, + {"grant_type": "client_credentials"}, + HTTPStatus.UNAUTHORIZED, + { + "error": "invalid_request", + "error_description": ( + "Missing or invalid authorization header" + ), + }, + id="missing-basic-auth", + ), + pytest.param( + ("invalid-client-id", "invalid-client-secret"), + {"grant_type": "client_credentials"}, + HTTPStatus.UNAUTHORIZED, + {"error": "invalid_client"}, + id="invalid-client", + ), + pytest.param( + ("invalid-client-id", "invalid-client-secret"), + {"grant_type": "unsupported"}, + HTTPStatus.BAD_REQUEST, + {"error": "unsupported_grant_type"}, + id="unsupported-grant-type", + ), + pytest.param( + None, + {"grant_type": "password", "password": "password"}, + HTTPStatus.BAD_REQUEST, + { + "error": "invalid_request", + "error_description": "Missing username and/or password", + }, + id="password-grant-missing-username", + ), + pytest.param( + None, + { + "grant_type": "password", + "username": "user@example.com", + }, + HTTPStatus.BAD_REQUEST, + { + "error": "invalid_request", + "error_description": "Missing username and/or password", + }, + id="password-grant-missing-password", + ), + ], +) +def test_invalid_oauth2_token_request( + *, + base_vws_url: str, + auth: tuple[str, str] | None, + data: dict[str, str], + status_code: HTTPStatus, + body: dict[str, str], +) -> None: + """Invalid OAuth2 token requests are rejected.""" + response = requests.post( + url=f"{base_vws_url}/oauth2/token", + auth=auth, + data=data, + timeout=30, + ) + + assert_oauth2_error( + response=response, + status_code=status_code, + body=body, + ) + + +def test_password_grant( + *, + model_target_credentials: ModelTargetCredentials, + base_vws_url: str, +) -> None: + """A username and password can be exchanged for a scoped token.""" + response = requests.post( + url=f"{base_vws_url}/oauth2/token", + data={ + "grant_type": "password", + "username": model_target_credentials.username, + "password": model_target_credentials.password, + "scope": "modeltargets.standardmodeltarget.all", + }, + timeout=30, + ) + + assert_model_target_status( + response=response, + status_codes=HTTPStatus.OK, + ) + assert isinstance(response.json()["access_token"], str) + assert response.json()["token_type"] == "bearer" + + +def test_scoped_client_credentials_grant( + *, + model_target_credentials: ModelTargetCredentials, + base_vws_url: str, +) -> None: + """A client credentials request accepts an explicit scope.""" + response = requests.post( + url=f"{base_vws_url}/oauth2/token", + auth=( + model_target_credentials.client_id, + model_target_credentials.client_secret, + ), + data={ + "grant_type": "client_credentials", + "scope": "modeltargets.standardmodeltarget.all", + }, + timeout=30, + ) + + assert_model_target_status( + response=response, + status_codes=HTTPStatus.OK, + ) + assert isinstance(response.json()["access_token"], str) + assert response.json()["token_type"] == "bearer" + + +def test_insufficient_scope( + *, + model_target_credentials: ModelTargetCredentials, + base_vws_url: str, +) -> None: + """A route rejects a token carrying only another route's scope.""" + token_response = requests.post( + url=f"{base_vws_url}/oauth2/token", + auth=( + model_target_credentials.client_id, + model_target_credentials.client_secret, + ), + data={ + "grant_type": "client_credentials", + "scope": "modeltargets.standardmodeltarget.all", + }, + timeout=30, + ) + assert_model_target_status( + response=token_response, + status_codes=HTTPStatus.OK, + ) + + response = requests.post( + url=f"{base_vws_url}/modeltargets/advancedDatasets", + headers={ + "Authorization": ( + f"Bearer {token_response.json()['access_token']}" + ), + }, + json={}, + timeout=30, + ) + + assert_model_target_status( + response=response, + status_codes=HTTPStatus.FORBIDDEN, + ) + assert response.text == ( + "User does not have the required scopes to perform this action" + ) + + +def test_client_credentials_management( + *, + model_target_credentials: ModelTargetCredentials, + base_vws_url: str, +) -> None: + """Client credentials can be created, listed, updated and + deleted. + """ + password_token_response = requests.post( + url=f"{base_vws_url}/oauth2/token", + data={ + "grant_type": "password", + "username": model_target_credentials.username, + "password": model_target_credentials.password, + "scope": "oauth2.clientcredentials.all", + }, + timeout=30, + ) + assert_model_target_status( + response=password_token_response, + status_codes=HTTPStatus.OK, + ) + headers = { + "Authorization": ( + f"Bearer {password_token_response.json()['access_token']}" + ), + } + create_response = requests.post( + url=f"{base_vws_url}/oauth2/clientcredentials", + headers=headers, + json={ + "scopes": ["modeltargets.standardmodeltarget.all"], + }, + timeout=30, + ) + assert_model_target_status( + response=create_response, + status_codes=HTTPStatus.CREATED, + ) + response_json = create_response.json() + client_id: object = response_json["client_id"] + client_secret: object = response_json["client_secret"] + assert isinstance(client_id, str) + assert isinstance(client_secret, str) + + try: + list_response = model_target_get( + url=f"{base_vws_url}/oauth2/clientcredentials", + headers=headers, + timeout=30, + ) + assert_model_target_status( + response=list_response, + status_codes=HTTPStatus.OK, + ) + created_entries = [ + entry + for entry in list_response.json() + if entry["clientId"] == client_id + ] + assert len(created_entries) == 1 + assert created_entries[0]["scopes"] == [ + "modeltargets.standardmodeltarget.all", + ] + + update_response = requests.put( + url=( + f"{base_vws_url}/oauth2/clientcredentials/{client_id}/scopes" + ), + headers=headers, + json=["modeltargets.advancedmodeltarget.all"], + timeout=30, + ) + assert_model_target_status( + response=update_response, + status_codes=HTTPStatus.OK, + ) + assert update_response.json() == { + "clientId": client_id, + "scopes": ["modeltargets.advancedmodeltarget.all"], + } + + client_token_response = requests.post( + url=f"{base_vws_url}/oauth2/token", + auth=(client_id, client_secret), + data={"grant_type": "client_credentials"}, + timeout=30, + ) + assert_model_target_status( + response=client_token_response, + status_codes=HTTPStatus.OK, + ) + client_access_token = parse_response_json( + response=client_token_response, + )["access_token"] + assert isinstance(client_access_token, str) + insufficient_response = requests.post( + url=f"{base_vws_url}/modeltargets/datasets", + headers={ + "Authorization": f"Bearer {client_access_token}", + }, + json={}, + timeout=30, + ) + assert_model_target_status( + response=insufficient_response, + status_codes=HTTPStatus.FORBIDDEN, + ) + finally: + delete_response = requests.delete( + url=(f"{base_vws_url}/oauth2/clientcredentials/{client_id}"), + headers=headers, + timeout=30, + ) + assert_model_target_status( + response=delete_response, + status_codes=HTTPStatus.NO_CONTENT, + ) + + missing_client_id = "000000000000000000000" + missing_response = requests.delete( + url=(f"{base_vws_url}/oauth2/clientcredentials/{missing_client_id}"), + headers=headers, + timeout=30, + ) + assert_model_target_status( + response=missing_response, + status_codes=HTTPStatus.NOT_FOUND, + ) + assert missing_response.json() == { + "error": { + "code": "NOT_FOUND", + "message": ( + f"Clientcredential with ID={missing_client_id} not found" + ), + "target": "clientcredential", + }, + } diff --git a/contract-tests/src/vws_mock_contract_tests/model_target_web_api/authentication_tests/contract/test_authorization_header.py b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/authentication_tests/contract/test_authorization_header.py new file mode 100644 index 000000000..0a232cb33 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/authentication_tests/contract/test_authorization_header.py @@ -0,0 +1,118 @@ +"""Tests for the ``Authorization`` header on every Model Target +endpoint. + +These mirror the cross-cutting tests which the ``endpoint`` fixture +supports for the VWS and Query APIs. The Model Target Web API uses +OAuth2 bearer tokens rather than HMAC signatures, so the VWS +``Authorization`` and ``Date`` header concerns do not apply to it, +and it gets its own smaller set of concerns via the +``model_target_endpoint`` fixture. The OAuth2 token endpoint is not +in that fixture because it takes HTTP Basic credentials rather than +a bearer token. +""" + +import dataclasses +from http import HTTPStatus + +import pytest + +from vws_mock_contract_tests.model_target_web_api.assertions import ( + assert_model_target_error, +) +from vws_mock_contract_tests.utils import ModelTargetEndpoint + + +def test_missing( + *, + model_target_endpoint: ModelTargetEndpoint, +) -> None: + """An ``UNAUTHORIZED`` response is returned when no + ``Authorization`` header is given. + """ + response = model_target_endpoint.send() + + assert_model_target_error( + response=response, + status_code=HTTPStatus.UNAUTHORIZED, + code="401", + message="no Bearer token", + target="jwt", + ) + + +@pytest.mark.parametrize( + argnames=("authorization", "message"), + argvalues=[ + pytest.param("Basic abc", "no Bearer token", id="not-bearer"), + pytest.param("Bearer ", "no Bearer token", id="blank"), + pytest.param( + "Bearer invalid-token", + "Invalid JWT serialization: Missing dot delimiter(s)", + id="malformed", + ), + pytest.param( + "Bearer ..", + "Invalid unsecured/JWS/JWE header: Invalid JSON object", + id="invalid-header-json", + ), + pytest.param( + "Bearer e30.e30.signature", + 'Missing "alg" in header JSON object', + id="missing-algorithm", + ), + pytest.param( + "Bearer eyJhbGciOiJub25lIn0.e30.", + ("Unsecured (plain) JWTs are rejected, extend class to handle"), + id="unsecured", + ), + pytest.param( + "Bearer eyJhbGciOiJSUzI1NiJ9.%.signature", + "Payload of JWS object is not a valid JSON object", + id="payload-not-base64", + ), + pytest.param( + "Bearer eyJhbGciOiJSUzI1NiJ9..signature", + "Payload of JWS object is not a valid JSON object", + id="blank-payload", + ), + pytest.param( + "Bearer eyJhbGciOiJSUzI1NiJ9.InZhbHVlIg.signature", + "Payload of JWS object is not a valid JSON object", + id="payload-not-json-object", + ), + pytest.param( + "Bearer eyJhbGciOiJSUzI1NiJ9.e30.", + "The signature must not be empty", + id="blank-signature", + ), + pytest.param( + "Bearer eyJhbGciOiJSUzI1NiJ9.e30.%", + "Signed JWT rejected: Invalid signature", + id="signature-not-base64", + ), + ], +) +def test_invalid_bearer_token( + *, + model_target_endpoint: ModelTargetEndpoint, + authorization: str, + message: str, +) -> None: + """Invalid bearer tokens are rejected.""" + new_endpoint = dataclasses.replace( + model_target_endpoint, + headers={ + **model_target_endpoint.headers, + "Authorization": authorization, + }, + ) + + response = new_endpoint.send() + + assert_model_target_error( + response=response, + status_code=HTTPStatus.UNAUTHORIZED, + code="401", + message=message, + target="jwt", + ) diff --git a/contract-tests/src/vws_mock_contract_tests/model_target_web_api/errors/__init__.py b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/errors/__init__.py new file mode 100644 index 000000000..18ccdc68d --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/errors/__init__.py @@ -0,0 +1 @@ +"""Errors tests.""" diff --git a/contract-tests/src/vws_mock_contract_tests/model_target_web_api/errors/contract/__init__.py b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/errors/contract/__init__.py new file mode 100644 index 000000000..7c8ab3fe3 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/errors/contract/__init__.py @@ -0,0 +1 @@ +"""Tests using verify model target mock vuforia.""" diff --git a/contract-tests/src/vws_mock_contract_tests/model_target_web_api/errors/contract/conftest.py b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/errors/contract/conftest.py new file mode 100644 index 000000000..a4643c00c --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/errors/contract/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures scoped to this group of tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def fixture_verify_backend( + *, + verify_model_target_mock_vuforia: None, +) -> None: + """Set up the backend for every contract test in this directory.""" + del verify_model_target_mock_vuforia diff --git a/contract-tests/src/vws_mock_contract_tests/model_target_web_api/errors/contract/test_error_responses.py b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/errors/contract/test_error_responses.py new file mode 100644 index 000000000..59b91e02e --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/errors/contract/test_error_responses.py @@ -0,0 +1,657 @@ +"""Verified fake tests for Model Target Web API error responses.""" + +import base64 +from http import HTTPMethod, HTTPStatus + +import pytest +import requests + +from vws_mock_contract_tests.fixtures.credentials import ModelTargetCredentials +from vws_mock_contract_tests.fixtures.model_target_prepared_requests import ( + MODEL_TARGET_DATASET_UUID, + get_access_token, +) +from vws_mock_contract_tests.model_target_web_api.assertions import ( + assert_oauth2_error, +) +from vws_mock_contract_tests.model_target_web_api.errors.helpers import ( + EMPTY_GUIDE_VIEW_POSITION, + EMPTY_GUIDE_VIEW_POSITION_OBJECT, + EMPTY_MODEL, + EMPTY_VIEW, + MODEL_WITHOUT_CAD_DATA, +) +from vws_mock_contract_tests.model_target_web_api.responses import ( + response_targeted_error, + response_validation_error, +) +from vws_mock_contract_tests.model_target_web_api.sample_data import ( + MODEL, + UNAUTHENTICATED_DATASET_REQUEST, + VIEW, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_model_target_status, +) + + +@pytest.mark.parametrize( + argnames="authorization", + argvalues=[ + pytest.param("Basic not-base64!", id="invalid-base64"), + pytest.param( + ( + "Basic " + + base64.b64encode(s=b"client-id-without-secret").decode() + ), + id="missing-separator", + ), + ], +) +def test_invalid_basic_auth_header( + *, base_vws_url: str, authorization: str +) -> None: + """Malformed OAuth2 Basic auth headers are rejected.""" + response = requests.post( + url=f"{base_vws_url}/oauth2/token", + headers={"Authorization": authorization}, + data={"grant_type": "client_credentials"}, + timeout=30, + ) + + assert_oauth2_error( + response=response, + status_code=HTTPStatus.UNAUTHORIZED, + body={ + "error": "invalid_request", + "error_description": "Missing or invalid authorization header", + }, + ) + + +@pytest.mark.parametrize( + argnames=("body", "expected_messages"), + argvalues=[ + pytest.param( + {}, + { + "/models: element is required", + "/name: element is required", + "/targetSdk: element is required", + }, + id="empty-body", + ), + pytest.param( + { + "name": "dataset-name", + "targetSdk": "10.18", + "models": "model", + }, + {"/models: error.expected.jsarray"}, + id="models-not-list", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [], + }, + {"exactly one model should be provided"}, + id="standard-zero-models", + ), + pytest.param( + {**UNAUTHENTICATED_DATASET_REQUEST, "name": 1}, + {"/name: error.expected.jsstring"}, + id="name-not-string", + ), + pytest.param( + {**UNAUTHENTICATED_DATASET_REQUEST, "targetSdk": ["10.18"]}, + {"/targetSdk: error.expected.jsstring"}, + id="target-sdk-not-string", + ), + pytest.param( + {"name": None, "targetSdk": None, "models": "model"}, + { + "/models: error.expected.jsarray", + "/name: error.expected.jsstring", + "/targetSdk: error.expected.jsstring", + }, + id="multiple-type-errors", + ), + pytest.param( + {**UNAUTHENTICATED_DATASET_REQUEST, "models": ["model"]}, + { + "/models(0)/name: element is required", + "/models(0)/views: element is required", + }, + id="model-not-object", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [MODEL, "model"], + }, + { + "/models(1)/name: element is required", + "/models(1)/views: element is required", + }, + id="second-model-not-object", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [EMPTY_MODEL], + }, + { + "/models(0)/name: element is required", + "/models(0)/views: element is required", + }, + id="model-missing-fields", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [MODEL_WITHOUT_CAD_DATA], + }, + { + ( + "model 'model-name' is invalid. One of `cadDataBlob`, " + "`cadDataUrl`, `cadDataUuid` need to be provided" + ), + }, + id="model-without-cad-data", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **MODEL, + "cadDataBlob": "ZmFrZQ==", + "cadDataFormat": "ZIP", + }, + ], + }, + { + ( + "model 'model-name' is invalid. Only one of " + "`cadDataBlob`, `cadDataUrl`, `cadDataUuid` need to " + "be provided" + ), + }, + id="model-with-both-cad-data-sources", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **MODEL, + "cadDataUrl": 1, + }, + ], + }, + {"/models(0)/cadDataUrl: error.expected.jsstring"}, + id="model-cad-data-url-not-string", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **MODEL_WITHOUT_CAD_DATA, + "cadDataBlob": 1, + "cadDataFormat": "ZIP", + }, + ], + }, + {"/models(0)/cadDataBlob: error.expected.jsstring"}, + id="model-cad-data-blob-not-string", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**MODEL, "cadDataFormat": 1}], + }, + {"/models(0)/cadDataFormat: error.expected.jsstring"}, + id="model-cad-data-format-not-string", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**MODEL, "cadDataFormat": "gltf"}], + }, + { + ( + "Unrecognized cadDataFormat 'GLTF'. Allowed values " + "are: ZIP, GLB, DRC_GLB, DRC_GLTF, DAE, FBX, IGES, " + "OBJ, PVS, PVZ, STL, VRML, or specify no " + "cadDataFormat to auto-detect GLB and zipped glTFs." + ), + }, + id="model-cad-data-format-not-in-enum", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**MODEL, "name": None}], + }, + {"/models(0)/name: error.expected.jsstring"}, + id="model-name-not-string", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**MODEL, "simplify": 1}], + }, + {"/models(0)/simplify: error.expected.jsstring"}, + id="model-simplify-not-string", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**MODEL, "simplify": "sometimes"}], + }, + { + ( + "invalid simplify. Should be one of 'never', " + "'always', 'auto'. You provided 'Some(sometimes)'" + ), + }, + id="model-simplify-not-in-enum", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**MODEL, "automaticColoring": "sometimes"}], + }, + { + ( + "invalid automaticColoring. Should be one of 'never', " + "'always', 'auto'. You provided 'sometimes'" + ), + }, + id="model-automatic-coloring-not-in-enum", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**MODEL, "motionHint": "still"}], + }, + { + ( + "`motionHint` and `trackingMode` are no longer " + "supported when using `targetsSdk` 10.9 or later. " + "Please use the `optimizeTrackingFor` setting instead." + ), + }, + id="model-motion-hint-not-in-enum", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**MODEL, "optimizeTrackingFor": "cars"}], + }, + { + ( + "`optimizeTrackingFor` must be one of " + "default,low_feature_objects,ar_controller" + ), + }, + id="model-optimize-tracking-for-not-in-enum", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**MODEL, "trackingMode": "boat"}], + }, + { + ( + "`motionHint` and `trackingMode` are no longer " + "supported when using `targetsSdk` 10.9 or later. " + "Please use the `optimizeTrackingFor` setting instead." + ), + }, + id="model-tracking-mode-not-in-enum", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **MODEL, + "motionHint": "still", + "simplify": "sometimes", + }, + ], + }, + { + ( + "`motionHint` and `trackingMode` are no longer " + "supported when using `targetsSdk` 10.9 or later. " + "Please use the `optimizeTrackingFor` setting instead." + ), + ( + "invalid simplify. Should be one of 'never', " + "'always', 'auto'. You provided 'Some(sometimes)'" + ), + }, + id="model-multiple-enum-errors", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**MODEL, "views": "view-name"}], + }, + {"/models(0)/views: error.expected.jsarray"}, + id="model-views-not-array", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**MODEL, "views": ["view-name"]}], + }, + {"/models(0)/views(0)/name: element is required"}, + id="view-not-object", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [{**MODEL, "views": [EMPTY_VIEW]}], + }, + {"/models(0)/views(0)/name: element is required"}, + id="view-missing-fields", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + {**MODEL, "views": [{**VIEW, "name": 1}]}, + ], + }, + {"/models(0)/views(0)/name: error.expected.jsstring"}, + id="view-name-not-string", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **MODEL, + "views": [ + VIEW, + { + **VIEW, + "guideViewPosition": ( + EMPTY_GUIDE_VIEW_POSITION + ), + }, + ], + }, + ], + }, + { + ( + "/models(0)/views(1)/guideViewPosition: " + "error.expected.jsobject" + ), + }, + id="view-guide-view-position-not-object", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **MODEL, + "views": [ + { + **VIEW, + "guideViewPosition": ( + EMPTY_GUIDE_VIEW_POSITION_OBJECT + ), + }, + ], + }, + ], + }, + { + ( + "/models(0)/views(0)/guideViewPosition/rotation: " + "element is required" + ), + ( + "/models(0)/views(0)/guideViewPosition/translation: " + "element is required" + ), + }, + id="guide-view-position-missing-fields", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **MODEL, + "views": [ + { + **VIEW, + "guideViewPosition": { + "rotation": "0,0,0,1", + "translation": [0, 0, 5], + }, + }, + ], + }, + ], + }, + { + ( + "/models(0)/views(0)/guideViewPosition/rotation: " + "error.expected.jsarray" + ), + }, + id="guide-view-position-rotation-not-array", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **MODEL, + "views": [ + { + **VIEW, + "guideViewPosition": { + "rotation": [0, 0, 0, 1], + "translation": 5, + }, + }, + ], + }, + ], + }, + { + ( + "/models(0)/views(0)/guideViewPosition/translation: " + "error.expected.jsarray" + ), + }, + id="guide-view-position-translation-not-array", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **MODEL, + "views": [ + { + **VIEW, + "guideViewPosition": { + "rotation": [0, "0", 0, 1], + "translation": [0, 0, 5], + }, + }, + ], + }, + ], + }, + { + ( + "/models(0)/views(0)/guideViewPosition/rotation(1): " + "error.expected.jsnumber" + ), + }, + id="guide-view-position-rotation-element-not-number", + ), + pytest.param( + { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **MODEL, + "views": [ + { + **VIEW, + "guideViewPosition": { + "rotation": [0, 0, 0, 1], + "translation": [0, 0, True], + }, + }, + ], + }, + ], + }, + { + ( + "/models(0)/views(0)/guideViewPosition/" + "translation(2): error.expected.jsnumber" + ), + }, + id="guide-view-position-translation-element-not-number", + ), + ], +) +def test_invalid_dataset_request( + *, + model_target_credentials: ModelTargetCredentials, + base_vws_url: str, + body: dict[str, object], + expected_messages: set[str], +) -> None: + """Invalid standard dataset creation requests are rejected.""" + access_token = get_access_token( + base_vws_url=base_vws_url, + credentials=model_target_credentials, + ) + response = requests.post( + url=f"{base_vws_url}/modeltargets/datasets", + headers={"Authorization": f"Bearer {access_token}"}, + json=body, + timeout=30, + ) + + assert_model_target_status( + response=response, + status_codes=HTTPStatus.BAD_REQUEST, + ) + error = response_validation_error(response=response) + assert error["code"] == "BAD_REQUEST" + assert error["message"] == ( + f"Validation error for request {error['target']}" + ) + actual_messages = {detail["message"] for detail in error["details"]} + assert actual_messages == expected_messages + for detail in error["details"]: + assert detail["code"] == "VALIDATION_ERROR" + + +def test_advanced_model_count_exceeds_limit( + *, + model_target_credentials: ModelTargetCredentials, + base_vws_url: str, +) -> None: + """Advanced datasets reject more than 20 uniquely named models.""" + models = [{**MODEL, "name": f"model-{index}"} for index in range(21)] + # Include a duplicate to verify the two validation details which real + # Vuforia returns together for this request. + models[-1]["name"] = models[0]["name"] + body = {**UNAUTHENTICATED_DATASET_REQUEST, "models": models} + access_token = get_access_token( + base_vws_url=base_vws_url, + credentials=model_target_credentials, + ) + response = requests.post( + url=f"{base_vws_url}/modeltargets/advancedDatasets", + headers={"Authorization": f"Bearer {access_token}"}, + json=body, + timeout=30, + ) + + assert_model_target_status( + response=response, + status_codes=HTTPStatus.BAD_REQUEST, + ) + error = response_validation_error(response=response) + assert error["code"] == "BAD_REQUEST" + assert {detail["message"] for detail in error["details"]} == { + "names of models must be unique within a Target.", + "total number of models must be maximum 20", + } + assert all( + detail["code"] == "VALIDATION_ERROR" for detail in error["details"] + ) + + +@pytest.mark.parametrize( + argnames=("method", "path"), + argvalues=[ + pytest.param( + HTTPMethod.GET, + f"/modeltargets/datasets/{MODEL_TARGET_DATASET_UUID}/status", + id="status", + ), + pytest.param( + HTTPMethod.GET, + f"/modeltargets/datasets/{MODEL_TARGET_DATASET_UUID}/dataset", + id="download", + ), + pytest.param( + HTTPMethod.DELETE, + f"/modeltargets/datasets/{MODEL_TARGET_DATASET_UUID}", + id="delete", + ), + ], +) +def test_unknown_dataset( + *, + model_target_credentials: ModelTargetCredentials, + base_vws_url: str, + method: HTTPMethod, + path: str, +) -> None: + """Unknown datasets are rejected with a NOT_FOUND error.""" + access_token = get_access_token( + base_vws_url=base_vws_url, + credentials=model_target_credentials, + ) + response = requests.request( + method=method, + url=f"{base_vws_url}{path}", + headers={"Authorization": f"Bearer {access_token}"}, + timeout=30, + ) + + assert_model_target_status( + response=response, + status_codes=HTTPStatus.NOT_FOUND, + ) + error = response_targeted_error(response=response) + assert error["code"] == "NOT_FOUND" + assert error["message"] == ( + "Could not find a model-view database with uuid " + f"{MODEL_TARGET_DATASET_UUID}" + ) + # The user-id portion is per-account in real Vuforia, so check only + # the stable prefix. + assert error["target"].startswith("userId:") diff --git a/tests/mock_vws/model_target_web_api/errors/helpers.py b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/errors/helpers.py similarity index 92% rename from tests/mock_vws/model_target_web_api/errors/helpers.py rename to contract-tests/src/vws_mock_contract_tests/model_target_web_api/errors/helpers.py index 0032ebbce..09339d9c5 100644 --- a/tests/mock_vws/model_target_web_api/errors/helpers.py +++ b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/errors/helpers.py @@ -5,7 +5,7 @@ from beartype import beartype from vws.response import Response -from tests.mock_vws.model_target_web_api.sample_data import ( +from vws_mock_contract_tests.model_target_web_api.sample_data import ( MODEL, ) diff --git a/contract-tests/src/vws_mock_contract_tests/model_target_web_api/errors/test_errors.py b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/errors/test_errors.py new file mode 100644 index 000000000..63a0a217f --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/errors/test_errors.py @@ -0,0 +1,105 @@ +"""Additional errors tests.""" + +from collections.abc import Set as AbstractSet +from http import HTTPStatus + +import pytest + +from vws_mock_contract_tests.model_target_web_api.errors.helpers import ( + fake_response, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_model_target_status, +) + + +@pytest.mark.parametrize( + argnames="status_codes", + argvalues=[ + pytest.param(HTTPStatus.OK, id="single"), + pytest.param( + {HTTPStatus.OK, HTTPStatus.NO_CONTENT}, + id="set", + ), + ], +) +def test_expected_status( + *, + base_vws_url: str, + status_codes: HTTPStatus | AbstractSet[HTTPStatus], +) -> None: + """An expected status code does not raise.""" + response = fake_response( + status_code=HTTPStatus.OK, + text="{}", + url=f"{base_vws_url}/modeltargets/advancedDatasets", + ) + assert_model_target_status( + response=response, + status_codes=status_codes, + ) + + +def test_unexpected_status_shows_the_body(*, base_vws_url: str) -> None: + """An unexpected status code reports the URL and the body.""" + text = '{"error":{"code":"VALIDATION_ERROR"}}' + response = fake_response( + status_code=HTTPStatus.BAD_REQUEST, + text=text, + url=f"{base_vws_url}/modeltargets/advancedDatasets", + ) + with pytest.raises(expected_exception=AssertionError) as exc: + assert_model_target_status( + response=response, + status_codes=HTTPStatus.CREATED, + ) + + message = str(object=exc.value) + assert message == ( + "Expected 201 CREATED from " + f"{base_vws_url}/modeltargets/advancedDatasets, got 400.\n" + f"\nResponse body:\n{text}" + ) + + +def test_multiple_expected_statuses(*, base_vws_url: str) -> None: + """Every expected status code is named in the message.""" + response = fake_response( + status_code=HTTPStatus.BAD_REQUEST, + text="{}", + url=f"{base_vws_url}/modeltargets/advancedDatasets", + ) + with pytest.raises(expected_exception=AssertionError) as exc: + assert_model_target_status( + response=response, + status_codes={HTTPStatus.OK, HTTPStatus.NO_CONTENT}, + ) + + assert "Expected 200 OK or 204 NO_CONTENT from " in str(object=exc.value) + + +@pytest.mark.parametrize( + argnames="base_url", + argvalues=["http://example.com", "https://vws.vuforia.com"], +) +def test_training_allowance_exceeded(*, base_url: str) -> None: + """An unexpected allowance response fails without a backend policy.""" + response = fake_response( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + text=( + '{"error":{"code":"TRAINING_ALLOWANCE_EXCEEDED",' + '"message":"Signing quota reached","target":"7635391"}}' + ), + url=f"{base_url}/modeltargets/datasets", + ) + with pytest.raises(expected_exception=AssertionError) as exc: + assert_model_target_status( + response=response, + status_codes=HTTPStatus.CREATED, + ) + + message = str(object=exc.value) + assert message == ( + f"Expected 201 CREATED from {base_url}/modeltargets/datasets, " + f"got 422.\n\nResponse body:\n{response.text}" + ) diff --git a/contract-tests/src/vws_mock_contract_tests/model_target_web_api/requests/__init__.py b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/requests/__init__.py new file mode 100644 index 000000000..dd2a1c337 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/requests/__init__.py @@ -0,0 +1 @@ +"""Requests tests.""" diff --git a/contract-tests/src/vws_mock_contract_tests/model_target_web_api/requests/contract/__init__.py b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/requests/contract/__init__.py new file mode 100644 index 000000000..7c8ab3fe3 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/requests/contract/__init__.py @@ -0,0 +1 @@ +"""Tests using verify model target mock vuforia.""" diff --git a/contract-tests/src/vws_mock_contract_tests/model_target_web_api/requests/contract/conftest.py b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/requests/contract/conftest.py new file mode 100644 index 000000000..a4643c00c --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/requests/contract/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures scoped to this group of tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def fixture_verify_backend( + *, + verify_model_target_mock_vuforia: None, +) -> None: + """Set up the backend for every contract test in this directory.""" + del verify_model_target_mock_vuforia diff --git a/contract-tests/src/vws_mock_contract_tests/model_target_web_api/requests/contract/test_content_length.py b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/requests/contract/test_content_length.py new file mode 100644 index 000000000..ba8e526aa --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/requests/contract/test_content_length.py @@ -0,0 +1,101 @@ +"""Tests for the ``Content-Length`` header on every Model Target +endpoint. + +These mirror the cross-cutting tests which the ``endpoint`` fixture +supports for the VWS and Query APIs. + +A ``Content-Length`` header which is too large is not covered, for the +same reason as it is not covered for the VWS API: real Vuforia waits +for the body it was promised before timing out, which takes too long +to run in a test. +""" + +import dataclasses +from http import HTTPMethod, HTTPStatus + +from vws_mock_contract_tests.model_target_web_api.assertions import ( + assert_model_target_error, +) +from vws_mock_contract_tests.model_target_web_api.requests.helpers import ( + assert_load_balancer_bad_request, +) +from vws_mock_contract_tests.utils import ModelTargetEndpoint + + +def test_not_integer( + *, + model_target_endpoint: ModelTargetEndpoint, +) -> None: + """A ``Content-Length`` header which is not an integer is rejected + by the load balancer in front of Vuforia, before any bearer token + is looked at. + """ + new_endpoint = dataclasses.replace( + model_target_endpoint, + headers={ + **model_target_endpoint.headers, + "Content-Length": "0.4", + }, + ) + + response = new_endpoint.send() + + assert_load_balancer_bad_request(response=response) + + +def test_not_integer_oauth2_token(*, base_vws_url: str) -> None: + """The OAuth2 token endpoint is behind the same load balancer. + + It is not in the ``model_target_endpoint`` fixture because it takes + HTTP Basic credentials rather than a bearer token. + """ + endpoint = ModelTargetEndpoint( + base_url=base_vws_url, + path_url="/oauth2/token", + method=HTTPMethod.POST, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Content-Length": "0.4", + }, + data=b"grant_type=client_credentials", + takes_json_body=False, + ) + + response = endpoint.send() + + assert_load_balancer_bad_request(response=response) + + +def test_too_small( + *, + model_target_endpoint: ModelTargetEndpoint, +) -> None: + """A ``Content-Length`` header which is too small truncates the + body, and the request is still rejected for having no bearer + token. + + The Model Target Web API does not sign the request body, so unlike + the VWS API it has no reason to notice the truncation before it + looks at the ``Authorization`` header. + """ + if not model_target_endpoint.takes_json_body: + return + + content_length = len(model_target_endpoint.data) - 1 + new_endpoint = dataclasses.replace( + model_target_endpoint, + headers={ + **model_target_endpoint.headers, + "Content-Length": str(object=content_length), + }, + ) + + response = new_endpoint.send() + + assert_model_target_error( + response=response, + status_code=HTTPStatus.UNAUTHORIZED, + code="401", + message="no Bearer token", + target="jwt", + ) diff --git a/tests/mock_vws/model_target_web_api/requests/helpers.py b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/requests/helpers.py similarity index 92% rename from tests/mock_vws/model_target_web_api/requests/helpers.py rename to contract-tests/src/vws_mock_contract_tests/model_target_web_api/requests/helpers.py index caba94c0b..d5c0bb988 100644 --- a/tests/mock_vws/model_target_web_api/requests/helpers.py +++ b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/requests/helpers.py @@ -9,14 +9,14 @@ from pydantic import TypeAdapter from vws.response import Response -from tests.mock_vws.fixtures.model_target_prepared_requests import ( +from vws_mock_contract_tests.fixtures.model_target_prepared_requests import ( MODEL_TARGET_DATASET_UUID, ) -from tests.mock_vws.model_target_web_api.responses import ( +from vws_mock_contract_tests.model_target_web_api.responses import ( Error, response_targeted_error, ) -from tests.mock_vws.utils.assertions import ( +from vws_mock_contract_tests.utils.assertions import ( assert_model_target_status, assert_valid_date_header, ) diff --git a/contract-tests/src/vws_mock_contract_tests/model_target_web_api/requests/test_requests.py b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/requests/test_requests.py new file mode 100644 index 000000000..7660cc889 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/requests/test_requests.py @@ -0,0 +1,212 @@ +"""Additional requests tests.""" + +import dataclasses +from http import HTTPStatus + +import pytest + +from vws_mock_contract_tests.fixtures.credentials import ModelTargetCredentials +from vws_mock_contract_tests.fixtures.model_target_prepared_requests import ( + get_access_token, +) +from vws_mock_contract_tests.model_target_web_api.requests.helpers import ( + assert_unknown_dataset, + response_error, +) +from vws_mock_contract_tests.model_target_web_api.responses import ( + response_validation_error, +) +from vws_mock_contract_tests.utils import ModelTargetEndpoint +from vws_mock_contract_tests.utils.assertions import ( + assert_model_target_status, +) + + +@pytest.mark.parametrize( + argnames="content_type", + argvalues=[ + pytest.param(None, id="missing"), + pytest.param("", id="empty"), + ], +) +def test_wrong_content_type( + *, + model_target_credentials: ModelTargetCredentials, + base_vws_url: str, + model_target_endpoint: ModelTargetEndpoint, + content_type: str | None, +) -> None: + """Requests without a JSON content type are rejected with 415 by + endpoints which read a body, and are unaffected elsewhere. + """ + access_token = get_access_token( + base_vws_url=base_vws_url, + credentials=model_target_credentials, + ) + new_headers = { + **model_target_endpoint.headers, + "Authorization": f"Bearer {access_token}", + } + if content_type is None: + _ = new_headers.pop("Content-Type", None) + else: + new_headers["Content-Type"] = content_type + new_endpoint = dataclasses.replace( + model_target_endpoint, + headers=new_headers, + ) + + response = new_endpoint.send() + + if not model_target_endpoint.takes_json_body: + assert_unknown_dataset(response=response) + return + + assert_model_target_status( + response=response, + status_codes=HTTPStatus.UNSUPPORTED_MEDIA_TYPE, + ) + error = response_error(response=response) + assert error["code"] == "ERROR" + assert error["message"] == ("Expecting text/json or application/json body") + assert "target" not in error + + +def test_invalid_json( + *, + model_target_credentials: ModelTargetCredentials, + base_vws_url: str, + model_target_endpoint: ModelTargetEndpoint, +) -> None: + """Malformed JSON bodies are rejected with 400 by endpoints which + read a body, and are ignored elsewhere. + """ + access_token = get_access_token( + base_vws_url=base_vws_url, + credentials=model_target_credentials, + ) + content = b"{" + new_endpoint = dataclasses.replace( + model_target_endpoint, + headers={ + **model_target_endpoint.headers, + "Authorization": f"Bearer {access_token}", + "Content-Length": str(object=len(content)), + }, + data=content, + ) + + response = new_endpoint.send() + + if not model_target_endpoint.takes_json_body: + assert_unknown_dataset(response=response) + return + + assert_model_target_status( + response=response, + status_codes=HTTPStatus.BAD_REQUEST, + ) + error = response_error(response=response) + assert error["code"] == "ERROR" + assert error["message"].startswith("Invalid Json") + assert "target" not in error + + +def test_body_not_utf_8( + *, + model_target_credentials: ModelTargetCredentials, + base_vws_url: str, + model_target_endpoint: ModelTargetEndpoint, +) -> None: + """Bodies which are not valid UTF-8 are rejected with 400 by + endpoints which read a body, and are ignored elsewhere. + """ + access_token = get_access_token( + base_vws_url=base_vws_url, + credentials=model_target_credentials, + ) + content = b"\xff{}" + new_endpoint = dataclasses.replace( + model_target_endpoint, + headers={ + **model_target_endpoint.headers, + "Authorization": f"Bearer {access_token}", + "Content-Length": str(object=len(content)), + }, + data=content, + ) + + response = new_endpoint.send() + + if not model_target_endpoint.takes_json_body: + assert_unknown_dataset(response=response) + return + + assert_model_target_status( + response=response, + status_codes=HTTPStatus.BAD_REQUEST, + ) + error = response_error(response=response) + assert error["code"] == "ERROR" + assert error["message"].startswith("Invalid Json") + assert "target" not in error + + +@pytest.mark.parametrize( + argnames="body", + argvalues=[ + pytest.param("[]", id="array"), + pytest.param('"dataset"', id="string"), + pytest.param("1", id="number"), + pytest.param("true", id="boolean"), + pytest.param("null", id="null"), + ], +) +def test_body_not_json_object( + *, + model_target_credentials: ModelTargetCredentials, + base_vws_url: str, + model_target_endpoint: ModelTargetEndpoint, + body: str, +) -> None: + """JSON bodies which are not objects are missing every field on + endpoints which read a body, and are ignored elsewhere. + """ + access_token = get_access_token( + base_vws_url=base_vws_url, + credentials=model_target_credentials, + ) + content = body.encode(encoding="utf-8") + new_endpoint = dataclasses.replace( + model_target_endpoint, + headers={ + **model_target_endpoint.headers, + "Authorization": f"Bearer {access_token}", + "Content-Length": str(object=len(content)), + }, + data=content, + ) + + response = new_endpoint.send() + + if not model_target_endpoint.takes_json_body: + assert_unknown_dataset(response=response) + return + + assert_model_target_status( + response=response, + status_codes=HTTPStatus.BAD_REQUEST, + ) + error = response_validation_error(response=response) + assert error["code"] == "BAD_REQUEST" + assert error["message"] == ( + f"Validation error for request {error['target']}" + ) + actual_messages = {detail["message"] for detail in error["details"]} + assert actual_messages == { + "/models: element is required", + "/name: element is required", + "/targetSdk: element is required", + } + for detail in error["details"]: + assert detail["code"] == "VALIDATION_ERROR" diff --git a/tests/mock_vws/model_target_web_api/responses.py b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/responses.py similarity index 100% rename from tests/mock_vws/model_target_web_api/responses.py rename to contract-tests/src/vws_mock_contract_tests/model_target_web_api/responses.py diff --git a/tests/mock_vws/model_target_web_api/sample_data.py b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/sample_data.py similarity index 100% rename from tests/mock_vws/model_target_web_api/sample_data.py rename to contract-tests/src/vws_mock_contract_tests/model_target_web_api/sample_data.py diff --git a/contract-tests/src/vws_mock_contract_tests/model_target_web_api/test_datasets.py b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/test_datasets.py new file mode 100644 index 000000000..2a8aa55f0 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/model_target_web_api/test_datasets.py @@ -0,0 +1,626 @@ +"""Tests for datasets through the Model Target Web API. + +Verified fake tests for State-Based Model Targets. + +The advanced (signed) cases are verified against the real Vuforia +only when ``--verify-model-target-signing`` is given: see +the ``signed_model_target_credentials`` fixture. +Additional verified and mock-only Model Target behaviors. +""" + +import base64 +import io +import json +import time +import zipfile +from http import HTTPStatus +from uuid import uuid4 + +import pytest +import requests +from beartype import beartype + +from vws_mock_contract_tests.fixtures.credentials import ModelTargetCredentials +from vws_mock_contract_tests.fixtures.model_target_prepared_requests import ( + get_access_token, +) +from vws_mock_contract_tests.model_target_web_api.responses import ( + parse_response_json, + response_validation_error, +) +from vws_mock_contract_tests.model_target_web_api.sample_data import ( + MODEL, + STATE_CONFIGURATION, + UNAUTHENTICATED_DATASET_REQUEST, + VIEW, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_model_target_status, +) +from vws_mock_contract_tests.utils.model_target_retries import model_target_get + + +@beartype +def _dataset_request(*, cad_data_url: str) -> dict[str, object]: + """Return a standard Model Target dataset request.""" + return { + "name": f"dataset-{uuid4().hex}", + "targetSdk": "10.18", + "models": [ + { + "name": "model-name", + "cadDataUrl": cad_data_url, + "views": [VIEW], + }, + ], + } + + +@beartype +def _cad_data_blob() -> str: + """Return a base64-encoded zipped model for inline CAD data.""" + zip_buffer = io.BytesIO() + with zipfile.ZipFile(file=zip_buffer, mode="w") as zip_file: + zip_file.writestr( + zinfo_or_arcname="model.gltf", + data=json.dumps(obj={"asset": {"version": "2.0"}}), + ) + return base64.b64encode(s=zip_buffer.getvalue()).decode(encoding="ascii") + + +@beartype +def _blob_dataset_request() -> dict[str, object]: + """Return a standard dataset request with inline CAD data.""" + return { + "name": f"dataset-{uuid4().hex}", + "targetSdk": "10.18", + "models": [ + { + "name": "model-name", + "cadDataBlob": _cad_data_blob(), + "cadDataFormat": "ZIP", + "views": [VIEW], + }, + ], + } + + +@pytest.mark.parametrize( + argnames="dataset_path", + argvalues=[ + pytest.param("/modeltargets/datasets", id="standard"), + pytest.param( + "/modeltargets/advancedDatasets", + id="advanced", + ), + ], +) +@pytest.mark.parametrize( + argnames="view_updates", + argvalues=[ + pytest.param({}, id="all-states"), + pytest.param( + {"states": ["assembled"]}, + id="selected-states", + ), + ], +) +def test_state_based_dataset( + *, + signed_model_target_credentials: ModelTargetCredentials, + base_vws_url: str, + dataset_path: str, + view_updates: dict[str, object], +) -> None: + """State-Based Model Target fields survive a dataset round + trip. + """ + body = { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **MODEL, + "stateBasedConfigurationJsonString": (STATE_CONFIGURATION), + "views": [{**VIEW, **view_updates}], + }, + ], + } + access_token = get_access_token( + base_vws_url=base_vws_url, + credentials=signed_model_target_credentials, + ) + headers = {"Authorization": f"Bearer {access_token}"} + create_response = requests.post( + url=f"{base_vws_url}{dataset_path}", + headers=headers, + json=body, + timeout=30, + ) + + assert_model_target_status( + response=create_response, + status_codes=HTTPStatus.CREATED, + ) + dataset_uuid = parse_response_json(response=create_response)["uuid"] + assert isinstance(dataset_uuid, str) + delete_response = requests.delete( + url=f"{base_vws_url}{dataset_path}/{dataset_uuid}", + headers=headers, + timeout=30, + ) + assert_model_target_status( + response=delete_response, + status_codes=HTTPStatus.OK, + ) + + +def test_view_states_are_a_subset( + *, + model_target_credentials: ModelTargetCredentials, + base_vws_url: str, +) -> None: + """A view cannot select a state absent from the configuration.""" + body = { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **MODEL, + "stateBasedConfigurationJsonString": (STATE_CONFIGURATION), + "views": [{**VIEW, "states": ["unknown"]}], + }, + ], + } + access_token = get_access_token( + base_vws_url=base_vws_url, + credentials=model_target_credentials, + ) + response = requests.post( + url=f"{base_vws_url}/modeltargets/datasets", + headers={"Authorization": f"Bearer {access_token}"}, + json=body, + timeout=30, + ) + + assert_model_target_status( + response=response, + status_codes=HTTPStatus.BAD_REQUEST, + ) + error = response_validation_error(response=response) + assert error["code"] == "BAD_REQUEST" + assert [detail["message"] for detail in error["details"]] == [ + "states in entrypoint view-name' must be a subset of all states", + ] + assert error["details"][0]["code"] == "VALIDATION_ERROR" + + +@pytest.mark.parametrize( + argnames=("model_updates", "view_updates", "expected_message"), + argvalues=[ + pytest.param( + {"stateBasedConfigurationJsonString": 1}, + {}, + ( + "/models(0)/stateBasedConfigurationJsonString: " + "error.expected.jsstring" + ), + id="configuration-not-string", + ), + pytest.param( + {"stateBasedConfigurationJsonString": "{"}, + {}, + ( + "/models(0)/stateBasedConfigurationJsonString: " + "error.expected.validjson" + ), + id="configuration-not-json", + ), + pytest.param( + {"stateBasedConfigurationJsonString": "{}"}, + {}, + ( + "/models(0)/stateBasedConfigurationJsonString/states: " + "error.expected.jsobject" + ), + id="configuration-states-not-object", + ), + pytest.param( + {"stateBasedConfigurationJsonString": "[]"}, + {}, + ( + "/models(0)/stateBasedConfigurationJsonString/states: " + "error.expected.jsobject" + ), + id="configuration-not-object", + ), + pytest.param( + {"stateBasedConfigurationJsonString": STATE_CONFIGURATION}, + {"states": "assembled"}, + "/models(0)/views(0)/states: error.expected.jsarray", + id="view-states-not-array", + ), + pytest.param( + {"stateBasedConfigurationJsonString": STATE_CONFIGURATION}, + {"states": ["assembled", 1]}, + ("/models(0)/views(0)/states(1): error.expected.jsstring"), + id="view-state-not-string", + ), + pytest.param( + {}, + {"states": ["assembled"]}, + ( + "/models(0)/stateBasedConfigurationJsonString: element " + "is required when view states are given" + ), + id="view-states-without-configuration", + ), + ], +) +def test_invalid_state_based_dataset( + *, + mock_model_target_credentials: ModelTargetCredentials, + base_vws_url: str, + model_updates: dict[str, object], + view_updates: dict[str, object], + expected_message: str, +) -> None: + """Invalid State-Based Model Target fields are rejected.""" + body = { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **MODEL, + **model_updates, + "views": [{**VIEW, **view_updates}], + }, + ], + } + access_token = get_access_token( + base_vws_url=base_vws_url, + credentials=mock_model_target_credentials, + ) + response = requests.post( + url=f"{base_vws_url}/modeltargets/datasets", + headers={"Authorization": f"Bearer {access_token}"}, + json=body, + timeout=30, + ) + + assert_model_target_status( + response=response, + status_codes=HTTPStatus.BAD_REQUEST, + ) + error = response_validation_error(response=response) + assert error["code"] == "BAD_REQUEST" + assert [detail["message"] for detail in error["details"]] == [ + expected_message, + ] + assert error["details"][0]["code"] == "VALIDATION_ERROR" + + +def test_advanced_realistic_appearance_not_in_enum( + *, + model_target_credentials: ModelTargetCredentials, + base_vws_url: str, +) -> None: + """Advanced dataset requests with a ``realisticAppearance`` value + outside the documented enumeration are rejected. + + The Model Target OpenAPI specification documents + ``realisticAppearance`` as a model field for advanced datasets + only. + """ + body = { + **UNAUTHENTICATED_DATASET_REQUEST, + "models": [ + { + **MODEL, + "cadDataUrl": model_target_credentials.cad_data_url, + "realisticAppearance": "yes", + }, + ], + } + access_token = get_access_token( + base_vws_url=base_vws_url, + credentials=model_target_credentials, + ) + advanced_response = requests.post( + url=f"{base_vws_url}/modeltargets/advancedDatasets", + headers={"Authorization": f"Bearer {access_token}"}, + json=body, + timeout=30, + ) + + assert_model_target_status( + response=advanced_response, + status_codes=HTTPStatus.BAD_REQUEST, + ) + error = response_validation_error(response=advanced_response) + assert error["code"] == "BAD_REQUEST" + assert [detail["message"] for detail in error["details"]] == [ + '`realisticAppearance` must be one of "true", "false", "auto".` ', + ] + assert error["details"][0]["code"] == "VALIDATION_ERROR" + + +def test_oauth2_token_body_not_utf_8( + *, + model_target_credentials: ModelTargetCredentials, + base_vws_url: str, +) -> None: + """An OAuth2 token request with a body which is not valid UTF-8 is + treated as one which does not name a grant type. + + Real Vuforia also treats a body that cannot be decoded as an empty + form. + """ + response = requests.post( + url=f"{base_vws_url}/oauth2/token", + auth=( + model_target_credentials.client_id, + model_target_credentials.client_secret, + ), + data=b"\xff", + timeout=30, + ) + + assert_model_target_status( + response=response, + status_codes=HTTPStatus.OK, + ) + assert response.json()["token_type"] == "bearer" + + +@pytest.mark.parametrize( + argnames=("created_path", "other_path"), + argvalues=[ + pytest.param( + "/modeltargets/datasets", + "/modeltargets/advancedDatasets", + id="standard-dataset-via-advanced-routes", + ), + pytest.param( + "/modeltargets/advancedDatasets", + "/modeltargets/datasets", + id="advanced-dataset-via-standard-routes", + ), + ], +) +def test_dataset_is_visible_to_the_other_dataset_type( + *, + model_target_credentials: ModelTargetCredentials, + base_vws_url: str, + created_path: str, + other_path: str, +) -> None: + """Standard and advanced routes share datasets by UUID.""" + access_token = get_access_token( + base_vws_url=base_vws_url, + credentials=model_target_credentials, + ) + headers = {"Authorization": f"Bearer {access_token}"} + create_response = requests.post( + url=f"{base_vws_url}{created_path}", + headers=headers, + json=_dataset_request( + cad_data_url=model_target_credentials.cad_data_url, + ), + timeout=30, + ) + assert_model_target_status( + response=create_response, + status_codes=HTTPStatus.CREATED, + ) + dataset_uuid: object = create_response.json()["uuid"] + assert isinstance(dataset_uuid, str) + + try: + other_status_response = model_target_get( + url=f"{base_vws_url}{other_path}/{dataset_uuid}/status", + headers=headers, + timeout=30, + ) + other_delete_response = requests.delete( + url=f"{base_vws_url}{other_path}/{dataset_uuid}", + headers=headers, + timeout=30, + ) + own_status_response = model_target_get( + url=( + f"{base_vws_url}{created_path}/" + f"{create_response.json()['uuid']}/status" + ), + headers=headers, + timeout=30, + ) + finally: + delete_response = requests.delete( + url=f"{base_vws_url}{created_path}/{dataset_uuid}", + headers=headers, + timeout=30, + ) + assert_model_target_status( + response=delete_response, + status_codes={ + HTTPStatus.OK, + HTTPStatus.NO_CONTENT, + }, + ) + + assert_model_target_status( + response=other_status_response, + status_codes=HTTPStatus.OK, + ) + assert_model_target_status( + response=other_delete_response, + status_codes={ + HTTPStatus.OK, + HTTPStatus.NO_CONTENT, + }, + ) + assert_model_target_status( + response=own_status_response, + status_codes=HTTPStatus.OK, + ) + + +def test_create_status_and_delete( + *, + model_target_credentials: ModelTargetCredentials, + base_vws_url: str, +) -> None: + """A standard dataset works through the shared advanced routes. + + Standard generation is fast enough for verified CI coverage. Real + Vuforia exposes its completed artifact through both route families, + so this also verifies the advanced status and download endpoints. + """ + access_token = get_access_token( + base_vws_url=base_vws_url, + credentials=model_target_credentials, + ) + headers = {"Authorization": f"Bearer {access_token}"} + create_response = requests.post( + url=f"{base_vws_url}/modeltargets/datasets", + headers=headers, + json=_dataset_request( + cad_data_url=model_target_credentials.cad_data_url + ), + timeout=30, + ) + + assert_model_target_status( + response=create_response, + status_codes=HTTPStatus.CREATED, + ) + create_response_json = parse_response_json(response=create_response) + dataset_uuid: object = create_response_json["uuid"] + assert isinstance(dataset_uuid, str) + + try: + status_response = model_target_get( + url=( + f"{base_vws_url}/modeltargets/advancedDatasets/" + f"{dataset_uuid}/status" + ), + headers=headers, + timeout=30, + ) + + assert_model_target_status( + response=status_response, + status_codes=HTTPStatus.OK, + ) + status_response_json = parse_response_json(response=status_response) + assert status_response_json["status"] in { + "processing", + "done", + "failed", + } + assert isinstance(status_response_json["createdAt"], str) + + deadline = time.monotonic() + 60 + while ( + status_response_json["status"] == "processing" + and time.monotonic() < deadline + ): + time.sleep(1) + status_response = model_target_get( + url=( + f"{base_vws_url}/modeltargets/advancedDatasets/" + f"{dataset_uuid}/status" + ), + headers=headers, + timeout=30, + ) + assert_model_target_status( + response=status_response, + status_codes=HTTPStatus.OK, + ) + status_response_json = status_response.json() + + assert status_response_json["status"] == "done" + assert isinstance(status_response_json["completedAt"], str) + assert set(status_response_json) == { + "completedAt", + "createdAt", + "status", + "uuid", + } + + download_response = model_target_get( + url=( + f"{base_vws_url}/modeltargets/advancedDatasets/" + f"{dataset_uuid}/dataset" + ), + headers=headers, + timeout=30, + ) + assert_model_target_status( + response=download_response, + status_codes=HTTPStatus.OK, + ) + assert download_response.headers["Content-Type"] == ("application/zip") + assert download_response.headers["Content-Disposition"] == ( + "attachment; filename=full-dataset.zip" + ) + with zipfile.ZipFile( + file=io.BytesIO(initial_bytes=download_response.content), + ) as archive: + assert archive.namelist() == ["MTDataset.dat", "MTDataset.xml"] + finally: + delete_response = requests.delete( + url=f"{base_vws_url}/modeltargets/datasets/{dataset_uuid}", + headers=headers, + timeout=30, + ) + assert_model_target_status( + response=delete_response, + status_codes={ + HTTPStatus.OK, + HTTPStatus.NO_CONTENT, + }, + ) + + +def test_create_with_cad_data_blob( + *, + signed_model_target_credentials: ModelTargetCredentials, + base_vws_url: str, +) -> None: + """A dataset can be created with inline CAD data.""" + access_token = get_access_token( + base_vws_url=base_vws_url, + credentials=signed_model_target_credentials, + ) + headers = {"Authorization": f"Bearer {access_token}"} + + create_response = requests.post( + url=f"{base_vws_url}/modeltargets/datasets", + headers=headers, + json=_blob_dataset_request(), + timeout=30, + ) + + assert_model_target_status( + response=create_response, + status_codes=HTTPStatus.CREATED, + ) + create_response_json = parse_response_json(response=create_response) + dataset_uuid = create_response_json["uuid"] + assert isinstance(dataset_uuid, str) + + # There is nothing to assert between creating and deleting the + # dataset, so the delete does not need a ``finally`` block to avoid + # leaving a dataset behind on real Vuforia. + delete_response = requests.delete( + url=f"{base_vws_url}/modeltargets/datasets/{dataset_uuid}", + headers=headers, + timeout=30, + ) + assert_model_target_status( + response=delete_response, + status_codes={ + HTTPStatus.OK, + HTTPStatus.NO_CONTENT, + }, + ) diff --git a/tests/mock_vws/model_target_web_api/authentication_tests/contract/conftest.py b/tests/mock_vws/model_target_web_api/authentication_tests/contract/conftest.py index a4643c00c..e4140d794 100644 --- a/tests/mock_vws/model_target_web_api/authentication_tests/contract/conftest.py +++ b/tests/mock_vws/model_target_web_api/authentication_tests/contract/conftest.py @@ -1,12 +1,7 @@ -"""Fixtures scoped to this group of tests.""" +"""Compatibility imports for the packaged contract suite.""" -import pytest +from vws_mock_contract_tests.model_target_web_api.authentication_tests.contract.conftest import ( # noqa: E501 + fixture_verify_backend, +) - -@pytest.fixture(autouse=True) -def fixture_verify_backend( - *, - verify_model_target_mock_vuforia: None, -) -> None: - """Set up the backend for every contract test in this directory.""" - del verify_model_target_mock_vuforia +__all__ = ["fixture_verify_backend"] diff --git a/tests/mock_vws/model_target_web_api/authentication_tests/contract/test_authentication.py b/tests/mock_vws/model_target_web_api/authentication_tests/contract/test_authentication.py index b262c7725..95143622a 100644 --- a/tests/mock_vws/model_target_web_api/authentication_tests/contract/test_authentication.py +++ b/tests/mock_vws/model_target_web_api/authentication_tests/contract/test_authentication.py @@ -1,333 +1,17 @@ -"""Tests for Model Target Web API authentication. - -Bearer token concerns which apply to every Model Target endpoint are -covered by the authorization header tests, via the -``model_target_endpoint`` fixture. -""" - -from http import HTTPStatus - -import pytest -import requests - -from tests.mock_vws.fixtures.credentials import ModelTargetCredentials -from tests.mock_vws.model_target_web_api.assertions import ( - assert_oauth2_error, -) -from tests.mock_vws.model_target_web_api.responses import parse_response_json -from tests.mock_vws.utils.assertions import ( - assert_model_target_status, -) -from tests.mock_vws.utils.model_target_retries import model_target_get - - -@pytest.mark.parametrize( - argnames=("auth", "data", "status_code", "body"), - argvalues=[ - pytest.param( - None, - {"grant_type": "client_credentials"}, - HTTPStatus.UNAUTHORIZED, - { - "error": "invalid_request", - "error_description": ( - "Missing or invalid authorization header" - ), - }, - id="missing-basic-auth", - ), - pytest.param( - ("invalid-client-id", "invalid-client-secret"), - {"grant_type": "client_credentials"}, - HTTPStatus.UNAUTHORIZED, - {"error": "invalid_client"}, - id="invalid-client", - ), - pytest.param( - ("invalid-client-id", "invalid-client-secret"), - {"grant_type": "unsupported"}, - HTTPStatus.BAD_REQUEST, - {"error": "unsupported_grant_type"}, - id="unsupported-grant-type", - ), - pytest.param( - None, - {"grant_type": "password", "password": "password"}, - HTTPStatus.BAD_REQUEST, - { - "error": "invalid_request", - "error_description": "Missing username and/or password", - }, - id="password-grant-missing-username", - ), - pytest.param( - None, - { - "grant_type": "password", - "username": "user@example.com", - }, - HTTPStatus.BAD_REQUEST, - { - "error": "invalid_request", - "error_description": "Missing username and/or password", - }, - id="password-grant-missing-password", - ), - ], +"""Compatibility imports for the packaged contract suite.""" + +from vws_mock_contract_tests.model_target_web_api.authentication_tests.contract.test_authentication import ( # noqa: E501 + test_client_credentials_management, + test_insufficient_scope, + test_invalid_oauth2_token_request, + test_password_grant, + test_scoped_client_credentials_grant, ) -def test_invalid_oauth2_token_request( - *, - base_vws_url: str, - auth: tuple[str, str] | None, - data: dict[str, str], - status_code: HTTPStatus, - body: dict[str, str], -) -> None: - """Invalid OAuth2 token requests are rejected.""" - response = requests.post( - url=f"{base_vws_url}/oauth2/token", - auth=auth, - data=data, - timeout=30, - ) - - assert_oauth2_error( - response=response, - status_code=status_code, - body=body, - ) - - -def test_password_grant( - *, - model_target_credentials: ModelTargetCredentials, - base_vws_url: str, -) -> None: - """A username and password can be exchanged for a scoped token.""" - response = requests.post( - url=f"{base_vws_url}/oauth2/token", - data={ - "grant_type": "password", - "username": model_target_credentials.username, - "password": model_target_credentials.password, - "scope": "modeltargets.standardmodeltarget.all", - }, - timeout=30, - ) - - assert_model_target_status( - response=response, - status_codes=HTTPStatus.OK, - ) - assert isinstance(response.json()["access_token"], str) - assert response.json()["token_type"] == "bearer" - - -def test_scoped_client_credentials_grant( - *, - model_target_credentials: ModelTargetCredentials, - base_vws_url: str, -) -> None: - """A client credentials request accepts an explicit scope.""" - response = requests.post( - url=f"{base_vws_url}/oauth2/token", - auth=( - model_target_credentials.client_id, - model_target_credentials.client_secret, - ), - data={ - "grant_type": "client_credentials", - "scope": "modeltargets.standardmodeltarget.all", - }, - timeout=30, - ) - - assert_model_target_status( - response=response, - status_codes=HTTPStatus.OK, - ) - assert isinstance(response.json()["access_token"], str) - assert response.json()["token_type"] == "bearer" - - -def test_insufficient_scope( - *, - model_target_credentials: ModelTargetCredentials, - base_vws_url: str, -) -> None: - """A route rejects a token carrying only another route's scope.""" - token_response = requests.post( - url=f"{base_vws_url}/oauth2/token", - auth=( - model_target_credentials.client_id, - model_target_credentials.client_secret, - ), - data={ - "grant_type": "client_credentials", - "scope": "modeltargets.standardmodeltarget.all", - }, - timeout=30, - ) - assert_model_target_status( - response=token_response, - status_codes=HTTPStatus.OK, - ) - - response = requests.post( - url=f"{base_vws_url}/modeltargets/advancedDatasets", - headers={ - "Authorization": ( - f"Bearer {token_response.json()['access_token']}" - ), - }, - json={}, - timeout=30, - ) - - assert_model_target_status( - response=response, - status_codes=HTTPStatus.FORBIDDEN, - ) - assert response.text == ( - "User does not have the required scopes to perform this action" - ) - - -def test_client_credentials_management( - *, - model_target_credentials: ModelTargetCredentials, - base_vws_url: str, -) -> None: - """Client credentials can be created, listed, updated and - deleted. - """ - password_token_response = requests.post( - url=f"{base_vws_url}/oauth2/token", - data={ - "grant_type": "password", - "username": model_target_credentials.username, - "password": model_target_credentials.password, - "scope": "oauth2.clientcredentials.all", - }, - timeout=30, - ) - assert_model_target_status( - response=password_token_response, - status_codes=HTTPStatus.OK, - ) - headers = { - "Authorization": ( - f"Bearer {password_token_response.json()['access_token']}" - ), - } - create_response = requests.post( - url=f"{base_vws_url}/oauth2/clientcredentials", - headers=headers, - json={ - "scopes": ["modeltargets.standardmodeltarget.all"], - }, - timeout=30, - ) - assert_model_target_status( - response=create_response, - status_codes=HTTPStatus.CREATED, - ) - response_json = create_response.json() - client_id: object = response_json["client_id"] - client_secret: object = response_json["client_secret"] - assert isinstance(client_id, str) - assert isinstance(client_secret, str) - - try: - list_response = model_target_get( - url=f"{base_vws_url}/oauth2/clientcredentials", - headers=headers, - timeout=30, - ) - assert_model_target_status( - response=list_response, - status_codes=HTTPStatus.OK, - ) - created_entries = [ - entry - for entry in list_response.json() - if entry["clientId"] == client_id - ] - assert len(created_entries) == 1 - assert created_entries[0]["scopes"] == [ - "modeltargets.standardmodeltarget.all", - ] - - update_response = requests.put( - url=( - f"{base_vws_url}/oauth2/clientcredentials/{client_id}/scopes" - ), - headers=headers, - json=["modeltargets.advancedmodeltarget.all"], - timeout=30, - ) - assert_model_target_status( - response=update_response, - status_codes=HTTPStatus.OK, - ) - assert update_response.json() == { - "clientId": client_id, - "scopes": ["modeltargets.advancedmodeltarget.all"], - } - - client_token_response = requests.post( - url=f"{base_vws_url}/oauth2/token", - auth=(client_id, client_secret), - data={"grant_type": "client_credentials"}, - timeout=30, - ) - assert_model_target_status( - response=client_token_response, - status_codes=HTTPStatus.OK, - ) - client_access_token = parse_response_json( - response=client_token_response, - )["access_token"] - assert isinstance(client_access_token, str) - insufficient_response = requests.post( - url=f"{base_vws_url}/modeltargets/datasets", - headers={ - "Authorization": f"Bearer {client_access_token}", - }, - json={}, - timeout=30, - ) - assert_model_target_status( - response=insufficient_response, - status_codes=HTTPStatus.FORBIDDEN, - ) - finally: - delete_response = requests.delete( - url=(f"{base_vws_url}/oauth2/clientcredentials/{client_id}"), - headers=headers, - timeout=30, - ) - assert_model_target_status( - response=delete_response, - status_codes=HTTPStatus.NO_CONTENT, - ) - missing_client_id = "000000000000000000000" - missing_response = requests.delete( - url=(f"{base_vws_url}/oauth2/clientcredentials/{missing_client_id}"), - headers=headers, - timeout=30, - ) - assert_model_target_status( - response=missing_response, - status_codes=HTTPStatus.NOT_FOUND, - ) - assert missing_response.json() == { - "error": { - "code": "NOT_FOUND", - "message": ( - f"Clientcredential with ID={missing_client_id} not found" - ), - "target": "clientcredential", - }, - } +__all__ = [ + "test_client_credentials_management", + "test_insufficient_scope", + "test_invalid_oauth2_token_request", + "test_password_grant", + "test_scoped_client_credentials_grant", +] diff --git a/tests/mock_vws/model_target_web_api/authentication_tests/contract/test_authorization_header.py b/tests/mock_vws/model_target_web_api/authentication_tests/contract/test_authorization_header.py index e751aadbc..f4ef6767f 100644 --- a/tests/mock_vws/model_target_web_api/authentication_tests/contract/test_authorization_header.py +++ b/tests/mock_vws/model_target_web_api/authentication_tests/contract/test_authorization_header.py @@ -1,118 +1,8 @@ -"""Tests for the ``Authorization`` header on every Model Target -endpoint. +"""Compatibility imports for the packaged contract suite.""" -These mirror the cross-cutting tests which the ``endpoint`` fixture -supports for the VWS and Query APIs. The Model Target Web API uses -OAuth2 bearer tokens rather than HMAC signatures, so the VWS -``Authorization`` and ``Date`` header concerns do not apply to it, -and it gets its own smaller set of concerns via the -``model_target_endpoint`` fixture. The OAuth2 token endpoint is not -in that fixture because it takes HTTP Basic credentials rather than -a bearer token. -""" - -import dataclasses -from http import HTTPStatus - -import pytest - -from tests.mock_vws.model_target_web_api.assertions import ( - assert_model_target_error, -) -from tests.mock_vws.utils import ModelTargetEndpoint - - -def test_missing( - *, - model_target_endpoint: ModelTargetEndpoint, -) -> None: - """An ``UNAUTHORIZED`` response is returned when no - ``Authorization`` header is given. - """ - response = model_target_endpoint.send() - - assert_model_target_error( - response=response, - status_code=HTTPStatus.UNAUTHORIZED, - code="401", - message="no Bearer token", - target="jwt", - ) - - -@pytest.mark.parametrize( - argnames=("authorization", "message"), - argvalues=[ - pytest.param("Basic abc", "no Bearer token", id="not-bearer"), - pytest.param("Bearer ", "no Bearer token", id="blank"), - pytest.param( - "Bearer invalid-token", - "Invalid JWT serialization: Missing dot delimiter(s)", - id="malformed", - ), - pytest.param( - "Bearer ..", - "Invalid unsecured/JWS/JWE header: Invalid JSON object", - id="invalid-header-json", - ), - pytest.param( - "Bearer e30.e30.signature", - 'Missing "alg" in header JSON object', - id="missing-algorithm", - ), - pytest.param( - "Bearer eyJhbGciOiJub25lIn0.e30.", - ("Unsecured (plain) JWTs are rejected, extend class to handle"), - id="unsecured", - ), - pytest.param( - "Bearer eyJhbGciOiJSUzI1NiJ9.%.signature", - "Payload of JWS object is not a valid JSON object", - id="payload-not-base64", - ), - pytest.param( - "Bearer eyJhbGciOiJSUzI1NiJ9..signature", - "Payload of JWS object is not a valid JSON object", - id="blank-payload", - ), - pytest.param( - "Bearer eyJhbGciOiJSUzI1NiJ9.InZhbHVlIg.signature", - "Payload of JWS object is not a valid JSON object", - id="payload-not-json-object", - ), - pytest.param( - "Bearer eyJhbGciOiJSUzI1NiJ9.e30.", - "The signature must not be empty", - id="blank-signature", - ), - pytest.param( - "Bearer eyJhbGciOiJSUzI1NiJ9.e30.%", - "Signed JWT rejected: Invalid signature", - id="signature-not-base64", - ), - ], +from vws_mock_contract_tests.model_target_web_api.authentication_tests.contract.test_authorization_header import ( # noqa: E501 + test_invalid_bearer_token, + test_missing, ) -def test_invalid_bearer_token( - *, - model_target_endpoint: ModelTargetEndpoint, - authorization: str, - message: str, -) -> None: - """Invalid bearer tokens are rejected.""" - new_endpoint = dataclasses.replace( - model_target_endpoint, - headers={ - **model_target_endpoint.headers, - "Authorization": authorization, - }, - ) - - response = new_endpoint.send() - assert_model_target_error( - response=response, - status_code=HTTPStatus.UNAUTHORIZED, - code="401", - message=message, - target="jwt", - ) +__all__ = ["test_invalid_bearer_token", "test_missing"] diff --git a/tests/mock_vws/model_target_web_api/errors/contract/conftest.py b/tests/mock_vws/model_target_web_api/errors/contract/conftest.py index a4643c00c..8af08092a 100644 --- a/tests/mock_vws/model_target_web_api/errors/contract/conftest.py +++ b/tests/mock_vws/model_target_web_api/errors/contract/conftest.py @@ -1,12 +1,7 @@ -"""Fixtures scoped to this group of tests.""" +"""Compatibility imports for the packaged contract suite.""" -import pytest +from vws_mock_contract_tests.model_target_web_api.errors.contract.conftest import ( # noqa: E501 + fixture_verify_backend, +) - -@pytest.fixture(autouse=True) -def fixture_verify_backend( - *, - verify_model_target_mock_vuforia: None, -) -> None: - """Set up the backend for every contract test in this directory.""" - del verify_model_target_mock_vuforia +__all__ = ["fixture_verify_backend"] diff --git a/tests/mock_vws/model_target_web_api/errors/contract/test_error_responses.py b/tests/mock_vws/model_target_web_api/errors/contract/test_error_responses.py index a3b33c4be..724c44515 100644 --- a/tests/mock_vws/model_target_web_api/errors/contract/test_error_responses.py +++ b/tests/mock_vws/model_target_web_api/errors/contract/test_error_responses.py @@ -1,655 +1,15 @@ -"""Verified fake tests for Model Target Web API error responses.""" +"""Compatibility imports for the packaged contract suite.""" -import base64 -from http import HTTPMethod, HTTPStatus - -import pytest -import requests - -from tests.mock_vws.fixtures.credentials import ModelTargetCredentials -from tests.mock_vws.fixtures.model_target_prepared_requests import ( - MODEL_TARGET_DATASET_UUID, - get_access_token, -) -from tests.mock_vws.model_target_web_api.assertions import assert_oauth2_error -from tests.mock_vws.model_target_web_api.errors.helpers import ( - EMPTY_GUIDE_VIEW_POSITION, - EMPTY_GUIDE_VIEW_POSITION_OBJECT, - EMPTY_MODEL, - EMPTY_VIEW, - MODEL_WITHOUT_CAD_DATA, -) -from tests.mock_vws.model_target_web_api.responses import ( - response_targeted_error, - response_validation_error, -) -from tests.mock_vws.model_target_web_api.sample_data import ( - MODEL, - UNAUTHENTICATED_DATASET_REQUEST, - VIEW, -) -from tests.mock_vws.utils.assertions import ( - assert_model_target_status, -) - - -@pytest.mark.parametrize( - argnames="authorization", - argvalues=[ - pytest.param("Basic not-base64!", id="invalid-base64"), - pytest.param( - ( - "Basic " - + base64.b64encode(s=b"client-id-without-secret").decode() - ), - id="missing-separator", - ), - ], -) -def test_invalid_basic_auth_header( - *, base_vws_url: str, authorization: str -) -> None: - """Malformed OAuth2 Basic auth headers are rejected.""" - response = requests.post( - url=f"{base_vws_url}/oauth2/token", - headers={"Authorization": authorization}, - data={"grant_type": "client_credentials"}, - timeout=30, - ) - - assert_oauth2_error( - response=response, - status_code=HTTPStatus.UNAUTHORIZED, - body={ - "error": "invalid_request", - "error_description": "Missing or invalid authorization header", - }, - ) - - -@pytest.mark.parametrize( - argnames=("body", "expected_messages"), - argvalues=[ - pytest.param( - {}, - { - "/models: element is required", - "/name: element is required", - "/targetSdk: element is required", - }, - id="empty-body", - ), - pytest.param( - { - "name": "dataset-name", - "targetSdk": "10.18", - "models": "model", - }, - {"/models: error.expected.jsarray"}, - id="models-not-list", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [], - }, - {"exactly one model should be provided"}, - id="standard-zero-models", - ), - pytest.param( - {**UNAUTHENTICATED_DATASET_REQUEST, "name": 1}, - {"/name: error.expected.jsstring"}, - id="name-not-string", - ), - pytest.param( - {**UNAUTHENTICATED_DATASET_REQUEST, "targetSdk": ["10.18"]}, - {"/targetSdk: error.expected.jsstring"}, - id="target-sdk-not-string", - ), - pytest.param( - {"name": None, "targetSdk": None, "models": "model"}, - { - "/models: error.expected.jsarray", - "/name: error.expected.jsstring", - "/targetSdk: error.expected.jsstring", - }, - id="multiple-type-errors", - ), - pytest.param( - {**UNAUTHENTICATED_DATASET_REQUEST, "models": ["model"]}, - { - "/models(0)/name: element is required", - "/models(0)/views: element is required", - }, - id="model-not-object", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [MODEL, "model"], - }, - { - "/models(1)/name: element is required", - "/models(1)/views: element is required", - }, - id="second-model-not-object", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [EMPTY_MODEL], - }, - { - "/models(0)/name: element is required", - "/models(0)/views: element is required", - }, - id="model-missing-fields", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [MODEL_WITHOUT_CAD_DATA], - }, - { - ( - "model 'model-name' is invalid. One of `cadDataBlob`, " - "`cadDataUrl`, `cadDataUuid` need to be provided" - ), - }, - id="model-without-cad-data", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [ - { - **MODEL, - "cadDataBlob": "ZmFrZQ==", - "cadDataFormat": "ZIP", - }, - ], - }, - { - ( - "model 'model-name' is invalid. Only one of " - "`cadDataBlob`, `cadDataUrl`, `cadDataUuid` need to " - "be provided" - ), - }, - id="model-with-both-cad-data-sources", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [ - { - **MODEL, - "cadDataUrl": 1, - }, - ], - }, - {"/models(0)/cadDataUrl: error.expected.jsstring"}, - id="model-cad-data-url-not-string", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [ - { - **MODEL_WITHOUT_CAD_DATA, - "cadDataBlob": 1, - "cadDataFormat": "ZIP", - }, - ], - }, - {"/models(0)/cadDataBlob: error.expected.jsstring"}, - id="model-cad-data-blob-not-string", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [{**MODEL, "cadDataFormat": 1}], - }, - {"/models(0)/cadDataFormat: error.expected.jsstring"}, - id="model-cad-data-format-not-string", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [{**MODEL, "cadDataFormat": "gltf"}], - }, - { - ( - "Unrecognized cadDataFormat 'GLTF'. Allowed values " - "are: ZIP, GLB, DRC_GLB, DRC_GLTF, DAE, FBX, IGES, " - "OBJ, PVS, PVZ, STL, VRML, or specify no " - "cadDataFormat to auto-detect GLB and zipped glTFs." - ), - }, - id="model-cad-data-format-not-in-enum", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [{**MODEL, "name": None}], - }, - {"/models(0)/name: error.expected.jsstring"}, - id="model-name-not-string", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [{**MODEL, "simplify": 1}], - }, - {"/models(0)/simplify: error.expected.jsstring"}, - id="model-simplify-not-string", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [{**MODEL, "simplify": "sometimes"}], - }, - { - ( - "invalid simplify. Should be one of 'never', " - "'always', 'auto'. You provided 'Some(sometimes)'" - ), - }, - id="model-simplify-not-in-enum", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [{**MODEL, "automaticColoring": "sometimes"}], - }, - { - ( - "invalid automaticColoring. Should be one of 'never', " - "'always', 'auto'. You provided 'sometimes'" - ), - }, - id="model-automatic-coloring-not-in-enum", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [{**MODEL, "motionHint": "still"}], - }, - { - ( - "`motionHint` and `trackingMode` are no longer " - "supported when using `targetsSdk` 10.9 or later. " - "Please use the `optimizeTrackingFor` setting instead." - ), - }, - id="model-motion-hint-not-in-enum", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [{**MODEL, "optimizeTrackingFor": "cars"}], - }, - { - ( - "`optimizeTrackingFor` must be one of " - "default,low_feature_objects,ar_controller" - ), - }, - id="model-optimize-tracking-for-not-in-enum", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [{**MODEL, "trackingMode": "boat"}], - }, - { - ( - "`motionHint` and `trackingMode` are no longer " - "supported when using `targetsSdk` 10.9 or later. " - "Please use the `optimizeTrackingFor` setting instead." - ), - }, - id="model-tracking-mode-not-in-enum", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [ - { - **MODEL, - "motionHint": "still", - "simplify": "sometimes", - }, - ], - }, - { - ( - "`motionHint` and `trackingMode` are no longer " - "supported when using `targetsSdk` 10.9 or later. " - "Please use the `optimizeTrackingFor` setting instead." - ), - ( - "invalid simplify. Should be one of 'never', " - "'always', 'auto'. You provided 'Some(sometimes)'" - ), - }, - id="model-multiple-enum-errors", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [{**MODEL, "views": "view-name"}], - }, - {"/models(0)/views: error.expected.jsarray"}, - id="model-views-not-array", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [{**MODEL, "views": ["view-name"]}], - }, - {"/models(0)/views(0)/name: element is required"}, - id="view-not-object", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [{**MODEL, "views": [EMPTY_VIEW]}], - }, - {"/models(0)/views(0)/name: element is required"}, - id="view-missing-fields", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [ - {**MODEL, "views": [{**VIEW, "name": 1}]}, - ], - }, - {"/models(0)/views(0)/name: error.expected.jsstring"}, - id="view-name-not-string", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [ - { - **MODEL, - "views": [ - VIEW, - { - **VIEW, - "guideViewPosition": ( - EMPTY_GUIDE_VIEW_POSITION - ), - }, - ], - }, - ], - }, - { - ( - "/models(0)/views(1)/guideViewPosition: " - "error.expected.jsobject" - ), - }, - id="view-guide-view-position-not-object", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [ - { - **MODEL, - "views": [ - { - **VIEW, - "guideViewPosition": ( - EMPTY_GUIDE_VIEW_POSITION_OBJECT - ), - }, - ], - }, - ], - }, - { - ( - "/models(0)/views(0)/guideViewPosition/rotation: " - "element is required" - ), - ( - "/models(0)/views(0)/guideViewPosition/translation: " - "element is required" - ), - }, - id="guide-view-position-missing-fields", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [ - { - **MODEL, - "views": [ - { - **VIEW, - "guideViewPosition": { - "rotation": "0,0,0,1", - "translation": [0, 0, 5], - }, - }, - ], - }, - ], - }, - { - ( - "/models(0)/views(0)/guideViewPosition/rotation: " - "error.expected.jsarray" - ), - }, - id="guide-view-position-rotation-not-array", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [ - { - **MODEL, - "views": [ - { - **VIEW, - "guideViewPosition": { - "rotation": [0, 0, 0, 1], - "translation": 5, - }, - }, - ], - }, - ], - }, - { - ( - "/models(0)/views(0)/guideViewPosition/translation: " - "error.expected.jsarray" - ), - }, - id="guide-view-position-translation-not-array", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [ - { - **MODEL, - "views": [ - { - **VIEW, - "guideViewPosition": { - "rotation": [0, "0", 0, 1], - "translation": [0, 0, 5], - }, - }, - ], - }, - ], - }, - { - ( - "/models(0)/views(0)/guideViewPosition/rotation(1): " - "error.expected.jsnumber" - ), - }, - id="guide-view-position-rotation-element-not-number", - ), - pytest.param( - { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [ - { - **MODEL, - "views": [ - { - **VIEW, - "guideViewPosition": { - "rotation": [0, 0, 0, 1], - "translation": [0, 0, True], - }, - }, - ], - }, - ], - }, - { - ( - "/models(0)/views(0)/guideViewPosition/" - "translation(2): error.expected.jsnumber" - ), - }, - id="guide-view-position-translation-element-not-number", - ), - ], -) -def test_invalid_dataset_request( - *, - model_target_credentials: ModelTargetCredentials, - base_vws_url: str, - body: dict[str, object], - expected_messages: set[str], -) -> None: - """Invalid standard dataset creation requests are rejected.""" - access_token = get_access_token( - base_vws_url=base_vws_url, - credentials=model_target_credentials, - ) - response = requests.post( - url=f"{base_vws_url}/modeltargets/datasets", - headers={"Authorization": f"Bearer {access_token}"}, - json=body, - timeout=30, - ) - - assert_model_target_status( - response=response, - status_codes=HTTPStatus.BAD_REQUEST, - ) - error = response_validation_error(response=response) - assert error["code"] == "BAD_REQUEST" - assert error["message"] == ( - f"Validation error for request {error['target']}" - ) - actual_messages = {detail["message"] for detail in error["details"]} - assert actual_messages == expected_messages - for detail in error["details"]: - assert detail["code"] == "VALIDATION_ERROR" - - -def test_advanced_model_count_exceeds_limit( - *, - model_target_credentials: ModelTargetCredentials, - base_vws_url: str, -) -> None: - """Advanced datasets reject more than 20 uniquely named models.""" - models = [{**MODEL, "name": f"model-{index}"} for index in range(21)] - # Include a duplicate to verify the two validation details which real - # Vuforia returns together for this request. - models[-1]["name"] = models[0]["name"] - body = {**UNAUTHENTICATED_DATASET_REQUEST, "models": models} - access_token = get_access_token( - base_vws_url=base_vws_url, - credentials=model_target_credentials, - ) - response = requests.post( - url=f"{base_vws_url}/modeltargets/advancedDatasets", - headers={"Authorization": f"Bearer {access_token}"}, - json=body, - timeout=30, - ) - - assert_model_target_status( - response=response, - status_codes=HTTPStatus.BAD_REQUEST, - ) - error = response_validation_error(response=response) - assert error["code"] == "BAD_REQUEST" - assert {detail["message"] for detail in error["details"]} == { - "names of models must be unique within a Target.", - "total number of models must be maximum 20", - } - assert all( - detail["code"] == "VALIDATION_ERROR" for detail in error["details"] - ) - - -@pytest.mark.parametrize( - argnames=("method", "path"), - argvalues=[ - pytest.param( - HTTPMethod.GET, - f"/modeltargets/datasets/{MODEL_TARGET_DATASET_UUID}/status", - id="status", - ), - pytest.param( - HTTPMethod.GET, - f"/modeltargets/datasets/{MODEL_TARGET_DATASET_UUID}/dataset", - id="download", - ), - pytest.param( - HTTPMethod.DELETE, - f"/modeltargets/datasets/{MODEL_TARGET_DATASET_UUID}", - id="delete", - ), - ], +from vws_mock_contract_tests.model_target_web_api.errors.contract.test_error_responses import ( # noqa: E501 + test_advanced_model_count_exceeds_limit, + test_invalid_basic_auth_header, + test_invalid_dataset_request, + test_unknown_dataset, ) -def test_unknown_dataset( - *, - model_target_credentials: ModelTargetCredentials, - base_vws_url: str, - method: HTTPMethod, - path: str, -) -> None: - """Unknown datasets are rejected with a NOT_FOUND error.""" - access_token = get_access_token( - base_vws_url=base_vws_url, - credentials=model_target_credentials, - ) - response = requests.request( - method=method, - url=f"{base_vws_url}{path}", - headers={"Authorization": f"Bearer {access_token}"}, - timeout=30, - ) - assert_model_target_status( - response=response, - status_codes=HTTPStatus.NOT_FOUND, - ) - error = response_targeted_error(response=response) - assert error["code"] == "NOT_FOUND" - assert error["message"] == ( - "Could not find a model-view database with uuid " - f"{MODEL_TARGET_DATASET_UUID}" - ) - # The user-id portion is per-account in real Vuforia, so check only - # the stable prefix. - assert error["target"].startswith("userId:") +__all__ = [ + "test_advanced_model_count_exceeds_limit", + "test_invalid_basic_auth_header", + "test_invalid_dataset_request", + "test_unknown_dataset", +] diff --git a/tests/mock_vws/model_target_web_api/errors/test_errors.py b/tests/mock_vws/model_target_web_api/errors/test_errors.py index 13cd6cee1..93edf45e5 100644 --- a/tests/mock_vws/model_target_web_api/errors/test_errors.py +++ b/tests/mock_vws/model_target_web_api/errors/test_errors.py @@ -1,105 +1,15 @@ -"""Additional errors tests.""" +"""Compatibility imports for the packaged contract suite.""" -from collections.abc import Set as AbstractSet -from http import HTTPStatus - -import pytest - -from tests.mock_vws.model_target_web_api.errors.helpers import ( - fake_response, -) -from tests.mock_vws.utils.assertions import ( - assert_model_target_status, -) - - -@pytest.mark.parametrize( - argnames="status_codes", - argvalues=[ - pytest.param(HTTPStatus.OK, id="single"), - pytest.param( - {HTTPStatus.OK, HTTPStatus.NO_CONTENT}, - id="set", - ), - ], -) -def test_expected_status( - *, - base_vws_url: str, - status_codes: HTTPStatus | AbstractSet[HTTPStatus], -) -> None: - """An expected status code does not raise.""" - response = fake_response( - status_code=HTTPStatus.OK, - text="{}", - url=f"{base_vws_url}/modeltargets/advancedDatasets", - ) - assert_model_target_status( - response=response, - status_codes=status_codes, - ) - - -def test_unexpected_status_shows_the_body(*, base_vws_url: str) -> None: - """An unexpected status code reports the URL and the body.""" - text = '{"error":{"code":"VALIDATION_ERROR"}}' - response = fake_response( - status_code=HTTPStatus.BAD_REQUEST, - text=text, - url=f"{base_vws_url}/modeltargets/advancedDatasets", - ) - with pytest.raises(expected_exception=AssertionError) as exc: - assert_model_target_status( - response=response, - status_codes=HTTPStatus.CREATED, - ) - - message = str(object=exc.value) - assert message == ( - "Expected 201 CREATED from " - f"{base_vws_url}/modeltargets/advancedDatasets, got 400.\n" - f"\nResponse body:\n{text}" - ) - - -def test_multiple_expected_statuses(*, base_vws_url: str) -> None: - """Every expected status code is named in the message.""" - response = fake_response( - status_code=HTTPStatus.BAD_REQUEST, - text="{}", - url=f"{base_vws_url}/modeltargets/advancedDatasets", - ) - with pytest.raises(expected_exception=AssertionError) as exc: - assert_model_target_status( - response=response, - status_codes={HTTPStatus.OK, HTTPStatus.NO_CONTENT}, - ) - - assert "Expected 200 OK or 204 NO_CONTENT from " in str(object=exc.value) - - -@pytest.mark.parametrize( - argnames="base_url", - argvalues=["http://example.com", "https://vws.vuforia.com"], +from vws_mock_contract_tests.model_target_web_api.errors.test_errors import ( + test_expected_status, + test_multiple_expected_statuses, + test_training_allowance_exceeded, + test_unexpected_status_shows_the_body, ) -def test_training_allowance_exceeded(*, base_url: str) -> None: - """An unexpected allowance response fails without a backend policy.""" - response = fake_response( - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - text=( - '{"error":{"code":"TRAINING_ALLOWANCE_EXCEEDED",' - '"message":"Signing quota reached","target":"7635391"}}' - ), - url=f"{base_url}/modeltargets/datasets", - ) - with pytest.raises(expected_exception=AssertionError) as exc: - assert_model_target_status( - response=response, - status_codes=HTTPStatus.CREATED, - ) - message = str(object=exc.value) - assert message == ( - f"Expected 201 CREATED from {base_url}/modeltargets/datasets, " - f"got 422.\n\nResponse body:\n{response.text}" - ) +__all__ = [ + "test_expected_status", + "test_multiple_expected_statuses", + "test_training_allowance_exceeded", + "test_unexpected_status_shows_the_body", +] diff --git a/tests/mock_vws/model_target_web_api/requests/contract/conftest.py b/tests/mock_vws/model_target_web_api/requests/contract/conftest.py index a4643c00c..ef1ebdfe4 100644 --- a/tests/mock_vws/model_target_web_api/requests/contract/conftest.py +++ b/tests/mock_vws/model_target_web_api/requests/contract/conftest.py @@ -1,12 +1,7 @@ -"""Fixtures scoped to this group of tests.""" +"""Compatibility imports for the packaged contract suite.""" -import pytest +from vws_mock_contract_tests.model_target_web_api.requests.contract.conftest import ( # noqa: E501 + fixture_verify_backend, +) - -@pytest.fixture(autouse=True) -def fixture_verify_backend( - *, - verify_model_target_mock_vuforia: None, -) -> None: - """Set up the backend for every contract test in this directory.""" - del verify_model_target_mock_vuforia +__all__ = ["fixture_verify_backend"] diff --git a/tests/mock_vws/model_target_web_api/requests/contract/test_content_length.py b/tests/mock_vws/model_target_web_api/requests/contract/test_content_length.py index 7722d238b..576036b6d 100644 --- a/tests/mock_vws/model_target_web_api/requests/contract/test_content_length.py +++ b/tests/mock_vws/model_target_web_api/requests/contract/test_content_length.py @@ -1,101 +1,13 @@ -"""Tests for the ``Content-Length`` header on every Model Target -endpoint. +"""Compatibility imports for the packaged contract suite.""" -These mirror the cross-cutting tests which the ``endpoint`` fixture -supports for the VWS and Query APIs. - -A ``Content-Length`` header which is too large is not covered, for the -same reason as it is not covered for the VWS API: real Vuforia waits -for the body it was promised before timing out, which takes too long -to run in a test. -""" - -import dataclasses -from http import HTTPMethod, HTTPStatus - -from tests.mock_vws.model_target_web_api.assertions import ( - assert_model_target_error, +from vws_mock_contract_tests.model_target_web_api.requests.contract.test_content_length import ( # noqa: E501 + test_not_integer, + test_not_integer_oauth2_token, + test_too_small, ) -from tests.mock_vws.model_target_web_api.requests.helpers import ( - assert_load_balancer_bad_request, -) -from tests.mock_vws.utils import ModelTargetEndpoint - - -def test_not_integer( - *, - model_target_endpoint: ModelTargetEndpoint, -) -> None: - """A ``Content-Length`` header which is not an integer is rejected - by the load balancer in front of Vuforia, before any bearer token - is looked at. - """ - new_endpoint = dataclasses.replace( - model_target_endpoint, - headers={ - **model_target_endpoint.headers, - "Content-Length": "0.4", - }, - ) - - response = new_endpoint.send() - - assert_load_balancer_bad_request(response=response) - - -def test_not_integer_oauth2_token(*, base_vws_url: str) -> None: - """The OAuth2 token endpoint is behind the same load balancer. - - It is not in the ``model_target_endpoint`` fixture because it takes - HTTP Basic credentials rather than a bearer token. - """ - endpoint = ModelTargetEndpoint( - base_url=base_vws_url, - path_url="/oauth2/token", - method=HTTPMethod.POST, - headers={ - "Content-Type": "application/x-www-form-urlencoded", - "Content-Length": "0.4", - }, - data=b"grant_type=client_credentials", - takes_json_body=False, - ) - - response = endpoint.send() - - assert_load_balancer_bad_request(response=response) - - -def test_too_small( - *, - model_target_endpoint: ModelTargetEndpoint, -) -> None: - """A ``Content-Length`` header which is too small truncates the - body, and the request is still rejected for having no bearer - token. - - The Model Target Web API does not sign the request body, so unlike - the VWS API it has no reason to notice the truncation before it - looks at the ``Authorization`` header. - """ - if not model_target_endpoint.takes_json_body: - return - - content_length = len(model_target_endpoint.data) - 1 - new_endpoint = dataclasses.replace( - model_target_endpoint, - headers={ - **model_target_endpoint.headers, - "Content-Length": str(object=content_length), - }, - ) - - response = new_endpoint.send() - assert_model_target_error( - response=response, - status_code=HTTPStatus.UNAUTHORIZED, - code="401", - message="no Bearer token", - target="jwt", - ) +__all__ = [ + "test_not_integer", + "test_not_integer_oauth2_token", + "test_too_small", +] diff --git a/tests/mock_vws/model_target_web_api/requests/test_requests.py b/tests/mock_vws/model_target_web_api/requests/test_requests.py index 356a53ebb..835d3cd0b 100644 --- a/tests/mock_vws/model_target_web_api/requests/test_requests.py +++ b/tests/mock_vws/model_target_web_api/requests/test_requests.py @@ -1,212 +1,15 @@ -"""Additional requests tests.""" +"""Compatibility imports for the packaged contract suite.""" -import dataclasses -from http import HTTPStatus - -import pytest - -from tests.mock_vws.fixtures.credentials import ModelTargetCredentials -from tests.mock_vws.fixtures.model_target_prepared_requests import ( - get_access_token, -) -from tests.mock_vws.model_target_web_api.requests.helpers import ( - assert_unknown_dataset, - response_error, -) -from tests.mock_vws.model_target_web_api.responses import ( - response_validation_error, -) -from tests.mock_vws.utils import ModelTargetEndpoint -from tests.mock_vws.utils.assertions import ( - assert_model_target_status, -) - - -@pytest.mark.parametrize( - argnames="content_type", - argvalues=[ - pytest.param(None, id="missing"), - pytest.param("", id="empty"), - ], -) -def test_wrong_content_type( - *, - model_target_credentials: ModelTargetCredentials, - base_vws_url: str, - model_target_endpoint: ModelTargetEndpoint, - content_type: str | None, -) -> None: - """Requests without a JSON content type are rejected with 415 by - endpoints which read a body, and are unaffected elsewhere. - """ - access_token = get_access_token( - base_vws_url=base_vws_url, - credentials=model_target_credentials, - ) - new_headers = { - **model_target_endpoint.headers, - "Authorization": f"Bearer {access_token}", - } - if content_type is None: - _ = new_headers.pop("Content-Type", None) - else: - new_headers["Content-Type"] = content_type - new_endpoint = dataclasses.replace( - model_target_endpoint, - headers=new_headers, - ) - - response = new_endpoint.send() - - if not model_target_endpoint.takes_json_body: - assert_unknown_dataset(response=response) - return - - assert_model_target_status( - response=response, - status_codes=HTTPStatus.UNSUPPORTED_MEDIA_TYPE, - ) - error = response_error(response=response) - assert error["code"] == "ERROR" - assert error["message"] == ("Expecting text/json or application/json body") - assert "target" not in error - - -def test_invalid_json( - *, - model_target_credentials: ModelTargetCredentials, - base_vws_url: str, - model_target_endpoint: ModelTargetEndpoint, -) -> None: - """Malformed JSON bodies are rejected with 400 by endpoints which - read a body, and are ignored elsewhere. - """ - access_token = get_access_token( - base_vws_url=base_vws_url, - credentials=model_target_credentials, - ) - content = b"{" - new_endpoint = dataclasses.replace( - model_target_endpoint, - headers={ - **model_target_endpoint.headers, - "Authorization": f"Bearer {access_token}", - "Content-Length": str(object=len(content)), - }, - data=content, - ) - - response = new_endpoint.send() - - if not model_target_endpoint.takes_json_body: - assert_unknown_dataset(response=response) - return - - assert_model_target_status( - response=response, - status_codes=HTTPStatus.BAD_REQUEST, - ) - error = response_error(response=response) - assert error["code"] == "ERROR" - assert error["message"].startswith("Invalid Json") - assert "target" not in error - - -def test_body_not_utf_8( - *, - model_target_credentials: ModelTargetCredentials, - base_vws_url: str, - model_target_endpoint: ModelTargetEndpoint, -) -> None: - """Bodies which are not valid UTF-8 are rejected with 400 by - endpoints which read a body, and are ignored elsewhere. - """ - access_token = get_access_token( - base_vws_url=base_vws_url, - credentials=model_target_credentials, - ) - content = b"\xff{}" - new_endpoint = dataclasses.replace( - model_target_endpoint, - headers={ - **model_target_endpoint.headers, - "Authorization": f"Bearer {access_token}", - "Content-Length": str(object=len(content)), - }, - data=content, - ) - - response = new_endpoint.send() - - if not model_target_endpoint.takes_json_body: - assert_unknown_dataset(response=response) - return - - assert_model_target_status( - response=response, - status_codes=HTTPStatus.BAD_REQUEST, - ) - error = response_error(response=response) - assert error["code"] == "ERROR" - assert error["message"].startswith("Invalid Json") - assert "target" not in error - - -@pytest.mark.parametrize( - argnames="body", - argvalues=[ - pytest.param("[]", id="array"), - pytest.param('"dataset"', id="string"), - pytest.param("1", id="number"), - pytest.param("true", id="boolean"), - pytest.param("null", id="null"), - ], +from vws_mock_contract_tests.model_target_web_api.requests.test_requests import ( # noqa: E501 + test_body_not_json_object, + test_body_not_utf_8, + test_invalid_json, + test_wrong_content_type, ) -def test_body_not_json_object( - *, - model_target_credentials: ModelTargetCredentials, - base_vws_url: str, - model_target_endpoint: ModelTargetEndpoint, - body: str, -) -> None: - """JSON bodies which are not objects are missing every field on - endpoints which read a body, and are ignored elsewhere. - """ - access_token = get_access_token( - base_vws_url=base_vws_url, - credentials=model_target_credentials, - ) - content = body.encode(encoding="utf-8") - new_endpoint = dataclasses.replace( - model_target_endpoint, - headers={ - **model_target_endpoint.headers, - "Authorization": f"Bearer {access_token}", - "Content-Length": str(object=len(content)), - }, - data=content, - ) - - response = new_endpoint.send() - - if not model_target_endpoint.takes_json_body: - assert_unknown_dataset(response=response) - return - assert_model_target_status( - response=response, - status_codes=HTTPStatus.BAD_REQUEST, - ) - error = response_validation_error(response=response) - assert error["code"] == "BAD_REQUEST" - assert error["message"] == ( - f"Validation error for request {error['target']}" - ) - actual_messages = {detail["message"] for detail in error["details"]} - assert actual_messages == { - "/models: element is required", - "/name: element is required", - "/targetSdk: element is required", - } - for detail in error["details"]: - assert detail["code"] == "VALIDATION_ERROR" +__all__ = [ + "test_body_not_json_object", + "test_body_not_utf_8", + "test_invalid_json", + "test_wrong_content_type", +] diff --git a/tests/mock_vws/model_target_web_api/test_datasets.py b/tests/mock_vws/model_target_web_api/test_datasets.py index d3b56e262..8a4887114 100644 --- a/tests/mock_vws/model_target_web_api/test_datasets.py +++ b/tests/mock_vws/model_target_web_api/test_datasets.py @@ -1,626 +1,23 @@ -"""Tests for datasets through the Model Target Web API. - -Verified fake tests for State-Based Model Targets. - -The advanced (signed) cases are verified against the real Vuforia -only when ``--verify-model-target-signing`` is given: see -the ``signed_model_target_credentials`` fixture. -Additional verified and mock-only Model Target behaviors. -""" - -import base64 -import io -import json -import time -import zipfile -from http import HTTPStatus -from uuid import uuid4 - -import pytest -import requests -from beartype import beartype - -from tests.mock_vws.fixtures.credentials import ModelTargetCredentials -from tests.mock_vws.fixtures.model_target_prepared_requests import ( - get_access_token, -) -from tests.mock_vws.model_target_web_api.responses import ( - parse_response_json, - response_validation_error, -) -from tests.mock_vws.model_target_web_api.sample_data import ( - MODEL, - STATE_CONFIGURATION, - UNAUTHENTICATED_DATASET_REQUEST, - VIEW, -) -from tests.mock_vws.utils.assertions import ( - assert_model_target_status, -) -from tests.mock_vws.utils.model_target_retries import model_target_get - - -@beartype -def _dataset_request(*, cad_data_url: str) -> dict[str, object]: - """Return a standard Model Target dataset request.""" - return { - "name": f"dataset-{uuid4().hex}", - "targetSdk": "10.18", - "models": [ - { - "name": "model-name", - "cadDataUrl": cad_data_url, - "views": [VIEW], - }, - ], - } - - -@beartype -def _cad_data_blob() -> str: - """Return a base64-encoded zipped model for inline CAD data.""" - zip_buffer = io.BytesIO() - with zipfile.ZipFile(file=zip_buffer, mode="w") as zip_file: - zip_file.writestr( - zinfo_or_arcname="model.gltf", - data=json.dumps(obj={"asset": {"version": "2.0"}}), - ) - return base64.b64encode(s=zip_buffer.getvalue()).decode(encoding="ascii") - - -@beartype -def _blob_dataset_request() -> dict[str, object]: - """Return a standard dataset request with inline CAD data.""" - return { - "name": f"dataset-{uuid4().hex}", - "targetSdk": "10.18", - "models": [ - { - "name": "model-name", - "cadDataBlob": _cad_data_blob(), - "cadDataFormat": "ZIP", - "views": [VIEW], - }, - ], - } - - -@pytest.mark.parametrize( - argnames="dataset_path", - argvalues=[ - pytest.param("/modeltargets/datasets", id="standard"), - pytest.param( - "/modeltargets/advancedDatasets", - id="advanced", - ), - ], -) -@pytest.mark.parametrize( - argnames="view_updates", - argvalues=[ - pytest.param({}, id="all-states"), - pytest.param( - {"states": ["assembled"]}, - id="selected-states", - ), - ], -) -def test_state_based_dataset( - *, - signed_model_target_credentials: ModelTargetCredentials, - base_vws_url: str, - dataset_path: str, - view_updates: dict[str, object], -) -> None: - """State-Based Model Target fields survive a dataset round - trip. - """ - body = { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [ - { - **MODEL, - "stateBasedConfigurationJsonString": (STATE_CONFIGURATION), - "views": [{**VIEW, **view_updates}], - }, - ], - } - access_token = get_access_token( - base_vws_url=base_vws_url, - credentials=signed_model_target_credentials, - ) - headers = {"Authorization": f"Bearer {access_token}"} - create_response = requests.post( - url=f"{base_vws_url}{dataset_path}", - headers=headers, - json=body, - timeout=30, - ) - - assert_model_target_status( - response=create_response, - status_codes=HTTPStatus.CREATED, - ) - dataset_uuid = parse_response_json(response=create_response)["uuid"] - assert isinstance(dataset_uuid, str) - delete_response = requests.delete( - url=f"{base_vws_url}{dataset_path}/{dataset_uuid}", - headers=headers, - timeout=30, - ) - assert_model_target_status( - response=delete_response, - status_codes=HTTPStatus.OK, - ) - - -def test_view_states_are_a_subset( - *, - model_target_credentials: ModelTargetCredentials, - base_vws_url: str, -) -> None: - """A view cannot select a state absent from the configuration.""" - body = { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [ - { - **MODEL, - "stateBasedConfigurationJsonString": (STATE_CONFIGURATION), - "views": [{**VIEW, "states": ["unknown"]}], - }, - ], - } - access_token = get_access_token( - base_vws_url=base_vws_url, - credentials=model_target_credentials, - ) - response = requests.post( - url=f"{base_vws_url}/modeltargets/datasets", - headers={"Authorization": f"Bearer {access_token}"}, - json=body, - timeout=30, - ) - - assert_model_target_status( - response=response, - status_codes=HTTPStatus.BAD_REQUEST, - ) - error = response_validation_error(response=response) - assert error["code"] == "BAD_REQUEST" - assert [detail["message"] for detail in error["details"]] == [ - "states in entrypoint view-name' must be a subset of all states", - ] - assert error["details"][0]["code"] == "VALIDATION_ERROR" - - -@pytest.mark.parametrize( - argnames=("model_updates", "view_updates", "expected_message"), - argvalues=[ - pytest.param( - {"stateBasedConfigurationJsonString": 1}, - {}, - ( - "/models(0)/stateBasedConfigurationJsonString: " - "error.expected.jsstring" - ), - id="configuration-not-string", - ), - pytest.param( - {"stateBasedConfigurationJsonString": "{"}, - {}, - ( - "/models(0)/stateBasedConfigurationJsonString: " - "error.expected.validjson" - ), - id="configuration-not-json", - ), - pytest.param( - {"stateBasedConfigurationJsonString": "{}"}, - {}, - ( - "/models(0)/stateBasedConfigurationJsonString/states: " - "error.expected.jsobject" - ), - id="configuration-states-not-object", - ), - pytest.param( - {"stateBasedConfigurationJsonString": "[]"}, - {}, - ( - "/models(0)/stateBasedConfigurationJsonString/states: " - "error.expected.jsobject" - ), - id="configuration-not-object", - ), - pytest.param( - {"stateBasedConfigurationJsonString": STATE_CONFIGURATION}, - {"states": "assembled"}, - "/models(0)/views(0)/states: error.expected.jsarray", - id="view-states-not-array", - ), - pytest.param( - {"stateBasedConfigurationJsonString": STATE_CONFIGURATION}, - {"states": ["assembled", 1]}, - ("/models(0)/views(0)/states(1): error.expected.jsstring"), - id="view-state-not-string", - ), - pytest.param( - {}, - {"states": ["assembled"]}, - ( - "/models(0)/stateBasedConfigurationJsonString: element " - "is required when view states are given" - ), - id="view-states-without-configuration", - ), - ], -) -def test_invalid_state_based_dataset( - *, - mock_model_target_credentials: ModelTargetCredentials, - base_vws_url: str, - model_updates: dict[str, object], - view_updates: dict[str, object], - expected_message: str, -) -> None: - """Invalid State-Based Model Target fields are rejected.""" - body = { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [ - { - **MODEL, - **model_updates, - "views": [{**VIEW, **view_updates}], - }, - ], - } - access_token = get_access_token( - base_vws_url=base_vws_url, - credentials=mock_model_target_credentials, - ) - response = requests.post( - url=f"{base_vws_url}/modeltargets/datasets", - headers={"Authorization": f"Bearer {access_token}"}, - json=body, - timeout=30, - ) - - assert_model_target_status( - response=response, - status_codes=HTTPStatus.BAD_REQUEST, - ) - error = response_validation_error(response=response) - assert error["code"] == "BAD_REQUEST" - assert [detail["message"] for detail in error["details"]] == [ - expected_message, - ] - assert error["details"][0]["code"] == "VALIDATION_ERROR" - - -def test_advanced_realistic_appearance_not_in_enum( - *, - model_target_credentials: ModelTargetCredentials, - base_vws_url: str, -) -> None: - """Advanced dataset requests with a ``realisticAppearance`` value - outside the documented enumeration are rejected. - - The Model Target OpenAPI specification documents - ``realisticAppearance`` as a model field for advanced datasets - only. - """ - body = { - **UNAUTHENTICATED_DATASET_REQUEST, - "models": [ - { - **MODEL, - "cadDataUrl": model_target_credentials.cad_data_url, - "realisticAppearance": "yes", - }, - ], - } - access_token = get_access_token( - base_vws_url=base_vws_url, - credentials=model_target_credentials, - ) - advanced_response = requests.post( - url=f"{base_vws_url}/modeltargets/advancedDatasets", - headers={"Authorization": f"Bearer {access_token}"}, - json=body, - timeout=30, - ) - - assert_model_target_status( - response=advanced_response, - status_codes=HTTPStatus.BAD_REQUEST, - ) - error = response_validation_error(response=advanced_response) - assert error["code"] == "BAD_REQUEST" - assert [detail["message"] for detail in error["details"]] == [ - '`realisticAppearance` must be one of "true", "false", "auto".` ', - ] - assert error["details"][0]["code"] == "VALIDATION_ERROR" - - -def test_oauth2_token_body_not_utf_8( - *, - model_target_credentials: ModelTargetCredentials, - base_vws_url: str, -) -> None: - """An OAuth2 token request with a body which is not valid UTF-8 is - treated as one which does not name a grant type. - - Real Vuforia also treats a body that cannot be decoded as an empty - form. - """ - response = requests.post( - url=f"{base_vws_url}/oauth2/token", - auth=( - model_target_credentials.client_id, - model_target_credentials.client_secret, - ), - data=b"\xff", - timeout=30, - ) - - assert_model_target_status( - response=response, - status_codes=HTTPStatus.OK, - ) - assert response.json()["token_type"] == "bearer" - - -@pytest.mark.parametrize( - argnames=("created_path", "other_path"), - argvalues=[ - pytest.param( - "/modeltargets/datasets", - "/modeltargets/advancedDatasets", - id="standard-dataset-via-advanced-routes", - ), - pytest.param( - "/modeltargets/advancedDatasets", - "/modeltargets/datasets", - id="advanced-dataset-via-standard-routes", - ), - ], -) -def test_dataset_is_visible_to_the_other_dataset_type( - *, - model_target_credentials: ModelTargetCredentials, - base_vws_url: str, - created_path: str, - other_path: str, -) -> None: - """Standard and advanced routes share datasets by UUID.""" - access_token = get_access_token( - base_vws_url=base_vws_url, - credentials=model_target_credentials, - ) - headers = {"Authorization": f"Bearer {access_token}"} - create_response = requests.post( - url=f"{base_vws_url}{created_path}", - headers=headers, - json=_dataset_request( - cad_data_url=model_target_credentials.cad_data_url, - ), - timeout=30, - ) - assert_model_target_status( - response=create_response, - status_codes=HTTPStatus.CREATED, - ) - dataset_uuid: object = create_response.json()["uuid"] - assert isinstance(dataset_uuid, str) - - try: - other_status_response = model_target_get( - url=f"{base_vws_url}{other_path}/{dataset_uuid}/status", - headers=headers, - timeout=30, - ) - other_delete_response = requests.delete( - url=f"{base_vws_url}{other_path}/{dataset_uuid}", - headers=headers, - timeout=30, - ) - own_status_response = model_target_get( - url=( - f"{base_vws_url}{created_path}/" - f"{create_response.json()['uuid']}/status" - ), - headers=headers, - timeout=30, - ) - finally: - delete_response = requests.delete( - url=f"{base_vws_url}{created_path}/{dataset_uuid}", - headers=headers, - timeout=30, - ) - assert_model_target_status( - response=delete_response, - status_codes={ - HTTPStatus.OK, - HTTPStatus.NO_CONTENT, - }, - ) - - assert_model_target_status( - response=other_status_response, - status_codes=HTTPStatus.OK, - ) - assert_model_target_status( - response=other_delete_response, - status_codes={ - HTTPStatus.OK, - HTTPStatus.NO_CONTENT, - }, - ) - assert_model_target_status( - response=own_status_response, - status_codes=HTTPStatus.OK, - ) - - -def test_create_status_and_delete( - *, - model_target_credentials: ModelTargetCredentials, - base_vws_url: str, -) -> None: - """A standard dataset works through the shared advanced routes. - - Standard generation is fast enough for verified CI coverage. Real - Vuforia exposes its completed artifact through both route families, - so this also verifies the advanced status and download endpoints. - """ - access_token = get_access_token( - base_vws_url=base_vws_url, - credentials=model_target_credentials, - ) - headers = {"Authorization": f"Bearer {access_token}"} - create_response = requests.post( - url=f"{base_vws_url}/modeltargets/datasets", - headers=headers, - json=_dataset_request( - cad_data_url=model_target_credentials.cad_data_url - ), - timeout=30, - ) - - assert_model_target_status( - response=create_response, - status_codes=HTTPStatus.CREATED, - ) - create_response_json = parse_response_json(response=create_response) - dataset_uuid: object = create_response_json["uuid"] - assert isinstance(dataset_uuid, str) - - try: - status_response = model_target_get( - url=( - f"{base_vws_url}/modeltargets/advancedDatasets/" - f"{dataset_uuid}/status" - ), - headers=headers, - timeout=30, - ) - - assert_model_target_status( - response=status_response, - status_codes=HTTPStatus.OK, - ) - status_response_json = parse_response_json(response=status_response) - assert status_response_json["status"] in { - "processing", - "done", - "failed", - } - assert isinstance(status_response_json["createdAt"], str) - - deadline = time.monotonic() + 60 - while ( - status_response_json["status"] == "processing" - and time.monotonic() < deadline - ): - time.sleep(1) - status_response = model_target_get( - url=( - f"{base_vws_url}/modeltargets/advancedDatasets/" - f"{dataset_uuid}/status" - ), - headers=headers, - timeout=30, - ) - assert_model_target_status( - response=status_response, - status_codes=HTTPStatus.OK, - ) - status_response_json = status_response.json() - - assert status_response_json["status"] == "done" - assert isinstance(status_response_json["completedAt"], str) - assert set(status_response_json) == { - "completedAt", - "createdAt", - "status", - "uuid", - } - - download_response = model_target_get( - url=( - f"{base_vws_url}/modeltargets/advancedDatasets/" - f"{dataset_uuid}/dataset" - ), - headers=headers, - timeout=30, - ) - assert_model_target_status( - response=download_response, - status_codes=HTTPStatus.OK, - ) - assert download_response.headers["Content-Type"] == ("application/zip") - assert download_response.headers["Content-Disposition"] == ( - "attachment; filename=full-dataset.zip" - ) - with zipfile.ZipFile( - file=io.BytesIO(initial_bytes=download_response.content), - ) as archive: - assert archive.namelist() == ["MTDataset.dat", "MTDataset.xml"] - finally: - delete_response = requests.delete( - url=f"{base_vws_url}/modeltargets/datasets/{dataset_uuid}", - headers=headers, - timeout=30, - ) - assert_model_target_status( - response=delete_response, - status_codes={ - HTTPStatus.OK, - HTTPStatus.NO_CONTENT, - }, - ) - - -def test_create_with_cad_data_blob( - *, - signed_model_target_credentials: ModelTargetCredentials, - base_vws_url: str, -) -> None: - """A dataset can be created with inline CAD data.""" - access_token = get_access_token( - base_vws_url=base_vws_url, - credentials=signed_model_target_credentials, - ) - headers = {"Authorization": f"Bearer {access_token}"} - - create_response = requests.post( - url=f"{base_vws_url}/modeltargets/datasets", - headers=headers, - json=_blob_dataset_request(), - timeout=30, - ) - - assert_model_target_status( - response=create_response, - status_codes=HTTPStatus.CREATED, - ) - create_response_json = parse_response_json(response=create_response) - dataset_uuid = create_response_json["uuid"] - assert isinstance(dataset_uuid, str) - - # There is nothing to assert between creating and deleting the - # dataset, so the delete does not need a ``finally`` block to avoid - # leaving a dataset behind on real Vuforia. - delete_response = requests.delete( - url=f"{base_vws_url}/modeltargets/datasets/{dataset_uuid}", - headers=headers, - timeout=30, - ) - assert_model_target_status( - response=delete_response, - status_codes={ - HTTPStatus.OK, - HTTPStatus.NO_CONTENT, - }, - ) +"""Compatibility imports for the packaged contract suite.""" + +from vws_mock_contract_tests.model_target_web_api.test_datasets import ( + test_advanced_realistic_appearance_not_in_enum, + test_create_status_and_delete, + test_create_with_cad_data_blob, + test_dataset_is_visible_to_the_other_dataset_type, + test_invalid_state_based_dataset, + test_oauth2_token_body_not_utf_8, + test_state_based_dataset, + test_view_states_are_a_subset, +) + +__all__ = [ + "test_advanced_realistic_appearance_not_in_enum", + "test_create_status_and_delete", + "test_create_with_cad_data_blob", + "test_dataset_is_visible_to_the_other_dataset_type", + "test_invalid_state_based_dataset", + "test_oauth2_token_body_not_utf_8", + "test_state_based_dataset", + "test_view_states_are_a_subset", +] diff --git a/tests/mock_vws/requests_mock/test_mock_only_oauth2_edge_cases.py b/tests/mock_vws/requests_mock/test_mock_only_oauth2_edge_cases.py index 0e6988c5b..3fd25efb1 100644 --- a/tests/mock_vws/requests_mock/test_mock_only_oauth2_edge_cases.py +++ b/tests/mock_vws/requests_mock/test_mock_only_oauth2_edge_cases.py @@ -7,16 +7,18 @@ import requests from mock_vws import MockVWS -from tests.mock_vws.model_target_web_api.authentication import ( +from vws_mock_contract_tests.model_target_web_api.authentication import ( MOCK_BEARER_TOKEN, ) -from tests.mock_vws.model_target_web_api.responses import parse_response_json -from tests.mock_vws.model_target_web_api.sample_data import ( +from vws_mock_contract_tests.model_target_web_api.responses import ( + parse_response_json, +) +from vws_mock_contract_tests.model_target_web_api.sample_data import ( MODEL, STATE_CONFIGURATION, UNAUTHENTICATED_DATASET_REQUEST, ) -from tests.mock_vws.utils.assertions import ( +from vws_mock_contract_tests.utils.assertions import ( assert_model_target_status, ) diff --git a/tests/mock_vws/requests_mock/test_model_target_response_policy.py b/tests/mock_vws/requests_mock/test_model_target_response_policy.py index e7a0b1e45..54b5368bf 100644 --- a/tests/mock_vws/requests_mock/test_model_target_response_policy.py +++ b/tests/mock_vws/requests_mock/test_model_target_response_policy.py @@ -7,12 +7,14 @@ import requests from vws.response import Response -from tests.mock_vws.fixtures.vuforia_backends import VuforiaBackend -from tests.mock_vws.model_target_web_api.errors.helpers import fake_response -from tests.mock_vws.utils.assertions import assert_model_target_status -from tests.mock_vws.utils.model_target_response_policy import ( +from vws_mock_contract_tests.model_target_web_api.errors.helpers import ( + fake_response, +) +from vws_mock_contract_tests.utils.assertions import assert_model_target_status +from vws_mock_contract_tests.utils.model_target_response_policy import ( handling_unexpected_model_target_responses, ) +from vws_mock_test_backends.vuforia_backends import VuforiaBackend _ALLOWANCE_BODY = '{"error":{"code":"TRAINING_ALLOWANCE_EXCEEDED"}}' diff --git a/tests/mock_vws/requests_mock/test_model_targets.py b/tests/mock_vws/requests_mock/test_model_targets.py index b35a93b76..00290d00e 100644 --- a/tests/mock_vws/requests_mock/test_model_targets.py +++ b/tests/mock_vws/requests_mock/test_model_targets.py @@ -8,17 +8,17 @@ from freezegun import freeze_time from mock_vws import MockVWS, ModelTargetGenerationFailure -from tests.mock_vws.model_target_web_api.authentication import ( +from vws_mock_contract_tests.model_target_web_api.authentication import ( MOCK_BEARER_TOKEN, ) -from tests.mock_vws.model_target_web_api.responses import ( +from vws_mock_contract_tests.model_target_web_api.responses import ( parse_response_json, response_targeted_error, ) -from tests.mock_vws.model_target_web_api.sample_data import ( +from vws_mock_contract_tests.model_target_web_api.sample_data import ( UNAUTHENTICATED_DATASET_REQUEST, ) -from tests.mock_vws.utils.assertions import assert_model_target_status +from vws_mock_contract_tests.utils.assertions import assert_model_target_status _MODEL_TARGET_AUTHORIZATION = ( "Bearer eyJhbGciOiJtb2NrIn0." From e11c64ef11a85be902f18d98f1b7ffdbdef613c8 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 28 Oct 2026 17:50:12 +0000 Subject: [PATCH 07/10] Package the shared HTTP header and invalid-request contracts --- .../authorization_header/__init__.py | 1 + .../authorization_header/conftest.py | 12 + .../test_authorization_header.py | 65 ++++ .../authorization_header/test_bad_key.py | 166 +++++++++ .../authorization_header/test_malformed.py | 166 +++++++++ .../content_length/__init__.py | 1 + .../content_length/conftest.py | 12 + .../content_length/test_incorrect.py | 198 +++++++++++ .../date_header/__init__.py | 1 + .../date_header/conftest.py | 12 + .../date_header/helpers.py | 0 .../date_header/test_format.py | 82 +++++ .../date_header/test_missing.py | 71 ++++ .../date_header/test_skewed_time.py | 322 +++++++++++++++++ .../header_size/__init__.py | 1 + .../header_size/conftest.py | 12 + .../header_size/helpers.py | 2 +- .../header_size/test_oversized_header_line.py | 117 +++++++ .../invalid_given_id/__init__.py | 1 + .../invalid_given_id/conftest.py | 12 + .../invalid_given_id/helpers.py | 6 +- .../invalid_given_id/test_invalid_given_id.py | 45 +++ .../test_unrouted_requests.py | 68 ++++ .../invalid_json/__init__.py | 1 + .../invalid_json/conftest.py | 12 + .../invalid_json/helpers.py | 8 +- .../invalid_json/test_invalid_json.py | 223 ++++++++++++ .../unexpected_json/__init__.py | 1 + .../unexpected_json/conftest.py | 12 + .../unexpected_json/test_unexpected_json.py | 82 +++++ .../mock_vws/authorization_header/conftest.py | 15 +- .../test_authorization_header.py | 64 +--- .../authorization_header/test_bad_key.py | 173 +-------- .../authorization_header/test_malformed.py | 171 +-------- tests/mock_vws/content_length/conftest.py | 15 +- .../mock_vws/content_length/test_incorrect.py | 197 +---------- tests/mock_vws/date_header/conftest.py | 15 +- tests/mock_vws/date_header/test_format.py | 81 +---- tests/mock_vws/date_header/test_missing.py | 70 +--- .../mock_vws/date_header/test_skewed_time.py | 329 +----------------- tests/mock_vws/header_size/conftest.py | 15 +- .../header_size/test_oversized_header_line.py | 122 +------ tests/mock_vws/invalid_given_id/conftest.py | 15 +- .../invalid_given_id/test_invalid_given_id.py | 46 +-- .../test_unrouted_requests.py | 68 +--- tests/mock_vws/invalid_json/conftest.py | 15 +- .../invalid_json/test_invalid_json.py | 234 +------------ tests/mock_vws/unexpected_json/conftest.py | 15 +- .../unexpected_json/test_unexpected_json.py | 83 +---- 49 files changed, 1833 insertions(+), 1622 deletions(-) create mode 100644 contract-tests/src/vws_mock_contract_tests/authorization_header/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/authorization_header/conftest.py create mode 100644 contract-tests/src/vws_mock_contract_tests/authorization_header/test_authorization_header.py create mode 100644 contract-tests/src/vws_mock_contract_tests/authorization_header/test_bad_key.py create mode 100644 contract-tests/src/vws_mock_contract_tests/authorization_header/test_malformed.py create mode 100644 contract-tests/src/vws_mock_contract_tests/content_length/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/content_length/conftest.py create mode 100644 contract-tests/src/vws_mock_contract_tests/content_length/test_incorrect.py create mode 100644 contract-tests/src/vws_mock_contract_tests/date_header/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/date_header/conftest.py rename {tests/mock_vws => contract-tests/src/vws_mock_contract_tests}/date_header/helpers.py (100%) create mode 100644 contract-tests/src/vws_mock_contract_tests/date_header/test_format.py create mode 100644 contract-tests/src/vws_mock_contract_tests/date_header/test_missing.py create mode 100644 contract-tests/src/vws_mock_contract_tests/date_header/test_skewed_time.py create mode 100644 contract-tests/src/vws_mock_contract_tests/header_size/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/header_size/conftest.py rename {tests/mock_vws => contract-tests/src/vws_mock_contract_tests}/header_size/helpers.py (98%) create mode 100644 contract-tests/src/vws_mock_contract_tests/header_size/test_oversized_header_line.py create mode 100644 contract-tests/src/vws_mock_contract_tests/invalid_given_id/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/invalid_given_id/conftest.py rename {tests/mock_vws => contract-tests/src/vws_mock_contract_tests}/invalid_given_id/helpers.py (87%) create mode 100644 contract-tests/src/vws_mock_contract_tests/invalid_given_id/test_invalid_given_id.py create mode 100644 contract-tests/src/vws_mock_contract_tests/invalid_given_id/test_unrouted_requests.py create mode 100644 contract-tests/src/vws_mock_contract_tests/invalid_json/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/invalid_json/conftest.py rename {tests/mock_vws => contract-tests/src/vws_mock_contract_tests}/invalid_json/helpers.py (93%) create mode 100644 contract-tests/src/vws_mock_contract_tests/invalid_json/test_invalid_json.py create mode 100644 contract-tests/src/vws_mock_contract_tests/unexpected_json/__init__.py create mode 100644 contract-tests/src/vws_mock_contract_tests/unexpected_json/conftest.py create mode 100644 contract-tests/src/vws_mock_contract_tests/unexpected_json/test_unexpected_json.py diff --git a/contract-tests/src/vws_mock_contract_tests/authorization_header/__init__.py b/contract-tests/src/vws_mock_contract_tests/authorization_header/__init__.py new file mode 100644 index 000000000..b34420691 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/authorization_header/__init__.py @@ -0,0 +1 @@ +"""Authorization header tests.""" diff --git a/contract-tests/src/vws_mock_contract_tests/authorization_header/conftest.py b/contract-tests/src/vws_mock_contract_tests/authorization_header/conftest.py new file mode 100644 index 000000000..d409c8ebf --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/authorization_header/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures scoped to this group of tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def fixture_verify_backend( + *, + verify_mock_vuforia: None, +) -> None: + """Set up the backend for every contract test in this directory.""" + del verify_mock_vuforia diff --git a/contract-tests/src/vws_mock_contract_tests/authorization_header/test_authorization_header.py b/contract-tests/src/vws_mock_contract_tests/authorization_header/test_authorization_header.py new file mode 100644 index 000000000..54592fd86 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/authorization_header/test_authorization_header.py @@ -0,0 +1,65 @@ +""" +Tests for what happens when the `Authorization` header is not as +expected. +""" + +from http import HTTPStatus + +from vws_auth_tools import rfc_1123_date + +from vws_mock_contract_tests.utils import Endpoint +from vws_mock_contract_tests.utils.assertions import ( + assert_vwq_failure, + assert_vws_failure, +) +from vws_mock_contract_tests.utils.too_many_requests import ( + handle_server_errors, +) + + +def test_missing(endpoint: Endpoint, base_vwq_url: str) -> None: + """ + An `UNAUTHORIZED` response is returned when no `Authorization` + header + is given. + """ + date = rfc_1123_date() + new_headers: dict[str, str] = { + **endpoint.headers, + "Date": date, + } + _ = new_headers.pop("Authorization", None) + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + + handle_server_errors(response=response) + + if endpoint.base_url == base_vwq_url: + assert_vwq_failure( + response=response, + status_code=HTTPStatus.UNAUTHORIZED, + content_type="text/plain;charset=iso-8859-1", + cache_control=None, + www_authenticate="KWS", + connection="keep-alive", + ) + assert response.text == "Authorization header missing." + return + + assert_vws_failure( + response=response, + status_code=HTTPStatus.UNAUTHORIZED, + result_code="AuthenticationFailure", + ) diff --git a/contract-tests/src/vws_mock_contract_tests/authorization_header/test_bad_key.py b/contract-tests/src/vws_mock_contract_tests/authorization_header/test_bad_key.py new file mode 100644 index 000000000..f9ebad93c --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/authorization_header/test_bad_key.py @@ -0,0 +1,166 @@ +"""Tests for making requests with incorrect keys.""" + +import io +import json +import uuid +from http import HTTPStatus + +import pytest +from vws import VWS, CloudRecoService +from vws.exceptions import cloud_reco_exceptions +from vws.exceptions.vws_exceptions import AuthenticationFailureError, FailError + +from vws_mock_contract_tests.fixtures.credentials import ( + CloudDatabaseCredentials, +) +from vws_mock_contract_tests.utils.assertions import ( + assert_valid_transaction_id, + assert_vwq_failure, +) + + +def test_bad_access_key_services( + vuforia_database: CloudDatabaseCredentials, + base_vws_url: str, +) -> None: + """ + If the server access key given does not match any database, a + ``Fail`` + response is returned. + """ + vws_client = VWS( + server_access_key="example", + server_secret_key=vuforia_database.server_secret_key, + base_vws_url=base_vws_url, + ) + + with pytest.raises(expected_exception=FailError) as exc: + _ = vws_client.get_target_record(target_id=uuid.uuid4().hex) + + assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST + + +def test_bad_access_key_query( + *, + vuforia_database: CloudDatabaseCredentials, + high_quality_image: io.BytesIO, + base_vwq_url: str, +) -> None: + """ + If the client access key given is incorrect, an ``UNAUTHORIZED`` + response is returned. + """ + cloud_reco_client = CloudRecoService( + client_access_key="example", + client_secret_key=vuforia_database.client_secret_key, + base_vwq_url=base_vwq_url, + ) + + with pytest.raises( + expected_exception=cloud_reco_exceptions.AuthenticationFailureError + ) as exc: + _ = cloud_reco_client.query(image=high_quality_image) + + response = exc.value.response + + assert_vwq_failure( + response=response, + status_code=HTTPStatus.UNAUTHORIZED, + content_type="application/json", + cache_control=None, + www_authenticate="VWS", + connection="keep-alive", + ) + + assert json.loads(s=response.text).keys() == { + "transaction_id", + "result_code", + } + assert_valid_transaction_id(response=response) + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert isinstance(response_json["result_code"], str) + assert isinstance(response_json["transaction_id"], str) + result_code: str = response_json["result_code"] + transaction_id: str = response_json["transaction_id"] + assert result_code == "AuthenticationFailure" + # The separators are inconsistent and we test this. + expected_text = ( + '{"transaction_id":' + f'"{transaction_id}",' + f'"result_code":"{result_code}"' + "}" + ) + assert response.text == expected_text + + +def test_bad_secret_key_services( + vuforia_database: CloudDatabaseCredentials, + base_vws_url: str, +) -> None: + """ + If the server secret key given is incorrect, an + ``AuthenticationFailureError`` response is returned. + """ + vws_client = VWS( + server_access_key=vuforia_database.server_access_key, + server_secret_key="example", + base_vws_url=base_vws_url, + ) + + with pytest.raises(expected_exception=AuthenticationFailureError): + _ = vws_client.get_target_record(target_id=uuid.uuid4().hex) + + +def test_bad_secret_key_query( + *, + vuforia_database: CloudDatabaseCredentials, + high_quality_image: io.BytesIO, + base_vwq_url: str, +) -> None: + """ + If the client secret key given is incorrect, an ``UNAUTHORIZED`` + response is returned. + """ + cloud_reco_client = CloudRecoService( + client_access_key=vuforia_database.client_access_key, + client_secret_key="example", + base_vwq_url=base_vwq_url, + ) + + with pytest.raises( + expected_exception=cloud_reco_exceptions.AuthenticationFailureError + ) as exc: + _ = cloud_reco_client.query(image=high_quality_image) + + response = exc.value.response + + assert_vwq_failure( + response=response, + status_code=HTTPStatus.UNAUTHORIZED, + content_type="application/json", + cache_control=None, + www_authenticate="VWS", + connection="keep-alive", + ) + + assert json.loads(s=response.text).keys() == { + "transaction_id", + "result_code", + } + assert_valid_transaction_id(response=response) + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert isinstance(response_json["result_code"], str) + assert isinstance(response_json["transaction_id"], str) + result_code: str = response_json["result_code"] + transaction_id: str = response_json["transaction_id"] + assert result_code == "AuthenticationFailure" + # The separators are inconsistent and we test this. + expected_text = ( + '{"transaction_id":' + f'"{transaction_id}",' + f'"result_code":"{result_code}"' + "}" + ) + assert response.text == expected_text diff --git a/contract-tests/src/vws_mock_contract_tests/authorization_header/test_malformed.py b/contract-tests/src/vws_mock_contract_tests/authorization_header/test_malformed.py new file mode 100644 index 000000000..f20986503 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/authorization_header/test_malformed.py @@ -0,0 +1,166 @@ +"""Tests for passing a malformed ``Authorization`` header.""" + +from http import HTTPStatus + +from vws_auth_tools import rfc_1123_date + +from vws_mock_contract_tests.utils import Endpoint +from vws_mock_contract_tests.utils.assertions import ( + assert_vwq_failure, + assert_vws_failure, +) +from vws_mock_contract_tests.utils.too_many_requests import ( + handle_server_errors, +) + + +def test_one_part_no_space(endpoint: Endpoint, base_vwq_url: str) -> None: + """A valid authorization string is two "parts" when split on a + space. + + When + a string is given which is one "part", a ``BAD_REQUEST`` or + ``UNAUTHORIZED`` response is returned. + """ + date = rfc_1123_date() + + # We use "VWS" as this is the first part of a valid authorization + # string, but really any string which is not two parts when split on a + # space will do. + authorization_string = "VWS" + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + handle_server_errors(response=response) + + if endpoint.base_url == base_vwq_url: + assert_vwq_failure( + response=response, + status_code=HTTPStatus.UNAUTHORIZED, + content_type="text/plain;charset=iso-8859-1", + cache_control=None, + www_authenticate="KWS", + connection="keep-alive", + ) + assert response.text == "Malformed authorization header." + return + + assert_vws_failure( + response=response, + status_code=HTTPStatus.BAD_REQUEST, + result_code="Fail", + ) + + +def test_one_part_with_space(endpoint: Endpoint, base_vwq_url: str) -> None: + """A valid authorization string is two "parts" when split on a + space. + + When + a string is given which is one "part", a ``BAD_REQUEST`` or + ``UNAUTHORIZED`` response is returned. + """ + authorization_string = "VWS " + date = rfc_1123_date() + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + handle_server_errors(response=response) + + if endpoint.base_url == base_vwq_url: + assert_vwq_failure( + response=response, + status_code=HTTPStatus.UNAUTHORIZED, + content_type="text/plain;charset=iso-8859-1", + cache_control=None, + www_authenticate="KWS", + connection="keep-alive", + ) + assert response.text == "Malformed authorization header." + return + + assert_vws_failure( + response=response, + status_code=HTTPStatus.BAD_REQUEST, + result_code="Fail", + ) + + +def test_missing_signature(endpoint: Endpoint, base_vwq_url: str) -> None: + """ + If a signature is missing `Authorization` header is given, a + ``BAD_REQUEST`` response is given. + """ + date = rfc_1123_date() + + authorization_string = "VWS foobar:" + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + handle_server_errors(response=response) + + if endpoint.base_url == base_vwq_url: + assert_vwq_failure( + response=response, + status_code=HTTPStatus.UNAUTHORIZED, + content_type="text/plain;charset=iso-8859-1", + cache_control=None, + www_authenticate="KWS", + connection="keep-alive", + ) + assert response.text == "Malformed authorization header." + return + + assert_vws_failure( + response=response, + status_code=HTTPStatus.BAD_REQUEST, + result_code="Fail", + ) diff --git a/contract-tests/src/vws_mock_contract_tests/content_length/__init__.py b/contract-tests/src/vws_mock_contract_tests/content_length/__init__.py new file mode 100644 index 000000000..d3bfec8e3 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/content_length/__init__.py @@ -0,0 +1 @@ +"""Content length tests.""" diff --git a/contract-tests/src/vws_mock_contract_tests/content_length/conftest.py b/contract-tests/src/vws_mock_contract_tests/content_length/conftest.py new file mode 100644 index 000000000..d409c8ebf --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/content_length/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures scoped to this group of tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def fixture_verify_backend( + *, + verify_mock_vuforia: None, +) -> None: + """Set up the backend for every contract test in this directory.""" + del verify_mock_vuforia diff --git a/contract-tests/src/vws_mock_contract_tests/content_length/test_incorrect.py b/contract-tests/src/vws_mock_contract_tests/content_length/test_incorrect.py new file mode 100644 index 000000000..4b66f6b55 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/content_length/test_incorrect.py @@ -0,0 +1,198 @@ +"""Tests for the ``Content-Length`` header set incorrectly. + +We cannot test what happens if ``Content-Length`` is removed from a +prepared request because ``requests-mock`` behaves differently to +``requests`` - https://github.com/jamielennox/requests-mock/issues/80. +""" + +import textwrap +from http import HTTPStatus + +import pytest + +from vws_mock_contract_tests.utils import Endpoint +from vws_mock_contract_tests.utils.assertions import ( + assert_valid_date_header, + assert_vwq_failure, + assert_vws_failure, +) +from vws_mock_contract_tests.utils.backend_capabilities import ( + BackendCapabilities, +) +from vws_mock_contract_tests.utils.too_many_requests import ( + handle_server_errors, +) + + +def test_not_integer(endpoint: Endpoint, base_vwq_url: str) -> None: + """ + A ``BAD_REQUEST`` error is given when the given ``Content- + Length`` is + not an integer. + """ + if not bool(endpoint.headers.get("Content-Type")): + return + + content_length = "0.4" + + new_headers = { + **endpoint.headers, + "Content-Length": content_length, + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + handle_server_errors(response=response) + assert response.status_code == HTTPStatus.BAD_REQUEST + + if endpoint.base_url == base_vwq_url: + assert not bool(response.text) + assert response.headers == { + "Content-Length": str(object=len(response.text)), + "Connection": "Close", + } + return + + assert_valid_date_header(response=response) + expected_response_text = textwrap.dedent( + text="""\ + \r + 400 Bad Request\r + \r +

400 Bad Request

\r + \r + \r + """, + ) + assert response.text == expected_response_text + expected_headers = { + "Content-Length": str(object=len(response.text)), + "Content-Type": "text/html", + "Connection": "close", + "Server": "awselb/2.0", + "Date": response.headers["Date"], + } + assert response.headers == expected_headers + + +def test_too_large( + endpoint: Endpoint, + backend_capabilities: BackendCapabilities, + base_vwq_url: str, +) -> None: + """An error is given if the given content length is too large.""" + if not backend_capabilities.fast_oversized_content_length_response: + pytest.skip( + reason="This backend responds slowly to oversized Content-Length." + ) + + if not bool(endpoint.headers.get("Content-Type")): + pytest.skip(reason="No Content-Type header for this request") + + content_length = str(object=int(endpoint.headers["Content-Length"]) + 1) + + new_headers = { + **endpoint.headers, + "Content-Length": content_length, + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + + # We do not use ``handle_server_errors`` here because we do not want to + # retry on the Gateway Timeout. + if endpoint.base_url == base_vwq_url: + assert response.status_code == HTTPStatus.GATEWAY_TIMEOUT + assert not bool(response.text) + assert response.headers == { + "Content-Length": str(object=len(response.text)), + "Connection": "keep-alive", + } + return + + handle_server_errors(response=response) + assert_valid_date_header(response=response) + # We have seen both of these response texts. + assert response.text in {"stream timeout", ""} + expected_headers = { + "Content-Length": str(object=len(response.text)), + "Connection": "close", + "Content-Type": "text/plain", + "server": "envoy", + "Date": response.headers["Date"], + } + assert response.headers == expected_headers + assert response.status_code == HTTPStatus.REQUEST_TIMEOUT + + +def test_too_small(endpoint: Endpoint, base_vwq_url: str) -> None: + """ + An ``UNAUTHORIZED`` response is given if the given content + length is + too small. + """ + if not bool(endpoint.headers.get("Content-Type")): + return + + real_content_length = len(endpoint.data) + content_length = real_content_length - 1 + + new_headers = { + **endpoint.headers, + "Content-Length": str(object=content_length), + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + + handle_server_errors(response=response) + + if endpoint.base_url == base_vwq_url: + assert_vwq_failure( + response=response, + status_code=HTTPStatus.UNAUTHORIZED, + content_type="application/json", + cache_control=None, + www_authenticate="VWS", + connection="keep-alive", + ) + return + + assert_vws_failure( + response=response, + status_code=HTTPStatus.UNAUTHORIZED, + result_code="AuthenticationFailure", + ) diff --git a/contract-tests/src/vws_mock_contract_tests/date_header/__init__.py b/contract-tests/src/vws_mock_contract_tests/date_header/__init__.py new file mode 100644 index 000000000..f2ab2686f --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/date_header/__init__.py @@ -0,0 +1 @@ +"""Date header tests.""" diff --git a/contract-tests/src/vws_mock_contract_tests/date_header/conftest.py b/contract-tests/src/vws_mock_contract_tests/date_header/conftest.py new file mode 100644 index 000000000..d409c8ebf --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/date_header/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures scoped to this group of tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def fixture_verify_backend( + *, + verify_mock_vuforia: None, +) -> None: + """Set up the backend for every contract test in this directory.""" + del verify_mock_vuforia diff --git a/tests/mock_vws/date_header/helpers.py b/contract-tests/src/vws_mock_contract_tests/date_header/helpers.py similarity index 100% rename from tests/mock_vws/date_header/helpers.py rename to contract-tests/src/vws_mock_contract_tests/date_header/helpers.py diff --git a/contract-tests/src/vws_mock_contract_tests/date_header/test_format.py b/contract-tests/src/vws_mock_contract_tests/date_header/test_format.py new file mode 100644 index 000000000..5afae692f --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/date_header/test_format.py @@ -0,0 +1,82 @@ +""" +Tests for what happens when the `Date` header is not in the expected +format. +""" + +from datetime import datetime +from http import HTTPStatus +from zoneinfo import ZoneInfo + +from freezegun import freeze_time +from vws_auth_tools import authorization_header + +from vws_mock_contract_tests.utils import Endpoint +from vws_mock_contract_tests.utils.assertions import ( + assert_vwq_failure, + assert_vws_failure, +) +from vws_mock_contract_tests.utils.too_many_requests import ( + handle_server_errors, +) + + +def test_incorrect_date_format(endpoint: Endpoint, base_vwq_url: str) -> None: + """A `BAD_REQUEST` response is returned when the date given in the + date + header is not in the expected format (RFC 1123) to VWS API. + + An `UNAUTHORIZED` response is returned to the VWQ API. + """ + gmt = ZoneInfo(key="GMT") + with freeze_time(time_to_freeze=datetime.now(tz=gmt)): + now = datetime.now(tz=gmt) + date_incorrect_format = now.strftime(format="%a %b %d %H:%M:%S") + + authorization_string = authorization_header( + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + method=endpoint.method, + content=endpoint.data, + content_type=endpoint.auth_header_content_type, + date=date_incorrect_format, + request_path=endpoint.path_url, + ) + + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date_incorrect_format, + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + response = new_endpoint.send() + + handle_server_errors(response=response) + + if endpoint.base_url == base_vwq_url: + assert response.text == "Malformed date header." + assert_vwq_failure( + response=response, + status_code=HTTPStatus.UNAUTHORIZED, + content_type="text/plain;charset=iso-8859-1", + cache_control=None, + www_authenticate="KWS", + connection="keep-alive", + ) + return + + assert_vws_failure( + response=response, + status_code=HTTPStatus.BAD_REQUEST, + result_code="Fail", + ) diff --git a/contract-tests/src/vws_mock_contract_tests/date_header/test_missing.py b/contract-tests/src/vws_mock_contract_tests/date_header/test_missing.py new file mode 100644 index 000000000..538cabd03 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/date_header/test_missing.py @@ -0,0 +1,71 @@ +"""Tests for what happens when the `Date` header is missing.""" + +from http import HTTPStatus + +from vws_auth_tools import authorization_header + +from vws_mock_contract_tests.utils import Endpoint +from vws_mock_contract_tests.utils.assertions import ( + assert_vwq_failure, + assert_vws_failure, +) +from vws_mock_contract_tests.utils.too_many_requests import ( + handle_server_errors, +) + + +def test_no_date_header(endpoint: Endpoint, base_vwq_url: str) -> None: + """ + A `BAD_REQUEST` response is returned when no `Date` header is + given. + """ + authorization_string = authorization_header( + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + method=endpoint.method, + content=endpoint.data, + content_type=endpoint.auth_header_content_type, + date="", + request_path=endpoint.path_url, + ) + + new_headers: dict[str, str] = { + **endpoint.headers, + "Authorization": authorization_string, + } + _ = new_headers.pop("Date", None) + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + + handle_server_errors(response=response) + + if endpoint.base_url == base_vwq_url: + expected_content_type = "text/plain;charset=iso-8859-1" + assert response.text == "Date header required." + assert_vwq_failure( + response=response, + status_code=HTTPStatus.BAD_REQUEST, + content_type=expected_content_type, + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + return + + assert_vws_failure( + response=response, + status_code=HTTPStatus.BAD_REQUEST, + result_code="Fail", + ) diff --git a/contract-tests/src/vws_mock_contract_tests/date_header/test_skewed_time.py b/contract-tests/src/vws_mock_contract_tests/date_header/test_skewed_time.py new file mode 100644 index 000000000..6b97a9dd9 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/date_header/test_skewed_time.py @@ -0,0 +1,322 @@ +""" +Tests for what happens when the `Date` header is given with an +unexpected +time. +""" + +import json +from datetime import datetime +from http import HTTPStatus +from zoneinfo import ZoneInfo + +from freezegun import freeze_time +from vws_auth_tools import authorization_header, rfc_1123_date + +from vws_mock_contract_tests.date_header.helpers import ( + LEEWAY, + VWQ_MAX_TIME_SKEW, + VWS_MAX_TIME_SKEW, +) +from vws_mock_contract_tests.utils import Endpoint +from vws_mock_contract_tests.utils.assertions import ( + assert_query_success, + assert_valid_transaction_id, + assert_vwq_failure, + assert_vws_failure, + assert_vws_response, +) +from vws_mock_contract_tests.utils.too_many_requests import ( + handle_server_errors, +) + + +def test_date_out_of_range_after( + endpoint: Endpoint, base_vwq_url: str +) -> None: + """If the date header is more than five minutes (target API) or 65 + minutes (query API) after the request is sent, a `FORBIDDEN` + response + is returned. + + Because there is a small delay in sending requests and Vuforia + isn't consistent, some leeway is given. + """ + skew = ( + VWQ_MAX_TIME_SKEW + if endpoint.base_url == base_vwq_url + else VWS_MAX_TIME_SKEW + ) + time_difference_from_now = skew + LEEWAY + gmt = ZoneInfo(key="GMT") + with freeze_time( + time_to_freeze=datetime.now(tz=gmt) + time_difference_from_now + ): + date = rfc_1123_date() + + authorization_string = authorization_header( + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + method=endpoint.method, + content=endpoint.data, + content_type=endpoint.auth_header_content_type, + date=date, + request_path=endpoint.path_url, + ) + + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + + handle_server_errors(response=response) + + # Even with the query endpoint, we get a JSON response. + if endpoint.base_url == base_vwq_url: + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert response_json.keys() == {"transaction_id", "result_code"} + assert response_json["result_code"] == "RequestTimeTooSkewed" + assert_valid_transaction_id(response=response) + assert_vwq_failure( + response=response, + status_code=HTTPStatus.FORBIDDEN, + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + return + + assert_vws_failure( + response=response, + status_code=HTTPStatus.FORBIDDEN, + result_code="RequestTimeTooSkewed", + ) + + +def test_date_out_of_range_before( + endpoint: Endpoint, base_vwq_url: str +) -> None: + """If the date header is more than five minutes (target API) or 65 + minutes (query API) before the request is sent, a `FORBIDDEN` + response + is returned. + + Because there is a small delay in sending requests and Vuforia + isn't consistent, some leeway is given. + """ + skew = ( + VWQ_MAX_TIME_SKEW + if endpoint.base_url == base_vwq_url + else VWS_MAX_TIME_SKEW + ) + time_difference_from_now = skew + LEEWAY + gmt = ZoneInfo(key="GMT") + with freeze_time( + time_to_freeze=datetime.now(tz=gmt) - time_difference_from_now + ): + date = rfc_1123_date() + + authorization_string = authorization_header( + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + method=endpoint.method, + content=endpoint.data, + content_type=endpoint.auth_header_content_type, + date=date, + request_path=endpoint.path_url, + ) + + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + + handle_server_errors(response=response) + + # Even with the query endpoint, we get a JSON response. + if endpoint.base_url == base_vwq_url: + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert response_json.keys() == {"transaction_id", "result_code"} + assert response_json["result_code"] == "RequestTimeTooSkewed" + assert_valid_transaction_id(response=response) + assert_vwq_failure( + response=response, + status_code=HTTPStatus.FORBIDDEN, + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + return + + assert_vws_failure( + response=response, + status_code=HTTPStatus.FORBIDDEN, + result_code="RequestTimeTooSkewed", + ) + + +def test_date_in_range_after(endpoint: Endpoint, base_vwq_url: str) -> None: + """If a date header is within five minutes after the request is + sent, + no error is returned. + + Because there is a small delay in sending requests and Vuforia + isn't consistent, some leeway is given. + """ + skew = ( + VWQ_MAX_TIME_SKEW + if endpoint.base_url == base_vwq_url + else VWS_MAX_TIME_SKEW + ) + time_difference_from_now = skew - LEEWAY + gmt = ZoneInfo(key="GMT") + with freeze_time( + time_to_freeze=datetime.now(tz=gmt) + time_difference_from_now + ): + date = rfc_1123_date() + + authorization_string = authorization_header( + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + method=endpoint.method, + content=endpoint.data, + content_type=endpoint.auth_header_content_type, + date=date, + request_path=endpoint.path_url, + ) + + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + + handle_server_errors(response=response) + + if endpoint.base_url == base_vwq_url: + assert_query_success(response=response) + return + + if endpoint.successful_headers_result_code is None: + assert response.status_code == endpoint.successful_headers_status_code + return + + assert_vws_response( + response=response, + status_code=endpoint.successful_headers_status_code, + result_code=endpoint.successful_headers_result_code, + ) + + +def test_date_in_range_before(endpoint: Endpoint, base_vwq_url: str) -> None: + """If a date header is within five minutes before the request is + sent, + no error is returned. + + Because there is a small delay in sending requests and Vuforia + isn't consistent, some leeway is given. + """ + skew = ( + VWQ_MAX_TIME_SKEW + if endpoint.base_url == base_vwq_url + else VWS_MAX_TIME_SKEW + ) + time_difference_from_now = skew - LEEWAY + gmt = ZoneInfo(key="GMT") + with freeze_time( + time_to_freeze=datetime.now(tz=gmt) - time_difference_from_now + ): + date = rfc_1123_date() + + authorization_string = authorization_header( + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + method=endpoint.method, + content=endpoint.data, + content_type=endpoint.auth_header_content_type, + date=date, + request_path=endpoint.path_url, + ) + + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=endpoint.data, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + + handle_server_errors(response=response) + + if endpoint.base_url == base_vwq_url: + assert_query_success(response=response) + return + + if endpoint.successful_headers_result_code is None: + assert response.status_code == endpoint.successful_headers_status_code + return + + assert_vws_response( + response=response, + status_code=endpoint.successful_headers_status_code, + result_code=endpoint.successful_headers_result_code, + ) diff --git a/contract-tests/src/vws_mock_contract_tests/header_size/__init__.py b/contract-tests/src/vws_mock_contract_tests/header_size/__init__.py new file mode 100644 index 000000000..a35f1b0e6 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/header_size/__init__.py @@ -0,0 +1 @@ +"""Header size tests.""" diff --git a/contract-tests/src/vws_mock_contract_tests/header_size/conftest.py b/contract-tests/src/vws_mock_contract_tests/header_size/conftest.py new file mode 100644 index 000000000..d409c8ebf --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/header_size/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures scoped to this group of tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def fixture_verify_backend( + *, + verify_mock_vuforia: None, +) -> None: + """Set up the backend for every contract test in this directory.""" + del verify_mock_vuforia diff --git a/tests/mock_vws/header_size/helpers.py b/contract-tests/src/vws_mock_contract_tests/header_size/helpers.py similarity index 98% rename from tests/mock_vws/header_size/helpers.py rename to contract-tests/src/vws_mock_contract_tests/header_size/helpers.py index 293fe90de..4f2ef50c4 100644 --- a/tests/mock_vws/header_size/helpers.py +++ b/contract-tests/src/vws_mock_contract_tests/header_size/helpers.py @@ -4,7 +4,7 @@ from vws.response import Response -from tests.mock_vws.utils import Endpoint +from vws_mock_contract_tests.utils import Endpoint _NGINX_TOO_LARGE_RESPONSE_TEXT = "".join( f"{line}\r\n" diff --git a/contract-tests/src/vws_mock_contract_tests/header_size/test_oversized_header_line.py b/contract-tests/src/vws_mock_contract_tests/header_size/test_oversized_header_line.py new file mode 100644 index 000000000..5acd4f43a --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/header_size/test_oversized_header_line.py @@ -0,0 +1,117 @@ +"""Tests for header lines which do not fit in NGINX's 8 KiB buffer. + +A header line is the header name, a colon, a space and the value. +NGINX's buffer also holds the line's CRLF, so the longest accepted +line is 8190 bytes. +""" + +from vws_mock_contract_tests.header_size.helpers import ( + assert_nginx_too_large_response, + endpoint_with_header, + header_value_for_line_length, +) +from vws_mock_contract_tests.utils import Endpoint +from vws_mock_contract_tests.utils.assertions import ( + assert_query_success, + assert_valid_date_header, + assert_vws_response, +) +from vws_mock_contract_tests.utils.too_many_requests import ( + handle_server_errors, +) + +# The observed NGINX boundary is an API expectation, independent of core. +_MAX_HEADER_LINE_LENGTH = 8190 + + +def test_header_too_large(endpoint: Endpoint, base_vwq_url: str) -> None: + """A header line one byte too long for NGINX gives a + ``BAD_REQUEST`` + response with NGINX's HTML body, before any authorization. + """ + name = "X-Padding" + new_endpoint = endpoint_with_header( + endpoint=endpoint, + name=name, + value=header_value_for_line_length( + name=name, + line_length=_MAX_HEADER_LINE_LENGTH + 1, + ), + ) + + response = new_endpoint.send() + handle_server_errors(response=response) + assert_valid_date_header(response=response) + assert_nginx_too_large_response( + endpoint=endpoint, + response=response, + base_vwq_url=base_vwq_url, + ) + + +def test_cookie_too_large(endpoint: Endpoint, base_vwq_url: str) -> None: + """A cookie which makes the ``Cookie`` line too long for NGINX + gives + the same ``BAD_REQUEST`` response as any other header. + + The Envoy layer in front of the Target API lets a ``Cookie`` line + slightly over the limit through, so this sends one well over it. + See :ref:`differences-nginx-error-cases`. + """ + name = "Cookie" + new_endpoint = endpoint_with_header( + endpoint=endpoint, + name=name, + value="pad=" + + header_value_for_line_length( + name=name, + line_length=_MAX_HEADER_LINE_LENGTH + 1000, + ), + ) + + response = new_endpoint.send() + handle_server_errors(response=response) + assert_valid_date_header(response=response) + assert_nginx_too_large_response( + endpoint=endpoint, + response=response, + base_vwq_url=base_vwq_url, + ) + + +def test_large_header_within_limit( + endpoint: Endpoint, base_vwq_url: str +) -> None: + """A large header line which fits in NGINX's buffer is accepted. + + This does not send a line at the limit exactly, because the Query + API rejects header blocks of about 8 KiB in total with a ``431`` + response from its application server. See + :ref:`differences-nginx-error-cases`. + """ + name = "X-Padding" + new_endpoint = endpoint_with_header( + endpoint=endpoint, + name=name, + value=header_value_for_line_length( + name=name, + line_length=_MAX_HEADER_LINE_LENGTH // 2, + ), + ) + + response = new_endpoint.send() + handle_server_errors(response=response) + + if endpoint.base_url == base_vwq_url: + assert_query_success(response=response) + return + + if endpoint.successful_headers_result_code is None: + assert response.status_code == endpoint.successful_headers_status_code + return + + assert_vws_response( + response=response, + status_code=endpoint.successful_headers_status_code, + result_code=endpoint.successful_headers_result_code, + ) diff --git a/contract-tests/src/vws_mock_contract_tests/invalid_given_id/__init__.py b/contract-tests/src/vws_mock_contract_tests/invalid_given_id/__init__.py new file mode 100644 index 000000000..08737df8c --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/invalid_given_id/__init__.py @@ -0,0 +1 @@ +"""Invalid given id tests.""" diff --git a/contract-tests/src/vws_mock_contract_tests/invalid_given_id/conftest.py b/contract-tests/src/vws_mock_contract_tests/invalid_given_id/conftest.py new file mode 100644 index 000000000..d409c8ebf --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/invalid_given_id/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures scoped to this group of tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def fixture_verify_backend( + *, + verify_mock_vuforia: None, +) -> None: + """Set up the backend for every contract test in this directory.""" + del verify_mock_vuforia diff --git a/tests/mock_vws/invalid_given_id/helpers.py b/contract-tests/src/vws_mock_contract_tests/invalid_given_id/helpers.py similarity index 87% rename from tests/mock_vws/invalid_given_id/helpers.py rename to contract-tests/src/vws_mock_contract_tests/invalid_given_id/helpers.py index 2474b7b06..d7e1f1b5b 100644 --- a/tests/mock_vws/invalid_given_id/helpers.py +++ b/contract-tests/src/vws_mock_contract_tests/invalid_given_id/helpers.py @@ -5,8 +5,10 @@ from beartype import beartype from vws_auth_tools import authorization_header, rfc_1123_date -from tests.mock_vws.fixtures.credentials import CloudDatabaseCredentials -from tests.mock_vws.utils.unrouted_requests import ( +from vws_mock_contract_tests.fixtures.credentials import ( + CloudDatabaseCredentials, +) +from vws_mock_contract_tests.utils.unrouted_requests import ( SendUnroutedRequest, UnroutedResponse, ) diff --git a/contract-tests/src/vws_mock_contract_tests/invalid_given_id/test_invalid_given_id.py b/contract-tests/src/vws_mock_contract_tests/invalid_given_id/test_invalid_given_id.py new file mode 100644 index 000000000..ab5cf485b --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/invalid_given_id/test_invalid_given_id.py @@ -0,0 +1,45 @@ +""" +Tests for giving an invalid ID to endpoints which require a target +ID to be +given. +""" + +from http import HTTPStatus + +from vws import VWS + +from vws_mock_contract_tests.utils import Endpoint +from vws_mock_contract_tests.utils.assertions import assert_vws_failure +from vws_mock_contract_tests.utils.too_many_requests import ( + handle_server_errors, +) + + +def test_not_real_id( + *, + vws_client: VWS, + endpoint: Endpoint, + target_id: str, +) -> None: + """ + A `NOT_FOUND` error is returned when an endpoint is given a + target ID + of a target which does not exist. + """ + # This shared check only covers endpoints that end in target_id, + # such as /targets/{target_id}. Endpoints with trailing segments + # are covered by endpoint-specific tests. + if not endpoint.path_url.endswith(target_id): + return + + vws_client.delete_target(target_id=target_id) + + response = endpoint.send() + + handle_server_errors(response=response) + + assert_vws_failure( + response=response, + status_code=HTTPStatus.NOT_FOUND, + result_code="UnknownTarget", + ) diff --git a/contract-tests/src/vws_mock_contract_tests/invalid_given_id/test_unrouted_requests.py b/contract-tests/src/vws_mock_contract_tests/invalid_given_id/test_unrouted_requests.py new file mode 100644 index 000000000..71422b8dc --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/invalid_given_id/test_unrouted_requests.py @@ -0,0 +1,68 @@ +"""Tests for requests which VWS does not serve.""" + +from http import HTTPMethod, HTTPStatus + +from vws_mock_contract_tests.fixtures.credentials import ( + CloudDatabaseCredentials, +) +from vws_mock_contract_tests.invalid_given_id.helpers import ( + send_unrouted_request, +) +from vws_mock_contract_tests.utils.unrouted_requests import UnroutedRequests + + +def test_unknown_path( + *, + vuforia_database: CloudDatabaseCredentials, + unrouted_requests: UnroutedRequests, +) -> None: + """A request to a path which is not served returns a 404 with no + body. + """ + response = send_unrouted_request( + send=unrouted_requests.send, + vuforia_database=vuforia_database, + method=HTTPMethod.GET, + request_path="/some-random-endpoint", + ) + + if not unrouted_requests.returns_response: + # The ``requests``, ``httpx`` and ``httpx2`` backends mock only + # the paths which they serve, so they give no response at all. + assert response is None + return + + assert response is not None + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.body == b"" + assert response.content_type is None + assert response.content_encoding is None + assert response.upstream_service_time is None + + +def test_unknown_method( + *, + vuforia_database: CloudDatabaseCredentials, + unrouted_requests: UnroutedRequests, +) -> None: + """A request to a served path with a method which that path does + not serve returns a 404, rather than a 405. + """ + response = send_unrouted_request( + send=unrouted_requests.send, + vuforia_database=vuforia_database, + method=HTTPMethod.DELETE, + request_path="/summary", + ) + + if not unrouted_requests.returns_response: + assert response is None + return + + assert response is not None + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.content_type == "text/html; charset=UTF-8" + assert response.content_encoding == "gzip" + assert response.upstream_service_time is not None + assert b"

Not Found

" in response.body + assert b"For request 'DELETE /summary'" in response.body diff --git a/contract-tests/src/vws_mock_contract_tests/invalid_json/__init__.py b/contract-tests/src/vws_mock_contract_tests/invalid_json/__init__.py new file mode 100644 index 000000000..2f691c52b --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/invalid_json/__init__.py @@ -0,0 +1 @@ +"""Invalid json tests.""" diff --git a/contract-tests/src/vws_mock_contract_tests/invalid_json/conftest.py b/contract-tests/src/vws_mock_contract_tests/invalid_json/conftest.py new file mode 100644 index 000000000..d409c8ebf --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/invalid_json/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures scoped to this group of tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def fixture_verify_backend( + *, + verify_mock_vuforia: None, +) -> None: + """Set up the backend for every contract test in this directory.""" + del verify_mock_vuforia diff --git a/tests/mock_vws/invalid_json/helpers.py b/contract-tests/src/vws_mock_contract_tests/invalid_json/helpers.py similarity index 93% rename from tests/mock_vws/invalid_json/helpers.py rename to contract-tests/src/vws_mock_contract_tests/invalid_json/helpers.py index d0a4769e4..4d87afa06 100644 --- a/tests/mock_vws/invalid_json/helpers.py +++ b/contract-tests/src/vws_mock_contract_tests/invalid_json/helpers.py @@ -8,13 +8,15 @@ from freezegun import freeze_time from vws_auth_tools import authorization_header, rfc_1123_date -from tests.mock_vws.utils import Endpoint -from tests.mock_vws.utils.assertions import ( +from vws_mock_contract_tests.utils import Endpoint +from vws_mock_contract_tests.utils.assertions import ( assert_valid_date_header, assert_vwq_failure, assert_vws_failure, ) -from tests.mock_vws.utils.too_many_requests import handle_server_errors +from vws_mock_contract_tests.utils.too_many_requests import ( + handle_server_errors, +) @beartype diff --git a/contract-tests/src/vws_mock_contract_tests/invalid_json/test_invalid_json.py b/contract-tests/src/vws_mock_contract_tests/invalid_json/test_invalid_json.py new file mode 100644 index 000000000..69e2c9616 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/invalid_json/test_invalid_json.py @@ -0,0 +1,223 @@ +"""Tests for giving invalid JSON to endpoints. + +The three cases are separate tests, rather than one test covering +every body, because each body costs a request per endpoint against +Real Vuforia and CI runs one ``ci_pattern`` per job. See +https://github.com/VWS-Python/vws-python-mock/issues/3540. +""" + +import json +from datetime import datetime, timedelta +from http import HTTPStatus +from zoneinfo import ZoneInfo + +import pytest +from freezegun import freeze_time +from vws_auth_tools import authorization_header, rfc_1123_date + +from vws_mock_contract_tests.invalid_json.helpers import ( + assert_body_rejected, +) +from vws_mock_contract_tests.utils import Endpoint +from vws_mock_contract_tests.utils.assertions import ( + assert_valid_date_header, + assert_valid_transaction_id, + assert_vwq_failure, + assert_vws_failure, +) +from vws_mock_contract_tests.utils.too_many_requests import ( + handle_server_errors, +) + + +def test_not_json(endpoint: Endpoint, base_vwq_url: str) -> None: + """Giving a body which is not JSON returns an error response.""" + assert_body_rejected( + endpoint=endpoint, content=b"a", base_vwq_url=base_vwq_url + ) + + +@pytest.mark.parametrize( + argnames="content", + argvalues=[b"[]", b'"hello"', b"5", b"null", b"true"], +) +def test_not_an_object( + endpoint: Endpoint, content: bytes, base_vwq_url: str +) -> None: + """Giving valid JSON which is not an object returns an error + response. + """ + assert_body_rejected( + endpoint=endpoint, content=content, base_vwq_url=base_vwq_url + ) + + +def test_not_utf_8(endpoint: Endpoint, base_vwq_url: str) -> None: + """Giving a JSON object which is not encoded as UTF-8 returns an + error response. + """ + content = '{"name": "café"}'.encode(encoding="latin-1") + assert_body_rejected( + endpoint=endpoint, content=content, base_vwq_url=base_vwq_url + ) + + +def test_empty_body(endpoint: Endpoint) -> None: + """Giving an empty body to an endpoint which takes JSON returns an + error response. + + Real Vuforia gives a server error for an empty body given to the + target endpoints, but a bad request for one given to the reco counts + report endpoint or to the VuMark instance generation endpoint. + + Unlike the other malformed bodies which this class sends, an empty + body is answered promptly by real Vuforia, so this test runs against + it. + """ + takes_json_data = endpoint.auth_header_content_type == "application/json" + if not takes_json_data: + pytest.skip(reason="This endpoint does not take a JSON body.") + + if endpoint.path_url.endswith("/instances"): + expected_status_code = HTTPStatus.BAD_REQUEST + expected_result_code = "BadRequest" + elif endpoint.path_url.endswith("/reports/recoCounts"): + expected_status_code = HTTPStatus.BAD_REQUEST + expected_result_code = "Fail" + else: + expected_status_code = HTTPStatus.INTERNAL_SERVER_ERROR + expected_result_code = "Fail" + + content = b"" + gmt = ZoneInfo(key="GMT") + now = datetime.now(tz=gmt) + time_to_freeze = now + with freeze_time(time_to_freeze=time_to_freeze): + date = rfc_1123_date() + + authorization_string = authorization_header( + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + method=endpoint.method, + content=content, + content_type=endpoint.auth_header_content_type, + date=date, + request_path=endpoint.path_url, + ) + + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + "Content-Length": str(object=len(content)), + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=content, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + + # A server error is the expected response for some endpoints, so + # only treat one as transient where it is not expected. + if expected_status_code != HTTPStatus.INTERNAL_SERVER_ERROR: + handle_server_errors(response=response) + + assert_valid_date_header(response=response) + assert_vws_failure( + response=response, + status_code=expected_status_code, + result_code=expected_result_code, + ) + + +def test_invalid_json_with_skewed_time( + endpoint: Endpoint, base_vwq_url: str +) -> None: + """Giving invalid JSON to endpoints returns error responses.""" + # We use a skew of 70 because the maximum allowed skew for services is + # 5 minutes, and for query is 65 minutes. 70 is comfortably larger than + # the max of these two. + date_skew_minutes = 70 + content = b"a" + gmt = ZoneInfo(key="GMT") + now = datetime.now(tz=gmt) + time_to_freeze = now + timedelta(minutes=date_skew_minutes) + with freeze_time(time_to_freeze=time_to_freeze): + date = rfc_1123_date() + + authorization_string = authorization_header( + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + method=endpoint.method, + content=content, + content_type=endpoint.auth_header_content_type, + date=date, + request_path=endpoint.path_url, + ) + + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Content-Length": str(object=len(content)), + "Date": date, + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=content, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + + handle_server_errors(response=response) + + takes_json_data = endpoint.auth_header_content_type == "application/json" + + assert_valid_date_header(response=response) + + if takes_json_data: + assert_vws_failure( + response=response, + status_code=HTTPStatus.FORBIDDEN, + result_code="RequestTimeTooSkewed", + ) + return + + if endpoint.base_url == base_vwq_url: + response_json = json.loads(s=response.text) + assert isinstance(response_json, dict) + assert response_json.keys() == { + "transaction_id", + "result_code", + } + assert response_json["result_code"] == "RequestTimeTooSkewed" + assert_valid_transaction_id(response=response) + assert_vwq_failure( + response=response, + status_code=HTTPStatus.FORBIDDEN, + content_type="application/json", + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + return + + assert response.status_code == HTTPStatus.BAD_REQUEST + assert not bool(response.text) + assert "Content-Type" not in response.headers diff --git a/contract-tests/src/vws_mock_contract_tests/unexpected_json/__init__.py b/contract-tests/src/vws_mock_contract_tests/unexpected_json/__init__.py new file mode 100644 index 000000000..f01cde169 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/unexpected_json/__init__.py @@ -0,0 +1 @@ +"""Unexpected json tests.""" diff --git a/contract-tests/src/vws_mock_contract_tests/unexpected_json/conftest.py b/contract-tests/src/vws_mock_contract_tests/unexpected_json/conftest.py new file mode 100644 index 000000000..d409c8ebf --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/unexpected_json/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures scoped to this group of tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def fixture_verify_backend( + *, + verify_mock_vuforia: None, +) -> None: + """Set up the backend for every contract test in this directory.""" + del verify_mock_vuforia diff --git a/contract-tests/src/vws_mock_contract_tests/unexpected_json/test_unexpected_json.py b/contract-tests/src/vws_mock_contract_tests/unexpected_json/test_unexpected_json.py new file mode 100644 index 000000000..86a566e86 --- /dev/null +++ b/contract-tests/src/vws_mock_contract_tests/unexpected_json/test_unexpected_json.py @@ -0,0 +1,82 @@ +"""Tests for giving JSON to endpoints which do not expect it.""" + +import json +from http import HTTPStatus + +from vws_auth_tools import authorization_header, rfc_1123_date + +from vws_mock_contract_tests.utils import Endpoint +from vws_mock_contract_tests.utils.assertions import assert_vwq_failure +from vws_mock_contract_tests.utils.too_many_requests import ( + handle_server_errors, +) + + +def test_does_not_take_data(endpoint: Endpoint, base_vwq_url: str) -> None: + """ + Giving JSON to endpoints which do not take any JSON data returns + error + responses. + """ + if ( + endpoint.headers.get( + "Content-Type", + ) + == "application/json" + ): + return + content = json.dumps(obj={"key": "value"}).encode(encoding="utf-8") + content_type = "application/json" + date = rfc_1123_date() + + authorization_string = authorization_header( + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + method=endpoint.method, + content=content, + content_type=content_type, + date=date, + request_path=endpoint.path_url, + ) + + new_headers = { + **endpoint.headers, + "Authorization": authorization_string, + "Date": date, + "Content-Type": content_type, + "Content-Length": str(object=len(content)), + } + + new_endpoint = Endpoint( + base_url=endpoint.base_url, + path_url=endpoint.path_url, + method=endpoint.method, + headers=new_headers, + data=content, + successful_headers_result_code=endpoint.successful_headers_result_code, + successful_headers_status_code=endpoint.successful_headers_status_code, + access_key=endpoint.access_key, + secret_key=endpoint.secret_key, + ) + + response = new_endpoint.send() + + handle_server_errors(response=response) + + if endpoint.base_url == base_vwq_url: + # The multipart/formdata boundary is no longer in the given + # content. + assert not bool(response.text) + assert_vwq_failure( + response=response, + status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE, + content_type=None, + cache_control=None, + www_authenticate=None, + connection="keep-alive", + ) + return + + assert response.status_code == HTTPStatus.BAD_REQUEST + assert not bool(response.text) + assert "Content-Type" not in response.headers diff --git a/tests/mock_vws/authorization_header/conftest.py b/tests/mock_vws/authorization_header/conftest.py index d409c8ebf..9bca4c75c 100644 --- a/tests/mock_vws/authorization_header/conftest.py +++ b/tests/mock_vws/authorization_header/conftest.py @@ -1,12 +1,7 @@ -"""Fixtures scoped to this group of tests.""" +"""Compatibility imports for the packaged contract suite.""" -import pytest +from vws_mock_contract_tests.authorization_header.conftest import ( + fixture_verify_backend, +) - -@pytest.fixture(autouse=True) -def fixture_verify_backend( - *, - verify_mock_vuforia: None, -) -> None: - """Set up the backend for every contract test in this directory.""" - del verify_mock_vuforia +__all__ = ["fixture_verify_backend"] diff --git a/tests/mock_vws/authorization_header/test_authorization_header.py b/tests/mock_vws/authorization_header/test_authorization_header.py index 25f382bae..6e4e2febe 100644 --- a/tests/mock_vws/authorization_header/test_authorization_header.py +++ b/tests/mock_vws/authorization_header/test_authorization_header.py @@ -1,63 +1,7 @@ -""" -Tests for what happens when the `Authorization` header is not as -expected. -""" +"""Compatibility imports for the packaged contract suite.""" -from http import HTTPStatus - -from vws_auth_tools import rfc_1123_date - -from tests.mock_vws.utils import Endpoint -from tests.mock_vws.utils.assertions import ( - assert_vwq_failure, - assert_vws_failure, +from vws_mock_contract_tests.authorization_header.test_authorization_header import ( # noqa: E501 + test_missing, ) -from tests.mock_vws.utils.too_many_requests import handle_server_errors - - -def test_missing(endpoint: Endpoint, base_vwq_url: str) -> None: - """ - An `UNAUTHORIZED` response is returned when no `Authorization` - header - is given. - """ - date = rfc_1123_date() - new_headers: dict[str, str] = { - **endpoint.headers, - "Date": date, - } - _ = new_headers.pop("Authorization", None) - - new_endpoint = Endpoint( - base_url=endpoint.base_url, - path_url=endpoint.path_url, - method=endpoint.method, - headers=new_headers, - data=endpoint.data, - successful_headers_result_code=endpoint.successful_headers_result_code, - successful_headers_status_code=endpoint.successful_headers_status_code, - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - ) - - response = new_endpoint.send() - - handle_server_errors(response=response) - - if endpoint.base_url == base_vwq_url: - assert_vwq_failure( - response=response, - status_code=HTTPStatus.UNAUTHORIZED, - content_type="text/plain;charset=iso-8859-1", - cache_control=None, - www_authenticate="KWS", - connection="keep-alive", - ) - assert response.text == "Authorization header missing." - return - assert_vws_failure( - response=response, - status_code=HTTPStatus.UNAUTHORIZED, - result_code="AuthenticationFailure", - ) +__all__ = ["test_missing"] diff --git a/tests/mock_vws/authorization_header/test_bad_key.py b/tests/mock_vws/authorization_header/test_bad_key.py index e01e9187f..a13649276 100644 --- a/tests/mock_vws/authorization_header/test_bad_key.py +++ b/tests/mock_vws/authorization_header/test_bad_key.py @@ -1,164 +1,15 @@ -"""Tests for making requests with incorrect keys.""" +"""Compatibility imports for the packaged contract suite.""" -import io -import json -import uuid -from http import HTTPStatus - -import pytest -from vws import VWS, CloudRecoService -from vws.exceptions import cloud_reco_exceptions -from vws.exceptions.vws_exceptions import AuthenticationFailureError, FailError - -from tests.mock_vws.fixtures.credentials import CloudDatabaseCredentials -from tests.mock_vws.utils.assertions import ( - assert_valid_transaction_id, - assert_vwq_failure, +from vws_mock_contract_tests.authorization_header.test_bad_key import ( + test_bad_access_key_query, + test_bad_access_key_services, + test_bad_secret_key_query, + test_bad_secret_key_services, ) - -def test_bad_access_key_services( - vuforia_database: CloudDatabaseCredentials, - base_vws_url: str, -) -> None: - """ - If the server access key given does not match any database, a - ``Fail`` - response is returned. - """ - vws_client = VWS( - server_access_key="example", - server_secret_key=vuforia_database.server_secret_key, - base_vws_url=base_vws_url, - ) - - with pytest.raises(expected_exception=FailError) as exc: - _ = vws_client.get_target_record(target_id=uuid.uuid4().hex) - - assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST - - -def test_bad_access_key_query( - *, - vuforia_database: CloudDatabaseCredentials, - high_quality_image: io.BytesIO, - base_vwq_url: str, -) -> None: - """ - If the client access key given is incorrect, an ``UNAUTHORIZED`` - response is returned. - """ - cloud_reco_client = CloudRecoService( - client_access_key="example", - client_secret_key=vuforia_database.client_secret_key, - base_vwq_url=base_vwq_url, - ) - - with pytest.raises( - expected_exception=cloud_reco_exceptions.AuthenticationFailureError - ) as exc: - _ = cloud_reco_client.query(image=high_quality_image) - - response = exc.value.response - - assert_vwq_failure( - response=response, - status_code=HTTPStatus.UNAUTHORIZED, - content_type="application/json", - cache_control=None, - www_authenticate="VWS", - connection="keep-alive", - ) - - assert json.loads(s=response.text).keys() == { - "transaction_id", - "result_code", - } - assert_valid_transaction_id(response=response) - response_json = json.loads(s=response.text) - assert isinstance(response_json, dict) - assert isinstance(response_json["result_code"], str) - assert isinstance(response_json["transaction_id"], str) - result_code: str = response_json["result_code"] - transaction_id: str = response_json["transaction_id"] - assert result_code == "AuthenticationFailure" - # The separators are inconsistent and we test this. - expected_text = ( - '{"transaction_id":' - f'"{transaction_id}",' - f'"result_code":"{result_code}"' - "}" - ) - assert response.text == expected_text - - -def test_bad_secret_key_services( - vuforia_database: CloudDatabaseCredentials, - base_vws_url: str, -) -> None: - """ - If the server secret key given is incorrect, an - ``AuthenticationFailureError`` response is returned. - """ - vws_client = VWS( - server_access_key=vuforia_database.server_access_key, - server_secret_key="example", - base_vws_url=base_vws_url, - ) - - with pytest.raises(expected_exception=AuthenticationFailureError): - _ = vws_client.get_target_record(target_id=uuid.uuid4().hex) - - -def test_bad_secret_key_query( - *, - vuforia_database: CloudDatabaseCredentials, - high_quality_image: io.BytesIO, - base_vwq_url: str, -) -> None: - """ - If the client secret key given is incorrect, an ``UNAUTHORIZED`` - response is returned. - """ - cloud_reco_client = CloudRecoService( - client_access_key=vuforia_database.client_access_key, - client_secret_key="example", - base_vwq_url=base_vwq_url, - ) - - with pytest.raises( - expected_exception=cloud_reco_exceptions.AuthenticationFailureError - ) as exc: - _ = cloud_reco_client.query(image=high_quality_image) - - response = exc.value.response - - assert_vwq_failure( - response=response, - status_code=HTTPStatus.UNAUTHORIZED, - content_type="application/json", - cache_control=None, - www_authenticate="VWS", - connection="keep-alive", - ) - - assert json.loads(s=response.text).keys() == { - "transaction_id", - "result_code", - } - assert_valid_transaction_id(response=response) - response_json = json.loads(s=response.text) - assert isinstance(response_json, dict) - assert isinstance(response_json["result_code"], str) - assert isinstance(response_json["transaction_id"], str) - result_code: str = response_json["result_code"] - transaction_id: str = response_json["transaction_id"] - assert result_code == "AuthenticationFailure" - # The separators are inconsistent and we test this. - expected_text = ( - '{"transaction_id":' - f'"{transaction_id}",' - f'"result_code":"{result_code}"' - "}" - ) - assert response.text == expected_text +__all__ = [ + "test_bad_access_key_query", + "test_bad_access_key_services", + "test_bad_secret_key_query", + "test_bad_secret_key_services", +] diff --git a/tests/mock_vws/authorization_header/test_malformed.py b/tests/mock_vws/authorization_header/test_malformed.py index e003620bb..dfd9f80c9 100644 --- a/tests/mock_vws/authorization_header/test_malformed.py +++ b/tests/mock_vws/authorization_header/test_malformed.py @@ -1,164 +1,13 @@ -"""Tests for passing a malformed ``Authorization`` header.""" +"""Compatibility imports for the packaged contract suite.""" -from http import HTTPStatus - -from vws_auth_tools import rfc_1123_date - -from tests.mock_vws.utils import Endpoint -from tests.mock_vws.utils.assertions import ( - assert_vwq_failure, - assert_vws_failure, +from vws_mock_contract_tests.authorization_header.test_malformed import ( + test_missing_signature, + test_one_part_no_space, + test_one_part_with_space, ) -from tests.mock_vws.utils.too_many_requests import handle_server_errors - - -def test_one_part_no_space(endpoint: Endpoint, base_vwq_url: str) -> None: - """A valid authorization string is two "parts" when split on a - space. - - When - a string is given which is one "part", a ``BAD_REQUEST`` or - ``UNAUTHORIZED`` response is returned. - """ - date = rfc_1123_date() - - # We use "VWS" as this is the first part of a valid authorization - # string, but really any string which is not two parts when split on a - # space will do. - authorization_string = "VWS" - new_headers = { - **endpoint.headers, - "Authorization": authorization_string, - "Date": date, - } - - new_endpoint = Endpoint( - base_url=endpoint.base_url, - path_url=endpoint.path_url, - method=endpoint.method, - headers=new_headers, - data=endpoint.data, - successful_headers_result_code=endpoint.successful_headers_result_code, - successful_headers_status_code=endpoint.successful_headers_status_code, - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - ) - - response = new_endpoint.send() - handle_server_errors(response=response) - - if endpoint.base_url == base_vwq_url: - assert_vwq_failure( - response=response, - status_code=HTTPStatus.UNAUTHORIZED, - content_type="text/plain;charset=iso-8859-1", - cache_control=None, - www_authenticate="KWS", - connection="keep-alive", - ) - assert response.text == "Malformed authorization header." - return - - assert_vws_failure( - response=response, - status_code=HTTPStatus.BAD_REQUEST, - result_code="Fail", - ) - - -def test_one_part_with_space(endpoint: Endpoint, base_vwq_url: str) -> None: - """A valid authorization string is two "parts" when split on a - space. - - When - a string is given which is one "part", a ``BAD_REQUEST`` or - ``UNAUTHORIZED`` response is returned. - """ - authorization_string = "VWS " - date = rfc_1123_date() - new_headers = { - **endpoint.headers, - "Authorization": authorization_string, - "Date": date, - } - - new_endpoint = Endpoint( - base_url=endpoint.base_url, - path_url=endpoint.path_url, - method=endpoint.method, - headers=new_headers, - data=endpoint.data, - successful_headers_result_code=endpoint.successful_headers_result_code, - successful_headers_status_code=endpoint.successful_headers_status_code, - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - ) - - response = new_endpoint.send() - handle_server_errors(response=response) - - if endpoint.base_url == base_vwq_url: - assert_vwq_failure( - response=response, - status_code=HTTPStatus.UNAUTHORIZED, - content_type="text/plain;charset=iso-8859-1", - cache_control=None, - www_authenticate="KWS", - connection="keep-alive", - ) - assert response.text == "Malformed authorization header." - return - - assert_vws_failure( - response=response, - status_code=HTTPStatus.BAD_REQUEST, - result_code="Fail", - ) - - -def test_missing_signature(endpoint: Endpoint, base_vwq_url: str) -> None: - """ - If a signature is missing `Authorization` header is given, a - ``BAD_REQUEST`` response is given. - """ - date = rfc_1123_date() - - authorization_string = "VWS foobar:" - new_headers = { - **endpoint.headers, - "Authorization": authorization_string, - "Date": date, - } - - new_endpoint = Endpoint( - base_url=endpoint.base_url, - path_url=endpoint.path_url, - method=endpoint.method, - headers=new_headers, - data=endpoint.data, - successful_headers_result_code=endpoint.successful_headers_result_code, - successful_headers_status_code=endpoint.successful_headers_status_code, - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - ) - - response = new_endpoint.send() - handle_server_errors(response=response) - - if endpoint.base_url == base_vwq_url: - assert_vwq_failure( - response=response, - status_code=HTTPStatus.UNAUTHORIZED, - content_type="text/plain;charset=iso-8859-1", - cache_control=None, - www_authenticate="KWS", - connection="keep-alive", - ) - assert response.text == "Malformed authorization header." - return - assert_vws_failure( - response=response, - status_code=HTTPStatus.BAD_REQUEST, - result_code="Fail", - ) +__all__ = [ + "test_missing_signature", + "test_one_part_no_space", + "test_one_part_with_space", +] diff --git a/tests/mock_vws/content_length/conftest.py b/tests/mock_vws/content_length/conftest.py index d409c8ebf..a529d5f43 100644 --- a/tests/mock_vws/content_length/conftest.py +++ b/tests/mock_vws/content_length/conftest.py @@ -1,12 +1,7 @@ -"""Fixtures scoped to this group of tests.""" +"""Compatibility imports for the packaged contract suite.""" -import pytest +from vws_mock_contract_tests.content_length.conftest import ( + fixture_verify_backend, +) - -@pytest.fixture(autouse=True) -def fixture_verify_backend( - *, - verify_mock_vuforia: None, -) -> None: - """Set up the backend for every contract test in this directory.""" - del verify_mock_vuforia +__all__ = ["fixture_verify_backend"] diff --git a/tests/mock_vws/content_length/test_incorrect.py b/tests/mock_vws/content_length/test_incorrect.py index 60ab9df90..29b84a289 100644 --- a/tests/mock_vws/content_length/test_incorrect.py +++ b/tests/mock_vws/content_length/test_incorrect.py @@ -1,194 +1,9 @@ -"""Tests for the ``Content-Length`` header set incorrectly. +"""Compatibility imports for the packaged contract suite.""" -We cannot test what happens if ``Content-Length`` is removed from a -prepared request because ``requests-mock`` behaves differently to -``requests`` - https://github.com/jamielennox/requests-mock/issues/80. -""" - -import textwrap -from http import HTTPStatus - -import pytest - -from tests.mock_vws.utils import Endpoint -from tests.mock_vws.utils.assertions import ( - assert_valid_date_header, - assert_vwq_failure, - assert_vws_failure, +from vws_mock_contract_tests.content_length.test_incorrect import ( + test_not_integer, + test_too_large, + test_too_small, ) -from tests.mock_vws.utils.backend_capabilities import BackendCapabilities -from tests.mock_vws.utils.too_many_requests import handle_server_errors - - -def test_not_integer(endpoint: Endpoint, base_vwq_url: str) -> None: - """ - A ``BAD_REQUEST`` error is given when the given ``Content- - Length`` is - not an integer. - """ - if not bool(endpoint.headers.get("Content-Type")): - return - - content_length = "0.4" - - new_headers = { - **endpoint.headers, - "Content-Length": content_length, - } - - new_endpoint = Endpoint( - base_url=endpoint.base_url, - path_url=endpoint.path_url, - method=endpoint.method, - headers=new_headers, - data=endpoint.data, - successful_headers_result_code=endpoint.successful_headers_result_code, - successful_headers_status_code=endpoint.successful_headers_status_code, - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - ) - - response = new_endpoint.send() - handle_server_errors(response=response) - assert response.status_code == HTTPStatus.BAD_REQUEST - - if endpoint.base_url == base_vwq_url: - assert not bool(response.text) - assert response.headers == { - "Content-Length": str(object=len(response.text)), - "Connection": "Close", - } - return - - assert_valid_date_header(response=response) - expected_response_text = textwrap.dedent( - text="""\ - \r - 400 Bad Request\r - \r -

400 Bad Request

\r - \r - \r - """, - ) - assert response.text == expected_response_text - expected_headers = { - "Content-Length": str(object=len(response.text)), - "Content-Type": "text/html", - "Connection": "close", - "Server": "awselb/2.0", - "Date": response.headers["Date"], - } - assert response.headers == expected_headers - - -def test_too_large( - endpoint: Endpoint, - backend_capabilities: BackendCapabilities, - base_vwq_url: str, -) -> None: - """An error is given if the given content length is too large.""" - if not backend_capabilities.fast_oversized_content_length_response: - pytest.skip( - reason="This backend responds slowly to oversized Content-Length." - ) - - if not bool(endpoint.headers.get("Content-Type")): - pytest.skip(reason="No Content-Type header for this request") - - content_length = str(object=int(endpoint.headers["Content-Length"]) + 1) - - new_headers = { - **endpoint.headers, - "Content-Length": content_length, - } - - new_endpoint = Endpoint( - base_url=endpoint.base_url, - path_url=endpoint.path_url, - method=endpoint.method, - headers=new_headers, - data=endpoint.data, - successful_headers_result_code=endpoint.successful_headers_result_code, - successful_headers_status_code=endpoint.successful_headers_status_code, - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - ) - - response = new_endpoint.send() - - # We do not use ``handle_server_errors`` here because we do not want to - # retry on the Gateway Timeout. - if endpoint.base_url == base_vwq_url: - assert response.status_code == HTTPStatus.GATEWAY_TIMEOUT - assert not bool(response.text) - assert response.headers == { - "Content-Length": str(object=len(response.text)), - "Connection": "keep-alive", - } - return - - handle_server_errors(response=response) - assert_valid_date_header(response=response) - # We have seen both of these response texts. - assert response.text in {"stream timeout", ""} - expected_headers = { - "Content-Length": str(object=len(response.text)), - "Connection": "close", - "Content-Type": "text/plain", - "server": "envoy", - "Date": response.headers["Date"], - } - assert response.headers == expected_headers - assert response.status_code == HTTPStatus.REQUEST_TIMEOUT - - -def test_too_small(endpoint: Endpoint, base_vwq_url: str) -> None: - """ - An ``UNAUTHORIZED`` response is given if the given content - length is - too small. - """ - if not bool(endpoint.headers.get("Content-Type")): - return - - real_content_length = len(endpoint.data) - content_length = real_content_length - 1 - - new_headers = { - **endpoint.headers, - "Content-Length": str(object=content_length), - } - - new_endpoint = Endpoint( - base_url=endpoint.base_url, - path_url=endpoint.path_url, - method=endpoint.method, - headers=new_headers, - data=endpoint.data, - successful_headers_result_code=endpoint.successful_headers_result_code, - successful_headers_status_code=endpoint.successful_headers_status_code, - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - ) - - response = new_endpoint.send() - - handle_server_errors(response=response) - - if endpoint.base_url == base_vwq_url: - assert_vwq_failure( - response=response, - status_code=HTTPStatus.UNAUTHORIZED, - content_type="application/json", - cache_control=None, - www_authenticate="VWS", - connection="keep-alive", - ) - return - assert_vws_failure( - response=response, - status_code=HTTPStatus.UNAUTHORIZED, - result_code="AuthenticationFailure", - ) +__all__ = ["test_not_integer", "test_too_large", "test_too_small"] diff --git a/tests/mock_vws/date_header/conftest.py b/tests/mock_vws/date_header/conftest.py index d409c8ebf..6b9df4852 100644 --- a/tests/mock_vws/date_header/conftest.py +++ b/tests/mock_vws/date_header/conftest.py @@ -1,12 +1,7 @@ -"""Fixtures scoped to this group of tests.""" +"""Compatibility imports for the packaged contract suite.""" -import pytest +from vws_mock_contract_tests.date_header.conftest import ( + fixture_verify_backend, +) - -@pytest.fixture(autouse=True) -def fixture_verify_backend( - *, - verify_mock_vuforia: None, -) -> None: - """Set up the backend for every contract test in this directory.""" - del verify_mock_vuforia +__all__ = ["fixture_verify_backend"] diff --git a/tests/mock_vws/date_header/test_format.py b/tests/mock_vws/date_header/test_format.py index ce9df54bf..2eb5f0320 100644 --- a/tests/mock_vws/date_header/test_format.py +++ b/tests/mock_vws/date_header/test_format.py @@ -1,80 +1,7 @@ -""" -Tests for what happens when the `Date` header is not in the expected -format. -""" +"""Compatibility imports for the packaged contract suite.""" -from datetime import datetime -from http import HTTPStatus -from zoneinfo import ZoneInfo - -from freezegun import freeze_time -from vws_auth_tools import authorization_header - -from tests.mock_vws.utils import Endpoint -from tests.mock_vws.utils.assertions import ( - assert_vwq_failure, - assert_vws_failure, +from vws_mock_contract_tests.date_header.test_format import ( + test_incorrect_date_format, ) -from tests.mock_vws.utils.too_many_requests import handle_server_errors - - -def test_incorrect_date_format(endpoint: Endpoint, base_vwq_url: str) -> None: - """A `BAD_REQUEST` response is returned when the date given in the - date - header is not in the expected format (RFC 1123) to VWS API. - - An `UNAUTHORIZED` response is returned to the VWQ API. - """ - gmt = ZoneInfo(key="GMT") - with freeze_time(time_to_freeze=datetime.now(tz=gmt)): - now = datetime.now(tz=gmt) - date_incorrect_format = now.strftime(format="%a %b %d %H:%M:%S") - - authorization_string = authorization_header( - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - method=endpoint.method, - content=endpoint.data, - content_type=endpoint.auth_header_content_type, - date=date_incorrect_format, - request_path=endpoint.path_url, - ) - - new_headers = { - **endpoint.headers, - "Authorization": authorization_string, - "Date": date_incorrect_format, - } - - new_endpoint = Endpoint( - base_url=endpoint.base_url, - path_url=endpoint.path_url, - method=endpoint.method, - headers=new_headers, - data=endpoint.data, - successful_headers_result_code=endpoint.successful_headers_result_code, - successful_headers_status_code=endpoint.successful_headers_status_code, - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - ) - response = new_endpoint.send() - - handle_server_errors(response=response) - - if endpoint.base_url == base_vwq_url: - assert response.text == "Malformed date header." - assert_vwq_failure( - response=response, - status_code=HTTPStatus.UNAUTHORIZED, - content_type="text/plain;charset=iso-8859-1", - cache_control=None, - www_authenticate="KWS", - connection="keep-alive", - ) - return - assert_vws_failure( - response=response, - status_code=HTTPStatus.BAD_REQUEST, - result_code="Fail", - ) +__all__ = ["test_incorrect_date_format"] diff --git a/tests/mock_vws/date_header/test_missing.py b/tests/mock_vws/date_header/test_missing.py index 458496122..73b5c351f 100644 --- a/tests/mock_vws/date_header/test_missing.py +++ b/tests/mock_vws/date_header/test_missing.py @@ -1,69 +1,7 @@ -"""Tests for what happens when the `Date` header is missing.""" +"""Compatibility imports for the packaged contract suite.""" -from http import HTTPStatus - -from vws_auth_tools import authorization_header - -from tests.mock_vws.utils import Endpoint -from tests.mock_vws.utils.assertions import ( - assert_vwq_failure, - assert_vws_failure, +from vws_mock_contract_tests.date_header.test_missing import ( + test_no_date_header, ) -from tests.mock_vws.utils.too_many_requests import handle_server_errors - - -def test_no_date_header(endpoint: Endpoint, base_vwq_url: str) -> None: - """ - A `BAD_REQUEST` response is returned when no `Date` header is - given. - """ - authorization_string = authorization_header( - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - method=endpoint.method, - content=endpoint.data, - content_type=endpoint.auth_header_content_type, - date="", - request_path=endpoint.path_url, - ) - - new_headers: dict[str, str] = { - **endpoint.headers, - "Authorization": authorization_string, - } - _ = new_headers.pop("Date", None) - - new_endpoint = Endpoint( - base_url=endpoint.base_url, - path_url=endpoint.path_url, - method=endpoint.method, - headers=new_headers, - data=endpoint.data, - successful_headers_result_code=endpoint.successful_headers_result_code, - successful_headers_status_code=endpoint.successful_headers_status_code, - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - ) - - response = new_endpoint.send() - - handle_server_errors(response=response) - - if endpoint.base_url == base_vwq_url: - expected_content_type = "text/plain;charset=iso-8859-1" - assert response.text == "Date header required." - assert_vwq_failure( - response=response, - status_code=HTTPStatus.BAD_REQUEST, - content_type=expected_content_type, - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) - return - assert_vws_failure( - response=response, - status_code=HTTPStatus.BAD_REQUEST, - result_code="Fail", - ) +__all__ = ["test_no_date_header"] diff --git a/tests/mock_vws/date_header/test_skewed_time.py b/tests/mock_vws/date_header/test_skewed_time.py index cd25df76d..e04c02083 100644 --- a/tests/mock_vws/date_header/test_skewed_time.py +++ b/tests/mock_vws/date_header/test_skewed_time.py @@ -1,320 +1,15 @@ -""" -Tests for what happens when the `Date` header is given with an -unexpected -time. -""" +"""Compatibility imports for the packaged contract suite.""" -import json -from datetime import datetime -from http import HTTPStatus -from zoneinfo import ZoneInfo - -from freezegun import freeze_time -from vws_auth_tools import authorization_header, rfc_1123_date - -from tests.mock_vws.date_header.helpers import ( - LEEWAY, - VWQ_MAX_TIME_SKEW, - VWS_MAX_TIME_SKEW, -) -from tests.mock_vws.utils import Endpoint -from tests.mock_vws.utils.assertions import ( - assert_query_success, - assert_valid_transaction_id, - assert_vwq_failure, - assert_vws_failure, - assert_vws_response, +from vws_mock_contract_tests.date_header.test_skewed_time import ( + test_date_in_range_after, + test_date_in_range_before, + test_date_out_of_range_after, + test_date_out_of_range_before, ) -from tests.mock_vws.utils.too_many_requests import handle_server_errors - - -def test_date_out_of_range_after( - endpoint: Endpoint, base_vwq_url: str -) -> None: - """If the date header is more than five minutes (target API) or 65 - minutes (query API) after the request is sent, a `FORBIDDEN` - response - is returned. - - Because there is a small delay in sending requests and Vuforia - isn't consistent, some leeway is given. - """ - skew = ( - VWQ_MAX_TIME_SKEW - if endpoint.base_url == base_vwq_url - else VWS_MAX_TIME_SKEW - ) - time_difference_from_now = skew + LEEWAY - gmt = ZoneInfo(key="GMT") - with freeze_time( - time_to_freeze=datetime.now(tz=gmt) + time_difference_from_now - ): - date = rfc_1123_date() - - authorization_string = authorization_header( - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - method=endpoint.method, - content=endpoint.data, - content_type=endpoint.auth_header_content_type, - date=date, - request_path=endpoint.path_url, - ) - - new_headers = { - **endpoint.headers, - "Authorization": authorization_string, - "Date": date, - } - - new_endpoint = Endpoint( - base_url=endpoint.base_url, - path_url=endpoint.path_url, - method=endpoint.method, - headers=new_headers, - data=endpoint.data, - successful_headers_result_code=endpoint.successful_headers_result_code, - successful_headers_status_code=endpoint.successful_headers_status_code, - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - ) - - response = new_endpoint.send() - - handle_server_errors(response=response) - - # Even with the query endpoint, we get a JSON response. - if endpoint.base_url == base_vwq_url: - response_json = json.loads(s=response.text) - assert isinstance(response_json, dict) - assert response_json.keys() == {"transaction_id", "result_code"} - assert response_json["result_code"] == "RequestTimeTooSkewed" - assert_valid_transaction_id(response=response) - assert_vwq_failure( - response=response, - status_code=HTTPStatus.FORBIDDEN, - content_type="application/json", - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) - return - - assert_vws_failure( - response=response, - status_code=HTTPStatus.FORBIDDEN, - result_code="RequestTimeTooSkewed", - ) - - -def test_date_out_of_range_before( - endpoint: Endpoint, base_vwq_url: str -) -> None: - """If the date header is more than five minutes (target API) or 65 - minutes (query API) before the request is sent, a `FORBIDDEN` - response - is returned. - - Because there is a small delay in sending requests and Vuforia - isn't consistent, some leeway is given. - """ - skew = ( - VWQ_MAX_TIME_SKEW - if endpoint.base_url == base_vwq_url - else VWS_MAX_TIME_SKEW - ) - time_difference_from_now = skew + LEEWAY - gmt = ZoneInfo(key="GMT") - with freeze_time( - time_to_freeze=datetime.now(tz=gmt) - time_difference_from_now - ): - date = rfc_1123_date() - - authorization_string = authorization_header( - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - method=endpoint.method, - content=endpoint.data, - content_type=endpoint.auth_header_content_type, - date=date, - request_path=endpoint.path_url, - ) - - new_headers = { - **endpoint.headers, - "Authorization": authorization_string, - "Date": date, - } - - new_endpoint = Endpoint( - base_url=endpoint.base_url, - path_url=endpoint.path_url, - method=endpoint.method, - headers=new_headers, - data=endpoint.data, - successful_headers_result_code=endpoint.successful_headers_result_code, - successful_headers_status_code=endpoint.successful_headers_status_code, - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - ) - - response = new_endpoint.send() - - handle_server_errors(response=response) - - # Even with the query endpoint, we get a JSON response. - if endpoint.base_url == base_vwq_url: - response_json = json.loads(s=response.text) - assert isinstance(response_json, dict) - assert response_json.keys() == {"transaction_id", "result_code"} - assert response_json["result_code"] == "RequestTimeTooSkewed" - assert_valid_transaction_id(response=response) - assert_vwq_failure( - response=response, - status_code=HTTPStatus.FORBIDDEN, - content_type="application/json", - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) - return - - assert_vws_failure( - response=response, - status_code=HTTPStatus.FORBIDDEN, - result_code="RequestTimeTooSkewed", - ) - - -def test_date_in_range_after(endpoint: Endpoint, base_vwq_url: str) -> None: - """If a date header is within five minutes after the request is - sent, - no error is returned. - - Because there is a small delay in sending requests and Vuforia - isn't consistent, some leeway is given. - """ - skew = ( - VWQ_MAX_TIME_SKEW - if endpoint.base_url == base_vwq_url - else VWS_MAX_TIME_SKEW - ) - time_difference_from_now = skew - LEEWAY - gmt = ZoneInfo(key="GMT") - with freeze_time( - time_to_freeze=datetime.now(tz=gmt) + time_difference_from_now - ): - date = rfc_1123_date() - - authorization_string = authorization_header( - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - method=endpoint.method, - content=endpoint.data, - content_type=endpoint.auth_header_content_type, - date=date, - request_path=endpoint.path_url, - ) - - new_headers = { - **endpoint.headers, - "Authorization": authorization_string, - "Date": date, - } - - new_endpoint = Endpoint( - base_url=endpoint.base_url, - path_url=endpoint.path_url, - method=endpoint.method, - headers=new_headers, - data=endpoint.data, - successful_headers_result_code=endpoint.successful_headers_result_code, - successful_headers_status_code=endpoint.successful_headers_status_code, - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - ) - - response = new_endpoint.send() - - handle_server_errors(response=response) - - if endpoint.base_url == base_vwq_url: - assert_query_success(response=response) - return - - if endpoint.successful_headers_result_code is None: - assert response.status_code == endpoint.successful_headers_status_code - return - - assert_vws_response( - response=response, - status_code=endpoint.successful_headers_status_code, - result_code=endpoint.successful_headers_result_code, - ) - - -def test_date_in_range_before(endpoint: Endpoint, base_vwq_url: str) -> None: - """If a date header is within five minutes before the request is - sent, - no error is returned. - - Because there is a small delay in sending requests and Vuforia - isn't consistent, some leeway is given. - """ - skew = ( - VWQ_MAX_TIME_SKEW - if endpoint.base_url == base_vwq_url - else VWS_MAX_TIME_SKEW - ) - time_difference_from_now = skew - LEEWAY - gmt = ZoneInfo(key="GMT") - with freeze_time( - time_to_freeze=datetime.now(tz=gmt) - time_difference_from_now - ): - date = rfc_1123_date() - - authorization_string = authorization_header( - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - method=endpoint.method, - content=endpoint.data, - content_type=endpoint.auth_header_content_type, - date=date, - request_path=endpoint.path_url, - ) - - new_headers = { - **endpoint.headers, - "Authorization": authorization_string, - "Date": date, - } - - new_endpoint = Endpoint( - base_url=endpoint.base_url, - path_url=endpoint.path_url, - method=endpoint.method, - headers=new_headers, - data=endpoint.data, - successful_headers_result_code=endpoint.successful_headers_result_code, - successful_headers_status_code=endpoint.successful_headers_status_code, - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - ) - - response = new_endpoint.send() - - handle_server_errors(response=response) - - if endpoint.base_url == base_vwq_url: - assert_query_success(response=response) - return - - if endpoint.successful_headers_result_code is None: - assert response.status_code == endpoint.successful_headers_status_code - return - assert_vws_response( - response=response, - status_code=endpoint.successful_headers_status_code, - result_code=endpoint.successful_headers_result_code, - ) +__all__ = [ + "test_date_in_range_after", + "test_date_in_range_before", + "test_date_out_of_range_after", + "test_date_out_of_range_before", +] diff --git a/tests/mock_vws/header_size/conftest.py b/tests/mock_vws/header_size/conftest.py index d409c8ebf..d52820b9f 100644 --- a/tests/mock_vws/header_size/conftest.py +++ b/tests/mock_vws/header_size/conftest.py @@ -1,12 +1,7 @@ -"""Fixtures scoped to this group of tests.""" +"""Compatibility imports for the packaged contract suite.""" -import pytest +from vws_mock_contract_tests.header_size.conftest import ( + fixture_verify_backend, +) - -@pytest.fixture(autouse=True) -def fixture_verify_backend( - *, - verify_mock_vuforia: None, -) -> None: - """Set up the backend for every contract test in this directory.""" - del verify_mock_vuforia +__all__ = ["fixture_verify_backend"] diff --git a/tests/mock_vws/header_size/test_oversized_header_line.py b/tests/mock_vws/header_size/test_oversized_header_line.py index 8b774da46..560801223 100644 --- a/tests/mock_vws/header_size/test_oversized_header_line.py +++ b/tests/mock_vws/header_size/test_oversized_header_line.py @@ -1,115 +1,13 @@ -"""Tests for header lines which do not fit in NGINX's 8 KiB buffer. +"""Compatibility imports for the packaged contract suite.""" -A header line is the header name, a colon, a space and the value. -NGINX's buffer also holds the line's CRLF, so the longest accepted -line is 8190 bytes. -""" - -from tests.mock_vws.header_size.helpers import ( - assert_nginx_too_large_response, - endpoint_with_header, - header_value_for_line_length, -) -from tests.mock_vws.utils import Endpoint -from tests.mock_vws.utils.assertions import ( - assert_query_success, - assert_valid_date_header, - assert_vws_response, +from vws_mock_contract_tests.header_size.test_oversized_header_line import ( + test_cookie_too_large, + test_header_too_large, + test_large_header_within_limit, ) -from tests.mock_vws.utils.too_many_requests import handle_server_errors - -# The observed NGINX boundary is an API expectation, independent of core. -_MAX_HEADER_LINE_LENGTH = 8190 - - -def test_header_too_large(endpoint: Endpoint, base_vwq_url: str) -> None: - """A header line one byte too long for NGINX gives a - ``BAD_REQUEST`` - response with NGINX's HTML body, before any authorization. - """ - name = "X-Padding" - new_endpoint = endpoint_with_header( - endpoint=endpoint, - name=name, - value=header_value_for_line_length( - name=name, - line_length=_MAX_HEADER_LINE_LENGTH + 1, - ), - ) - - response = new_endpoint.send() - handle_server_errors(response=response) - assert_valid_date_header(response=response) - assert_nginx_too_large_response( - endpoint=endpoint, - response=response, - base_vwq_url=base_vwq_url, - ) - - -def test_cookie_too_large(endpoint: Endpoint, base_vwq_url: str) -> None: - """A cookie which makes the ``Cookie`` line too long for NGINX - gives - the same ``BAD_REQUEST`` response as any other header. - - The Envoy layer in front of the Target API lets a ``Cookie`` line - slightly over the limit through, so this sends one well over it. - See :ref:`differences-nginx-error-cases`. - """ - name = "Cookie" - new_endpoint = endpoint_with_header( - endpoint=endpoint, - name=name, - value="pad=" - + header_value_for_line_length( - name=name, - line_length=_MAX_HEADER_LINE_LENGTH + 1000, - ), - ) - - response = new_endpoint.send() - handle_server_errors(response=response) - assert_valid_date_header(response=response) - assert_nginx_too_large_response( - endpoint=endpoint, - response=response, - base_vwq_url=base_vwq_url, - ) - - -def test_large_header_within_limit( - endpoint: Endpoint, base_vwq_url: str -) -> None: - """A large header line which fits in NGINX's buffer is accepted. - - This does not send a line at the limit exactly, because the Query - API rejects header blocks of about 8 KiB in total with a ``431`` - response from its application server. See - :ref:`differences-nginx-error-cases`. - """ - name = "X-Padding" - new_endpoint = endpoint_with_header( - endpoint=endpoint, - name=name, - value=header_value_for_line_length( - name=name, - line_length=_MAX_HEADER_LINE_LENGTH // 2, - ), - ) - - response = new_endpoint.send() - handle_server_errors(response=response) - - if endpoint.base_url == base_vwq_url: - assert_query_success(response=response) - return - - if endpoint.successful_headers_result_code is None: - assert response.status_code == endpoint.successful_headers_status_code - return - assert_vws_response( - response=response, - status_code=endpoint.successful_headers_status_code, - result_code=endpoint.successful_headers_result_code, - ) +__all__ = [ + "test_cookie_too_large", + "test_header_too_large", + "test_large_header_within_limit", +] diff --git a/tests/mock_vws/invalid_given_id/conftest.py b/tests/mock_vws/invalid_given_id/conftest.py index d409c8ebf..ddcb311f5 100644 --- a/tests/mock_vws/invalid_given_id/conftest.py +++ b/tests/mock_vws/invalid_given_id/conftest.py @@ -1,12 +1,7 @@ -"""Fixtures scoped to this group of tests.""" +"""Compatibility imports for the packaged contract suite.""" -import pytest +from vws_mock_contract_tests.invalid_given_id.conftest import ( + fixture_verify_backend, +) - -@pytest.fixture(autouse=True) -def fixture_verify_backend( - *, - verify_mock_vuforia: None, -) -> None: - """Set up the backend for every contract test in this directory.""" - del verify_mock_vuforia +__all__ = ["fixture_verify_backend"] diff --git a/tests/mock_vws/invalid_given_id/test_invalid_given_id.py b/tests/mock_vws/invalid_given_id/test_invalid_given_id.py index 310a0aa78..dbe8b98ca 100644 --- a/tests/mock_vws/invalid_given_id/test_invalid_given_id.py +++ b/tests/mock_vws/invalid_given_id/test_invalid_given_id.py @@ -1,43 +1,7 @@ -""" -Tests for giving an invalid ID to endpoints which require a target -ID to be -given. -""" +"""Compatibility imports for the packaged contract suite.""" -from http import HTTPStatus +from vws_mock_contract_tests.invalid_given_id.test_invalid_given_id import ( + test_not_real_id, +) -from vws import VWS - -from tests.mock_vws.utils import Endpoint -from tests.mock_vws.utils.assertions import assert_vws_failure -from tests.mock_vws.utils.too_many_requests import handle_server_errors - - -def test_not_real_id( - *, - vws_client: VWS, - endpoint: Endpoint, - target_id: str, -) -> None: - """ - A `NOT_FOUND` error is returned when an endpoint is given a - target ID - of a target which does not exist. - """ - # This shared check only covers endpoints that end in target_id, - # such as /targets/{target_id}. Endpoints with trailing segments - # are covered by endpoint-specific tests. - if not endpoint.path_url.endswith(target_id): - return - - vws_client.delete_target(target_id=target_id) - - response = endpoint.send() - - handle_server_errors(response=response) - - assert_vws_failure( - response=response, - status_code=HTTPStatus.NOT_FOUND, - result_code="UnknownTarget", - ) +__all__ = ["test_not_real_id"] diff --git a/tests/mock_vws/invalid_given_id/test_unrouted_requests.py b/tests/mock_vws/invalid_given_id/test_unrouted_requests.py index 64cf88553..889845bd7 100644 --- a/tests/mock_vws/invalid_given_id/test_unrouted_requests.py +++ b/tests/mock_vws/invalid_given_id/test_unrouted_requests.py @@ -1,66 +1,8 @@ -"""Tests for requests which VWS does not serve.""" +"""Compatibility imports for the packaged contract suite.""" -from http import HTTPMethod, HTTPStatus - -from tests.mock_vws.fixtures.credentials import CloudDatabaseCredentials -from tests.mock_vws.invalid_given_id.helpers import ( - send_unrouted_request, +from vws_mock_contract_tests.invalid_given_id.test_unrouted_requests import ( + test_unknown_method, + test_unknown_path, ) -from tests.mock_vws.utils.unrouted_requests import UnroutedRequests - - -def test_unknown_path( - *, - vuforia_database: CloudDatabaseCredentials, - unrouted_requests: UnroutedRequests, -) -> None: - """A request to a path which is not served returns a 404 with no - body. - """ - response = send_unrouted_request( - send=unrouted_requests.send, - vuforia_database=vuforia_database, - method=HTTPMethod.GET, - request_path="/some-random-endpoint", - ) - - if not unrouted_requests.returns_response: - # The ``requests``, ``httpx`` and ``httpx2`` backends mock only - # the paths which they serve, so they give no response at all. - assert response is None - return - - assert response is not None - assert response.status_code == HTTPStatus.NOT_FOUND - assert response.body == b"" - assert response.content_type is None - assert response.content_encoding is None - assert response.upstream_service_time is None - - -def test_unknown_method( - *, - vuforia_database: CloudDatabaseCredentials, - unrouted_requests: UnroutedRequests, -) -> None: - """A request to a served path with a method which that path does - not serve returns a 404, rather than a 405. - """ - response = send_unrouted_request( - send=unrouted_requests.send, - vuforia_database=vuforia_database, - method=HTTPMethod.DELETE, - request_path="/summary", - ) - - if not unrouted_requests.returns_response: - assert response is None - return - assert response is not None - assert response.status_code == HTTPStatus.NOT_FOUND - assert response.content_type == "text/html; charset=UTF-8" - assert response.content_encoding == "gzip" - assert response.upstream_service_time is not None - assert b"

Not Found

" in response.body - assert b"For request 'DELETE /summary'" in response.body +__all__ = ["test_unknown_method", "test_unknown_path"] diff --git a/tests/mock_vws/invalid_json/conftest.py b/tests/mock_vws/invalid_json/conftest.py index d409c8ebf..83f6000e8 100644 --- a/tests/mock_vws/invalid_json/conftest.py +++ b/tests/mock_vws/invalid_json/conftest.py @@ -1,12 +1,7 @@ -"""Fixtures scoped to this group of tests.""" +"""Compatibility imports for the packaged contract suite.""" -import pytest +from vws_mock_contract_tests.invalid_json.conftest import ( + fixture_verify_backend, +) - -@pytest.fixture(autouse=True) -def fixture_verify_backend( - *, - verify_mock_vuforia: None, -) -> None: - """Set up the backend for every contract test in this directory.""" - del verify_mock_vuforia +__all__ = ["fixture_verify_backend"] diff --git a/tests/mock_vws/invalid_json/test_invalid_json.py b/tests/mock_vws/invalid_json/test_invalid_json.py index 967a51df3..f034ebb77 100644 --- a/tests/mock_vws/invalid_json/test_invalid_json.py +++ b/tests/mock_vws/invalid_json/test_invalid_json.py @@ -1,221 +1,17 @@ -"""Tests for giving invalid JSON to endpoints. - -The three cases are separate tests, rather than one test covering -every body, because each body costs a request per endpoint against -Real Vuforia and CI runs one ``ci_pattern`` per job. See -https://github.com/VWS-Python/vws-python-mock/issues/3540. -""" - -import json -from datetime import datetime, timedelta -from http import HTTPStatus -from zoneinfo import ZoneInfo - -import pytest -from freezegun import freeze_time -from vws_auth_tools import authorization_header, rfc_1123_date - -from tests.mock_vws.invalid_json.helpers import ( - assert_body_rejected, -) -from tests.mock_vws.utils import Endpoint -from tests.mock_vws.utils.assertions import ( - assert_valid_date_header, - assert_valid_transaction_id, - assert_vwq_failure, - assert_vws_failure, -) -from tests.mock_vws.utils.too_many_requests import handle_server_errors - - -def test_not_json(endpoint: Endpoint, base_vwq_url: str) -> None: - """Giving a body which is not JSON returns an error response.""" - assert_body_rejected( - endpoint=endpoint, content=b"a", base_vwq_url=base_vwq_url - ) - - -@pytest.mark.parametrize( - argnames="content", - argvalues=[b"[]", b'"hello"', b"5", b"null", b"true"], +"""Compatibility imports for the packaged contract suite.""" + +from vws_mock_contract_tests.invalid_json.test_invalid_json import ( + test_empty_body, + test_invalid_json_with_skewed_time, + test_not_an_object, + test_not_json, + test_not_utf_8, ) -def test_not_an_object( - endpoint: Endpoint, content: bytes, base_vwq_url: str -) -> None: - """Giving valid JSON which is not an object returns an error - response. - """ - assert_body_rejected( - endpoint=endpoint, content=content, base_vwq_url=base_vwq_url - ) - - -def test_not_utf_8(endpoint: Endpoint, base_vwq_url: str) -> None: - """Giving a JSON object which is not encoded as UTF-8 returns an - error response. - """ - content = '{"name": "café"}'.encode(encoding="latin-1") - assert_body_rejected( - endpoint=endpoint, content=content, base_vwq_url=base_vwq_url - ) - - -def test_empty_body(endpoint: Endpoint) -> None: - """Giving an empty body to an endpoint which takes JSON returns an - error response. - - Real Vuforia gives a server error for an empty body given to the - target endpoints, but a bad request for one given to the reco counts - report endpoint or to the VuMark instance generation endpoint. - - Unlike the other malformed bodies which this class sends, an empty - body is answered promptly by real Vuforia, so this test runs against - it. - """ - takes_json_data = endpoint.auth_header_content_type == "application/json" - if not takes_json_data: - pytest.skip(reason="This endpoint does not take a JSON body.") - - if endpoint.path_url.endswith("/instances"): - expected_status_code = HTTPStatus.BAD_REQUEST - expected_result_code = "BadRequest" - elif endpoint.path_url.endswith("/reports/recoCounts"): - expected_status_code = HTTPStatus.BAD_REQUEST - expected_result_code = "Fail" - else: - expected_status_code = HTTPStatus.INTERNAL_SERVER_ERROR - expected_result_code = "Fail" - - content = b"" - gmt = ZoneInfo(key="GMT") - now = datetime.now(tz=gmt) - time_to_freeze = now - with freeze_time(time_to_freeze=time_to_freeze): - date = rfc_1123_date() - - authorization_string = authorization_header( - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - method=endpoint.method, - content=content, - content_type=endpoint.auth_header_content_type, - date=date, - request_path=endpoint.path_url, - ) - - new_headers = { - **endpoint.headers, - "Authorization": authorization_string, - "Date": date, - "Content-Length": str(object=len(content)), - } - - new_endpoint = Endpoint( - base_url=endpoint.base_url, - path_url=endpoint.path_url, - method=endpoint.method, - headers=new_headers, - data=content, - successful_headers_result_code=endpoint.successful_headers_result_code, - successful_headers_status_code=endpoint.successful_headers_status_code, - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - ) - - response = new_endpoint.send() - - # A server error is the expected response for some endpoints, so - # only treat one as transient where it is not expected. - if expected_status_code != HTTPStatus.INTERNAL_SERVER_ERROR: - handle_server_errors(response=response) - - assert_valid_date_header(response=response) - assert_vws_failure( - response=response, - status_code=expected_status_code, - result_code=expected_result_code, - ) - - -def test_invalid_json_with_skewed_time( - endpoint: Endpoint, base_vwq_url: str -) -> None: - """Giving invalid JSON to endpoints returns error responses.""" - # We use a skew of 70 because the maximum allowed skew for services is - # 5 minutes, and for query is 65 minutes. 70 is comfortably larger than - # the max of these two. - date_skew_minutes = 70 - content = b"a" - gmt = ZoneInfo(key="GMT") - now = datetime.now(tz=gmt) - time_to_freeze = now + timedelta(minutes=date_skew_minutes) - with freeze_time(time_to_freeze=time_to_freeze): - date = rfc_1123_date() - - authorization_string = authorization_header( - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - method=endpoint.method, - content=content, - content_type=endpoint.auth_header_content_type, - date=date, - request_path=endpoint.path_url, - ) - - new_headers = { - **endpoint.headers, - "Authorization": authorization_string, - "Content-Length": str(object=len(content)), - "Date": date, - } - - new_endpoint = Endpoint( - base_url=endpoint.base_url, - path_url=endpoint.path_url, - method=endpoint.method, - headers=new_headers, - data=content, - successful_headers_result_code=endpoint.successful_headers_result_code, - successful_headers_status_code=endpoint.successful_headers_status_code, - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - ) - - response = new_endpoint.send() - - handle_server_errors(response=response) - - takes_json_data = endpoint.auth_header_content_type == "application/json" - - assert_valid_date_header(response=response) - - if takes_json_data: - assert_vws_failure( - response=response, - status_code=HTTPStatus.FORBIDDEN, - result_code="RequestTimeTooSkewed", - ) - return - - if endpoint.base_url == base_vwq_url: - response_json = json.loads(s=response.text) - assert isinstance(response_json, dict) - assert response_json.keys() == { - "transaction_id", - "result_code", - } - assert response_json["result_code"] == "RequestTimeTooSkewed" - assert_valid_transaction_id(response=response) - assert_vwq_failure( - response=response, - status_code=HTTPStatus.FORBIDDEN, - content_type="application/json", - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) - return - assert response.status_code == HTTPStatus.BAD_REQUEST - assert not bool(response.text) - assert "Content-Type" not in response.headers +__all__ = [ + "test_empty_body", + "test_invalid_json_with_skewed_time", + "test_not_an_object", + "test_not_json", + "test_not_utf_8", +] diff --git a/tests/mock_vws/unexpected_json/conftest.py b/tests/mock_vws/unexpected_json/conftest.py index d409c8ebf..4839d1cb4 100644 --- a/tests/mock_vws/unexpected_json/conftest.py +++ b/tests/mock_vws/unexpected_json/conftest.py @@ -1,12 +1,7 @@ -"""Fixtures scoped to this group of tests.""" +"""Compatibility imports for the packaged contract suite.""" -import pytest +from vws_mock_contract_tests.unexpected_json.conftest import ( + fixture_verify_backend, +) - -@pytest.fixture(autouse=True) -def fixture_verify_backend( - *, - verify_mock_vuforia: None, -) -> None: - """Set up the backend for every contract test in this directory.""" - del verify_mock_vuforia +__all__ = ["fixture_verify_backend"] diff --git a/tests/mock_vws/unexpected_json/test_unexpected_json.py b/tests/mock_vws/unexpected_json/test_unexpected_json.py index b59697427..84ea5f312 100644 --- a/tests/mock_vws/unexpected_json/test_unexpected_json.py +++ b/tests/mock_vws/unexpected_json/test_unexpected_json.py @@ -1,80 +1,7 @@ -"""Tests for giving JSON to endpoints which do not expect it.""" +"""Compatibility imports for the packaged contract suite.""" -import json -from http import HTTPStatus +from vws_mock_contract_tests.unexpected_json.test_unexpected_json import ( + test_does_not_take_data, +) -from vws_auth_tools import authorization_header, rfc_1123_date - -from tests.mock_vws.utils import Endpoint -from tests.mock_vws.utils.assertions import assert_vwq_failure -from tests.mock_vws.utils.too_many_requests import handle_server_errors - - -def test_does_not_take_data(endpoint: Endpoint, base_vwq_url: str) -> None: - """ - Giving JSON to endpoints which do not take any JSON data returns - error - responses. - """ - if ( - endpoint.headers.get( - "Content-Type", - ) - == "application/json" - ): - return - content = json.dumps(obj={"key": "value"}).encode(encoding="utf-8") - content_type = "application/json" - date = rfc_1123_date() - - authorization_string = authorization_header( - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - method=endpoint.method, - content=content, - content_type=content_type, - date=date, - request_path=endpoint.path_url, - ) - - new_headers = { - **endpoint.headers, - "Authorization": authorization_string, - "Date": date, - "Content-Type": content_type, - "Content-Length": str(object=len(content)), - } - - new_endpoint = Endpoint( - base_url=endpoint.base_url, - path_url=endpoint.path_url, - method=endpoint.method, - headers=new_headers, - data=content, - successful_headers_result_code=endpoint.successful_headers_result_code, - successful_headers_status_code=endpoint.successful_headers_status_code, - access_key=endpoint.access_key, - secret_key=endpoint.secret_key, - ) - - response = new_endpoint.send() - - handle_server_errors(response=response) - - if endpoint.base_url == base_vwq_url: - # The multipart/formdata boundary is no longer in the given - # content. - assert not bool(response.text) - assert_vwq_failure( - response=response, - status_code=HTTPStatus.UNSUPPORTED_MEDIA_TYPE, - content_type=None, - cache_control=None, - www_authenticate=None, - connection="keep-alive", - ) - return - - assert response.status_code == HTTPStatus.BAD_REQUEST - assert not bool(response.text) - assert "Content-Type" not in response.headers +__all__ = ["test_does_not_take_data"] From 1aaad50f62aae4fa45c20cde79ed3ab6c770603e Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 28 Oct 2026 17:50:27 +0000 Subject: [PATCH 08/10] Use supported wording in package documentation --- docs/source/contributing.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst index a938274d0..d90e741ed 100644 --- a/docs/source/contributing.rst +++ b/docs/source/contributing.rst @@ -169,8 +169,8 @@ These controls keep backend operations and transport differences behind the adap Installed contract tests ------------------------ -The ``contract-tests`` subproject builds ``vws-mock-contract-tests`` with an explicit shared fixture plugin. -The ``test-backends`` subproject builds the core repository's adapter plugin separately. +The ``contract-tests`` project builds ``vws-mock-contract-tests`` with an explicit shared fixture plugin. +The ``test-backends`` project builds the core repository's adapter plugin separately. See :file:`contract-tests/README.rst` for installation, invocation and revision compatibility. Container deployment tests From 4f6e2bcea5fc10a3c2aea91229fb97e4df1d97d6 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 28 Oct 2026 18:25:42 +0000 Subject: [PATCH 09/10] Replace the contract package shell harness with pytest --- .github/workflows/contract-package.yml | 6 +- MANIFEST.in | 1 + ci/check_contract_package.sh | 60 ------------ contract-tests/README.rst | 11 +++ contract-tests/consumer/__init__.py | 1 + contract-tests/consumer/contract_checks.py | 20 ++++ contract-tests/consumer/dependency_checks.py | 13 +++ contract-tests/consumer/pytest.ini | 1 + .../consumer/test_installed_suite.py | 98 +++++++++++++++++++ prek.toml | 2 +- pyproject.toml | 1 + 11 files changed, 152 insertions(+), 62 deletions(-) delete mode 100755 ci/check_contract_package.sh create mode 100644 contract-tests/consumer/__init__.py create mode 100644 contract-tests/consumer/contract_checks.py create mode 100644 contract-tests/consumer/dependency_checks.py create mode 100644 contract-tests/consumer/pytest.ini create mode 100644 contract-tests/consumer/test_installed_suite.py diff --git a/.github/workflows/contract-package.yml b/.github/workflows/contract-package.yml index 5a0a11c86..10eac971c 100644 --- a/.github/workflows/contract-package.yml +++ b/.github/workflows/contract-package.yml @@ -20,6 +20,10 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v10.3.2 - name: Verify installed contracts through the Flask adapter - run: ci/check_contract_package.sh + run: >- + uv run --group=dev pytest + --confcutdir=contract-tests/consumer + -c contract-tests/consumer/pytest.ini + contract-tests/consumer/test_installed_suite.py env: UV_PYTHON: '3.15' diff --git a/MANIFEST.in b/MANIFEST.in index 8fb921484..782113de4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,6 @@ recursive-include docs *.md include contract-tests/pyproject.toml contract-tests/README.rst contract-tests/LICENSE recursive-include contract-tests/src *.py py.typed +recursive-include contract-tests/consumer *.py *.ini include test-backends/pyproject.toml test-backends/README.rst test-backends/LICENSE recursive-include test-backends/src *.py py.typed diff --git a/ci/check_contract_package.sh b/ci/check_contract_package.sh deleted file mode 100755 index 1690d8bfd..000000000 --- a/ci/check_contract_package.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -CHECK_DIR="$(mktemp -d)" -trap 'rm -rf "${CHECK_DIR}"' EXIT - -uv build --all-packages --wheel --out-dir "${CHECK_DIR}/dist" -uv venv --python 3.15 "${CHECK_DIR}/consumer" -CONSUMER_PYTHON="${CHECK_DIR}/consumer/bin/python" -uv pip install --python "${CONSUMER_PYTHON}" "${CHECK_DIR}"/dist/vws_mock_contract_tests-*.whl - -cd "${CHECK_DIR}" -unset PYTHONPATH -printf '[pytest]\n' > pytest.ini - -"${CONSUMER_PYTHON}" - << 'PY' -import importlib.util - -assert importlib.util.find_spec(name="mock_vws") is None -assert importlib.util.find_spec(name="flask") is None -PY - -"${CONSUMER_PYTHON}" -m pytest \ - -c pytest.ini \ - -p vws_mock_contract_tests.fixtures.plugin \ - --pyargs vws_mock_contract_tests --collect-only -q - -uv pip install --python "${CONSUMER_PYTHON}" \ - "${CHECK_DIR}"/dist/vws_python_mock-*.whl \ - "${CHECK_DIR}"/dist/vws_mock_test_backends-*.whl - -cat > consumer_contract.py << 'PY' -"""Check the lifecycle boundary of the explicitly selected adapter.""" - -import sys - -import pytest - - -@pytest.fixture(autouse=True) -def ready(request: pytest.FixtureRequest) -> None: - """Check that the adapter's public setup fixture yields None.""" - for name in ("verify_mock_vuforia", "mock_only_vuforia"): - if name in request.fixturenames: - assert request.getfixturevalue(argname=name) is None - - -def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: - """Keep the core repository's tests outside the consumer process.""" - del session, exitstatus - assert not any(name.startswith("tests.mock_vws") for name in sys.modules) -PY - -"${CONSUMER_PYTHON}" -m pytest \ - -c pytest.ini \ - -p consumer_contract \ - -p vws_mock_contract_tests.fixtures.plugin \ - -p vws_mock_test_backends.vuforia_backends \ - --pyargs "${CONTRACT_TEST_TARGET:-vws_mock_contract_tests.database_summary}" \ - --skip-backend=real --skip-backend=mock -q diff --git a/contract-tests/README.rst b/contract-tests/README.rst index e532ccef0..7e1797e3a 100644 --- a/contract-tests/README.rst +++ b/contract-tests/README.rst @@ -42,3 +42,14 @@ Import-only modules under ``tests/mock_vws`` preserve the existing CI selectors. The assertions have one source in ``vws_mock_contract_tests``. Core coverage measures the packaged source and omits the forwarding modules. The root test configuration avoids collecting the packaged tests a second time. + +The consumer integration test builds wheels and checks them in a fresh environment: + +.. code-block:: console + + $ uv run --group=dev pytest \ + --confcutdir=contract-tests/consumer \ + -c contract-tests/consumer/pytest.ini \ + contract-tests/consumer/test_installed_suite.py + +The consumer's dependency and lifecycle checks are explicit pytest plugins. diff --git a/contract-tests/consumer/__init__.py b/contract-tests/consumer/__init__.py new file mode 100644 index 000000000..33be12e97 --- /dev/null +++ b/contract-tests/consumer/__init__.py @@ -0,0 +1 @@ +"""Integration checks for an independent package consumer.""" diff --git a/contract-tests/consumer/contract_checks.py b/contract-tests/consumer/contract_checks.py new file mode 100644 index 000000000..fd3262af4 --- /dev/null +++ b/contract-tests/consumer/contract_checks.py @@ -0,0 +1,20 @@ +"""Check the installed suite's adapter and import boundaries.""" + +import sys + +import pytest + +__all__ = ["pytest_sessionfinish"] + + +@pytest.fixture(autouse=True) +def ready(request: pytest.FixtureRequest) -> None: + """Check that public setup fixtures yield None.""" + for name in ("verify_mock_vuforia", "mock_only_vuforia"): + if name in request.fixturenames: + assert request.getfixturevalue(argname=name) is None + + +def pytest_sessionfinish() -> None: + """Keep the core repository's tests outside the consumer process.""" + assert not any(name.startswith("tests.mock_vws") for name in sys.modules) diff --git a/contract-tests/consumer/dependency_checks.py b/contract-tests/consumer/dependency_checks.py new file mode 100644 index 000000000..b6be78342 --- /dev/null +++ b/contract-tests/consumer/dependency_checks.py @@ -0,0 +1,13 @@ +"""Check that collecting the shared suite needs no implementation.""" + +import importlib.util +import sys + +__all__ = ["pytest_sessionstart"] + + +def pytest_sessionstart() -> None: + """Reject implementation dependencies before collection begins.""" + assert importlib.util.find_spec(name="mock_vws") is None + assert importlib.util.find_spec(name="flask") is None + assert not any(name.startswith("tests.mock_vws") for name in sys.modules) diff --git a/contract-tests/consumer/pytest.ini b/contract-tests/consumer/pytest.ini new file mode 100644 index 000000000..eea2c1802 --- /dev/null +++ b/contract-tests/consumer/pytest.ini @@ -0,0 +1 @@ +[pytest] diff --git a/contract-tests/consumer/test_installed_suite.py b/contract-tests/consumer/test_installed_suite.py new file mode 100644 index 000000000..17d204045 --- /dev/null +++ b/contract-tests/consumer/test_installed_suite.py @@ -0,0 +1,98 @@ +"""Exercise built wheels from a separate consumer environment.""" + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + + +def _run(*, args: list[str | Path], cwd: Path) -> None: + """Run a local tool, failing the test if it fails.""" + # Commands use a local executable and fixed options or built wheel paths. + _ = subprocess.run(args=args, cwd=cwd, check=True) # noqa: S603 + + +def test_installed_suite( + *, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Collect without an implementation, then run the Flask adapter.""" + consumer = Path(__file__).resolve().parent + repository = consumer.parent.parent + dist = tmp_path / "dist" + environment = tmp_path / "venv" + uv = shutil.which(cmd="uv") + assert uv is not None + monkeypatch.delenv(name="PYTHONPATH", raising=False) + + _run( + args=[uv, "build", "--all-packages", "--wheel", "--out-dir", dist], + cwd=repository, + ) + _run( + args=[uv, "venv", "--python", sys.executable, environment], + cwd=tmp_path, + ) + python = environment / ( + "Scripts/python.exe" if sys.platform == "win32" else "bin/python" + ) + suite = next(dist.glob(pattern="vws_mock_contract_tests-*.whl")) + _run(args=[uv, "pip", "install", "--python", python, suite], cwd=tmp_path) + for filename in ( + "pytest.ini", + "dependency_checks.py", + "contract_checks.py", + ): + _ = shutil.copyfile(src=consumer / filename, dst=tmp_path / filename) + + _run( + args=[ + python, + "-m", + "pytest", + "-p", + "dependency_checks", + "-p", + "vws_mock_contract_tests.fixtures.plugin", + "--pyargs", + "vws_mock_contract_tests", + "--collect-only", + "-q", + ], + cwd=tmp_path, + ) + _run( + args=[ + uv, + "pip", + "install", + "--python", + python, + *dist.glob(pattern="*.whl"), + ], + cwd=tmp_path, + ) + _run( + args=[ + python, + "-m", + "pytest", + "-p", + "contract_checks", + "-p", + "vws_mock_contract_tests.fixtures.plugin", + "-p", + "vws_mock_test_backends.vuforia_backends", + "--pyargs", + os.environ.get( + key="CONTRACT_TEST_TARGET", + default="vws_mock_contract_tests.database_summary", + ), + "--skip-backend=real", + "--skip-backend=mock", + "-q", + ], + cwd=tmp_path, + ) diff --git a/prek.toml b/prek.toml index 4d4af51cb..c20646e45 100644 --- a/prek.toml +++ b/prek.toml @@ -318,7 +318,7 @@ hooks = [ { id = "pylint", name = "pylint", - entry = "uv run --group=dev -m pylint *.py src/ tests/ contract-tests/src/ test-backends/src/ docs/ ci/ admin/", + entry = "uv run --group=dev -m pylint *.py src/ tests/ contract-tests/src/ contract-tests/consumer/ test-backends/src/ docs/ ci/ admin/", language = "python", stages = ["manual"], pass_filenames = false, diff --git a/pyproject.toml b/pyproject.toml index 1e1ee2541..b4c18561f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -200,6 +200,7 @@ lint.ignore = [ "TC003", ] lint.explicit-preview-rules = true +lint.per-file-ignores."contract-tests/consumer/**" = [ "S101" ] lint.per-file-ignores."contract-tests/src/**" = [ "S101", "S105", "S106" ] lint.per-file-ignores."doccmd_*.py" = [ # Allow our chosen docstring line-style - pydocstringformatter handles From 2fe17606ecba5df4c03437824058fb2e8d223ac8 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 28 Oct 2026 21:04:46 +0000 Subject: [PATCH 10/10] Document the complete installed API contract suite --- contract-tests/README.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/contract-tests/README.rst b/contract-tests/README.rst index 7e1797e3a..d1df661cc 100644 --- a/contract-tests/README.rst +++ b/contract-tests/README.rst @@ -1,8 +1,7 @@ VWS mock contract tests ======================= -This project contains the shared API fixtures, helpers and database-summary tests. -Further API groups are migrated separately. +This project contains shared fixtures, helpers and contracts for the Vuforia Web Services and Model Target APIs. It is a development dependency for projects which implement the Vuforia APIs. Build and install @@ -26,7 +25,7 @@ Run the installed suite from outside the source checkout: $ python -m pytest \ -p vws_mock_contract_tests.fixtures.plugin \ -p vws_mock_test_backends.vuforia_backends \ - --pyargs vws_mock_contract_tests.database_summary \ + --pyargs vws_mock_contract_tests \ --skip-backend=real The shared plugin provides clients, prepared requests and the retry allowlist.