diff --git a/.cirrus.yml b/.cirrus.yml index a52b4f0d5c..f6ba0c61ef 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -11,6 +11,7 @@ TEST_TEMPLATE: &TEST_TEMPLATE linux_arm64_task: env: PATH: ${HOME}/.local/bin:${PATH} + CIRRUS_CLONE_SUBMODULES: "true" matrix: - IMAGE: "python:3.8-slim" - IMAGE: "python:3.9-slim" @@ -30,6 +31,7 @@ macosx_arm64_task: image: ghcr.io/cirruslabs/macos-ventura-base:latest env: PATH: ${HOME}/.local/bin:${HOME}/.pyenv/shims:${PATH} + CIRRUS_CLONE_SUBMODULES: "true" matrix: - PYTHON: "3.8" - PYTHON: "3.9" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d6785a55d7..e63ff5e85d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,6 +38,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + submodules: true - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..fa1a87278c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "compatibility-suite"] + path = tests/v3/compatiblity_suite/definition + url = ../pact-compatibility-suite.git diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4b6ca493c8..5f1de75e09 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -99,7 +99,7 @@ You can also try using the new [github.dev](https://github.dev/pact-foundation/p pipx install hatch ``` -3. After cloning the repository, run `hatch shell` in the root of the repository. This will install all dependencies in a Python virtual environment and then ensure that the virtual environment is activated. +3. After cloning the repository, run `hatch shell` in the root of the repository. This will install all dependencies in a Python virtual environment and then ensure that the virtual environment is activated. You will also need to run `git submodule init` if you want to run tests, as Pact Python makes use of the Pact Compability Suite. 4. To run tests, run `hatch run test` to make sure the test suite is working. You should also make sure the example works by running `hatch run example`. For the examples, you will have to make sure that you have Docker (or a suitable alternative) installed and running. diff --git a/pact/v3/ffi.py b/pact/v3/ffi.py index 88d0f0e571..4d085aaca6 100644 --- a/pact/v3/ffi.py +++ b/pact/v3/ffi.py @@ -82,10 +82,11 @@ from __future__ import annotations import gc +import json import typing import warnings from enum import Enum -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING, Any, List from ._ffi import ffi, lib # type: ignore[import] @@ -4557,11 +4558,7 @@ def create_mock_server_for_transport( addr.encode("utf-8"), port, transport.encode("utf-8"), - ( - transport_config.encode("utf-8") - if transport_config is not None - else ffi.NULL - ), + (transport_config.encode("utf-8") if transport_config else ffi.NULL), ) if ret > 0: return PactServerHandle(ret) @@ -4581,42 +4578,42 @@ def create_mock_server_for_transport( raise RuntimeError(msg) -def mock_server_matched(mock_server_port: int) -> bool: +def mock_server_matched(mock_server_handle: PactServerHandle) -> bool: """ External interface to check if a mock server has matched all its requests. - The port number is passed in, and if all requests have been matched, true is - returned. False is returned if there is no mock server on the given port, or + If all requests have been matched, `true` is returned. `false` is returned if any request has not been successfully matched, or the method panics. [Rust `pactffi_mock_server_matched`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_mock_server_matched) """ - raise NotImplementedError + return lib.pactffi_mock_server_matched(mock_server_handle._ref) -def mock_server_mismatches(mock_server_port: int) -> str: +def mock_server_mismatches( + mock_server_handle: PactServerHandle, +) -> list[dict[str, Any]]: """ External interface to get all the mismatches from a mock server. - The port number of the mock server is passed in, and a pointer to a C string - with the mismatches in JSON format is returned. - [Rust `pactffi_mock_server_mismatches`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_mock_server_mismatches) - **NOTE:** The JSON string for the result is allocated on the heap, and will - have to be freed once the code using the mock server is complete. The - [`cleanup_mock_server`](fn.cleanup_mock_server.html) function is provided - for this purpose. - # Errors - If there is no mock server with the provided port number, or the function - panics, a NULL pointer will be returned. Don't try to dereference it, it - will not end well for you. + Raises: + RuntimeError: If there is no mock server with the provided port number, + or the function panics. """ - raise NotImplementedError + ptr = lib.pactffi_mock_server_mismatches(mock_server_handle._ref) + if ptr == ffi.NULL: + msg = f"No mock server found with port {mock_server_handle}." + raise RuntimeError(msg) + string = ffi.string(ptr) + if isinstance(string, bytes): + string = string.decode("utf-8") + return json.loads(string) def cleanup_mock_server(mock_server_handle: PactServerHandle) -> None: @@ -4673,7 +4670,7 @@ def write_pact_file( """ ret: int = lib.pactffi_write_pact_file( mock_server_handle._ref, - directory, + str(directory).encode("utf-8"), overwrite, ) if ret == 0: @@ -4698,21 +4695,27 @@ def write_pact_file( raise RuntimeError(msg) -def mock_server_logs(mock_server_port: int) -> str: +def mock_server_logs(mock_server_handle: PactServerHandle) -> str: """ Fetch the logs for the mock server. This needs the memory buffer log sink to be setup before the mock server is - started. Returned string will be freed with the `cleanup_mock_server` - function call. + started. [Rust `pactffi_mock_server_logs`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_mock_server_logs) - Will return a NULL pointer if the logs for the mock server can not be - retrieved. + Raises: + RuntimeError: If the logs for the mock server can not be retrieved. """ - raise NotImplementedError + ptr = lib.pactffi_mock_server_logs(mock_server_handle._ref) + if ptr == ffi.NULL: + msg = f"Unable to obtain logs from {mock_server_handle!r}" + raise RuntimeError(msg) + string = ffi.string(ptr) + if isinstance(string, bytes): + string = string.decode("utf-8") + return string def generate_datetime_string(format: str) -> StringResult: @@ -5452,7 +5455,7 @@ def response_status(interaction: InteractionHandle, status: int) -> None: def with_body( interaction: InteractionHandle, part: InteractionPart, - content_type: str, + content_type: str | None, body: str | None, ) -> None: """ @@ -5488,8 +5491,8 @@ def with_body( success: bool = lib.pactffi_with_body( interaction._ref, part.value, - content_type.encode("utf-8"), - body.encode("utf-8") if body is not None else None, + content_type.encode("utf-8") if content_type else ffi.NULL, + body.encode("utf-8") if body else None, ) if not success: msg = f"Unable to set body for {interaction}." @@ -5499,7 +5502,7 @@ def with_body( def with_binary_file( interaction: InteractionHandle, part: InteractionPart, - content_type: str, + content_type: str | None, body: bytes | None, ) -> None: """ @@ -5542,7 +5545,7 @@ def with_binary_file( success: bool = lib.pactffi_with_binary_file( interaction._ref, part.value, - content_type.encode("utf-8"), + content_type.encode("utf-8") if content_type else ffi.NULL, body if body else ffi.NULL, len(body) if body else 0, ) @@ -5554,7 +5557,7 @@ def with_binary_file( def with_multipart_file_v2( # noqa: PLR0913 interaction: InteractionHandle, part: InteractionPart, - content_type: str, + content_type: str | None, file: Path | None, part_name: str, boundary: str | None, @@ -5597,7 +5600,7 @@ def with_multipart_file_v2( # noqa: PLR0913 lib.pactffi_with_multipart_file_v2( interaction._ref, part.value, - content_type.encode("utf-8"), + content_type.encode("utf-8") if content_type else ffi.NULL, str(file).encode("utf-8") if file else ffi.NULL, part_name.encode("utf-8"), boundary.encode("utf-8") if boundary else ffi.NULL, @@ -6635,7 +6638,7 @@ def using_plugin( ret: int = lib.pactffi_using_plugin( pact._ref, plugin_name.encode("utf-8"), - plugin_version.encode("utf-8") if plugin_version is not None else ffi.NULL, + plugin_version.encode("utf-8") if plugin_version else ffi.NULL, ) if ret == 0: return diff --git a/pact/v3/pact.py b/pact/v3/pact.py index a2536ca54b..7ef64e5b31 100644 --- a/pact/v3/pact.py +++ b/pact/v3/pact.py @@ -233,7 +233,7 @@ def given( def with_body( self, body: str | None = None, - content_type: str = "text/plain", + content_type: str | None = None, part: Literal["Request", "Response"] | None = None, ) -> Self: """ @@ -264,14 +264,14 @@ def with_body( ) return self - def with_binary_file( + def with_binary_body( self, body: bytes | None, - content_type: str = "application/octet-stream", + content_type: str | None = None, part: Literal["Request", "Response"] | None = None, ) -> Self: """ - Adds a binary file to the request or response. + Adds a binary body to the request or response. Note that for HTTP interactions, this function will overwrite the body if it has been set using @@ -305,7 +305,7 @@ def with_multipart_file( # noqa: PLR0913 self, part_name: str, path: Path | None, - content_type: str = "application/octet-stream", + content_type: str | None = None, part: Literal["Request", "Response"] | None = None, boundary: str | None = None, ) -> Self: @@ -317,9 +317,9 @@ def with_multipart_file( # noqa: PLR0913 pact.v3.ffi.with_multipart_file_v2( self._handle, self._parse_interaction_part(part), - part_name, - path, content_type, + path, + part_name, boundary, ) return self @@ -343,7 +343,7 @@ def test_name( def with_plugin_contents( self, contents: dict[str, Any] | str, - content_type: str = "text/plain", + content_type: str, part: Literal["Request", "Response"] | None = None, ) -> Self: """ @@ -1075,12 +1075,14 @@ def upon_receiving( msg = f"Invalid interaction type: {interaction}" raise ValueError(msg) - def serve( + def serve( # noqa: PLR0913 self, addr: str = "localhost", port: int = 0, transport: str = "http", transport_config: str | None = None, + *, + raises: bool = True, ) -> PactServer: """ Return a mock server for the Pact. @@ -1122,6 +1124,7 @@ def serve( port, transport, transport_config, + raises=raises, ) def messages(self) -> pact.v3.ffi.PactMessageIterator: @@ -1174,7 +1177,7 @@ def interactions( The kind is used to specify the type of interactions that will be iterated over. """ - # TODO(JP-Ellis): Add an iterator for `All` interactions. + # TODO: Add an iterator for `All` interactions. # https://github.com/pact-foundation/pact-python/issues/451 if kind == "HTTP": return pact.v3.ffi.pact_handle_get_sync_http_iter(self._handle) @@ -1218,6 +1221,30 @@ def write_file( ) +class MismatchesError(Exception): + """ + Exception raised when there are mismatches between the Pact and the server. + """ + + def __init__(self, mismatches: list[dict[str, Any]]) -> None: + """ + Initialise a new MismatchesError. + + Args: + mismatches: + Mismatches between the Pact and the server. + """ + super().__init__(f"Mismatched interaction (count: {len(mismatches)})") + self._mismatches = mismatches + + @property + def mismatches(self) -> list[dict[str, Any]]: + """ + Mismatches between the Pact and the server. + """ + return self._mismatches + + class PactServer: """ Pact Server. @@ -1234,6 +1261,8 @@ def __init__( # noqa: PLR0913 port: int = 0, transport: str = "HTTP", transport_config: str | None = None, + *, + raises: bool = True, ) -> None: """ Initialise a new Pact Server. @@ -1267,8 +1296,8 @@ def __init__( # noqa: PLR0913 Configuration for the transport. This is specific to the transport being used and should be a JSON string. - raises: Whether or not to raise an exception if the server is not - matched upon exit. + raises: Whether or not to raise an exception if the server + is not matched upon exit. """ self._host = host self._port = port @@ -1276,6 +1305,7 @@ def __init__( # noqa: PLR0913 self._transport_config = transport_config self._pact_handle = pact_handle self._handle: None | pact.v3.ffi.PactServerHandle = None + self._raises = raises @property def port(self) -> int: @@ -1310,6 +1340,49 @@ def url(self) -> URL: """ return URL(str(self)) + @property + def matched(self) -> bool: + """ + Whether or not the server has been matched. + + This is `True` if the server has been matched, and `False` otherwise. + """ + if not self._handle: + msg = "The server is not running." + raise RuntimeError(msg) + return pact.v3.ffi.mock_server_matched(self._handle) + + @property + def mismatches(self) -> list[dict[str, Any]]: + """ + Mismatches between the Pact and the server. + + This is a string containing the mismatches between the Pact and the + server. If there are no mismatches, then this is an empty string. + """ + if not self._handle: + msg = "The server is not running." + raise RuntimeError(msg) + return pact.v3.ffi.mock_server_mismatches(self._handle) + + @property + def logs(self) -> str | None: + """ + Logs from the server. + + This is a string containing the logs from the server. If there are no + logs, then this is `None`. For this to be populated, the logging must + be configured to make use of the internal buffer. + """ + if not self._handle: + msg = "The server is not running." + raise RuntimeError(msg) + + try: + return pact.v3.ffi.mock_server_logs(self._handle) + except RuntimeError: + return None + def __str__(self) -> str: """ URL for the server. @@ -1357,11 +1430,18 @@ def __exit__( ) -> None: """ Stop the server. + + Raises: + MismatchesError: + If the server has not been fully matched and the server is + configured to raise an exception. """ if self._handle: + if self._raises and not self.matched: + raise MismatchesError(self.mismatches) self._handle = None - def __truediv__(self, other: str) -> URL: + def __truediv__(self, other: str | object) -> URL: """ URL for the server. """ diff --git a/pyproject.toml b/pyproject.toml index 821f15c93f..1a01027790 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,7 @@ test = [ "mock ~= 5.0", "pytest ~= 7.0", "pytest-asyncio ~= 0.0", + "pytest-bdd ~= 7.0", "pytest-cov ~= 4.0", "testcontainers ~= 3.0", ] @@ -115,7 +116,7 @@ extra-dependencies = ["hatchling", "packaging", "requests", "cffi"] [tool.hatch.envs.default.scripts] lint = ["black --check --diff {args:.}", "ruff {args:.}", "mypy {args:.}"] test = "pytest {args:tests/}" -example = "pytest {args:examples/}" +example = "pytest examples/ {args}" all = ["lint", "test", "example"] # Test environment for running unit tests. This automatically tests against all @@ -128,7 +129,7 @@ python = ["3.8", "3.9", "3.10", "3.11"] [tool.hatch.envs.test.scripts] test = "pytest {args:tests/}" -example = "pytest {args:examples/}" +example = "pytest examples/ {args}" all = ["test", "example"] ################################################################################ @@ -149,6 +150,11 @@ filterwarnings = [ "ignore::PendingDeprecationWarning:tests", ] +markers = [ + # Markers for the compatibility suite + "consumer", +] + ################################################################################ ## Coverage ################################################################################ @@ -176,6 +182,7 @@ ignore = [ "ANN101", # `self` must be typed "ANN102", # `cls` must be typed "FIX002", # Forbid TODO in comments + "TD002", # Assign someone to 'TODO' comments ] # TODO: Remove the explicity extend-exclude once astral-sh/ruff#6262 is fixed. diff --git a/tests/v3/compatiblity_suite/__init__.py b/tests/v3/compatiblity_suite/__init__.py new file mode 100644 index 0000000000..16228019ee --- /dev/null +++ b/tests/v3/compatiblity_suite/__init__.py @@ -0,0 +1,3 @@ +""" +Compatibility suite tests. +""" diff --git a/tests/v3/compatiblity_suite/conftest.py b/tests/v3/compatiblity_suite/conftest.py new file mode 100644 index 0000000000..46d3d33cb5 --- /dev/null +++ b/tests/v3/compatiblity_suite/conftest.py @@ -0,0 +1,30 @@ +""" +Pytest configuration. + +As the compatibility suite makes use of a submodule, we need to make sure the +submodule has been initialized before running the tests. +""" + +import shutil +import subprocess +from pathlib import Path + +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def _submodule_init() -> None: + """Initialize the submodule.""" + # Locate the git execute + submodule_dir = Path(__file__).parent / "definition" + if submodule_dir.is_dir(): + return + + git_exec = shutil.which("git") + if git_exec is None: + msg = ( + "Submodule not initialized and git executable not found." + " Please initialize the submodule with `git submodule init`." + ) + raise RuntimeError(msg) + subprocess.check_call([git_exec, "submodule", "init"]) # noqa: S603 diff --git a/tests/v3/compatiblity_suite/definition b/tests/v3/compatiblity_suite/definition new file mode 160000 index 0000000000..d22d4667c0 --- /dev/null +++ b/tests/v3/compatiblity_suite/definition @@ -0,0 +1 @@ +Subproject commit d22d4667c0bda76d408676044cb33db834e7167e diff --git a/tests/v3/compatiblity_suite/test_v1_consumer.py b/tests/v3/compatiblity_suite/test_v1_consumer.py new file mode 100644 index 0000000000..9807f67cf0 --- /dev/null +++ b/tests/v3/compatiblity_suite/test_v1_consumer.py @@ -0,0 +1,928 @@ +"""Basic HTTP consumer feature tests.""" + +from __future__ import annotations + +import json +import logging +import re +from typing import TYPE_CHECKING, Any, Generator + +import pytest +import requests +from pact.v3 import Pact +from pytest_bdd import given, parsers, scenario, then, when +from yarl import URL + +from .util import ( # type: ignore[import-untyped] + FIXTURES_ROOT, + InteractionDefinition, + string_to_int, + truncate, +) + +if TYPE_CHECKING: + from pathlib import Path + + from pact.v3.pact import PactServer + +logger = logging.getLogger(__name__) + +################################################################################ +## Scenario +################################################################################ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "When all requests are made to the mock server", +) +def test_when_all_requests_are_made_to_the_mock_server() -> None: + """ + When all requests are made to the mock server. + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "When not all requests are made to the mock server", +) +def test_when_not_all_requests_are_made_to_the_mock_server() -> None: + """ + When not all requests are made to the mock server. + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "When an unexpected request is made to the mock server", +) +def test_when_an_unexpected_request_is_made_to_the_mock_server() -> None: + """ + When an unexpected request is made to the mock server. + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with query parameters", +) +def test_request_with_query_parameters() -> None: + """ + Request with query parameters. + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with invalid query parameters", +) +def test_request_with_invalid_query_parameters() -> None: + """ + Request with invalid query parameters. + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with invalid path", +) +def test_request_with_invalid_path() -> None: + """ + Request with invalid path. + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with invalid method", +) +def test_request_with_invalid_method() -> None: + """ + Request with invalid method. + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with headers", +) +def test_request_with_headers() -> None: + """ + Request with headers. + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with invalid headers", +) +def test_request_with_invalid_headers() -> None: + """ + Request with invalid headers. + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with body", +) +def test_request_with_body() -> None: + """ + Request with body. + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with invalid body", +) +def test_request_with_invalid_body() -> None: + """ + Request with invalid body. + """ + + +# TODO: Enable this test when the upstream issue is resolved: +# https://github.com/pact-foundation/pact-compatibility-suite/issues/3 +@pytest.mark.skip("Waiting on upstream fix") +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with the incorrect type of body contents", +) +def test_request_with_the_incorrect_type_of_body_contents() -> None: + """ + Request with the incorrect type of body contents. + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with plain text body (positive case)", +) +def test_request_with_plain_text_body_positive_case() -> None: + """ + Request with plain text body (positive case). + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with plain text body (negative case)", +) +def test_request_with_plain_text_body_negative_case() -> None: + """ + Request with plain text body (negative case). + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with JSON body (positive case)", +) +def test_request_with_json_body_positive_case() -> None: + """ + Request with JSON body (positive case). + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with JSON body (negative case)", +) +def test_request_with_json_body_negative_case() -> None: + """ + Request with JSON body (negative case). + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with XML body (positive case)", +) +def test_request_with_xml_body_positive_case() -> None: + """ + Request with XML body (positive case). + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with XML body (negative case)", +) +def test_request_with_xml_body_negative_case() -> None: + """ + Request with XML body (negative case). + """ + + +# TODO: Enable this test when the upstream issue is resolved: +# https://github.com/pact-foundation/pact-reference/issues/336 +@pytest.mark.skip("Waiting on upstream fix") +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with a binary body (positive case)", +) +def test_request_with_a_binary_body_positive_case() -> None: + """ + Request with a binary body (positive case). + """ + + +# TODO: Enable this test when the upstream issue is resolved: +# https://github.com/pact-foundation/pact-reference/issues/336 +@pytest.mark.skip("Waiting on upstream fix") +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with a binary body (negative case)", +) +def test_request_with_a_binary_body_negative_case() -> None: + """ + Request with a binary body (negative case). + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with a form post body (positive case)", +) +def test_request_with_a_form_post_body_positive_case() -> None: + """ + Request with a form post body (positive case). + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with a form post body (negative case)", +) +def test_request_with_a_form_post_body_negative_case() -> None: + """ + Request with a form post body (negative case). + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with a multipart body (positive case)", +) +def test_request_with_a_multipart_body_positive_case() -> None: + """ + Request with a multipart body (positive case). + """ + + +@scenario( + "definition/features/V1/http_consumer.feature", + "Request with a multipart body (negative case)", +) +def test_request_with_a_multipart_body_negative_case() -> None: + """ + Request with a multipart body (negative case). + """ + + +################################################################################ +## Given +################################################################################ + + +@given( + parsers.parse("the following HTTP interactions have been defined:\n{content}"), + target_fixture="interaction_definitions", +) +def the_following_http_interactions_have_been_defined( + content: str, +) -> dict[int, InteractionDefinition]: + """ + Parse the HTTP interactions table into a dictionary. + + The table columns are expected to be: + + - No + - method + - path + - query + - headers + - body + - response + - response content + - response body + + The first row is ignored, as it is assumed to be the column headers. The + order of the columns is similarly ignored. + """ + rows = [ + list(map(str.strip, row.split("|")))[1:-1] + for row in content.split("\n") + if row.strip() + ] + + # Check that the table is well-formed + assert len(rows[0]) == 9 + assert rows[0][0] == "No" + + # Parse the table into a more useful format + interactions: dict[int, InteractionDefinition] = {} + for row in rows[1:]: + interactions[int(row[0])] = InteractionDefinition(**dict(zip(rows[0], row))) + return interactions + + +################################################################################ +## When +################################################################################ + + +@when( + parsers.re( + r"the mock server is started" + r" with interactions?" + r' "?(?P((\d+)(,\s)?)+)"?', + ), + converters={"ids": lambda s: list(map(int, s.split(",")))}, + target_fixture="srv", +) +def the_mock_server_is_started_with_interactions( # noqa: C901 + ids: list[int], + interaction_definitions: dict[int, InteractionDefinition], +) -> Generator[PactServer, Any, None]: + """The mock server is started with interactions.""" + pact = Pact("consumer", "provider") + pact.with_specification("V1") + for iid in ids: + definition = interaction_definitions[iid] + logging.info("Adding interaction %s", iid) + + interaction = pact.upon_receiving(f"interactions {iid}") + logging.info("-> with_request(%s, %s)", definition.method, definition.path) + interaction.with_request(definition.method, definition.path) + + if definition.query: + query = URL.build(query_string=definition.query).query + logging.info("-> with_query_parameters(%s)", query.items()) + interaction.with_query_parameters(query.items()) + + if definition.headers: + logging.info("-> with_headers(%s)", definition.headers.items()) + interaction.with_headers(definition.headers.items()) + + if definition.body: + if definition.body.string: + logging.info( + "-> with_body(%s, %s)", + truncate(definition.body.string), + definition.body.mime_type, + ) + interaction.with_body( + definition.body.string, + definition.body.mime_type, + ) + elif definition.body.bytes: + logging.info( + "-> with_binary_file(%s, %s)", + truncate(definition.body.bytes), + definition.body.mime_type, + ) + interaction.with_binary_body( + definition.body.bytes, + definition.body.mime_type, + ) + else: + msg = "Unexpected body definition" + raise RuntimeError(msg) + + logging.info("-> will_respond_with(%s)", definition.response) + interaction.will_respond_with(definition.response) + + if definition.response_content: + if definition.response_body is None: + msg = "Expected response body along with response content type" + raise ValueError(msg) + + if definition.response_body.string: + logging.info( + "-> with_body(%s, %s)", + truncate(definition.response_body.string), + definition.response_content, + ) + interaction.with_body( + definition.response_body.string, + definition.response_content, + ) + elif definition.response_body.bytes: + logging.info( + "-> with_binary_file(%s, %s)", + truncate(definition.response_body.bytes), + definition.response_content, + ) + interaction.with_binary_body( + definition.response_body.bytes, + definition.response_content, + ) + else: + msg = "Unexpected body definition" + raise RuntimeError(msg) + + with pact.serve(raises=False) as srv: + yield srv + + +@when( + parsers.re( + r"request (?P\d+) is made to the mock server", + ), + converters={"request_id": int}, + target_fixture="response", +) +def request_n_is_made_to_the_mock_server( + interaction_definitions: dict[int, InteractionDefinition], + request_id: int, + srv: PactServer, +) -> requests.Response: + """ + Request n is made to the mock server. + """ + definition = interaction_definitions[request_id] + if ( + definition.body + and definition.body.mime_type + and "Content-Type" not in definition.headers + ): + definition.headers.add("Content-Type", definition.body.mime_type) + + return requests.request( + definition.method, + str(srv.url.with_path(definition.path)), + params=URL.build(query_string=definition.query).query + if definition.query + else None, + headers=definition.headers if definition.headers else None, # type: ignore[arg-type] + data=definition.body.bytes if definition.body else None, + ) + + +@when( + parsers.re( + r"request (?P\d+) is made to the mock server" + r" with the following changes?:\n(?P.*)", + re.DOTALL, + ), + converters={"request_id": int}, + target_fixture="response", +) +def request_n_is_made_to_the_mock_server_with_the_following_changes( + interaction_definitions: dict[int, InteractionDefinition], + request_id: int, + content: str, + srv: PactServer, +) -> requests.Response: + """ + Request n is made to the mock server with changes. + + The content is a markdown table with a subset of the columns defining the + definition (as in the given step). + """ + definition = interaction_definitions[request_id] + rows = [ + list(map(str.strip, row.split("|")))[1:-1] + for row in content.split("\n") + if row.strip() + ] + assert len(rows) == 2, "Expected two rows in the table" + updates = dict(zip(rows[0], rows[1])) + definition.update(**updates) + + if ( + definition.body + and definition.body.mime_type + and "Content-Type" not in definition.headers + ): + definition.headers.add("Content-Type", definition.body.mime_type) + + return requests.request( + definition.method, + str(srv.url.with_path(definition.path)), + params=URL.build(query_string=definition.query).query + if definition.query + else None, + headers=definition.headers if definition.headers else None, # type: ignore[arg-type] + data=definition.body.bytes if definition.body else None, + ) + + +################################################################################ +## Then +################################################################################ + + +@then( + parsers.re( + r"a (?P\d+) (success|error) response is returned", + ), + converters={"code": int}, +) +def a_response_is_returned( + response: requests.Response, + code: int, + srv: PactServer, +) -> None: + """ + A response is returned. + """ + logging.info("Request Information:") + logging.info("-> Method: %s", response.request.method) + logging.info("-> URL: %s", response.request.url) + logging.info( + "-> Headers: %s", + json.dumps( + dict(**response.request.headers), + indent=2, + ), + ) + logging.info( + "-> Body: %s", + truncate(response.request.body) if response.request.body else None, + ) + logging.info("Mismatches:\n%s", json.dumps(srv.mismatches, indent=2)) + assert response.status_code == code + + +@then( + parsers.re( + r'the payload will contain the "(?P[^"]+)" JSON document', + ), +) +def the_payload_will_contain_the_json_document( + response: requests.Response, + file: str, +) -> None: + """ + The payload will contain the JSON document. + """ + path = FIXTURES_ROOT / f"{file}.json" + assert response.json() == json.loads(path.read_text()) + + +@then( + parsers.re( + r'the content type will be set as "(?P[^"]+)"', + ), +) +def the_content_type_will_be_set_as( + response: requests.Response, + content_type: str, +) -> None: + assert response.headers["Content-Type"] == content_type + + +@when("the pact test is done") +def the_pact_test_is_done() -> None: + """ + The pact test is done. + """ + + +@then( + parsers.re(r"the mock server status will (?P(NOT )?)be OK"), + converters={"negated": lambda s: s == "NOT "}, +) +def the_mock_server_status_will_be( + srv: PactServer, + negated: bool, # noqa: FBT001 +) -> None: + """ + The mock server status will be. + """ + assert srv.matched is not negated + + +@then( + parsers.re( + r"the mock server status will be" + r" an expected but not received error" + r" for interaction \{(?P\d+)\}", + ), + converters={"n": int}, +) +def the_mock_server_status_will_be_an_expected_but_not_received_error_for_interaction_n( + srv: PactServer, + n: int, + interaction_definitions: dict[int, InteractionDefinition], +) -> None: + """ + The mock server status will be an expected but not received error for interaction n. + """ + assert srv.matched is False + assert len(srv.mismatches) > 0 + + for mismatch in srv.mismatches: + if ( + mismatch["method"] == interaction_definitions[n].method + and mismatch["path"] == interaction_definitions[n].path + and mismatch["type"] == "missing-request" + ): + return + pytest.fail("Expected mismatch not found") + + +@then( + parsers.re( + r"the mock server status will be" + r' an unexpected "(?P[^"]+)" request received error' + r" for interaction \{(?P\d+)\}", + ), + converters={"n": int}, +) +def the_mock_server_status_will_be_an_unexpected_request_received_for_interaction_n( + srv: PactServer, + method: str, + n: int, + interaction_definitions: dict[int, InteractionDefinition], +) -> None: + """ + The mock server status will be an expected but not received error for interaction n. + """ + assert srv.matched is False + assert len(srv.mismatches) > 0 + + for mismatch in srv.mismatches: + if ( + mismatch["method"] == interaction_definitions[n].method + and mismatch["request"]["method"] == method + and mismatch["path"] == interaction_definitions[n].path + and mismatch["type"] == "request-not-found" + ): + return + pytest.fail("Expected mismatch not found") + + +@then( + parsers.re( + r"the mock server status will be" + r' an unexpected "(?P[^"]+)" request received error' + r' for path "(?P[^"]+)"', + ), + converters={"n": int}, +) +def the_mock_server_status_will_be_an_unexpected_request_received_for_path( + srv: PactServer, + method: str, + path: str, +) -> None: + """ + The mock server status will be an expected but not received error for interaction n. + """ + assert srv.matched is False + assert len(srv.mismatches) > 0 + + for mismatch in srv.mismatches: + if ( + mismatch["request"]["method"] == method + and mismatch["path"] == path + and mismatch["type"] == "request-not-found" + ): + return + pytest.fail("Expected mismatch not found") + + +@then("the mock server status will be mismatches") +def the_mock_server_status_will_be_mismatches( + srv: PactServer, +) -> None: + """ + The mock server status will be mismatches. + """ + assert srv.matched is False + assert len(srv.mismatches) > 0 + + +@then( + parsers.re( + r'the mismatches will contain a "(?P[^"]+)" mismatch' + r' with error "(?P[^"]+)"', + ), +) +def the_mismatches_will_contain_a_mismatch_with_the_error( + srv: PactServer, + mismatch_type: str, + error: str, +) -> None: + """ + The mismatches will contain a mismatch with the error. + """ + if mismatch_type == "query": + mismatch_type = "QueryMismatch" + elif mismatch_type == "header": + mismatch_type = "HeaderMismatch" + elif mismatch_type == "body": + mismatch_type = "BodyMismatch" + elif mismatch_type == "body-content-type": + mismatch_type = "BodyTypeMismatch" + else: + msg = f"Unexpected mismatch type: {mismatch_type}" + raise ValueError(msg) + + logger.info("Expecting mismatch: %s", mismatch_type) + logger.info("With error: %s", error) + for mismatch in srv.mismatches: + for sub_mismatch in mismatch["mismatches"]: + if ( + error in sub_mismatch["mismatch"] + and sub_mismatch["type"] == mismatch_type + ): + return + pytest.fail("Expected mismatch not found") + + +@then( + parsers.re( + r'the mismatches will contain a "(?P[^"]+)" mismatch' + r' with path "(?P[^"]+)"' + r' with error "(?P[^"]+)"', + ), +) +def the_mismatches_will_contain_a_mismatch_with_path_with_the_error( + srv: PactServer, + mismatch_type: str, + path: str, + error: str, +) -> None: + """ + The mismatches will contain a mismatch with the error. + """ + mismatch_type = "BodyMismatch" if mismatch_type == "body" else mismatch_type + for mismatch in srv.mismatches: + for sub_mismatch in mismatch["mismatches"]: + if ( + sub_mismatch["mismatch"] == error + and sub_mismatch["type"] == mismatch_type + and sub_mismatch["path"] == path + ): + return + pytest.fail("Expected mismatch not found") + + +@then( + parsers.re( + r"the mock server will (?P(NOT )?)write out" + r" a Pact file for the interactions? when done", + ), + converters={"negated": lambda s: s == "NOT "}, + target_fixture="pact_file", +) +def the_mock_server_will_write_out_a_pact_file_for_the_interaction_when_done( + srv: PactServer, + temp_dir: Path, + negated: bool, # noqa: FBT001 +) -> dict[str, Any] | None: + """ + The mock server will write out a Pact file for the interaction when done. + """ + if not negated: + srv.write_file(temp_dir) + output = temp_dir / "consumer-provider.json" + assert output.is_file() + return json.load(output.open()) + return None + + +@then( + parsers.re(r"the pact file will contain \{(?P\d+)\} interactions?"), + converters={"n": int}, +) +def the_pact_file_will_contain_n_interactions( + pact_file: dict[str, Any], + n: int, +) -> None: + """ + The pact file will contain n interactions. + """ + assert len(pact_file["interactions"]) == n + + +@then( + parsers.re( + r"the \{(?P\w+)\} interaction response" + r' will contain the "(?P[^"]+)" document', + ), + converters={"n": string_to_int}, +) +def the_nth_interaction_will_contain_the_document( + pact_file: dict[str, Any], + n: int, + file: str, +) -> None: + """ + The nth interaction response will contain the document. + """ + file_path = FIXTURES_ROOT / file + if file.endswith(".json"): + assert pact_file["interactions"][n - 1]["response"]["body"] == json.load( + file_path.open(), + ) + + +@then( + parsers.re( + r'the \{(?P\w+)\} interaction request will be for a "(?P[A-Z]+)"', + ), + converters={"n": string_to_int}, +) +def the_nth_interaction_request_will_be_for_method( + pact_file: dict[str, Any], + n: int, + method: str, +) -> None: + """ + The nth interaction request will be for a method. + """ + assert pact_file["interactions"][n - 1]["request"]["method"] == method + + +@then( + parsers.re( + r"the \{(?P\w+)\} interaction request" + r' query parameters will be "(?P[^"]+)"', + ), + converters={"n": string_to_int}, +) +def the_nth_interaction_request_query_parameters_will_be( + pact_file: dict[str, Any], + n: int, + query: str, +) -> None: + """ + The nth interaction request query parameters will be. + """ + assert query == pact_file["interactions"][n - 1]["request"]["query"] + + +@then( + parsers.re( + r"the \{(?P\w+)\} interaction request" + r' will contain the header "(?P[^"]+)"' + r' with value "(?P[^"]+)"', + ), + converters={"n": string_to_int}, +) +def the_nth_interaction_request_will_contain_the_header( + pact_file: dict[str, Any], + n: int, + key: str, + value: str, +) -> None: + """ + The nth interaction request will contain the header. + """ + expected = {key: value} + actual = pact_file["interactions"][n - 1]["request"]["headers"] + assert expected.keys() == actual.keys() + for key in expected: + assert expected[key] == actual[key] or [expected[key]] == actual[key] + + +@then( + parsers.re( + r"the \{(?P\w+)\} interaction request" + r' content type will be "(?P[^"]+)"', + ), + converters={"n": string_to_int}, +) +def the_nth_interaction_request_content_type_will_be( + pact_file: dict[str, Any], + n: int, + content_type: str, +) -> None: + """ + The nth interaction request will contain the header. + """ + assert ( + pact_file["interactions"][n - 1]["request"]["headers"]["Content-Type"] + == content_type + ) + + +@then( + parsers.re( + r"the \{(?P\w+)\} interaction request" + r' will contain the "(?P[^"]+)" document', + ), + converters={"n": string_to_int}, +) +def the_nth_interaction_request_will_contain_the_document( + pact_file: dict[str, Any], + n: int, + file: str, +) -> None: + """ + The nth interaction request will contain the document. + """ + file_path = FIXTURES_ROOT / file + if file.endswith(".json"): + assert pact_file["interactions"][n - 1]["request"]["body"] == json.load( + file_path.open(), + ) + else: + assert ( + pact_file["interactions"][n - 1]["request"]["body"] == file_path.read_text() + ) diff --git a/tests/v3/compatiblity_suite/util.py b/tests/v3/compatiblity_suite/util.py new file mode 100644 index 0000000000..53be2902cc --- /dev/null +++ b/tests/v3/compatiblity_suite/util.py @@ -0,0 +1,301 @@ +""" +Utility functions to help with testing. +""" + +from __future__ import annotations + +import contextlib +import hashlib +import logging +import typing +from pathlib import Path +from xml.etree import ElementTree + +from multidict import MultiDict + +logger = logging.getLogger(__name__) +SUITE_ROOT = Path(__file__).parent / "definition" +FIXTURES_ROOT = SUITE_ROOT / "fixtures" + + +def string_to_int(word: str) -> int: + """ + Convert a word to an integer. + + The word can be a number, or a word representing a number. + + Args: + word: The word to convert. + + Returns: + The integer value of the word. + + Raises: + ValueError: If the word cannot be converted to an integer. + """ + try: + return int(word) + except ValueError: + pass + + try: + return { + "first": 1, + "second": 2, + "third": 3, + "fourth": 4, + "fifth": 5, + "sixth": 6, + "seventh": 7, + "eighth": 8, + "ninth": 9, + "1st": 1, + "2nd": 2, + "3rd": 3, + "4th": 4, + "5th": 5, + "6th": 6, + "7th": 7, + "8th": 8, + "9th": 9, + }[word] + except KeyError: + pass + + msg = f"Unable to convert {word!r} to an integer" + raise ValueError(msg) + + +def truncate(data: str | bytes) -> str: + """ + Truncate a large string or bytes object. + + This is useful for printing large strings or bytes objects in tests. + """ + if len(data) <= 32: + if isinstance(data, str): + return f"{data!r}" + return data.decode("utf-8", "backslashreplace") + + length = len(data) + if isinstance(data, str): + checksum = hashlib.sha256(data.encode()).hexdigest() + return ( + '"' + + data[:6] + + "⋯" + + data[-6:] + + '"' + + f" ({length} bytes, sha256={checksum[:7]})" + ) + + checksum = hashlib.sha256(data).hexdigest() + return ( + 'b"' + + data[:8].decode("utf-8", "backslashreplace") + + "⋯" + + data[-8:].decode("utf-8", "backslashreplace") + + '"' + + f" ({length} bytes, sha256={checksum[:7]})" + ) + + +class InteractionDefinition: + """ + Interaction definition. + + This is a dictionary that represents a single interaction. It is used to + parse the HTTP interactions table into a more useful format. + """ + + class Body: + """ + Interaction body. + + The interaction body can be one of: + + - A file + - An arbitrary string + - A JSON document + - An XML document + """ + + def __init__(self, data: str) -> None: + """ + Instantiate the interaction body. + """ + self.string: str | None = None + self.bytes: bytes | None = None + self.mime_type: str | None = None + + if data.startswith("file: ") and data.endswith("-body.xml"): + self.parse_fixture(FIXTURES_ROOT / data[6:]) + return + + if data.startswith("file: "): + self.parse_file(FIXTURES_ROOT / data[6:]) + return + + if data.startswith("JSON: "): + self.string = data[6:] + self.bytes = self.string.encode("utf-8") + self.mime_type = "application/json" + return + + if data.startswith("XML: "): + self.string = data[5:] + self.bytes = self.string.encode("utf-8") + self.mime_type = "application/xml" + return + + self.bytes = data.encode("utf-8") + self.string = data + self.mime_type = "text/plain" + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return "".format( + ", ".join(truncate(f"{k}={v!r}") for k, v in vars(self).items()), + ) + + def parse_fixture(self, fixture: Path) -> None: + """ + Parse a fixture file. + + This is used to parse the fixture files that contain additional + metadata about the body (such as the content type). + """ + etree = ElementTree.parse(fixture) # noqa: S314 + root = etree.getroot() + if not root or root.tag != "body": + msg = "Invalid XML fixture document" + raise ValueError(msg) + + contents = root.find("contents") + content_type = root.find("contentType") + if contents is None: + msg = "Invalid XML fixture document: no contents" + raise ValueError(msg) + if content_type is None: + msg = "Invalid XML fixture document: no contentType" + raise ValueError(msg) + self.string = typing.cast(str, contents.text) + + if eol := contents.attrib.get("eol", None): + if eol == "CRLF": + self.string = self.string.replace("\r\n", "\n") + self.string = self.string.replace("\n", "\r\n") + elif eol == "LF": + self.string = self.string.replace("\r\n", "\n") + + self.bytes = self.string.encode("utf-8") + self.mime_type = content_type.text + + def parse_file(self, file: Path) -> None: + """ + Load the contents of a file. + + The mime type is inferred from the file extension, and the contents + are loaded as a byte array, and optionally as a string. + """ + self.bytes = file.read_bytes() + with contextlib.suppress(UnicodeDecodeError): + self.string = file.read_text() + + if file.suffix == ".xml": + self.mime_type = "application/xml" + elif file.suffix == ".json": + self.mime_type = "application/json" + elif file.suffix == ".jpg": + self.mime_type = "image/jpeg" + elif file.suffix == ".pdf": + self.mime_type = "application/pdf" + else: + msg = "Unknown file type" + raise ValueError(msg) + + def __init__(self, **kwargs: str) -> None: + """Initialise the interaction definition.""" + self.id: int | None = None + self.method: str = kwargs.pop("method") + self.path: str = kwargs.pop("path") + self.response: int = int(kwargs.pop("response")) + self.query: str | None = None + self.headers: MultiDict[str] = MultiDict() + self.body: InteractionDefinition.Body | None = None + self.response_content: str | None = None + self.response_body: InteractionDefinition.Body | None = None + self.update(**kwargs) + + def update(self, **kwargs: str) -> None: # noqa: C901 + """ + Update the interaction definition. + + This is a convenience method that allows the interaction definition to + be updated with new values. + """ + if interaction_id := kwargs.pop("No", None): + self.id = int(interaction_id) + if method := kwargs.pop("method", None): + self.method = method + if path := kwargs.pop("path", None): + self.path = path + if query := kwargs.pop("query", None): + self.query = query + if headers := kwargs.pop("headers", None): + self.headers = InteractionDefinition.parse_headers(headers) + if body := kwargs.pop("body", None): + # When updating the body, we _only_ update the body content, not + # the content type. + orig_content_type = self.body.mime_type if self.body else None + self.body = InteractionDefinition.Body(body) + self.body.mime_type = orig_content_type or self.body.mime_type + if content_type := ( + kwargs.pop("content_type", None) or kwargs.pop("content type", None) + ): + if self.body is None: + self.body = InteractionDefinition.Body("") + self.body.mime_type = content_type + if response := kwargs.pop("response", None): + self.response = int(response) + if response_content := ( + kwargs.pop("response_content", None) or kwargs.pop("response content", None) + ): + self.response_content = response_content + if response_body := ( + kwargs.pop("response_body", None) or kwargs.pop("response body", None) + ): + self.response_body = InteractionDefinition.Body(response_body) + + if len(kwargs) > 0: + msg = f"Unexpected arguments: {kwargs.keys()}" + raise TypeError(msg) + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return "".format( + ", ".join(f"{k}={v!r}" for k, v in vars(self).items()), + ) + + @staticmethod + def parse_headers(headers: str) -> MultiDict[str]: + """ + Parse the headers. + + The headers are in the format: + + ```text + 'X-A: 1', 'X-B: 2', 'X-A: 3' + ``` + + As headers can be repeated, the result is a MultiDict. + """ + kvs: list[tuple[str, str]] = [] + for header in headers.split(", "): + k, v = header.strip("'").split(": ") + kvs.append((k, v)) + return MultiDict(kvs) diff --git a/tests/v3/test_http_interaction.py b/tests/v3/test_http_interaction.py index fa5d6cbe09..51a6134d04 100644 --- a/tests/v3/test_http_interaction.py +++ b/tests/v3/test_http_interaction.py @@ -60,12 +60,16 @@ async def test_basic_request_method(pact: Pact, method: str) -> None: .with_request(method, "/") .will_respond_with(200) ) - with pact.serve() as srv: + with pact.serve(raises=False) as srv: async with aiohttp.ClientSession(srv.url) as session: for m in ALL_HTTP_METHODS: async with session.request(m, "/") as resp: assert resp.status == (200 if m == method else 500) + # As we are making unexpected requests, we should have mismatches + for mismatch in srv.mismatches: + assert mismatch["type"] == "request-not-found" + @pytest.mark.parametrize( "status", @@ -192,10 +196,16 @@ async def test_set_header_request_repeat( .set_headers(headers) .will_respond_with(200) ) - with pact.serve() as srv: - async with aiohttp.ClientSession(srv.url) as session: - async with session.request("GET", "/", headers=headers) as resp: - assert resp.status == 500 + with pact.serve(raises=False) as srv: + async with aiohttp.ClientSession(srv.url) as session, session.request( + "GET", + "/", + headers=headers, + ) as resp: + assert resp.status == 500 + + assert len(srv.mismatches) == 1 + assert srv.mismatches[0]["type"] == "request-mismatch" @pytest.mark.parametrize( @@ -425,7 +435,7 @@ async def test_binary_file_request(pact: Pact) -> None: ( pact.upon_receiving("a basic request with a binary file") .with_request("POST", "/") - .with_binary_file(payload, "application/octet-stream") + .with_binary_body(payload, "application/octet-stream") .will_respond_with(200) ) with pact.serve() as srv: @@ -446,7 +456,7 @@ async def test_binary_file_response(pact: Pact) -> None: pact.upon_receiving("a basic request with a binary file response") .with_request("GET", "/") .will_respond_with(200) - .with_binary_file(payload, "application/bytes") + .with_binary_body(payload, "application/bytes") ) with pact.serve() as srv: async with aiohttp.ClientSession(srv.url) as session: @@ -480,13 +490,13 @@ async def test_multipart_file_request(pact: Pact, temp_dir: Path) -> None: with pact.serve() as srv, aiohttp.MultipartWriter() as mpwriter: mpwriter.append( fpy.open("rb"), - # TODO(JP-Ellis): Remove type ignore once aio-libs/aiohttp#7741 is resolved + # TODO: Remove type ignore once aio-libs/aiohttp#7741 is resolved # https://github.com/pact-foundation/pact-python/issues/450 {"Content-Type": "text/x-python"}, # type: ignore[arg-type] ) mpwriter.append( fpng.open("rb"), - # TODO(JP-Ellis): Remove type ignore once aio-libs/aiohttp#7741 is resolved + # TODO: Remove type ignore once aio-libs/aiohttp#7741 is resolved # https://github.com/pact-foundation/pact-python/issues/450 {"Content-Type": "image/png"}, # type: ignore[arg-type] ) @@ -517,7 +527,7 @@ async def test_name(pact: Pact) -> None: async def test_with_plugin(pact: Pact) -> None: ( pact.upon_receiving("a basic request with a plugin") - .with_plugin_contents("{}") + .with_plugin_contents("{}", "application/json") .will_respond_with(200) ) with pact.serve() as srv: diff --git a/tests/v3/test_pact.py b/tests/v3/test_pact.py index 64fd520a60..e919e760a4 100644 --- a/tests/v3/test_pact.py +++ b/tests/v3/test_pact.py @@ -130,3 +130,8 @@ def test_write_file(pact: Pact, temp_dir: Path) -> None: ) def test_specification(pact: Pact, version: str) -> None: pact.with_specification(version) + + +def test_server_log(pact: Pact) -> None: + with pact.serve() as srv: + assert srv.logs is not None