Repository: pact-foundation/pact-python

Issue:
Implement all FFI methods relating to `Interaction`
## Description

- [x] Implement all missing methods for the `Interaction` class. The scope of 'all' here relates to the methods exposed by the FFI which typically either: construct a Interaction (returning an `InteractionHandle`); or, take an `InteractionHandle` as an argument and modify the underlying Pact
- [x] Ensure all new methods are systematically unit-tested.

## Has this been requested on Canny?

_No response_

## Motivation

Initial work implementing the `Interaction` class was done as part of #410; however, the scope of the PR was limited to a MWE to ease with the review and ensure that the more substantive/foundational parts can be reviewed and the finer implementation details can kept separate.

## Have you tried building it?

Continuation of prior work

## Self-service

- [X] I'd be willing to contribute this feature to Pact Python myself.

Discussion:

Patch:
diff --git a/.github/labels.yml b/.github/labels.yml
new file mode 100644
index 0000000..f818381
--- /dev/null
+++ b/.github/labels.yml
@@ -0,0 +1,13 @@
+- name: area:v3
+  description: Relating to the pact.v3 module
+  color: "C2E0C6"
+
+- name: area:v2
+  description: Relating to v2 code
+  color: "C2E0C6"
+  aliases:
+    - documentation
+
+- name: area:examples
+  description: Relating to the examples
+  color: "C2E0C6"
diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml
new file mode 100644
index 0000000..be54b11
--- /dev/null
+++ b/.github/workflows/labels.yml
@@ -0,0 +1,30 @@
+name: Labels
+
+on:
+  # For downstream repos, we want to run this on a schedule
+  # so that updates propagate automatically. Weekly is probably
+  # enough.
+  schedule:
+    - cron: "20 0 * * 0"
+  push:
+    branches:
+      - master
+    paths:
+      - .github/labels.yml
+
+jobs:
+  sync-labels:
+    name: Synchronise labels
+
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout Code
+        uses: actions/checkout@v3
+
+      - name: Synchronize labels
+        uses: EndBug/label-sync@v2
+        with:
+          config-file: |
+            https://raw.githubusercontent.com/pact-foundation/.github/master/.github/labels.yml
+            .github/labels.yml
diff --git a/pact/v3/ffi.py b/pact/v3/ffi.py
index 5dc0aeb..e1426fe 100644
--- a/pact/v3/ffi.py
+++ b/pact/v3/ffi.py
@@ -67,6 +67,7 @@ an appropriate Python exception. The exception should be raised using the
 appropriate Python exception class, and should be documented in the function's
 docstring.
 """
+
 # The following lints are disabled during initial development and should be
 # removed later.
 # ruff: noqa: ARG001 (unused-function-argument)
@@ -80,6 +81,7 @@ docstring.
 
 from __future__ import annotations
 
+import gc
 import warnings
 from enum import Enum
 from typing import TYPE_CHECKING, List
@@ -150,7 +152,7 @@ class InteractionHandle:
         """
         String representation of the Interaction Handle.
         """
-        return f"InteractionHandle({self._ref})"
+        return f"InteractionHandle({self._ref!r})"
 
 
 class MatchingRule:
@@ -267,7 +269,7 @@ class PactHandle:
         """
         String representation of the Pact Handle.
         """
-        return f"PactHandle({self._ref})"
+        return f"PactHandle({self._ref!r})"
 
 
 class PactServerHandle:
@@ -309,7 +311,7 @@ class PactServerHandle:
         """
         String representation of the Pact Server Handle.
         """
-        return f"PactServerHandle({self._ref})"
+        return f"PactServerHandle({self._ref!r})"
 
     @property
     def port(self) -> int:
@@ -525,10 +527,10 @@ class PactSpecification(Enum):
         """
         Information-rich string representation of the Pact Specification.
         """
-        return f"Pact Specification.{self.name}"
+        return f"PactSpecification.{self.name}"
 
 
-class StringResult(Enum):
+class _StringResult(Enum):
     """
     String Result.
 
@@ -548,7 +550,70 @@ class StringResult(Enum):
         """
         Information-rich string representation of the String Result.
         """
-        return f"StringResult.{self.name}"
+        return f"_StringResultEnum.{self.name}"
+
+
+class StringResult:
+    """
+    String result.
+    """
+
+    def __init__(self, cdata: ffi.CData) -> None:
+        """
+        Initialise a new String Result.
+
+        Args:
+            cdata:
+                CFFI data structure.
+        """
+        if ffi.typeof(cdata).cname != "struct StringResult":
+            msg = f"cdata must be a struct StringResult, got {ffi.typeof(cdata).cname}"
+            raise TypeError(msg)
+        self._cdata: ffi.CData = cdata
+
+    def __str__(self) -> str:
+        """
+        String representation of the String Result.
+        """
+        return self.text
+
+    def __repr__(self) -> str:
+        """
+        Debugging string representation of the String Result.
+        """
+        return f"<StringResult: {'OK' if self.is_ok else 'FAILED'}, {self.text!r}>"
+
+    @property
+    def is_failed(self) -> bool:
+        """
+        Whether the result is an error.
+        """
+        return self._cdata.tag == _StringResult.FAILED.value
+
+    @property
+    def is_ok(self) -> bool:
+        """
+        Whether the result is ok.
+        """
+        return self._cdata.tag == _StringResult.OK.value
+
+    @property
+    def text(self) -> str:
+        """
+        The text of the result.
+        """
+        # The specific `.ok` or `.failed` does not matter.
+        return ffi.string(self._cdata.ok).decode("utf-8")
+
+    def raise_exception(self) -> None:
+        """
+        Raise an exception with the text of the result.
+
+        Raises:
+            RuntimeError: If the result is an error.
+        """
+        if self.is_failed:
+            raise RuntimeError(self.text)
 
 
 def version() -> str:
@@ -614,7 +679,11 @@ def enable_ansi_support() -> None:
     raise NotImplementedError
 
 
-def log_message(source: str, log_level: str, message: str) -> None:
+def log_message(
+    message: str,
+    log_level: LevelFilter | str = LevelFilter.ERROR,
+    source: str | None = None,
+) -> None:
     """
     Log using the shared core logging facility.
 
@@ -623,16 +692,27 @@ def log_message(source: str, log_level: str, message: str) -> None:
 
     This is useful for callers to have a single set of logs.
 
-    - `source`: String. The source of the log, such as the class or caller
-      framework to disambiguate log lines from the rust logging (e.g.  pact_go)
-    - `log_level`: String. One of TRACE, DEBUG, INFO, WARN, ERROR
-    - `message`: Message to log
+    Args:
+        message:
+            The contents written to the log
 
-    # Safety
+        log_level:
+            The verbosity at which this message should be logged.
 
-    This function will fail if any of the pointers passed to it are invalid.
+        source:
+            The source of the log, such as the class, module or caller.
     """
-    raise NotImplementedError
+    if isinstance(log_level, str):
+        log_level = LevelFilter[log_level.upper()]
+    if source is None:
+        import inspect
+
+        source = inspect.stack()[1].function
+    lib.pactffi_log_message(
+        source.encode("utf-8"),
+        log_level.name.encode("utf-8"),
+        message.encode("utf-8"),
+    )
 
 
 def match_message(msg_1: Message, msg_2: Message) -> Mismatches:
@@ -731,7 +811,7 @@ def mismatch_ansi_description(mismatch: Mismatch) -> str:
     raise NotImplementedError
 
 
-def get_error_message(buffer: str, length: int) -> int:
+def get_error_message(length: int = 1024) -> str | None:
     """
     Provide the error message from `LAST_ERROR` to the calling C code.
 
@@ -747,37 +827,40 @@ def get_error_message(buffer: str, length: int) -> int:
     type. If you want more detailed information for debugging purposes, use the
     logging interface.
 
-    # Params
-
-    - `buffer`: a pointer to an array of `char` of sufficient length to hold the
-      error message.
-    - `length`: an int providing the length of the `buffer`.
-
-    # Return Codes
-
-    - The number of bytes written to the provided buffer, which may be zero if
-      there is no last error.
-    - `-1` if the provided buffer is a null pointer.
-    - `-2` if the provided buffer length is too small for the error message.
-    - `-3` if the write failed for some other reason.
-    - `-4` if the error message had an interior NULL
-
-    # Notes
-
-    Note that this function zeroes out any excess in the provided buffer.
-
-    # Error Handling
+    Args:
+        length:
+            The length of the buffer to allocate for the error message. If the
+            error message is longer than this, it will be truncated.
 
-    The return code must be checked for one of the negative number error codes
-    before the buffer is used. If an error code is present, the buffer may not
-    be in a usable state.
+    Returns:
+        A string containing the error message, or None if there is no error
+        message.
 
-    If the buffer is longer than needed for the error message, the excess space
-    will be zeroed as a safety mechanism. This is slightly less efficient than
-    leaving the contents of the buffer alone, but the difference is expected to
-    be negligible in practice.
+    Raises:
+        RuntimeError: If the error message could not be retrieved.
     """
-    raise NotImplementedError
+    buffer = ffi.new("char[]", length)
+    ret: int = lib.pactffi_get_error_message(buffer, length)
+
+    if ret >= 0:
+        # While the documentation says that the return value is the number of bytes
+        # written, the actually return value is always 0 on success.
+        if msg := ffi.string(buffer).decode("utf-8"):
+            return msg
+        return None
+    if ret == -1:
+        msg = "The provided buffer is a null pointer."
+    elif ret == -2:  # noqa: PLR2004
+        # Instead of returning an error here, we call the function again with a
+        # larger buffer.
+        return get_error_message(length * 32)
+    elif ret == -3:  # noqa: PLR2004
+        msg = "The write failed for some other reason."
+    elif ret == -4:  # noqa: PLR2004
+        msg = "The error message had an interior NULL."
+    else:
+        msg = "An unknown error occurred."
+    raise RuntimeError(msg)
 
 
 def log_to_stdout(level_filter: LevelFilter) -> int:
@@ -807,7 +890,7 @@ def log_to_stderr(level_filter: LevelFilter | str = LevelFilter.ERROR) -> None:
     """
     if isinstance(level_filter, str):
         level_filter = LevelFilter[level_filter.upper()]
-    ret = lib.pactffi_log_to_stderr(level_filter.value)
+    ret: int = lib.pactffi_log_to_stderr(level_filter.value)
     if ret != 0:
         msg = "There was an unknown error setting the logger."
         raise RuntimeError(msg)
@@ -828,13 +911,18 @@ def log_to_file(file_name: str, level_filter: LevelFilter) -> int:
     raise NotImplementedError
 
 
-def log_to_buffer(level_filter: LevelFilter) -> int:
+def log_to_buffer(level_filter: LevelFilter | str = LevelFilter.ERROR) -> None:
     """
     Convenience function to direct all logging to a task local memory buffer.
 
     [Rust `pactffi_log_to_buffer`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_log_to_buffer)
     """
-    raise NotImplementedError
+    if isinstance(level_filter, str):
+        level_filter = LevelFilter[level_filter.upper()]
+    ret: int = lib.pactffi_log_to_buffer(level_filter.value)
+    if ret != 0:
+        msg = "There was an unknown error setting the logger."
+        raise RuntimeError(msg)
 
 
 def logger_init() -> None:
@@ -1994,10 +2082,12 @@ def matching_rule_reference_name(rule_result: MatchingRuleResult) -> str:
     raise NotImplementedError
 
 
-def validate_datetime(value: str, format: str) -> int:
+def validate_datetime(value: str, format: str) -> None:
     """
     Validates the date/time value against the date/time format string.
 
