aboutsummaryrefslogtreecommitdiff
path: root/src/zenhttp/httpclient_test.cpp
diff options
context:
space:
mode:
authorStefan Boberg <[email protected]>2026-03-15 20:42:36 +0100
committerStefan Boberg <[email protected]>2026-03-15 20:42:36 +0100
commit9c724efbf6b38466a9b6bfde37236369f1e85cb8 (patch)
tree214e1ec00c5bfca0704ce52789017ade734fd054 /src/zenhttp/httpclient_test.cpp
parentreduced WaitForThreads time to see how it behaves with explicit thread pools (diff)
parentadd buildid updates to oplog and builds test scripts (#838) (diff)
downloadzen-9c724efbf6b38466a9b6bfde37236369f1e85cb8.tar.xz
zen-9c724efbf6b38466a9b6bfde37236369f1e85cb8.zip
Merge remote-tracking branch 'origin/main' into sb/threadpool
Diffstat (limited to 'src/zenhttp/httpclient_test.cpp')
-rw-r--r--src/zenhttp/httpclient_test.cpp1701
1 files changed, 1701 insertions, 0 deletions
diff --git a/src/zenhttp/httpclient_test.cpp b/src/zenhttp/httpclient_test.cpp
new file mode 100644
index 000000000..5f3ad2455
--- /dev/null
+++ b/src/zenhttp/httpclient_test.cpp
@@ -0,0 +1,1701 @@
+// 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/filesystem.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(0, 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_SUITE_BEGIN("http.httpclient");
+
+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.post-streaming")
+{
+ TestServerFixture Fixture;
+ ScopedTemporaryDirectory PostDir;
+
+ SUBCASE("POST CbObject with TempFolderPath stays in memory when response is small")
+ {
+ HttpClient Client = Fixture.MakeClient();
+
+ CbObjectWriter Writer;
+ Writer.AddBool("streaming", false);
+ Writer.AddString("mode", "memory");
+ CbObject Obj = Writer.Save();
+
+ HttpClient::Response Resp = Client.Post("/api/test/echo", Obj, {}, PostDir.Path());
+ CHECK(Resp.IsSuccess());
+ IoBufferFileReference _;
+ CHECK(!Resp.ResponsePayload.GetFileReference(_));
+ CbObject RoundTripped = Resp.AsObject();
+ CHECK(RoundTripped["streaming"].AsBool() == false);
+ CHECK_EQ(RoundTripped["mode"].AsString(), "memory");
+ }
+
+ SUBCASE("POST CbObject with TempFolderPath streams to file when response exceeds MaximumInMemoryDownloadSize")
+ {
+ HttpClientSettings Settings;
+ Settings.MaximumInMemoryDownloadSize = 4;
+ HttpClient Client = Fixture.MakeClient(Settings);
+
+ CbObjectWriter Writer;
+ Writer.AddBool("streaming", true);
+ Writer.AddString("mode", "file");
+ CbObject Obj = Writer.Save();
+
+ HttpClient::Response Resp = Client.Post("/api/test/echo", Obj, {}, PostDir.Path());
+ CHECK(Resp.IsSuccess());
+ IoBufferFileReference _;
+ CHECK(Resp.ResponsePayload.GetFileReference(_));
+ CbObject RoundTripped = Resp.AsObject();
+ CHECK(RoundTripped["streaming"].AsBool() == true);
+ CHECK_EQ(RoundTripped["mode"].AsString(), "file");
+ }
+}
+
+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]() {
+ try
+ {
+ m_IoContext.run();
+ }
+ catch (...)
+ {
+ }
+ });
+ }
+
+ ~FaultTcpServer()
+ {
+ // io_context::stop() is thread-safe; do NOT call m_Acceptor.close() from this
+ // thread — ASIO I/O objects are not safe for concurrent access and the io_context
+ // thread may be touching the acceptor in StartAccept().
+ 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.range-response")
+{
+ ScopedTemporaryDirectory DownloadDir;
+
+ SUBCASE("single range 206 response populates Ranges")
+ {
+ std::string RangeBody(100, 'A');
+
+ FaultTcpServer Server([&](asio::ip::tcp::socket& Socket) {
+ DrainHttpRequest(Socket);
+ std::string Response = fmt::format(
+ "HTTP/1.1 206 Partial Content\r\n"
+ "Content-Type: application/octet-stream\r\n"
+ "Content-Range: bytes 200-299/1000\r\n"
+ "Content-Length: {}\r\n"
+ "\r\n"
+ "{}",
+ RangeBody.size(),
+ RangeBody);
+ std::error_code Ec;
+ asio::write(Socket, asio::buffer(Response), Ec);
+ });
+
+ HttpClient Client = Server.MakeClient();
+ HttpClient::Response Resp = Client.Download("/test", DownloadDir.Path());
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.StatusCode, HttpResponseCode::PartialContent);
+ REQUIRE(Resp.Ranges.size() == 1);
+ CHECK_EQ(Resp.Ranges[0].RangeOffset, 200);
+ CHECK_EQ(Resp.Ranges[0].RangeLength, 100);
+ }
+
+ SUBCASE("multipart byteranges 206 response populates Ranges")
+ {
+ std::string Part1Data(16, 'X');
+ std::string Part2Data(12, 'Y');
+ std::string Boundary = "testboundary123";
+
+ std::string MultipartBody = fmt::format(
+ "\r\n--{}\r\n"
+ "Content-Type: application/octet-stream\r\n"
+ "Content-Range: bytes 100-115/1000\r\n"
+ "\r\n"
+ "{}"
+ "\r\n--{}\r\n"
+ "Content-Type: application/octet-stream\r\n"
+ "Content-Range: bytes 500-511/1000\r\n"
+ "\r\n"
+ "{}"
+ "\r\n--{}--",
+ Boundary,
+ Part1Data,
+ Boundary,
+ Part2Data,
+ Boundary);
+
+ FaultTcpServer Server([&](asio::ip::tcp::socket& Socket) {
+ DrainHttpRequest(Socket);
+ std::string Response = fmt::format(
+ "HTTP/1.1 206 Partial Content\r\n"
+ "Content-Type: multipart/byteranges; boundary={}\r\n"
+ "Content-Length: {}\r\n"
+ "\r\n"
+ "{}",
+ Boundary,
+ MultipartBody.size(),
+ MultipartBody);
+ std::error_code Ec;
+ asio::write(Socket, asio::buffer(Response), Ec);
+ });
+
+ HttpClient Client = Server.MakeClient();
+ HttpClient::Response Resp = Client.Download("/test", DownloadDir.Path());
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.StatusCode, HttpResponseCode::PartialContent);
+ REQUIRE(Resp.Ranges.size() == 2);
+ // Ranges should be sorted by RangeOffset
+ CHECK_EQ(Resp.Ranges[0].RangeOffset, 100);
+ CHECK_EQ(Resp.Ranges[0].RangeLength, 16);
+ CHECK_EQ(Resp.Ranges[1].RangeOffset, 500);
+ CHECK_EQ(Resp.Ranges[1].RangeLength, 12);
+ }
+
+ SUBCASE("non-range 200 response has empty Ranges")
+ {
+ FaultTcpServer Server([&](asio::ip::tcp::socket& Socket) {
+ DrainHttpRequest(Socket);
+ std::string Response = MakeRawHttpResponse(200, "full content");
+ std::error_code Ec;
+ asio::write(Socket, asio::buffer(Response), Ec);
+ });
+
+ HttpClient Client = Server.MakeClient();
+ HttpClient::Response Resp = Client.Download("/test", DownloadDir.Path());
+ CHECK(Resp.IsSuccess());
+ CHECK(Resp.Ranges.empty());
+ }
+}
+
+TEST_CASE("httpclient.transport-faults" * doctest::skip())
+{
+ 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" * doctest::skip())
+{
+ 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");
+ }
+}
+
+TEST_CASE("httpclient.unixsocket")
+{
+ ScopedTemporaryDirectory TmpDir;
+ std::string SocketPath = (TmpDir.Path() / "zen.sock").string();
+
+ HttpClientTestService TestService;
+
+ Ref<HttpServer> Server = CreateHttpAsioServer(AsioConfig{.UnixSocketPath = SocketPath});
+
+ int Port = Server->Initialize(0, TmpDir.Path());
+ REQUIRE(Port != -1);
+
+ Server->RegisterService(TestService);
+
+ std::thread ServerThread([&]() { Server->Run(false); });
+
+ auto _ = MakeGuard([&]() {
+ Server->RequestExit();
+ if (ServerThread.joinable())
+ {
+ ServerThread.join();
+ }
+ Server->Close();
+ });
+
+ HttpClientSettings Settings;
+ Settings.UnixSocketPath = SocketPath;
+
+ HttpClient Client("localhost", Settings, /*CheckIfAbortFunction*/ {});
+
+ SUBCASE("GET over unix socket")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/hello");
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "hello world");
+ }
+
+ SUBCASE("POST echo over unix socket")
+ {
+ const char* Payload = "unix socket payload";
+ IoBuffer Body(IoBuffer::Clone, Payload, strlen(Payload));
+ Body.SetContentType(ZenContentType::kText);
+
+ HttpClient::Response Resp = Client.Post("/api/test/echo", Body);
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "unix socket payload");
+ }
+}
+
+# if ZEN_USE_OPENSSL
+
+TEST_CASE("httpclient.https")
+{
+ // Self-signed test certificate for localhost/127.0.0.1, valid until 2036
+ static constexpr std::string_view TestCertPem =
+ "-----BEGIN CERTIFICATE-----\n"
+ "MIIDJTCCAg2gAwIBAgIUEtJYMSUmJmvJ157We/qXNVJ7W8gwDQYJKoZIhvcNAQEL\n"
+ "BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDMwOTIwMjU1M1oXDTM2MDMw\n"
+ "NjIwMjU1M1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF\n"
+ "AAOCAQ8AMIIBCgKCAQEAv9YvZ6WeBz3z/Zuxi6OIivWksDxDZZ5oAXKVwlUXaa7v\n"
+ "iDkm9P5ZsEhN+M5vZMe2Yb9i3cnTUaE6Avs1ddOwTAYNGrE/B5DmibrRWc23R0cv\n"
+ "gdnYQJ+gjsAeMvUWYLK58xW4YoMR5bmfpj1ruqobUNkG/oJYnAUcjgo4J149irW+\n"
+ "4n9uLJvxL+5fI/b/AIkv+4TMe70/d/BPmnixWrrzxUT6S5ghE2Mq7+XLScfpY2Sp\n"
+ "GQ/Xbnj9/ELYLpQnNLuVZwWZDpXj+FLbF1zxgjYdw1cCjbRcOIEW2/GJeJvGXQ6Y\n"
+ "Vld5pCBm9uKPPLWoFCoakK5YvP00h+8X+HghGVSscQIDAQABo28wbTAdBgNVHQ4E\n"
+ "FgQUgM6hjymi6g2EBUg2ENu0nIK8yhMwHwYDVR0jBBgwFoAUgM6hjymi6g2EBUg2\n"
+ "ENu0nIK8yhMwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARhwR/AAABgglsb2Nh\n"
+ "bGhvc3QwDQYJKoZIhvcNAQELBQADggEBABY1oaaWwL4RaK/epKvk/IrmVT2mlAai\n"
+ "uvGLfjhc6FGvXaxPGTSUPrVbFornaWZAg7bOWCexWnEm2sWd75V/usvZAPN4aIiD\n"
+ "H66YQipq3OD4F9Gowp01IU4AcGh7MerFpYPk76+wp2ANq71x8axtlZjVn3hSFMmN\n"
+ "i6m9S/eyCl9WjYBT5ZEC4fJV0nOSmNe/+gCAm11/js9zNfXKmUchJtuZpubY3A0k\n"
+ "X2II6qYWf1PH+JJkefNZtt2c66CrEN5eAg4/rGEgsp43zcd4ZHVkpBKFLDEls1ev\n"
+ "drQ45zc4Ht77pHfnHu7YsLcRZ9Wq3COMNZYx5lItqnomX2qBm1pkwjI=\n"
+ "-----END CERTIFICATE-----\n";
+
+ static constexpr std::string_view TestKeyPem =
+ "-----BEGIN PRIVATE KEY-----\n"
+ "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC/1i9npZ4HPfP9\n"
+ "m7GLo4iK9aSwPENlnmgBcpXCVRdpru+IOSb0/lmwSE34zm9kx7Zhv2LdydNRoToC\n"
+ "+zV107BMBg0asT8HkOaJutFZzbdHRy+B2dhAn6COwB4y9RZgsrnzFbhigxHluZ+m\n"
+ "PWu6qhtQ2Qb+glicBRyOCjgnXj2Ktb7if24sm/Ev7l8j9v8AiS/7hMx7vT938E+a\n"
+ "eLFauvPFRPpLmCETYyrv5ctJx+ljZKkZD9dueP38QtgulCc0u5VnBZkOleP4UtsX\n"
+ "XPGCNh3DVwKNtFw4gRbb8Yl4m8ZdDphWV3mkIGb24o88tagUKhqQrli8/TSH7xf4\n"
+ "eCEZVKxxAgMBAAECggEAILd9pDaZqfCF8SWhdQgx3Ekiii/s6qLGaCDLq7XpZUvB\n"
+ "bEEbBMNwNmFOcvV6B/0LfMYwLVUjZhOSGjoPlwXAVmbdy0SZVEgBGVI0LBWqgUyB\n"
+ "rKqjd/oBXvci71vfMiSpE+0LYjmqTryGnspw2gfy2qn4yGUgiZNRmGPjycsHweUL\n"
+ "V3FHm3cf0dyE4sJ0mjVqZzRT/unw2QOCE6FlY7M1XxZL88IWfn6G4lckdJTwoOP5\n"
+ "VPR2J3XbyhvCeXeDRCHKRXojWWR2HovWnDXQc95GRgCd0vYdHuIUM6RXVPZQvy3X\n"
+ "l0GwQKHNcVr1uwtYDgGKw0tNCUDvxdfQaWilTFuicQKBgQDvEYp+vL1hnF+AVdu3\n"
+ "elsYsHpFgExkTI8wnUMvGZrFiIQyCyVDU3jkG3kcKacI1bfwopXopaQCjrYk9epm\n"
+ "liOVm3/Xtr6e2ENa7w8TQbdK65PciQNOMxml6g8clRRBl0cwj+aI3nW/Kop1cdrR\n"
+ "A9Vo+8iPTO5gDcxTiIb45a6E3QKBgQDNbE009P6ewx9PU7Llkhb9VBgsb7oQN3EV\n"
+ "TCYd4taiN6FPnTuL/cdijAA8y04hiVT+Efo9TUN9NCl9HdHXQcjj7/n/eFLH0Pkw\n"
+ "OIK3QN49OfR88wivLMtwWxIog0tJjc9+7dR4bR4o1jTlIrasEIvUTuDJQ8MKGc9v\n"
+ "pBITua+SpQKBgE4raSKZqj7hd6Sp7kbnHiRLiB9znQbqtaNKuK4M7DuMsNUAKfYC\n"
+ "tDO5+/bGc9SCtTtcnjHM/3zKlyossrFKhGYlyz6IhXnA8v0nz8EXKsy3jMh+kHMg\n"
+ "aFGE394TrOTphyCM3O+B9fRE/7L5QHg5ja1fLqwUlpkXyejCaoe16kONAoGAYIz9\n"
+ "wN1B67cEOVG6rOI8QfdLoV8mEcctNHhlFfjvLrF89SGOwl6WX0A0QF7CK0sUEpK6\n"
+ "jiOJjAh/U5o3bbgyxsedNjEEn3weE0cMUTuA+UALJMtKEqO4PuffIgGL2ld35k28\n"
+ "ZpnK6iC8HdJyD297eV9VkeNygYXeFLgF8xV8ay0CgYEAh4fmVZt9YhgVByYny2kF\n"
+ "ZUIkGF5h9wxzVOPpQwpizIGFFb3i/ZdGQcuLTfIBVRKf50sT3IwJe65ATv6+Lz0f\n"
+ "wg/pMvosi0/F5KGbVRVdzBMQy58WyyGti4tNl+8EXGvo8+DCmjlTYwfjRoZGg/qJ\n"
+ "EMP3/hTN7dHDRxPK8E0Fh0Y=\n"
+ "-----END PRIVATE KEY-----\n";
+
+ ScopedTemporaryDirectory TmpDir;
+
+ // Write cert and key to temp files
+ const auto CertPath = TmpDir.Path() / "test.crt";
+ const auto KeyPath = TmpDir.Path() / "test.key";
+ WriteFile(CertPath, IoBuffer(IoBuffer::Clone, TestCertPem.data(), TestCertPem.size()));
+ WriteFile(KeyPath, IoBuffer(IoBuffer::Clone, TestKeyPem.data(), TestKeyPem.size()));
+
+ HttpClientTestService TestService;
+
+ AsioConfig Config;
+ Config.CertFile = CertPath.string();
+ Config.KeyFile = KeyPath.string();
+
+ Ref<HttpServer> Server = CreateHttpAsioServer(Config);
+
+ int Port = Server->Initialize(0, TmpDir.Path());
+ REQUIRE(Port != -1);
+
+ Server->RegisterService(TestService);
+
+ std::thread ServerThread([&]() { Server->Run(false); });
+
+ auto _ = MakeGuard([&]() {
+ Server->RequestExit();
+ if (ServerThread.joinable())
+ {
+ ServerThread.join();
+ }
+ Server->Close();
+ });
+
+ int HttpsPort = Server->GetEffectiveHttpsPort();
+ REQUIRE(HttpsPort > 0);
+
+ HttpClientSettings Settings;
+ Settings.InsecureSsl = true;
+
+ HttpClient Client(fmt::format("https://127.0.0.1:{}", HttpsPort), Settings, /*CheckIfAbortFunction*/ {});
+
+ SUBCASE("GET over HTTPS")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/hello");
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "hello world");
+ }
+
+ SUBCASE("POST echo over HTTPS")
+ {
+ const char* Payload = "https payload";
+ IoBuffer Body(IoBuffer::Clone, Payload, strlen(Payload));
+ Body.SetContentType(ZenContentType::kText);
+
+ HttpClient::Response Resp = Client.Post("/api/test/echo", Body);
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.AsText(), "https payload");
+ }
+
+ SUBCASE("GET JSON over HTTPS")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/json");
+ CHECK(Resp.IsSuccess());
+ CbObject Obj = Resp.AsObject();
+ CHECK_EQ(Obj["ok"].AsBool(), true);
+ CHECK_EQ(Obj["message"].AsString(), "test");
+ }
+
+ SUBCASE("Large payload over HTTPS")
+ {
+ HttpClient::Response Resp = Client.Get("/api/test/large");
+ CHECK(Resp.IsSuccess());
+ CHECK_EQ(Resp.ResponsePayload.GetSize(), 64u * 1024u);
+ }
+}
+
+# endif // ZEN_USE_OPENSSL
+
+TEST_SUITE_END();
+
+void
+httpclient_test_forcelink()
+{
+}
+
+} // namespace zen
+
+#endif