auto(0129): agents_dashboard — secret_store_cpp_infra + CMakeLists register #4
@@ -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
|
||||
|
||||
@@ -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 <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#ifndef _WIN32
|
||||
#include <signal.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
#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'<<not json>>'); 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<bool>() == true);
|
||||
REQUIRE(j.at("path").get<std::string>() == "/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<std::string>() == "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);
|
||||
}
|
||||
@@ -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:<PORT> 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 <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <sys/types.h>
|
||||
#include <thread>
|
||||
|
||||
#ifndef _WIN32
|
||||
#include <signal.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
#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'<<not json>>'); 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::milliseconds>(
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user