|
| 1 | +""" |
| 2 | +Consumer test demonstrating multipart/form-data with matching rules. |
| 3 | +
|
| 4 | +This test shows how to use Pact matching rules with multipart requests. The |
| 5 | +examples illustrates this with a request containing both JSON metadata and |
| 6 | +binary data (an image). The contract uses matching rules to validate |
| 7 | +structure and types rather than exact values, allowing flexibility in the data |
| 8 | +sent by the consumer and accepted by the provider. |
| 9 | +""" |
| 10 | + |
| 11 | +from __future__ import annotations |
| 12 | + |
| 13 | +import json |
| 14 | +from pathlib import Path |
| 15 | +from typing import TYPE_CHECKING |
| 16 | + |
| 17 | +import httpx |
| 18 | +import pytest |
| 19 | + |
| 20 | +from pact import Pact, match |
| 21 | + |
| 22 | +if TYPE_CHECKING: |
| 23 | + from collections.abc import Generator |
| 24 | + |
| 25 | + |
| 26 | +# Minimal JPEG for testing. The important part is the magic bytes. The rest is |
| 27 | +# not strictly valid JPEG data. |
| 28 | +# fmt: off |
| 29 | +JPEG_BYTES = bytes([ |
| 30 | + 0xFF, 0xD8, # Start of Image (SOI) marker |
| 31 | + 0xFF, 0xE0, # JFIF APP0 Marker |
| 32 | + 0x00, 0x10, # Length of APP0 segment |
| 33 | + 0x4A, 0x46, 0x49, 0x46, 0x00, # "JFIF\0" |
| 34 | + 0x01, 0x02, # Major and minor versions |
| 35 | +]) |
| 36 | +# fmt: on |
| 37 | +""" |
| 38 | +Some minimal JPEG bytes for testing multipart uploads. |
| 39 | +
|
| 40 | +In this example, we only need the JPEG magic bytes to validate the file type. |
| 41 | +This is not a complete JPEG file, but is sufficient for testing purposes. |
| 42 | +""" |
| 43 | + |
| 44 | + |
| 45 | +@pytest.fixture |
| 46 | +def pact() -> Generator[Pact, None, None]: |
| 47 | + """ |
| 48 | + Set up Pact for consumer contract testing. |
| 49 | +
|
| 50 | + This fixture initializes a Pact instance for the consumer tests, specifying |
| 51 | + the consumer and provider names, and ensuring that the generated Pact files |
| 52 | + are written to the appropriate directory after the tests run. |
| 53 | + """ |
| 54 | + pact = Pact( |
| 55 | + "multipart-consumer", |
| 56 | + "multipart-provider", |
| 57 | + ) |
| 58 | + yield pact |
| 59 | + pact.write_file(Path(__file__).parents[1] / "pacts") |
| 60 | + |
| 61 | + |
| 62 | +def test_multipart_upload_with_matching_rules(pact: Pact) -> None: |
| 63 | + """ |
| 64 | + Test multipart upload with matching of the contents. |
| 65 | +
|
| 66 | + This test builds a `multipart/form-data` request by hand, and then uses a |
| 67 | + library (`httpx`) to send the request to the mock server started by Pact. |
| 68 | + Unlike simpler payloads, the matching rules _cannot_ be embedded within the |
| 69 | + body itself. Instead, the body and matching rules are defined in separate |
| 70 | + calls. |
| 71 | +
|
| 72 | + Some key points about this example: |
| 73 | +
|
| 74 | + - We use a matching rule for the `Content-Type` header to allow any valid |
| 75 | + multipart boundary. This is important because many HTTP libraries |
| 76 | + generate random boundaries automatically without user control. |
| 77 | + - The body includes arbitrary binary data (a JPEG image) which cannot be |
| 78 | + represented as a string. Therefore, it is critical that |
| 79 | + `with_binary_body` is used to define the payload. |
| 80 | + - Matching rules are defined for both the JSON metadata and the image part |
| 81 | + to allow flexibility in the values sent by the consumer. The general |
| 82 | + form to match a part within the multipart body is `$.<part name>`. So |
| 83 | + to match a field in the `metadata` part, we use `$.metadata.<field>`; or |
| 84 | + to match the content type of the `image` part, we use `$.image`: |
| 85 | +
|
| 86 | + ```python |
| 87 | + from pact import match |
| 88 | +
|
| 89 | + { |
| 90 | + "body": { |
| 91 | + "$.image": match.content_type("image/jpeg"), |
| 92 | + "$.metadata.name": match.regex(regex=r"^[a-zA-Z]+$"), |
| 93 | + }, |
| 94 | + } |
| 95 | + ``` |
| 96 | +
|
| 97 | + /// warning |
| 98 | +
|
| 99 | + Proper content types are essential when working with multipart data. This |
| 100 | + ensures that Pact can correctly identify and apply matching rules to each |
| 101 | + part of the multipart body. If content types are missing or incorrect, the |
| 102 | + matching rules may not be applied as expected, leading to test failures or |
| 103 | + incorrect behavior. |
| 104 | +
|
| 105 | + /// |
| 106 | +
|
| 107 | + To view the implementation, expand the source code below. |
| 108 | + """ |
| 109 | + # It is recommended to use a fixed boundary for testing, this ensures that |
| 110 | + # the generated Pact is consistent across test runs. |
| 111 | + boundary = "test-boundary-12345" |
| 112 | + |
| 113 | + metadata = { |
| 114 | + "name": "test", |
| 115 | + "size": 100, |
| 116 | + } |
| 117 | + |
| 118 | + # Build multipart body with both JSON and binary parts. Note that since we |
| 119 | + # are combining text and binary data, the strings must be encoded to bytes. |
| 120 | + expected_body = ( |
| 121 | + f"--{boundary}\r\n" |
| 122 | + f'Content-Disposition: form-data; name="metadata"\r\n' |
| 123 | + f"Content-Type: application/json\r\n" |
| 124 | + f"\r\n" |
| 125 | + f"{json.dumps(metadata)}\r\n" |
| 126 | + f"--{boundary}\r\n" |
| 127 | + f'Content-Disposition: form-data; name="image"; filename="test.jpg"\r\n' |
| 128 | + f"Content-Type: image/jpeg\r\n" |
| 129 | + f"\r\n" |
| 130 | + ).encode() |
| 131 | + expected_body += JPEG_BYTES |
| 132 | + expected_body += f"\r\n--{boundary}--\r\n".encode() |
| 133 | + |
| 134 | + # Define the interaction with matching rules |
| 135 | + ( |
| 136 | + pact.upon_receiving("a multipart upload with JSON metadata and image") |
| 137 | + .with_request("POST", "/upload") |
| 138 | + .with_header( |
| 139 | + "Content-Type", |
| 140 | + # The matcher here is important if you don't have the ability to fix |
| 141 | + # the boundary in the actual request (e.g., when using a library |
| 142 | + # that generates it automatically). |
| 143 | + match.regex( |
| 144 | + f"multipart/form-data; boundary={boundary}", |
| 145 | + regex=r"multipart/form-data;\s*boundary=.*", |
| 146 | + ), |
| 147 | + ) |
| 148 | + .with_binary_body( |
| 149 | + expected_body, |
| 150 | + f"multipart/form-data; boundary={boundary}", |
| 151 | + ) |
| 152 | + # Matching rules make the contract flexible |
| 153 | + .with_matching_rules({ |
| 154 | + "body": { |
| 155 | + "$.image": match.content_type("image/jpeg"), |
| 156 | + "$.metadata": match.type({}), |
| 157 | + "$.metadata.name": match.regex(regex=r"^[a-zA-Z]+$"), |
| 158 | + "$.metadata.size": match.int(), |
| 159 | + }, |
| 160 | + }) |
| 161 | + .will_respond_with(201) |
| 162 | + .with_body({ |
| 163 | + "id": "upload-1", |
| 164 | + "message": "Upload successful", |
| 165 | + "metadata": {"name": "test", "size": 100}, |
| 166 | + "image_size": len(JPEG_BYTES), |
| 167 | + }) |
| 168 | + ) |
| 169 | + |
| 170 | + # Execute the test. Note that the matching rules take effect here, so we can |
| 171 | + # send data that differs from the example in the contract. |
| 172 | + with pact.serve() as srv: |
| 173 | + # Simple inline consumer: just make the multipart request |
| 174 | + files = { |
| 175 | + "metadata": ( |
| 176 | + None, |
| 177 | + json.dumps({"name": "different", "size": 200}).encode(), |
| 178 | + "application/json", |
| 179 | + ), |
| 180 | + "image": ("test.jpg", JPEG_BYTES, "image/jpeg"), |
| 181 | + } |
| 182 | + response = httpx.post(f"{srv.url}/upload", files=files, timeout=5) |
| 183 | + |
| 184 | + assert response.status_code == 201 |
| 185 | + result = response.json() |
| 186 | + assert result["message"] == "Upload successful" |
| 187 | + assert result["id"] == "upload-1" |
0 commit comments