Files
vdm/core/tests/net/http_client_test.cpp
samiandClaude Sonnet 5 bd1bc029f3 core: net/http_client — libcurl multi wrapper (stage 2)
One HttpClient owns a small pool of workers, each with its own CURLM; an
easy handle lives on one worker for its life. Public start/pause/resume/
cancel enqueue a command + curl_multi_wakeup(); callbacks (on_head /
on_data / on_finished) run on the worker thread and return a DataAction
(proceed / pause / abort). Covers redirects (final-response head only),
ranges (inclusive ByteRange -> CURLOPT_RANGE), proxy/SOCKS5, basic/digest
auth, cookies, verbatim headers, stall detection, a coarse recv-rate cap,
and a curl_share DNS/TLS cache across workers. CURLcode + HTTP status ->
vdm::Error in net/curl_error. A probe is on_head returning abort: it
finishes successfully (head_complete), not canceled.

Tests drive tools/testserver: full GET, ranged 206, redirect chain, 404
-> not_found, connection refused -> connect_failed, HEAD probe + ranged
0-0 probe (no body), cancel mid-transfer, pause/resume completes. Skip
cleanly if testserver isn't in the tree.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HPPSGhiArbvQgwC2DNiURS
2026-09-09 23:31:09 +04:00

271 lines
8.3 KiB
C++

