#include "vdm/net/http_client.hpp" #include #include #include #include #include #include #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> done; std::future> done_fut = done.get_future(); DataAction want_on_head = DataAction::proceed; // set before start() std::atomic want_on_data{DataAction::proceed}; std::atomic 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 r) { done.set_value(std::move(r)); }, }; } Result 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 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> done; auto fut = done.get_future(); std::atomic 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 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 calls{0}; std::atomic total{0}; std::promise> 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 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); }