diff --git a/MANIFEST b/MANIFEST new file mode 100644 index 000000000..6d8bb2386 --- /dev/null +++ b/MANIFEST @@ -0,0 +1,29 @@ +# file GENERATED by distutils, do NOT edit +CHANGELOG.md +CONTRIBUTING.md +LICENSE +README.md +RELEASING.md +requirements_dev.txt +setup.cfg +setup.py +pact/__init__.py +pact/__version__.py +pact/broker.py +pact/constants.py +pact/consumer.py +pact/http_proxy.py +pact/matchers.py +pact/message_consumer.py +pact/message_pact.py +pact/message_provider.py +pact/pact.py +pact/provider.py +pact/verifier.py +pact/verify_wrapper.py +pact/bin/pact-1.88.77-linux-x86.tar.gz +pact/bin/pact-1.88.77-linux-x86_64.tar.gz +pact/bin/pact-1.88.77-osx.tar.gz +pact/bin/pact-1.88.77-win32.zip +pact/cli/__init__.py +pact/cli/verify.py diff --git a/examples/README.md b/examples/README.md index e74683017..439875dd1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,6 +9,7 @@ * [fastapi_provider](#fastapi_provider) * [message](#message) * [pacts](#pacts) + * [abc](#abc) ## Overview @@ -195,6 +196,20 @@ Both the Flask and the FastAPI [Provider] examples implement the same service th This folder contains the generated [Pact file] for reference, which is also used when running the [Provider] tests without a [Pact Broker]. +## abc + +In some situations, there may be a chain of dependent components e.g. `A -> B -> C`. Both Layers i.e. `A -> B` and +`B -> C` will need to be Pact tested separately, but the apparent "state" of `C` will need to be different for the +various tests of `A`. + +To deal with this, one approach is to use the provider state of `B` to mock the response from `C` using the Pact "given". + +In the examples here, we have the consumer A, `consumer_a_client` which sends all requests via B, `provider_b_hub`. +Sitting behind B are the various "internal" services, in this case `provider_c_products` and `provider_d_orders`. + +When defining the Pact between A and B, we declare the "state" of C and D - how B should mock the behaviour. + + [Pact Broker]: https://docs.pact.io/pact_broker [Pact Introduction]: https://docs.pact.io/ [Consumer]: https://docs.pact.io/getting_started/terminology#service-consumer diff --git a/examples/abc/consumer_a_client/conftest.py b/examples/abc/consumer_a_client/conftest.py new file mode 100644 index 000000000..4a189d508 --- /dev/null +++ b/examples/abc/consumer_a_client/conftest.py @@ -0,0 +1,49 @@ +import pytest +from testcontainers.compose import DockerCompose + + +def pytest_addoption(parser): + parser.addoption( + "--publish-pact", + type=str, + action="store", + help="Upload generated Pact file to Pact Broker with the version provided", + ) + + parser.addoption("--run-broker", type=bool, action="store", help="Whether to run broker in this test or not") + + +# This fixture is to simulate a managed Pact Broker or Pactflow account. +# For almost all purposes outside this example, you will want to use a real +# broker. See https://github.com/pact-foundation/pact_broker for further details. +@pytest.fixture(scope="session", autouse=True) +def broker(request): + version = request.config.getoption("--publish-pact") + publish = True if version else False + + # If the results are not going to be published to the broker, there is + # nothing further to do anyway + if not publish: + yield + return + + run_broker = request.config.getoption("--run-broker") + + if run_broker: + # Start up the broker using docker-compose + print("Starting broker") + with DockerCompose("../../broker", compose_file_name=["docker-compose.yml"], pull=True) as compose: + stdout, stderr = compose.get_logs() + if stderr: + print("Errors\\n:{}".format(stderr)) + print("{}".format(stdout)) + print("Started broker") + + yield + print("Stopping broker") + print("Broker stopped") + else: + # Assuming there is a broker available already, docker-compose has been + # used manually as the --run-broker option has not been provided + yield + return diff --git a/examples/abc/consumer_a_client/run_pytest.sh b/examples/abc/consumer_a_client/run_pytest.sh new file mode 100755 index 000000000..7494398bb --- /dev/null +++ b/examples/abc/consumer_a_client/run_pytest.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -o pipefail + +pytest tests --run-broker True --publish-pact 1 \ No newline at end of file diff --git a/examples/abc/consumer_a_client/src/consumer_a.py b/examples/abc/consumer_a_client/src/consumer_a.py new file mode 100644 index 000000000..460a0cda1 --- /dev/null +++ b/examples/abc/consumer_a_client/src/consumer_a.py @@ -0,0 +1,75 @@ +from typing import Optional, List + +import requests +from pydantic import BaseModel + + +class Product(BaseModel): + id: int + title: str + author: Optional[str] + category: Optional[str] + isbn: Optional[str] + published: Optional[int] + + +class Account(BaseModel): + id: int + name: str + phone: Optional[str] + + +class Order(BaseModel): + id: int + ordered: str + shipped: Optional[str] + product_ids: List[int] + + +class HubConsumer(object): + def __init__(self, base_uri: str): + self.base_uri = base_uri + + def get_account(self, account_id: int) -> Optional[Account]: + uri = f"{self.base_uri}/accounts/{account_id}" + response = requests.get(uri) + if response.status_code == 404: + return None + + name = response.json()["name"] + phone = response.json()["phone"] + + return Account(id=account_id, name=name, phone=phone) + + def get_products(self) -> List[Product]: + uri = f"{self.base_uri}/products" + response = requests.get(uri) + + products = [Product(id=j["id"], title=j["title"]) for j in response.json()] + + return products + + def get_product(self, product_id) -> Optional[Product]: + uri = f"{self.base_uri}/products/{product_id}" + response = requests.get(uri) + + j = response.json() + product = Product( + id=j["id"], + title=j["title"], + author=j["author"], + category=j["category"], + isbn=j["isbn"], + published=j["published"], + ) + + return product + + def get_order(self, order_id: int) -> Optional[Order]: + uri = f"{self.base_uri}/orders/{order_id}" + response = requests.get(uri) + + j = response.json() + order = Order(id=j["id"], ordered=j["ordered"], shipped=j["shipped"], product_ids=j["product_ids"]) + + return order diff --git a/examples/abc/consumer_a_client/tests/consumer/test_consumer_a.py b/examples/abc/consumer_a_client/tests/consumer/test_consumer_a.py new file mode 100644 index 000000000..60104a55d --- /dev/null +++ b/examples/abc/consumer_a_client/tests/consumer/test_consumer_a.py @@ -0,0 +1,137 @@ +"""pact test for hub service consumer""" + +import atexit +import logging +import os + +import pytest + +from pact import Consumer, Like, Provider, EachLike +from src.consumer_a import HubConsumer + +log = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + +PACT_BROKER_URL = "http://localhost" +PACT_BROKER_USERNAME = "pactbroker" +PACT_BROKER_PASSWORD = "pactbroker" + +PACT_MOCK_HOST = "localhost" +PACT_MOCK_PORT = 1234 + +PACT_DIR = os.path.dirname(os.path.realpath(__file__)) + +EXAMPLE_BOOK = { + "id": 1, + "title": "The Last Continent", + "author": "Terry Pratchett", + "category": "Fantasy", + "isbn": "0385409893", + "published": "1998", +} + +EXAMPLE_ORDER = {"id": 1, "ordered": "2021-11-01", "shipped": "2021-11-14", "product_ids": [1, 2]} + + +@pytest.fixture +def consumer() -> HubConsumer: + return HubConsumer("http://{host}:{port}".format(host=PACT_MOCK_HOST, port=PACT_MOCK_PORT)) + + +@pytest.fixture(scope="session") +def pact(request): + """Setup a Pact Consumer, which provides the Provider mock service. This + will generate and optionally publish Pacts to the Pact Broker""" + + # When publishing a Pact to the Pact Broker, a version number of the Consumer + # is required, to be able to construct the compatability matrix between the + # Consumer versions and Provider versions + version = request.config.getoption("--publish-pact") + publish = True if version else False + + pact = Consumer("ConsumerAClient", version=version).has_pact_with( + Provider("ProviderBHub"), + host_name=PACT_MOCK_HOST, + port=PACT_MOCK_PORT, + pact_dir=PACT_DIR, + publish_to_broker=publish, + broker_base_url=PACT_BROKER_URL, + broker_username=PACT_BROKER_USERNAME, + broker_password=PACT_BROKER_PASSWORD, + ) + + pact.start_service() + + # Make sure the Pact mocked provider is stopped when we finish, otherwise + # port 1234 may become blocked + atexit.register(pact.stop_service) + + yield pact + + # This will stop the Pact mock server, and if publish is True, submit Pacts + # to the Pact Broker + pact.stop_service() + + # Given we have cleanly stopped the service, we do not want to re-submit the + # Pacts to the Pact Broker again atexit, since the Broker may no longer be + # available if it has been started using the --run-broker option, as it will + # have been torn down at that point + pact.publish_to_broker = False + + +def test_get_product(pact, consumer): + ( + pact.given("Some books exist") + .upon_receiving("a request for product 1") + .with_request("get", "/products/1") + .will_respond_with(200, body=Like(EXAMPLE_BOOK)) + ) + + with pact: + product = consumer.get_product(1) + assert product.title == "The Last Continent" + pact.verify() + + +def test_get_products(pact, consumer): + + ( + pact.given("Some books exist") + .upon_receiving("a request for products") + .with_request("get", "/products") + .will_respond_with(200, body=EachLike(EXAMPLE_BOOK)) + ) + + with pact: + products = consumer.get_products() + assert len(products) > 0 + pact.verify() + + +def test_get_products_empty(pact, consumer): + + ( + pact.given("No books exist") + .upon_receiving("a request for products when none exist") + .with_request("get", "/products") + .will_respond_with(200, body=[]) + ) + + with pact: + products = consumer.get_products() + assert len(products) == 0 + pact.verify() + + +def test_get_order(pact, consumer): + ( + pact.given("Some orders exist") + .upon_receiving("a request for order 1") + .with_request("get", "/orders/1") + .will_respond_with(200, body=Like(EXAMPLE_ORDER)) + ) + + with pact: + order = consumer.get_order(1) + assert order.ordered == "2021-11-01" + pact.verify() diff --git a/examples/abc/pacts/consumeraclient-providerbhub.json b/examples/abc/pacts/consumeraclient-providerbhub.json new file mode 100644 index 000000000..238c03aab --- /dev/null +++ b/examples/abc/pacts/consumeraclient-providerbhub.json @@ -0,0 +1,115 @@ +{ + "consumer": { + "name": "ConsumerAClient" + }, + "provider": { + "name": "ProviderBHub" + }, + "interactions": [ + { + "description": "a request for product 1", + "providerState": "Some books exist", + "request": { + "method": "get", + "path": "/products/1" + }, + "response": { + "status": 200, + "headers": { + }, + "body": { + "id": 1, + "title": "The Last Continent", + "author": "Terry Pratchett", + "category": "Fantasy", + "isbn": "0385409893", + "published": "1998" + }, + "matchingRules": { + "$.body": { + "match": "type" + } + } + } + }, + { + "description": "a request for products", + "providerState": "Some books exist", + "request": { + "method": "get", + "path": "/products" + }, + "response": { + "status": 200, + "headers": { + }, + "body": [ + { + "id": 1, + "title": "The Last Continent", + "author": "Terry Pratchett", + "category": "Fantasy", + "isbn": "0385409893", + "published": "1998" + } + ], + "matchingRules": { + "$.body": { + "min": 1 + }, + "$.body[*].*": { + "match": "type" + } + } + } + }, + { + "description": "a request for products when none exist", + "providerState": "No books exist", + "request": { + "method": "get", + "path": "/products" + }, + "response": { + "status": 200, + "headers": { + }, + "body": [ + + ] + } + }, + { + "description": "a request for order 1", + "providerState": "Some orders exist", + "request": { + "method": "get", + "path": "/orders/1" + }, + "response": { + "status": 200, + "headers": { + }, + "body": { + "id": 1, + "ordered": "2021-11-01", + "shipped": "2021-11-14", + "product_ids": [ + 1, + 2 + ] + }, + "matchingRules": { + "$.body": { + "match": "type" + } + } + } + } + ], + "metadata": { + "pactSpecification": { + "version": "2.0.0" + } + } +} \ No newline at end of file diff --git a/examples/abc/pacts/consumerbclient-providercproducts.json b/examples/abc/pacts/consumerbclient-providercproducts.json new file mode 100644 index 000000000..a785eb184 --- /dev/null +++ b/examples/abc/pacts/consumerbclient-providercproducts.json @@ -0,0 +1,62 @@ +{ + "consumer": { + "name": "ConsumerBClient" + }, + "provider": { + "name": "ProviderCProducts" + }, + "interactions": [ + { + "description": "todo", + "providerState": "Some books exist", + "request": { + "method": "GET", + "path": "/" + }, + "response": { + "status": 200, + "headers": { + }, + "body": [ + { + "id": 1, + "title": "The Last Continent", + "author": "Terry Pratchett", + "category": "Fantasy", + "isbn": "0385409893", + "published": "1998" + } + ], + "matchingRules": { + "$.body": { + "min": 1 + }, + "$.body[*].*": { + "match": "type" + } + } + } + }, + { + "description": "todo", + "providerState": "No books exist", + "request": { + "method": "GET", + "path": "/" + }, + "response": { + "status": 200, + "headers": { + }, + "body": [ + + ] + } + } + ], + "metadata": { + "pactSpecification": { + "version": "2.0.0" + } + } +} \ No newline at end of file diff --git a/examples/abc/pacts/consumerbclient-providerdorders.json b/examples/abc/pacts/consumerbclient-providerdorders.json new file mode 100644 index 000000000..753040291 --- /dev/null +++ b/examples/abc/pacts/consumerbclient-providerdorders.json @@ -0,0 +1,47 @@ +{ + "consumer": { + "name": "ConsumerBClient" + }, + "provider": { + "name": "ProviderDOrders" + }, + "interactions": [ + { + "description": "a request for order 1", + "providerState": "Some orders exist", + "request": { + "method": "get", + "path": "/1" + }, + "response": { + "status": 200, + "headers": { + }, + "body": [ + { + "id": 1, + "ordered": "2021-11-01", + "shipped": "2021-11-14", + "product_ids": [ + 1, + 2 + ] + } + ], + "matchingRules": { + "$.body": { + "min": 1 + }, + "$.body[*].*": { + "match": "type" + } + } + } + } + ], + "metadata": { + "pactSpecification": { + "version": "2.0.0" + } + } +} \ No newline at end of file diff --git a/examples/abc/provider_b_hub/src/provider_b.py b/examples/abc/provider_b_hub/src/provider_b.py new file mode 100644 index 000000000..7d2089598 --- /dev/null +++ b/examples/abc/provider_b_hub/src/provider_b.py @@ -0,0 +1,62 @@ +import logging + +import requests +from fastapi import FastAPI, APIRouter +from fastapi.logger import logger + +PRODUCT_URL = "https://productstore" +ORDER_URL = "https://orders" + +logger.setLevel(logging.DEBUG) + +router = APIRouter() +app = FastAPI() + +session = requests.Session() + + +class ProductConsumer(object): + def __init__(self, base_uri: str): + self.base_uri = base_uri + + def get_product_by_id(self, product_id: int): + response = session.get(self.base_uri) + product = [p for p in response.json() if p["id"] == product_id] + + if product: + return product[0] + else: + return None + + def get_products(self): + response = session.get(self.base_uri) + return response.json() + + +class OrderConsumer(object): + def __init__(self, base_uri: str): + self.base_uri = base_uri + + def get_order(self, order_id: int): + url = f"{self.base_uri}/{order_id}" + response = session.get(url) + return response.json() + + +product_consumer = ProductConsumer(PRODUCT_URL) +order_consumer = OrderConsumer(ORDER_URL) + + +@app.get("/products/{product_id}") +def get_product_by_id(product_id: int): + return product_consumer.get_product_by_id(product_id=product_id) + + +@app.get("/products") +def get_products(): + return product_consumer.get_products() + + +@app.get("/orders/{order_id}") +def get_order(order_id: int): + return order_consumer.get_order(order_id=order_id) diff --git a/examples/abc/provider_b_hub/tests/__init__.py b/examples/abc/provider_b_hub/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/abc/provider_b_hub/tests/conftest.py b/examples/abc/provider_b_hub/tests/conftest.py new file mode 100644 index 000000000..3933cb3f5 --- /dev/null +++ b/examples/abc/provider_b_hub/tests/conftest.py @@ -0,0 +1,102 @@ +import pathlib +import sys +from multiprocessing import Process + +import docker +import pytest +import requests_mock +from testcontainers.compose import DockerCompose + +from src.provider_b import session +from .pact_provider_b import run_server + + +@pytest.fixture(scope="module") +def server(): + # Before running the server, setup a mock adapter to handle calls + adapter = requests_mock.Adapter() + session.mount("https://", adapter) + + proc = Process(target=run_server, args=(), daemon=True) + proc.start() + yield proc + + # Cleanup after test + if sys.version_info >= (3, 7): + # multiprocessing.kill is new in 3.7 + proc.kill() + else: + proc.terminate() + + +@pytest.fixture(scope="session", autouse=True) +def publish_existing_pact(broker): + source = str(pathlib.Path.cwd().joinpath("..", "pacts").resolve()) + pacts = [f"{source}:/pacts"] + envs = { + "PACT_BROKER_BASE_URL": "http://broker_app:9292", + "PACT_BROKER_USERNAME": "pactbroker", + "PACT_BROKER_PASSWORD": "pactbroker", + } + + client = docker.from_env() + + print("Publishing existing Pact") + client.containers.run( + remove=True, + network="broker_default", + volumes=pacts, + image="pactfoundation/pact-cli:latest", + environment=envs, + command="publish /pacts --consumer-app-version 1", + ) + print("Finished publishing") + + +@pytest.fixture(scope="session", autouse=True) +def broker(): + # Start up the broker using docker-compose + print("Starting broker") + with DockerCompose("../../broker", compose_file_name=["docker-compose.yml"], pull=True) as compose: + stdout, stderr = compose.get_logs() + if stderr: + print("Errors\\n:{}".format(stderr)) + print("{}".format(stdout)) + print("Started broker") + + yield + print("Stopping broker") + print("Broker stopped") + + +# To help see what is going on, using this approach from: https://pythontesting.net/framework/pytest/pytest-session-scoped-fixtures/ +# these are just some fun dividiers to make the output pretty +# completely unnecessary, I was just playing with autouse fixtures +@pytest.fixture(scope="function", autouse=True) +def divider_function(request): + print("\n --- function %s() start ---" % request.function.__name__) + + def fin(): + print(" --- function %s() done ---" % request.function.__name__) + + request.addfinalizer(fin) + + +@pytest.fixture(scope="module", autouse=True) +def divider_module(request): + print("\n ------- module %s start ---------" % request.module.__name__) + + def fin(): + print(" ------- module %s done ---------" % request.module.__name__) + + request.addfinalizer(fin) + + +@pytest.fixture(scope="session", autouse=True) +def divider_session(request): + print("\n----------- session start ---------------") + + def fin(): + print("----------- session done ---------------") + + request.addfinalizer(fin) diff --git a/examples/abc/provider_b_hub/tests/interactions.py b/examples/abc/provider_b_hub/tests/interactions.py new file mode 100644 index 000000000..fb9e8fb00 --- /dev/null +++ b/examples/abc/provider_b_hub/tests/interactions.py @@ -0,0 +1,49 @@ +from dataclasses import dataclass +from typing import Any, Dict + + +@dataclass +class RequestArgs: + action: str + base: str + path: str + + +@dataclass +class RequestResponsePair: + provider_name: str + request_args: RequestArgs + response_status: int + response_content_filename: str + method_name: str + method_args: Dict[str, Any] + + +# Define the various states which Consumers request of this Provider, along with the corresponding call this Provider will need to make +# This will then be used when this Provider then acts as a Consumer +HUB_TO_PROVIDER_INTERACTIONS: Dict[str, RequestResponsePair] = { + "Some books exist": RequestResponsePair( + provider_name="ProviderCProducts", + request_args=RequestArgs("GET", "https://productstore", "/"), + response_status=200, + response_content_filename="books.json", + method_name="get_products", + method_args={}, + ), + "No books exist": RequestResponsePair( + provider_name="ProviderCProducts", + request_args=RequestArgs("GET", "https://productstore", "/"), + response_status=200, + response_content_filename="empty_array.json", + method_name="get_products", + method_args={}, + ), + "Some orders exist": RequestResponsePair( + provider_name="ProviderDOrders", + request_args=RequestArgs("GET", "https://orders", "/1"), + response_status=200, + response_content_filename="order.json", + method_name="get_order", + method_args={"order_id": 1}, + ), +} diff --git a/examples/abc/provider_b_hub/tests/pact_provider_b.py b/examples/abc/provider_b_hub/tests/pact_provider_b.py new file mode 100644 index 000000000..f49967e4e --- /dev/null +++ b/examples/abc/provider_b_hub/tests/pact_provider_b.py @@ -0,0 +1,64 @@ +import json + +import pytest +import uvicorn +from fastapi import APIRouter +from pydantic import BaseModel + +from src.provider_b import app, router as main_router, session +from .interactions import HUB_TO_PROVIDER_INTERACTIONS + +pact_router = APIRouter() + +monkey_patch = None + + +@pytest.fixture(autouse=True) +def set_monkey_patch(monkeypatch): + global monkey_patch + monkey_patch = monkeypatch + + +class ProviderState(BaseModel): + state: str # noqa: E999 + + +@pact_router.post("/_pact/provider_states") +async def provider_states(provider_state: ProviderState): + setup_chained_provider_mock_state(provider_state.state) + + +# Make sure the app includes both routers. This needs to be done after the +# declaration of the provider_states +app.include_router(main_router) +app.include_router(pact_router) + + +def run_server(): + uvicorn.run(app) + + +def setup_chained_provider_mock_state(given): + """Define the expected interaction with a provider from the hub, mocking the response. + + :param given: "Given" string from the Consumer test + """ + print(f"YYYYYY {given=}") + interaction = HUB_TO_PROVIDER_INTERACTIONS[given] + json_data = load_json(interaction.response_content_filename) + print(f"XXXXXXX {json_data=}") + session.adapters.get("https://").register_uri( + interaction.request_args.action, + f"{interaction.request_args.base}{interaction.request_args.path}", + json=json_data, + status_code=interaction.response_status, + ) + + +def load_json(filename: str): + """Load and return the JSON contained in a file within the resources folder. + + :param filename: Filename to load, including the extension but not including any path e.g. "order.json" + """ + with open(f"tests/resources/{filename}") as data_file: + return json.loads(data_file.read()) diff --git a/examples/abc/provider_b_hub/tests/provider/__init__.py b/examples/abc/provider_b_hub/tests/provider/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/abc/provider_b_hub/tests/provider/test_provider_b_consumer_provider_c_provider.py b/examples/abc/provider_b_hub/tests/provider/test_provider_b_consumer_provider_c_provider.py new file mode 100644 index 000000000..955efa70f --- /dev/null +++ b/examples/abc/provider_b_hub/tests/provider/test_provider_b_consumer_provider_c_provider.py @@ -0,0 +1,85 @@ +import atexit +import json +import logging +import os +import pathlib + +import pytest + +from interactions import HUB_TO_PROVIDER_INTERACTIONS +from pact import Consumer, Provider, EachLike +from src.provider_b import ProductConsumer + +log = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + +PACT_BROKER_URL = "http://localhost" +PACT_BROKER_USERNAME = "pactbroker" +PACT_BROKER_PASSWORD = "pactbroker" + +PACT_MOCK_HOST = "localhost" +PACT_MOCK_PORT = 1234 + +PROVIDER_NAME = "ProviderCProducts" + +PACT_DIR = os.path.dirname(os.path.realpath(__file__)) + + +@pytest.fixture +def product_consumer() -> ProductConsumer: + return ProductConsumer("http://{host}:{port}".format(host=PACT_MOCK_HOST, port=PACT_MOCK_PORT)) + + +@pytest.fixture(scope="session") +def pact(request): + version = 1 + pact = Consumer("ConsumerBClient", version=version).has_pact_with( + Provider(PROVIDER_NAME), + host_name=PACT_MOCK_HOST, + port=PACT_MOCK_PORT, + pact_dir=PACT_DIR, + publish_to_broker=False, + broker_base_url=PACT_BROKER_URL, + broker_username=PACT_BROKER_USERNAME, + broker_password=PACT_BROKER_PASSWORD, + ) + + pact.start_service() + atexit.register(pact.stop_service) + + yield pact + + pact.stop_service() + pact.publish_to_broker = False + + +@pytest.mark.parametrize( + "given,pair", + [(given, pair) for given, pair in HUB_TO_PROVIDER_INTERACTIONS.items() if pair.provider_name == PROVIDER_NAME], +) +def test_interactions(pact, product_consumer, given, pair): + """For every interaction state defined, which is used by the Consumer A -> Provider B states, perform the appropriate call with B acting as Consumer. + + This ensures that we are actually mocking up valid expected responses from the chained service, since this will ensure that there is a valid Pact between + B and C, D etc. + """ + source = str(pathlib.Path.cwd().joinpath("tests/resources").joinpath(pair.response_content_filename).resolve()) + with open(source) as json_file: + payload = json.load(json_file) + + # TODO: Needs to be more generic / able to handle other cases + if len(payload) > 0: + body = EachLike(payload[0]) + else: + body = [] + + ( + pact.given(given) + .upon_receiving("todo") + .with_request(pair.request_args.action, pair.request_args.path) + .will_respond_with(pair.response_status, body=body) + ) + + with pact: + getattr(product_consumer, pair.method_name)(**pair.method_args) + pact.verify() diff --git a/examples/abc/provider_b_hub/tests/provider/test_provider_b_consumer_provider_d_provider.py b/examples/abc/provider_b_hub/tests/provider/test_provider_b_consumer_provider_d_provider.py new file mode 100644 index 000000000..62bdbeda9 --- /dev/null +++ b/examples/abc/provider_b_hub/tests/provider/test_provider_b_consumer_provider_d_provider.py @@ -0,0 +1,65 @@ +import atexit +import logging +import os + +import pytest + +from pact import Consumer, Provider, EachLike +from src.provider_b import OrderConsumer + +log = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + +PACT_BROKER_URL = "http://localhost" +PACT_BROKER_USERNAME = "pactbroker" +PACT_BROKER_PASSWORD = "pactbroker" + +PACT_MOCK_HOST = "localhost" +PACT_MOCK_PORT = 1235 # Since we will use multiple Consumers, we need unique ports to avoid a conflict + +PACT_DIR = os.path.dirname(os.path.realpath(__file__)) + +EXAMPLE_ORDER = {"id": 1, "ordered": "2021-11-01", "shipped": "2021-11-14", "product_ids": [1, 2]} + + +@pytest.fixture +def order_consumer() -> OrderConsumer: + return OrderConsumer("http://{host}:{port}".format(host=PACT_MOCK_HOST, port=PACT_MOCK_PORT)) + + +@pytest.fixture(scope="session") +def pact(): + version = 1 + + pact = Consumer("ConsumerBClient", version=version).has_pact_with( + Provider("ProviderDOrders"), + host_name=PACT_MOCK_HOST, + port=PACT_MOCK_PORT, + pact_dir=PACT_DIR, + publish_to_broker=False, + broker_base_url=PACT_BROKER_URL, + broker_username=PACT_BROKER_USERNAME, + broker_password=PACT_BROKER_PASSWORD, + ) + + pact.start_service() + atexit.register(pact.stop_service) + + yield pact + + pact.stop_service() + pact.publish_to_broker = False + + +def test_get_orders(pact, order_consumer): + ( + pact.given("Some orders exist") + .upon_receiving("a request for order 1") + .with_request("get", "/1") + .will_respond_with(200, body=EachLike(EXAMPLE_ORDER)) + ) + + with pact: + orders = order_consumer.get_order(1) + assert len(orders) > 0 + pact.verify() diff --git a/examples/abc/provider_b_hub/tests/provider/test_provider_b_provider.py b/examples/abc/provider_b_hub/tests/provider/test_provider_b_provider.py new file mode 100644 index 000000000..0c5d37434 --- /dev/null +++ b/examples/abc/provider_b_hub/tests/provider/test_provider_b_provider.py @@ -0,0 +1,47 @@ +"""pact test for user service client""" +import logging + +import pytest + +from pact import Verifier + +log = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + +# For the purposes of this example, the broker is started up as a fixture defined +# in conftest.py. For normal usage this would be self-hosted or using Pactflow. +PACT_BROKER_URL = "http://localhost" +PACT_BROKER_USERNAME = "pactbroker" +PACT_BROKER_PASSWORD = "pactbroker" + +# For the purposes of this example, the FastAPI provider will be started up as +# a fixture in conftest.py ("server"). Alternatives could be, for example +# running a Docker container with a database of test data configured. +# This is the "real" provider to verify against. +PROVIDER_HOST = "127.0.0.1" +PROVIDER_PORT = 8000 +PROVIDER_URL = f"http://{PROVIDER_HOST}:{PROVIDER_PORT}" +PROVIDER_NAME = "ProviderBHub" + + +@pytest.fixture +def broker_opts(): + return { + "broker_username": PACT_BROKER_USERNAME, + "broker_password": PACT_BROKER_PASSWORD, + "broker_url": PACT_BROKER_URL, + "publish_version": "3", + "publish_verification_results": True, + } + + +def test_provider_b_hub_against_broker(server, broker_opts, **kwargs): + verifier = Verifier(provider=PROVIDER_NAME, provider_base_url=PROVIDER_URL) + + success, logs = verifier.verify_with_broker( + **broker_opts, + verbose=True, + provider_states_setup_url=f"{PROVIDER_URL}/_pact/provider_states", + enable_pending=False, + ) + assert success == 0 diff --git a/examples/abc/provider_b_hub/tests/resources/books.json b/examples/abc/provider_b_hub/tests/resources/books.json new file mode 100644 index 000000000..90c4a990d --- /dev/null +++ b/examples/abc/provider_b_hub/tests/resources/books.json @@ -0,0 +1,18 @@ +[ + { + "id": 1, + "title": "The Last Continent", + "author": "Terry Pratchett", + "category": "Fantasy", + "isbn": "0385409893", + "published": "1998" + }, + { + "id": 2, + "title": "Northern Lights", + "author": "Philip Pullman", + "category": "Fantasy", + "isbn": "0-590-54178-1", + "published": "1995-07-09" + } +] \ No newline at end of file diff --git a/examples/abc/provider_b_hub/tests/resources/empty_array.json b/examples/abc/provider_b_hub/tests/resources/empty_array.json new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/examples/abc/provider_b_hub/tests/resources/empty_array.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/examples/abc/provider_b_hub/tests/resources/order.json b/examples/abc/provider_b_hub/tests/resources/order.json new file mode 100644 index 000000000..00b4820e2 --- /dev/null +++ b/examples/abc/provider_b_hub/tests/resources/order.json @@ -0,0 +1,9 @@ +{ + "id": 1, + "ordered": "2021-11-01", + "shipped": "2021-11-14", + "product_ids": [ + 1, + 2 + ] +} \ No newline at end of file