diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index 1afb8240..67fa23f7 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -256,6 +256,19 @@ add_fn_test(test_tql_to_sql test_tql_to_sql.cpp add_fn_test(test_llm_anthropic test_llm_anthropic.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/llm_anthropic.cpp) +# --- Issue 0110 — http_request: generic cURL-popen client ------------------- +# Integration test: fork+execs python3 -m http.server fixture in the test +# process. Skips gracefully if python3 isn't available (server().pid == 0). +add_fn_test(test_http_request test_http_request.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/http_request.cpp) + +# --- Issue 0110 — http_get_json: nlohmann::json wrapper over http_request --- +add_fn_test(test_http_get_json test_http_get_json.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/http_request.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/http_get_json.cpp) +target_include_directories(test_http_get_json PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../vendor) + # --- Visual golden-image diff (issue 0048) --------------------------------- # El binario primitives_gallery se compila con --capture; el test compara los # PNGs generados con los goldens en cpp/tests/golden/. Si no hay goldens o el diff --git a/cpp/tests/test_http_get_json.cpp b/cpp/tests/test_http_get_json.cpp new file mode 100644 index 00000000..1a845a26 --- /dev/null +++ b/cpp/tests/test_http_get_json.cpp @@ -0,0 +1,145 @@ +// test_http_get_json.cpp — Catch2 integration tests for fn_http::get_json. +// Issue 0110. Uses the same Python HTTP fixture as test_http_request.cpp but +// scoped locally to keep tests independent (each binary owns its own server). +#include "catch_amalgamated.hpp" +#include "core/http_get_json.h" +#include "core/http_request.h" +#include "nlohmann/json.hpp" + +#include +#include +#include +#include +#include +#include + +#ifndef _WIN32 +#include +#include +#include +#endif + +namespace { + +constexpr const char* kHost = "127.0.0.1"; + +int pick_port() { + return 47300 + (int)(getpid() % 100); +} + +struct PythonServer { + pid_t pid = 0; + int port = 0; + + bool start() { + port = pick_port(); + 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 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" + " payload = {\n" + " 'ok':True,\n" + " 'path':self.path,\n" + " 'auth':self.headers.get('Authorization','')\n" + " }\n" + " out = json.dumps(payload).encode()\n" + " self.send_response(200); self.send_header('content-type','application/json'); self.send_header('content-length',str(len(out))); self.end_headers(); self.wfile.write(out)\n" + "HTTPServer(('127.0.0.1', PORT), H).serve_forever()\n"; + } + +#ifdef _WIN32 + return false; +#else + pid_t p = fork(); + if (p < 0) return false; + if (p == 0) { + 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; + 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(); + std::atexit([] { server().stop(); }); + } + return s; +} + +bool server_alive() { return server().pid > 0; } + +} // anon + +TEST_CASE("get_json: parses 200 JSON body", "[http_get_json]") { + if (!server_alive()) { SUCCEED("python3 fixture not available — skipped"); return; } + auto j = fn_http::get_json(server().base() + "/echo"); + REQUIRE(j.is_object()); + REQUIRE(j.at("ok").get() == true); + REQUIRE(j.at("path").get() == "/echo"); +} + +TEST_CASE("get_json: throws on 404", "[http_get_json]") { + if (!server_alive()) { SUCCEED("python3 fixture not available — skipped"); return; } + REQUIRE_THROWS_AS( + fn_http::get_json(server().base() + "/status/404"), + std::runtime_error); +} + +TEST_CASE("get_json: bearer_token reaches server", "[http_get_json]") { + if (!server_alive()) { SUCCEED("python3 fixture not available — skipped"); return; } + auto j = fn_http::get_json(server().base() + "/whoami", "tok_42"); + REQUIRE(j.at("auth").get() == "Bearer tok_42"); +} + +TEST_CASE("get_json: throws on invalid JSON", "[http_get_json]") { + if (!server_alive()) { SUCCEED("python3 fixture not available — skipped"); return; } + REQUIRE_THROWS_AS( + fn_http::get_json(server().base() + "/notjson"), + std::runtime_error); +} diff --git a/cpp/tests/test_http_request.cpp b/cpp/tests/test_http_request.cpp new file mode 100644 index 00000000..da84ffd4 --- /dev/null +++ b/cpp/tests/test_http_request.cpp @@ -0,0 +1,231 @@ +// 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 +}