// test_http_request.cpp — Catch2 integration tests for fn_http::request. // Issue 0110. // // Strategy: spin up a local Python HTTP server fixture (stdlib BaseHTTPServer) // in a background process for the duration of the suite. Tests hit // http://127.0.0.1: instead of httpbin.org so the CI is deterministic // and works offline. The server echoes method, headers, and body as JSON. #include "catch_amalgamated.hpp" #include "core/http_request.h" #include #include #include #include #include #include #include #ifndef _WIN32 #include #include #include #endif namespace { constexpr const char* kHost = "127.0.0.1"; // Random-ish port in the upper unprivileged range. Tests are independent — // each suite picks one. Add jitter from PID to reduce collision risk. int pick_port() { int base = 47200; int jitter = (int)(getpid() % 100); return base + jitter; } struct PythonServer { pid_t pid = 0; int port = 0; bool start() { port = pick_port(); // Write the server script to a tmp file. std::string script_path = std::tmpnam(nullptr); script_path += ".py"; { std::ofstream f(script_path); f << "import json, sys\n" "from http.server import BaseHTTPRequestHandler, HTTPServer\n" "PORT = int(sys.argv[1])\n" "class H(BaseHTTPRequestHandler):\n" " def log_message(self, *a, **k): pass\n" " def _echo(self):\n" " n = int(self.headers.get('Content-Length','0') or '0')\n" " body = self.rfile.read(n).decode('utf-8','replace') if n else ''\n" " payload = {\n" " 'method': self.command,\n" " 'path': self.path,\n" " 'headers':{k:v for k,v in self.headers.items()},\n" " 'body': body,\n" " }\n" " out = json.dumps(payload).encode()\n" " self.send_response(200); self.send_header('content-type','application/json')\n" " self.send_header('x-fn-test','ok')\n" " self.send_header('content-length', str(len(out)))\n" " self.end_headers(); self.wfile.write(out)\n" " def do_GET(self):\n" " if self.path.startswith('/status/'):\n" " code = int(self.path.rsplit('/',1)[1])\n" " msg = ('error %d' % code).encode()\n" " self.send_response(code); self.send_header('content-length', str(len(msg))); self.end_headers(); self.wfile.write(msg); return\n" " if self.path == '/notjson':\n" " self.send_response(200); self.send_header('content-type','text/plain'); self.end_headers(); self.wfile.write(b'<>'); return\n" " if self.path.startswith('/slow/'):\n" " import time; time.sleep(float(self.path.rsplit('/',1)[1]));\n" " self._echo()\n" " def do_POST(self): self._echo()\n" " def do_PUT(self): self._echo()\n" " def do_DELETE(self):self._echo()\n" "HTTPServer(('127.0.0.1', PORT), H).serve_forever()\n"; } #ifdef _WIN32 return false; // tests skipped on Windows for now #else pid_t p = fork(); if (p < 0) return false; if (p == 0) { // child — redirect stdio to /dev/null and exec python3 FILE* dn = std::freopen("/dev/null", "r", stdin); (void)dn; FILE* d2 = std::freopen("/dev/null", "w", stdout); (void)d2; FILE* d3 = std::freopen("/dev/null", "w", stderr); (void)d3; execlp("python3", "python3", "-u", script_path.c_str(), std::to_string(port).c_str(), (char*)nullptr); std::_Exit(127); } pid = p; // Poll for readiness — up to 3s. for (int i = 0; i < 60; ++i) { std::this_thread::sleep_for(std::chrono::milliseconds(50)); fn_http::Request r; r.url = std::string("http://") + kHost + ":" + std::to_string(port) + "/ping"; r.timeout_ms = 500; auto res = fn_http::request(r); if (res.status == 200) return true; } return false; #endif } void stop() { #ifndef _WIN32 if (pid > 0) { kill(pid, SIGTERM); int st = 0; waitpid(pid, &st, 0); pid = 0; } #endif } std::string base() const { return std::string("http://") + kHost + ":" + std::to_string(port); } }; PythonServer& server() { static PythonServer s; static bool inited = false; if (!inited) { inited = true; s.start(); // Best-effort cleanup at process exit (Catch2 has no global teardown // hook portable to v3 amalgamated — atexit is fine). std::atexit([] { server().stop(); }); } return s; } bool server_alive() { return server().pid > 0; } } // anon TEST_CASE("request: GET 200 status + body", "[http_request]") { if (!server_alive()) { SUCCEED("python3 fixture not available — skipped"); return; } fn_http::Request r; r.method = "GET"; r.url = server().base() + "/get"; auto res = fn_http::request(r); REQUIRE(res.error.empty()); REQUIRE(res.status == 200); REQUIRE(res.body.find("\"method\": \"GET\"") != std::string::npos); REQUIRE(res.duration_ms >= 0); } TEST_CASE("request: GET 404 status (HTTP error not transport)", "[http_request]") { if (!server_alive()) { SUCCEED("python3 fixture not available — skipped"); return; } fn_http::Request r; r.method = "GET"; r.url = server().base() + "/status/404"; auto res = fn_http::request(r); REQUIRE(res.error.empty()); // 404 is NOT a transport error REQUIRE(res.status == 404); } TEST_CASE("request: POST with body + content-type header", "[http_request]") { if (!server_alive()) { SUCCEED("python3 fixture not available — skipped"); return; } fn_http::Request r; r.method = "POST"; r.url = server().base() + "/echo"; r.headers = {{"Content-Type", "application/json"}}; r.body = R"({"hello":"world"})"; auto res = fn_http::request(r); REQUIRE(res.error.empty()); REQUIRE(res.status == 200); REQUIRE(res.body.find("\"method\": \"POST\"") != std::string::npos); REQUIRE(res.body.find("hello") != std::string::npos); } TEST_CASE("request: bearer_token shortcut adds Authorization header", "[http_request]") { if (!server_alive()) { SUCCEED("python3 fixture not available — skipped"); return; } fn_http::Request r; r.method = "GET"; r.url = server().base() + "/whoami"; r.bearer_token = "s3cret_xyz"; auto res = fn_http::request(r); REQUIRE(res.error.empty()); REQUIRE(res.status == 200); REQUIRE(res.body.find("Bearer s3cret_xyz") != std::string::npos); } TEST_CASE("request: basic_user/basic_pass shortcut authenticates", "[http_request]") { if (!server_alive()) { SUCCEED("python3 fixture not available — skipped"); return; } fn_http::Request r; r.method = "GET"; r.url = server().base() + "/basic"; r.basic_user = "alice"; r.basic_pass = "wonderland"; auto res = fn_http::request(r); REQUIRE(res.error.empty()); REQUIRE(res.status == 200); // curl base64s "alice:wonderland" -> "YWxpY2U6d29uZGVybGFuZA==" REQUIRE(res.body.find("Basic YWxpY2U6d29uZGVybGFuZA==") != std::string::npos); } TEST_CASE("request: invalid URL surfaces transport error", "[http_request]") { // No server needed — this exercises curl's DNS/transport layer. fn_http::Request r; r.method = "GET"; r.url = "http://127.0.0.1:1/never_listens_here"; // port 1 is reserved r.timeout_ms = 1500; auto res = fn_http::request(r); REQUIRE(res.status == 0); REQUIRE_FALSE(res.error.empty()); } TEST_CASE("request: timeout_ms honored", "[http_request]") { if (!server_alive()) { SUCCEED("python3 fixture not available — skipped"); return; } fn_http::Request r; r.method = "GET"; r.url = server().base() + "/slow/3"; // server sleeps 3s r.timeout_ms = 1000; // we want bail at 1s auto t0 = std::chrono::steady_clock::now(); auto res = fn_http::request(r); auto elapsed_ms = std::chrono::duration_cast( std::chrono::steady_clock::now() - t0).count(); REQUIRE(res.status == 0); // timeout = transport error REQUIRE_FALSE(res.error.empty()); REQUIRE(elapsed_ms < 2500); // we did NOT wait the full 3s }