diff --git a/include/cpp_request/response.hpp b/include/cpp_request/response.hpp new file mode 100644 index 0000000..c400332 --- /dev/null +++ b/include/cpp_request/response.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +#include + +namespace cpp_request { + +namespace detail::http { +class ResponseParser; +} + +class Response { +public: + [[nodiscard]] int status_code() const noexcept { return status_code_; } + [[nodiscard]] std::string_view reason() const noexcept { return reason_; } + [[nodiscard]] const Headers& headers() const noexcept { return headers_; } + [[nodiscard]] std::string_view body() const noexcept { return body_; } + [[nodiscard]] const std::string& body_storage() const noexcept { return body_; } + +private: + friend class detail::http::ResponseParser; + + int status_code_{0}; + std::string reason_; + Headers headers_; + std::string body_; +}; + +} // namespace cpp_request diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index e0b0b87..fdec0d8 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -3,6 +3,7 @@ add_library(internal ${CMAKE_CURRENT_SOURCE_DIR}/url.cpp ${CMAKE_CURRENT_SOURCE_DIR}/headers.cpp ${CMAKE_CURRENT_SOURCE_DIR}/http/request_serializer.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/http/response_parser.cpp ${CMAKE_CURRENT_SOURCE_DIR}/platform/native_socket.cpp ${CMAKE_CURRENT_SOURCE_DIR}/platform/network_runtime.cpp ${CMAKE_CURRENT_SOURCE_DIR}/platform/socket_mode.cpp diff --git a/src/http/response_parser.cpp b/src/http/response_parser.cpp new file mode 100644 index 0000000..4e5f17b --- /dev/null +++ b/src/http/response_parser.cpp @@ -0,0 +1,439 @@ +#include "http/response_parser.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cpp_request::detail::http { +namespace { + +struct ParsedHead { + int status_code{0}; + std::string reason; + Headers headers; +}; + +[[nodiscard]] constexpr char ascii_lower(char ch) noexcept { + return ch >= 'A' && ch <= 'Z' + ? static_cast(ch + ('a' - 'A')) + : ch; +} + +[[nodiscard]] bool ascii_iequals( + std::string_view lhs, + std::string_view rhs) noexcept { + if (lhs.size() != rhs.size()) { + return false; + } + + for (std::size_t index = 0; index < lhs.size(); ++index) { + if (ascii_lower(lhs[index]) != ascii_lower(rhs[index])) { + return false; + } + } + return true; +} + +[[nodiscard]] constexpr bool is_tchar(unsigned char ch) noexcept { + if ((ch >= '0' && ch <= '9') + || (ch >= 'A' && ch <= 'Z') + || (ch >= 'a' && ch <= 'z')) { + return true; + } + + switch (ch) { + case '!': + case '#': + case '$': + case '%': + case '&': + case '\'': + case '*': + case '+': + case '-': + case '.': + case '^': + case '_': + case '`': + case '|': + case '~': + return true; + default: + return false; + } +} + +[[nodiscard]] bool valid_header_name(std::string_view name) noexcept { + if (name.empty()) { + return false; + } + + for (const unsigned char ch : name) { + if (!is_tchar(ch)) { + return false; + } + } + return true; +} + +[[nodiscard]] bool valid_field_value(std::string_view value) noexcept { + for (const unsigned char ch : value) { + if (ch == '\t') { + continue; + } + if (ch < 0x20 || ch == 0x7f) { + return false; + } + } + return true; +} + +[[nodiscard]] std::string_view trim_ows(std::string_view value) noexcept { + while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) { + value.remove_prefix(1); + } + while (!value.empty() && (value.back() == ' ' || value.back() == '\t')) { + value.remove_suffix(1); + } + return value; +} + +[[nodiscard]] bool contains_token( + std::string_view value, + std::string_view expected) noexcept { + std::size_t begin = 0; + while (begin <= value.size()) { + const std::size_t comma = value.find(',', begin); + const std::size_t end = comma == std::string_view::npos + ? value.size() + : comma; + const std::string_view token = trim_ows(value.substr(begin, end - begin)); + if (ascii_iequals(token, expected)) { + return true; + } + if (comma == std::string_view::npos) { + break; + } + begin = comma + 1; + } + return false; +} + +[[nodiscard]] bool valid_reason_phrase(std::string_view reason) noexcept { + for (const unsigned char ch : reason) { + if (ch == '\t') { + continue; + } + if (ch < 0x20 || ch == 0x7f) { + return false; + } + } + return true; +} + +[[nodiscard]] Result parse_head(std::string_view block) { + const std::size_t first_line_end = block.find("\r\n"); + const std::string_view status_line = first_line_end == std::string_view::npos + ? block + : block.substr(0, first_line_end); + + constexpr std::string_view kVersionPrefix = "HTTP/1.1 "; + if (status_line.size() < kVersionPrefix.size() + 3 + || status_line.substr(0, kVersionPrefix.size()) != kVersionPrefix) { + return Error{ErrorCode::InvalidStatusLine}; + } + + const std::size_t code_begin = kVersionPrefix.size(); + if (status_line[code_begin] < '0' || status_line[code_begin] > '9' + || status_line[code_begin + 1] < '0' || status_line[code_begin + 1] > '9' + || status_line[code_begin + 2] < '0' || status_line[code_begin + 2] > '9') { + return Error{ErrorCode::InvalidStatusLine}; + } + + int status_code = 0; + const char* const code_first = status_line.data() + code_begin; + const char* const code_last = code_first + 3; + const auto parsed_code = std::from_chars(code_first, code_last, status_code, 10); + if (parsed_code.ec != std::errc{} || parsed_code.ptr != code_last + || status_code < 100 || status_code > 999) { + return Error{ErrorCode::InvalidStatusLine}; + } + + std::string_view reason; + if (status_line.size() > code_begin + 3) { + if (status_line[code_begin + 3] != ' ') { + return Error{ErrorCode::InvalidStatusLine}; + } + reason = status_line.substr(code_begin + 4); + if (!valid_reason_phrase(reason)) { + return Error{ErrorCode::InvalidStatusLine}; + } + } + + ParsedHead result; + result.status_code = status_code; + result.reason.assign(reason.data(), reason.size()); + + if (first_line_end == std::string_view::npos) { + return result; + } + + std::size_t line_begin = first_line_end + 2; + while (line_begin < block.size()) { + const std::size_t line_end = block.find("\r\n", line_begin); + const std::size_t current_end = line_end == std::string_view::npos + ? block.size() + : line_end; + const std::string_view line = block.substr(line_begin, current_end - line_begin); + + if (line.empty() || line.front() == ' ' || line.front() == '\t') { + return Error{ErrorCode::InvalidHeader}; + } + + const std::size_t colon = line.find(':'); + if (colon == std::string_view::npos || colon == 0) { + return Error{ErrorCode::InvalidHeader}; + } + + const std::string_view name = line.substr(0, colon); + const std::string_view value = trim_ows(line.substr(colon + 1)); + if (!valid_header_name(name) || !valid_field_value(value)) { + return Error{ErrorCode::InvalidHeader}; + } + + result.headers.add(name, value); + + if (line_end == std::string_view::npos) { + break; + } + line_begin = line_end + 2; + } + + return result; +} + +[[nodiscard]] bool parse_content_length( + std::string_view text, + std::size_t& value) noexcept { + if (text.empty()) { + return false; + } + + const char* const first = text.data(); + const char* const last = text.data() + text.size(); + const auto parsed = std::from_chars(first, last, value, 10); + return parsed.ec == std::errc{} && parsed.ptr == last; +} + +[[nodiscard]] bool is_no_body_status(int status_code) noexcept { + return status_code == 101 + || status_code == 204 + || status_code == 205 + || status_code == 304; +} + +[[nodiscard]] bool is_interim_status(int status_code) noexcept { + return status_code >= 100 && status_code < 200 && status_code != 101; +} + +} // namespace + +ResponseParser::ResponseParser(Method request_method) noexcept + : request_method_(request_method) {} + +Result ResponseParser::feed(std::string_view bytes) { + if (!bytes.empty()) { + buffer_.append(bytes.data(), bytes.size()); + } + return process_buffer(); +} + +Result ResponseParser::process_buffer() { + while (true) { + switch (stage_) { + case Stage::Head: { + const std::size_t head_end = buffer_.find("\r\n\r\n"); + if (head_end == std::string::npos) { + return ResponseParseProgress::NeedMore; + } + + auto parsed = parse_head(std::string_view{buffer_.data(), head_end}); + if (!parsed) { + return parsed.error(); + } + + const std::size_t consumed = head_end + 4; + buffer_.erase(0, consumed); + + ParsedHead head = std::move(parsed).value(); + if (is_interim_status(head.status_code)) { + continue; + } + + response_.status_code_ = head.status_code; + response_.reason_ = std::move(head.reason); + response_.headers_ = std::move(head.headers); + + connection_close_requested_ = response_.status_code_ == 101; + std::size_t content_length_count = 0; + std::size_t content_length = 0; + std::size_t transfer_encoding_fields = 0; + std::size_t transfer_encoding_tokens = 0; + bool transfer_encoding_is_chunked = true; + + for (const auto& field : response_.headers_) { + if (ascii_iequals(field.name, "Connection") + && contains_token(field.value, "close")) { + connection_close_requested_ = true; + } + + if (ascii_iequals(field.name, "Content-Length")) { + ++content_length_count; + if (!parse_content_length(field.value, content_length)) { + return Error{ErrorCode::InvalidContentLength}; + } + continue; + } + + if (ascii_iequals(field.name, "Transfer-Encoding")) { + ++transfer_encoding_fields; + std::size_t token_begin = 0; + while (token_begin <= field.value.size()) { + const std::size_t comma = field.value.find(',', token_begin); + const std::size_t token_end = comma == std::string::npos + ? field.value.size() + : comma; + const std::string_view token = trim_ows( + std::string_view{field.value.data(), field.value.size()}.substr( + token_begin, + token_end - token_begin)); + if (token.empty()) { + return Error{ErrorCode::MalformedResponse}; + } + ++transfer_encoding_tokens; + if (!ascii_iequals(token, "chunked")) { + transfer_encoding_is_chunked = false; + } + if (comma == std::string::npos) { + break; + } + token_begin = comma + 1; + } + } + } + + if (content_length_count > 1) { + return Error{ErrorCode::InvalidContentLength}; + } + + const bool no_body = request_method_ == Method::Head + || is_no_body_status(response_.status_code_); + if (no_body) { + stage_ = Stage::Complete; + return ResponseParseProgress::Complete; + } + + if (transfer_encoding_fields != 0 && content_length_count != 0) { + return Error{ErrorCode::ConflictingMessageFraming}; + } + + if (transfer_encoding_fields != 0) { + if (transfer_encoding_tokens != 1 || !transfer_encoding_is_chunked) { + return Error{ErrorCode::MalformedResponse}; + } + stage_ = Stage::ChunkedBody; + return ResponseParseProgress::NeedsChunkedDecoder; + } + + if (content_length_count == 1) { + content_length_remaining_ = content_length; + response_.body_.reserve(content_length); + if (content_length_remaining_ == 0) { + stage_ = Stage::Complete; + return ResponseParseProgress::Complete; + } + stage_ = Stage::ContentLengthBody; + continue; + } + + close_delimited_ = true; + stage_ = Stage::CloseDelimitedBody; + continue; + } + + case Stage::ContentLengthBody: { + const std::size_t amount = std::min( + content_length_remaining_, + buffer_.size()); + if (amount != 0) { + response_.body_.append(buffer_.data(), amount); + buffer_.erase(0, amount); + content_length_remaining_ -= amount; + } + + if (content_length_remaining_ == 0) { + stage_ = Stage::Complete; + return ResponseParseProgress::Complete; + } + return ResponseParseProgress::NeedMore; + } + + case Stage::CloseDelimitedBody: + if (!buffer_.empty()) { + response_.body_.append(buffer_); + buffer_.clear(); + } + return ResponseParseProgress::NeedMore; + + case Stage::ChunkedBody: + return ResponseParseProgress::NeedsChunkedDecoder; + + case Stage::Complete: + return ResponseParseProgress::Complete; + } + } +} + +Result ResponseParser::finish_eof() { + switch (stage_) { + case Stage::CloseDelimitedBody: + if (!buffer_.empty()) { + response_.body_.append(buffer_); + buffer_.clear(); + } + stage_ = Stage::Complete; + return ResponseParseProgress::Complete; + + case Stage::Complete: + return ResponseParseProgress::Complete; + + case Stage::ContentLengthBody: + case Stage::Head: + case Stage::ChunkedBody: + return Error{ErrorCode::UnexpectedEof}; + } + + return Error{ErrorCode::Unknown}; +} + +bool ResponseParser::complete() const noexcept { + return stage_ == Stage::Complete; +} + +bool ResponseParser::connection_reusable() const noexcept { + return complete() + && !connection_close_requested_ + && !close_delimited_; +} + +Response ResponseParser::take_response() { + assert(complete()); + return std::move(response_); +} + +} // namespace cpp_request::detail::http diff --git a/src/http/response_parser.hpp b/src/http/response_parser.hpp new file mode 100644 index 0000000..33fe1cc --- /dev/null +++ b/src/http/response_parser.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +namespace cpp_request::detail::http { + +enum class ResponseParseProgress { + NeedMore, + Complete, + NeedsChunkedDecoder +}; + +class ResponseParser final { +public: + explicit ResponseParser(Method request_method) noexcept; + + [[nodiscard]] Result feed(std::string_view bytes); + [[nodiscard]] Result finish_eof(); + + [[nodiscard]] bool complete() const noexcept; + [[nodiscard]] bool connection_reusable() const noexcept; + [[nodiscard]] const Response& response() const noexcept { return response_; } + [[nodiscard]] std::string_view pending_bytes() const noexcept { return buffer_; } + + [[nodiscard]] Response take_response(); + +private: + enum class Stage { + Head, + ContentLengthBody, + CloseDelimitedBody, + ChunkedBody, + Complete + }; + + [[nodiscard]] Result process_buffer(); + + Method request_method_; + Stage stage_{Stage::Head}; + Response response_; + std::string buffer_; + std::size_t content_length_remaining_{0}; + bool connection_close_requested_{false}; + bool close_delimited_{false}; +}; + +} // namespace cpp_request::detail::http diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e73b3e4..a541067 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -6,6 +6,8 @@ add_executable(cpp_request_tests ${CMAKE_CURRENT_SOURCE_DIR}/headers_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/request_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/request_serializer_test.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/response_parser_test.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/response_upgrade_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/native_socket_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/resolver_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tcp_connection_test.cpp diff --git a/tests/response_parser_test.cpp b/tests/response_parser_test.cpp new file mode 100644 index 0000000..5ea10c9 --- /dev/null +++ b/tests/response_parser_test.cpp @@ -0,0 +1,241 @@ +#include + +#include "http/response_parser.hpp" + +#include +#include + +#include +#include + +namespace { + +using cpp_request::ErrorCode; +using cpp_request::Method; +using cpp_request::detail::http::ResponseParseProgress; +using cpp_request::detail::http::ResponseParser; + +TEST(ResponseParserTest, ParsesContentLengthResponseAcrossArbitraryChunks) { + ResponseParser parser{Method::Get}; + + const std::string wire = + "HTTP/1.1 200 OK\r\n" + "Content-Type: text/plain\r\n" + "Content-Length: 11\r\n" + "X-Test: one\r\n" + "X-Test: two\r\n" + "\r\n" + "hello world"; + + for (const char ch : wire) { + auto result = parser.feed(std::string_view{&ch, 1}); + ASSERT_TRUE(result); + } + + ASSERT_TRUE(parser.complete()); + EXPECT_TRUE(parser.connection_reusable()); + + auto response = parser.take_response(); + EXPECT_EQ(response.status_code(), 200); + EXPECT_EQ(response.reason(), "OK"); + EXPECT_EQ(response.headers().get("content-type"), "text/plain"); + EXPECT_EQ(response.headers().get("x-test"), "one"); + EXPECT_EQ(response.body(), "hello world"); +} + +TEST(ResponseParserTest, PreservesBytesAfterContentLengthBody) { + ResponseParser parser{Method::Get}; + + auto result = parser.feed( + "HTTP/1.1 200 OK\r\n" + "Content-Length: 5\r\n" + "\r\n" + "helloHTTP/1.1 204 No Content\r\n\r\n"); + + ASSERT_TRUE(result); + EXPECT_EQ(result.value(), ResponseParseProgress::Complete); + EXPECT_EQ(parser.response().body(), "hello"); + EXPECT_EQ( + parser.pending_bytes(), + "HTTP/1.1 204 No Content\r\n\r\n"); +} + +TEST(ResponseParserTest, HeadCompletesAfterHeadersAndLeavesFollowingBytesPending) { + ResponseParser parser{Method::Head}; + + auto result = parser.feed( + "HTTP/1.1 200 OK\r\n" + "Content-Length: 1234\r\n" + "\r\n" + "next-response-bytes"); + + ASSERT_TRUE(result); + EXPECT_EQ(result.value(), ResponseParseProgress::Complete); + EXPECT_TRUE(parser.response().body().empty()); + EXPECT_EQ(parser.pending_bytes(), "next-response-bytes"); + EXPECT_TRUE(parser.connection_reusable()); +} + +TEST(ResponseParserTest, CloseDelimitedBodyCompletesOnlyAtEof) { + ResponseParser parser{Method::Get}; + + auto first = parser.feed( + "HTTP/1.1 200 OK\r\n" + "Content-Type: text/plain\r\n" + "\r\n" + "hello "); + ASSERT_TRUE(first); + EXPECT_EQ(first.value(), ResponseParseProgress::NeedMore); + + auto second = parser.feed("world"); + ASSERT_TRUE(second); + EXPECT_EQ(second.value(), ResponseParseProgress::NeedMore); + EXPECT_FALSE(parser.complete()); + + auto eof = parser.finish_eof(); + ASSERT_TRUE(eof); + EXPECT_EQ(eof.value(), ResponseParseProgress::Complete); + EXPECT_EQ(parser.response().body(), "hello world"); + EXPECT_FALSE(parser.connection_reusable()); +} + +TEST(ResponseParserTest, ConnectionClosePreventsReuseEvenWithContentLength) { + ResponseParser parser{Method::Get}; + + auto result = parser.feed( + "HTTP/1.1 200 OK\r\n" + "Content-Length: 2\r\n" + "Connection: keep-alive, close\r\n" + "\r\n" + "ok"); + + ASSERT_TRUE(result); + EXPECT_EQ(result.value(), ResponseParseProgress::Complete); + EXPECT_FALSE(parser.connection_reusable()); +} + +TEST(ResponseParserTest, ChunkedResponseHandsOffWithoutConsumingBodyBytes) { + ResponseParser parser{Method::Get}; + + auto result = parser.feed( + "HTTP/1.1 200 OK\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n" + "4\r\nWiki\r\n0\r\n\r\n"); + + ASSERT_TRUE(result); + EXPECT_EQ(result.value(), ResponseParseProgress::NeedsChunkedDecoder); + EXPECT_FALSE(parser.complete()); + EXPECT_EQ(parser.pending_bytes(), "4\r\nWiki\r\n0\r\n\r\n"); +} + +TEST(ResponseParserTest, RejectsTransferEncodingAndContentLengthTogether) { + ResponseParser parser{Method::Get}; + + auto result = parser.feed( + "HTTP/1.1 200 OK\r\n" + "Transfer-Encoding: chunked\r\n" + "Content-Length: 4\r\n" + "\r\n"); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::ConflictingMessageFraming); +} + +TEST(ResponseParserTest, RejectsDuplicateContentLength) { + ResponseParser parser{Method::Get}; + + auto result = parser.feed( + "HTTP/1.1 200 OK\r\n" + "Content-Length: 4\r\n" + "Content-Length: 4\r\n" + "\r\n"); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::InvalidContentLength); +} + +TEST(ResponseParserTest, RejectsInvalidContentLength) { + ResponseParser parser{Method::Get}; + + auto result = parser.feed( + "HTTP/1.1 200 OK\r\n" + "Content-Length: 4x\r\n" + "\r\n"); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::InvalidContentLength); +} + +TEST(ResponseParserTest, RejectsMalformedStatusLine) { + ResponseParser parser{Method::Get}; + + auto result = parser.feed("HTTP/1.0 200 OK\r\n\r\n"); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::InvalidStatusLine); +} + +TEST(ResponseParserTest, RejectsMalformedHeader) { + ResponseParser parser{Method::Get}; + + auto result = parser.feed( + "HTTP/1.1 200 OK\r\n" + "Bad Header: value\r\n" + "\r\n"); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::InvalidHeader); +} + +TEST(ResponseParserTest, ConsumesInterimResponseBeforeFinalResponse) { + ResponseParser parser{Method::Post}; + + auto result = parser.feed( + "HTTP/1.1 100 Continue\r\n" + "X-Interim: ignored\r\n" + "\r\n" + "HTTP/1.1 201 Created\r\n" + "Content-Length: 2\r\n" + "\r\n" + "ok"); + + ASSERT_TRUE(result); + EXPECT_EQ(result.value(), ResponseParseProgress::Complete); + EXPECT_EQ(parser.response().status_code(), 201); + EXPECT_EQ(parser.response().reason(), "Created"); + EXPECT_FALSE(parser.response().headers().contains("X-Interim")); + EXPECT_EQ(parser.response().body(), "ok"); +} + +TEST(ResponseParserTest, NoBodyStatusCompletesAtHeaders) { + ResponseParser parser{Method::Get}; + + auto result = parser.feed( + "HTTP/1.1 204 No Content\r\n" + "Content-Length: 0\r\n" + "\r\n"); + + ASSERT_TRUE(result); + EXPECT_EQ(result.value(), ResponseParseProgress::Complete); + EXPECT_TRUE(parser.response().body().empty()); + EXPECT_TRUE(parser.connection_reusable()); +} + +TEST(ResponseParserTest, PrematureEofForContentLengthIsError) { + ResponseParser parser{Method::Get}; + + auto result = parser.feed( + "HTTP/1.1 200 OK\r\n" + "Content-Length: 5\r\n" + "\r\n" + "abc"); + ASSERT_TRUE(result); + EXPECT_EQ(result.value(), ResponseParseProgress::NeedMore); + + auto eof = parser.finish_eof(); + ASSERT_FALSE(eof); + EXPECT_EQ(eof.error().code, ErrorCode::UnexpectedEof); +} + +} // namespace diff --git a/tests/response_upgrade_test.cpp b/tests/response_upgrade_test.cpp new file mode 100644 index 0000000..aa94726 --- /dev/null +++ b/tests/response_upgrade_test.cpp @@ -0,0 +1,28 @@ +#include + +#include "http/response_parser.hpp" + +#include + +namespace { + +using cpp_request::Method; +using cpp_request::detail::http::ResponseParseProgress; +using cpp_request::detail::http::ResponseParser; + +TEST(ResponseParserUpgradeTest, SwitchingProtocolsIsNotReusableHttpConnection) { + ResponseParser parser{Method::Get}; + + auto result = parser.feed( + "HTTP/1.1 101 Switching Protocols\r\n" + "Connection: Upgrade\r\n" + "Upgrade: websocket\r\n" + "\r\n"); + + ASSERT_TRUE(result); + EXPECT_EQ(result.value(), ResponseParseProgress::Complete); + EXPECT_TRUE(parser.complete()); + EXPECT_FALSE(parser.connection_reusable()); +} + +} // namespace