Skip to content

Commit f0ff8c1

Browse files
author
gophergogo
committed
Add integration tests for HttpAsyncClient (#213)
Drive the client against a real 127.0.0.1 listener stood up by RealListenerTestBase so the codec, deferred-delete teardown, and connection lifecycle all run through live kernel sockets rather than mocks. Covers: POST round-trip (request formatting and response body delivery), malformed-URL rejection (send returns false and fires no callback), and malformed response bytes (codec error surfaces as an error callback). Callbacks are captured through a shared_ptr so late firings from the dispatcher teardown path cannot hit a destroyed sink on the test stack.
1 parent b820ab8 commit f0ff8c1

2 files changed

Lines changed: 286 additions & 0 deletions

File tree

tests/CMakeLists.txt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ add_executable(test_llhttp_parser http/test_llhttp_parser.cc)
8484
add_executable(test_nghttp2_parser http/test_nghttp2_parser.cc)
8585
add_executable(test_sse_parser http/test_sse_parser.cc)
8686

87+
# Integration test for HttpAsyncClient (real IO against a localhost listener)
88+
add_executable(test_http_async_client http/test_http_async_client.cc)
89+
8790
# Transport layer tests (low-level transport/pipe/socket testing)
8891
add_executable(test_stdio_echo_server transport/test_stdio_echo_server.cc)
8992
add_executable(test_stdio_echo_client transport/test_stdio_echo_client.cc)
@@ -656,6 +659,15 @@ target_link_libraries(test_sse_parser
656659
Threads::Threads
657660
)
658661

662+
target_link_libraries(test_http_async_client
663+
gopher-mcp
664+
gopher-mcp-event
665+
gtest
666+
gmock
667+
gtest_main
668+
Threads::Threads
669+
)
670+
659671
target_link_libraries(test_stdio_echo_server
660672
gopher-mcp
661673
gopher-mcp-event
@@ -1329,6 +1341,7 @@ add_test(NAME HttpParserTest COMMAND test_http_parser)
13291341
add_test(NAME LLHttpParserTest COMMAND test_llhttp_parser)
13301342
add_test(NAME Nghttp2ParserTest COMMAND test_nghttp2_parser)
13311343
add_test(NAME SseParserTest COMMAND test_sse_parser)
1344+
add_test(NAME HttpAsyncClientTest COMMAND test_http_async_client)
13321345
add_test(NAME StdioEchoServerTest COMMAND test_stdio_echo_server)
13331346
add_test(NAME StdioEchoClientTest COMMAND test_stdio_echo_client)
13341347

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
/**
2+
* Round-trip integration tests for mcp::http::HttpAsyncClient.
3+
*
4+
* We stand up a real TCP listener on 127.0.0.1 (ephemeral port) via
5+
* RealListenerTestBase, drive HttpAsyncClient::send at it, then hand-
6+
* handle the server-side accept/read/write with the MCP socket
7+
* interface. That gives us real I/O across the whole stack — the
8+
* client codec, the deferred-delete teardown, and the connection
9+
* lifecycle all run against a live kernel socket rather than a mock.
10+
*/
11+
12+
#include <atomic>
13+
#include <chrono>
14+
#include <condition_variable>
15+
#include <mutex>
16+
#include <string>
17+
#include <thread>
18+
19+
#include <gtest/gtest.h>
20+
21+
#include "mcp/http/http_async_client.h"
22+
#include "mcp/network/socket_interface.h"
23+
#include "mcp/network/transport_socket.h"
24+
25+
#include "../integration/real_io_test_base.h"
26+
27+
namespace mcp {
28+
namespace http {
29+
namespace {
30+
31+
using namespace std::chrono_literals;
32+
33+
// Collects the outcome of an HttpAsyncClient::send so the test thread
34+
// can block on it without racing the dispatcher thread.
35+
class ResponseSink {
36+
public:
37+
void setResponse(HttpResponse r) {
38+
std::lock_guard<std::mutex> g(mu_);
39+
response_ = std::move(r);
40+
got_response_ = true;
41+
cv_.notify_all();
42+
}
43+
void setError(const std::string& msg) {
44+
std::lock_guard<std::mutex> g(mu_);
45+
error_ = msg;
46+
got_error_ = true;
47+
cv_.notify_all();
48+
}
49+
bool wait(std::chrono::milliseconds d = 2000ms) {
50+
std::unique_lock<std::mutex> g(mu_);
51+
return cv_.wait_for(g, d,
52+
[&] { return got_response_ || got_error_; });
53+
}
54+
bool hasResponse() const {
55+
std::lock_guard<std::mutex> g(mu_);
56+
return got_response_;
57+
}
58+
bool hasError() const {
59+
std::lock_guard<std::mutex> g(mu_);
60+
return got_error_;
61+
}
62+
HttpResponse response() const {
63+
std::lock_guard<std::mutex> g(mu_);
64+
return response_;
65+
}
66+
std::string error() const {
67+
std::lock_guard<std::mutex> g(mu_);
68+
return error_;
69+
}
70+
71+
private:
72+
mutable std::mutex mu_;
73+
std::condition_variable cv_;
74+
bool got_response_{false};
75+
bool got_error_{false};
76+
HttpResponse response_;
77+
std::string error_;
78+
};
79+
80+
class HttpAsyncClientTest : public test::RealListenerTestBase {
81+
protected:
82+
void SetUp() override { RealListenerTestBase::SetUp(); }
83+
84+
void TearDown() override {
85+
executeInDispatcher([this]() { client_.reset(); });
86+
RealListenerTestBase::TearDown();
87+
}
88+
89+
void createClient() {
90+
executeInDispatcher([this]() {
91+
client_ = std::make_unique<HttpAsyncClient>(
92+
*dispatcher_, network::socketInterface(),
93+
std::make_unique<network::RawBufferTransportSocketFactory>());
94+
});
95+
}
96+
97+
// Busy-accept a single pending connection — the listen socket is
98+
// non-blocking so we retry briefly.
99+
network::IoHandlePtr acceptOne(std::chrono::milliseconds budget = 2000ms) {
100+
const auto deadline = std::chrono::steady_clock::now() + budget;
101+
while (std::chrono::steady_clock::now() < deadline) {
102+
network::IoHandlePtr accepted;
103+
executeInDispatcher([this, &accepted]() {
104+
auto r = listen_handle_->accept();
105+
if (r.ok()) {
106+
accepted = std::move(*r);
107+
}
108+
});
109+
if (accepted) {
110+
return accepted;
111+
}
112+
std::this_thread::sleep_for(5ms);
113+
}
114+
return nullptr;
115+
}
116+
117+
// Read until we see the request line + headers terminator, then
118+
// slurp Content-Length bytes of body. IoHandle::read fills a Buffer,
119+
// which we drain to a std::string so the test can pattern-match.
120+
std::string readRequest(network::IoHandle& handle,
121+
std::chrono::milliseconds budget = 2000ms) {
122+
std::string data;
123+
const auto deadline = std::chrono::steady_clock::now() + budget;
124+
bool headers_done = false;
125+
size_t content_length = 0;
126+
size_t header_end = 0;
127+
128+
while (std::chrono::steady_clock::now() < deadline) {
129+
OwnedBuffer buf;
130+
auto r = handle.read(buf, /*max_length=*/4096);
131+
if (r.ok() && *r > 0) {
132+
data.append(buf.toString());
133+
} else {
134+
std::this_thread::sleep_for(5ms);
135+
}
136+
if (!headers_done) {
137+
auto pos = data.find("\r\n\r\n");
138+
if (pos != std::string::npos) {
139+
headers_done = true;
140+
header_end = pos + 4;
141+
auto cl_pos = data.find("Content-Length:");
142+
if (cl_pos != std::string::npos && cl_pos < header_end) {
143+
content_length =
144+
std::stoul(data.substr(cl_pos + 15, header_end - cl_pos - 15));
145+
}
146+
}
147+
}
148+
if (headers_done && data.size() >= header_end + content_length) {
149+
return data;
150+
}
151+
}
152+
return data;
153+
}
154+
155+
void writeResponse(network::IoHandle& handle, const std::string& bytes) {
156+
// IoHandle::write drains the buffer as bytes go on the wire. We
157+
// keep looping until the buffer is empty or the deadline fires so
158+
// partial sends don't silently truncate the fake response.
159+
OwnedBuffer buf;
160+
buf.add(bytes);
161+
const auto deadline = std::chrono::steady_clock::now() + 2000ms;
162+
while (buf.length() > 0 &&
163+
std::chrono::steady_clock::now() < deadline) {
164+
auto r = handle.write(buf);
165+
if (!r.ok() || *r == 0) {
166+
std::this_thread::sleep_for(5ms);
167+
}
168+
}
169+
}
170+
171+
std::unique_ptr<HttpAsyncClient> client_;
172+
};
173+
174+
TEST_F(HttpAsyncClientTest, PostRoundTripDeliversResponseBody) {
175+
uint16_t port = createRealListener();
176+
createClient();
177+
178+
ResponseSink sink;
179+
HttpRequest req;
180+
req.method = "POST";
181+
req.url = "http://127.0.0.1:" + std::to_string(port) + "/mcp";
182+
req.headers["Content-Type"] = "application/json";
183+
req.body = "{\"jsonrpc\":\"2.0\",\"method\":\"ping\"}";
184+
185+
executeInDispatcher([this, &req, &sink]() {
186+
const bool ok = client_->send(
187+
req,
188+
[&sink](HttpResponse r) { sink.setResponse(std::move(r)); },
189+
[&sink](const std::string& e) { sink.setError(e); });
190+
ASSERT_TRUE(ok);
191+
});
192+
193+
auto accepted = acceptOne();
194+
ASSERT_TRUE(accepted) << "server never saw the inbound POST";
195+
196+
const std::string request_wire = readRequest(*accepted);
197+
EXPECT_NE(request_wire.find("POST /mcp HTTP/1.1"), std::string::npos);
198+
EXPECT_NE(request_wire.find("Host: 127.0.0.1:"), std::string::npos);
199+
EXPECT_NE(request_wire.find("Content-Type: application/json"),
200+
std::string::npos);
201+
EXPECT_NE(request_wire.find(req.body), std::string::npos);
202+
203+
const std::string reply_body = "{\"result\":\"pong\"}";
204+
std::string reply = "HTTP/1.1 200 OK\r\n";
205+
reply += "Content-Type: application/json\r\n";
206+
reply += "Content-Length: " + std::to_string(reply_body.size()) + "\r\n";
207+
reply += "Connection: close\r\n\r\n";
208+
reply += reply_body;
209+
writeResponse(*accepted, reply);
210+
211+
ASSERT_TRUE(sink.wait());
212+
ASSERT_TRUE(sink.hasResponse()) << "error instead: " << sink.error();
213+
auto r = sink.response();
214+
EXPECT_EQ(r.status_code, 200);
215+
EXPECT_EQ(r.body, reply_body);
216+
EXPECT_EQ(r.headers["content-type"], "application/json");
217+
}
218+
219+
TEST_F(HttpAsyncClientTest, RejectsMalformedUrl) {
220+
createClient();
221+
bool cb_fired = false;
222+
executeInDispatcher([this, &cb_fired]() {
223+
HttpRequest req;
224+
req.method = "POST";
225+
req.url = "not-a-url";
226+
const bool ok = client_->send(
227+
req, [&](HttpResponse) { cb_fired = true; },
228+
[&](const std::string&) { cb_fired = true; });
229+
EXPECT_FALSE(ok);
230+
});
231+
// Contract: send returns false for a malformed URL and does NOT fire
232+
// either callback. Give the dispatcher a tick to prove nothing posts.
233+
std::this_thread::sleep_for(50ms);
234+
EXPECT_FALSE(cb_fired);
235+
}
236+
237+
TEST_F(HttpAsyncClientTest, MalformedResponseFiresErrorCallback) {
238+
uint16_t port = createRealListener();
239+
createClient();
240+
241+
// Shared with the callbacks so a late-firing callback (e.g. from
242+
// teardown draining the dispatcher) doesn't hit a destroyed stack
243+
// object.
244+
auto sink = std::make_shared<ResponseSink>();
245+
246+
executeInDispatcher([this, port, sink]() {
247+
HttpRequest req;
248+
req.method = "POST";
249+
req.url = "http://127.0.0.1:" + std::to_string(port) + "/mcp";
250+
req.body = "{}";
251+
ASSERT_TRUE(client_->send(
252+
req,
253+
[sink](HttpResponse r) { sink->setResponse(std::move(r)); },
254+
[sink](const std::string& e) { sink->setError(e); }));
255+
});
256+
257+
auto accepted = acceptOne();
258+
ASSERT_TRUE(accepted);
259+
// Consume the request so the client moves past writeRequestBytes.
260+
(void)readRequest(*accepted, 2000ms);
261+
// Reply with bytes that cannot be parsed as an HTTP/1.1 response
262+
// status line. The client codec surfaces this as onError, which
263+
// RequestContext forwards as an error callback.
264+
writeResponse(*accepted, "NOT AN HTTP RESPONSE\r\n\r\n");
265+
266+
ASSERT_TRUE(sink->wait());
267+
EXPECT_TRUE(sink->hasError());
268+
EXPECT_FALSE(sink->hasResponse());
269+
}
270+
271+
} // namespace
272+
} // namespace http
273+
} // namespace mcp

0 commit comments

Comments
 (0)