clawdforge/clients/cpp/tests/test_client.cpp
Kayos bae34a7701 clients/cpp: initial C++ SDK for clawdforge
Modern C++20 SDK targeting CMake 3.20+. Library is RAII / move-only,
backed by a libcurl easy handle per Client. Public surface is throwing;
exception hierarchy under clawdforge::Error covers AuthError, APIError
(carries status_code + body), TransportError, and ProtocolError.

Dependencies: libcurl + nlohmann/json (FetchContent or find_package).
Tests use cpp-httplib's in-process server + doctest. 12 test cases /
70 assertions cover healthz, run with JSON / text / 502 / files,
multipart upload, full token CRUD, transport failure, URL normalization,
and bad-input rejection. Clean under -Wall -Wextra -Wpedantic -Werror,
ASan + UBSan clean (no leaks, no UB).

upload_file streams via curl_mime_filedata — no in-memory buffering.

Install path produces clawdforge::clawdforge target consumable via
target_link_libraries; FetchContent path mirrors the existing Rust /
Go SDK ergonomics. MIT licensed.
2026-04-28 23:02:51 -07:00

335 lines
12 KiB
C++

// SPDX-License-Identifier: MIT
//
// Mocks the clawdforge HTTP API with cpp-httplib and exercises the SDK end to
// end. Each TEST_CASE spins up a fresh server bound to 127.0.0.1:0 (random
// free port) so cases can run in parallel without colliding.
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include <doctest/doctest.h>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <future>
#include <memory>
#include <string>
#include <thread>
#include <httplib.h>
#include <nlohmann/json.hpp>
#include <clawdforge/client.hpp>
namespace cf = clawdforge;
using nlohmann::json;
namespace {
/// Owns a httplib::Server bound to 127.0.0.1:0 in a background thread. RAII —
/// the destructor stops the server and joins the thread.
class MockServer {
public:
explicit MockServer(std::function<void(httplib::Server&)> wire_routes) {
// Cap the worker thread pool low — cpp-httplib defaults to one worker
// per CPU core, which on big hosts piles up under ASan and trips the
// per-process map limit. Two workers is plenty for these tests.
srv_.new_task_queue = [] { return new httplib::ThreadPool(2); };
wire_routes(srv_);
port_ = srv_.bind_to_any_port("127.0.0.1");
REQUIRE(port_ > 0);
thread_ = std::thread([this] { srv_.listen_after_bind(); });
// Wait for the server to be ready (cheap poll — `listen_after_bind`
// doesn't expose a barrier).
for (int i = 0; i < 200; ++i) {
if (srv_.is_running()) break;
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
REQUIRE(srv_.is_running());
}
~MockServer() {
srv_.stop();
if (thread_.joinable()) thread_.join();
}
[[nodiscard]] std::string base_url() const {
return "http://127.0.0.1:" + std::to_string(port_);
}
private:
httplib::Server srv_;
int port_{0};
std::thread thread_;
};
[[nodiscard]] std::string write_temp_file(std::string_view content) {
namespace fs = std::filesystem;
const auto path = fs::temp_directory_path() /
("clawdforge-test-" +
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) +
".bin");
std::ofstream out(path, std::ios::binary);
out.write(content.data(), static_cast<std::streamsize>(content.size()));
out.close();
return path.string();
}
} // namespace
TEST_CASE("healthz returns parsed body") {
MockServer mock{[](httplib::Server& s) {
s.Get("/healthz", [](const httplib::Request&, httplib::Response& res) {
res.set_content(
R"({"ok":true,"claude_present":true,"claude_version":"1.2.3"})",
"application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
const auto h = c.healthz();
CHECK(h.ok);
CHECK(h.claude_present);
CHECK(h.claude_version == "1.2.3");
}
TEST_CASE("run with JSON result decodes into variant<json>") {
MockServer mock{[](httplib::Server& s) {
s.Post("/run", [](const httplib::Request& req, httplib::Response& res) {
CHECK(req.has_header("Authorization"));
CHECK(req.get_header_value("Authorization") == "Bearer cf_test");
const auto body = json::parse(req.body);
CHECK(body.at("prompt").get<std::string>() == "hi");
CHECK(body.at("model").get<std::string>() == "sonnet");
res.set_content(
R"({"ok":true,"result":{"hello":"world"},"duration_ms":42,"stop_reason":"end_turn"})",
"application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
const auto r = c.run(cf::RunRequest{.prompt = "hi", .model = "sonnet"});
CHECK(r.ok);
CHECK(r.duration_ms == 42);
REQUIRE(r.stop_reason.has_value());
CHECK(*r.stop_reason == "end_turn");
const auto* j = std::get_if<nlohmann::json>(&r.result);
REQUIRE(j != nullptr);
CHECK(j->at("hello").get<std::string>() == "world");
}
TEST_CASE("run with string result decodes into variant<string>") {
MockServer mock{[](httplib::Server& s) {
s.Post("/run", [](const httplib::Request&, httplib::Response& res) {
res.set_content(
R"({"ok":true,"result":"plain text reply","duration_ms":12})",
"application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
const auto r = c.run(cf::RunRequest{.prompt = "hi"});
const auto* s = std::get_if<std::string>(&r.result);
REQUIRE(s != nullptr);
CHECK(*s == "plain text reply");
}
TEST_CASE("run failure -> APIError with status 502 and body") {
MockServer mock{[](httplib::Server& s) {
s.Post("/run", [](const httplib::Request&, httplib::Response& res) {
res.status = 502;
res.set_content(
R"({"ok":false,"error":"timeout","duration_ms":60000,"stderr":"...","stop_reason":null})",
"application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
try {
(void)c.run(cf::RunRequest{.prompt = "hi"});
FAIL("expected APIError");
} catch (const cf::APIError& e) {
CHECK(e.status_code() == 502);
const auto body = json::parse(e.body());
CHECK(body.at("error").get<std::string>() == "timeout");
}
}
TEST_CASE("401 -> AuthError") {
MockServer mock{[](httplib::Server& s) {
s.Post("/run", [](const httplib::Request&, httplib::Response& res) {
res.status = 401;
res.set_content(R"({"detail":"bad token"})", "application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_bad"}};
CHECK_THROWS_AS(c.run(cf::RunRequest{.prompt = "hi"}), cf::AuthError);
}
TEST_CASE("run with files attaches list to body") {
MockServer mock{[](httplib::Server& s) {
s.Post("/run", [](const httplib::Request& req, httplib::Response& res) {
const auto body = json::parse(req.body);
REQUIRE(body.contains("files"));
REQUIRE(body.at("files").is_array());
CHECK(body.at("files").size() == 1);
CHECK(body.at("files")[0].get<std::string>() == "ff_abcd");
res.set_content(
R"({"ok":true,"result":"got file","duration_ms":1})",
"application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
const auto r = c.run(cf::RunRequest{
.prompt = "extract", .files = {"ff_abcd"}, .timeout_secs = 30});
CHECK(r.ok);
}
TEST_CASE("upload_file streams a multipart and parses FileToken") {
const std::string payload(1024 * 200, 'x'); // 200 KB
const auto path = write_temp_file(payload);
std::atomic<bool> saw_field{false};
std::atomic<std::size_t> received_size{0};
std::atomic<bool> saw_ttl{false};
MockServer mock{[&](httplib::Server& s) {
s.Post("/files", [&](const httplib::Request& req, httplib::Response& res) {
if (req.has_file("file")) {
saw_field = true;
received_size = req.get_file_value("file").content.size();
}
if (req.has_file("ttl_secs")) {
saw_ttl = true;
CHECK(req.get_file_value("ttl_secs").content == "120");
}
res.set_content(
R"({"file_token":"ff_xyz","ttl_secs":120,"size":204800})",
"application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
const auto ft = c.upload_file(path, 120);
CHECK(ft.file_token == "ff_xyz");
CHECK(ft.ttl_secs == 120);
CHECK(ft.size == 204800);
CHECK(saw_field.load());
CHECK(saw_ttl.load());
CHECK(received_size.load() == payload.size());
std::filesystem::remove(path);
}
TEST_CASE("admin token CRUD round-trip") {
std::vector<std::string> seen_methods;
std::vector<std::string> seen_paths;
MockServer mock{[&](httplib::Server& s) {
s.Post("/admin/tokens", [&](const httplib::Request& req, httplib::Response& res) {
seen_methods.emplace_back("POST");
seen_paths.emplace_back("/admin/tokens");
CHECK(req.get_header_value("Authorization") == "Bearer admin_secret");
const auto body = json::parse(req.body);
CHECK(body.at("name").get<std::string>() == "consumer-1");
res.set_content(
R"({"name":"consumer-1","token":"cf_NEWTOKEN","ip_cidrs":["10.0.0.0/8"]})",
"application/json");
});
s.Get("/admin/tokens", [&](const httplib::Request& req, httplib::Response& res) {
seen_methods.emplace_back("GET");
seen_paths.emplace_back("/admin/tokens");
CHECK(req.get_header_value("Authorization") == "Bearer admin_secret");
res.set_content(
R"({"tokens":[
{"name":"consumer-1","ip_cidrs":["10.0.0.0/8"],"created_at":1700000000,"extra":"yo"}
]})",
"application/json");
});
s.Delete(R"(/admin/tokens/(.+))",
[&](const httplib::Request& req, httplib::Response& res) {
seen_methods.emplace_back("DELETE");
seen_paths.emplace_back(req.path);
res.set_content(R"({"ok":true})", "application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(),
.token = "cf_test",
.admin_token = "admin_secret"}};
const auto created = c.create_token(cf::TokenCreateRequest{
.name = "consumer-1", .ip_cidrs = {"10.0.0.0/8"}});
CHECK(created.token == "cf_NEWTOKEN");
const auto list = c.list_tokens();
REQUIRE(list.size() == 1);
CHECK(list[0].name == "consumer-1");
REQUIRE(list[0].created_at.has_value());
CHECK(*list[0].created_at == 1700000000);
CHECK(list[0].extra.contains("extra"));
c.revoke_token("consumer-1");
REQUIRE(seen_methods.size() == 3);
CHECK(seen_methods[0] == "POST");
CHECK(seen_methods[1] == "GET");
CHECK(seen_methods[2] == "DELETE");
CHECK(seen_paths[2] == "/admin/tokens/consumer-1");
}
TEST_CASE("revoke_token without admin token throws AuthError") {
cf::Client c{cf::ClientOptions{.base_url = "http://127.0.0.1:1", .token = "cf_test"}};
CHECK_THROWS_AS(c.revoke_token("foo"), cf::AuthError);
}
TEST_CASE("transport-level failure surfaces as TransportError") {
// Port 1 is reserved (tcpmux); connect should refuse fast on Linux.
cf::Client c{cf::ClientOptions{
.base_url = "http://127.0.0.1:1",
.token = "cf_test",
.timeout = std::chrono::seconds{2},
.connect_timeout = std::chrono::seconds{1},
}};
auto fire = [&] {
auto h = c.healthz();
(void)h;
};
CHECK_THROWS_AS(fire(), cf::TransportError);
}
TEST_CASE("base_url is normalized (trailing slash trimmed)") {
MockServer mock{[](httplib::Server& s) {
s.Get("/healthz", [](const httplib::Request&, httplib::Response& res) {
res.set_content(R"({"ok":true,"claude_present":false})", "application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url() + "/", .token = "cf_test"}};
CHECK(c.base_url() == mock.base_url());
const auto h = c.healthz();
CHECK(h.ok);
CHECK_FALSE(h.claude_present);
CHECK(h.claude_version.empty());
}
TEST_CASE("invalid base_url scheme is rejected at construction") {
auto make_ftp = [] {
cf::ClientOptions opts;
opts.base_url = "ftp://nope";
opts.token = "x";
cf::Client c{std::move(opts)};
(void)c;
};
auto make_empty = [] {
cf::ClientOptions opts;
opts.base_url = "";
opts.token = "x";
cf::Client c{std::move(opts)};
(void)c;
};
CHECK_THROWS_AS(make_ftp(), cf::ProtocolError);
CHECK_THROWS_AS(make_empty(), cf::ProtocolError);
}