+    Raises an error if the value is not a valid date/time for the format string.
+
     If the value is valid, this function will return a zero status code
     (EXIT_SUCCESS). If the value is not valid, will return a value of 1
     (EXIT_FAILURE) and set the error message which can be retrieved with
@@ -2014,7 +2104,17 @@ def validate_datetime(value: str, format: str) -> int:
     This function is safe as long as the value and format parameters point to
     valid NULL-terminated strings.
     """
-    raise NotImplementedError
+    ret = lib.pactffi_validate_datetime(value.encode(), format.encode())
+    if ret == 0:
+        return
+    if ret == 1:
+        msg = f"Invalid datetime value {value!r}' for format {format!r}"
+        raise ValueError(msg)
+    if ret == 2:  # noqa: PLR2004
+        msg = f"Panic while validating datetime value: {get_error_message()}"
+    else:
+        msg = f"Unknown error while validating datetime value: {ret}"
+    raise RuntimeError(msg)
 
 
 def generator_to_json(generator: Generator) -> str:
@@ -4524,8 +4624,7 @@ def new_interaction(pact: PactHandle, description: str) -> InteractionHandle:
             Handle to the Pact model.
 
         description:
-            The interaction description. It needs to be unique for each
-            interaction.
+            The interaction description. It needs to be unique for each Pact.
 
     Returns:
         Handle to the new Interaction.
@@ -4542,15 +4641,26 @@ def new_message_interaction(pact: PactHandle, description: str) -> InteractionHa
     """
     Creates a new message interaction and return a handle to it.
 
-    * `description` - The interaction description. It needs to be unique for
-      each interaction.
-
     [Rust
     `pactffi_new_message_interaction`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_new_message_interaction)
 
-    Returns a new `InteractionHandle`.
+    Args:
+        pact:
+            Handle to the Pact model.
+
+        description:
+            The interaction description. It needs to be unique for each
+            Pact.
+
+    Returns:
+        Handle to the new Interaction
     """
-    raise NotImplementedError
+    return InteractionHandle(
+        lib.pactffi_new_message_interaction(
+            pact._ref,
+            description.encode("utf-8"),
+        ),
+    )
 
 
 def new_sync_message_interaction(
@@ -4560,32 +4670,63 @@ def new_sync_message_interaction(
     """
     Creates a new synchronous message interaction and return a handle to it.
 
-    * `description` - The interaction description. It needs to be unique for
-      each interaction.
-
     [Rust
     `pactffi_new_sync_message_interaction`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_new_sync_message_interaction)
 
-    Returns a new `InteractionHandle`.
+    Args:
+        pact:
+            Handle to the Pact model.
+
+        description:
+            The interaction description. It needs to be unique for each Pact.
+
+    Returns:
+        Handle to the new Interaction
     """
-    raise NotImplementedError
+    return InteractionHandle(
+        lib.pactffi_new_sync_message_interaction(
+            pact._ref,
+            description.encode("utf-8"),
+        ),
+    )
 
 
-def upon_receiving(interaction: InteractionHandle, description: str) -> bool:
+def upon_receiving(interaction: InteractionHandle, description: str) -> None:
     """
     Sets the description for the Interaction.
 
-    Returns false if the interaction or Pact can't be modified (i.e. the mock
-    server for it has already started)
-
     [Rust
     `pactffi_upon_receiving`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_upon_receiving)
 
-    * `description` - The interaction description. It needs to be unique for
-      each interaction.
+    This function
+
+    Args:
+        interaction:
+            Handle to the Interaction.
+
+        description:
+            The interaction description. It needs to be unique for each Pact.
+
+    Raises:
+        RuntimeError: If the interaction description could not be set.
     """
+    # This function has intentionally been left unimplemented. The rationale is
+    # to avoid code of the form:
+    #
+    # ```python
+    #     .with_request("GET", "/")
+    #     .upon_receiving("some new description")
+    # ```
     raise NotImplementedError
 
+    success: bool = lib.pactffi_upon_receiving(
+        interaction._ref,
+        description.encode("utf-8"),
+    )
+    if not success:
+        msg = "The interaction description could not be set."
+        raise RuntimeError(msg)
+
 
 def given(interaction: InteractionHandle, description: str) -> None:
     """
@@ -4610,7 +4751,7 @@ def given(interaction: InteractionHandle, description: str) -> None:
         raise RuntimeError(msg)
 
 
-def interaction_test_name(interaction: InteractionHandle, test_name: str) -> int:
+def interaction_test_name(interaction: InteractionHandle, test_name: str) -> None:
     """
     Sets the test name annotation for the interaction.
 
@@ -4620,10 +4761,20 @@ def interaction_test_name(interaction: InteractionHandle, test_name: str) -> int
     [Rust
     `pactffi_interaction_test_name`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_interaction_test_name)
 
+    Args:
+        interaction:
+            Handle to the Interaction.
+
+        test_name:
+            The test name to set.
+
     # Safety
 
     The test name parameter must be a valid pointer to a NULL terminated string.
 
+    Raises:
+        RuntimeError: If the test name can not be set.
+
     # Error Handling
 
     If the test name can not be set, this will return a positive value.
@@ -4635,7 +4786,23 @@ def interaction_test_name(interaction: InteractionHandle, test_name: str) -> int
       modified.
     * `4` - Not a V4 interaction.
     """
-    raise NotImplementedError
+    ret: int = lib.pactffi_interaction_test_name(
+        interaction._ref,
+        test_name.encode("utf-8"),
+    )
+    if ret == 0:
+        return
+    if ret == 1:
+        msg = f"Function panicked: {get_error_message()}"
+    elif ret == 2:  # noqa: PLR2004
+        msg = f"Invalid handle: {interaction}."
+    elif ret == 3:  # noqa: PLR2004
+        msg = f"Mock server for {interaction} has already started."
+    elif ret == 4:  # noqa: PLR2004
+        msg = f"Interaction {interaction} is not a V4 interaction."
+    else:
+        msg = f"Unknown error setting test name for {interaction}."
+    raise RuntimeError(msg)
 
 
 def given_with_param(
@@ -4643,7 +4810,7 @@ def given_with_param(
     description: str,
     name: str,
     value: str,
-) -> bool:
+) -> None:
     """
     Adds a parameter key and value to a provider state to the Interaction.
 
@@ -4654,23 +4821,38 @@ def given_with_param(
     [Rust
     `pactffi_given_with_param`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_given_with_param)
 
-    Returns false if the interaction or Pact can't be modified (i.e. the mock
-    server for it has already started).
+    Args:
+        interaction:
+            Handle to the Interaction.
+
+        description:
+            The provider state description.
 
-    # Parameters
+        name:
+            Parameter name.
 
-    * `description` - The provider state description. It needs to be unique.
-    * `name` - Parameter name.
-    * `value` - Parameter value as JSON.
+        value:
+            Parameter value as JSON.
+
+    Raises:
+        RuntimeError: If the interaction state could not be updated.
     """
-    raise NotImplementedError
+    success: bool = lib.pactffi_given_with_param(
+        interaction._ref,
+        description.encode("utf-8"),
+        name.encode("utf-8"),
+        value.encode("utf-8"),
+    )
+    if not success:
+        msg = "The interaction state could not be updated."
+        raise RuntimeError(msg)
 
 
 def given_with_params(
     interaction: InteractionHandle,
     description: str,
     params: str,
-) -> int:
+) -> None:
     """
     Adds a provider state to the Interaction.
 
@@ -4680,10 +4862,18 @@ def given_with_params(
     [Rust
     `pactffi_given_with_params`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_given_with_params)
 
-    # Parameters
+    Args:
+        interaction:
+            Handle to the Interaction.
 
-    * `description` - The provider state description.
-    * `params` - Parameter values as a JSON fragment.
+        description:
+            The provider state description.
+
+        params:
+            Parameter values as a JSON fragment.
+
+    Raises:
+        RuntimeError: If the interaction state could not be updated.
 
     # Errors
 
@@ -4697,7 +4887,22 @@ def given_with_params(
     Returns 3 if any of the C strings are not valid.
 
     """
-    raise NotImplementedError
+    ret: int = lib.pactffi_given_with_params(
+        interaction._ref,
+        description.encode("utf-8"),
+        params.encode("utf-8"),
+    )
+    if ret == 0:
+        return
+    if ret == 1:
+        msg = "The interaction state could not be updated."
+    elif ret == 2:  # noqa: PLR2004
+        msg = f"Internal error: {get_error_message()}"
+    elif ret == 3:  # noqa: PLR2004
+        msg = "Invalid C string."
+    else:
+        msg = "Unknown error."
+    raise RuntimeError(msg)
 
 
 def with_request(interaction: InteractionHandle, method: str, path: str) -> None:
@@ -5071,9 +5276,8 @@ def with_binary_file(
     interaction: InteractionHandle,
     part: InteractionPart,
     content_type: str,
-    body: List[int],
-    size: int,
-) -> bool:
+    body: bytes | None,
+) -> None:
     """
     Adds a binary file as the body with the expected content type and contents.
 
@@ -5084,41 +5288,53 @@ def with_binary_file(
     [Rust
     `pactffi_with_binary_file`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_with_binary_file)
 
-    * `interaction` - Interaction handle to set the body for.
-    * `part` - Request or response part.
-    * `content_type` - Expected content type.
-    * `body` - example body contents in bytes
-    * `size` - number of bytes in the body
-
     For HTTP and async message interactions, this will overwrite the body. With
     asynchronous messages, the part parameter will be ignored. With synchronous
     messages, the request contents will be overwritten, while a new response
     will be appended to the message.
 
-    # Safety
+    Args:
+        interaction:
+            Handle to the Interaction.
 
-    The content type must be a valid UTF-8 encoded NULL-terminated string. The
-    body pointer must be valid for reads of `size` bytes, and it must be
-    properly aligned and consecutive.
+        part:
+            The part of the interaction to add the body to (Request or
+            Response).
 
-    # Error Handling
+        content_type:
+            The content type of the body. Will be ignored if a content type
+            header is already set.
 
-    If the body is a NULL pointer, it will set the body contents as null. If the
-    content type is a null pointer, or can't be parsed, it will return false.
-    Returns false if the interaction or Pact can't be modified (i.e. the mock
-    server for it has already started) or an error has occurred.
-    """
-    raise NotImplementedError
+        body:
+            The body contents. If `None`, the body will be set to null.
+    """
+    if len(gc.get_referrers(body)) == 0:
+        warnings.warn(
+            "Make sure to assign the body to a variable to avoid having the byte array"
+            " modified.",
+            UserWarning,
+            stacklevel=3,
+        )
+    success: bool = lib.pactffi_with_binary_file(
+        interaction._ref,
+        part.value,
+        content_type.encode("utf-8"),
+        body if body else ffi.NULL,
+        len(body) if body else 0,
+    )
+    if not success:
+        msg = f"Unable to set body for {interaction}."
+        raise RuntimeError(msg)
 
 
 def with_multipart_file_v2(  # noqa: PLR0913
     interaction: InteractionHandle,
     part: InteractionPart,
     content_type: str,
-    file: str,
+    file: Path | None,
     part_name: str,
-    boundary: str,
-) -> StringResult:
+    boundary: str | None,
+) -> None:
     """
     Adds a binary file as the body as a MIME multipart.
 
