diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 69a627bb..d8411953 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,6 +11,7 @@ This project adheres to `Semantic Versioning `_. ------------------------- * Fixed upload with ``--session`` (`#1020`_). +* Fixed a missing blank line between request and response (`#1006`_). `2.3.0`_ (2020-10-25) @@ -485,4 +486,5 @@ This project adheres to `Semantic Versioning `_. .. _#934: https://github.com/httpie/httpie/issues/934 .. _#943: https://github.com/httpie/httpie/issues/943 .. _#963: https://github.com/httpie/httpie/issues/963 +.. _#1006: https://github.com/httpie/httpie/issues/1006 .. _#1020: https://github.com/httpie/httpie/issues/1020 diff --git a/httpie/core.py b/httpie/core.py index 43b4255c..3f46603e 100644 --- a/httpie/core.py +++ b/httpie/core.py @@ -9,26 +9,17 @@ from pygments import __version__ as pygments_version from requests import __version__ as requests_version from httpie import __version__ as httpie_version -from httpie.cli.constants import ( - OUT_REQ_BODY, OUT_REQ_HEAD, OUT_RESP_BODY, - OUT_RESP_HEAD, -) +from httpie.cli.constants import OUT_REQ_BODY, OUT_REQ_HEAD, OUT_RESP_BODY, OUT_RESP_HEAD from httpie.client import collect_messages from httpie.context import Environment from httpie.downloads import Downloader -from httpie.output.writer import ( - write_message, - write_stream, -) +from httpie.output.writer import write_message, write_stream, MESSAGE_SEPARATOR_BYTES from httpie.plugins.registry import plugin_manager from httpie.status import ExitStatus, http_status_to_exit_status # noinspection PyDefaultArgument -def main( - args: List[Union[str, bytes]] = sys.argv, - env=Environment(), -) -> ExitStatus: +def main(args: List[Union[str, bytes]] = sys.argv, env=Environment()) -> ExitStatus: """ The main function. @@ -134,112 +125,82 @@ def get_output_options( }[type(message)] -def program( - args: argparse.Namespace, - env: Environment, -) -> ExitStatus: +def program(args: argparse.Namespace, env: Environment) -> ExitStatus: """ The main program without error handling. """ + # TODO: Refactor and drastically simplify, especially so that the separator logic is elsewhere. exit_status = ExitStatus.SUCCESS downloader = None + initial_request: Optional[requests.PreparedRequest] = None + final_response: Optional[requests.Response] = None + + def separate(): + getattr(env.stdout, 'buffer', env.stdout).write(MESSAGE_SEPARATOR_BYTES) + + def request_body_read_callback(chunk: bytes): + should_pipe_to_stdout = bool( + # Request body output desired + OUT_REQ_BODY in args.output_options + # & not `.read()` already pre-request (e.g., for compression) + and initial_request + # & non-EOF chunk + and chunk + ) + if should_pipe_to_stdout: + msg = requests.PreparedRequest() + msg.is_body_upload_chunk = True + msg.body = chunk + msg.headers = initial_request.headers + write_message(requests_message=msg, env=env, args=args, with_body=True, with_headers=False) try: if args.download: args.follow = True # --download implies --follow. - downloader = Downloader( - output_file=args.output_file, - progress_file=env.stderr, - resume=args.download_resume - ) + downloader = Downloader(output_file=args.output_file, progress_file=env.stderr, resume=args.download_resume) downloader.pre_request(args.headers) + messages = collect_messages(args=args, config_dir=env.config.directory, + request_body_read_callback=request_body_read_callback) + force_separator = False + prev_with_body = False - needs_separator = False - - def maybe_separate(): - nonlocal needs_separator - if env.stdout_isatty and needs_separator: - needs_separator = False - getattr(env.stdout, 'buffer', env.stdout).write(b'\n\n') - - initial_request: Optional[requests.PreparedRequest] = None - final_response: Optional[requests.Response] = None - - def request_body_read_callback(chunk: bytes): - should_pipe_to_stdout = ( - # Request body output desired - OUT_REQ_BODY in args.output_options - # & not `.read()` already pre-request (e.g., for compression) - and initial_request - # & non-EOF chunk - and chunk - ) - if should_pipe_to_stdout: - msg = requests.PreparedRequest() - msg.is_body_upload_chunk = True - msg.body = chunk - msg.headers = initial_request.headers - write_message( - requests_message=msg, - env=env, - args=args, - with_body=True, - with_headers=False - ) - - messages = collect_messages( - args=args, - config_dir=env.config.directory, - request_body_read_callback=request_body_read_callback - ) + # Process messages as they’re generated for message in messages: - maybe_separate() is_request = isinstance(message, requests.PreparedRequest) - with_headers, with_body = get_output_options( - args=args, message=message) + with_headers, with_body = get_output_options(args=args, message=message) + do_write_body = with_body + if prev_with_body and (with_headers or with_body) and (force_separator or not env.stdout_isatty): + # Separate after a previous message with body, if needed. See test_tokens.py. + separate() + force_separator = False if is_request: if not initial_request: initial_request = message - is_streamed_upload = not isinstance( - message.body, (str, bytes)) + is_streamed_upload = not isinstance(message.body, (str, bytes)) if with_body: - with_body = not is_streamed_upload - needs_separator = is_streamed_upload + do_write_body = not is_streamed_upload + force_separator = is_streamed_upload and env.stdout_isatty else: final_response = message if args.check_status or downloader: - exit_status = http_status_to_exit_status( - http_status=message.status_code, - follow=args.follow - ) - if (not env.stdout_isatty - and exit_status != ExitStatus.SUCCESS): - env.log_error( - f'HTTP {message.raw.status} {message.raw.reason}', - level='warning' - ) - write_message( - requests_message=message, - env=env, - args=args, - with_headers=with_headers, - with_body=with_body, - ) - - maybe_separate() + exit_status = http_status_to_exit_status(http_status=message.status_code, follow=args.follow) + if not env.stdout_isatty and exit_status != ExitStatus.SUCCESS: + env.log_error(f'HTTP {message.raw.status} {message.raw.reason}', level='warning') + write_message(requests_message=message, env=env, args=args, with_headers=with_headers, + with_body=do_write_body) + prev_with_body = with_body + # Cleanup + if force_separator: + separate() if downloader and exit_status == ExitStatus.SUCCESS: # Last response body download. download_stream, download_to = downloader.start( initial_url=initial_request.url, final_response=final_response, ) - write_stream( - stream=download_stream, - outfile=download_to, - flush=False, - ) + write_stream(stream=download_stream, outfile=download_to, flush=False) downloader.finish() if downloader.interrupted: exit_status = ExitStatus.ERROR @@ -253,9 +214,7 @@ def program( finally: if downloader and not downloader.finished: downloader.failed() - - if (not isinstance(args, list) and args.output_file - and args.output_file_specified): + if not isinstance(args, list) and args.output_file and args.output_file_specified: args.output_file.close() diff --git a/httpie/output/writer.py b/httpie/output/writer.py index d8a34aa3..2e63b6ae 100644 --- a/httpie/output/writer.py +++ b/httpie/output/writer.py @@ -12,6 +12,10 @@ from httpie.output.streams import ( ) +MESSAGE_SEPARATOR = '\n\n' +MESSAGE_SEPARATOR_BYTES = MESSAGE_SEPARATOR.encode() + + def write_message( requests_message: Union[requests.PreparedRequest, requests.Response], env: Environment, @@ -111,7 +115,7 @@ def build_output_stream_for_message( and not getattr(requests_message, 'is_body_upload_chunk', False)): # Ensure a blank line after the response body. # For terminal output only. - yield b'\n\n' + yield MESSAGE_SEPARATOR_BYTES def get_stream_type_and_kwargs( diff --git a/setup.cfg b/setup.cfg index e672f486..cf654a6b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,7 +4,7 @@ [tool:pytest] # norecursedirs = tests/fixtures -addopts = --tb=native +addopts = --tb=native --doctest-modules [pycodestyle] diff --git a/tests/test_regressions.py b/tests/test_regressions.py index c6c0c3db..118b1023 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -2,6 +2,7 @@ import pytest from httpie.compat import is_windows +from tests.utils.matching import assert_output_matches, Expect from utils import HTTP_OK, MockEnvironment, http @@ -27,7 +28,6 @@ def test_output_devnull(httpbin): http('--output=/dev/null', httpbin + '/get') -@pytest.mark.skip(reason='TODO: fix #1006') def test_verbose_redirected_stdout_separator(httpbin): """ @@ -40,3 +40,10 @@ def test_verbose_redirected_stdout_separator(httpbin): env=MockEnvironment(stdout_isatty=False), ) assert '}HTTP/' not in r + assert_output_matches(r, [ + Expect.REQUEST_HEADERS, + Expect.BODY, + Expect.SEPARATOR, + Expect.RESPONSE_HEADERS, + Expect.BODY, + ]) diff --git a/tests/test_tokens.py b/tests/test_tokens.py new file mode 100644 index 00000000..60af01e5 --- /dev/null +++ b/tests/test_tokens.py @@ -0,0 +1,111 @@ +""" +The ideas behind these test and the named templates is to ensure consistent output +across all supported different scenarios: + +TODO: cover more scenarios + * terminal vs. redirect stdout + * different combinations of `--print=HBhb` (request/response headers/body) + * multipart requests + * streamed uploads + +""" +from tests.utils.matching import assert_output_matches, Expect +from utils import http, HTTP_OK, MockEnvironment, HTTPBIN_WITH_CHUNKED_SUPPORT + + +RAW_REQUEST = [ + Expect.REQUEST_HEADERS, + Expect.BODY, +] +RAW_RESPONSE = [ + Expect.RESPONSE_HEADERS, + Expect.BODY, +] +RAW_EXCHANGE = [ + *RAW_REQUEST, + Expect.SEPARATOR, # Good choice? + *RAW_RESPONSE, +] +RAW_BODY = [ + Expect.BODY, +] + +TERMINAL_REQUEST = [ + *RAW_REQUEST, + Expect.SEPARATOR, +] +TERMINAL_RESPONSE = [ + *RAW_RESPONSE, + Expect.SEPARATOR, +] +TERMINAL_EXCHANGE = [ + *TERMINAL_REQUEST, + *TERMINAL_RESPONSE, +] +TERMINAL_BODY = [ + RAW_BODY, + Expect.SEPARATOR +] + + +def test_headers(): + r = http('--print=H', '--offline', 'pie.dev') + assert_output_matches(r, [Expect.REQUEST_HEADERS]) + + +def test_redirected_headers(): + r = http('--print=H', '--offline', 'pie.dev', env=MockEnvironment(stdout_isatty=False)) + assert_output_matches(r, [Expect.REQUEST_HEADERS]) + + +def test_terminal_headers_and_body(): + r = http('--print=HB', '--offline', 'pie.dev', 'AAA=BBB') + assert_output_matches(r, TERMINAL_REQUEST) + + +def test_raw_headers_and_body(): + r = http( + '--print=HB', '--offline', 'pie.dev', 'AAA=BBB', + env=MockEnvironment(stdout_isatty=False), + ) + assert_output_matches(r, RAW_REQUEST) + + +def test_raw_body(): + r = http( + '--print=B', '--offline', 'pie.dev', 'AAA=BBB', + env=MockEnvironment(stdout_isatty=False), + ) + assert_output_matches(r, RAW_BODY) + + +def test_raw_exchange(httpbin): + r = http('--verbose', httpbin + '/post', 'a=b', env=MockEnvironment(stdout_isatty=False)) + assert HTTP_OK in r + assert_output_matches(r, RAW_EXCHANGE) + + +def test_terminal_exchange(httpbin): + r = http('--verbose', httpbin + '/post', 'a=b') + assert HTTP_OK in r + assert_output_matches(r, TERMINAL_EXCHANGE) + + +def test_headers_multipart_body_separator(): + r = http('--print=HB', '--multipart', '--offline', 'pie.dev', 'AAA=BBB') + assert_output_matches(r, TERMINAL_REQUEST) + + +def test_redirected_headers_multipart_no_separator(): + r = http( + '--print=HB', '--multipart', '--offline', 'pie.dev', 'AAA=BBB', + env=MockEnvironment(stdout_isatty=False), + ) + assert_output_matches(r, RAW_REQUEST) + + +def test_verbose_chunked(): + r = http('--verbose', '--chunked', HTTPBIN_WITH_CHUNKED_SUPPORT + '/post', 'hello=world') + assert HTTP_OK in r + assert 'Transfer-Encoding: chunked' in r + assert_output_matches(r, TERMINAL_EXCHANGE) diff --git a/tests/utils.py b/tests/utils/__init__.py similarity index 93% rename from tests/utils.py rename to tests/utils/__init__.py index 254c5264..575f1e2d 100644 --- a/tests/utils.py +++ b/tests/utils/__init__.py @@ -1,12 +1,14 @@ # coding=utf-8 """Utilities for HTTPie test suite.""" +import re +import shlex import sys import time import json import tempfile from io import BytesIO from pathlib import Path -from typing import Optional, Union +from typing import Optional, Union, List from httpie.status import ExitStatus from httpie.config import Config @@ -20,7 +22,7 @@ from httpie.core import main HTTPBIN_WITH_CHUNKED_SUPPORT = 'http://pie.dev' -TESTS_ROOT = Path(__file__).parent +TESTS_ROOT = Path(__file__).parent.parent CRLF = '\r\n' COLOR = '\x1b[' HTTP_OK = '200 OK' @@ -49,7 +51,7 @@ class StdinBytesIO(BytesIO): class MockEnvironment(Environment): """Environment subclass with reasonable defaults for testing.""" - colors = 0 + colors = 0 # For easier debugging stdin_isatty = True, stdout_isatty = True is_windows = False @@ -113,6 +115,15 @@ class BaseCLIResponse: devnull: str = None json: dict = None exit_status: ExitStatus = None + command: str = None + args: List[str] = [] + complete_args: List[str] = [] + + @property + def command(self): + cmd = ' '.join(shlex.quote(arg) for arg in ['http', *self.args]) + # pytest-httpbin to real httpbin. + return re.sub(r'127\.0\.0\.1:\d+', 'httpbin.org', cmd) class BytesCLIResponse(bytes, BaseCLIResponse): @@ -284,10 +295,13 @@ def http( r.devnull = devnull_output r.stderr = stderr.read() r.exit_status = exit_status + r.args = args + r.complete_args = ' '.join(complete_args) if r.exit_status != ExitStatus.SUCCESS: sys.stderr.write(r.stderr) + # print(f'\n\n$ {r.command}\n') return r finally: diff --git a/tests/utils/matching/__init__.py b/tests/utils/matching/__init__.py new file mode 100644 index 00000000..6afeaf14 --- /dev/null +++ b/tests/utils/matching/__init__.py @@ -0,0 +1,32 @@ +from typing import Iterable + +import pytest + +from tests.utils.matching.parsing import OutputMatchingError, expect_tokens, Expect + + +__all__ = [ + 'assert_output_matches', + 'assert_output_does_not_match', + 'Expect', +] + + +def assert_output_matches(output: str, tokens: Iterable[Expect]): + r""" + Check the command `output` for an exact full sequence of `tokens`. + + >>> out = 'GET / HTTP/1.1\r\nAAA:BBB\r\n\r\nCCC\n\n' + >>> assert_output_matches(out, [Expect.REQUEST_HEADERS, Expect.BODY, Expect.SEPARATOR]) + + """ + # TODO: auto-remove ansi colors to allow for testing of colorized output as well. + expect_tokens(tokens=tokens, s=output) + + +def assert_output_does_not_match(output: str, tokens: Iterable[Expect]): + r""" + >>> assert_output_does_not_match('\r\n', [Expect.BODY]) + """ + with pytest.raises(OutputMatchingError): + assert_output_matches(output=output, tokens=tokens) diff --git a/tests/utils/matching/parsing.py b/tests/utils/matching/parsing.py new file mode 100644 index 00000000..4d573aa1 --- /dev/null +++ b/tests/utils/matching/parsing.py @@ -0,0 +1,107 @@ +import re +from typing import Iterable +from enum import Enum, auto + +from httpie.output.writer import MESSAGE_SEPARATOR +from tests.utils import CRLF + + +class Expect(Enum): + """ + Predefined token types we can expect in the output. + + """ + REQUEST_HEADERS = auto() + RESPONSE_HEADERS = auto() + BODY = auto() + SEPARATOR = auto() + + +SEPARATOR_RE = re.compile(f'^{MESSAGE_SEPARATOR}') + + +def make_headers_re(message_type: Expect): + assert message_type in {Expect.REQUEST_HEADERS, Expect.RESPONSE_HEADERS} + + # language=RegExp + crlf = r'[\r][\n]' + non_crlf = rf'[^{CRLF}]' + + # language=RegExp + http_version = r'HTTP/\d+\.\d+' + if message_type is Expect.REQUEST_HEADERS: + # POST /post HTTP/1.1 + start_line_re = fr'{non_crlf}*{http_version}{crlf}' + else: + # HTTP/1.1 200 OK + start_line_re = fr'{http_version}{non_crlf}*{crlf}' + + return re.compile( + fr''' + ^ + {start_line_re} + ({non_crlf}+:{non_crlf}+{crlf})+ + {crlf} + ''', + flags=re.VERBOSE + ) + + +BODY_ENDINGS = [ + MESSAGE_SEPARATOR, + CRLF, # Not really but useful for testing (just remember not to include it in a body). +] +TOKEN_REGEX_MAP = { + Expect.REQUEST_HEADERS: make_headers_re(Expect.REQUEST_HEADERS), + Expect.RESPONSE_HEADERS: make_headers_re(Expect.RESPONSE_HEADERS), + Expect.SEPARATOR: SEPARATOR_RE, +} + + +class OutputMatchingError(ValueError): + pass + + +def expect_tokens(tokens: Iterable[Expect], s: str): + for token in tokens: + s = expect_token(token, s) + if s: + raise OutputMatchingError(f'Unmatched remaining output for {tokens} in {s!r}') + + +def expect_token(token: Expect, s: str) -> str: + if token is Expect.BODY: + s = expect_body(s) + else: + s = expect_regex(token, s) + return s + + +def expect_regex(token: Expect, s: str) -> str: + match = TOKEN_REGEX_MAP[token].match(s) + if not match: + raise OutputMatchingError(f'No match for {token} in {s!r}') + return s[match.end():] + + +def expect_body(s: str) -> str: + """ + We require some text, and continue to read until we find an ending or until the end of the string. + + """ + if 'content-disposition:' in s.lower(): + # Multipart body heuristic. + final_boundary_re = re.compile('\r\n--[^-]+?--\r\n') + match = final_boundary_re.search(s) + if match: + return s[match.end():] + + endings = [s.index(sep) for sep in BODY_ENDINGS if sep in s] + if not endings: + s = '' # Only body + else: + end = min(endings) + if end == 0: + raise OutputMatchingError(f'Empty body: {s!r}') + s = s[end:] + return s diff --git a/tests/utils/matching/test_matching.py b/tests/utils/matching/test_matching.py new file mode 100644 index 00000000..0cc09858 --- /dev/null +++ b/tests/utils/matching/test_matching.py @@ -0,0 +1,190 @@ +""" +Here we test our output parsing and matching implementation, not HTTPie itself. + +""" +from httpie.output.writer import MESSAGE_SEPARATOR +from tests.utils import CRLF +from tests.utils.matching import assert_output_does_not_match, assert_output_matches, Expect + + +def test_assert_output_matches_headers_incomplete(): + assert_output_does_not_match(f'HTTP/1.1{CRLF}', [Expect.RESPONSE_HEADERS]) + + +def test_assert_output_matches_headers_unterminated(): + assert_output_does_not_match( + ( + f'HTTP/1.1{CRLF}' + f'AAA:BBB' + f'{CRLF}' + ), + [Expect.RESPONSE_HEADERS], + ) + + +def test_assert_output_matches_response_headers(): + assert_output_matches( + ( + f'HTTP/1.1 200 OK{CRLF}' + f'AAA:BBB{CRLF}' + f'{CRLF}' + ), + [Expect.RESPONSE_HEADERS], + ) + + +def test_assert_output_matches_request_headers(): + assert_output_matches( + ( + f'GET / HTTP/1.1{CRLF}' + f'AAA:BBB{CRLF}' + f'{CRLF}' + ), + [Expect.REQUEST_HEADERS], + ) + + +def test_assert_output_matches_headers_and_separator(): + assert_output_matches( + ( + f'HTTP/1.1{CRLF}' + f'AAA:BBB{CRLF}' + f'{CRLF}' + f'{MESSAGE_SEPARATOR}' + ), + [Expect.RESPONSE_HEADERS, Expect.SEPARATOR], + ) + + +def test_assert_output_matches_body_unmatched_crlf(): + assert_output_does_not_match(f'AAA{CRLF}', [Expect.BODY]) + + +def test_assert_output_matches_body_unmatched_separator(): + assert_output_does_not_match(f'AAA{MESSAGE_SEPARATOR}', [Expect.BODY]) + + +def test_assert_output_matches_body_and_separator(): + assert_output_matches(f'AAA{MESSAGE_SEPARATOR}', [Expect.BODY, Expect.SEPARATOR]) + + +def test_assert_output_matches_body_r(): + assert_output_matches(f'AAA\r', [Expect.BODY]) + + +def test_assert_output_matches_body_n(): + assert_output_matches(f'AAA\n', [Expect.BODY]) + + +def test_assert_output_matches_body_r_body(): + assert_output_matches(f'AAA\rBBB', [Expect.BODY]) + + +def test_assert_output_matches_body_n_body(): + assert_output_matches(f'AAA\nBBB', [Expect.BODY]) + + +def test_assert_output_matches_headers_and_body(): + assert_output_matches( + ( + f'HTTP/1.1{CRLF}' + f'AAA:BBB{CRLF}' + f'{CRLF}' + f'CCC' + ), + [Expect.RESPONSE_HEADERS, Expect.BODY] + ) + + +def test_assert_output_matches_headers_with_body_and_separator(): + assert_output_matches( + ( + f'HTTP/1.1 {CRLF}' + f'AAA:BBB{CRLF}{CRLF}' + f'CCC{MESSAGE_SEPARATOR}' + ), + [Expect.RESPONSE_HEADERS, Expect.BODY, Expect.SEPARATOR] + ) + + +def test_assert_output_matches_multiple_messages(): + assert_output_matches( + ( + f'POST / HTTP/1.1{CRLF}' + f'AAA:BBB{CRLF}' + f'{CRLF}' + + f'CCC' + f'{MESSAGE_SEPARATOR}' + + f'HTTP/1.1 200 OK{CRLF}' + f'EEE:FFF{CRLF}' + f'{CRLF}' + + f'GGG' + f'{MESSAGE_SEPARATOR}' + ), [ + Expect.REQUEST_HEADERS, + Expect.BODY, + Expect.SEPARATOR, + Expect.RESPONSE_HEADERS, + Expect.BODY, + Expect.SEPARATOR, + ] + ) + + +def test_assert_output_matches_multipart_body(): + output = ( + 'POST / HTTP/1.1\r\n' + 'User-Agent: HTTPie/2.4.0-dev\r\n' + 'Accept-Encoding: gzip, deflate\r\n' + 'Accept: */*\r\n' + 'Connection: keep-alive\r\n' + 'Content-Type: multipart/form-data; boundary=1e22169de43e4a2e8d9e41c0a1c93cc5\r\n' + 'Content-Length: 212\r\n' + 'Host: pie.dev\r\n' + '\r\n' + '--1e22169de43e4a2e8d9e41c0a1c93cc5\r\n' + 'Content-Disposition: form-data; name="AAA"\r\n' + '\r\n' + 'BBB\r\n' + '--1e22169de43e4a2e8d9e41c0a1c93cc5\r\n' + 'Content-Disposition: form-data; name="CCC"\r\n' + '\r\n' + 'DDD\r\n' + '--1e22169de43e4a2e8d9e41c0a1c93cc5--\r\n' + ) + assert_output_matches(output, [Expect.REQUEST_HEADERS, Expect.BODY]) + + +def test_assert_output_matches_multipart_body_with_separator(): + output = ( + 'POST / HTTP/1.1\r\n' + 'User-Agent: HTTPie/2.4.0-dev\r\n' + 'Accept-Encoding: gzip, deflate\r\n' + 'Accept: */*\r\n' + 'Connection: keep-alive\r\n' + 'Content-Type: multipart/form-data; boundary=1e22169de43e4a2e8d9e41c0a1c93cc5\r\n' + 'Content-Length: 212\r\n' + 'Host: pie.dev\r\n' + '\r\n' + '--1e22169de43e4a2e8d9e41c0a1c93cc5\r\n' + 'Content-Disposition: form-data; name="AAA"\r\n' + '\r\n' + 'BBB\r\n' + '--1e22169de43e4a2e8d9e41c0a1c93cc5\r\n' + 'Content-Disposition: form-data; name="CCC"\r\n' + '\r\n' + 'DDD\r\n' + '--1e22169de43e4a2e8d9e41c0a1c93cc5--\r\n' + f'{MESSAGE_SEPARATOR}' + ) + assert_output_matches(output, [Expect.REQUEST_HEADERS, Expect.BODY, Expect.SEPARATOR]) + + +def test_assert_output_matches_multiple_separators(): + assert_output_matches( + MESSAGE_SEPARATOR + MESSAGE_SEPARATOR + 'AAA' + MESSAGE_SEPARATOR + MESSAGE_SEPARATOR, + [Expect.SEPARATOR, Expect.SEPARATOR, Expect.BODY, Expect.SEPARATOR, Expect.SEPARATOR] + )