aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/zenhttp/httpclient_test.cpp1362
-rw-r--r--src/zenhttp/include/zenhttp/httpclient.h3
-rw-r--r--src/zenhttp/zenhttp.cpp1
-rw-r--r--xmake.lua4
4 files changed, 1367 insertions, 3 deletions
diff --git a/src/zenhttp/httpclient_test.cpp b/src/zenhttp/httpclient_test.cpp
new file mode 100644
index 000000000..509b56371
--- /dev/null
+++ b/src/zenhttp/httpclient_test.cpp
@@ -0,0 +1,1362 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+#include <zenhttp/httpclient.h>
+#include <zenhttp/httpserver.h>
+
+#if ZEN_WITH_TESTS
+
+# include <zencore/compactbinarybuilder.h>
+# include <zencore/compactbinaryutil.h>
+# include <zencore/compositebuffer.h>
+# include <zencore/iobuffer.h>
+# include <zencore/logging.h>
+# include <zencore/scopeguard.h>
+# include <zencore/session.h>
+# include <zencore/testing.h>
+# include <zencore/testutils.h>
+
+# include "servers/httpasio.h"
+
+# include <atomic>
+# include <thread>
+
+ZEN_THIRD_PARTY_INCLUDES_START
+# include <asio.hpp>
+ZEN_THIRD_PARTY_INCLUDES_END
+
+namespace zen {
+
+using namespace std::literals;
+
+//////////////////////////////////////////////////////////////////////////
+// Test service
+
+class HttpClientTestService : public HttpService
+{
+public:
+ HttpClientTestService()
+ {
+ m_Router.AddMatcher("statuscode", [](std::string_view Str) -> bool {
+ for (char C : Str)
+ {
+ if (C < '0' || C > '9')
+ {
+ return false;
+ }
+ }
+ return !Str.empty();
+ });
+
+ m_Router.RegisterRoute(
+ "hello",
+ [](HttpRouterRequest& Req) { Req.ServerRequest().WriteResponse(HttpResponseCode::OK, HttpContentType::kText, "hello world"); },
+ HttpVerb::kGet);
+
+ m_Router.RegisterRoute(
+ "echo",
+ [](HttpRouterRequest& Req) {
+ HttpServerRequest& HttpReq = Req.ServerRequest();
+ IoBuffer Body = HttpReq.ReadPayload();
+ HttpContentType CT = HttpReq.RequestContentType();
+ HttpReq.WriteResponse(HttpResponseCode::OK, CT, Body);
+ },
+ HttpVerb::kPost | HttpVerb::kPut);
+
+ m_Router.RegisterRoute(
+ "echo/headers",
+ [](HttpRouterRequest& Req) {
+ HttpServerRequest& HttpReq = Req.ServerRequest();
+ std::string_view Auth = HttpReq.GetAuthorizationHeader();
+ CbObjectWriter Writer;
+ if (!Auth.empty())
+ {
+ Writer.AddString("Authorization", Auth);
+ }
+ HttpReq.WriteResponse(HttpResponseCode::OK, Writer.Save());
+ },
+ HttpVerb::kGet | HttpVerb::kPost);
+
+ m_Router.RegisterRoute(
+ "echo/method",
+ [](HttpRouterRequest& Req) {
+ HttpServerRequest& HttpReq = Req.ServerRequest();
+ std::string_view Method = ToString(HttpReq.RequestVerb());
+ HttpReq.WriteResponse(HttpResponseCode::OK, HttpContentType::kText, Method);
+ },
+ HttpVerb::kGet | HttpVerb::kPost | HttpVerb::kPut | HttpVerb::kDelete | HttpVerb::kHead);
+
+ m_Router.RegisterRoute(
+ "json",
+ [](HttpRouterRequest& Req) {
+ CbObjectWriter Obj;
+ Obj.AddBool("ok", true);
+ Obj.AddString("message", "test");
+ Req.ServerRequest().WriteResponse(HttpResponseCode::OK, Obj.Save());
+ },
+ HttpVerb::kGet);
+
+ m_Router.RegisterRoute(
+ "nocontent",
+ [](HttpRouterRequest& Req) { Req.ServerRequest().WriteResponse(HttpResponseCode::NoContent); },
+ HttpVerb::kGet | HttpVerb::kPost | HttpVerb::kPut | HttpVerb::kDelete);
+
+ m_Router.RegisterRoute(
+ "created",
+ [](HttpRouterRequest& Req) {
+ Req.ServerRequest().WriteResponse(HttpResponseCode::Created, HttpContentType::kText, "resource created");
+ },
+ HttpVerb::kPost | HttpVerb::kPut);
+
+ m_Router.RegisterRoute(
+ "content-type/text",
+ [](HttpRouterRequest& Req) { Req.ServerRequest().WriteResponse(HttpResponseCode::OK, HttpContentType::kText, "plain text"); },
+ HttpVerb::kGet);
+
+ m_Router.RegisterRoute(
+ "content-type/json",
+ [](HttpRouterRequest& Req) {
+ Req.ServerRequest().WriteResponse(HttpResponseCode::OK, HttpContentType::kJSON, "{\"key\":\"value\"}");
+ },
+ HttpVerb::kGet);
+
+ m_Router.RegisterRoute(
+ "content-type/binary",
+ [](HttpRouterRequest& Req) {
+ uint8_t Data[] = {0xDE, 0xAD, 0xBE, 0xEF};
+ IoBuffer Buf(IoBuffer::Clone, Data, sizeof(Data));
+ Req.ServerRequest().WriteResponse(HttpResponseCode::OK, HttpContentType::kBinary, Buf);
+ },
+ HttpVerb::kGet);
+
+ m_Router.RegisterRoute(
+ "content-type/cbobject",
+ [](HttpRouterRequest& Req) {
+ CbObjectWriter Obj;
+ Obj.AddString("type", "cbobject");
+ Req.ServerRequest().WriteResponse(HttpResponseCode::OK, Obj.Save());
+ },
+ HttpVerb::kGet);
+
+ m_Router.RegisterRoute(
+ "auth/bearer",
+ [](HttpRouterRequest& Req) {
+ HttpServerRequest& HttpReq = Req.ServerRequest();
+ std::string_view Auth = HttpReq.GetAuthorizationHeader();
+ if (Auth.starts_with("Bearer ") && Auth.size() > 7)
+ {
+ HttpReq.WriteResponse(HttpResponseCode::OK, HttpContentType::kText, "authenticated");
+ }
+ else
+ {
+ HttpReq.WriteResponse(HttpResponseCode::Unauthorized, HttpContentType::kText, "unauthorized");
+ }
+ },
+ HttpVerb::kGet);
+
+ m_Router.RegisterRoute(
+ "slow",
+ [](HttpRouterRequest& Req) {
+ Req.ServerRequest().WriteResponseAsync([](HttpServerRequest& Request) {
+ Sleep(2000);
+ Request.WriteResponse(HttpResponseCode::OK, HttpContentType::kText, "slow response");
+ });
+ },
+ HttpVerb::kGet);
+
+ m_Router.RegisterRoute(
+ "large",
+ [](HttpRouterRequest& Req) {
+ constexpr size_t Size = 64 * 1024;
+ IoBuffer Buf(Size);
+ uint8_t* Ptr = static_cast<uint8_t*>(Buf.MutableData());
+ for (size_t i = 0; i < Size; ++i)
+ {
+ Ptr[i] = static_cast<uint8_t>(i & 0xFF);
+ }
+ Req.ServerRequest().WriteResponse(HttpResponseCode::OK, HttpContentType::kBinary, Buf);
+ },
+ HttpVerb::kGet);
+
+ m_Router.RegisterRoute(
+ "status/{statuscode}",
+ [](HttpRouterRequest& Req) {
+ std::string_view CodeStr = Req.GetCapture(1);
+ int Code = std::stoi(std::string{CodeStr});
+ const HttpResponseCode ResponseCode = static_cast<HttpResponseCode>(Code);
+ Req.ServerRequest().WriteResponse(ResponseCode);
+ },
+ HttpVerb::kGet | HttpVerb::kPost | HttpVerb::kPut | HttpVerb::kDelete | HttpVerb::kHead);
+
+ m_Router.RegisterRoute(
+ "attempt-counter",
+ [this](HttpRouterRequest& Req) {
+ uint32_t Count = m_AttemptCounter.fetch_add(1);
+ if (Count < m_FailCount)
+ {
+ Req.ServerRequest().WriteResponse(HttpResponseCode::ServiceUnavailable);
+ }
+ else
+ {
+ Req.ServerRequest().WriteResponse(HttpResponseCode::OK, HttpContentType::kText, "success after retries");
+ }
+ },
+ HttpVerb::kGet);
+ }
+
+ virtual const char* BaseUri() const override { return "/api/test/"; }
+ virtual void HandleRequest(HttpServerRequest& Request) override { m_Router.HandleRequest(Request); }
+
+ void ResetAttemptCounter(uint32_t FailCount)
+ {
+ m_AttemptCounter.store(0);
+ m_FailCount = FailCount;
+ }
+
+private:
+ HttpRequestRouter m_Router;
+ std::atomic<uint32_t> m_AttemptCounter{0};
+ uint32_t m_FailCount = 2;
+};
+
+//////////////////////////////////////////////////////////////////////////
+// Test server fixture
+
+struct TestServerFixture
+{
+ HttpClientTestService TestService;
+ ScopedTemporaryDirectory TmpDir;
+ Ref<HttpServer> Server;
+ std::thread ServerThread;
+ int Port = -1;
+
+ TestServerFixture()
+ {
+ Server = CreateHttpAsioServer(AsioConfig{});
+ Port = Server->Initialize(7600, TmpDir.Path());
+ ZEN_ASSERT(Port != -1);
+ Server->RegisterService(TestService);
+ ServerThread = std::thread([this]() { Server->Run(false); });
+ }
+
+ ~TestServerFixture()
+ {
+ Server->RequestExit();
+ if (ServerThread.joinable())
+ {
+ ServerThread.join();
+ }
+ Server->Close();
+ }
+
+ HttpClient MakeClient(HttpClientSettings Settings = {})
+ {
+ return HttpClient(fmt::format("127.0.0.1:{}", Port), Settings, /*CheckIfAbortFunction*/ {});
+ }
+};
+
+//////////////////////////////////////////////////////////////////////////
+// Tests
+
+TEST_CASE("httpclient.verbs")
+{
+ TestServerFixture Fixture;
+ HttpClient Client = Fixture.MakeClient();
+
+ SUBCASE("GET returns 200 with expected body")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/echo/method");
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "GET");
+ }
+
+ SUBCASE("POST dispatches correctly")
+ {
+ HttpClient::Response Resp = Client.Post("/api/test/echo/method");
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "POST");
+ }
+
+ SUBCASE("PUT dispatches correctly")
+ {
+ HttpClient::Response Resp = Client.Put("/api/test/echo/method");
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "PUT");
+ }
+
+ SUBCASE("DELETE dispatches correctly")
+ {
+ HttpClient::Response Resp = Client.Delete("/api/test/echo/method");
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "DELETE");
+ }
+
+ SUBCASE("HEAD returns 200 with empty body")
+ {
+ HttpClient::Response Resp = Client.Head("/api/test/echo/method");
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), ""sv);
+ }
+}
+
+TEST_CASE("httpclient.get")
+{
+ TestServerFixture Fixture;
+ HttpClient Client = Fixture.MakeClient();
+
+ SUBCASE("simple GET with text response")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/hello");
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.StatusCode, HttpResponseCode::OK);
+ CHECK_EQ(Resp.AsText(), "hello world");
+ }
+
+ SUBCASE("GET with auth header via echo")
+ {
+ HttpClient::Response Resp =
+ Client.Get("/api/test/echo/headers", std::pair<std::string, std::string>("Authorization", "Bearer test-token-123"));
+ CHECK(Resp.IsSuccess());
+ CbObject Obj = Resp.AsObject();
+ CHECK_EQ(Obj["Authorization"].AsString(), "Bearer test-token-123");
+ }
+
+ SUBCASE("GET returning CbObject")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/json");
+ CHECK(Resp.IsSuccess());
+ CbObject Obj = Resp.AsObject();
+ CHECK(Obj["ok"].AsBool() == true);
+ CHECK_EQ(Obj["message"].AsString(), "test");
+ }
+
+ SUBCASE("GET large payload")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/large");
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.ResponsePayload.GetSize(), 64u * 1024u);
+
+ const uint8_t* Data = static_cast<const uint8_t*>(Resp.ResponsePayload.GetData());
+ bool Valid = true;
+ for (size_t i = 0; i < 64 * 1024; ++i)
+ {
+ if (Data[i] != static_cast<uint8_t>(i & 0xFF))
+ {
+ Valid = false;
+ break;
+ }
+ }
+ CHECK(Valid);
+ }
+}
+
+TEST_CASE("httpclient.post")
+{
+ TestServerFixture Fixture;
+ HttpClient Client = Fixture.MakeClient();
+
+ SUBCASE("POST with IoBuffer payload echo round-trip")
+ {
+ const char* Payload = "test payload data";
+ IoBuffer Buf(IoBuffer::Clone, Payload, strlen(Payload));
+ Buf.SetContentType(ZenContentType::kText);
+
+ HttpClient::Response Resp = Client.Post("/api/test/echo", Buf);
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "test payload data");
+ }
+
+ SUBCASE("POST with IoBuffer and explicit content type")
+ {
+ const char* Payload = "{\"key\":\"value\"}";
+ IoBuffer Buf(IoBuffer::Clone, Payload, strlen(Payload));
+
+ HttpClient::Response Resp = Client.Post("/api/test/echo", Buf, ZenContentType::kJSON);
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "{\"key\":\"value\"}");
+ }
+
+ SUBCASE("POST with CbObject payload round-trip")
+ {
+ CbObjectWriter Writer;
+ Writer.AddBool("enabled", true);
+ Writer.AddString("name", "testobj");
+ CbObject Obj = Writer.Save();
+
+ HttpClient::Response Resp = Client.Post("/api/test/echo", Obj);
+ CHECK(Resp.IsSuccess());
+ CbObject RoundTripped = Resp.AsObject();
+ CHECK(RoundTripped["enabled"].AsBool() == true);
+ CHECK_EQ(RoundTripped["name"].AsString(), "testobj");
+ }
+
+ SUBCASE("POST with CompositeBuffer payload")
+ {
+ const char* Part1 = "hello ";
+ const char* Part2 = "composite";
+ IoBuffer Buf1(IoBuffer::Clone, Part1, strlen(Part1));
+ IoBuffer Buf2(IoBuffer::Clone, Part2, strlen(Part2));
+
+ SharedBuffer Seg1{Buf1};
+ SharedBuffer Seg2{Buf2};
+ CompositeBuffer Composite{std::move(Seg1), std::move(Seg2)};
+
+ HttpClient::Response Resp = Client.Post("/api/test/echo", Composite, ZenContentType::kText);
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "hello composite");
+ }
+
+ SUBCASE("POST with custom headers")
+ {
+ HttpClient::Response Resp = Client.Post("/api/test/echo/headers", HttpClient::KeyValueMap{}, HttpClient::KeyValueMap{});
+ CHECK(Resp.IsSuccess());
+ }
+
+ SUBCASE("POST with empty body to nocontent endpoint")
+ {
+ HttpClient::Response Resp = Client.Post("/api/test/nocontent");
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.StatusCode, HttpResponseCode::NoContent);
+ }
+}
+
+TEST_CASE("httpclient.put")
+{
+ TestServerFixture Fixture;
+ HttpClient Client = Fixture.MakeClient();
+
+ SUBCASE("PUT with IoBuffer payload echo round-trip")
+ {
+ const char* Payload = "put payload data";
+ IoBuffer Buf(IoBuffer::Clone, Payload, strlen(Payload));
+ Buf.SetContentType(ZenContentType::kText);
+
+ HttpClient::Response Resp = Client.Put("/api/test/echo", Buf);
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "put payload data");
+ }
+
+ SUBCASE("PUT with parameters only")
+ {
+ HttpClient::Response Resp = Client.Put("/api/test/nocontent");
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.StatusCode, HttpResponseCode::NoContent);
+ }
+
+ SUBCASE("PUT to created endpoint")
+ {
+ const char* Payload = "new resource";
+ IoBuffer Buf(IoBuffer::Clone, Payload, strlen(Payload));
+ Buf.SetContentType(ZenContentType::kText);
+
+ HttpClient::Response Resp = Client.Put("/api/test/created", Buf);
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.StatusCode, HttpResponseCode::Created);
+ CHECK_EQ(Resp.AsText(), "resource created");
+ }
+}
+
+TEST_CASE("httpclient.upload")
+{
+ TestServerFixture Fixture;
+ HttpClient Client = Fixture.MakeClient();
+
+ SUBCASE("Upload IoBuffer")
+ {
+ constexpr size_t Size = 128 * 1024;
+ IoBuffer Blob = CreateSemiRandomBlob(Size);
+
+ HttpClient::Response Resp = Client.Upload("/api/test/echo", Blob);
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.ResponsePayload.GetSize(), Size);
+ }
+
+ SUBCASE("Upload CompositeBuffer")
+ {
+ IoBuffer Buf1 = CreateSemiRandomBlob(32 * 1024);
+ IoBuffer Buf2 = CreateSemiRandomBlob(32 * 1024);
+
+ SharedBuffer Seg1{Buf1};
+ SharedBuffer Seg2{Buf2};
+ CompositeBuffer Composite{std::move(Seg1), std::move(Seg2)};
+
+ HttpClient::Response Resp = Client.Upload("/api/test/echo", Composite, ZenContentType::kBinary);
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.ResponsePayload.GetSize(), 64u * 1024u);
+ }
+}
+
+TEST_CASE("httpclient.download")
+{
+ TestServerFixture Fixture;
+ ScopedTemporaryDirectory DownloadDir;
+
+ SUBCASE("Download small payload stays in memory")
+ {
+ HttpClient Client = Fixture.MakeClient();
+
+ HttpClient::Response Resp = Client.Download("/api/test/hello", DownloadDir.Path());
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "hello world");
+ }
+
+ SUBCASE("Download with reduced MaximumInMemoryDownloadSize forces file spill")
+ {
+ HttpClientSettings Settings;
+ Settings.MaximumInMemoryDownloadSize = 4;
+ HttpClient Client = Fixture.MakeClient(Settings);
+
+ HttpClient::Response Resp = Client.Download("/api/test/large", DownloadDir.Path());
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.ResponsePayload.GetSize(), 64u * 1024u);
+ }
+}
+
+TEST_CASE("httpclient.status-codes")
+{
+ TestServerFixture Fixture;
+ HttpClient Client = Fixture.MakeClient();
+
+ SUBCASE("2xx are success")
+ {
+ CHECK(Client.Get("/api/test/status/200").IsSuccess());
+ CHECK(Client.Get("/api/test/status/201").IsSuccess());
+ CHECK(Client.Get("/api/test/status/204").IsSuccess());
+ }
+
+ SUBCASE("4xx are not success")
+ {
+ CHECK(!Client.Get("/api/test/status/400").IsSuccess());
+ CHECK(!Client.Get("/api/test/status/401").IsSuccess());
+ CHECK(!Client.Get("/api/test/status/403").IsSuccess());
+ CHECK(!Client.Get("/api/test/status/404").IsSuccess());
+ CHECK(!Client.Get("/api/test/status/409").IsSuccess());
+ }
+
+ SUBCASE("5xx are not success")
+ {
+ CHECK(!Client.Get("/api/test/status/500").IsSuccess());
+ CHECK(!Client.Get("/api/test/status/502").IsSuccess());
+ CHECK(!Client.Get("/api/test/status/503").IsSuccess());
+ }
+
+ SUBCASE("status code values match")
+ {
+ CHECK_EQ(Client.Get("/api/test/status/200").StatusCode, HttpResponseCode::OK);
+ CHECK_EQ(Client.Get("/api/test/status/201").StatusCode, HttpResponseCode::Created);
+ CHECK_EQ(Client.Get("/api/test/status/204").StatusCode, HttpResponseCode::NoContent);
+ CHECK_EQ(Client.Get("/api/test/status/400").StatusCode, HttpResponseCode::BadRequest);
+ CHECK_EQ(Client.Get("/api/test/status/401").StatusCode, HttpResponseCode::Unauthorized);
+ CHECK_EQ(Client.Get("/api/test/status/403").StatusCode, HttpResponseCode::Forbidden);
+ CHECK_EQ(Client.Get("/api/test/status/404").StatusCode, HttpResponseCode::NotFound);
+ CHECK_EQ(Client.Get("/api/test/status/409").StatusCode, HttpResponseCode::Conflict);
+ CHECK_EQ(Client.Get("/api/test/status/500").StatusCode, HttpResponseCode::InternalServerError);
+ CHECK_EQ(Client.Get("/api/test/status/502").StatusCode, HttpResponseCode::BadGateway);
+ CHECK_EQ(Client.Get("/api/test/status/503").StatusCode, HttpResponseCode::ServiceUnavailable);
+ }
+}
+
+TEST_CASE("httpclient.response")
+{
+ TestServerFixture Fixture;
+ HttpClient Client = Fixture.MakeClient();
+
+ SUBCASE("IsSuccess and operator bool for success")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/hello");
+ CHECK(Resp.IsSuccess());
+ CHECK(static_cast<bool>(Resp));
+ }
+
+ SUBCASE("IsSuccess and operator bool for failure")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/status/404");
+ CHECK(!Resp.IsSuccess());
+ CHECK(!static_cast<bool>(Resp));
+ }
+
+ SUBCASE("AsText returns body")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/hello");
+ CHECK_EQ(Resp.AsText(), "hello world");
+ }
+
+ SUBCASE("AsText returns empty for no-content")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/nocontent");
+ CHECK(Resp.AsText().empty());
+ }
+
+ SUBCASE("AsObject parses CbObject")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/json");
+ CbObject Obj = Resp.AsObject();
+ CHECK(Obj["ok"].AsBool() == true);
+ CHECK_EQ(Obj["message"].AsString(), "test");
+ }
+
+ SUBCASE("AsObject returns empty for non-CB content")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/hello");
+ CbObject Obj = Resp.AsObject();
+ CHECK(!Obj);
+ }
+
+ SUBCASE("ToText for text content")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/content-type/text");
+ CHECK_EQ(Resp.ToText(), "plain text");
+ }
+
+ SUBCASE("ToText for CbObject content")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/json");
+ std::string Text = Resp.ToText();
+ CHECK(!Text.empty());
+ // ToText for CbObject converts to JSON string representation
+ CHECK(Text.find("ok") != std::string::npos);
+ CHECK(Text.find("test") != std::string::npos);
+ }
+
+ SUBCASE("ErrorMessage includes status code on failure")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/status/404");
+ std::string Msg = Resp.ErrorMessage("test-prefix");
+ CHECK(Msg.find("test-prefix") != std::string::npos);
+ CHECK(Msg.find("404") != std::string::npos);
+ }
+
+ SUBCASE("ThrowError throws on failure")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/status/500");
+ CHECK_THROWS_AS(Resp.ThrowError("test"), HttpClientError);
+ }
+
+ SUBCASE("ThrowError does not throw on success")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/hello");
+ CHECK_NOTHROW(Resp.ThrowError("test"));
+ }
+
+ SUBCASE("HttpClientError carries response code")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/status/403");
+ try
+ {
+ Resp.ThrowError("test");
+ CHECK(false); // should not reach
+ }
+ catch (const HttpClientError& Err)
+ {
+ CHECK_EQ(Err.GetHttpResponseCode(), HttpResponseCode::Forbidden);
+ }
+ }
+}
+
+TEST_CASE("httpclient.error-handling")
+{
+ SUBCASE("Connection refused")
+ {
+ HttpClient Client("127.0.0.1:19999", HttpClientSettings{}, /*CheckIfAbortFunction*/ {});
+ HttpClient::Response Resp = Client.Get("/api/test/hello");
+ CHECK(!Resp.IsSuccess());
+ CHECK(Resp.Error.has_value());
+ }
+
+ SUBCASE("Request timeout")
+ {
+ TestServerFixture Fixture;
+ HttpClientSettings Settings;
+ Settings.Timeout = std::chrono::milliseconds(500);
+ HttpClient Client = Fixture.MakeClient(Settings);
+
+ HttpClient::Response Resp = Client.Get("/api/test/slow");
+ CHECK(!Resp.IsSuccess());
+ }
+
+ SUBCASE("Nonexistent endpoint returns failure")
+ {
+ TestServerFixture Fixture;
+ HttpClient Client = Fixture.MakeClient();
+
+ HttpClient::Response Resp = Client.Get("/api/test/does-not-exist");
+ CHECK(!Resp.IsSuccess());
+ }
+}
+
+TEST_CASE("httpclient.session")
+{
+ TestServerFixture Fixture;
+
+ SUBCASE("Default session ID is non-empty")
+ {
+ HttpClient Client = Fixture.MakeClient();
+ CHECK(!Client.GetSessionId().empty());
+ }
+
+ SUBCASE("SetSessionId changes ID")
+ {
+ HttpClient Client = Fixture.MakeClient();
+ Oid NewId = Oid::NewOid();
+ std::string OldId = std::string(Client.GetSessionId());
+ Client.SetSessionId(NewId);
+ CHECK_EQ(Client.GetSessionId(), NewId.ToString());
+ CHECK_NE(Client.GetSessionId(), OldId);
+ }
+
+ SUBCASE("SetSessionId with Zero resets")
+ {
+ HttpClient Client = Fixture.MakeClient();
+ Oid NewId = Oid::NewOid();
+ Client.SetSessionId(NewId);
+ CHECK_EQ(Client.GetSessionId(), NewId.ToString());
+ Client.SetSessionId(Oid::Zero);
+ // After resetting, should get a session string (not empty, not the custom one)
+ CHECK(!Client.GetSessionId().empty());
+ CHECK_NE(Client.GetSessionId(), NewId.ToString());
+ }
+}
+
+TEST_CASE("httpclient.authentication")
+{
+ TestServerFixture Fixture;
+
+ SUBCASE("Authenticate returns false without provider")
+ {
+ HttpClient Client = Fixture.MakeClient();
+ CHECK(!Client.Authenticate());
+ }
+
+ SUBCASE("Authenticate returns true with valid token")
+ {
+ HttpClientSettings Settings;
+ Settings.AccessTokenProvider = []() -> HttpClientAccessToken {
+ return HttpClientAccessToken{
+ .Value = "valid-token",
+ .ExpireTime = HttpClientAccessToken::Clock::now() + std::chrono::hours(1),
+ };
+ };
+ HttpClient Client = Fixture.MakeClient(Settings);
+ CHECK(Client.Authenticate());
+ }
+
+ SUBCASE("Authenticate returns false with expired token")
+ {
+ HttpClientSettings Settings;
+ Settings.AccessTokenProvider = []() -> HttpClientAccessToken {
+ return HttpClientAccessToken{
+ .Value = "expired-token",
+ .ExpireTime = HttpClientAccessToken::Clock::now() - std::chrono::hours(1),
+ };
+ };
+ HttpClient Client = Fixture.MakeClient(Settings);
+ CHECK(!Client.Authenticate());
+ }
+
+ SUBCASE("Bearer token verified by auth endpoint")
+ {
+ HttpClient Client = Fixture.MakeClient();
+
+ HttpClient::Response AuthResp =
+ Client.Get("/api/test/auth/bearer", std::pair<std::string, std::string>("Authorization", "Bearer my-secret-token"));
+ CHECK(AuthResp.IsSuccess());
+ CHECK_EQ(AuthResp.AsText(), "authenticated");
+ }
+
+ SUBCASE("Request without token to auth endpoint gets 401")
+ {
+ HttpClient Client = Fixture.MakeClient();
+
+ HttpClient::Response Resp = Client.Get("/api/test/auth/bearer");
+ CHECK(!Resp.IsSuccess());
+ CHECK_EQ(Resp.StatusCode, HttpResponseCode::Unauthorized);
+ }
+}
+
+TEST_CASE("httpclient.content-types")
+{
+ TestServerFixture Fixture;
+ HttpClient Client = Fixture.MakeClient();
+
+ SUBCASE("text content type")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/content-type/text");
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.ResponsePayload.GetContentType(), ZenContentType::kText);
+ }
+
+ SUBCASE("JSON content type")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/content-type/json");
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.ResponsePayload.GetContentType(), ZenContentType::kJSON);
+ }
+
+ SUBCASE("binary content type")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/content-type/binary");
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.ResponsePayload.GetContentType(), ZenContentType::kBinary);
+ }
+
+ SUBCASE("CbObject content type")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/content-type/cbobject");
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.ResponsePayload.GetContentType(), ZenContentType::kCbObject);
+ }
+}
+
+TEST_CASE("httpclient.metadata")
+{
+ TestServerFixture Fixture;
+ HttpClient Client = Fixture.MakeClient();
+
+ SUBCASE("ElapsedSeconds is positive")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/hello");
+ CHECK(Resp.IsSuccess());
+ CHECK(Resp.ElapsedSeconds > 0.0);
+ }
+
+ SUBCASE("DownloadedBytes populated for GET")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/hello");
+ CHECK(Resp.IsSuccess());
+ CHECK(Resp.DownloadedBytes > 0);
+ }
+
+ SUBCASE("UploadedBytes populated for POST with payload")
+ {
+ const char* Payload = "some upload data";
+ IoBuffer Buf(IoBuffer::Clone, Payload, strlen(Payload));
+ Buf.SetContentType(ZenContentType::kText);
+
+ HttpClient::Response Resp = Client.Post("/api/test/echo", Buf);
+ CHECK(Resp.IsSuccess());
+ CHECK(Resp.UploadedBytes > 0);
+ }
+}
+
+TEST_CASE("httpclient.retry")
+{
+ TestServerFixture Fixture;
+
+ SUBCASE("Retry succeeds after transient failures")
+ {
+ Fixture.TestService.ResetAttemptCounter(2);
+
+ HttpClientSettings Settings;
+ Settings.RetryCount = 3;
+ HttpClient Client = Fixture.MakeClient(Settings);
+
+ HttpClient::Response Resp = Client.Get("/api/test/attempt-counter");
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "success after retries");
+ }
+
+ SUBCASE("No retry returns 503 immediately")
+ {
+ Fixture.TestService.ResetAttemptCounter(2);
+
+ HttpClientSettings Settings;
+ Settings.RetryCount = 0;
+ HttpClient Client = Fixture.MakeClient(Settings);
+
+ HttpClient::Response Resp = Client.Get("/api/test/attempt-counter");
+ CHECK(!Resp.IsSuccess());
+ CHECK_EQ(Resp.StatusCode, HttpResponseCode::ServiceUnavailable);
+ }
+}
+
+TEST_CASE("httpclient.measurelatency")
+{
+ SUBCASE("Successful measurement against live server")
+ {
+ TestServerFixture Fixture;
+ HttpClient Client = Fixture.MakeClient();
+
+ LatencyTestResult Result = MeasureLatency(Client, "/api/test/hello");
+ CHECK(Result.Success);
+ CHECK(Result.LatencySeconds > 0.0);
+ }
+
+ SUBCASE("Failed measurement against unreachable port")
+ {
+ HttpClient Client("127.0.0.1:19999", HttpClientSettings{}, /*CheckIfAbortFunction*/ {});
+ LatencyTestResult Result = MeasureLatency(Client, "/api/test/hello");
+ CHECK(!Result.Success);
+ CHECK(!Result.FailureReason.empty());
+ }
+}
+
+TEST_CASE("httpclient.keyvaluemap")
+{
+ SUBCASE("Default construction is empty")
+ {
+ HttpClient::KeyValueMap Map;
+ CHECK(Map->empty());
+ }
+
+ SUBCASE("Construction from pair")
+ {
+ HttpClient::KeyValueMap Map(std::pair<std::string, std::string>("key", "value"));
+ CHECK_EQ(Map->size(), 1u);
+ CHECK_EQ(Map->at("key"), "value");
+ }
+
+ SUBCASE("Construction from string_view pair")
+ {
+ HttpClient::KeyValueMap Map(std::pair<std::string_view, std::string_view>("key"sv, "value"sv));
+ CHECK_EQ(Map->size(), 1u);
+ CHECK_EQ(Map->at("key"), "value");
+ }
+
+ SUBCASE("Construction from initializer list")
+ {
+ HttpClient::KeyValueMap Map({{"a"sv, "1"sv}, {"b"sv, "2"sv}});
+ CHECK_EQ(Map->size(), 2u);
+ CHECK_EQ(Map->at("a"), "1");
+ CHECK_EQ(Map->at("b"), "2");
+ }
+}
+
+//////////////////////////////////////////////////////////////////////////
+// Transport fault testing
+
+static std::string
+MakeRawHttpResponse(int StatusCode, std::string_view Body)
+{
+ return fmt::format(
+ "HTTP/1.1 {} OK\r\n"
+ "Content-Type: text/plain\r\n"
+ "Content-Length: {}\r\n"
+ "\r\n"
+ "{}",
+ StatusCode,
+ Body.size(),
+ Body);
+}
+
+static std::string
+MakeRawHttpHeaders(int StatusCode, size_t ContentLength)
+{
+ return fmt::format(
+ "HTTP/1.1 {} OK\r\n"
+ "Content-Type: application/octet-stream\r\n"
+ "Content-Length: {}\r\n"
+ "\r\n",
+ StatusCode,
+ ContentLength);
+}
+
+static void
+DrainHttpRequest(asio::ip::tcp::socket& Socket)
+{
+ asio::streambuf Buf;
+ std::error_code Ec;
+ asio::read_until(Socket, Buf, "\r\n\r\n", Ec);
+}
+
+static void
+DrainFullHttpRequest(asio::ip::tcp::socket& Socket)
+{
+ // Read until end of headers
+ asio::streambuf Buf;
+ std::error_code Ec;
+ asio::read_until(Socket, Buf, "\r\n\r\n", Ec);
+ if (Ec)
+ {
+ return;
+ }
+
+ // Extract headers to find Content-Length
+ std::string Headers(asio::buffers_begin(Buf.data()), asio::buffers_end(Buf.data()));
+
+ size_t ContentLength = 0;
+ auto Pos = Headers.find("Content-Length: ");
+ if (Pos == std::string::npos)
+ {
+ Pos = Headers.find("content-length: ");
+ }
+ if (Pos != std::string::npos)
+ {
+ size_t ValStart = Pos + 16; // length of "Content-Length: "
+ size_t ValEnd = Headers.find("\r\n", ValStart);
+ if (ValEnd != std::string::npos)
+ {
+ ContentLength = std::stoull(Headers.substr(ValStart, ValEnd - ValStart));
+ }
+ }
+
+ // Calculate how many body bytes were already read past the header boundary.
+ // asio::read_until may read past the delimiter, so Buf.data() contains everything read.
+ size_t HeaderEnd = Headers.find("\r\n\r\n") + 4;
+ size_t BodyBytesInBuf = Headers.size() > HeaderEnd ? Headers.size() - HeaderEnd : 0;
+ size_t Remaining = ContentLength > BodyBytesInBuf ? ContentLength - BodyBytesInBuf : 0;
+
+ if (Remaining > 0)
+ {
+ std::vector<char> BodyBuf(Remaining);
+ asio::read(Socket, asio::buffer(BodyBuf), Ec);
+ }
+}
+
+static void
+DrainPartialBody(asio::ip::tcp::socket& Socket, size_t BytesToRead)
+{
+ // Read headers first
+ asio::streambuf Buf;
+ std::error_code Ec;
+ asio::read_until(Socket, Buf, "\r\n\r\n", Ec);
+ if (Ec)
+ {
+ return;
+ }
+
+ // Determine how many body bytes were already buffered past headers
+ std::string All(asio::buffers_begin(Buf.data()), asio::buffers_end(Buf.data()));
+ size_t HeaderEnd = All.find("\r\n\r\n") + 4;
+ size_t BodyBytesInBuf = All.size() > HeaderEnd ? All.size() - HeaderEnd : 0;
+
+ if (BodyBytesInBuf < BytesToRead)
+ {
+ size_t Remaining = BytesToRead - BodyBytesInBuf;
+ std::vector<char> BodyBuf(Remaining);
+ asio::read(Socket, asio::buffer(BodyBuf), Ec);
+ }
+}
+
+struct FaultTcpServer
+{
+ using FaultHandler = std::function<void(asio::ip::tcp::socket&)>;
+
+ asio::io_context m_IoContext;
+ asio::ip::tcp::acceptor m_Acceptor;
+ FaultHandler m_Handler;
+ std::thread m_Thread;
+ int m_Port;
+
+ explicit FaultTcpServer(FaultHandler Handler)
+ : m_Acceptor(m_IoContext, asio::ip::tcp::endpoint(asio::ip::address_v4::loopback(), 0))
+ , m_Handler(std::move(Handler))
+ {
+ m_Port = m_Acceptor.local_endpoint().port();
+ StartAccept();
+ m_Thread = std::thread([this]() { m_IoContext.run(); });
+ }
+
+ ~FaultTcpServer()
+ {
+ std::error_code Ec;
+ m_Acceptor.close(Ec);
+ m_IoContext.stop();
+ if (m_Thread.joinable())
+ {
+ m_Thread.join();
+ }
+ }
+
+ FaultTcpServer(const FaultTcpServer&) = delete;
+ FaultTcpServer& operator=(const FaultTcpServer&) = delete;
+
+ void StartAccept()
+ {
+ m_Acceptor.async_accept([this](std::error_code Ec, asio::ip::tcp::socket Socket) {
+ if (!Ec)
+ {
+ m_Handler(Socket);
+ }
+ if (m_Acceptor.is_open())
+ {
+ StartAccept();
+ }
+ });
+ }
+
+ HttpClient MakeClient(HttpClientSettings Settings = {})
+ {
+ return HttpClient(fmt::format("127.0.0.1:{}", m_Port), Settings, /*CheckIfAbortFunction*/ {});
+ }
+};
+
+TEST_CASE("httpclient.transport-faults")
+{
+ SUBCASE("connection reset before response")
+ {
+ FaultTcpServer Server([](asio::ip::tcp::socket& Socket) {
+ DrainHttpRequest(Socket);
+ std::error_code Ec;
+ Socket.set_option(asio::socket_base::linger(true, 0), Ec);
+ Socket.close(Ec);
+ });
+ HttpClient Client = Server.MakeClient();
+ HttpClient::Response Resp = Client.Get("/test");
+ CHECK(!Resp.IsSuccess());
+ CHECK(Resp.Error.has_value());
+ }
+
+ SUBCASE("connection closed before response")
+ {
+ FaultTcpServer Server([](asio::ip::tcp::socket& Socket) {
+ DrainHttpRequest(Socket);
+ std::error_code Ec;
+ Socket.shutdown(asio::ip::tcp::socket::shutdown_both, Ec);
+ Socket.close(Ec);
+ });
+ HttpClient Client = Server.MakeClient();
+ HttpClient::Response Resp = Client.Get("/test");
+ CHECK(!Resp.IsSuccess());
+ CHECK(Resp.Error.has_value());
+ }
+
+ SUBCASE("partial headers then close")
+ {
+ // libcurl parses the status line (200 OK) and accepts the response even though
+ // headers are truncated mid-field. It reports success with an empty body instead
+ // of an error. Ideally this should be detected as a transport failure.
+ FaultTcpServer Server([](asio::ip::tcp::socket& Socket) {
+ DrainHttpRequest(Socket);
+ std::string Partial = "HTTP/1.1 200 OK\r\nContent-";
+ std::error_code Ec;
+ asio::write(Socket, asio::buffer(Partial), Ec);
+ Socket.close(Ec);
+ });
+ HttpClient Client = Server.MakeClient();
+ HttpClient::Response Resp = Client.Get("/test");
+ WARN(!Resp.IsSuccess());
+ WARN(Resp.Error.has_value());
+ }
+
+ SUBCASE("truncated body")
+ {
+ FaultTcpServer Server([](asio::ip::tcp::socket& Socket) {
+ DrainHttpRequest(Socket);
+ std::string Headers = MakeRawHttpHeaders(200, 1000);
+ std::error_code Ec;
+ asio::write(Socket, asio::buffer(Headers), Ec);
+ std::string PartialBody(100, 'x');
+ asio::write(Socket, asio::buffer(PartialBody), Ec);
+ Socket.close(Ec);
+ });
+ HttpClient Client = Server.MakeClient();
+ HttpClient::Response Resp = Client.Get("/test");
+ CHECK(!Resp.IsSuccess());
+ CHECK(Resp.Error.has_value());
+ }
+
+ SUBCASE("connection reset mid-body")
+ {
+ FaultTcpServer Server([](asio::ip::tcp::socket& Socket) {
+ DrainHttpRequest(Socket);
+ std::string Headers = MakeRawHttpHeaders(200, 10000);
+ std::error_code Ec;
+ asio::write(Socket, asio::buffer(Headers), Ec);
+ std::string PartialBody(1000, 'x');
+ asio::write(Socket, asio::buffer(PartialBody), Ec);
+ Socket.set_option(asio::socket_base::linger(true, 0), Ec);
+ Socket.close(Ec);
+ });
+ HttpClient Client = Server.MakeClient();
+ HttpClient::Response Resp = Client.Get("/test");
+ CHECK(!Resp.IsSuccess());
+ CHECK(Resp.Error.has_value());
+ }
+
+ SUBCASE("stalled response triggers timeout")
+ {
+ std::atomic<bool> StallActive{true};
+ FaultTcpServer Server([&StallActive](asio::ip::tcp::socket& Socket) {
+ DrainHttpRequest(Socket);
+ std::string Headers = MakeRawHttpHeaders(200, 1000);
+ std::error_code Ec;
+ asio::write(Socket, asio::buffer(Headers), Ec);
+ while (StallActive.load())
+ {
+ std::this_thread::sleep_for(std::chrono::milliseconds(50));
+ }
+ });
+
+ HttpClientSettings Settings;
+ Settings.Timeout = std::chrono::milliseconds(500);
+ HttpClient Client = Server.MakeClient(Settings);
+
+ HttpClient::Response Resp = Client.Get("/test");
+ CHECK(!Resp.IsSuccess());
+ CHECK(Resp.Error.has_value());
+ StallActive.store(false);
+ }
+
+ SUBCASE("retry succeeds after transient failures")
+ {
+ std::atomic<int> ConnCount{0};
+ FaultTcpServer Server([&ConnCount](asio::ip::tcp::socket& Socket) {
+ int N = ConnCount.fetch_add(1);
+ DrainHttpRequest(Socket);
+ if (N < 2)
+ {
+ // Connection reset produces NETWORK_SEND_FAILURE which is retryable
+ std::error_code Ec;
+ Socket.set_option(asio::socket_base::linger(true, 0), Ec);
+ Socket.close(Ec);
+ }
+ else
+ {
+ std::string Response = MakeRawHttpResponse(200, "recovered");
+ std::error_code Ec;
+ asio::write(Socket, asio::buffer(Response), Ec);
+ }
+ });
+
+ HttpClientSettings Settings;
+ Settings.RetryCount = 3;
+ HttpClient Client = Server.MakeClient(Settings);
+
+ HttpClient::Response Resp = Client.Get("/test");
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "recovered");
+ }
+}
+
+TEST_CASE("httpclient.transport-faults-post")
+{
+ constexpr size_t kPostBodySize = 256 * 1024;
+
+ auto MakePostBody = []() -> IoBuffer {
+ IoBuffer Buf(kPostBodySize);
+ uint8_t* Ptr = static_cast<uint8_t*>(Buf.MutableData());
+ for (size_t i = 0; i < kPostBodySize; ++i)
+ {
+ Ptr[i] = static_cast<uint8_t>(i & 0xFF);
+ }
+ Buf.SetContentType(ZenContentType::kBinary);
+ return Buf;
+ };
+
+ SUBCASE("POST: server resets before consuming body")
+ {
+ FaultTcpServer Server([](asio::ip::tcp::socket& Socket) {
+ DrainHttpRequest(Socket);
+ std::error_code Ec;
+ Socket.set_option(asio::socket_base::linger(true, 0), Ec);
+ Socket.close(Ec);
+ });
+ HttpClient Client = Server.MakeClient();
+ IoBuffer Body = MakePostBody();
+ HttpClient::Response Resp = Client.Post("/test", Body);
+ CHECK(!Resp.IsSuccess());
+ CHECK(Resp.Error.has_value());
+ }
+
+ SUBCASE("POST: server closes before consuming body")
+ {
+ FaultTcpServer Server([](asio::ip::tcp::socket& Socket) {
+ DrainHttpRequest(Socket);
+ std::error_code Ec;
+ Socket.shutdown(asio::ip::tcp::socket::shutdown_both, Ec);
+ Socket.close(Ec);
+ });
+ HttpClient Client = Server.MakeClient();
+ IoBuffer Body = MakePostBody();
+ HttpClient::Response Resp = Client.Post("/test", Body);
+ CHECK(!Resp.IsSuccess());
+ CHECK(Resp.Error.has_value());
+ }
+
+ SUBCASE("POST: server resets mid-body")
+ {
+ FaultTcpServer Server([](asio::ip::tcp::socket& Socket) {
+ DrainPartialBody(Socket, 8 * 1024);
+ std::error_code Ec;
+ Socket.set_option(asio::socket_base::linger(true, 0), Ec);
+ Socket.close(Ec);
+ });
+ HttpClient Client = Server.MakeClient();
+ IoBuffer Body = MakePostBody();
+ HttpClient::Response Resp = Client.Post("/test", Body);
+ CHECK(!Resp.IsSuccess());
+ CHECK(Resp.Error.has_value());
+ }
+
+ SUBCASE("POST: early error response before consuming body")
+ {
+ FaultTcpServer Server([](asio::ip::tcp::socket& Socket) {
+ DrainHttpRequest(Socket);
+ std::string Response = MakeRawHttpResponse(503, "service busy");
+ std::error_code Ec;
+ asio::write(Socket, asio::buffer(Response), Ec);
+ Socket.shutdown(asio::ip::tcp::socket::shutdown_both, Ec);
+ Socket.close(Ec);
+ });
+ HttpClient Client = Server.MakeClient();
+ IoBuffer Body = MakePostBody();
+ HttpClient::Response Resp = Client.Post("/test", Body);
+ CHECK(!Resp.IsSuccess());
+ // With a large upload body, the server may RST the connection before the client
+ // reads the 503 response. Either outcome is valid: the client sees the HTTP 503
+ // status, or it sees a transport-level error from the RST.
+ CHECK((Resp.StatusCode == HttpResponseCode::ServiceUnavailable || Resp.Error.has_value()));
+ }
+
+ SUBCASE("POST: stalled upload triggers timeout")
+ {
+ std::atomic<bool> StallActive{true};
+ FaultTcpServer Server([&StallActive](asio::ip::tcp::socket& Socket) {
+ DrainHttpRequest(Socket);
+ // Stop reading body — TCP window will fill and client send will stall
+ while (StallActive.load())
+ {
+ std::this_thread::sleep_for(std::chrono::milliseconds(50));
+ }
+ });
+
+ HttpClientSettings Settings;
+ Settings.Timeout = std::chrono::milliseconds(2000);
+ HttpClient Client = Server.MakeClient(Settings);
+
+ IoBuffer Body = MakePostBody();
+ HttpClient::Response Resp = Client.Post("/test", Body);
+ CHECK(!Resp.IsSuccess());
+ CHECK(Resp.Error.has_value());
+ StallActive.store(false);
+ }
+
+ SUBCASE("POST: retry with large body after transient failure")
+ {
+ std::atomic<int> ConnCount{0};
+ FaultTcpServer Server([&ConnCount](asio::ip::tcp::socket& Socket) {
+ int N = ConnCount.fetch_add(1);
+ if (N < 2)
+ {
+ DrainHttpRequest(Socket);
+ std::error_code Ec;
+ Socket.set_option(asio::socket_base::linger(true, 0), Ec);
+ Socket.close(Ec);
+ }
+ else
+ {
+ DrainFullHttpRequest(Socket);
+ std::string Response = MakeRawHttpResponse(200, "upload-ok");
+ std::error_code Ec;
+ asio::write(Socket, asio::buffer(Response), Ec);
+ }
+ });
+
+ HttpClientSettings Settings;
+ Settings.RetryCount = 3;
+ HttpClient Client = Server.MakeClient(Settings);
+
+ IoBuffer Body = MakePostBody();
+ HttpClient::Response Resp = Client.Post("/test", Body);
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "upload-ok");
+ }
+}
+
+void
+httpclient_test_forcelink()
+{
+}
+
+} // namespace zen
+
+#endif
diff --git a/src/zenhttp/include/zenhttp/httpclient.h b/src/zenhttp/include/zenhttp/httpclient.h
index 7a129a98c..336a3deee 100644
--- a/src/zenhttp/include/zenhttp/httpclient.h
+++ b/src/zenhttp/include/zenhttp/httpclient.h
@@ -269,6 +269,7 @@ struct LatencyTestResult
LatencyTestResult MeasureLatency(HttpClient& Client, std::string_view Url);
-void httpclient_forcelink(); // internal
+void httpclient_forcelink(); // internal
+void httpclient_test_forcelink(); // internal
} // namespace zen
diff --git a/src/zenhttp/zenhttp.cpp b/src/zenhttp/zenhttp.cpp
index 0b5408453..ad14ecb8d 100644
--- a/src/zenhttp/zenhttp.cpp
+++ b/src/zenhttp/zenhttp.cpp
@@ -16,6 +16,7 @@ zenhttp_forcelinktests()
{
http_forcelink();
httpclient_forcelink();
+ httpclient_test_forcelink();
forcelink_packageformat();
passwordsecurity_forcelink();
}
diff --git a/xmake.lua b/xmake.lua
index 3537c618d..d49743cb2 100644
--- a/xmake.lua
+++ b/xmake.lua
@@ -344,10 +344,10 @@ task("sln")
task("test")
set_menu {
- usage = "xmake test --run=[core|store|server|integration|all]",
+ usage = "xmake test --run=[core|store|http|server|integration|all]",
description = "Run Zen tests",
options = {
- {'r', "run", "kv", "all", "Run test(s)", " - all", " - core ", " - remotestore", " - store", " - server", " - integration"},
+ {'r', "run", "kv", "all", "Run test(s)", " - all", " - core ", " - remotestore", " - store", " - http", " - server", " - integration"},
{'j', "junit", "k", nil, "Enable junit report output"}
}
}