diff --git a/examples/message/README.md b/examples/message/README.md deleted file mode 100644 index 13c738057..000000000 --- a/examples/message/README.md +++ /dev/null @@ -1,246 +0,0 @@ -# Introduction - -This is an e2e example that uses messages, including a sample implementation of a message handler. - -## Consumer - -A Consumer is the system that will be reading a message from a queue or some intermediary. In this example, the consumer is a Lambda function that handles the message. - -From a Pact testing point of view, Pact takes the place of the intermediary (MQ/broker etc.) and confirms whether or not the consumer is able to handle a request. - -``` -+-----------+ +-------------------+ -| (Pact) | message |(Message Consumer) | -| MQ/broker |--------->|Lambda Function | -| | |check valid doc | -+-----------+ +-------------------+ -``` - -Below is a sample message handler that only accepts that the key `documentType` would only be `microsoft-word`. If not, the message handler will throw an exception (`CustomError`) - -```python -class CustomError(Exception): - def __init__(self, *args): - if args: - self.topic = args[0] - else: - self.topic = None - - def __str__(self): - if self.topic: - return 'Custom Error:, {0}'.format(self.topic) - -class MessageHandler(object): - def __init__(self, event): - self.pass_event(event) - - @staticmethod - def pass_event(event): - if event.get('documentType') != 'microsoft-word': - raise CustomError("Not correct document type") -``` - -Below is a snippet from a test where the message handler has no error. -Since the expected event contains a key `documentType` with value `microsoft-word`, message handler does not throw an error and a pact file `f"{PACT_FILE}""` is expected to be generated. - -```python -def test_generate_new_pact_file(pact): - cleanup_json(PACT_FILE) - - expected_event = { - 'documentName': 'document.doc', - 'creator': 'TP', - 'documentType': 'microsoft-word' - } - - (pact - .given('A document create in Document Service') - .expects_to_receive('Description') - .with_content(expected_event) - .with_metadata({ - 'Content-Type': 'application/json' - })) - - with pact: - # handler needs 'documentType' == 'microsoft-word' - MessageHandler(expected_event) - - progressive_delay(f"{PACT_FILE}") - assert isfile(f"{PACT_FILE}") == 1 -``` - -For a similar test where the event does not contain a key `documentType` with value `microsoft-word`, a `CustomError` is generated and there there is no generated json file `f"{PACT_FILE}"`. - -```python -def test_throw_exception_handler(pact): - cleanup_json(PACT_FILE) - wrong_event = { - 'documentName': 'spreadsheet.xls', - 'creator': 'WI', - 'documentType': 'microsoft-excel' - } - - (pact - .given('Another document in Document Service') - .expects_to_receive('Description') - .with_content(wrong_event) - .with_metadata({ - 'Content-Type': 'application/json' - })) - - with pytest.raises(CustomError): - with pact: - # handler needs 'documentType' == 'microsoft-word' - MessageHandler(wrong_event) - - progressive_delay(f"{PACT_FILE}") - assert isfile(f"{PACT_FILE}") == 0 -``` - -## Provider - -``` -+-------------------+ +-----------+ -|(Message Provider) | message | (Pact) | -|Document Upload |--------->| MQ/broker | -|Service | | | -+-------------------+ +-----------+ -``` - -```python -import pytest -from pact import MessageProvider - -def document_created_handler(): - return { - "event": "ObjectCreated:Put", - "documentName": "document.doc", - "creator": "TP", - "documentType": "microsoft-word" - } - - -def test_verify_success(): - provider = MessageProvider( - message_providers={ - 'A document created successfully': document_created_handler - }, - provider='ContentProvider', - consumer='DetectContentLambda', - pact_dir='pacts' - - ) - with provider: - provider.verify() -``` - - -### Provider with pact broker -```python -import pytest -from pact import MessageProvider - - -PACT_BROKER_URL = "http://localhost" -PACT_BROKER_USERNAME = "pactbroker" -PACT_BROKER_PASSWORD = "pactbroker" -PACT_DIR = "pacts" - - -@pytest.fixture -def default_opts(): - return { - 'broker_username': PACT_BROKER_USERNAME, - 'broker_password': PACT_BROKER_PASSWORD, - 'broker_url': PACT_BROKER_URL, - 'publish_version': '3', - 'publish_verification_results': False - } - -def document_created_handler(): - return { - "event": "ObjectCreated:Put", - "documentName": "document.doc", - "creator": "TP", - "documentType": "microsoft-word" - } - -def test_verify_from_broker(default_opts): - provider = MessageProvider( - message_providers={ - 'A document created successfully': document_created_handler, - }, - provider='ContentProvider', - consumer='DetectContentLambda', - pact_dir='pacts' - - ) - - with pytest.raises(AssertionError): - with provider: - provider.verify_with_broker(**default_opts) - -``` - -## E2E Messaging - -``` -+-------------------+ +-----------+ +-------------------+ -|(Message Provider) | message | (Pact) | message |(Message Consumer) | -|Document Upload |--------->| MQ/broker |--------->|Lambda Function | -|Service | | | |check valid doc | -+-------------------+ +-----------+ +-------------------+ -``` - -# Setup - -## Virtual Environment - -Go to the `example/message` directory Create your own virtualenv for this. Run - -```bash -pip install -r requirements.txt -pip install -e ../../ -pytest -``` - -## Message Consumer - -From the root directory run: - -```bash -pytest -``` - -Or you can run individual tests like: - -```bash -pytest tests/consumer/test_message_consumer.py::test_generate_new_pact_file -``` - -## With Broker - -The current consumer test can run even without a local broker, -but this is added for demo purposes. - -Open a separate terminal in the `examples/broker` folder and run: - -```bash -docker-compose up -``` - -Open a browser to http://localhost and see the broker you have succeeded. -If needed, log-in using the provided details in tests such as: - -``` -PACT_BROKER_USERNAME = "pactbroker" -PACT_BROKER_PASSWORD = "pactbroker" -``` - -To get the consumer to publish a pact to broker, -open a new terminal in the `examples/message` and run the following (2 is an arbitary version number). The first part makes sure that the an existing json has been generated: - -```bash -pytest tests/consumer/test_message_consumer.py::test_publish_to_broker -pytest tests/consumer/test_message_consumer.py::test_publish_to_broker --publish-pact 2 -``` diff --git a/examples/message/conftest.py b/examples/message/conftest.py deleted file mode 100644 index 90ac63b9a..000000000 --- a/examples/message/conftest.py +++ /dev/null @@ -1,8 +0,0 @@ -import sys - -# Load in the fixtures from common/sharedfixtures.py -sys.path.append("../common") - -pytest_plugins = [ - "sharedfixtures", -] diff --git a/examples/message/requirements.txt b/examples/message/requirements.txt deleted file mode 100644 index 021958ce1..000000000 --- a/examples/message/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -Flask==2.0.3; python_version < '3.7' -Flask==2.2.5; python_version >= '3.7' -pytest==7.0.1; python_version < '3.7' -pytest==7.1.3; python_version >= '3.7' -requests==2.27.1; python_version < '3.7' -requests>=2.28.0; python_version >= '3.7' diff --git a/examples/message/run_pytest.sh b/examples/message/run_pytest.sh deleted file mode 100755 index 35dade4fb..000000000 --- a/examples/message/run_pytest.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash -set -o pipefail - -pytest --run-broker True --publish-pact 2 - -# publish to broker assuming broker is active -# pytest tests/consumer/test_message_consumer.py::test_publish_to_broker --publish-pact 2 diff --git a/examples/message/src/message_handler.py b/examples/message/src/message_handler.py deleted file mode 100644 index 1be2b4641..000000000 --- a/examples/message/src/message_handler.py +++ /dev/null @@ -1,19 +0,0 @@ -class CustomError(Exception): - def __init__(self, *args): - if args: - self.topic = args[0] - else: - self.topic = None - - def __str__(self): - if self.topic: - return 'Custom Error:, {0}'.format(self.topic) - -class MessageHandler(object): - def __init__(self, event): - self.pass_event(event) - - @staticmethod - def pass_event(event): - if event.get('documentType') != 'microsoft-word': - raise CustomError("Not correct document type") diff --git a/examples/message/tests/consumer/__init__.py b/examples/message/tests/consumer/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/message/tests/consumer/test_message_consumer.py b/examples/message/tests/consumer/test_message_consumer.py deleted file mode 100644 index 3c7de4c1a..000000000 --- a/examples/message/tests/consumer/test_message_consumer.py +++ /dev/null @@ -1,156 +0,0 @@ -"""pact test for a message consumer""" - -import logging -import pytest -import time - -from os import remove -from os.path import isfile - -from pact import MessageConsumer, Provider -from src.message_handler import MessageHandler, CustomError - -log = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) - -PACT_BROKER_URL = "http://localhost" -PACT_BROKER_USERNAME = "pactbroker" -PACT_BROKER_PASSWORD = "pactbroker" -PACT_DIR = "pacts" - -CONSUMER_NAME = "DetectContentLambda" -PROVIDER_NAME = "ContentProvider" -PACT_FILE = (f"{PACT_DIR}/{CONSUMER_NAME.lower().replace(' ', '_')}-" - + f"{PROVIDER_NAME.lower().replace(' ', '_')}.json") - - -@pytest.fixture(scope="session") -def pact(request): - version = request.config.getoption("--publish-pact") - publish = True if version else False - - pact = MessageConsumer(CONSUMER_NAME, version=version).has_pact_with( - Provider(PROVIDER_NAME), - publish_to_broker=publish, broker_base_url=PACT_BROKER_URL, - broker_username=PACT_BROKER_USERNAME, broker_password=PACT_BROKER_PASSWORD, pact_dir=PACT_DIR) - - yield pact - - -@pytest.fixture(scope="session") -def pact_no_publish(request): - version = request.config.getoption("--publish-pact") - pact = MessageConsumer(CONSUMER_NAME, version=version).has_pact_with( - Provider(PROVIDER_NAME), - publish_to_broker=False, broker_base_url=PACT_BROKER_URL, - broker_username=PACT_BROKER_USERNAME, broker_password=PACT_BROKER_PASSWORD, pact_dir=PACT_DIR) - - yield pact - -def cleanup_json(file): - """ - Remove existing json file before test if any - """ - if (isfile(f"{file}")): - remove(f"{file}") - - -def progressive_delay(file, time_to_wait=10, second_interval=0.5, verbose=False): - """ - progressive delay - defaults to wait up to 5 seconds with 0.5 second intervals - """ - time_counter = 0 - while not isfile(file): - time.sleep(second_interval) - time_counter += 1 - if verbose: - print(f"Trying for {time_counter*second_interval} seconds") - if time_counter > time_to_wait: - if verbose: - print(f"Already waited {time_counter*second_interval} seconds") - break - - -def test_throw_exception_handler(pact_no_publish): - cleanup_json(PACT_FILE) - - wrong_event = { - "event": "ObjectCreated:Put", - "documentName": "spreadsheet.xls", - "creator": "WI", - "documentType": "microsoft-excel" - } - - (pact_no_publish - .given("Document unsupported type") - .expects_to_receive("Description") - .with_content(wrong_event) - .with_metadata({ - "Content-Type": "application/json" - })) - - with pytest.raises(CustomError): - with pact_no_publish: - # handler needs "documentType" == "microsoft-word" - MessageHandler(wrong_event) - - progressive_delay(f"{PACT_FILE}") - assert isfile(f"{PACT_FILE}") == 0 - - -def test_put_file(pact_no_publish): - cleanup_json(PACT_FILE) - - expected_event = { - "event": "ObjectCreated:Put", - "documentName": "document.doc", - "creator": "TP", - "documentType": "microsoft-word" - } - - (pact_no_publish - .given("A document created successfully") - .expects_to_receive("Description") - .with_content(expected_event) - .with_metadata({ - "Content-Type": "application/json" - })) - - with pact_no_publish: - MessageHandler(expected_event) - - progressive_delay(f"{PACT_FILE}") - assert isfile(f"{PACT_FILE}") == 1 - - -def test_publish_to_broker(pact): - """ - This test does not clean-up previously generated pact. - Sample execution where 2 is an arbitrary version: - - `pytest tests/consumer/test_message_consumer.py::test_publish_pact_to_broker` - - `pytest tests/consumer/test_message_consumer.py::test_publish_pact_to_broker --publish-pact 2` - """ - - expected_event = { - "event": "ObjectCreated:Delete", - "documentName": "document.doc", - "creator": "TP", - "documentType": "microsoft-word" - } - - (pact - .given("A document deleted successfully") - .expects_to_receive("Description with broker") - .with_content(expected_event) - .with_metadata({ - "Content-Type": "application/json" - })) - - with pact: - MessageHandler(expected_event) - - progressive_delay(f"{PACT_FILE}") - assert isfile(f"{PACT_FILE}") == 1 diff --git a/examples/message/tests/provider/__init__.py b/examples/message/tests/provider/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/message/tests/provider/test_message_provider.py b/examples/message/tests/provider/test_message_provider.py deleted file mode 100644 index ae20ab3a1..000000000 --- a/examples/message/tests/provider/test_message_provider.py +++ /dev/null @@ -1,83 +0,0 @@ -import pytest -from pact import MessageProvider - -PACT_BROKER_URL = "http://localhost" -PACT_BROKER_USERNAME = "pactbroker" -PACT_BROKER_PASSWORD = "pactbroker" -PACT_DIR = "pacts" - - -@pytest.fixture -def default_opts(): - return { - 'broker_username': PACT_BROKER_USERNAME, - 'broker_password': PACT_BROKER_PASSWORD, - 'broker_url': PACT_BROKER_URL, - 'publish_version': '3', - 'publish_verification_results': False - } - - -def document_created_handler(): - return { - "event": "ObjectCreated:Put", - "documentName": "document.doc", - "creator": "TP", - "documentType": "microsoft-word" - } - - -def document_deleted_handler(): - return { - "event": "ObjectCreated:Delete", - "documentName": "document.doc", - "creator": "TP", - "documentType": "microsoft-word" - } - - -def test_verify_success(): - provider = MessageProvider( - message_providers={ - 'A document created successfully': document_created_handler, - 'A document deleted successfully': document_deleted_handler - }, - provider='ContentProvider', - consumer='DetectContentLambda', - pact_dir='pacts' - - ) - with provider: - provider.verify() - - -def test_verify_failure_when_a_provider_missing(): - provider = MessageProvider( - message_providers={ - 'A document created successfully': document_created_handler, - }, - provider='ContentProvider', - consumer='DetectContentLambda', - pact_dir='pacts' - - ) - - with pytest.raises(AssertionError): - with provider: - provider.verify() - - -def test_verify_from_broker(default_opts): - provider = MessageProvider( - message_providers={ - 'A document created successfully': document_created_handler, - 'A document deleted successfully': document_deleted_handler - }, - provider='ContentProvider', - consumer='DetectContentLambda', - pact_dir='pacts' - - ) - - with provider: - provider.verify_with_broker(**default_opts) diff --git a/examples/src/message.py b/examples/src/message.py new file mode 100644 index 000000000..56377d4ae --- /dev/null +++ b/examples/src/message.py @@ -0,0 +1,98 @@ +""" +Handler for non-HTTP interactions. + +Pact is able to handle non-HTTP interactions in so-called ['message +pacts'](https://docs.pact.io/getting_started/how_pact_works#non-http-testing-message-pact). +It does so by abstracting away the protocol and queueing system, and focuses on +the messages passing between them instead. + +This handler +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + + +class Filesystem: + """Filesystem interface.""" + + def __init__(self) -> None: + """Initialize the filesystem connection.""" + + def write(self, _file: str, _contents: str) -> None: + """Write contents to a file.""" + raise NotImplementedError + + def read(self, file: str) -> str: + """Read contents from a file.""" + raise NotImplementedError + + +class Handler: + """ + Message queue handler. + + This class is responsible for handling messages from the queue. + """ + + def __init__(self) -> None: + """ + Initialize the handler. + + This ensures the underlying filesystem is ready to be used. + """ + self.fs = Filesystem() + + def process(self, event: dict[str, Any]) -> str | None: + """ + Process an event from the queue. + + The event is expected to be a dictionary with the following mandatory + keys: + + - `action`: The action to be performed, either `READ` or `WRITE`. + - `path`: The path to the file to be read or written. + + The event may also contain an optional `contents` key, which is the + contents to be written to the file. If the `contents` key is not + present, an empty file will be written. + """ + self.validate_event(event) + + if event["action"] == "WRITE": + return self.fs.write(event["path"], event.get("contents", "")) + if event["action"] == "READ": + return self.fs.read(event["path"]) + + msg = "Invalid action." + raise ValueError(msg) + + @staticmethod + def validate_event(event: dict[str, Any] | Any) -> None: # noqa: ANN401 + """ + Validates the event received from the queue. + + The event is expected to be a dictionary with the following mandatory + keys: + + - `action`: The action to be performed, either `READ` or `WRITe`. + - `path`: The path to the file to be read or written. + """ + if not isinstance(event, dict): + msg = "Event must be a dictionary." + raise TypeError(msg) + if "action" not in event: + msg = "Event must contain an 'action' key." + raise ValueError(msg) + if "path" not in event: + msg = "Event must contain a 'path' key." + raise ValueError(msg) + if event["action"] not in ["READ", "WRITE"]: + msg = "Event must contain a valid 'action' key." + raise ValueError(msg) + try: + Path(event["path"]) + except TypeError as err: + msg = "Event must contain a valid 'path' key." + raise ValueError(msg) from err diff --git a/examples/tests/test_02_message_consumer.py b/examples/tests/test_02_message_consumer.py new file mode 100644 index 000000000..857a391fc --- /dev/null +++ b/examples/tests/test_02_message_consumer.py @@ -0,0 +1,120 @@ +""" +Test Message Pact consumer. + +Unlike the standard Pact, which is designed for HTTP interactions, the Message +Pact is designed for non-HTTP interactions. This example demonstrates how to use +the Message Pact to test whether a consumer can handle the messages it receives. + +In such examples, Pact simply ensures that the consumer is capable of processing +the message. The consumer need not send back a message, and any sideffects of +the message must be verified separately. + +The below example verifies that the consumer makes the correct filesystem calls +when it receives a message to read or write a file. The calls themselves are +mocked out so as to avoid actually writing to the filesystem. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, Generator +from unittest.mock import MagicMock + +import pytest +from pact import MessageConsumer, MessagePact, Provider + +from src.message import Handler + +if TYPE_CHECKING: + from pathlib import Path + + from yarl import URL + +log = logging.getLogger(__name__) + + +@pytest.fixture(scope="module") +def pact(broker: URL, pact_dir: Path) -> Generator[MessagePact, Any, None]: + """ + Set up Message Pact. + + In order to test the consumer in isolation, Pact sets up a mock version of + the provider. This mock provider will expect to receive defined requests + and will respond with defined responses. + + The fixture here simply defines the Consumer and Provider, and sets up the + mock provider. With each test, we define the expected request and response + from the provider as follows: + + ```python + pact.given("UserA exists and is not an admin") \ + .upon_receiving("A request for UserA") \ + .with_request("get", "/users/123") \ + .will_respond_with(200, body=Like(expected)) + ``` + """ + consumer = MessageConsumer("MessageConsumer") + return consumer.has_pact_with( + Provider("MessageProvider"), + pact_dir=pact_dir, + publish_to_broker=True, + # Broker configuration + broker_base_url=str(broker), + broker_username=broker.user, + broker_password=broker.password, + ) + + # with pact: + # yield pact + + +@pytest.fixture() +def handler() -> Handler: + """ + Fixture for the Handler. + + This fixture mocks the filesystem calls in the handler, so that we can + verify that the handler is calling the filesystem correctly. + """ + handler = Handler() + handler.fs = MagicMock() + handler.fs.write.return_value = None + handler.fs.read.return_value = "Hello world!" + return handler + + +def test_write_file(pact: MessagePact, handler: Handler) -> None: + """ + Test write file. + + This test will be run against the mock provider. The mock provider will + expect to receive a request to write a file, and will respond with a 200 + status code. + """ + msg = {"action": "WRITE", "path": "test.txt", "contents": "Hello world!"} + ( + pact.given("empty filesystem") + .expects_to_receive("a request to write test.txt") + .with_content(msg) + .with_metadata({"Content-Type": "application/json"}) + ) + + with pact: + result = handler.process(msg) + handler.fs.write.assert_called_once_with("test.txt", "Hello world!") + assert result is None + + +def test_read_file(pact: MessagePact, handler: Handler) -> None: + msg = {"action": "READ", "path": "test.txt"} + ( + pact.given("test.txt exists") + .expects_to_receive("a request to read test.txt") + .with_content(msg) + .with_metadata({"Content-Type": "application/json"}) + ) + + with pact: + result = handler.process(msg) + handler.fs.read.assert_called_once_with("test.txt") + assert result == "Hello world!" diff --git a/examples/tests/test_03_message_provider.py b/examples/tests/test_03_message_provider.py new file mode 100644 index 000000000..7b83dce8e --- /dev/null +++ b/examples/tests/test_03_message_provider.py @@ -0,0 +1,57 @@ +""" +Test Message Pact provider. + +Unlike the standard Pact, which is designed for HTTP interactions, the Message +Pact is designed for non-HTTP interactions. This example demonstrates how to use +the Message Pact to test whether a provider generates the correct messages. + +In such examples, Pact simply checks the kind of messages produced. The consumer +need not send back a message, and any sideffects of the message must be verified +separately. + +The below example verifies that the consumer makes the correct filesystem calls +when it receives a message to read or write a file. The calls themselves are +mocked out so as to avoid actually writing to the filesystem. +""" + +from __future__ import annotations + +from pathlib import Path + +from flask import Flask +from pact import MessageProvider +from yarl import URL + +app = Flask(__name__) +PACT_DIR = (Path(__file__).parent / "pacts").resolve() +PROVIDER_URL = URL("http://localhost:8080") + + +def generate_write_message() -> dict[str, str]: + return { + "action": "WRITE", + "path": "test.txt", + "contents": "Hello world!", + } + + +def generate_read_message() -> dict[str, str]: + return { + "action": "READ", + "path": "test.txt", + } + + +def test_verify(broker: URL) -> None: + provider = MessageProvider( + provider="MessageProvider", + consumer="MessageConsumer", + pact_dir=str(PACT_DIR), + message_providers={ + "a request to write test.txt": generate_write_message, + "a request to read test.txt": generate_read_message, + }, + ) + + with provider: + provider.verify_with_broker(broker_url=str(broker))