@@ -5129,34 +5345,41 @@ def with_multipart_file_v2(  # noqa: PLR0913
     [Rust
     `pactffi_with_multipart_file_v2`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_with_multipart_file_v2)
 
-    * `interaction` - Interaction handle to set the body for.
-    * `part` - Request or response part.
-    * `content_type` - Expected content type of the file.
-    * `file` - path to the example file
-    * `part_name` - name for the mime part
-    * `boundary` - boundary for the multipart separation
-
     This function can be called multiple times. In that case, each subsequent
     call will be appended to the existing multipart body as a new part.
 
-    # Safety
-
-    The content type, file path and part name must be valid pointers to UTF-8
-    encoded NULL-terminated strings. Passing invalid pointers or pointers to
-    strings that are not NULL terminated will lead to undefined behaviour.
+    Args:
+        interaction:
+            Handle to the Interaction.
 
-    # Error Handling
+        part:
+            The part of the interaction to add the body to (Request or
+            Response).
 
-    If the boundary is a NULL pointer, a random string will be used. If the file
-    path is a NULL pointer, it will set the body contents as as an empty
-    mime-part. If the file path does not point to a valid file, or is not able
-    to be read, it will return an error result. If the content type is a null
-    pointer, or can't be parsed, it will return an error result. Returns an
-    error if the interaction or Pact can't be modified (i.e. the mock server for
-    it has already started), the interaction is not an HTTP interaction or some
-    other error occurs.
-    """
-    raise NotImplementedError
+        content_type:
+            The content type of the body.
+
+        file:
+            Path to the file to add. If `None`, the body will be set to null.
+
+        part_name:
+            Name for the mime part.
+
+        boundary:
+            Boundary for the multipart separation. If `None`, a random string
+            will be used.
+    """
+    result = StringResult(
+        lib.pactffi_with_multipart_file_v2(
+            interaction._ref,
+            part.value,
+            content_type.encode("utf-8"),
+            str(file).encode("utf-8") if file else ffi.NULL,
+            part_name.encode("utf-8"),
+            boundary.encode("utf-8") if boundary else ffi.NULL,
+        ),
+    )
+    result.raise_exception()
 
 
 def with_multipart_file(
@@ -5201,6 +5424,8 @@ def with_multipart_file(
     it has already started), the interaction is not an HTTP interaction or some
     other error occurs.
     """
+    # This function is intentionally left unimplemented. The
+    # `with_multipart_file_v2` function should be used instead.
     raise NotImplementedError
 
 
@@ -6216,7 +6441,7 @@ def interaction_contents(
     part: InteractionPart,
     content_type: str,
     contents: str,
-) -> int:
+) -> None:
     """
     Setup the interaction part using a plugin.
 
@@ -6227,32 +6452,43 @@ def interaction_contents(
     [Rust
     `pactffi_interaction_contents`](https://docs.rs/pact_ffi/0.4.9/pact_ffi/?search=pactffi_interaction_contents)
 
-    Returns zero on success, and a positive integer value on failure.
-
-    * `interaction` - Handle to the interaction to configure.
-    * `part` - The part of the interaction to configure (request or response).
-      It is ignored for messages.
-    * `content_type` - NULL terminated C string of the content type of the part.
-    * `contents` - NULL terminated C string of the JSON contents that gets
-      passed to the plugin.
-
-    # Safety
-
-    `content_type` and `contents` must be a valid pointers to NULL terminated
-    strings. Invalid pointers will result in undefined behaviour.
+    Args:
+        interaction:
+            Handle to the interaction to configure.
 
-    # Errors
+        part:
+            The part of the interaction to configure (request or response). It
+            is ignored for messages.
 
-    * `1` - A general panic was caught.
-    * `2` - The mock server has already been started.
-    * `3` - The interaction handle is invalid.
-    * `4` - The content type is not valid.
-    * `5` - The contents JSON is not valid JSON.
-    * `6` - The plugin returned an error.
+        content_type:
+            Mime type of the contents.
 
-    When an error errors, LAST_ERROR will contain the error message.
+        contents:
+            JSON contents that gets passed to the plugin.
     """
-    raise NotImplementedError
+    ret: int = lib.pactffi_interaction_contents(
+        interaction._ref,
+        part.value,
+        content_type.encode("utf-8"),
+        contents.encode("utf-8"),
+    )
+    if ret == 0:
+        return
+    if ret == 1:
+        msg = f"A general panic was caught: {get_error_message()}"
+    if ret == 2:  # noqa: PLR2004
+        msg = "The mock server has already been started."
+    if ret == 3:  # noqa: PLR2004
+        msg = f"The interaction handle {interaction} is invalid."
+    if ret == 4:  # noqa: PLR2004
+        msg = f"The content type {content_type} is not valid."
+    if ret == 5:  # noqa: PLR2004
+        msg = "The content is not valid JSON."
+    if ret == 6:  # noqa: PLR2004
+        msg = f"The plugin returned an error: {get_error_message()}"
+    else:
+        msg = f"There was an unknown error configuring the interaction: {ret}"
+    raise RuntimeError(msg)
 
 
 def matches_string_value(
diff --git a/pact/v3/pact.py b/pact/v3/pact.py
index 92e6d7c..b4dfe3a 100644
--- a/pact/v3/pact.py
+++ b/pact/v3/pact.py
@@ -11,9 +11,11 @@ For the roles of consumer and provider, see the documentation for the
 
 from __future__ import annotations
 
+import abc
+import json
 from collections import defaultdict
 from pathlib import Path
-from typing import TYPE_CHECKING, Iterable, Literal, Set
+from typing import TYPE_CHECKING, Any, Iterable, Literal, Set, overload
 
 from yarl import URL
 
@@ -28,61 +30,404 @@ if TYPE_CHECKING:
         from typing_extensions import Self
 
 
-class Interaction:
+class Interaction(abc.ABC):
     """
     Interaction between a consumer and a provider.
 
-    This class defines an interaction between a consumer and a provider. It
-    defines a specific request that the consumer makes to the provider, and the
-    response that the provider should return.
+    This abstract class defines an interaction between a consumer and a
+    provider. The concrete subclasses define the type of interaction, and include:
+
+    -  [`HttpInteraction`][pact.v3.pact.HttpInteraction]
+    -  [`AsyncMessageInteraction`][pact.v3.pact.AsyncMessageInteraction]
+    -  [`SyncMessageInteraction`][pact.v3.pact.SyncMessageInteraction]
 
     A set of interactions between a consumer and a provider is called a Pact.
     """
 
-    def __init__(self, pact_handle: pact.v3.ffi.PactHandle, description: str) -> None:
+    def __init__(self, description: str) -> None:
         """
-        Initialise a new Interaction.
+        Create a new Interaction.
 
-        This function should not be called directly. Instead, an Interaction
-        should be created using the
-        [`upon_receiving(...)`][pact.v3.Pact.upon_receiving] method of a
-        [`Pact`][pact.v3.Pact] instance.
+        As this class is abstract, this function should not be called directly
+        but should instead be called through one of the concrete subclasses.
+
+        Args:
+            description:
+                Description of the interaction. This must be unique within the
+                Pact.
         """
-        self._handle = pact.v3.ffi.new_interaction(pact_handle, description)
-        self._is_request = True
-        self._request_indices: dict[
-            tuple[pact.v3.ffi.InteractionPart, str],
-            int,
-        ] = defaultdict(int)
-        self._parameter_indices: dict[str, int] = defaultdict(int)
+        self._description = description
 
     def __str__(self) -> str:
         """
-        Informal string representation of the Interaction.
+        Nice representation of the Interaction.
         """
-        raise NotImplementedError
+        return f"{self.__class__.__name__}({self._description})"
 
     def __repr__(self) -> str:
         """
-        Information-rich string representation of the Interaction.
+        Debugging representation of the Interaction.
+        """
+        return f"{self.__class__.__name__}({self._handle!r})"
+
+    @property
+    @abc.abstractmethod
+    def _handle(self) -> pact.v3.ffi.InteractionHandle:
+        """
+        Handle for the Interaction.
+
+        This is used internally by the library to pass the Interaction to the
+        underlying Pact library.
+        """
+
+    @property
+    @abc.abstractmethod
+    def _interaction_part(self) -> pact.v3.ffi.InteractionPart:
+        """
+        Interaction part.
+
+        Where interactions have multiple parts, this property keeps track
+        of which part is currently being set.
+        """
+
+    def _parse_interaction_part(
+        self,
+        part: Literal["Request", "Response", None],
+    ) -> pact.v3.ffi.InteractionPart:
+        """
+        Convert the input into an InteractionPart.
         """
-        raise NotImplementedError
+        if part == "Request":
+            return pact.v3.ffi.InteractionPart.REQUEST
+        if part == "Response":
+            return pact.v3.ffi.InteractionPart.RESPONSE
+        if part is None:
+            return self._interaction_part
+        msg = f"Invalid part: {part}"
+        raise ValueError(msg)
 
-    def given(self, state: str) -> Interaction:
+    @overload
+    def given(self, state: str) -> Self:
+        ...
+
+    @overload
+    def given(self, state: str, *, name: str, value: str) -> Self:
+        ...
+
+    @overload
+    def given(self, state: str, *, parameters: dict[str, Any] | str) -> Self:
+        ...
+
+    def given(
+        self,
+        state: str,
+        *,
+        name: str | None = None,
+        value: str | None = None,
+        parameters: dict[str, Any] | str | None = None,
+    ) -> Self:
         """
         Set the provider state.
 
         This is the state that the provider should be in when the Interaction is
-        executed.
+        executed. When the provider is being verified, the provider state is
+        passed to the provider so that its internal state can be set to match
+        the provider state.
+
+        In its simplest form, the provider state is a string. For example, to
+        match a provider state of `a user exists`, you would use:
+
+        ```python
+        pact.upon_receiving("a request").given("a user exists")
+        ```
+
+        It is also possible to specify a parameter that will be used to match
+        the provider state. For example, to match a provider state of `a user
+        exists` with a parameter `id` that has the value `123`, you would use:
+
+        ```python
+        (
+            pact.upon_receiving("a request")
+            .given("a user exists", name="id", value="123")
+        )
+        ```
+
+        Lastly, it is possible to specify multiple parameters that will be used
+        to match the provider state. For example, to match a provider state of
+        `a user exists` with a parameter `id` that has the value `123` and a
+        parameter `name` that has the value `John`, you would use:
+
+        ```python
+        (
+            pact.upon_receiving("a request")
+            .given("a user exists", parameters={
+                "id": "123",
+                "name": "John",
+            })
+        )
+        ```
+
+        This function can be called repeatedly to specify multiple provider
+        states for the same Interaction. If the same `state` is specified with
+        different parameters, then the parameters are merged together. The above
+        example with multiple parameters can equivalently be specified as:
+
+        ```python
+        (
+            pact.upon_receiving("a request")
+            .given("a user exists", name="id", value="123")
+            .given("a user exists", name="name", value="John")
+        )
+        ```
 
         Args:
             state:
                 Provider state for the Interaction.
+
+            name:
+                Name of the parameter. This must be specified in conjunction
+                with `value`.
+
+            value:
+                Value of the parameter. This must be specified in conjunction
+                with `name`.
+
+            parameters:
+                Key-value pairs of parameters to use for the provider state.
+                These must be encodable using [`json.dumps(...)`][json.dumps].
+                Alternatively, a string contained the JSON object can be passed
+                directly.
+
+                If the string does not contain a valid JSON object, then the
+                string is passed directly as follows:
+
+                ```python
+                (
+                    pact.upon_receiving("a request")
+                    .given("a user exists", name="value", value=parameters)
+                )
+                ```
+
+        Raises:
+            ValueError:
+                If the combination of arguments is invalid or inconsistent.
+        """
+        if name is not None and value is not None and parameters is None:
+            pact.v3.ffi.given_with_param(self._handle, state, name, value)
+        elif name is None and value is None and parameters is not None:
+            if isinstance(parameters, dict):
+                pact.v3.ffi.given_with_params(
+                    self._handle,
+                    state,
+                    json.dumps(parameters),
+                )
+            else:
+                pact.v3.ffi.given_with_params(self._handle, state, parameters)
+        elif name is None and value is None and parameters is None:
+            pact.v3.ffi.given(self._handle, state)
+        else:
+            msg = "Invalid combination of arguments."
+            raise ValueError(msg)
+        return self
+
+    def with_body(
+        self,
+        body: str | None = None,
+        content_type: str = "text/plain",
+        part: Literal["Request", "Response"] | None = None,
+    ) -> Self:
+        """
+        Set the body of the request or response.
+
+        Args:
+            body:
+                Body of the request. If this is `None`, then the body is
+                empty.
+
+            content_type:
+                Content type of the body. This is ignored if the `Content-Type`
+                header has already been set.
+
+            part:
+                Whether the body should be added to the request or the response.
+                If `None`, then the function intelligently determines whether
+                the body should be added to the request or the response, based
+                on whether the
+                [`will_respond_with(...)`][pact.v3.Interaction.will_respond_with]
+                method has been called.
+        """
+        pact.v3.ffi.with_body(
+            self._handle,
+            self._parse_interaction_part(part),
+            content_type,
+            body,
+        )
+        return self
+
+    def with_binary_file(
+        self,
+        body: bytes | None,
+        content_type: str = "application/octet-stream",
+        part: Literal["Request", "Response"] | None = None,
+    ) -> Self:
+        """
+        Adds a binary file to the request or response.
+
+        Note that for HTTP interactions, this function will overwrite the body
+        if it has been set using
+        [`with_body(...)`][pact.v3.Interaction.with_body].
+
+        Args:
+            part:
+                Whether the body should be added to the request or the response.
+                If `None`, then the function intelligently determines whether
+                the body should be added to the request or the response, based
+                on whether the
+                [`will_respond_with(...)`][pact.v3.Interaction.will_respond_with]
+                method has been called.
+
+            content_type:
+                Content type of the body. This is ignored if the `Content-Type`
+                header has already been set.
+
+            body:
+                Body of the request.
+        """
+        pact.v3.ffi.with_binary_file(
+            self._handle,
+            self._parse_interaction_part(part),
+            content_type,
+            body,
+        )
+        return self
+
+    def with_multipart_file(  # noqa: PLR0913
+        self,
+        part_name: str,
+        path: Path | None,
+        content_type: str = "application/octet-stream",
+        part: Literal["Request", "Response"] | None = None,
+        boundary: str | None = None,
+    ) -> Self:
+        """
+        Adds a binary file as the body of a multipart request or response.
+
+        The content type of the body will be set to a MIME multipart message.
+        """
+        pact.v3.ffi.with_multipart_file_v2(
+            self._handle,
+            self._parse_interaction_part(part),
+            part_name,
+            path,
+            content_type,
+            boundary,
+        )
+        return self
+
+    def test_name(
+        self,
+        name: str,
+    ) -> Self:
+        """
+        Set the test name annotation for the interaction.
+
+        This is used by V4 interactions to set the name of the test.
+
+        Args:
+            name:
+                Name of the test.
+        """
+        pact.v3.ffi.interaction_test_name(self._handle, name)
+        return self
+
+    def with_plugin_contents(
+        self,
+        contents: dict[str, Any] | str,
+        content_type: str = "text/plain",
+        part: Literal["Request", "Response"] | None = None,
+    ) -> Self:
+        """
+        Set the interaction content using a plugin.
+
+        The value of `contents` is passed directly to the plugin as a JSON
+        string. The plugin will document the format of the JSON content.
+
+        Args:
+            contents:
+                Body of the request. If this is `None`, then the body is empty.
+
+            content_type:
+                Content type of the body. This is ignored if the `Content-Type`
+                header has already been set.
+
+            part:
+                Whether the body should be added to the request or the response.
+                If `None`, then the function intelligently determines whether
+                the body should be added to the request or the response, based
+                on whether the
+                [`will_respond_with(...)`][pact.v3.Interaction.will_respond_with]
+                method has been called.
         """
-        pact.v3.ffi.given(self._handle, state)
+        if isinstance(contents, dict):
+            contents = json.dumps(contents)
+
+        pact.v3.ffi.interaction_contents(
+            self._handle,
+            self._parse_interaction_part(part),
+            content_type,
+            contents,
+        )
         return self
 
-    def with_request(self, method: str, path: str) -> Interaction:
+
+class HttpInteraction(Interaction):
+    """
+    A synchronous HTTP interaction.
+
+    This class defines a synchronous HTTP interaction between a consumer and a
+    provider. It defines a specific request that the consumer makes to the
+    provider, and the response that the provider should return.
+    """
+
+    def __init__(self, pact_handle: pact.v3.ffi.PactHandle, description: str) -> None:
+        """
+        Initialise a new HTTP Interaction.
+
+        This function should not be called directly. Instead, an Interaction
+        should be created using the
+        [`upon_receiving(...)`][pact.v3.Pact.upon_receiving] method of a
+        [`Pact`][pact.v3.Pact] instance.
+        """
+        super().__init__(description)
+        self.__handle = pact.v3.ffi.new_interaction(pact_handle, description)
+        self.__interaction_part = pact.v3.ffi.InteractionPart.REQUEST
+        self._request_indices: dict[
+            tuple[pact.v3.ffi.InteractionPart, str],
+            int,
+        ] = defaultdict(int)
+        self._parameter_indices: dict[str, int] = defaultdict(int)
+
+    @property
+    def _handle(self) -> pact.v3.ffi.InteractionHandle:
+        """
+        Handle for the Interaction.
+
+        This is used internally by the library to pass the Interaction to the
+        underlying Pact library.
+        """
+        return self.__handle
+
+    @property
+    def _interaction_part(self) -> pact.v3.ffi.InteractionPart:
+        """
+        Interaction part.
+
+        Keeps track whether we are setting by default the request or the
+        response in the HTTP interaction.
+        """
+        return self.__interaction_part
+
+    def with_request(self, method: str, path: str) -> Self:
         """
         Set the request.
 
@@ -97,27 +442,12 @@ class Interaction:
         pact.v3.ffi.with_request(self._handle, method, path)
         return self
 
-    def _interaction_part(
-        self,
-        part: Literal["Request", "Response", None],
-    ) -> pact.v3.ffi.InteractionPart:
-        """
-        Convert the input into an InteractionPart.
-        """
-        part = part or ("Request" if self._is_request else "Response")
-        if part == "Request":
-            return pact.v3.ffi.InteractionPart.REQUEST
-        if part == "Response":
-            return pact.v3.ffi.InteractionPart.RESPONSE
-        msg = f"Invalid part: {part}"
-        raise ValueError(msg)
-
     def with_header(
         self,
         name: str,
         value: str,
         part: Literal["Request", "Response"] | None = None,
-    ) -> Interaction:
+    ) -> Self:
         r"""
         Add a header to the request.
 
@@ -207,7 +537,7 @@ class Interaction:
                 [`will_respond_with(...)`][pact.v3.Interaction.will_respond_with]
                 method has been called.
         """
-        interaction_part = self._interaction_part(part)
+        interaction_part = self._parse_interaction_part(part)
         name_lower = name.lower()
         index = self._request_indices[(interaction_part, name_lower)]
         self._request_indices[(interaction_part, name_lower)] += 1
@@ -224,7 +554,7 @@ class Interaction:
         self,
         headers: dict[str, str] | Iterable[tuple[str, str]],
         part: Literal["Request", "Response"] | None = None,
-    ) -> Interaction:
+    ) -> Self:
         """
         Add multiple headers to the request.
 
@@ -279,7 +609,7 @@ class Interaction:
         name: str,
         value: str,
         part: Literal["Request", "Response"] | None = None,
-    ) -> Interaction:
+    ) -> Self:
         r"""
         Add a header to the request.
 