#include "vdm/net/http_client.hpp"
#include <atomic>
#include <chrono>
#include <future>
#include <mutex>
#include <string>
#include <vector>
#include "testserver_fixture.hpp"
#include "vtest.hpp"
using namespace vdm;
using namespace vdm::net;
using vdm::testing::TestServer;
namespace {
// Collects callback output from a transfer and lets the test thread wait for the end.
struct Recorder {
std::mutex mu;
ResponseHead head;
bool head_seen = false;
std::uint64_t bytes = 0;
std::promise<Result<TransferStats>> done;
std::future<Result<TransferStats>> done_fut = done.get_future();
DataAction want_on_head = DataAction::proceed; // set before start()
std::atomic<DataAction> want_on_data{DataAction::proceed};
std::atomic<int> data_calls{0};
TransferCallbacks callbacks() {
return TransferCallbacks{
.on_head =
[this](const ResponseHead &h) {
std::lock_guard lk(mu);
head = h;
head_seen = true;
return want_on_head;
},
.on_data =
[this](ConstByteSpan s) {
data_calls.fetch_add(1);
std::lock_guard lk(mu);
bytes += s.size();
return want_on_data.load();
},
.on_finished = [this](Result<TransferStats> r) { done.set_value(std::move(r)); },
};
}
Result<TransferStats> wait(std::chrono::seconds to = std::chrono::seconds(20)) {
if (done_fut.wait_for(to) != std::future_status::ready)
return Err{Error::timeout, "test wait timed out"};
return done_fut.get();
}
};
// on_data needs an atomic for the cancel/pause tests to flip it from the test thread.
struct AtomicAction {
std::atomic<DataAction> a{DataAction::proceed};
void store(DataAction v) { a.store(v); }
operator DataAction() const { return a.load(); }
DataAction load() const { return a.load(); }
};
} // namespace
VT_TEST(http_plain_full_get) {
TestServer srv;
VT_REQUIRE(srv.available());
HttpClient client({.workers = 1});
Recorder rec;
Request req;
req.url = srv.url("/plain/file/64K");
auto t = client.start(std::move(req), rec.callbacks());
auto r = rec.wait();
VT_REQUIRE(r.has_value());
VT_CHECK_EQ(r.value().http_status, 200);
VT_CHECK_EQ(r.value().bytes_received, 65536u);
VT_CHECK(rec.head_seen);
VT_CHECK_EQ(rec.head.status, 200);
VT_CHECK(rec.head.headers.has("Accept-Ranges"));
VT_CHECK_EQ(rec.bytes, 65536u);
VT_CHECK(t.id() != 0);
}
VT_TEST(http_ranged_get_is_206) {
TestServer srv;
VT_REQUIRE(srv.available());
HttpClient client({.workers = 1});
Recorder rec;
Request req;
req.url = srv.url("/plain/file/64K");
req.range = ByteRange{1000, 1999};
auto t = client.start(std::move(req), rec.callbacks());
auto r = rec.wait();
VT_REQUIRE(r.has_value());
VT_CHECK_EQ(rec.head.status, 206);
auto cr = rec.head.headers.get("Content-Range");
VT_REQUIRE(cr.has_value());
VT_CHECK_EQ(std::string(*cr), std::string("bytes 1000-1999/65536"));
VT_CHECK_EQ(rec.bytes, 1000u);
VT_CHECK_EQ(r.value().bytes_received, 1000u);
}
VT_TEST(http_follows_redirect_chain) {
TestServer srv;
VT_REQUIRE(srv.available());
HttpClient client({.workers = 1});
Recorder rec;
Request req;
req.url = srv.url("/redirect-chain/file/16K");
auto t = client.start(std::move(req), rec.callbacks());
auto r = rec.wait();
VT_REQUIRE(r.has_value());
VT_CHECK_EQ(rec.head.status, 200); // on_head fires once, for the final response
VT_CHECK_EQ(rec.bytes, 16u * 1024u);
VT_CHECK(r.value().effective_url != srv.url("/redirect-chain/file/16K"));
VT_CHECK(r.value().effective_url.find("_r=done") != std::string::npos);
}
VT_TEST(http_404_is_not_found_error) {
TestServer srv;
VT_REQUIRE(srv.available());
HttpClient client({.workers = 1});
Recorder rec;
Request req;
req.url = srv.url("/plain/nope");
auto t = client.start(std::move(req), rec.callbacks());
auto r = rec.wait();
VT_REQUIRE(!r.has_value());
VT_CHECK_EQ(r.error().code, Error::not_found);
VT_CHECK_EQ(r.error().http_status, 404);
}
VT_TEST(http_connection_refused_is_connect_failed) {
HttpClient client({.workers = 1});
Recorder rec;
Request req;
req.url = "http://127.0.0.1:1/nothing"; // nothing listens on :1
req.connect_timeout_ms = 2000;
auto t = client.start(std::move(req), rec.callbacks());
auto r = rec.wait();
VT_REQUIRE(!r.has_value());
VT_CHECK_EQ(r.error().code, Error::connect_failed);
}
VT_TEST(http_head_probe_stops_after_headers) {
TestServer srv;
VT_REQUIRE(srv.available());
HttpClient client({.workers = 1});
Recorder rec;
rec.want_on_head = DataAction::abort; // probe: headers only
Request req;
req.url = srv.url("/plain/file/1M");
req.method = Method::head;
auto t = client.start(std::move(req), rec.callbacks());
auto r = rec.wait();
VT_REQUIRE(r.has_value()); // head_complete, NOT canceled
VT_CHECK(rec.head_seen);
VT_REQUIRE(rec.head.content_length.has_value());
VT_CHECK_EQ(*rec.head.content_length, 1024u * 1024u);
VT_CHECK_EQ(rec.data_calls.load(), 0);
}
VT_TEST(http_ranged_get_probe_stops_without_downloading_file) {
TestServer srv;
VT_REQUIRE(srv.available());
HttpClient client({.workers = 1});
Recorder rec;
rec.want_on_head = DataAction::abort;
Request req;
req.url = srv.url("/plain/file/8M");
req.range = ByteRange{0, 0}; // classic HEAD-refused fallback
auto t = client.start(std::move(req), rec.callbacks());
auto r = rec.wait();
VT_REQUIRE(r.has_value());
VT_CHECK(rec.bytes <= 1u); // at most the one probe byte, usually 0
}
VT_TEST(http_cancel_mid_transfer) {
TestServer srv;
VT_REQUIRE(srv.available());
HttpClient client({.workers = 1});
AtomicAction data_action;
std::promise<Result<TransferStats>> done;
auto fut = done.get_future();
std::atomic<int> calls{0};
TransferCallbacks cbs{
.on_head = [](const ResponseHead &) { return DataAction::proceed; },
.on_data =
[&](ConstByteSpan) {
calls.fetch_add(1);
return data_action.load();
},
.on_finished = [&](Result<TransferStats> r) { done.set_value(std::move(r)); },
};
Request req;
req.url = srv.url("/throttled/file/4M"); // ~128 KiB/s => lots of chunks
auto t = client.start(std::move(req), std::move(cbs));
// wait for the transfer to actually start, then cancel
for (int i = 0; i < 200 && calls.load() == 0; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(10));
VT_REQUIRE(calls.load() > 0);
t.cancel();
VT_REQUIRE(fut.wait_for(std::chrono::seconds(10)) == std::future_status::ready);
auto r = fut.get();
VT_REQUIRE(!r.has_value());
VT_CHECK_EQ(r.error().code, Error::canceled);
}
VT_TEST(http_pause_then_resume_completes) {
TestServer srv;
VT_REQUIRE(srv.available());
HttpClient client({.workers = 1});
std::atomic<int> calls{0};
std::atomic<std::uint64_t> total{0};
std::promise<Result<TransferStats>> done;
auto fut = done.get_future();
TransferCallbacks cbs{
.on_head = [](const ResponseHead &) { return DataAction::proceed; },
.on_data =
[&](ConstByteSpan s) {
calls.fetch_add(1);
total.fetch_add(s.size());
return DataAction::proceed;
},
.on_finished = [&](Result<TransferStats> r) { done.set_value(std::move(r)); },
};
Request req;
req.url = srv.url("/throttled/file/1M");
auto t = client.start(std::move(req), std::move(cbs));
for (int i = 0; i < 200 && calls.load() == 0; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(10));
VT_REQUIRE(calls.load() > 0);
t.pause();
int calls_at_pause = calls.load();
std::this_thread::sleep_for(std::chrono::milliseconds(400));
VT_CHECK(calls.load() - calls_at_pause <= 1); // at most one in-flight chunk slips through
t.resume();
VT_REQUIRE(fut.wait_for(std::chrono::seconds(20)) == std::future_status::ready);
auto r = fut.get();
VT_REQUIRE(r.has_value());
VT_CHECK_EQ(total.load(), 1024u * 1024u);
}