diff --git a/tests/v3/compatibility_suite/conftest.py b/tests/v3/compatibility_suite/conftest.py index 46d3d33cb5..e5446e1e51 100644 --- a/tests/v3/compatibility_suite/conftest.py +++ b/tests/v3/compatibility_suite/conftest.py @@ -8,8 +8,13 @@ import shutil import subprocess from pathlib import Path +from typing import Any, Generator, Union import pytest +from testcontainers.compose import DockerCompose # type: ignore[import-untyped] +from yarl import URL + +from pact.v3.verifier import Verifier @pytest.fixture(scope="session", autouse=True) @@ -28,3 +33,38 @@ def _submodule_init() -> None: ) raise RuntimeError(msg) subprocess.check_call([git_exec, "submodule", "init"]) # noqa: S603 + + +@pytest.fixture() +def verifier() -> Verifier: + """Return a new Verifier.""" + return Verifier() + + +@pytest.fixture(scope="session") +def broker_url(request: pytest.FixtureRequest) -> Generator[URL, Any, None]: + """ + Fixture to run the Pact broker. + + This inspects whether the `--broker-url` option has been given. If it has, + it is assumed that the broker is already running and simply returns the + given URL. + + Otherwise, the Pact broker is started in a container. The URL of the + containerised broker is then returned. + """ + broker_url: Union[str, None] = request.config.getoption("--broker-url") + + # If we have been given a broker URL, there's nothing more to do here and we + # can return early. + if broker_url: + yield URL(broker_url) + return + + with DockerCompose( + Path(__file__).parent / "util", + compose_file_name="pact-broker.yml", + pull=True, + ) as _: + yield URL("http://pactbroker:pactbroker@localhost:9292") + return diff --git a/tests/v3/compatibility_suite/definition-update.diff b/tests/v3/compatibility_suite/definition-update.diff index 23538b1aba..9b48543044 100644 --- a/tests/v3/compatibility_suite/definition-update.diff +++ b/tests/v3/compatibility_suite/definition-update.diff @@ -77,3 +77,57 @@ index 94fda44..2838116 100644 | file: multipart2-body.xml | And a Pact file for interaction 10 is to be verified When the verification is run +diff --git a/features/V2/http_provider.feature b/features/V2/http_provider.feature +index d51df8b..57c58e7 100644 +--- a/features/V2/http_provider.feature ++++ b/features/V2/http_provider.feature +@@ -10,15 +10,15 @@ Feature: Basic HTTP provider + + Scenario: Supports matching rules for the response headers (positive case) + Given a provider is started that returns the response from interaction 1, with the following changes: +- | headers | +- | 'X-TEST: 1000' | ++ | response headers | ++ | 'X-TEST: 1000' | + And a Pact file for interaction 1 is to be verified + When the verification is run + Then the verification will be successful + + Scenario: Supports matching rules for the response headers (negative case) + Given a provider is started that returns the response from interaction 1, with the following changes: +- | headers | ++ | response headers | + | 'X-TEST: 123ABC' | + And a Pact file for interaction 1 is to be verified + When the verification is run +@@ -27,7 +27,7 @@ Feature: Basic HTTP provider + + Scenario: Verifies the response body (positive case) + Given a provider is started that returns the response from interaction 2, with the following changes: +- | body | ++ | response body | + | JSON: { "one": "100", "two": "b" } | + And a Pact file for interaction 2 is to be verified + When the verification is run +@@ -35,7 +35,7 @@ Feature: Basic HTTP provider + + Scenario: Verifies the response body (negative case) + Given a provider is started that returns the response from interaction 2, with the following changes: +- | body | ++ | response body | + | JSON: { "one": 100, "two": "b" } | + And a Pact file for interaction 2 is to be verified + When the verification is run +diff --git a/features/V4/http_provider.feature b/features/V4/http_provider.feature +index be3d1ff..8e15a13 100644 +--- a/features/V4/http_provider.feature ++++ b/features/V4/http_provider.feature +@@ -9,7 +9,7 @@ Feature: HTTP provider + + Scenario: Verifying a pending HTTP interaction + Given a provider is started that returns the response from interaction 1, with the following changes: +- | body | ++ | response body | + | file: basic2.json | + And a Pact file for interaction 1 is to be verified, but is marked pending + When the verification is run diff --git a/tests/v3/compatibility_suite/test_v1_provider.py b/tests/v3/compatibility_suite/test_v1_provider.py index 880847d1a3..bdcca16fcb 100644 --- a/tests/v3/compatibility_suite/test_v1_provider.py +++ b/tests/v3/compatibility_suite/test_v1_provider.py @@ -4,82 +4,41 @@ from __future__ import annotations -import copy -import json import logging -import pickle -import re -import signal -import subprocess -import sys -import time -from contextvars import ContextVar -from pathlib import Path -from threading import Thread -from typing import Any, Generator, NoReturn, Union import pytest -import requests -from pytest_bdd import given, parsers, scenario, then, when -from testcontainers.compose import DockerCompose # type: ignore[import-untyped] -from yarl import URL +from pytest_bdd import given, parsers, scenario -from pact.v3.pact import Pact -from pact.v3.verifier import Verifier from tests.v3.compatibility_suite.util import ( InteractionDefinition, - parse_headers, parse_markdown_table, ) -from tests.v3.compatibility_suite.util.provider import PactBroker +from tests.v3.compatibility_suite.util.provider import ( + a_failed_verification_result_will_be_published_back, + a_pact_file_for_interaction_is_to_be_verified, + a_pact_file_for_interaction_is_to_be_verified_from_a_pact_broker, + a_pact_file_for_interaction_is_to_be_verified_with_a_provider_state_defined, + a_provider_is_started_that_returns_the_responses_from_interactions, + a_provider_is_started_that_returns_the_responses_from_interactions_with_changes, + a_provider_state_callback_is_configured, + a_request_filter_is_configured_to_make_the_following_changes, + a_successful_verification_result_will_be_published_back, + a_verification_result_will_not_be_published_back, + a_warning_will_be_displayed_that_there_was_no_callback_configured, + publishing_of_verification_results_is_enabled, + reset_broker_var, + the_provider_state_callback_will_be_called_after_the_verification_is_run, + the_provider_state_callback_will_be_called_before_the_verification_is_run, + the_provider_state_callback_will_not_receive_a_setup_call, + the_provider_state_callback_will_receive_a_setup_call, + the_request_to_the_provider_will_contain_the_header, + the_verification_is_run, + the_verification_results_will_contain_a_error, + the_verification_will_be_successful, +) logger = logging.getLogger(__name__) -reset_broker_var = ContextVar("reset_broker", default=True) -""" -This context variable is used to determine whether the Pact broker should be -cleaned up. It is used to ensure that the broker is only cleaned up once, even -if a step is run multiple times. - -All scenarios which make use of the Pact broker should set this to `True` at the -start of the scenario. -""" - - -@pytest.fixture() -def verifier() -> Verifier: - """Return a new Verifier.""" - return Verifier() - - -@pytest.fixture(scope="session") -def broker_url(request: pytest.FixtureRequest) -> Generator[URL, Any, None]: - """ - Fixture to run the Pact broker. - - This inspects whether the `--broker-url` option has been given. If it has, - it is assumed that the broker is already running and simply returns the - given URL. - - Otherwise, the Pact broker is started in a container. The URL of the - containerised broker is then returned. - """ - broker_url: Union[str, None] = request.config.getoption("--broker-url") - - # If we have been given a broker URL, there's nothing more to do here and we - # can return early. - if broker_url: - yield URL(broker_url) - return - - with DockerCompose( - Path(__file__).parent / "util", - compose_file_name="pact-broker.yml", - pull=True, - ) as _: - yield URL("http://pactbroker:pactbroker@localhost:9292") - return - ################################################################################ ## Scenario @@ -347,311 +306,14 @@ def the_following_http_interactions_have_been_defined( return interactions -@given( - parsers.re( - r"a provider is started that returns the responses? " - r'from interactions? "?(?P[0-9, ]+)"?', - ), - converters={"interactions": lambda x: [int(i) for i in x.split(",") if i]}, - target_fixture="provider_url", -) -def a_provider_is_started_that_returns_the_responses_from_interactions( - interaction_definitions: dict[int, InteractionDefinition], - interactions: list[int], - temp_dir: Path, -) -> Generator[URL, None, None]: - """ - Start a provider that returns the responses from the given interactions. - """ - logger.debug("Starting provider for interactions %s", interactions) - - for i in interactions: - logger.debug("Interaction %d: %s", i, interaction_definitions[i]) - - with (temp_dir / "interactions.pkl").open("wb") as pkl_file: - pickle.dump([interaction_definitions[i] for i in interactions], pkl_file) - - yield from start_provider(temp_dir) - - -@given( - parsers.re( - r"a provider is started that returns the responses?" - r' from interactions? "?(?P[0-9, ]+)"?' - r" with the following changes:\n(?P.+)", - re.DOTALL, - ), - converters={ - "interactions": lambda x: [int(i) for i in x.split(",") if i], - "changes": parse_markdown_table, - }, - target_fixture="provider_url", -) -def a_provider_is_started_that_returns_the_responses_from_interactions_with_changes( - interaction_definitions: dict[int, InteractionDefinition], - interactions: list[int], - changes: list[dict[str, str]], - temp_dir: Path, -) -> Generator[URL, None, None]: - """ - Start a provider that returns the responses from the given interactions. - """ - logger.debug("Starting provider for interactions %s", interactions) - - assert len(changes) == 1, "Only one set of changes is supported" - defns: list[InteractionDefinition] = [] - for interaction in interactions: - defn = copy.deepcopy(interaction_definitions[interaction]) - defn.update(**changes[0]) - defns.append(defn) - logger.debug( - "Update interaction %d: %s", - interaction, - defn, - ) - - with (temp_dir / "interactions.pkl").open("wb") as pkl_file: - pickle.dump(defns, pkl_file) - - yield from start_provider(temp_dir) - - -def start_provider(provider_dir: str | Path) -> Generator[URL, None, None]: # noqa: C901 - """Start the provider app with the given interactions.""" - process = subprocess.Popen( - [ # noqa: S603 - sys.executable, - Path(__file__).parent / "util" / "provider.py", - str(provider_dir), - ], - cwd=Path.cwd(), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - encoding="utf-8", - ) - - pattern = re.compile(r" \* Running on (?P[^ ]+)") - while True: - if process.poll() is not None: - logger.error("Provider process exited with code %d", process.returncode) - logger.error( - "Provider stdout: %s", process.stdout.read() if process.stdout else "" - ) - logger.error( - "Provider stderr: %s", process.stderr.read() if process.stderr else "" - ) - msg = f"Provider process exited with code {process.returncode}" - raise RuntimeError(msg) - if ( - process.stderr - and (line := process.stderr.readline()) - and (match := pattern.match(line)) - ): - break - time.sleep(0.1) - - url = URL(match.group("url")) - logger.debug("Provider started on %s", url) - for _ in range(50): - try: - response = requests.get(str(url / "_test" / "ping"), timeout=1) - assert response.text == "pong" - break - except (requests.RequestException, AssertionError): - time.sleep(0.1) - continue - else: - msg = "Failed to ping provider" - raise RuntimeError(msg) - - def redirect() -> NoReturn: - while True: - if process.stdout: - while line := process.stdout.readline(): - logger.debug("Provider stdout: %s", line.strip()) - if process.stderr: - while line := process.stderr.readline(): - logger.debug("Provider stderr: %s", line.strip()) - - thread = Thread(target=redirect, daemon=True) - thread.start() - - yield url - - process.send_signal(signal.SIGINT) - - -@given( - parsers.re( - r"a Pact file for interaction (?P\d+) is to be verified", - ), - converters={"interaction": int}, -) -def a_pact_file_for_interaction_is_to_be_verified( - interaction_definitions: dict[int, InteractionDefinition], - verifier: Verifier, - interaction: int, - temp_dir: Path, -) -> None: - """ - Verify the Pact file for the given interaction. - """ - logger.debug( - "Adding interaction %d to be verified: %s", - interaction, - interaction_definitions[interaction], - ) - - defn = interaction_definitions[interaction] - - pact = Pact("consumer", "provider") - pact.with_specification("V1") - defn.add_to_pact(pact, f"interaction {interaction}") - (temp_dir / "pacts").mkdir(exist_ok=True, parents=True) - pact.write_file(temp_dir / "pacts") - - verifier.add_source(temp_dir / "pacts") - - -@given( - parsers.re( - r"a Pact file for interaction (?P\d+)" - r" is to be verified from a Pact broker", - ), - converters={"interaction": int}, - target_fixture="pact_broker", -) -def a_pact_file_for_interaction_is_to_be_verified_from_a_pact_broker( - interaction_definitions: dict[int, InteractionDefinition], - broker_url: URL, - verifier: Verifier, - interaction: int, - temp_dir: Path, -) -> Generator[PactBroker, None, None]: - """ - Verify the Pact file for the given interaction from a Pact broker. - """ - logger.debug("Adding interaction %d to be verified from a Pact broker", interaction) - - defn = interaction_definitions[interaction] - - pact = Pact("consumer", "provider") - pact.with_specification("V1") - defn.add_to_pact(pact, f"interaction {interaction}") - - pacts_dir = temp_dir / "pacts" - pacts_dir.mkdir(exist_ok=True, parents=True) - pact.write_file(pacts_dir) - - pact_broker = PactBroker(broker_url) - if reset_broker_var.get(): - logger.debug("Resetting Pact broker") - pact_broker.reset() - reset_broker_var.set(False) # noqa: FBT003 - pact_broker.publish(pacts_dir) - verifier.broker_source(pact_broker.url) - yield pact_broker - - -@given("publishing of verification results is enabled") -def publishing_of_verification_results_is_enabled(verifier: Verifier) -> None: - """ - Enable publishing of verification results. - """ - logger.debug("Publishing verification results") - - verifier.set_publish_options( - "0.0.0", - ) - - -@given( - parsers.re( - r"a provider state callback is configured" - r"(?P(, but will return a failure)?)", - ), - converters={"failure": lambda x: x != ""}, -) -def a_provider_state_callback_is_configured( - verifier: Verifier, - provider_url: URL, - temp_dir: Path, - failure: bool, # noqa: FBT001 -) -> None: - """ - Configure a provider state callback. - """ - logger.debug("Configuring provider state callback") - - if failure: - with (temp_dir / "fail_callback").open("w") as f: - f.write("true") - - verifier.set_state( - provider_url / "_test" / "callback", - teardown=True, - ) - - -@given( - parsers.re( - r"a Pact file for interaction (?P\d+) is to be verified" - r' with a provider state "(?P[^"]+)" defined', - ), - converters={"interaction": int}, -) -def a_pact_file_for_interaction_is_to_be_verified_with_a_provider_state_define( - interaction_definitions: dict[int, InteractionDefinition], - verifier: Verifier, - interaction: int, - state: str, - temp_dir: Path, -) -> None: - """ - Verify the Pact file for the given interaction with a provider state defined. - """ - logger.debug( - "Adding interaction %d to be verified with provider state %s", - interaction, - state, - ) - - defn = interaction_definitions[interaction] - defn.state = state - - pact = Pact("consumer", "provider") - pact.with_specification("V1") - defn.add_to_pact(pact, f"interaction {interaction}") - (temp_dir / "pacts").mkdir(exist_ok=True, parents=True) - pact.write_file(temp_dir / "pacts") - - verifier.add_source(temp_dir / "pacts") - - with (temp_dir / "provider_state").open("w") as f: - logger.debug("Writing provider state to %s", temp_dir / "provider_state") - f.write(state) - - -@given( - parsers.parse( - "a request filter is configured to make the following changes:\n{content}" - ), - converters={"content": parse_markdown_table}, -) -def a_request_filter_is_configured_to_make_the_following_changes( - content: list[dict[str, str]], - verifier: Verifier, -) -> None: - """ - Configure a request filter to make the given changes. - """ - logger.debug("Configuring request filter") - - if "headers" in content[0]: - verifier.add_custom_headers(parse_headers(content[0]["headers"]).items()) - else: - msg = "Unsupported filter type" - raise RuntimeError(msg) +a_pact_file_for_interaction_is_to_be_verified("V1") +a_pact_file_for_interaction_is_to_be_verified_from_a_pact_broker("V1") +a_pact_file_for_interaction_is_to_be_verified_with_a_provider_state_defined("V1") +a_provider_is_started_that_returns_the_responses_from_interactions() +a_provider_is_started_that_returns_the_responses_from_interactions_with_changes() +a_provider_state_callback_is_configured() +a_request_filter_is_configured_to_make_the_following_changes() +publishing_of_verification_results_is_enabled() ################################################################################ @@ -659,22 +321,7 @@ def a_request_filter_is_configured_to_make_the_following_changes( ################################################################################ -@when("the verification is run", target_fixture="verifier_result") -def the_verification_is_run( - verifier: Verifier, - provider_url: URL, -) -> tuple[Verifier, Exception | None]: - """ - Run the verification. - """ - logger.debug("Running verification on %r", verifier) - - verifier.set_info("provider", url=provider_url) - try: - verifier.verify() - except Exception as e: # noqa: BLE001 - return verifier, e - return verifier, None +the_verification_is_run() ################################################################################ @@ -682,263 +329,14 @@ def the_verification_is_run( ################################################################################ -@then( - parsers.re(r"the verification will(?P( NOT)?) be successful"), - converters={"negated": lambda x: x == " NOT"}, -) -def the_verification_will_be_successful( - verifier_result: tuple[Verifier, Exception | None], - negated: bool, # noqa: FBT001 -) -> None: - """ - Check that the verification was successful. - """ - logger.debug("Checking verification result") - logger.debug("Verifier result: %s", verifier_result) - - if negated: - assert verifier_result[1] is not None - else: - assert verifier_result[1] is None - - -@then( - parsers.re(r'the verification results will contain a "(?P[^"]+)" error'), -) -def the_verification_results_will_contain_a_error( - verifier_result: tuple[Verifier, Exception | None], error: str -) -> None: - """ - Check that the verification results contain the given error. - """ - logger.debug("Checking that verification results contain error %s", error) - - verifier = verifier_result[0] - logger.debug("Verification results: %s", json.dumps(verifier.results, indent=2)) - - if error == "Response status did not match": - mismatch_type = "StatusMismatch" - elif error == "Headers had differences": - mismatch_type = "HeaderMismatch" - elif error == "Body had differences": - mismatch_type = "BodyMismatch" - elif error == "State change request failed": - assert "One or more of the setup state change handlers has failed" in [ - error["mismatch"]["message"] for error in verifier.results["errors"] - ] - return - else: - msg = f"Unknown error type: {error}" - raise ValueError(msg) - - assert mismatch_type in [ - mismatch["type"] - for error in verifier.results["errors"] - for mismatch in error["mismatch"]["mismatches"] - ] - - -@then( - parsers.re(r"a verification result will NOT be published back"), -) -def a_verification_result_will_not_be_published_back(pact_broker: PactBroker) -> None: - """ - Check that the verification result was published back to the Pact broker. - """ - logger.debug("Checking that verification result was not published back") - - response = pact_broker.latest_verification_results() - if response: - with pytest.raises(requests.HTTPError, match="404 Client Error"): - response.raise_for_status() - - -@then( - parsers.re( - "a successful verification result " - "will be published back " - r"for interaction \{(?P\d+)\}", - ), - converters={"interaction": int}, -) -def a_successful_verification_result_will_be_published_back( - pact_broker: PactBroker, - interaction: int, -) -> None: - """ - Check that the verification result was published back to the Pact broker. - """ - logger.debug( - "Checking that verification result was published back for interaction %d", - interaction, - ) - - interaction_id = pact_broker.interaction_id(interaction) - response = pact_broker.latest_verification_results() - assert response is not None - assert response.ok - data: dict[str, Any] = response.json() - assert data["success"] - - for test_result in data["testResults"]: - if test_result["interactionId"] == interaction_id: - assert test_result["success"] - break - else: - msg = f"Interaction {interaction} not found in verification results" - raise ValueError(msg) - - -@then( - parsers.re( - "a failed verification result " - "will be published back " - r"for the interaction \{(?P\d+)\}", - ), - converters={"interaction": int}, -) -def a_failed_verification_result_will_be_published_back( - pact_broker: PactBroker, - interaction: int, -) -> None: - """ - Check that the verification result was published back to the Pact broker. - """ - logger.debug( - "Checking that failed verification result" - " was published back for interaction %d", - interaction, - ) - - interaction_id = pact_broker.interaction_id(interaction) - response = pact_broker.latest_verification_results() - assert response is not None - assert response.ok - data: dict[str, Any] = response.json() - assert not data["success"] - - for test_result in data["testResults"]: - if test_result["interactionId"] == interaction_id: - assert not test_result["success"] - break - else: - msg = f"Interaction {interaction} not found in verification results" - raise ValueError(msg) - - -@then("the provider state callback will be called before the verification is run") -def the_provider_state_callback_will_be_called_before_the_verification_is_run() -> None: - """ - Check that the provider state callback was called before the verification was run. - """ - logger.debug("Checking provider state callback was called before verification") - - -@then( - parsers.re( - r"the provider state callback will receive a (?Psetup|teardown) call" - r' (with )?"(?P[^"]*)" as the provider state parameter', - ), -) -def the_provider_state_callback_will_receive_a_setup_call( - temp_dir: Path, - action: str, - state: str, -) -> None: - """ - Check that the provider state callback received a setup call. - """ - logger.info("Checking provider state callback received a %s call", action) - logger.info("Callback files: %s", list(temp_dir.glob("callback.*.json"))) - for file in temp_dir.glob("callback.*.json"): - with file.open("r") as f: - data: dict[str, Any] = json.load(f) - logger.debug("Checking callback data: %s", data) - if ( - "action" in data["query_params"] - and data["query_params"]["action"] == action - and data["query_params"]["state"] == state - ): - break - else: - msg = f"No {action} call found" - raise AssertionError(msg) - - -@then( - parsers.re( - r"the provider state callback will " - r"NOT receive a (?Psetup|teardown) call" - ) -) -def the_provider_state_callback_will_not_receive_a_setup_call( - temp_dir: Path, - action: str, -) -> None: - """ - Check that the provider state callback did not receive a setup call. - """ - for file in temp_dir.glob("callback.*.json"): - with file.open("r") as f: - data: dict[str, Any] = json.load(f) - logger.debug("Checking callback data: %s", data) - if ( - "action" in data["query_params"] - and data["query_params"]["action"] == action - ): - msg = f"Unexpected {action} call found" - raise AssertionError(msg) - - -@then("the provider state callback will be called after the verification is run") -def the_provider_state_callback_will_be_called_after_the_verification_is_run() -> None: - """ - Check that the provider state callback was called after the verification was run. - """ - - -@then( - parsers.re( - r"a warning will be displayed " - r"that there was no provider state callback configured " - r'for provider state "(?P[^"]*)"', - ) -) -def a_warning_will_be_displayed_that_there_was_no_callback_configured( - state: str, -) -> None: - """ - Check that a warning was displayed that there was no callback configured. - """ - logger.debug("Checking for warning about missing provider state callback") - assert state - - -@then( - parsers.re( - r'the request to the provider will contain the header "(?P
[^"]+)"', - ), - converters={"header": lambda x: parse_headers(f"'{x}'")}, -) -def the_request_to_the_provider_will_contain_the_header( - verifier_result: tuple[Verifier, Exception | None], - header: dict[str, str], - temp_dir: Path, -) -> None: - """ - Check that the request to the provider contained the given header. - """ - verifier = verifier_result[0] - logger.debug("verifier output: %s", verifier.output(strip_ansi=True)) - logger.debug("verifier results: %s", json.dumps(verifier.results, indent=2)) - for request in temp_dir.glob("request.*.json"): - with request.open("r") as f: - data: dict[str, Any] = json.load(f) - if data["path"].startswith("/_test"): - continue - logger.debug("Checking request data: %s", data) - assert all([k, v] in data["headers_list"] for k, v in header.items()) - break - else: - msg = "No request found" - raise AssertionError(msg) +a_failed_verification_result_will_be_published_back() +a_successful_verification_result_will_be_published_back() +a_verification_result_will_not_be_published_back() +a_warning_will_be_displayed_that_there_was_no_callback_configured() +the_provider_state_callback_will_be_called_after_the_verification_is_run() +the_provider_state_callback_will_be_called_before_the_verification_is_run() +the_provider_state_callback_will_not_receive_a_setup_call() +the_provider_state_callback_will_receive_a_setup_call() +the_request_to_the_provider_will_contain_the_header() +the_verification_results_will_contain_a_error() +the_verification_will_be_successful() diff --git a/tests/v3/compatibility_suite/test_v2_provider.py b/tests/v3/compatibility_suite/test_v2_provider.py new file mode 100644 index 0000000000..303425aa55 --- /dev/null +++ b/tests/v3/compatibility_suite/test_v2_provider.py @@ -0,0 +1,131 @@ +""" +Basic HTTP provider feature test. +""" + +from __future__ import annotations + +import logging + +from pytest_bdd import given, parsers, scenario + +from tests.v3.compatibility_suite.util import ( + InteractionDefinition, + parse_markdown_table, +) +from tests.v3.compatibility_suite.util.provider import ( + a_pact_file_for_interaction_is_to_be_verified, + a_provider_is_started_that_returns_the_responses_from_interactions_with_changes, + the_verification_is_run, + the_verification_results_will_contain_a_error, + the_verification_will_be_successful, +) + +logger = logging.getLogger(__name__) + + +################################################################################ +## Scenario +################################################################################ + + +@scenario( + "definition/features/V2/http_provider.feature", + "Supports matching rules for the response headers (positive case)", +) +def test_supports_matching_rules_for_the_response_headers_positive_case() -> None: + """ + Supports matching rules for the response headers (positive case). + """ + + +@scenario( + "definition/features/V2/http_provider.feature", + "Supports matching rules for the response headers (negative case)", +) +def test_supports_matching_rules_for_the_response_headers_negative_case() -> None: + """ + Supports matching rules for the response headers (negative case). + """ + + +@scenario( + "definition/features/V2/http_provider.feature", + "Verifies the response body (positive case)", +) +def test_verifies_the_response_body_positive_case() -> None: + """ + Verifies the response body (positive case). + """ + + +@scenario( + "definition/features/V2/http_provider.feature", + "Verifies the response body (negative case)", +) +def test_verifies_the_response_body_negative_case() -> None: + """ + Verifies the response body (negative case). + """ + + +################################################################################ +## Given +################################################################################ + + +@given( + parsers.parse("the following HTTP interactions have been defined:\n{content}"), + target_fixture="interaction_definitions", + converters={"content": parse_markdown_table}, +) +def the_following_http_interactions_have_been_defined( + content: list[dict[str, str]], +) -> dict[int, InteractionDefinition]: + """ + Parse the HTTP interactions table into a dictionary. + + The table columns are expected to be: + + - No + - method + - path + - response + - response headers + - response content + - response body + - response matching rules + + The first row is ignored, as it is assumed to be the column headers. The + order of the columns is similarly ignored. + """ + logger.debug("Parsing interaction definitions") + + # Check that the table is well-formed + assert len(content[0]) == 8, f"Expected 8 columns, got {len(content[0])}" + assert "No" in content[0], "'No' column not found" + + # Parse the table into a more useful format + interactions: dict[int, InteractionDefinition] = {} + for row in content: + interactions[int(row["No"])] = InteractionDefinition(**row) + return interactions + + +a_pact_file_for_interaction_is_to_be_verified("V2") +a_provider_is_started_that_returns_the_responses_from_interactions_with_changes() + +################################################################################ +## When +################################################################################ + + +the_verification_is_run() + + +################################################################################ +## Then +################################################################################ + + +the_verification_results_will_contain_a_error() +the_verification_will_be_successful() diff --git a/tests/v3/compatibility_suite/test_v3_provider.py b/tests/v3/compatibility_suite/test_v3_provider.py new file mode 100644 index 0000000000..c702d3e9c3 --- /dev/null +++ b/tests/v3/compatibility_suite/test_v3_provider.py @@ -0,0 +1,117 @@ +""" +Basic HTTP provider feature test. +""" + +from __future__ import annotations + +import logging + +from pytest_bdd import given, parsers, scenario + +from tests.v3.compatibility_suite.util import ( + InteractionDefinition, + parse_markdown_table, +) +from tests.v3.compatibility_suite.util.provider import ( + a_pact_file_for_interaction_is_to_be_verified_with_a_provider_states_defined, + a_provider_is_started_that_returns_the_responses_from_interactions, + a_provider_state_callback_is_configured, + the_provider_state_callback_will_be_called_after_the_verification_is_run, + the_provider_state_callback_will_be_called_before_the_verification_is_run, + the_provider_state_callback_will_receive_a_setup_call, + the_provider_state_callback_will_receive_a_setup_call_with_parameters, + the_verification_is_run, +) + +logger = logging.getLogger(__name__) + + +################################################################################ +## Scenario +################################################################################ + + +@scenario( + "definition/features/V3/http_provider.feature", + "Verifying an interaction with multiple defined provider states", +) +def test_verifying_an_interaction_with_multiple_defined_provider_states() -> None: + """ + Verifying an interaction with multiple defined provider states. + """ + + +@scenario( + "definition/features/V3/http_provider.feature", + "Verifying an interaction with a provider state with parameters", +) +def test_verifying_an_interaction_with_a_provider_state_with_parameters() -> None: + """ + Verifying an interaction with a provider state with parameters. + """ + + +################################################################################ +## Given +################################################################################ + + +@given( + parsers.parse("the following HTTP interactions have been defined:\n{content}"), + target_fixture="interaction_definitions", + converters={"content": parse_markdown_table}, +) +def the_following_http_interactions_have_been_defined( + content: list[dict[str, str]], +) -> dict[int, InteractionDefinition]: + """ + Parse the HTTP interactions table into a dictionary. + + The table columns are expected to be: + + - No + - method + - path + - response + - response headers + - response content + - response body + - response matching rules + + The first row is ignored, as it is assumed to be the column headers. The + order of the columns is similarly ignored. + """ + logger.debug("Parsing interaction definitions") + + # Check that the table is well-formed + assert len(content[0]) == 8, f"Expected 8 columns, got {len(content[0])}" + assert "No" in content[0], "'No' column not found" + + # Parse the table into a more useful format + interactions: dict[int, InteractionDefinition] = {} + for row in content: + interactions[int(row["No"])] = InteractionDefinition(**row) + return interactions + + +a_pact_file_for_interaction_is_to_be_verified_with_a_provider_states_defined("V3") +a_provider_is_started_that_returns_the_responses_from_interactions() +a_provider_state_callback_is_configured() + +################################################################################ +## When +################################################################################ + + +the_verification_is_run() + + +################################################################################ +## Then +################################################################################ + + +the_provider_state_callback_will_be_called_after_the_verification_is_run() +the_provider_state_callback_will_be_called_before_the_verification_is_run() +the_provider_state_callback_will_receive_a_setup_call() +the_provider_state_callback_will_receive_a_setup_call_with_parameters() diff --git a/tests/v3/compatibility_suite/test_v4_provider.py b/tests/v3/compatibility_suite/test_v4_provider.py new file mode 100644 index 0000000000..8d4463d814 --- /dev/null +++ b/tests/v3/compatibility_suite/test_v4_provider.py @@ -0,0 +1,123 @@ +""" +Basic HTTP provider feature test. +""" + +from __future__ import annotations + +import logging + +from pytest_bdd import given, parsers, scenario + +from tests.v3.compatibility_suite.util import ( + InteractionDefinition, + parse_markdown_table, +) +from tests.v3.compatibility_suite.util.provider import ( + a_pact_file_for_interaction_is_to_be_verified, + a_pact_file_for_interaction_is_to_be_verified_with_comments, + a_provider_is_started_that_returns_the_responses_from_interactions, + a_provider_is_started_that_returns_the_responses_from_interactions_with_changes, + the_comment_will_have_been_printed_to_the_console, + the_name_of_the_test_will_be_displayed_as_the_original_test_name, + the_verification_is_run, + the_verification_results_will_contain_a_error, + the_verification_will_be_successful, + there_will_be_a_pending_error, +) + +logger = logging.getLogger(__name__) + + +################################################################################ +## Scenario +################################################################################ + + +@scenario( + "definition/features/V4/http_provider.feature", + "Verifying a pending HTTP interaction", +) +def test_verifying_a_pending_http_interaction() -> None: + """ + Verifying a pending HTTP interaction. + """ + + +@scenario( + "definition/features/V4/http_provider.feature", + "Verifying a HTTP interaction with comments", +) +def test_verifying_a_http_interaction_with_comments() -> None: + """ + Verifying a HTTP interaction with comments. + """ + + +################################################################################ +## Given +################################################################################ + + +@given( + parsers.parse("the following HTTP interactions have been defined:\n{content}"), + target_fixture="interaction_definitions", + converters={"content": parse_markdown_table}, +) +def the_following_http_interactions_have_been_defined( + content: list[dict[str, 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 headers + - 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. + """ + logger.debug("Parsing interaction definitions") + + # Check that the table is well-formed + assert len(content[0]) == 10, f"Expected 10 columns, got {len(content[0])}" + assert "No" in content[0], "'No' column not found" + + # Parse the table into a more useful format + interactions: dict[int, InteractionDefinition] = {} + for row in content: + interactions[int(row["No"])] = InteractionDefinition(**row) + return interactions + + +a_pact_file_for_interaction_is_to_be_verified("V4") +a_pact_file_for_interaction_is_to_be_verified_with_comments("V4") +a_provider_is_started_that_returns_the_responses_from_interactions() +a_provider_is_started_that_returns_the_responses_from_interactions_with_changes() + +################################################################################ +## When +################################################################################ + + +the_verification_is_run() + + +################################################################################ +## Then +################################################################################ + + +the_comment_will_have_been_printed_to_the_console() +the_name_of_the_test_will_be_displayed_as_the_original_test_name() +the_verification_results_will_contain_a_error() +the_verification_will_be_successful() +there_will_be_a_pending_error() diff --git a/tests/v3/compatibility_suite/util/__init__.py b/tests/v3/compatibility_suite/util/__init__.py index 1f47080b24..1351b11462 100644 --- a/tests/v3/compatibility_suite/util/__init__.py +++ b/tests/v3/compatibility_suite/util/__init__.py @@ -24,6 +24,7 @@ def _(): import base64 import contextlib import hashlib +import json import logging import sys import typing @@ -36,6 +37,7 @@ def _(): import flask from flask import request from multidict import MultiDict +from typing_extensions import Self from yarl import URL if typing.TYPE_CHECKING: @@ -358,19 +360,71 @@ def parse_file(self, file: Path) -> None: msg = "Unknown file type" raise ValueError(msg) + class State: + """ + Provider state. + """ + + def __init__( + self, + name: str, + parameters: str | dict[str, Any] | None = None, + ) -> None: + """ + Instantiate the provider state. + """ + self.name = name + self.parameters: dict[str, Any] + if isinstance(parameters, str): + self.parameters = json.loads(parameters) + else: + self.parameters = parameters or {} + + def __repr__(self) -> str: + """ + Debugging representation. + """ + return "".format( + ", ".join( + str(k) + "=" + truncate(repr(v)) for k, v in vars(self).items() + ), + ) + + def as_dict(self) -> dict[str, str | dict[str, Any]]: + """ + Convert the provider state to a dictionary. + """ + return {"name": self.name, "parameters": self.parameters} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Self: + """ + Convert a dictionary to a provider state. + """ + return cls(**data) + def __init__(self, **kwargs: str) -> None: """Initialise the interaction definition.""" self.id: int | None = None - self.state: str | None = None + + self.states: list[InteractionDefinition.State] = [] + self.pending: bool = False + self.text_comments: list[str] = [] + self.comments: dict[str, str] = {} + self.test_name: str | None = None + self.method: str = kwargs.pop("method") self.path: str = kwargs.pop("path") self.response: int = int(kwargs.pop("response", 200)) self.query: str | None = None self.headers: MultiDict[str] = MultiDict() self.body: InteractionDefinition.Body | None = None + self.response_headers: MultiDict[str] = MultiDict() self.response_body: InteractionDefinition.Body | None = None self.matching_rules: str | None = None + self.response_matching_rules: str | None = None + self.update(**kwargs) def update(self, **kwargs: str) -> None: # noqa: C901, PLR0912 @@ -445,6 +499,12 @@ def update(self, **kwargs: str) -> None: # noqa: C901, PLR0912 ): self.matching_rules = parse_matching_rules(matching_rules) + if matching_rules := ( + kwargs.pop("response_matching_rules", None) + or kwargs.pop("response matching rules", None) + ): + self.response_matching_rules = parse_matching_rules(matching_rules) + if len(kwargs) > 0: msg = f"Unexpected arguments: {kwargs.keys()}" raise TypeError(msg) @@ -457,7 +517,7 @@ def __repr__(self) -> str: ", ".join(f"{k}={v!r}" for k, v in vars(self).items()), ) - def add_to_pact(self, pact: Pact, name: str) -> None: # noqa: C901, PLR0912 + def add_to_pact(self, pact: Pact, name: str) -> None: # noqa: C901, PLR0912, PLR0915 """ Add the interaction to the pact. @@ -473,26 +533,45 @@ def add_to_pact(self, pact: Pact, name: str) -> None: # noqa: C901, PLR0912 Name for this interaction. Must be unique for the pact. """ interaction = pact.upon_receiving(name) - logging.info("with_request(%s, %s)", self.method, self.path) + logger.info("with_request(%s, %s)", self.method, self.path) interaction.with_request(self.method, self.path) - # We distinguish between "" and None here. - if self.state is not None: - logging.info("given(%s)", self.state) - interaction.given(self.state) + for state in self.states or []: + if state.parameters: + logger.info("given(%s, parameters=%s)", state.name, state.parameters) + interaction.given(state.name, parameters=state.parameters) + else: + logger.info("given(%s)", state.name) + interaction.given(state.name) + + if self.pending: + logger.info("set_pending(True)") + interaction.set_pending(pending=True) + + if self.text_comments: + logger.info("set_comment(text, %s)", self.text_comments) + interaction.set_comment("text", self.text_comments) + + for key, value in self.comments.items(): + logger.info("set_comment(%s, %s)", key, value) + interaction.set_comment(key, value) + + if self.test_name: + logger.info("test_name(%s)", self.test_name) + interaction.test_name(self.test_name) if self.query: query = URL.build(query_string=self.query).query - logging.info("with_query_parameters(%s)", query.items()) + logger.info("with_query_parameters(%s)", query.items()) interaction.with_query_parameters(query.items()) if self.headers: - logging.info("with_headers(%s)", self.headers.items()) + logger.info("with_headers(%s)", self.headers.items()) interaction.with_headers(self.headers.items()) if self.body: if self.body.string: - logging.info( + logger.info( "with_body(%s, %s)", truncate(self.body.string), self.body.mime_type, @@ -502,7 +581,7 @@ def add_to_pact(self, pact: Pact, name: str) -> None: # noqa: C901, PLR0912 self.body.mime_type, ) elif self.body.bytes: - logging.info( + logger.info( "with_binary_file(%s, %s)", truncate(self.body.bytes), self.body.mime_type, @@ -516,16 +595,20 @@ def add_to_pact(self, pact: Pact, name: str) -> None: # noqa: C901, PLR0912 raise RuntimeError(msg) if self.matching_rules: - logging.info("with_matching_rules(%s)", self.matching_rules) + logger.info("with_matching_rules(%s)", self.matching_rules) interaction.with_matching_rules(self.matching_rules) if self.response: - logging.info("will_respond_with(%s)", self.response) + logger.info("will_respond_with(%s)", self.response) interaction.will_respond_with(self.response) + if self.response_headers: + logger.info("with_headers(%s)", self.response_headers) + interaction.with_headers(self.response_headers.items()) + if self.response_body: if self.response_body.string: - logging.info( + logger.info( "with_body(%s, %s)", truncate(self.response_body.string), self.response_body.mime_type, @@ -535,7 +618,7 @@ def add_to_pact(self, pact: Pact, name: str) -> None: # noqa: C901, PLR0912 self.response_body.mime_type, ) elif self.response_body.bytes: - logging.info( + logger.info( "with_binary_file(%s, %s)", truncate(self.response_body.bytes), self.response_body.mime_type, @@ -548,6 +631,10 @@ def add_to_pact(self, pact: Pact, name: str) -> None: # noqa: C901, PLR0912 msg = "Unexpected body definition" raise RuntimeError(msg) + if self.response_matching_rules: + logger.info("with_matching_rules(%s)", self.response_matching_rules) + interaction.with_matching_rules(self.response_matching_rules) + def add_to_flask(self, app: flask.Flask) -> None: """ Add an interaction to a Flask app. @@ -594,7 +681,7 @@ def route_fn() -> flask.Response: if self.response_body else None, status=self.response, - headers=self.response_headers, + headers=dict(**self.response_headers), content_type=self.response_body.mime_type if self.response_body else None, diff --git a/tests/v3/compatibility_suite/util/provider.py b/tests/v3/compatibility_suite/util/provider.py index 2968152b0d..d060b72f79 100644 --- a/tests/v3/compatibility_suite/util/provider.py +++ b/tests/v3/compatibility_suite/util/provider.py @@ -1,9 +1,15 @@ """ Provider utilities for compatibility suite tests. -The main functionality provided by this module is the ability to start a -provider application with a set of interactions. Since this is done -in a subprocess, any configuration must be passed in through files. +This file has two main purposes. + +The first functionality provided by this module is the ability to start a +provider application with a set of interactions. Since this is done in a +subprocess, any configuration must be passed in through files. The process is +started with + +The second functionality provided by this module is to define some of the shared +steps for the compatibility suite tests. """ from __future__ import annotations @@ -11,32 +17,48 @@ import sys from pathlib import Path +import pytest + sys.path.append(str(Path(__file__).parent.parent.parent.parent.parent)) import contextlib +import copy import json import logging import os import pickle +import re import shutil +import signal import socket import subprocess +import time +import warnings from contextvars import ContextVar from datetime import datetime -from pathlib import Path -from typing import TYPE_CHECKING +from threading import Thread +from typing import TYPE_CHECKING, Any, NoReturn import flask import requests from flask import request +from pytest_bdd import given, parsers, then, when from yarl import URL import pact.constants # type: ignore[import-untyped] -from tests.v3.compatibility_suite.util import serialize +from pact.v3.pact import Pact +from tests.v3.compatibility_suite.util import ( + InteractionDefinition, + parse_headers, + parse_markdown_table, + serialize, +) if TYPE_CHECKING: - from tests.v3.compatibility_suite.util import InteractionDefinition + from collections.abc import Generator + + from pact.v3.verifier import Verifier if sys.version_info < (3, 11): from datetime import timezone @@ -49,6 +71,21 @@ logger = logging.getLogger(__name__) version_var = ContextVar("version_var", default="0") +""" +Shared context variable to store the version of the consumer application. + +This is used to generate a new version for the consumer application to use when +publishing the interactions to the Pact Broker. +""" +reset_broker_var = ContextVar("reset_broker", default=True) +""" +This context variable is used to determine whether the Pact broker should be +cleaned up. It is used to ensure that the broker is only cleaned up once, even +if a step is run multiple times. + +All scenarios which make use of the Pact broker should set this to `True` at the +start of the scenario. +""" def next_version() -> str: @@ -144,10 +181,19 @@ def callback() -> tuple[str, int] | str: if (self.provider_dir / "fail_callback").exists(): return "Provider state not found", 404 - provider_state_path = self.provider_dir / "provider_state" - if provider_state_path.exists(): - state = provider_state_path.read_text() - assert request.args["state"] == state + provider_states_path = self.provider_dir / "provider_states" + if provider_states_path.exists(): + with provider_states_path.open() as f: + states = [InteractionDefinition.State(**s) for s in json.load(f)] + for state in states: + if request.args["state"] == state.name: + for k, v in state.parameters.items(): + assert k in request.args + assert str(request.args[k]) == str(v) + break + else: + msg = "State not found" + raise ValueError(msg) json_file = ( self.provider_dir @@ -245,6 +291,10 @@ def run(self) -> None: Start the provider. """ url = URL(f"http://localhost:{_find_free_port()}") + sys.stderr.write("Starting provider on %s\n" % url) + for endpoint in self.app.url_map.iter_rules(): + sys.stderr.write(f" * {endpoint}\n") + self.app.run( host=url.host, port=url.port, @@ -444,3 +494,946 @@ def latest_verification_results(self) -> requests.Response | None: sys.exit(1) Provider(sys.argv[1]).run() + + +################################################################################ +## Given +################################################################################ + + +def a_provider_is_started_that_returns_the_responses_from_interactions( + stacklevel: int = 1, +) -> None: + @given( + parsers.re( + r"a provider is started that returns the responses? " + r'from interactions? "?(?P[0-9, ]+)"?', + ), + converters={"interactions": lambda x: [int(i) for i in x.split(",") if i]}, + target_fixture="provider_url", + stacklevel=stacklevel + 1, + ) + def _( + interaction_definitions: dict[int, InteractionDefinition], + interactions: list[int], + temp_dir: Path, + ) -> Generator[URL, None, None]: + """ + Start a provider that returns the responses from the given interactions. + """ + logger.debug("Starting provider for interactions %s", interactions) + + for i in interactions: + logger.debug("Interaction %d: %s", i, interaction_definitions[i]) + + with (temp_dir / "interactions.pkl").open("wb") as pkl_file: + pickle.dump([interaction_definitions[i] for i in interactions], pkl_file) + + yield from start_provider(temp_dir) + + +def a_provider_is_started_that_returns_the_responses_from_interactions_with_changes( + stacklevel: int = 1, +) -> None: + @given( + parsers.re( + r"a provider is started that returns the responses?" + r' from interactions? "?(?P[0-9, ]+)"?' + r" with the following changes:\n(?P.+)", + re.DOTALL, + ), + converters={ + "interactions": lambda x: [int(i) for i in x.split(",") if i], + "changes": parse_markdown_table, + }, + target_fixture="provider_url", + stacklevel=stacklevel + 1, + ) + def _( + interaction_definitions: dict[int, InteractionDefinition], + interactions: list[int], + changes: list[dict[str, str]], + temp_dir: Path, + ) -> Generator[URL, None, None]: + """ + Start a provider that returns the responses from the given interactions. + """ + logger.debug("Starting provider for modified interactions %s", interactions) + + assert len(changes) == 1, "Only one set of changes is supported" + defns: list[InteractionDefinition] = [] + for interaction in interactions: + defn = copy.deepcopy(interaction_definitions[interaction]) + defn.update(**changes[0]) + defns.append(defn) + logger.debug( + "Updated interaction %d: %s", + interaction, + defn, + ) + + with (temp_dir / "interactions.pkl").open("wb") as pkl_file: + pickle.dump(defns, pkl_file) + + yield from start_provider(temp_dir) + + +def start_provider(provider_dir: str | Path) -> Generator[URL, None, None]: # noqa: C901 + """Start the provider app with the given interactions.""" + process = subprocess.Popen( + [ # noqa: S603 + sys.executable, + Path(__file__), + str(provider_dir), + ], + cwd=Path.cwd(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + + pattern = re.compile(r" \* Running on (?P[^ ]+)") + while True: + if process.poll() is not None: + logger.error("Provider process exited with code %d", process.returncode) + logger.error( + "Provider stdout: %s", process.stdout.read() if process.stdout else "" + ) + logger.error( + "Provider stderr: %s", process.stderr.read() if process.stderr else "" + ) + msg = f"Provider process exited with code {process.returncode}" + raise RuntimeError(msg) + if ( + process.stderr + and (line := process.stderr.readline()) + and (match := pattern.match(line)) + ): + break + time.sleep(0.1) + + url = URL(match.group("url")) + logger.debug("Provider started on %s", url) + for _ in range(50): + try: + response = requests.get(str(url / "_test" / "ping"), timeout=1) + assert response.text == "pong" + break + except (requests.RequestException, AssertionError): + time.sleep(0.1) + continue + else: + msg = "Failed to ping provider" + raise RuntimeError(msg) + + def redirect() -> NoReturn: + while True: + if process.stdout: + while line := process.stdout.readline(): + logger.debug("Provider stdout: %s", line.strip()) + if process.stderr: + while line := process.stderr.readline(): + logger.debug("Provider stderr: %s", line.strip()) + + thread = Thread(target=redirect, daemon=True) + thread.start() + + yield url + + process.send_signal(signal.SIGINT) + + +def a_pact_file_for_interaction_is_to_be_verified( + version: str, + stacklevel: int = 1, +) -> None: + @given( + parsers.re( + r"a Pact file for interaction (?P\d+) is to be verified" + r"(?P(, but is marked pending)?)", + ), + converters={"interaction": int, "pending": lambda x: x != ""}, + stacklevel=stacklevel + 1, + ) + def _( + interaction_definitions: dict[int, InteractionDefinition], + verifier: Verifier, + interaction: int, + pending: bool, # noqa: FBT001 + temp_dir: Path, + ) -> None: + """ + Verify the Pact file for the given interaction. + """ + logger.debug( + "Adding interaction %d to be verified: %s", + interaction, + interaction_definitions[interaction], + ) + + defn = interaction_definitions[interaction] + defn.pending = pending + + pact = Pact("consumer", "provider") + pact.with_specification(version) + defn.add_to_pact(pact, f"interaction {interaction}") + (temp_dir / "pacts").mkdir(exist_ok=True, parents=True) + pact.write_file(temp_dir / "pacts") + + with (temp_dir / "pacts" / "consumer-provider.json").open( + "r", + encoding="utf-8", + ) as f: + for line in f: + logger.debug("Pact file: %s", line.rstrip()) + + verifier.add_source(temp_dir / "pacts") + + +def a_pact_file_for_interaction_is_to_be_verified_with_comments( + version: str, + stacklevel: int = 1, +) -> None: + @given( + parsers.re( + r"a Pact file for interaction (?P\d+) is to be verified" + r" with the following comments:\n(?P.+)", + re.DOTALL, + ), + converters={"interaction": int, "comments": parse_markdown_table}, + stacklevel=stacklevel + 1, + ) + def _( + interaction_definitions: dict[int, InteractionDefinition], + verifier: Verifier, + interaction: int, + comments: list[dict[str, str]], + temp_dir: Path, + ) -> None: + """ + Verify the Pact file for the given interaction. + """ + logger.debug( + "Adding interaction %d to be verified: %s", + interaction, + interaction_definitions[interaction], + ) + + defn = interaction_definitions[interaction] + for comment in comments: + if comment["type"] == "text": + defn.text_comments.append(comment["comment"]) + elif comment["type"] == "testname": + defn.test_name = comment["comment"] + else: + defn.comments[comment["type"]] = comment["comment"] + logger.info("Updated interaction %d: %s", interaction, defn) + + pact = Pact("consumer", "provider") + pact.with_specification(version) + defn.add_to_pact(pact, f"interaction {interaction}") + (temp_dir / "pacts").mkdir(exist_ok=True, parents=True) + pact.write_file(temp_dir / "pacts") + + with (temp_dir / "pacts" / "consumer-provider.json").open( + "r", + encoding="utf-8", + ) as f: + for line in f: + logger.debug("Pact file: %s", line.rstrip()) + + verifier.add_source(temp_dir / "pacts") + + +def a_pact_file_for_interaction_is_to_be_verified_from_a_pact_broker( + version: str, + stacklevel: int = 1, +) -> None: + @given( + parsers.re( + r"a Pact file for interaction (?P\d+)" + r" is to be verified from a Pact broker", + ), + converters={"interaction": int}, + target_fixture="pact_broker", + stacklevel=stacklevel + 1, + ) + def _( + interaction_definitions: dict[int, InteractionDefinition], + broker_url: URL, + verifier: Verifier, + interaction: int, + temp_dir: Path, + ) -> Generator[PactBroker, None, None]: + """ + Verify the Pact file for the given interaction from a Pact broker. + """ + logger.debug( + "Adding interaction %d to be verified from a Pact broker", interaction + ) + + defn = interaction_definitions[interaction] + + pact = Pact("consumer", "provider") + pact.with_specification(version) + defn.add_to_pact(pact, f"interaction {interaction}") + + pacts_dir = temp_dir / "pacts" + pacts_dir.mkdir(exist_ok=True, parents=True) + pact.write_file(pacts_dir) + + pact_broker = PactBroker(broker_url) + if reset_broker_var.get(): + logger.debug("Resetting Pact broker") + pact_broker.reset() + reset_broker_var.set(False) # noqa: FBT003 + pact_broker.publish(pacts_dir) + verifier.broker_source(pact_broker.url) + yield pact_broker + + +def publishing_of_verification_results_is_enabled(stacklevel: int = 1) -> None: + @given("publishing of verification results is enabled", stacklevel=stacklevel + 1) + def _(verifier: Verifier) -> None: + """ + Enable publishing of verification results. + """ + logger.debug("Publishing verification results") + + verifier.set_publish_options( + "0.0.0", + ) + + +def a_provider_state_callback_is_configured( + stacklevel: int = 1, +) -> None: + @given( + parsers.re( + r"a provider state callback is configured" + r"(?P(, but will return a failure)?)", + ), + converters={"failure": lambda x: x != ""}, + stacklevel=stacklevel + 1, + ) + def _( + verifier: Verifier, + provider_url: URL, + temp_dir: Path, + failure: bool, # noqa: FBT001 + ) -> None: + """ + Configure a provider state callback. + """ + logger.debug("Configuring provider state callback") + + if failure: + with (temp_dir / "fail_callback").open("w") as f: + f.write("true") + + verifier.set_state( + provider_url / "_test" / "callback", + teardown=True, + ) + + +def a_pact_file_for_interaction_is_to_be_verified_with_a_provider_state_defined( + version: str, + stacklevel: int = 1, +) -> None: + @given( + parsers.re( + r"a Pact file for interaction (?P\d+) is to be verified" + r' with a provider state "(?P[^"]+)" defined', + ), + converters={"interaction": int}, + stacklevel=stacklevel + 1, + ) + def _( + interaction_definitions: dict[int, InteractionDefinition], + verifier: Verifier, + interaction: int, + state: str, + temp_dir: Path, + ) -> None: + """ + Verify the Pact file for the given interaction with a provider state defined. + """ + logger.debug( + "Adding interaction %d to be verified with provider state %s", + interaction, + state, + ) + + defn = interaction_definitions[interaction] + defn.states = [InteractionDefinition.State(state)] + + pact = Pact("consumer", "provider") + pact.with_specification(version) + defn.add_to_pact(pact, f"interaction {interaction}") + (temp_dir / "pacts").mkdir(exist_ok=True, parents=True) + pact.write_file(temp_dir / "pacts") + + verifier.add_source(temp_dir / "pacts") + + with (temp_dir / "provider_states").open("w") as f: + logger.debug("Writing provider state to %s", temp_dir / "provider_states") + json.dump([s.as_dict() for s in defn.states], f) + + +def a_pact_file_for_interaction_is_to_be_verified_with_a_provider_states_defined( + version: str, + stacklevel: int = 1, +) -> None: + @given( + parsers.re( + r"a Pact file for interaction (?P\d+) is to be verified" + r" with the following provider states defined:\n(?P.+)", + re.DOTALL, + ), + converters={"interaction": int, "states": parse_markdown_table}, + stacklevel=stacklevel + 1, + ) + def _( + interaction_definitions: dict[int, InteractionDefinition], + verifier: Verifier, + interaction: int, + states: list[dict[str, Any]], + temp_dir: Path, + ) -> None: + """ + Verify the Pact file for the given interaction with provider states defined. + """ + logger.debug( + "Adding interaction %d to be verified with provider states %s", + interaction, + states, + ) + + defn = interaction_definitions[interaction] + defn.states = [ + InteractionDefinition.State(s["State Name"], s.get("Parameters", None)) + for s in states + ] + + pact = Pact("consumer", "provider") + pact.with_specification(version) + defn.add_to_pact(pact, f"interaction {interaction}") + (temp_dir / "pacts").mkdir(exist_ok=True, parents=True) + pact.write_file(temp_dir / "pacts") + + verifier.add_source(temp_dir / "pacts") + + with (temp_dir / "provider_states").open("w") as f: + logger.debug("Writing provider state to %s", temp_dir / "provider_states") + json.dump([s.as_dict() for s in defn.states], f) + + +def a_request_filter_is_configured_to_make_the_following_changes( + stacklevel: int = 1, +) -> None: + @given( + parsers.parse( + "a request filter is configured to make the following changes:\n{content}" + ), + converters={"content": parse_markdown_table}, + stacklevel=stacklevel + 1, + ) + def _( + content: list[dict[str, str]], + verifier: Verifier, + ) -> None: + """ + Configure a request filter to make the given changes. + """ + logger.debug("Configuring request filter") + + if "headers" in content[0]: + verifier.add_custom_headers(parse_headers(content[0]["headers"]).items()) + else: + msg = "Unsupported filter type" + raise RuntimeError(msg) + + +################################################################################ +## When +################################################################################ + + +def the_verification_is_run( + stacklevel: int = 1, +) -> None: + @when( + "the verification is run", + target_fixture="verifier_result", + stacklevel=stacklevel + 1, + ) + def _( + verifier: Verifier, + provider_url: URL, + ) -> tuple[Verifier, Exception | None]: + """ + Run the verification. + """ + logger.debug("Running verification on %r", verifier) + + verifier.set_info("provider", url=provider_url) + try: + verifier.verify() + except Exception as e: # noqa: BLE001 + return verifier, e + return verifier, None + + +################################################################################ +## Then +################################################################################ + + +def the_verification_will_be_successful( + stacklevel: int = 1, +) -> None: + @then( + parsers.re(r"the verification will(?P( NOT)?) be successful"), + converters={"negated": lambda x: x == " NOT"}, + stacklevel=stacklevel + 1, + ) + def _( + verifier_result: tuple[Verifier, Exception | None], + negated: bool, # noqa: FBT001 + ) -> None: + """ + Check that the verification was successful. + """ + logger.debug("Checking verification result") + logger.debug("Verifier result: %s", verifier_result) + + if negated: + assert verifier_result[1] is not None + else: + assert verifier_result[1] is None + + +def the_verification_results_will_contain_a_error( + stacklevel: int = 1, +) -> None: + @then( + parsers.re(r'the verification results will contain a "(?P[^"]+)" error'), + stacklevel=stacklevel + 1, + ) + def _(verifier_result: tuple[Verifier, Exception | None], error: str) -> None: + """ + Check that the verification results contain the given error. + """ + logger.debug("Checking that verification results contain error %s", error) + + verifier = verifier_result[0] + logger.debug("Verification results: %s", json.dumps(verifier.results, indent=2)) + + if error == "Response status did not match": + mismatch_type = "StatusMismatch" + elif error == "Headers had differences": + mismatch_type = "HeaderMismatch" + elif error == "Body had differences": + mismatch_type = "BodyMismatch" + elif error == "State change request failed": + assert "One or more of the setup state change handlers has failed" in [ + error["mismatch"]["message"] for error in verifier.results["errors"] + ] + return + else: + msg = f"Unknown error type: {error}" + raise ValueError(msg) + + mismatch_types = [ + mismatch["type"] + for error in verifier.results["errors"] + for mismatch in error["mismatch"]["mismatches"] + ] + assert mismatch_type in mismatch_types + if len(mismatch_types) > 1: + warnings.warn( + f"Multiple mismatch types found: {mismatch_types}", stacklevel=1 + ) + for verifier_error in verifier.results["errors"]: + for mismatch in verifier_error["mismatch"]["mismatches"]: + warnings.warn(f"Mismatch: {mismatch}", stacklevel=1) + + +def a_verification_result_will_not_be_published_back( + stacklevel: int = 1, +) -> None: + @then( + parsers.re(r"a verification result will NOT be published back"), + stacklevel=stacklevel + 1, + ) + def _(pact_broker: PactBroker) -> None: + """ + Check that the verification result was published back to the Pact broker. + """ + logger.debug("Checking that verification result was not published back") + + response = pact_broker.latest_verification_results() + if response: + with pytest.raises(requests.HTTPError, match="404 Client Error"): + response.raise_for_status() + + +def a_successful_verification_result_will_be_published_back( + stacklevel: int = 1, +) -> None: + @then( + parsers.re( + "a successful verification result " + "will be published back " + r"for interaction \{(?P\d+)\}", + ), + converters={"interaction": int}, + stacklevel=stacklevel + 1, + ) + def _( + pact_broker: PactBroker, + interaction: int, + ) -> None: + """ + Check that the verification result was published back to the Pact broker. + """ + logger.debug( + "Checking that verification result was published back for interaction %d", + interaction, + ) + + interaction_id = pact_broker.interaction_id(interaction) + response = pact_broker.latest_verification_results() + assert response is not None + assert response.ok + data: dict[str, Any] = response.json() + assert data["success"] + + for test_result in data["testResults"]: + if test_result["interactionId"] == interaction_id: + assert test_result["success"] + break + else: + msg = f"Interaction {interaction} not found in verification results" + raise ValueError(msg) + + +def a_failed_verification_result_will_be_published_back( + stacklevel: int = 1, +) -> None: + @then( + parsers.re( + "a failed verification result " + "will be published back " + r"for the interaction \{(?P\d+)\}", + ), + converters={"interaction": int}, + stacklevel=stacklevel + 1, + ) + def _( + pact_broker: PactBroker, + interaction: int, + ) -> None: + """ + Check that the verification result was published back to the Pact broker. + """ + logger.debug( + "Checking that failed verification result" + " was published back for interaction %d", + interaction, + ) + + interaction_id = pact_broker.interaction_id(interaction) + response = pact_broker.latest_verification_results() + assert response is not None + assert response.ok + data: dict[str, Any] = response.json() + assert not data["success"] + + for test_result in data["testResults"]: + if test_result["interactionId"] == interaction_id: + assert not test_result["success"] + break + else: + msg = f"Interaction {interaction} not found in verification results" + raise ValueError(msg) + + +def the_provider_state_callback_will_be_called_before_the_verification_is_run( + stacklevel: int = 1, +) -> None: + @then( + "the provider state callback will be called before the verification is run", + stacklevel=stacklevel + 1, + ) + def _() -> None: + """ + Check that the provider state callback was called before the verification. + """ + logger.debug("Checking provider state callback was called before verification") + + +def the_provider_state_callback_will_receive_a_setup_call( + stacklevel: int = 1, +) -> None: + @then( + parsers.re( + r"the provider state callback" + r" will receive a (?Psetup|teardown) call" + r' (with )?"(?P[^"]*)" as the provider state parameter', + ), + stacklevel=stacklevel + 1, + ) + def _( + temp_dir: Path, + action: str, + state: str, + ) -> None: + """ + Check that the provider state callback received a setup call. + """ + logger.info("Checking provider state callback received a %s call", action) + logger.info("Callback files: %s", list(temp_dir.glob("callback.*.json"))) + for file in temp_dir.glob("callback.*.json"): + with file.open("r") as f: + data: dict[str, Any] = json.load(f) + logger.debug("Checking callback data: %s", data) + if ( + "action" in data["query_params"] + and data["query_params"]["action"] == action + and data["query_params"]["state"] == state + ): + break + else: + msg = f"No {action} call found" + raise AssertionError(msg) + + +def the_provider_state_callback_will_receive_a_setup_call_with_parameters( + stacklevel: int = 1, +) -> None: + @then( + parsers.re( + r"the provider state callback" + r" will receive a (?Psetup|teardown) call" + r' (with )?"(?P[^"]*)"' + r" and the following parameters:\n(?P.+)", + re.DOTALL, + ), + converters={"parameters": parse_markdown_table}, + stacklevel=stacklevel + 1, + ) + def _( + temp_dir: Path, + action: str, + state: str, + parameters: list[dict[str, str]], + ) -> None: + """ + Check that the provider state callback received a setup call. + """ + logger.info("Checking provider state callback received a %s call", action) + logger.info("Callback files: %s", list(temp_dir.glob("callback.*.json"))) + params: dict[str, str] = parameters[0] + # If we have a string that looks quoted, unquote it + for key, value in params.items(): + if value.startswith('"') and value.endswith('"'): + params[key] = value[1:-1] + + for file in temp_dir.glob("callback.*.json"): + with file.open("r") as f: + data: dict[str, Any] = json.load(f) + logger.debug("Checking callback data: %s", data) + if ( + "action" in data["query_params"] + and data["query_params"]["action"] == action + and data["query_params"]["state"] == state + ): + for key, value in params.items(): + assert key in data["query_params"], f"Parameter {key} not found" + assert data["query_params"][key] == value + break + else: + msg = f"No {action} call found" + raise AssertionError(msg) + + +def the_provider_state_callback_will_not_receive_a_setup_call( + stacklevel: int = 1, +) -> None: + @then( + parsers.re( + r"the provider state callback will " + r"NOT receive a (?Psetup|teardown) call" + ), + stacklevel=stacklevel + 1, + ) + def _( + temp_dir: Path, + action: str, + ) -> None: + """ + Check that the provider state callback did not receive a setup call. + """ + for file in temp_dir.glob("callback.*.json"): + with file.open("r") as f: + data: dict[str, Any] = json.load(f) + logger.debug("Checking callback data: %s", data) + if ( + "action" in data["query_params"] + and data["query_params"]["action"] == action + ): + msg = f"Unexpected {action} call found" + raise AssertionError(msg) + + +def the_provider_state_callback_will_be_called_after_the_verification_is_run( + stacklevel: int = 1, +) -> None: + @then( + "the provider state callback will be called after the verification is run", + stacklevel=stacklevel + 1, + ) + def _() -> None: + """ + Check that the provider state callback was called after the verification. + """ + + +def a_warning_will_be_displayed_that_there_was_no_callback_configured( + stacklevel: int = 1, +) -> None: + @then( + parsers.re( + r"a warning will be displayed" + r" that there was no provider state callback configured" + r' for provider state "(?P[^"]*)"', + ), + stacklevel=stacklevel + 1, + ) + def _( + state: str, + ) -> None: + """ + Check that a warning was displayed that there was no callback configured. + """ + logger.debug("Checking for warning about missing provider state callback") + assert state + + +def the_request_to_the_provider_will_contain_the_header( + stacklevel: int = 1, +) -> None: + @then( + parsers.re( + r'the request to the provider will contain the header "(?P
[^"]+)"', + ), + converters={"header": lambda x: parse_headers(f"'{x}'")}, + stacklevel=stacklevel + 1, + ) + def _( + verifier_result: tuple[Verifier, Exception | None], + header: dict[str, str], + temp_dir: Path, + ) -> None: + """ + Check that the request to the provider contained the given header. + """ + verifier = verifier_result[0] + logger.debug("verifier output: %s", verifier.output(strip_ansi=True)) + logger.debug("verifier results: %s", json.dumps(verifier.results, indent=2)) + for request_path in temp_dir.glob("request.*.json"): + with request_path.open("r") as f: + data: dict[str, Any] = json.load(f) + if data["path"].startswith("/_test"): + continue + logger.debug("Checking request data: %s", data) + assert all([k, v] in data["headers_list"] for k, v in header.items()) + break + else: + msg = "No request found" + raise AssertionError(msg) + + +def there_will_be_a_pending_error( + stacklevel: int = 1, +) -> None: + @then( + parsers.re(r'there will be a pending "(?P[^"]+)" error'), + stacklevel=stacklevel + 1, + ) + def _( + error: str, + verifier_result: tuple[Verifier, Exception | None], + ) -> None: + """ + There will be a pending error. + """ + logger.debug("Checking for pending error") + verifier, err = verifier_result + + if error == "Body had differences": + mismatch = "BodyMismatch" + else: + msg = f"Unknown error type: {error}" + raise ValueError(msg) + + assert err is None + assert "pendingErrors" in verifier.results + for verifier_error in verifier.results["pendingErrors"]: + mismatches = [m["type"] for m in verifier_error["mismatch"]["mismatches"]] + if mismatch in mismatches: + if len(mismatches) > 1: + warnings.warn( + f"Multiple mismatch types found: {mismatches}", + stacklevel=2, + ) + break + else: + msg = "Pending error not found" + raise AssertionError(msg) + + +def the_comment_will_have_been_printed_to_the_console(stacklevel: int = 1) -> None: + @then( + parsers.re( + r'the comment "(?P[^"]+)" will have been printed to the console' + ), + stacklevel=stacklevel + 1, + ) + def _( + comment: str, + verifier_result: tuple[Verifier, Exception | None], + ) -> None: + """ + Check that the given comment was printed to the console. + """ + verifier, err = verifier_result + logger.debug("Checking for comment %r in verifier output", comment) + logger.debug("Verifier output: %s", verifier.output(strip_ansi=True)) + assert err is None + assert comment in verifier.output(strip_ansi=True) + + +def the_name_of_the_test_will_be_displayed_as_the_original_test_name( + stacklevel: int = 1, +) -> None: + @then( + parsers.re( + r'the "(?P[^"]+)" will displayed as the original test name' + ), + stacklevel=stacklevel + 1, + ) + def _( + test_name: str, + verifier_result: tuple[Verifier, Exception | None], + ) -> None: + """ + Check that the given test name was displayed as the original test name. + """ + verifier, err = verifier_result + logger.debug("Checking for test name %r in verifier output", test_name) + logger.debug("Verifier output: %s", verifier.output(strip_ansi=True)) + assert err is None + assert test_name in verifier.output(strip_ansi=True)