@@ -304,7 +634,7 @@ class Interaction:
         """
         pact.v3.ffi.set_header(
             self._handle,
-            self._interaction_part(part),
+            self._parse_interaction_part(part),
             name,
             value,
         )
@@ -314,7 +644,7 @@ class Interaction:
         self,
         headers: dict[str, str] | Iterable[tuple[str, str]],
         part: Literal["Request", "Response"] | None = None,
-    ) -> Interaction:
+    ) -> Self:
         """
         Add multiple headers to the request.
 
@@ -344,7 +674,7 @@ class Interaction:
             self.set_header(name, value, part)
         return self
 
-    def with_query_parameter(self, name: str, value: str) -> Interaction:
+    def with_query_parameter(self, name: str, value: str) -> Self:
         r"""
         Add a query to the request.
 
@@ -417,7 +747,7 @@ class Interaction:
     def with_query_parameters(
         self,
         parameters: dict[str, str] | Iterable[tuple[str, str]],
-    ) -> Interaction:
+    ) -> Self:
         """
         Add multiple query parameters to the request.
 
@@ -434,41 +764,7 @@ class Interaction:
             self.with_query_parameter(name, value)
         return self
 
-    def with_body(
-        self,
-        body: str | None = None,
-        content_type: str = "text/plain",
-        part: Literal["Request", "Response"] | None = None,
-    ) -> Interaction:
-        """
-        Set the body of the request.
-
-        Args:
-            body:
-                Body of the request. If this is `None`, then the body is
-                empty.
-
-            content_type:
-                Content type of the body. This is ignored if the `Content-Type`
-                header has already been set.
-
-            part:
-                Whether the body should be added to the request or the response.
-                If `None`, then the function intelligently determines whether
-                the body should be added to the request or the response, based
-                on whether the
-                [`will_respond_with(...)`][pact.v3.Interaction.will_respond_with]
-                method has been called.
-        """
-        pact.v3.ffi.with_body(
-            self._handle,
-            self._interaction_part(part),
-            content_type,
-            body,
-        )
-        return self
-
-    def will_respond_with(self, status: int) -> Interaction:
+    def will_respond_with(self, status: int) -> Self:
         """
         Set the response status.
 
@@ -485,10 +781,104 @@ class Interaction:
                 Status for the response.
         """
         pact.v3.ffi.response_status(self._handle, status)
