Repository: taverntesting/tavern

Issue:
YAML syntax prevents uploading multiple files with the same "name"
It looks like Tavern's current YAML syntax prevents using Tavern for certain standard kinds of multi-file uploads.

If you have an HTML form input like this:

```html
<input type="file" name="input_files" multiple="true" required="true"/>
```

Then the `POST` request should look like this:

```
POST /foo HTTP/1.1
User-Agent: PostmanRuntime/7.30.0
Accept: */*
Host: localhost:31950
Connection: keep-alive
Content-Type: multipart/form-data; boundary=--------------------------432665675269641474044671
Content-Length: 12345

----------------------------432665675269641474044671
Content-Disposition: form-data; name="input_files"; filename="file_1.txt"
Content-Type: application/octet-stream

first file conents
blah blah blah

----------------------------432665675269641474044671
Content-Disposition: form-data; name="input_files"; filename="file_2.txt"
Content-Type: application/octet-stream

second file contents
blah blah blah

----------------------------432665675269641474044671--
```

Notice how both files have `name="input_files"`, even though they have different `filename`s and different contents.

Quoting from [RFC 7578 section 4.3](https://datatracker.ietf.org/doc/html/rfc7578#section-4.3):

> The form data for a form field might include multiple files.
> ...
> To match widely deployed implementations, multiple files MUST be sent by supplying each file in a separate part but all with the same "name" parameter.

In `requests`, you can do this by [providing a list as the `files` argument](https://docs.python-requests.org/en/latest/user/advanced/#post-multiple-multipart-encoded-files):

```python
multiple_files = [
    ('input_files', ('file_1.txt', file_1, 'text/plain')),
    ('input_files', ('file_2.txt', file_1), 'text/plain'))]
r = requests.post(url, files=multiple_files)
```

But in Tavern's YAML syntax, this doesn't seem possible.

```yaml
request:
  url: '{url}'
  method: POST
  files:
    input_files: 'file_1.txt'
    input_files: 'file_2.txt' # Invalid YAML because this is a duplicate key. :(
```


Discussion:
SyntaxColoring: Tested and confirmed that this works for my simple use case, which was like this:

```yaml
test_name: Upload multiple files

stages:
  - name: Upload multiple files
    request:
      url: 'http://localhost/foo'
      method: POST
      files:
        - form_field_name: files
          file_path: '/path/to/file/1'
        - form_field_name: files
          file_path: '/path/to/file/2'
    response:
      status_code: 201
```

Thank you!

Patch:
diff --git a/constraints.txt b/constraints.txt
index 29af6d2..7d1fd46 100644
--- a/constraints.txt
+++ b/constraints.txt
@@ -269,6 +269,12 @@ twine==4.0.2
     # via tavern (pyproject.toml)
 types-pyyaml==6.0.12.2
     # via tavern (pyproject.toml)
+types-requests==2.31.0.0
+    # via tavern (pyproject.toml)
+types-setuptools==67.8.0.0
+    # via tavern (pyproject.toml)
+types-urllib3==1.26.25.13
+    # via types-requests
 typing-extensions==4.4.0
     # via mypy
 urllib3==1.26.13
diff --git a/pyproject.toml b/pyproject.toml
index aa0ba20..edd3a31 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -80,6 +80,8 @@ dev = [
     "tox-travis",
     "twine",
     "wheel",
+    "types-setuptools",
+    "types-requests",
     # This has to be installed separately, otherwise you can't upload to pypi
     # "tbump@https://github.com/michaelboulton/tbump/archive/714ba8957a3c84b625608ceca39811ebe56229dc.zip",
 ]
@@ -132,6 +134,7 @@ addopts = [
     "--strict-markers",
     "-p", "no:logging",
     "--tb=short",
+    "--color=yes"
 ]
 norecursedirs = [
     ".git",
diff --git a/requirements.txt b/requirements.txt
index eb58c87..54015fc 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -856,6 +856,18 @@ types-pyyaml==6.0.12.2 \
     --hash=sha256:1e94e80aafee07a7e798addb2a320e32956a373f376655128ae20637adb2655b \
     --hash=sha256:6840819871c92deebe6a2067fb800c11b8a063632eb4e3e755914e7ab3604e83
     # via tavern (pyproject.toml)
+types-requests==2.31.0.0 \
+    --hash=sha256:7c5cea7940f8e92ec560bbc468f65bf684aa3dcf0554a6f8c4710f5f708dc598 \
+    --hash=sha256:c1c29d20ab8d84dff468d7febfe8e0cb0b4664543221b386605e14672b44ea25
+    # via tavern (pyproject.toml)
+types-setuptools==67.8.0.0 \
+    --hash=sha256:6df73340d96b238a4188b7b7668814b37e8018168aef1eef94a3b1872e3f60ff \
+    --hash=sha256:95c9ed61871d6c0e258433373a4e1753c0a7c3627a46f4d4058c7b5a08ab844f
+    # via tavern (pyproject.toml)
+types-urllib3==1.26.25.13 \
+    --hash=sha256:3300538c9dc11dad32eae4827ac313f5d986b8b21494801f1bf97a1ac6c03ae5 \
+    --hash=sha256:5dbd1d2bef14efee43f5318b5d36d805a489f6600252bb53626d4bfafd95e27c
+    # via types-requests
 typing-extensions==4.4.0 \
     --hash=sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa \
     --hash=sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e
diff --git a/scripts/smoke.bash b/scripts/smoke.bash
index dceb58d..49f7d8a 100755
--- a/scripts/smoke.bash
+++ b/scripts/smoke.bash
@@ -2,7 +2,8 @@
 
 set -ex
 
-ruff tavern
+ruff tavern tests --fix
+black tavern tests
 
 # Separate as isort can interfere with other testenvs
 tox --parallel -c tox.ini        \
diff --git a/tavern/_core/schema/extensions.py b/tavern/_core/schema/extensions.py
index 5e6c9eb..ca352be 100644
--- a/tavern/_core/schema/extensions.py
+++ b/tavern/_core/schema/extensions.py
@@ -368,6 +368,8 @@ def validate_cert_tuple_or_str(value, rule_obj, path) -> bool:
 def validate_file_spec(value, rule_obj, path) -> bool:
     """Validate file upload arguments"""
 
+    logger = get_pykwalify_logger("tavern.schema.extensions")
+
     if not isinstance(value, dict):
         raise BadSchemaError(
             "File specification must be a mapping of file names to file specs, got {}".format(
@@ -375,11 +377,16 @@ def validate_file_spec(value, rule_obj, path) -> bool:
             )
         )
 
+    if value.get("file_path"):
+        # If the file spec was a list, this function will be called for each item. Just call this
+        # function recursively to check each item.
+        return validate_file_spec({"file": value}, rule_obj, path)
+
     for _, filespec in value.items():
         if isinstance(filespec, str):
             file_path = filespec
         elif isinstance(filespec, dict):
-            valid = {"file_path", "content_type", "content_encoding"}
+            valid = {"file_path", "content_type", "content_encoding", "form_field_name"}
             extra = set(filespec.keys()) - valid
             if extra:
                 raise BadSchemaError(
@@ -399,7 +406,7 @@ def validate_file_spec(value, rule_obj, path) -> bool:
 
         if not os.path.exists(file_path):
             if re.search(".*{.+}.*", file_path):
-                get_pykwalify_logger("tavern.schemas.extensions").debug(
+                logger.debug(
                     "Could not find file path, but it might be a format variable, so continuing"
                 )
             else:
diff --git a/tavern/_plugins/rest/files.py b/tavern/_plugins/rest/files.py
new file mode 100644
index 0000000..3f72db8
--- /dev/null
+++ b/tavern/_plugins/rest/files.py
@@ -0,0 +1,189 @@
+import dataclasses
+import logging
+import mimetypes
+import os
+from contextlib import ExitStack
+from typing import Any, List, Optional, Tuple, Union
+
+from tavern._core import exceptions
+from tavern._core.dict_util import format_keys
+from tavern._core.pytest.config import TestConfig
+
+logger = logging.getLogger(__name__)
+
+
+@dataclasses.dataclass
+class _Filespec:
+    """A description of a file for a file upload, possibly as part of a multi part upload"""
+
+    path: str
+    content_type: Optional[str] = None
+    content_encoding: Optional[str] = None
+    form_field_name: Optional[str] = None
+
+
+def _parse_filespec(filespec: Union[str, dict]) -> _Filespec:
+    """
+    Get configuration for uploading file
+
+    Args:
+        filespec: Can either be one of
+            - A path to a file
+            - A dict containing 'long' format, possibly including content type/encoding and the
+                multipart 'name'
+
+    Returns:
+        The parsed file spec
+
+    Raises:
+        exceptions.BadSchemaError: If the file spec was invalid
+    """
+    if isinstance(filespec, str):
+        return _Filespec(filespec)
+    elif isinstance(filespec, dict):
+        try:
+            # The one required key
+            path = filespec["file_path"]
+        except KeyError as e:
+            raise exceptions.BadSchemaError(
+                "File spec dict did not contain the required 'file_path' key"
+            ) from e
+
+        return _Filespec(
+            path,
+            filespec.get("content_type"),
+            filespec.get("content_encoding"),
+            filespec.get("form_field_name"),
+        )
+    else:
+        # Could remove, also done in schema check
+        raise exceptions.BadSchemaError(
+            "File specification must be a path or a dictionary"
+        )
+
+
+def guess_filespec(
+    filespec: Union[str, dict], stack: ExitStack, test_block_config: TestConfig
+) -> Tuple[List, Optional[str]]:
+    """tries to guess the content type and encoding from a file.
+
+    Args:
+        test_block_config: config for test/stage
+        stack: exit stack to add open files context to
+        filespec: a string path to a file or a dictionary of the file path, content type, and encoding.
+
+    Returns:
+        A tuple of either length 2 (filename and file object), 3 (as before, with content type),
+            or 4 (as before, with with content encoding). If a group name for the multipart upload
+            was specified, this is also returned.
+
+    Notes:
+        If a 4-tuple is returned, the last element is a dictionary of headers to send to requests,
+            _not_ the raw encoding value.
+    """
+    if not mimetypes.inited:
+        mimetypes.init()
+
+    parsed = _parse_filespec(filespec)
+
+    filepath = format_keys(parsed.path, test_block_config.variables)
+    filename = os.path.basename(filepath)
+
+    # a 2-tuple ('filename', fileobj)
+    file_spec = [
+        filename,
+        stack.enter_context(open(filepath, "rb")),
+    ]
+
+    # Try to guess as well, but don't override what the user specified
+    guessed_content_type, guessed_encoding = mimetypes.guess_type(filepath)
+    content_type = parsed.content_type or guessed_content_type
+    encoding = parsed.content_encoding or guessed_encoding
+
+    # If it doesn't have a mimetype, or can't guess it, don't
+    # send the content type for the file
+    if content_type:
+        # a 3-tuple ('filename', fileobj, 'content_type')
+        logger.debug("content_type for '%s' = '%s'", filename, content_type)
+        file_spec.append(content_type)
+        if encoding:
+            # or a 4-tuple ('filename', fileobj, 'content_type', custom_headers)
+            logger.debug("encoding for '%s' = '%s'", filename, encoding)
+            # encoding is None for no encoding or the name of the
+            # program used to encode (e.g. compress or gzip). The
+            # encoding is suitable for use as a Content-Encoding header.
+            file_spec.append({"Content-Encoding": encoding})
+
+    return file_spec, parsed.form_field_name
+
+
+def _parse_file_mapping(file_args, stack, test_block_config) -> dict:
+    """Parses a simple mapping of uploads where each key is mapped to one form field name which has one file"""
+    files_to_send = {}
+    for key, filespec in file_args.items():
+        file_spec, form_field_name = guess_filespec(filespec, stack, test_block_config)
+
+        # If it's a dict then the key is used as the name, at least to maintain backwards compatability
+        if form_field_name:
+            logger.warning(
+                f"Specified 'form_field_name' as '{form_field_name}' in file spec, but the file name was inferred to be '{key}' from the mapping - the form_field_name will be ignored"
+            )
+
+        files_to_send[key] = tuple(file_spec)
+    return files_to_send
+
+
+def _parse_file_list(file_args, stack, test_block_config) -> List:
+    """Parses a case where there may be multiple files uploaded as part of one form field"""
+    files_to_send: List[Any] = []
+    for filespec in file_args:
+        file_spec, form_field_name = guess_filespec(filespec, stack, test_block_config)
+
+        if not form_field_name:
+            raise exceptions.BadSchemaError(
+                "If specifying a list of files to upload for a multi part upload, the 'form_field_name' key must also be specified for each file to upload"
+            )
+
+        files_to_send.append(
+            (
+                form_field_name,
+                tuple(file_spec),
+            )
+        )
+
+    return files_to_send
+
+
+def get_file_arguments(
+    request_args: dict, stack: ExitStack, test_block_config: TestConfig
+) -> dict:
+    """Get correct arguments for anything that should be passed as a file to
+    requests
+
+    Args:
+        request_args: args passed to requests
+        test_block_config: config for test
+        stack: context stack to add file objects to so they're
+            closed correctly after use
+
+    Returns:
+        mapping of 'files' block to pass directly to requests
+    """
+
+    files_to_send: Optional[Union[dict, List]] = None
+
+    file_args = request_args.get("files")
+
+    if isinstance(file_args, dict):
+        files_to_send = _parse_file_mapping(file_args, stack, test_block_config)
+    elif isinstance(file_args, list):
+        files_to_send = _parse_file_list(file_args, stack, test_block_config)
+    elif file_args is not None:
+        raise exceptions.BadSchemaError(
+            f"'files' key in a HTTP request can only be a dict or a list but was {type(file_args)}"
+        )
+
+    if files_to_send:
+        return {"files": files_to_send}
+    else:
+        return {}
diff --git a/tavern/_plugins/rest/request.py b/tavern/_plugins/rest/request.py
index 1639b99..e7ffe75 100644
--- a/tavern/_plugins/rest/request.py
+++ b/tavern/_plugins/rest/request.py
@@ -1,12 +1,10 @@
 import contextlib
 import json
 import logging
-import mimetypes
-import os
 import warnings
 from contextlib import ExitStack
 from itertools import filterfalse, tee
-from typing import Mapping, MutableMapping, Optional, Union
+from typing import Mapping, MutableMapping, Optional
 from urllib.parse import quote_plus
 
 import requests
@@ -20,6 +18,7 @@ from tavern._core.extfunctions import update_from_ext
 from tavern._core.general import valid_http_methods
 from tavern._core.pytest.config import TestConfig
 from tavern._core.report import attach_yaml
+from tavern._plugins.rest.files import get_file_arguments, guess_filespec
 from tavern.request import BaseRequest
 
 logger = logging.getLogger(__name__)
@@ -94,7 +93,14 @@ def get_request_args(rspec: MutableMapping, test_block_config: TestConfig) -> di
     filename = fspec.get("file_body")
     if filename:
         with ExitStack() as stack:
-            file_spec = guess_filespec(filename, stack, test_block_config)
+            file_spec, group_name = guess_filespec(filename, stack, test_block_config)
+
+            # Group name doesn't matter here as it's a single file
+            if group_name:
+                logger.warning(
+                    f"'group_name' for the 'file_body' key was specified as '{group_name}' but this will be ignored "
+                )
+
             fspec["file_body"] = filename
             if len(file_spec) == 2:
                 logger.debug(
@@ -326,114 +332,6 @@ def _read_expected_cookies(
     return deep_dict_merge(from_cookiejar, from_extra)
 
 
-def _read_filespec(filespec: Union[str, dict]):
-    """
-    Get configuration for uploading file
-
-    Can either be just a path to a file or a 'long' format including content type/encoding
-
-    Args:
-        filespec: Either a string with the path to a file or a dictionary with file_path and possible content_type and/or content_encoding
-
-    Returns:
-        (file path, content type, content encoding)
-    """
-    if isinstance(filespec, str):
-        return filespec, None, None
-    elif isinstance(filespec, dict):
-        return (
-            filespec.get("file_path"),
-            filespec.get("content_type"),
-            filespec.get("content_encoding"),
-        )
-    else:
-        # Could remove, also done in schema check
-        raise exceptions.BadSchemaError(
-            "File specification must be a path or a dictionary"
-        )
-
-
-def _get_file_arguments(
-    request_args: dict, stack: ExitStack, test_block_config: TestConfig
-) -> dict:
-    """Get corect arguments for anything that should be passed as a file to
-    requests
-
-    Args:
-        request_args: args passed to requests
-        test_block_config: config for test
-        stack: context stack to add file objects to so they're
-            closed correctly after use
-
-    Returns:
-        mapping of 'files' block to pass directly to requests
-    """
-
-    files_to_send = {}
-
-    for key, filespec in request_args.get("files", {}).items():
-        file_spec = guess_filespec(filespec, stack, test_block_config)
-
-        files_to_send[key] = tuple(file_spec)
-
-    if files_to_send:
-        return {"files": files_to_send}
-    else:
-        return {}
-
-
-def guess_filespec(
-    filespec: Union[str, dict], stack: ExitStack, test_block_config: TestConfig
-):
-    """tries to guess the content type and encoding from a file.
-
-    Args:
-        test_block_config: config for test/stage
-        stack: exit stack to add open files context to
-        filespec: a string path to a file or a dictionary of the file path, content type, and encoding.
-
-    Returns:
-        tuple: A tuple of either length 2 (filename and file object), 3 (as before, with ceontent type), or 4 (as before, with with content encoding)
-
-    Notes:
-        If a 4-tuple is returned, the last element is a dictionary of headers to send to requests, _not_ the raw encoding value.
-    """
-    if not mimetypes.inited:
-        mimetypes.init()
-
-    filepath, content_type, encoding = _read_filespec(filespec)
-
-    filepath = format_keys(filepath, test_block_config.variables)
-    filename = os.path.basename(filepath)
-
-    # a 2-tuple ('filename', fileobj)
-    file_spec = [
-        filename,
-        stack.enter_context(open(filepath, "rb")),
-    ]
-
-    # Try to guess as well, but don't override what the user specified
-    guessed_content_type, guessed_encoding = mimetypes.guess_type(filepath)
-    content_type = content_type or guessed_content_type
-    encoding = encoding or guessed_encoding
-
-    # If it doesn't have a mimetype, or can't guess it, don't
-    # send the content type for the file
-    if content_type:
-        # a 3-tuple ('filename', fileobj, 'content_type')
-        logger.debug("content_type for '%s' = '%s'", filename, content_type)
-        file_spec.append(content_type)
-        if encoding:
-            # or a 4-tuple ('filename', fileobj, 'content_type', custom_headers)
-            logger.debug("encoding for '%s' = '%s'", filename, encoding)
-            # encoding is None for no encoding or the name of the
-            # program used to encode (e.g. compress or gzip). The
-            # encoding is suitable for use as a Content-Encoding header.
-            file_spec.append({"Content-Encoding": encoding})
-
-    return file_spec
-
-
 class RestRequest(BaseRequest):
     optional_in_file = [
         "json",
@@ -530,9 +428,10 @@ class RestRequest(BaseRequest):
                     file = stack.enter_context(open(file_body, "rb"))
                     request_args.update(data=file)
                 else:
-                    self._request_args.update(
-                        _get_file_arguments(request_args, stack, test_block_config)
-                    )
+                    files = get_file_arguments(request_args, stack, test_block_config)
+                    if files:
+                        logger.debug("Sending %d files in request", len(files["files"]))
+                        self._request_args.update(files)
 
                 headers = self._request_args.get("headers", {})
                 for k, v in headers.items():
diff --git a/tox-integration.ini b/tox-integration.ini
index 402763c..10598e9 100644
--- a/tox-integration.ini
+++ b/tox-integration.ini
@@ -32,7 +32,7 @@ commands =
 ;    docker-compose build
     docker-compose up --build -d
     python -m pytest --collect-only
-    python -m pytest --tavern-global-cfg={toxinidir}/tests/integration/global_cfg.yaml --cov tavern
+    python -m pytest --tavern-global-cfg={toxinidir}/tests/integration/global_cfg.yaml --cov tavern {posargs}
 
     generic: py.test --tavern-global-cfg={toxinidir}/tests/integration/global_cfg.yaml -n 3
     generic: tavern-ci --stdout . --tavern-global-cfg={toxinidir}/tests/integration/global_cfg.yaml


Test patch:
diff --git a/tavern/_core/schema/tests.jsonschema.yaml b/tavern/_core/schema/tests.jsonschema.yaml
index ebd62f0..880333d 100644
--- a/tavern/_core/schema/tests.jsonschema.yaml
+++ b/tavern/_core/schema/tests.jsonschema.yaml
@@ -162,7 +162,9 @@ definitions:
         description: Path to a file to upload as the request body
 
       files:
-        type: object
+        oneOf:
+          - type: object
+          - type: array
         description: Files to send as part of the request
 
       clear_session_cookies:
diff --git a/tests/integration/server.py b/tests/integration/server.py
index d409b89..7d17801 100644
--- a/tests/integration/server.py
+++ b/tests/integration/server.py
@@ -87,8 +87,6 @@ def upload_fake_file():
 
 
 def _handle_files():
-    if not mimetypes.inited:
-        mimetypes.init()
     for item in request.files.values():
         if item.filename:
             filetype = ".{}".format(item.filename.split(".")[-1])
@@ -104,21 +102,72 @@ def _handle_files():
     return "", 200
 
 
+class BadFileUploadException(Exception):
+    """Something wrong when uploading files"""
+
+
+def _verify_is_file_multipart():
+    if not mimetypes.inited:
+        mimetypes.init()
+
+    if not request.content_type.startswith("multipart/form-data"):
+        raise BadFileUploadException("Was not a multipart form upload")
+
+    if not request.files:
+        raise BadFileUploadException("No files in request")
+
+
 @app.route("/fake_upload_file_data", methods=["POST"])
 def upload_fake_file_and_data():
-    if not request.files:
-        return "", 401
+    try:
+        _verify_is_file_multipart()
+    except BadFileUploadException as e:
+        return jsonify({"error": str(e)}), 400
 
     if not request.form.to_dict():
-        return "", 402
-
-    # Verify that the content type is `multipart`
-    if not request.content_type.startswith("multipart/form-data"):
-        return "", 403
+        return "", 400
 
     return _handle_files()
 
 
+@app.route("/files_expect_in_order", methods=["POST"])
+def upload_specific_files_in_order():
+    """Expects a multipart form upload with files in the correct order
+
+    See test_files.tavern.yaml for expected list of files here
+    """
+
+    try:
+        _verify_is_file_multipart()
+    except BadFileUploadException as e:
+        return jsonify({"error": str(e)}), 400
+
+    try:
+        group_1 = request.files.getlist("group_1")
+        if len(group_1) != 2:
+            raise Exception(f"expected 2 files in group 1, got {len(group_1)}")
+        if group_1[0].filename != "OK.txt":
+            raise Exception(
+                f"First file in group 1 should be OK.txt, was {group_1[0].filename}"
+            )
+        if group_1[1].filename != "OK.json.gz":
+            raise Exception(
+                f"Second file in group 1 should be OK.json.gz, was {group_1[1].filename}"
+            )
+
+        group_2 = request.files.getlist("group_2")
+        if len(group_2) != 1:
+            raise Exception(f"expected 1 files in group 2, got {len(group_2)}")
+        if group_2[0].filename != "OK.txt":
+            raise Exception(
+                f"First file in group 2 should be OK.txt, was {group_2[0].filename}"
+            )
+    except Exception as e:
+        return jsonify({"error": str(e)}), 400
+
+    return "", 200
+
+
 @app.route("/nested/again", methods=["GET"])
 def multiple_path_items_response():
     response = {"status": "OK"}
diff --git a/tests/integration/test_files.tavern.yaml b/tests/integration/test_files.tavern.yaml
index 7645492..f7f4924 100644
--- a/tests/integration/test_files.tavern.yaml
+++ b/tests/integration/test_files.tavern.yaml
@@ -224,3 +224,59 @@ stages:
         test_files: "test_files.tavern.yaml"
     response:
       status_code: 200
+
+---
+test_name: Test uploading multi part files in a common group
+
+stages:
+  - name: Upload files
+    request:
+      url: "{global_host}/files_expect_in_order"
+      method: POST
+      files:
+        - form_field_name: group_2
+          file_path: OK.txt
+        - form_field_name: group_1
+          file_path: OK.txt
+        - form_field_name: group_1
+          file_path: OK.json.gz
+    response:
+      status_code: 200
+
+---
+test_name: Test uploading multi part files in a common group in a different order
+
+stages:
+  - name: Upload files
+    request:
+      url: "{global_host}/files_expect_in_order"
+      method: POST
+      files:
+        - form_field_name: group_1
+          file_path: OK.txt
+        - form_field_name: group_2
+          file_path: OK.txt
+        - form_field_name: group_1
+          file_path: OK.json.gz
+    response:
+      status_code: 200
+
+---
+test_name: Test uploading multi part files with wrong groups
+
+stages:
+  - name: Upload files
+    request:
+      url: "{global_host}/files_expect_in_order"
+      method: POST
+      files:
+        - form_field_name: group_1
+          file_path: OK.txt
+        - form_field_name: group_2
+          file_path: OK.txt
+        - form_field_name: group_3
+          file_path: OK.json.gz
+    response:
+      status_code: 400
+      json:
+        error: expected 2 files in group 1, got 1
diff --git a/tests/unit/test_request.py b/tests/unit/test_request.py
index 2efdbe7..43aa630 100644
--- a/tests/unit/test_request.py
+++ b/tests/unit/test_request.py
@@ -2,18 +2,20 @@ import dataclasses
 import os
 import tempfile
 from contextlib import ExitStack
+from textwrap import dedent
 from unittest.mock import Mock
 
 import pytest
 import requests
+import yaml
 from requests.cookies import RequestsCookieJar
 
 from tavern._core import exceptions
 from tavern._core.extfunctions import update_from_ext
+from tavern._plugins.rest.files import get_file_arguments
 from tavern._plugins.rest.request import (
     RestRequest,
     _check_allow_redirects,
-    _get_file_arguments,
     _read_expected_cookies,
     get_request_args,
 )
@@ -400,14 +402,14 @@ class TestGetFiles:
 
         request_args = {}
 
-        assert _get_file_arguments(request_args, mock_stack, includes) == {}
+        assert get_file_arguments(request_args, mock_stack, includes) == {}
 
     def test_get_empty_files_list(self, mock_stack, includes):
         """No specific files specified -> no files"""
 
         request_args = {"files": {}}
 
-        assert _get_file_arguments(request_args, mock_stack, includes) == {}
+        assert get_file_arguments(request_args, mock_stack, includes) == {}
 
     def test_a_file(self, mock_stack, includes):
         """Json file should have the correct mimetype etc."""
@@ -415,7 +417,7 @@ class TestGetFiles:
         with tempfile.NamedTemporaryFile(suffix=".json") as tfile:
             request_args = {"files": {"file1": tfile.name}}
 
-            file_spec = _get_file_arguments(request_args, mock_stack, includes)
+            file_spec = get_file_arguments(request_args, mock_stack, includes)
 
         file = file_spec["files"]["file1"]
         assert file[0] == os.path.basename(tfile.name)
@@ -435,7 +437,7 @@ class TestGetFiles:
                 }
             }
 
-            file_spec = _get_file_arguments(request_args, mock_stack, includes)
+            file_spec = get_file_arguments(request_args, mock_stack, includes)
 
         file = file_spec["files"]["file1"]
         assert file[0] == os.path.basename(tfile.name)
@@ -462,7 +464,53 @@ class TestGetFiles:
             includes.variables["tmpname"] = tfile.name
             request_args = {"files": {"file1": tfile.name}}
 
-            file_spec = _get_file_arguments(request_args, mock_stack, includes)
+            file_spec = get_file_arguments(request_args, mock_stack, includes)
 
         file = file_spec["files"]["file1"]
         assert file[0] == os.path.basename(tfile.name)
+
+    def test_grouped_file_names(self, mock_stack, includes):
+        """Parse grouped names appropriately"""
+        with tempfile.NamedTemporaryFile() as tfile:
+            raw_yaml_args = """
+                # Send file_1.txt and file_2.txt, both with name="input_files", in the multipart data.
+                - form_field_name: "input_files"
+                  file_path: "%FILENAME%"
+                  content_type: "application/customtype"
+                  content_encoding: "UTF16"
+                - form_field_name: "input_files"
+                  file_path: "%FILENAME%"
+                  content_type: "application/json"
+                """
+
+            raw_yaml_args = raw_yaml_args.replace("%FILENAME%", tfile.name)
+
+            file_args = yaml.safe_load(dedent(raw_yaml_args))
+
+            request_args = {"files": file_args}
+
+            parsed = get_file_arguments(request_args, mock_stack, includes)
+
+            parsed_into = {
+                "files": [
+                    (
+                        "input_files",
+                        (
+                            os.path.basename(tfile.name),
+                            mock_stack.enter_context.return_value,
+                            "application/customtype",
+                            {"Content-Encoding": "UTF16"},
+                        ),
+                    ),
+                    (
+                        "input_files",
+                        (
+                            os.path.basename(tfile.name),
+                            mock_stack.enter_context.return_value,
+                            "application/json",
+                        ),
+                    ),
+                ],
+            }
+
+        assert parsed_into == parsed