diff --git a/tests/v3/compatibility_suite/util/consumer.py b/tests/v3/compatibility_suite/util/consumer.py index 76887b636..f880d6f56 100644 --- a/tests/v3/compatibility_suite/util/consumer.py +++ b/tests/v3/compatibility_suite/util/consumer.py @@ -12,9 +12,23 @@ import pytest import requests from pytest_bdd import given, parsers, then, when +from typing_extensions import TypeGuard from yarl import URL from pact.v3 import Pact +from pact.v3.error import ( + BodyMismatch, + BodyTypeMismatch, + HeaderMismatch, + MetadataMismatch, + Mismatch, + MissingRequest, + PathMismatch, + QueryMismatch, + RequestMismatch, + RequestNotFound, + StatusMismatch, +) from tests.v3.compatibility_suite.util import ( FIXTURES_ROOT, PactInteractionTuple, @@ -35,6 +49,62 @@ logger = logging.getLogger(__name__) + +MISMATCH_MAP: dict[str, type[Mismatch]] = { + "query": QueryMismatch, + "header": HeaderMismatch, + "body": BodyMismatch, + "body-content-type": BodyTypeMismatch, +} + + +def _mismatch_with_path( + mismatch: Mismatch, +) -> TypeGuard[MissingRequest | RequestNotFound | RequestMismatch | BodyMismatch]: + """ + Check if a mismatch has a `path` attribute. + + This function is used to check if the mismatch in question is one of the + variants that have a `path` attribute. This has little purpose at runtime, + but is useful for type checking. + """ + return isinstance( + mismatch, (MissingRequest, RequestNotFound, RequestMismatch, BodyMismatch) + ) + + +def _mismatch_with_mismatch( + mismatch: Mismatch, +) -> TypeGuard[ + PathMismatch + | StatusMismatch + | QueryMismatch + | HeaderMismatch + | BodyTypeMismatch + | BodyMismatch + | MetadataMismatch +]: + """ + Check if a mismatch has a `mismatch` attribute. + + This function is used to check if the mismatch in question is one of the + variants that have a `mismatch` attribute. This has little purpose at runtime, + but is useful for type checking. + """ + return isinstance( + mismatch, + ( + PathMismatch, + StatusMismatch, + QueryMismatch, + HeaderMismatch, + BodyTypeMismatch, + BodyMismatch, + MetadataMismatch, + ), + ) + + ################################################################################ ## Given ################################################################################ @@ -285,7 +355,11 @@ def _( indent=2, ), ) - logger.info("Mismatches:\n%s", json.dumps(srv.mismatches, indent=2)) + msg = "\n".join([ + "Mismatches:", + *(f" ({i + 1}) {m}" for i, m in enumerate(srv.mismatches)), + ]) + logger.info(msg) assert response.status_code == code @@ -363,9 +437,9 @@ def _( for mismatch in srv.mismatches: if ( - mismatch["method"] == interaction_definitions[n].method - and mismatch["path"] == interaction_definitions[n].path - and mismatch["type"] == "missing-request" + isinstance(mismatch, MissingRequest) + and mismatch.method == interaction_definitions[n].method + and mismatch.path == interaction_definitions[n].path ): return pytest.fail("Expected mismatch not found") @@ -397,10 +471,10 @@ def _( for mismatch in srv.mismatches: if ( - mismatch["method"] == interaction_definitions[n].method - and mismatch["request"]["method"] == method - and mismatch["path"] == interaction_definitions[n].path - and mismatch["type"] == "request-not-found" + isinstance(mismatch, RequestNotFound) + and mismatch.method == interaction_definitions[n].method + and mismatch.method == method + and mismatch.path == interaction_definitions[n].path ): return pytest.fail("Expected mismatch not found") @@ -431,9 +505,9 @@ def _( for mismatch in srv.mismatches: if ( - mismatch["request"]["method"] == method - and mismatch["path"] == path - and mismatch["type"] == "request-not-found" + isinstance(mismatch, RequestNotFound) + and mismatch.method == method + and mismatch.path == path ): return pytest.fail("Expected mismatch not found") @@ -470,27 +544,17 @@ def _( """ The mismatches will contain a mismatch with the error. """ - if mismatch_type == "query": - mismatch_type = "QueryMismatch" - elif mismatch_type == "header": - mismatch_type = "HeaderMismatch" - elif mismatch_type == "body": - mismatch_type = "BodyMismatch" - elif mismatch_type == "body-content-type": - mismatch_type = "BodyTypeMismatch" - else: - msg = f"Unexpected mismatch type: {mismatch_type}" - raise ValueError(msg) - logger.info("Expecting mismatch: %s", mismatch_type) logger.info("With error: %s", error) for mismatch in srv.mismatches: - for sub_mismatch in mismatch["mismatches"]: - if ( - error in sub_mismatch["mismatch"] - and sub_mismatch["type"] == mismatch_type - ): - return + if isinstance(mismatch, RequestMismatch): + for sub_mismatch in mismatch.mismatches: + if ( + isinstance(sub_mismatch, MISMATCH_MAP[mismatch_type]) + and _mismatch_with_mismatch(sub_mismatch) + and error in sub_mismatch.mismatch + ): + return pytest.fail("Expected mismatch not found") @@ -514,13 +578,15 @@ def _( """ The mismatches will contain a mismatch with the error. """ - mismatch_type = "BodyMismatch" if mismatch_type == "body" else mismatch_type for mismatch in srv.mismatches: - for sub_mismatch in mismatch["mismatches"]: + assert isinstance(mismatch, RequestMismatch) + for sub_mismatch in mismatch.mismatches: if ( - sub_mismatch["mismatch"] == error - and sub_mismatch["type"] == mismatch_type - and sub_mismatch["path"] == path + isinstance(sub_mismatch, MISMATCH_MAP[mismatch_type]) + and _mismatch_with_mismatch(sub_mismatch) + and sub_mismatch.mismatch == error + and _mismatch_with_path(sub_mismatch) + and sub_mismatch.path == path ): return pytest.fail("Expected mismatch not found") diff --git a/tests/v3/test_error.py b/tests/v3/test_error.py new file mode 100644 index 000000000..0b087afaf --- /dev/null +++ b/tests/v3/test_error.py @@ -0,0 +1,258 @@ +""" +Error handling and mismatch tests. +""" + +import re + +import aiohttp +import pytest + +from pact.v3 import Pact +from pact.v3.error import ( + BodyMismatch, + BodyTypeMismatch, + HeaderMismatch, + MismatchesError, + MissingRequest, + QueryMismatch, + RequestMismatch, + RequestNotFound, +) + + +@pytest.fixture +def pact() -> Pact: + """ + Fixture for a Pact instance. + """ + return Pact("consumer", "provider") + + +@pytest.mark.asyncio +async def test_missing_request(pact: Pact) -> None: + ( + pact.upon_receiving("a missing request") + .with_request("GET", "/") + .will_respond_with(200) + ) + with pytest.raises(MismatchesError) as exc, pact.serve() as srv: # noqa: PT012 + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + "GET", + "/nonexistent", + ): + pass + + assert len(exc.value.mismatches) == 2 + missing_request, request_not_found = sorted( + exc.value.mismatches, + key=lambda m: m.__class__.__name__, + ) + + assert isinstance(missing_request, MissingRequest) + assert missing_request.path == "/" + assert missing_request.method == "GET" + assert re.match(r"Missing request: GET /: \{.*\}", str(missing_request)) + + assert isinstance(request_not_found, RequestNotFound) + assert request_not_found.path == "/nonexistent" + assert request_not_found.method == "GET" + assert re.match( + r"Request not found: GET /nonexistent: \{.*\}", str(request_not_found) + ) + + +@pytest.mark.asyncio +async def test_query_mismatch_value(pact: Pact) -> None: + ( + pact.upon_receiving("a query mismatch") + .with_request("GET", "/resource") + .with_query_parameter("param", "expected") + .will_respond_with(200) + ) + with pytest.raises(MismatchesError) as exc, pact.serve() as srv: # noqa: PT012 + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + "GET", + "/resource?param=actual", + ): + pass + + assert len(exc.value.mismatches) == 1 + request_mismatch = exc.value.mismatches[0] + + assert isinstance(request_mismatch, RequestMismatch) + assert request_mismatch.path == "/resource" + assert request_mismatch.method == "GET" + assert ( + str(request_mismatch) + == """Request mismatch: GET /resource + (1) Query mismatch: Expected query parameter 'param' \ +with value 'expected' but was 'actual'""" + ) + + query_mismatch = request_mismatch.mismatches[0] + assert isinstance(query_mismatch, QueryMismatch) + assert query_mismatch.parameter == "param" + assert query_mismatch.expected == "expected" + assert query_mismatch.actual == "actual" + assert str(query_mismatch) == ( + "Query mismatch: " + "Expected query parameter 'param' with value 'expected' but was 'actual'" + ) + + +@pytest.mark.asyncio +async def test_query_mismatch_different_keys(pact: Pact) -> None: + ( + pact.upon_receiving("a query mismatch with different keys") + .with_request("GET", "/resource") + .with_query_parameter("key", "value") + .will_respond_with(200) + ) + with pytest.raises(MismatchesError) as exc, pact.serve() as srv: # noqa: PT012 + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + "GET", + "/resource?foo=bar", + ): + pass + + assert len(exc.value.mismatches) == 1 + request_mismatch = exc.value.mismatches[0] + + assert isinstance(request_mismatch, RequestMismatch) + assert request_mismatch.path == "/resource" + assert request_mismatch.method == "GET" + + mismatches = sorted( + request_mismatch.mismatches, + key=lambda m: getattr(m, "parameter", ""), + ) + + mismatch = mismatches[0] + assert isinstance(mismatch, QueryMismatch) + assert mismatch.parameter == "foo" + assert mismatch.expected == "" + assert mismatch.actual == '["bar"]' + + mismatch = mismatches[1] + assert isinstance(mismatch, QueryMismatch) + assert mismatch.parameter == "key" + assert mismatch.expected == '["value"]' + assert mismatch.actual == "" + + +@pytest.mark.asyncio +async def test_header_mismatch(pact: Pact) -> None: + ( + pact.upon_receiving("a header mismatch") + .with_request("GET", "/") + .with_header("X-Foo", "expected") + .will_respond_with(200) + ) + with pytest.raises(MismatchesError) as exc, pact.serve() as srv: # noqa: PT012 + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + "GET", + "/", + headers={"X-Foo": "unexpected"}, + ): + pass + + assert len(exc.value.mismatches) == 1 + request_mismatch = exc.value.mismatches[0] + + assert isinstance(request_mismatch, RequestMismatch) + assert request_mismatch.path == "/" + assert request_mismatch.method == "GET" + + header_mismatch = request_mismatch.mismatches[0] + assert isinstance(header_mismatch, HeaderMismatch) + assert header_mismatch.key == "X-Foo" + assert header_mismatch.expected == "expected" + assert header_mismatch.actual == "unexpected" + assert str(header_mismatch) == ( + "Header mismatch: Mismatch with header 'X-Foo': " + "Expected 'unexpected' to be equal to 'expected'" + ) + + +@pytest.mark.asyncio +async def test_body_type_mismatch(pact: Pact) -> None: + ( + pact.upon_receiving("a body type mismatch") + .with_request("POST", "/") + .with_body("{}", "application/json") + .will_respond_with(200) + ) + with pytest.raises(MismatchesError) as exc, pact.serve() as srv: # noqa: PT012 + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + "POST", + "/", + headers={"Content-Type": "text/plain"}, + data="plain text", + ): + pass + + assert len(exc.value.mismatches) == 1 + request_mismatch = exc.value.mismatches[0] + assert isinstance(request_mismatch, RequestMismatch) + assert request_mismatch.path == "/" + assert request_mismatch.method == "POST" + + header_mismatch = request_mismatch.mismatches[0] + assert isinstance(header_mismatch, HeaderMismatch) + assert header_mismatch.key == "Content-Type" + assert header_mismatch.expected == "application/json" + assert header_mismatch.actual == "text/plain" + assert str(header_mismatch) == ( + "Header mismatch: Mismatch with header 'Content-Type': " + "Expected header 'Content-Type' to have value 'application/json' " + "but was 'text/plain'" + ) + + body_type_mismatch = request_mismatch.mismatches[1] + assert isinstance(body_type_mismatch, BodyTypeMismatch) + assert body_type_mismatch.expected == "application/json" + assert body_type_mismatch.actual == "text/plain" + assert str(body_type_mismatch) == ( + "Body type mismatch: Expected a body of 'application/json' " + "but the actual content type was 'text/plain'" + ) + + +@pytest.mark.asyncio +async def test_body_mismatch(pact: Pact) -> None: + ( + pact.upon_receiving("a body mismatch") + .with_request("POST", "/") + .with_body("expected") + .will_respond_with(200) + ) + with pytest.raises(MismatchesError) as exc, pact.serve() as srv: # noqa: PT012 + async with aiohttp.ClientSession(srv.url) as session: + async with session.request( + "POST", + "/", + data="unexpected", + ): + pass + + assert len(exc.value.mismatches) == 1 + request_mismatch = exc.value.mismatches[0] + + assert isinstance(request_mismatch, RequestMismatch) + assert request_mismatch.path == "/" + assert request_mismatch.method == "POST" + + body_mismatch = request_mismatch.mismatches[0] + assert isinstance(body_mismatch, BodyMismatch) + assert body_mismatch.path == "$" + assert body_mismatch.expected == "expected" + assert body_mismatch.actual == "unexpected" + assert str(body_mismatch) == ( + "Body mismatch: Expected body 'expected' to match 'unexpected' " + "using equality but did not match" + ) diff --git a/tests/v3/test_http_interaction.py b/tests/v3/test_http_interaction.py index b700fddd7..a6f170c38 100644 --- a/tests/v3/test_http_interaction.py +++ b/tests/v3/test_http_interaction.py @@ -13,6 +13,7 @@ import pytest from pact.v3 import Pact, match +from pact.v3.error import RequestMismatch, RequestNotFound from pact.v3.pact import MismatchesError if TYPE_CHECKING: @@ -71,7 +72,7 @@ async def test_basic_request_method(pact: Pact, method: str) -> None: # As we are making unexpected requests, we should have mismatches for mismatch in srv.mismatches: - assert mismatch["type"] == "request-not-found" + assert isinstance(mismatch, RequestNotFound) @pytest.mark.parametrize( @@ -208,7 +209,7 @@ async def test_set_header_request_repeat( assert resp.status == 500 assert len(srv.mismatches) == 1 - assert srv.mismatches[0]["type"] == "request-mismatch" + assert isinstance(srv.mismatches[0], RequestMismatch) @pytest.mark.parametrize(