-        self._is_request = False
+        self.__interaction_part = pact.v3.ffi.InteractionPart.RESPONSE
         return self
 
 
+class AsyncMessageInteraction(Interaction):
+    """
+    An asynchronous message interaction.
+
+    This class defines an asynchronous message interaction between a consumer
+    and a provider. It defines the kind of messages a consumer can accept, and
+    the is agnostic of the underlying protocol, be it a message queue, Apache
+    Kafka, or some other asynchronous protocol.
+    """
+
+    def __init__(self, pact_handle: pact.v3.ffi.PactHandle, description: str) -> None:
+        """
+        Initialise a new Asynchronous Message Interaction.
+
+        This function should not be called directly. Instead, an
+        AsyncMessageInteraction should be created using the
+        [`upon_receiving(...)`][pact.v3.Pact.upon_receiving] method of a
+        [`Pact`][pact.v3.Pact] instance using the `"Async"` interaction type.
+
+        Args:
+            pact_handle:
+                Handle for the Pact.
+
+            description:
+                Description of the interaction. This must be unique within the
+                Pact.
+        """
+        super().__init__(description)
+        self.__handle = pact.v3.ffi.new_message_interaction(pact_handle, description)
+
+    @property
+    def _handle(self) -> pact.v3.ffi.InteractionHandle:
+        """
+        Handle for the Interaction.
+
+        This is used internally by the library to pass the Interaction to the
+        underlying Pact library.
+        """
+        return self.__handle
+
+    @property
+    def _interaction_part(self) -> pact.v3.ffi.InteractionPart:
+        return pact.v3.ffi.InteractionPart.REQUEST
+
+
+class SyncMessageInteraction(Interaction):
+    """
+    A synchronous message interaction.
+
+    This class defines a synchronous message interaction between a consumer and
+    a provider. As with [`HttpInteraction`][pact.v3.pact.HttpInteraction], it
+    defines a specific request that the consumer makes to the provider, and the
+    response that the provider should return.
+    """
+
+    def __init__(self, pact_handle: pact.v3.ffi.PactHandle, description: str) -> None:
+        """
+        Initialise a new Synchronous Message Interaction.
+
+        This function should not be called directly. Instead, an
+        AsyncMessageInteraction should be created using the
+        [`upon_receiving(...)`][pact.v3.Pact.upon_receiving] method of a
+        [`Pact`][pact.v3.Pact] instance using the `"Sync"` interaction type.
+
+        Args:
+            pact_handle:
+                Handle for the Pact.
+
+            description:
+                Description of the interaction. This must be unique within the
+                Pact.
+        """
+        super().__init__(description)
+        self.__handle = pact.v3.ffi.new_sync_message_interaction(
+            pact_handle,
+            description,
+        )
+        self.__interaction_part = pact.v3.ffi.InteractionPart.REQUEST
+
+    @property
+    def _handle(self) -> pact.v3.ffi.InteractionHandle:
+        """
+        Handle for the Interaction.
+
+        This is used internally by the library to pass the Interaction to the
+        underlying Pact library.
+        """
+        return self.__handle
+
+    @property
+    def _interaction_part(self) -> pact.v3.ffi.InteractionPart:
+        return self.__interaction_part
+
+
 class Pact:
     """
     A Pact between a consumer and a provider.
@@ -542,7 +932,15 @@ class Pact:
         """
         Information-rich string representation of the Pact.
         """
-        return f"Pact({self})"
+        return "<Pact: {}>".format(
+            ", ".join(
+                [
+                    f"consumer={self.consumer!r}",
+                    f"provider={self.provider!r}",
+                    f"handle={self._handle!r}",
+                ],
+            ),
+        )
 
     @property
     def consumer(self) -> str:
@@ -558,7 +956,42 @@ class Pact:
         """
         return self._provider
 
-    def upon_receiving(self, description: str) -> Interaction:
+    @overload
+    def upon_receiving(
+        self,
+        description: str,
+    ) -> HttpInteraction:
+        ...
+
+    @overload
+    def upon_receiving(
+        self,
+        description: str,
+        interaction: Literal["HTTP"],
+    ) -> HttpInteraction:
+        ...
+
+    @overload
+    def upon_receiving(
+        self,
+        description: str,
+        interaction: Literal["Async"],
+    ) -> AsyncMessageInteraction:
+        ...
+
+    @overload
+    def upon_receiving(
+        self,
+        description: str,
+        interaction: Literal["Sync"],
+    ) -> SyncMessageInteraction:
+        ...
+
+    def upon_receiving(
+        self,
+        description: str,
+        interaction: Literal["HTTP", "Sync", "Async"] = "HTTP",
+    ) -> HttpInteraction | AsyncMessageInteraction | SyncMessageInteraction:
         """
         Create a new Interaction.
 
@@ -568,8 +1001,20 @@ class Pact:
             description:
                 Description of the interaction. This must be unique
                 within the Pact.
+
+            interaction:
+                Type of interaction. Defaults to `HTTP`. This must be one of
+                `HTTP`, `Async`, or `Sync`.
         """
-        return Interaction(self._handle, description)
+        if interaction == "HTTP":
+            return HttpInteraction(self._handle, description)
+        if interaction == "Async":
+            return AsyncMessageInteraction(self._handle, description)
+        if interaction == "Sync":
+            return SyncMessageInteraction(self._handle, description)
+
+        msg = f"Invalid interaction type: {interaction}"
+        raise ValueError(msg)
 
     def serve(
         self,
@@ -713,7 +1158,17 @@ class PactServer:
         """
         Information-rich string representation of the Pact Server.
         """
-        return f"PactServer({self})"
+        return "<PactServer: {}>".format(
+            ", ".join(
+                [
+                    f"transport={self.transport!r}",
+                    f"host={self.host!r}",
+                    f"port={self.port!r}",
+                    f"handle={self._handle!r}",
+                    f"pact={self._pact_handle!r}",
+                ],
+            ),
+        )
 
     def __enter__(self) -> Self:
         """
diff --git a/pyproject.toml b/pyproject.toml
index e3e13f9..62db1dd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -131,6 +131,7 @@ addopts = [
   "--import-mode=importlib",
   "--cov-config=pyproject.toml",
   "--cov=pact",
+  "--cov-report=xml",
 ]
 filterwarnings = [
   "ignore::DeprecationWarning:pact",


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..6d29224
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,45 @@
+"""
+Common fixtures for tests.
+"""
+import json
+import shutil
+import tempfile
+from pathlib import Path
+from typing import Any, Generator
+
+import pytest
+
+
+@pytest.fixture()
+def temp_dir() -> Generator[Path, Any, None]:
+    """
+    Create a temporary directory.
+
+    This fixture automatically handles cleanup of the temporary directory once
+    the test has finished.
+
+    The directory is populated with a few minimal files:
+
+    - `test.py`: A minimal hello-world Python script.
+    - `test.txt`: A minimal text file.
+    - `test.json`: A minimal JSON file.
+    - `test.png`: A minimal PNG image.
+    """
+    temp_dir = Path(tempfile.mkdtemp())
+    with (temp_dir / "test.py").open("w") as f:
+        f.write('print("Hello, world!")')
+    with (temp_dir / "test.txt").open("w") as f:
+        f.write("Hello, world!")
+    with (temp_dir / "test.json").open("w") as f:
+        json.dump({"hello": "world"}, f)
+    with (temp_dir / "test.png").open("wb") as f:
+        f.write(
+            b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52"
+            b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4"
+            b"\x89\x00\x00\x00\x0a\x49\x44\x41\x54\x78\x9c\x63\x00\x01\x00\x00"
+            b"\x05\x00\x01\x0d\x0a\x2d\xb4\x00\x00\x00\x00\x49\x45\x4e\x44\xae"
+            b"\x42\x60\x82",
+        )
+
+    yield temp_dir
+    shutil.rmtree(temp_dir)
diff --git a/tests/v3/conftest.py b/tests/v3/conftest.py
index e76e3be..d509cba 100644
--- a/tests/v3/conftest.py
+++ b/tests/v3/conftest.py
@@ -5,6 +5,7 @@ This file is loaded automatically by PyTest when running the tests in this
 directory.
 """
 
+
 import pytest
 
 
@@ -15,4 +16,4 @@ def _setup_pact_logging() -> None:
     """
     from pact.v3 import ffi
 
-    ffi.log_to_stderr(ffi.LevelFilter.DEBUG)
+    ffi.log_to_stderr("DEBUG")
diff --git a/tests/v3/test_async_interaction.py b/tests/v3/test_async_interaction.py
new file mode 100644
index 0000000..64e2154
--- /dev/null
+++ b/tests/v3/test_async_interaction.py
@@ -0,0 +1,34 @@
+"""
+Pact Async Message Interaction unit tests.
+"""
+
+from __future__ import annotations
+
+import re
+
+import pytest
+from pact.v3 import Pact
+
+
+@pytest.fixture()
+def pact() -> Pact:
+    """
+    Fixture for a Pact instance.
+    """
+    return Pact("consumer", "provider")
+
+
+def test_str(pact: Pact) -> None:
+    interaction = pact.upon_receiving("a basic request", "Async")
+    assert str(interaction) == "AsyncMessageInteraction(a basic request)"
+
+
+def test_repr(pact: Pact) -> None:
+    interaction = pact.upon_receiving("a basic request", "Async")
+    assert (
+        re.match(
+            r"^AsyncMessageInteraction\(InteractionHandle\(\d+\)\)$",
+            repr(interaction),
+        )
+        is not None
+    )
diff --git a/tests/v3/test_ffi.py b/tests/v3/test_ffi.py
index be3aff5..f899144 100644
--- a/tests/v3/test_ffi.py
+++ b/tests/v3/test_ffi.py
@@ -5,7 +5,9 @@ These tests are intended to ensure that the FFI module is working correctly.
 They are not intended to test the Pact API itself, as that is handled by the
 client library.
 """
+import re
 
+import pytest
 from pact.v3 import ffi
 
 
@@ -13,3 +15,39 @@ def test_version() -> None:
     assert isinstance(ffi.version(), str)
     assert len(ffi.version()) > 0
     assert ffi.version().count(".") == 2
+
+
+def test_string_result_ok() -> None:
+    result = ffi.StringResult(ffi.lib.pactffi_generate_datetime_string(b"yyyy"))
+    assert result.is_ok
+    assert not result.is_failed
+    assert re.match(r"^\d{4}$", result.text)
+    assert str(result) == result.text
+    assert repr(result) == f"<StringResult: OK, {result.text!r}>"
+    result.raise_exception()
+
+
+def test_string_result_failed() -> None:
+    result = ffi.StringResult(ffi.lib.pactffi_generate_datetime_string(b"t"))
+    assert not result.is_ok
+    assert result.is_failed
+    assert result.text.startswith("Error parsing")
+    with pytest.raises(RuntimeError):
+        result.raise_exception()
+
+
+def test_datetime_valid() -> None:
+    ffi.validate_datetime("2023-01-01", "yyyy-MM-dd")
+
+
+def test_datetime_invalid() -> None:
+    with pytest.raises(ValueError, match=r"Invalid datetime value.*"):
+        ffi.validate_datetime("01/01/2023", "yyyy-MM-dd")
+
+
+def test_get_error_message() -> None:
+    # The first bit makes sure that an error is generated.
+    invalid_utf8 = b"\xc3\x28"
+    ret: int = ffi.lib.pactffi_validate_datetime(invalid_utf8, invalid_utf8)
+    assert ret == 2
+    assert ffi.get_error_message() == "error parsing value as UTF-8"
diff --git a/tests/v3/test_http_interaction.py b/tests/v3/test_http_interaction.py
new file mode 100644
index 0000000..2e9ed65
--- /dev/null
+++ b/tests/v3/test_http_interaction.py
@@ -0,0 +1,547 @@
+"""
+Pact Http Interaction unit tests.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from typing import TYPE_CHECKING
+
+import aiohttp
+import pytest
+from pact.v3 import Pact
+
+if TYPE_CHECKING:
+    from pathlib import Path
+
+ALL_HTTP_METHODS = [
+    "GET",
+    "POST",
+    "PUT",
+    "PATCH",
+    "DELETE",
+    "HEAD",
+    "OPTIONS",
+    "TRACE",
+    "CONNECT",
+]
+
+
+@pytest.fixture()
+def pact() -> Pact:
+    """
+    Fixture for a Pact instance.
+    """
+    return Pact("consumer", "provider")
+
+
+def test_str(pact: Pact) -> None:
+    interaction = pact.upon_receiving("a basic request")
+    assert str(interaction) == "HttpInteraction(a basic request)"
+
+
+def test_repr(pact: Pact) -> None:
+    interaction = pact.upon_receiving("a basic request")
+    assert (
+        re.match(r"^HttpInteraction\(InteractionHandle\(\d+\)\)$", repr(interaction))
+        is not None
+    )
+
+
+@pytest.mark.parametrize(
+    "method",
+    ALL_HTTP_METHODS,
+)
+@pytest.mark.asyncio()
+async def test_basic_request_method(pact: Pact, method: str) -> None:
+    (
+        pact.upon_receiving(f"a basic {method} request")
+        .with_request(method, "/")
+        .will_respond_with(200)
+    )
+    with pact.serve() as srv:
+        async with aiohttp.ClientSession(srv.url) as session:
+            for m in ALL_HTTP_METHODS:
+                async with session.request(m, "/") as resp:
+                    assert resp.status == (200 if m == method else 500)
+
+
+@pytest.mark.parametrize(
+    "status",
+    list(range(200, 600, 13)),
+)
+@pytest.mark.asyncio()
+async def test_basic_response_status(pact: Pact, status: int) -> None:
+    (
+        pact.upon_receiving(f"a basic request producing status {status}")
+        .with_request("GET", "/")
+        .will_respond_with(status)
+    )
+    with pact.serve() as srv:
+        async with aiohttp.ClientSession(srv.url) as session:
+            async with session.request("GET", "/") as resp:
+                assert resp.status == status
+
+
+@pytest.mark.parametrize(
+    "headers",
+    [
+        [("X-Test", "true")],
+        [("X-Foo", "true"), ("X-Bar", "true")],
+        [("X-Test", "1"), ("X-Test", "2")],
+    ],
+)
+@pytest.mark.asyncio()
+async def test_with_header_request(
+    pact: Pact,
+    headers: list[tuple[str, str]],
+) -> None:
+    (
+        pact.upon_receiving("a basic request with a header")
+        .with_request("GET", "/")
+        .with_headers(headers)
+        .will_respond_with(200)
+    )
+    with pact.serve() as srv:
+        async with aiohttp.ClientSession(srv.url) as session:
+            async with session.request(
+                "GET",
+                "/",
+                headers=headers,
+            ) as resp:
+                assert resp.status == 200
+
+
+@pytest.mark.parametrize(
+    "headers",
+    [
+        [("X-Test", "true")],
+        [("X-Foo", "true"), ("X-Bar", "true")],
+        [("X-Test", "1"), ("X-Test", "2")],
+    ],
+)
+@pytest.mark.asyncio()
+async def test_with_header_response(
+    pact: Pact,
+    headers: list[tuple[str, str]],
+) -> None:
+    (
+        pact.upon_receiving("a basic request with a header")
+        .with_request("GET", "/")
+        .will_respond_with(200)
+        .with_headers(headers)
+    )
+    with pact.serve() as srv:
+        async with aiohttp.ClientSession(srv.url) as session:
+            async with session.request(
+                "GET",
+                "/",
+            ) as resp:
+                assert resp.status == 200
+                response_headers = [(h.lower(), v) for h, v in resp.headers.items()]
+                for header, value in headers:
+                    assert (header.lower(), value) in response_headers
+
+
+@pytest.mark.asyncio()
+async def test_with_header_dict(pact: Pact) -> None:
+    (
+        pact.upon_receiving("a basic request with a headers from a dict")
+        .with_request("GET", "/")
+        .with_headers({"X-Test": "true", "X-Foo": "bar"})
+        .will_respond_with(200)
+    )
+    with pact.serve() as srv:
+        async with aiohttp.ClientSession(srv.url) as session:
+            async with session.request(
+                "GET",
+                "/",
+                headers={"X-Test": "true", "X-Foo": "bar"},
+            ) as resp:
+                assert resp.status == 200
+
+
+@pytest.mark.parametrize(
+    "headers",
+    [
+        [("X-Test", "true")],
+        [("X-Foo", "true"), ("X-Bar", "true")],
+    ],
+)
+@pytest.mark.asyncio()
+async def test_set_header_request(
+    pact: Pact,
+    headers: list[tuple[str, str]],
+) -> None:
+    (
+        pact.upon_receiving("a basic request with a header")
+        .with_request("GET", "/")
+        .set_headers(headers)
+        .will_respond_with(200)
+    )
+    with pact.serve() as srv:
+        async with aiohttp.ClientSession(srv.url) as session:
+            async with session.request(
+                "GET",
+                "/",
+                headers=headers,
+            ) as resp:
+                assert resp.status == 200
+
+
+@pytest.mark.asyncio()
+async def test_set_header_request_repeat(
+    pact: Pact,
+) -> None:
+    headers = [("X-Test", "1"), ("X-Test", "2")]
+    (
+        pact.upon_receiving("a basic request with a header")
+        .with_request("GET", "/")
+        # As set_headers makes no additional processing, the last header will be
+        # the one that is used.
+        .set_headers(headers)
+        .will_respond_with(200)
+    )
+    with pact.serve() as srv:
+        async with aiohttp.ClientSession(srv.url) as session:
+            async with session.request(
+                "GET",
+                "/",
+                headers=headers,
+            ) as resp:
+                assert resp.status == 500
+
+
+@pytest.mark.parametrize(
+    "headers",
+    [
+        [("X-Test", "true")],
+        [("X-Foo", "true"), ("X-Bar", "true")],
+    ],
+)
+@pytest.mark.asyncio()
+async def test_set_header_response(
+    pact: Pact,
+    headers: list[tuple[str, str]],
+) -> None:
+    (
+        pact.upon_receiving("a basic request with a header")
+        .with_request("GET", "/")
+        .will_respond_with(200)
+        .set_headers(headers)
+    )
+    with pact.serve() as srv:
+        async with aiohttp.ClientSession(srv.url) as session:
+            async with session.request(
+                "GET",
+                "/",
+            ) as resp:
+                assert resp.status == 200
+                response_headers = [(h.lower(), v) for h, v in resp.headers.items()]
+                for header, value in headers:
+                    assert (header.lower(), value) in response_headers
+
+
+@pytest.mark.asyncio()
+async def test_set_header_response_repeat(
+    pact: Pact,
+) -> None:
+    headers = [("X-Test", "1"), ("X-Test", "2")]
+    (
+        pact.upon_receiving("a basic request with a header")
+        .with_request("GET", "/")
+        .will_respond_with(200)
+        # As set_headers makes no additional processing, the last header will be
+        # the one that is used.
+        .set_headers(headers)
+    )
+    with pact.serve() as srv:
+        async with aiohttp.ClientSession(srv.url) as session:
+            async with session.request(
+                "GET",
+                "/",
+                headers=headers,
+            ) as resp:
+                assert resp.status == 200
+                response_headers = [(h.lower(), v) for h, v in resp.headers.items()]
+                assert ("x-test", "2") in response_headers
+                assert ("x-test", "1") not in response_headers
+
+
+@pytest.mark.asyncio()
+async def test_set_header_dict(pact: Pact) -> None:
+    (
+        pact.upon_receiving("a basic request with a headers from a dict")
+        .with_request("GET", "/")
+        .set_headers({"X-Test": "true", "X-Foo": "bar"})
+        .will_respond_with(200)
+    )
+    with pact.serve() as srv:
+        async with aiohttp.ClientSession(srv.url) as session:
+            async with session.request(
+                "GET",
+                "/",
+                headers={"X-Test": "true", "X-Foo": "bar"},
+            ) as resp:
+                assert resp.status == 200
+
+
+@pytest.mark.parametrize(
+    "query",
+    [
+        [("test", "true")],
+        [("foo", "true"), ("bar", "true")],
+        [("test", "1"), ("test", "2")],
+    ],
+)
+@pytest.mark.asyncio()
+async def test_with_query_parameter_request(
+    pact: Pact,
+    query: list[tuple[str, str]],
+) -> None:
+    (
+        pact.upon_receiving("a basic request with a query parameter")
+        .with_request("GET", "/")
+        .with_query_parameters(query)
+        .will_respond_with(200)
+    )
+    with pact.serve() as srv:
+        async with aiohttp.ClientSession(srv.url) as session:
+            url = srv.url.with_query(query)
+            async with session.request(
+                "GET",
+                url.path_qs,
+            ) as resp:
+                assert resp.status == 200
+
+
+@pytest.mark.asyncio()
+async def test_with_query_parameter_dict(pact: Pact) -> None:
+    (
+        pact.upon_receiving("a basic request with a query parameter from a dict")
+        .with_request("GET", "/")
+        .with_query_parameters({"test": "true", "foo": "bar"})
+        .will_respond_with(200)
+    )
+    with pact.serve() as srv:
+        async with aiohttp.ClientSession(srv.url) as session:
+            url = srv.url.with_query({"test": "true", "foo": "bar"})
+            async with session.request(
+                "GET",
+                url.path_qs,
+            ) as resp:
+                assert resp.status == 200
+
+
+@pytest.mark.parametrize(
+    "method",
+    ["GET", "POST", "PUT"],
+)
+@pytest.mark.asyncio()
+async def test_with_body_request(pact: Pact, method: str) -> None:
+    (
+        pact.upon_receiving(f"a basic {method} request with a body")
+        .with_request(method, "/")
+        .with_body(json.dumps({"test": True}), "application/json")
+        .will_respond_with(200)
+    )
+    with pact.serve() as srv:
+        async with aiohttp.ClientSession(srv.url) as session:
+            async with session.request(
+                method,
+                "/",
+                json={"test": True},
+            ) as resp:
+                assert resp.status == 200
+
+
+@pytest.mark.parametrize(
+    "method",
+    ["GET", "POST", "PUT"],
+)
+@pytest.mark.asyncio()
+async def test_with_body_response(pact: Pact, method: str) -> None:
+    (
+        pact.upon_receiving(
+            f"a basic {method} request expecting a response with a body",
+        )
+        .with_request(method, "/")
+        .will_respond_with(200)
+        .with_body(json.dumps({"test": True}), "application/json")
+    )
+    with pact.serve() as srv:
+        async with aiohttp.ClientSession(srv.url) as session:
+            async with session.request(
+                method,
+                "/",
+                json={"test": True},
+            ) as resp:
+                assert resp.status == 200
+                assert await resp.json() == {"test": True}
+
+
+@pytest.mark.asyncio()
+async def test_with_body_explicit(pact: Pact) -> None:
+    (
+        pact.upon_receiving("")
+        .with_request("GET", "/")
+        .will_respond_with(200)
+        .with_body(json.dumps({"request": True}), "application/json", "Request")
+        .with_body(json.dumps({"response": True}), "application/json", "Response")
+    )
+    with pact.serve() as srv:
+        async with aiohttp.ClientSession(srv.url) as session:
+            async with session.request(
+                "GET",
+                "/",
+                json={"request": True},
+            ) as resp:
+                assert resp.status == 200
+                assert await resp.json() == {"response": True}
+
+
+def test_with_body_invalid(pact: Pact) -> None:
+    with pytest.raises(ValueError, match="Invalid part: Invalid"):
+        (
+            pact.upon_receiving("")
+            .with_request("GET", "/")
+            .will_respond_with(200)
+            .with_body(json.dumps({"request": True}), "application/json", "Invalid")
+        )
+
+
+@pytest.mark.asyncio()
+async def test_given(pact: Pact) -> None:
+    (
+        pact.upon_receiving("a basic request given state 1")
+        .given("state 1")
+        .with_request("GET", "/state")
+        .will_respond_with(200)
+    )
+    (
+        pact.upon_receiving("a basic request given a user exists (1)")
+        .given("a user exists", name="id", value="123")
+        .given("a user exists", name="name", value="John")
+        .with_request("GET", "/user1")
+        .will_respond_with(201)
+    )
+    (
+        pact.upon_receiving("a basic request given a user exists (2)")
+        .given("a user exists", parameters={"id": "123", "name": "John"})
+        .with_request("GET", "/user2")
+        .will_respond_with(202)
+    )
+
+    with pact.serve() as srv:
+        async with aiohttp.ClientSession(srv.url) as session:
+            async with session.request("GET", "/state") as resp:
+                assert resp.status == 200
+            async with session.request("GET", "/user1") as resp:
+                assert resp.status == 201
+            async with session.request("GET", "/user2") as resp:
+                assert resp.status == 202
+
+
+@pytest.mark.asyncio()
+async def test_binary_file_request(pact: Pact) -> None:
+    payload = bytes(range(8))
+    (
+        pact.upon_receiving("a basic request with a binary file")
+        .with_request("POST", "/")
+        .with_binary_file(payload, "application/octet-stream")
+        .will_respond_with(200)
+    )
+    with pact.serve() as srv:
+        async with aiohttp.ClientSession(srv.url) as session:
+            async with session.post("/", data=payload) as resp:
+                assert resp.status == 200
+            async with session.post("/", data=payload[:2]) as resp:
+                # The match _only_ checks the content type, not the content
+                # itself. See
+                # https://pact-foundation.slack.com/archives/C02BXLDJ7JR/p1697032990681329
+                assert resp.status == 200
+
+
+@pytest.mark.asyncio()
+async def test_binary_file_response(pact: Pact) -> None:
+    payload = bytes(range(5))
+    (
+        pact.upon_receiving("a basic request with a binary file response")
+        .with_request("GET", "/")
+        .will_respond_with(200)
+        .with_binary_file(payload, "application/bytes")
+    )
+    with pact.serve() as srv:
+        async with aiohttp.ClientSession(srv.url) as session:
+            async with session.get("/") as resp:
+                assert resp.status == 200
+                assert await resp.read() == payload
+                assert payload == bytes(range(5))  # to make sure it's not mutated
+                assert resp.headers["Content-Type"] == "application/bytes"
+
+
+@pytest.mark.skip(reason="Not working yet")
+@pytest.mark.asyncio()
+async def test_multipart_file_request(pact: Pact, temp_dir: Path) -> None:
+    fpy = temp_dir / "test.py"
+    fpng = temp_dir / "test.png"
+    (
+        pact.upon_receiving("a basic request with a multipart file")
+        .with_request("POST", "/")
+        .with_multipart_file(
+            fpy.name,
+            fpy,
+            "text/x-python",
+        )
+        .with_multipart_file(
+            fpng.name,
+            fpng,
+            "image/png",
+        )
+        .will_respond_with(200)
+    )
+    with pact.serve() as srv, aiohttp.MultipartWriter() as mpwriter:
+        mpwriter.append(
+            fpy.open("rb"),
+            {"Content-Type": "text/x-python"},
+        )
+        mpwriter.append(
+            fpng.open("rb"),
+            {"Content-Type": "image/png"},
+        )
+
+        async with aiohttp.ClientSession(srv.url) as session, session.post(
+            "/",
+            data=mpwriter,
+        ) as resp:
+            assert resp.status == 200
+            assert await resp.read() == b""
+
+
+@pytest.mark.asyncio()
+async def test_name(pact: Pact) -> None:
+    (
+        pact.upon_receiving("a basic request with a test name")
+        .test_name("a test name")
+        .will_respond_with(200)
+    )
+    with pact.serve() as srv:
+        async with aiohttp.ClientSession(srv.url) as session:
+            async with session.get("/") as resp:
+                assert resp.status == 200
+                assert await resp.read() == b""
+
+
+@pytest.mark.asyncio()
+async def test_with_plugin(pact: Pact) -> None:
+    (
+        pact.upon_receiving("a basic request with a plugin")
+        .with_plugin_contents("{}")
+        .will_respond_with(200)
+    )
+    with pact.serve() as srv:
+        async with aiohttp.ClientSession(srv.url) as session:
+            async with session.get("/") as resp:
+                assert resp.status == 200
+                assert await resp.read() == b""
diff --git a/tests/v3/test_pact.py b/tests/v3/test_pact.py
index 92049b6..6f24c04 100644
--- a/tests/v3/test_pact.py
+++ b/tests/v3/test_pact.py
@@ -4,9 +4,6 @@ Pact unit tests.
 
 from __future__ import annotations
 
-import json
-
-import aiohttp
 import pytest
 from pact.v3 import Pact
 
@@ -44,388 +41,3 @@ def test_serve(pact: Pact) -> None:
         assert srv.url.path == "/"
         assert srv / "foo" == srv.url / "foo"
         assert str(srv / "foo") == f"http://localhost:{srv.port}/foo"
-
-
-@pytest.mark.parametrize(
-    "method",
-    [
-        "GET",
-        "POST",
-        "PUT",
-        "PATCH",
-        "DELETE",
-        "HEAD",
-        "OPTIONS",
-        "TRACE",
-        "CONNECT",
-    ],
-)
-@pytest.mark.asyncio()
-async def test_basic_request_method(pact: Pact, method: str) -> None:
-    (
-        pact.upon_receiving(f"a basic {method} request")
-        .with_request(method, "/")
-        .will_respond_with(200)
-    )
-    with pact.serve() as srv:
-        async with aiohttp.ClientSession(srv.url) as session:
-            async with session.request(method, "/") as resp:
-                assert resp.status == 200
-
-
-@pytest.mark.parametrize(
-    "status",
-    list(range(200, 600, 13)),
-)
-@pytest.mark.asyncio()
-async def test_basic_response_status(pact: Pact, status: int) -> None:
-    (
-        pact.upon_receiving(f"a basic request producing status {status}")
-        .with_request("GET", "/")
-        .will_respond_with(status)
-    )
-    with pact.serve() as srv:
-        async with aiohttp.ClientSession(srv.url) as session:
-            async with session.request("GET", "/") as resp:
-                assert resp.status == status
-
-
-@pytest.mark.parametrize(
-    "headers",
-    [
-        [("X-Test", "true")],
-        [("X-Foo", "true"), ("X-Bar", "true")],
-        [("X-Test", "1"), ("X-Test", "2")],
-    ],
-)
-@pytest.mark.asyncio()
-async def test_with_header_request(
-    pact: Pact,
-    headers: list[tuple[str, str]],
-) -> None:
-    (
-        pact.upon_receiving("a basic request with a header")
-        .with_request("GET", "/")
-        .with_headers(headers)
-        .will_respond_with(200)
-    )
-    with pact.serve() as srv:
-        async with aiohttp.ClientSession(srv.url) as session:
-            async with session.request(
-                "GET",
-                "/",
-                headers=headers,
-            ) as resp:
-                assert resp.status == 200
-
-
-@pytest.mark.parametrize(
-    "headers",
-    [
-        [("X-Test", "true")],
-        [("X-Foo", "true"), ("X-Bar", "true")],
-        [("X-Test", "1"), ("X-Test", "2")],
-    ],
-)
-@pytest.mark.asyncio()
-async def test_with_header_response(
-    pact: Pact,
-    headers: list[tuple[str, str]],
-) -> None:
-    (
-        pact.upon_receiving("a basic request with a header")
-        .with_request("GET", "/")
-        .will_respond_with(200)
-        .with_headers(headers)
-    )
-    with pact.serve() as srv:
-        async with aiohttp.ClientSession(srv.url) as session:
-            async with session.request(
-                "GET",
-                "/",
-            ) as resp:
-                assert resp.status == 200
-                response_headers = [(h.lower(), v) for h, v in resp.headers.items()]
-                for header, value in headers:
-                    assert (header.lower(), value) in response_headers
-
-
-@pytest.mark.asyncio()
-async def test_with_header_dict(pact: Pact) -> None:
-    (
-        pact.upon_receiving("a basic request with a headers from a dict")
-        .with_request("GET", "/")
-        .with_headers({"X-Test": "true", "X-Foo": "bar"})
-        .will_respond_with(200)
-    )
-    with pact.serve() as srv:
-        async with aiohttp.ClientSession(srv.url) as session:
-            async with session.request(
-                "GET",
-                "/",
-                headers={"X-Test": "true", "X-Foo": "bar"},
-            ) as resp:
-                assert resp.status == 200
-
-
-@pytest.mark.parametrize(
-    "headers",
-    [
-        [("X-Test", "true")],
-        [("X-Foo", "true"), ("X-Bar", "true")],
-    ],
-)
-@pytest.mark.asyncio()
-async def test_set_header_request(
-    pact: Pact,
-    headers: list[tuple[str, str]],
-) -> None:
-    (
-        pact.upon_receiving("a basic request with a header")
-        .with_request("GET", "/")
-        .set_headers(headers)
-        .will_respond_with(200)
-    )
-    with pact.serve() as srv:
-        async with aiohttp.ClientSession(srv.url) as session:
-            async with session.request(
-                "GET",
-                "/",
-                headers=headers,
-            ) as resp:
-                assert resp.status == 200
-
-
-@pytest.mark.asyncio()
-async def test_set_header_request_repeat(
-    pact: Pact,
-) -> None:
-    headers = [("X-Test", "1"), ("X-Test", "2")]
-    (
-        pact.upon_receiving("a basic request with a header")
-        .with_request("GET", "/")
-        # As set_headers makes no additional processing, the last header will be
-        # the one that is used.
-        .set_headers(headers)
-        .will_respond_with(200)
-    )
-    with pact.serve() as srv:
-        async with aiohttp.ClientSession(srv.url) as session:
-            async with session.request(
-                "GET",
-                "/",
-                headers=headers,
-            ) as resp:
-                assert resp.status == 500
-
-
-@pytest.mark.parametrize(
-    "headers",
-    [
-        [("X-Test", "true")],
-        [("X-Foo", "true"), ("X-Bar", "true")],
-    ],
-)
-@pytest.mark.asyncio()
-async def test_set_header_response(
-    pact: Pact,
-    headers: list[tuple[str, str]],
-) -> None:
-    (
-        pact.upon_receiving("a basic request with a header")
-        .with_request("GET", "/")
-        .will_respond_with(200)
-        .set_headers(headers)
-    )
-    with pact.serve() as srv:
-        async with aiohttp.ClientSession(srv.url) as session:
-            async with session.request(
-                "GET",
-                "/",
-            ) as resp:
-                assert resp.status == 200
-                response_headers = [(h.lower(), v) for h, v in resp.headers.items()]
-                for header, value in headers:
-                    assert (header.lower(), value) in response_headers
-
-
-@pytest.mark.asyncio()
-async def test_set_header_response_repeat(
-    pact: Pact,
-) -> None:
-    headers = [("X-Test", "1"), ("X-Test", "2")]
-    (
-        pact.upon_receiving("a basic request with a header")
-        .with_request("GET", "/")
-        .will_respond_with(200)
-        # As set_headers makes no additional processing, the last header will be
-        # the one that is used.
-        .set_headers(headers)
-    )
-    with pact.serve() as srv:
-        async with aiohttp.ClientSession(srv.url) as session:
-            async with session.request(
-                "GET",
-                "/",
-                headers=headers,
-            ) as resp:
-                assert resp.status == 200
-                response_headers = [(h.lower(), v) for h, v in resp.headers.items()]
-                assert ("x-test", "2") in response_headers
-                assert ("x-test", "1") not in response_headers
-
-
-@pytest.mark.asyncio()
-async def test_set_header_dict(pact: Pact) -> None:
-    (
-        pact.upon_receiving("a basic request with a headers from a dict")
-        .with_request("GET", "/")
-        .set_headers({"X-Test": "true", "X-Foo": "bar"})
-        .will_respond_with(200)
-    )
-    with pact.serve() as srv:
-        async with aiohttp.ClientSession(srv.url) as session:
-            async with session.request(
-                "GET",
-                "/",
-                headers={"X-Test": "true", "X-Foo": "bar"},
-            ) as resp:
-                assert resp.status == 200
-
-
-@pytest.mark.parametrize(
-    "query",
-    [
-        [("test", "true")],
-        [("foo", "true"), ("bar", "true")],
-        [("test", "1"), ("test", "2")],
-    ],
-)
-@pytest.mark.asyncio()
-async def test_with_query_parameter_request(
-    pact: Pact,
-    query: list[tuple[str, str]],
-) -> None:
-    (
-        pact.upon_receiving("a basic request with a query parameter")
-        .with_request("GET", "/")
-        .with_query_parameters(query)
-        .will_respond_with(200)
-    )
-    with pact.serve() as srv:
-        async with aiohttp.ClientSession(srv.url) as session:
-            url = srv.url.with_query(query)
-            async with session.request(
-                "GET",
-                url.path_qs,
-            ) as resp:
-                assert resp.status == 200
-
-
-@pytest.mark.asyncio()
-async def test_with_query_parameter_dict(pact: Pact) -> None:
-    (
-        pact.upon_receiving("a basic request with a query parameter from a dict")
-        .with_request("GET", "/")
-        .with_query_parameters({"test": "true", "foo": "bar"})
-        .will_respond_with(200)
-    )
-    with pact.serve() as srv:
-        async with aiohttp.ClientSession(srv.url) as session:
-            url = srv.url.with_query({"test": "true", "foo": "bar"})
-            async with session.request(
-                "GET",
-                url.path_qs,
-            ) as resp:
-                assert resp.status == 200
-
-
-@pytest.mark.parametrize(
-    "method",
-    ["GET", "POST", "PUT"],
-)
-@pytest.mark.asyncio()
-async def test_with_body_request(pact: Pact, method: str) -> None:
-    (
-        pact.upon_receiving(f"a basic {method} request with a body")
-        .with_request(method, "/")
-        .with_body(json.dumps({"test": True}), "application/json")
-        .will_respond_with(200)
-    )
-    with pact.serve() as srv:
-        async with aiohttp.ClientSession(srv.url) as session:
-            async with session.request(
-                method,
-                "/",
-                json={"test": True},
-            ) as resp:
-                assert resp.status == 200
-
-
-@pytest.mark.parametrize(
-    "method",
-    ["GET", "POST", "PUT"],
-)
-@pytest.mark.asyncio()
-async def test_with_body_response(pact: Pact, method: str) -> None:
-    (
-        pact.upon_receiving(
-            f"a basic {method} request expecting a response with a body",
-        )
-        .with_request(method, "/")
-        .will_respond_with(200)
-        .with_body(json.dumps({"test": True}), "application/json")
-    )
-    with pact.serve() as srv:
-        async with aiohttp.ClientSession(srv.url) as session:
-            async with session.request(
-                method,
-                "/",
-                json={"test": True},
-            ) as resp:
-                assert resp.status == 200
-                assert await resp.json() == {"test": True}
-
-
-@pytest.mark.asyncio()
-async def test_with_body_explicit(pact: Pact) -> None:
-    (
-        pact.upon_receiving("")
-        .with_request("GET", "/")
-        .will_respond_with(200)
-        .with_body(json.dumps({"request": True}), "application/json", "Request")
-        .with_body(json.dumps({"response": True}), "application/json", "Response")
-    )
-    with pact.serve() as srv:
-        async with aiohttp.ClientSession(srv.url) as session:
-            async with session.request(
-                "GET",
-                "/",
-                json={"request": True},
-            ) as resp:
-                assert resp.status == 200
-                assert await resp.json() == {"response": True}
-
-
-def test_with_body_invalid(pact: Pact) -> None:
-    with pytest.raises(ValueError, match="Invalid part: Invalid"):
-        (
-            pact.upon_receiving("")
-            .with_request("GET", "/")
-            .will_respond_with(200)
-            .with_body(json.dumps({"request": True}), "application/json", "Invalid")
-        )
-
-
-@pytest.mark.asyncio()
-async def test_given(pact: Pact) -> None:
-    (
-        pact.upon_receiving("a basic request given state 1")
-        .given("state 1")
-        .with_request("GET", "/")
-        .will_respond_with(200)
-    )
-    with pact.serve() as srv:
-        async with aiohttp.ClientSession(srv.url) as session:
-            async with session.request("GET", "/") as resp:
-                assert resp.status == 200
diff --git a/tests/v3/test_sync_interaction.py b/tests/v3/test_sync_interaction.py
new file mode 100644
index 0000000..64e2154
--- /dev/null
+++ b/tests/v3/test_sync_interaction.py
@@ -0,0 +1,34 @@
+"""
+Pact Async Message Interaction unit tests.
+"""
+
+from __future__ import annotations
+
+import re
+
+import pytest
+from pact.v3 import Pact
+
+
+@pytest.fixture()
+def pact() -> Pact:
+    """
+    Fixture for a Pact instance.
+    """
+    return Pact("consumer", "provider")
+
+
+def test_str(pact: Pact) -> None:
+    interaction = pact.upon_receiving("a basic request", "Async")
+    assert str(interaction) == "AsyncMessageInteraction(a basic request)"
+
+
+def test_repr(pact: Pact) -> None:
+    interaction = pact.upon_receiving("a basic request", "Async")
+    assert (
+        re.match(
+            r"^AsyncMessageInteraction\(InteractionHandle\(\d+\)\)$",
+            repr(interaction),
+        )
+        is not None
+    )