kbot cpp testing

This commit is contained in:
lovebird 2026-03-30 12:07:13 +02:00
parent f0385f41ec
commit fcdf03faed
15 changed files with 215 additions and 900 deletions

View File

@ -1,13 +1,31 @@
/**
* orchestrator/presets.js defaults for IPC integration tests (extend here as suites grow).
*
* Llama local runner (llama-basics.test.ts): OpenAI-compatible API at http://localhost:8888/v1,
* router `ollama` + `base_url` override, model `default` (server picks loaded GGUF).
*/
import { resolve } from 'node:path';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { probeTcpPort } from './test-commons.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
export const platform = {
isWin: process.platform === 'win32',
};
/** kbot/cpp root (parent of orchestrator/). */
export const paths = {
orchestratorDir: __dirname,
cppRoot: resolve(__dirname, '..'),
/** Same as packages/kbot/cpp/scripts/run-7b.sh — llama-server on :8888 */
run7bScript: resolve(__dirname, '../scripts/run-7b.sh'),
};
/** Dist binary name for the current OS. */
export function exeName() {
return platform.isWin ? 'kbot.exe' : 'kbot';
@ -38,6 +56,10 @@ export const uds = {
export const timeouts = {
ipcDefault: 5000,
kbotAi: 180_000,
/** Llama local arithmetic (same order of magnitude as kbot-ai). */
llamaKbotAi: 180_000,
/** Max wait for :8888 after spawning run-7b.sh (model load can be slow). */
llamaServerStart: Number(process.env.KBOT_LLAMA_START_TIMEOUT_MS || 600_000),
connectAttempts: 15,
connectRetryMs: 400,
postShutdownMs: 200,
@ -50,6 +72,47 @@ export const router = {
},
};
/**
* Local llama.cpp HTTP server mirrors tests/unit/llama-basics.test.ts (LLAMA_OPTS).
* Uses router `ollama` so api_key resolves to dummy `ollama`; `base_url` points at :8888/v1.
*/
export const llama = {
get port() {
return Number(process.env.KBOT_LLAMA_PORT || 8888);
},
get host() {
return process.env.KBOT_LLAMA_HOST || '127.0.0.1';
},
get baseURL() {
return process.env.KBOT_LLAMA_BASE_URL || `http://localhost:${this.port}/v1`;
},
router: 'ollama',
get model() {
return process.env.KBOT_LLAMA_MODEL || 'default';
},
prompts: {
/** Same idea as llama-basics completion tests. */
add5_3: 'What is 5 + 3? Reply with just the number, nothing else.',
},
};
/**
* IPC payload for kbot-ai local llama-server (OpenAI-compatible).
* Pass `base_url` so LLMClient uses port 8888 instead of default ollama :11434.
*/
export function kbotAiPayloadLlamaLocal(overrides = {}) {
const merged = {
prompt: llama.prompts.add5_3,
router: llama.router,
model: llama.model,
base_url: llama.baseURL,
...overrides,
};
merged.base_url = merged.base_url ?? merged.baseURL ?? llama.baseURL;
delete merged.baseURL;
return merged;
}
/** Stock prompts and assertions helpers for LLM smoke tests. */
export const prompts = {
germanyCapital: 'What is the capital of Germany? Reply in one short sentence.',
@ -71,3 +134,53 @@ export function kbotAiPayloadFromEnv() {
export function usingDefaultGermanyPrompt() {
return !process.env.KBOT_IPC_PROMPT;
}
/**
* If nothing listens on llama.port, optionally spawn `scripts/run-7b.sh` (requires `sh` on PATH, e.g. Git Bash on Windows).
*
* @param {{ autostart?: boolean, startTimeoutMs?: number }} [opts]
* @returns {Promise<{ ok: boolean, alreadyRunning: boolean, started?: boolean, pid?: number }>}
*/
export async function ensureLlamaLocalServer(opts = {}) {
const autostart = opts.autostart ?? true;
const startTimeoutMs = opts.startTimeoutMs ?? timeouts.llamaServerStart;
const host = llama.host;
const port = llama.port;
const scriptPath = paths.run7bScript;
if (await probeTcpPort(host, port, 1500)) {
return { ok: true, alreadyRunning: true };
}
if (!autostart) {
throw new Error(
`[llama] Nothing listening on ${host}:${port}. Start the server (e.g. sh scripts/run-7b.sh), or remove KBOT_IPC_LLAMA_AUTOSTART=0 to allow autostart`
);
}
if (!existsSync(scriptPath)) {
throw new Error(`[llama] Script missing: ${scriptPath}`);
}
console.log(`[llama] Port ${port} closed — starting ${scriptPath} (timeout ${startTimeoutMs}ms) …`);
const child = spawn('sh', [scriptPath], {
detached: true,
stdio: 'ignore',
cwd: dirname(scriptPath),
env: { ...process.env },
});
child.unref();
const deadline = Date.now() + startTimeoutMs;
while (Date.now() < deadline) {
if (await probeTcpPort(host, port, 1500)) {
return { ok: true, alreadyRunning: false, started: true, pid: child.pid };
}
await new Promise((r) => setTimeout(r, 1500));
}
throw new Error(
`[llama] Server did not open ${host}:${port} within ${startTimeoutMs}ms — check llama-server / GPU / model path`
);
}

View File

@ -145,6 +145,9 @@ export function renderMarkdownReport(payload) {
if (payload.passed != null) lines.push(`| Assertions passed | ${payload.passed} |`);
if (payload.failed != null) lines.push(`| Assertions failed | ${payload.failed} |`);
if (payload.ipcLlm != null) lines.push(`| IPC LLM step | ${payload.ipcLlm ? 'enabled' : 'skipped'} |`);
if (payload.ipcLlama != null) {
lines.push(`| IPC llama :8888 step | ${payload.ipcLlama ? 'enabled' : 'skipped'} |`);
}
lines.push(`| CWD | \`${String(meta.cwd ?? '').replace(/`/g, "'")}\` |`);
lines.push('');

View File

@ -3,6 +3,7 @@
*/
import { randomUUID } from 'node:crypto';
import net from 'node:net';
/** kbot-ai live call runs unless KBOT_IPC_LLM is explicitly disabled. */
export function ipcLlmEnabled() {
@ -13,6 +14,49 @@ export function ipcLlmEnabled() {
return true;
}
/** Llama local (:8888) IPC block — on by default; set KBOT_IPC_LLAMA=0 to skip (CI / no server). */
export function ipcLlamaEnabled() {
const v = process.env.KBOT_IPC_LLAMA;
if (v === undefined || v === '') return true;
const s = String(v).trim().toLowerCase();
if (s === '0' || s === 'false' || s === 'no' || s === 'off') return false;
return true;
}
/** Auto-start scripts/run-7b.sh when :8888 is closed (default on). */
export function llamaAutostartEnabled() {
const v = process.env.KBOT_IPC_LLAMA_AUTOSTART;
if (v === undefined || v === '') return true;
const s = String(v).trim().toLowerCase();
if (s === '0' || s === 'false' || s === 'no' || s === 'off') return false;
return true;
}
/** TCP connect probe — true if something accepts connections. */
export function probeTcpPort(host, port, timeoutMs = 2000) {
return new Promise((resolve) => {
const socket = net.connect({ port, host });
const done = (ok) => {
socket.removeAllListeners();
try {
socket.destroy();
} catch {
/* ignore */
}
resolve(ok);
};
const timer = setTimeout(() => done(false), timeoutMs);
socket.once('connect', () => {
clearTimeout(timer);
done(true);
});
socket.once('error', () => {
clearTimeout(timer);
done(false);
});
});
}
/** Counters for a test run (create one per process / suite). */
export function createAssert() {
let passed = 0;

View File

@ -11,6 +11,9 @@
* KBOT_IPC_MODEL optional model id (e.g. openrouter slug); else C++ default for that router
* KBOT_IPC_PROMPT custom prompt (default: capital of Germany; asserts "berlin" in reply)
* KBOT_IPC_LLM_LOG_MAX max chars to print for LLM text (default: unlimited)
* KBOT_IPC_LLAMA llama :8888 step on by default; set 0/false/no/off to skip
* KBOT_IPC_LLAMA_AUTOSTART if 0, do not spawn scripts/run-7b.sh when :8888 is closed
* KBOT_LLAMA_* KBOT_LLAMA_PORT, KBOT_LLAMA_BASE_URL, KBOT_LLAMA_MODEL, KBOT_LLAMA_START_TIMEOUT_MS
*
* Shared: presets.js, test-commons.js, reports.js
* Report: cwd/tests/test-ipc__HH-mm.{json,md} (see reports.js)
@ -28,13 +31,17 @@ import {
uds,
timeouts,
kbotAiPayloadFromEnv,
kbotAiPayloadLlamaLocal,
usingDefaultGermanyPrompt,
ensureLlamaLocalServer,
} from './presets.js';
import {
createAssert,
payloadObj,
logKbotAiResponse,
ipcLlmEnabled,
ipcLlamaEnabled,
llamaAutostartEnabled,
createIpcClient,
pipeWorkerStderr,
} from './test-commons.js';
@ -155,8 +162,43 @@ async function run() {
console.log('6. kbot-ai — skipped (KBOT_IPC_LLM=0/false/no/off; default is to run live LLM)');
}
// ── 7. Graceful shutdown ────────────────────────────────────────────────
console.log('7. Graceful shutdown');
// ── 7. kbot-ai — llama local :8888 (optional; llama-basics parity) ───────
if (ipcLlamaEnabled()) {
console.log('7. kbot-ai → llama local runner (OpenAI :8888, presets.llama)');
let llamaReady = false;
try {
await ensureLlamaLocalServer({
autostart: llamaAutostartEnabled(),
startTimeoutMs: timeouts.llamaServerStart,
});
llamaReady = true;
} catch (e) {
console.error(`${e?.message ?? e}`);
}
assert(llamaReady, 'llama-server listening on :8888 (or autostart run-7b.sh succeeded)');
if (llamaReady) {
const llamaPayload = kbotAiPayloadLlamaLocal();
const llamaRes = await ipc.request(
{ type: 'kbot-ai', payload: llamaPayload },
timeouts.llamaKbotAi
);
assert(llamaRes.type === 'job_result', `llama IPC response type job_result (got "${llamaRes.type}")`);
const llp = payloadObj(llamaRes);
assert(llp?.status === 'success', `llama payload status success (got "${llp?.status}")`);
assert(
typeof llp?.text === 'string' && llp.text.trim().length >= 1,
`llama assistant text present (length ${(llp?.text || '').length})`
);
assert(/\b8\b/.test(String(llp?.text || '')), 'llama arithmetic: reply mentions 8 (5+3)');
logKbotAiResponse('kbot-ai llama local', llamaRes);
}
} else {
console.log('7. kbot-ai llama local — skipped (KBOT_IPC_LLAMA=0; default is to run)');
}
// ── 8. Graceful shutdown ────────────────────────────────────────────────
console.log('8. Graceful shutdown');
const shutdownRes = await ipc.request({ type: 'shutdown' }, timeouts.ipcDefault);
assert(shutdownRes.type === 'shutdown_ack', `Shutdown acknowledged (got "${shutdownRes.type}")`);
@ -180,11 +222,16 @@ async function run() {
failed: stats.failed,
ok: stats.failed === 0,
ipcLlm: ipcLlmEnabled(),
ipcLlama: ipcLlamaEnabled(),
env: {
KBOT_IPC_LLM: process.env.KBOT_IPC_LLM ?? null,
KBOT_IPC_LLAMA: process.env.KBOT_IPC_LLAMA ?? null,
KBOT_IPC_LLAMA_AUTOSTART: process.env.KBOT_IPC_LLAMA_AUTOSTART ?? null,
KBOT_ROUTER: process.env.KBOT_ROUTER ?? null,
KBOT_IPC_MODEL: process.env.KBOT_IPC_MODEL ?? null,
KBOT_IPC_PROMPT: process.env.KBOT_IPC_PROMPT ?? null,
KBOT_LLAMA_PORT: process.env.KBOT_LLAMA_PORT ?? null,
KBOT_LLAMA_BASE_URL: process.env.KBOT_LLAMA_BASE_URL ?? null,
},
metrics: buildMetricsBundle(ipcMetricsCollector, ipcRunStartedAt, finishedAt),
},

View File

@ -286,14 +286,9 @@ All packages depend on `logger` and `json` implicitly.
## Testing
### Unit tests (Catch2) — 62 tests, 248 assertions ✅
### Unit tests (Catch2)
| Test file | Tests | Assertions | Validates |
|-----------|-------|------------|-----------|
| `test_geo.cpp` | 23 | 77 | Haversine, area, centroid, PIP, hex/square grid |
| `test_gadm_reader.cpp` | 18 | 53 | JSON parsing, GHS props, fallback resolution |
| `test_grid.cpp` | 13 | 105 | All 4 modes × 5 sorts, GHS filtering, PIP clipping |
| `test_search.cpp` | 8 | 13 | Config loading, key validation, error handling |
Catch2 targets live in `tests/CMakeLists.txt` (e.g. `test_logger`, `test_html`, `test_postgres`, `test_json`, `test_http`, `test_polymech`, `test_cmd_kbot`, `test_ipc`, `test_functional`, e2e targets). The old geo / gadm_reader / grid / search / enrichers / `test_postgres_live` suites were removed with those package implementations.
### Integration test (Node.js)

View File

@ -1,2 +0,0 @@
ollama run qwen2.5-coder:latest

View File

@ -1,4 +1,4 @@
./llama-server.exe \
llama-server.exe \
--hf-repo paultimothymooney/Qwen2.5-7B-Instruct-Q4_K_M-GGUF \
--hf-file qwen2.5-7b-instruct-q4_k_m.gguf \
-t 16 \

View File

@ -102,6 +102,8 @@ int run_kbot_ai_ipc(const std::string& payload, const std::string& jobId, const
if (doc.HasMember("api_key") && doc["api_key"].IsString()) opts.api_key = doc["api_key"].GetString();
if (doc.HasMember("router") && doc["router"].IsString()) opts.router = doc["router"].GetString();
if (doc.HasMember("model") && doc["model"].IsString()) opts.model = doc["model"].GetString();
if (doc.HasMember("base_url") && doc["base_url"].IsString()) opts.base_url = doc["base_url"].GetString();
else if (doc.HasMember("baseURL") && doc["baseURL"].IsString()) opts.base_url = doc["baseURL"].GetString();
}
if (opts.api_key.empty()) {

View File

@ -36,10 +36,6 @@ add_executable(test_supabase e2e/test_supabase.cpp)
target_link_libraries(test_supabase PRIVATE Catch2::Catch2WithMain tomlplusplus::tomlplusplus logger postgres json Threads::Threads)
catch_discover_tests(test_supabase WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
add_executable(test_postgres_live functional/test_postgres_live.cpp)
target_link_libraries(test_postgres_live PRIVATE Catch2::Catch2WithMain postgres search json logger tomlplusplus::tomlplusplus Threads::Threads)
catch_discover_tests(test_postgres_live WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
add_executable(test_polymech unit/test_polymech.cpp)
target_link_libraries(test_polymech PRIVATE Catch2::Catch2WithMain polymech postgres Threads::Threads)
catch_discover_tests(test_polymech)
@ -56,23 +52,3 @@ catch_discover_tests(test_polymech_e2e WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
add_executable(test_ipc unit/test_ipc.cpp)
target_link_libraries(test_ipc PRIVATE Catch2::Catch2WithMain ipc Threads::Threads)
catch_discover_tests(test_ipc)
add_executable(test_geo unit/test_geo.cpp)
target_link_libraries(test_geo PRIVATE Catch2::Catch2WithMain geo Threads::Threads)
catch_discover_tests(test_geo)
add_executable(test_gadm_reader unit/test_gadm_reader.cpp)
target_link_libraries(test_gadm_reader PRIVATE Catch2::Catch2WithMain gadm_reader Threads::Threads)
catch_discover_tests(test_gadm_reader WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
add_executable(test_grid unit/test_grid.cpp)
target_link_libraries(test_grid PRIVATE Catch2::Catch2WithMain grid Threads::Threads)
catch_discover_tests(test_grid WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
add_executable(test_search unit/test_search.cpp)
target_link_libraries(test_search PRIVATE Catch2::Catch2WithMain search Threads::Threads)
catch_discover_tests(test_search WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
add_executable(test_enrichers unit/test_enrichers.cpp)
target_link_libraries(test_enrichers PRIVATE Catch2::Catch2WithMain enrichers Threads::Threads)
catch_discover_tests(test_enrichers)

View File

@ -1,81 +0,0 @@
#include <catch2/catch_test_macros.hpp>
#include "postgres/postgres.h"
#include "search/search.h"
#include "json/json.h"
#include "logger/logger.h"
#include <toml++/toml.h>
// Note: This test requires a valid config/postgres.toml pointing to a Supabase instance.
// We test against an arbitrary table 'test_items' or standard table.
// In this case we'll test against `grid_search_runs` since we know it exists,
// using a dummy uuid for testing.
// DO NOT RUN UNLESS CONFIGURED.
TEST_CASE("Postgres Live Operations", "[postgres_live]") {
// Load config
std::string supabase_url;
std::string supabase_key;
try {
auto config = toml::parse_file("config/postgres.toml");
supabase_url = config["supabase"]["url"].value_or("");
supabase_key = config["supabase"]["service_key"].value_or("");
} catch (const std::exception &e) {
WARN("Skipping postgres live tests. Config missing or invalid: " << e.what());
return;
}
if (supabase_url.empty() || supabase_key.empty()) {
WARN("Skipping postgres live tests. Supabase credentials missing.");
return;
}
postgres::Config pg_cfg;
pg_cfg.supabase_url = supabase_url;
pg_cfg.supabase_key = supabase_key;
postgres::init(pg_cfg);
REQUIRE(postgres::ping() == "ok");
std::string test_id = "00000000-0000-0000-0000-0000000000cc";
std::string user_id = "3bb4cfbf-318b-44d3-a9d3-35680e738421";
SECTION("Insert, Query, Update, Upsert, Delete") {
// 1. Clean up first just in case
postgres::del("grid_search_runs", "id=eq." + test_id);
// 2. Insert
std::string insert_body = R"({"id": ")" + test_id + R"(", "user_id": ")" + user_id + R"(", "run_id": "test_run", "status": "searching", "request": {}})";
std::string res1 = postgres::insert("grid_search_runs", insert_body);
// 3. Query
std::string res2 = postgres::query("grid_search_runs", "*", "id=eq." + test_id);
WARN("Insert Result: " << res1);
WARN("Query Result: " << res2);
REQUIRE(json::is_valid(res2));
REQUIRE(res2.find("test_run") != std::string::npos);
// 4. Update
std::string update_body = R"({"status": "enriching"})";
std::string res3 = postgres::update("grid_search_runs", update_body, "id=eq." + test_id);
REQUIRE(json::is_valid(res3));
REQUIRE(res3.find("error") == std::string::npos);
// 5. Upsert
std::string upsert_body = R"({"id": ")" + test_id + R"(", "user_id": ")" + user_id + R"(", "run_id": "upsert_run", "status": "complete", "request": {}})";
std::string res4 = postgres::upsert("grid_search_runs", upsert_body, "id");
REQUIRE(res4.find("error") == std::string::npos);
// Query again to verify upsert
std::string res5 = postgres::query("grid_search_runs", "*", "id=eq." + test_id);
REQUIRE(res5.find("upsert_run") != std::string::npos);
// 6. Delete
std::string res6 = postgres::del("grid_search_runs", "id=eq." + test_id);
REQUIRE(json::is_valid(res6));
// Verify deleted
std::string res7 = postgres::query("grid_search_runs", "*", "id=eq." + test_id);
REQUIRE(res7 == "[]");
}
}

View File

@ -1,115 +0,0 @@
#include <catch2/catch_test_macros.hpp>
#include "enrichers/enrichers.h"
using namespace enrichers;
// ── is_likely_email ─────────────────────────────────────────────────────────
TEST_CASE("is_likely_email: valid emails", "[enrichers]") {
CHECK(is_likely_email("info@example.com"));
CHECK(is_likely_email("john.doe@company.co.uk"));
CHECK(is_likely_email("contact@recycling-firm.de"));
CHECK(is_likely_email("hello@my-domain.org"));
}
TEST_CASE("is_likely_email: rejects non-emails", "[enrichers]") {
CHECK_FALSE(is_likely_email(""));
CHECK_FALSE(is_likely_email("not-an-email"));
CHECK_FALSE(is_likely_email("@no-user.com"));
CHECK_FALSE(is_likely_email("user@"));
}
TEST_CASE("is_likely_email: rejects asset extensions", "[enrichers]") {
CHECK_FALSE(is_likely_email("logo@site.png"));
CHECK_FALSE(is_likely_email("icon@site.svg"));
CHECK_FALSE(is_likely_email("style@site.css"));
CHECK_FALSE(is_likely_email("script@site.js"));
CHECK_FALSE(is_likely_email("photo@site.jpg"));
CHECK_FALSE(is_likely_email("photo@site.webp"));
}
TEST_CASE("is_likely_email: rejects placeholder/hash patterns", "[enrichers]") {
CHECK_FALSE(is_likely_email("user@example.com"));
CHECK_FALSE(is_likely_email("test@test.com"));
CHECK_FALSE(is_likely_email("a3f2b@hash.com"));
CHECK_FALSE(is_likely_email("your@email.com"));
CHECK_FALSE(is_likely_email("email@email.com"));
CHECK_FALSE(is_likely_email("name@domain.com"));
}
// ── extract_emails ──────────────────────────────────────────────────────────
TEST_CASE("extract_emails: finds emails in text", "[enrichers]") {
auto emails = extract_emails("Contact us at info@example.org or sales@company.com");
CHECK(emails.size() >= 2);
bool found_info = false, found_sales = false;
for (auto& e : emails) {
if (e == "info@example.org") found_info = true;
if (e == "sales@company.com") found_sales = true;
}
CHECK(found_info);
CHECK(found_sales);
}
TEST_CASE("extract_emails: deduplicates", "[enrichers]") {
auto emails = extract_emails("info@acme.org info@acme.org info@acme.org");
CHECK(emails.size() == 1);
}
TEST_CASE("extract_emails: empty text returns empty", "[enrichers]") {
auto emails = extract_emails("");
CHECK(emails.empty());
}
TEST_CASE("extract_emails: filters out asset emails", "[enrichers]") {
auto emails = extract_emails("logo@site.png info@real-company.de");
CHECK(emails.size() == 1);
CHECK(emails[0] == "info@real-company.de");
}
// ── resolve_url ─────────────────────────────────────────────────────────────
TEST_CASE("resolve_url: absolute stays absolute", "[enrichers]") {
CHECK(resolve_url("https://example.com", "https://other.com/page") == "https://other.com/page");
}
TEST_CASE("resolve_url: relative path", "[enrichers]") {
auto r = resolve_url("https://example.com/page", "/contact");
CHECK(r == "https://example.com/contact");
}
TEST_CASE("resolve_url: protocol-relative", "[enrichers]") {
auto r = resolve_url("https://example.com", "//other.com/foo");
CHECK(r == "https://other.com/foo");
}
TEST_CASE("resolve_url: relative without slash", "[enrichers]") {
auto r = resolve_url("https://example.com/dir/page", "about.html");
CHECK(r == "https://example.com/dir/about.html");
}
// ── status_string ───────────────────────────────────────────────────────────
TEST_CASE("status_string: covers all statuses", "[enrichers]") {
CHECK(std::string(status_string(EnrichStatus::OK)) == "OK");
CHECK(std::string(status_string(EnrichStatus::NO_EMAIL)) == "NO_EMAIL");
CHECK(std::string(status_string(EnrichStatus::META_TIMEOUT)) == "META_TIMEOUT");
CHECK(std::string(status_string(EnrichStatus::EMAIL_TIMEOUT)) == "EMAIL_TIMEOUT");
CHECK(std::string(status_string(EnrichStatus::FETCH_ERROR)) == "FETCH_ERROR");
CHECK(std::string(status_string(EnrichStatus::NO_PAGES)) == "NO_PAGES");
CHECK(std::string(status_string(EnrichStatus::ERROR)) == "ERROR");
}
// ── EnrichConfig defaults ───────────────────────────────────────────────────
TEST_CASE("EnrichConfig: default values", "[enrichers]") {
EnrichConfig cfg;
CHECK(cfg.meta_timeout_ms == 20000);
CHECK(cfg.email_timeout_ms == 30000);
CHECK(cfg.email_page_timeout_ms == 10000);
CHECK(cfg.email_max_pages == 8);
CHECK(cfg.email_abort_after == 1);
CHECK_FALSE(cfg.contact_patterns.empty());
CHECK_FALSE(cfg.probe_paths.empty());
}

View File

@ -1,163 +0,0 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "gadm_reader/gadm_reader.h"
#include <cmath>
using namespace gadm;
using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;
// ── Helper: fixtures path ───────────────────────────────────────────────────
// Tests are run with WORKING_DIRECTORY = CMAKE_SOURCE_DIR (server/cpp)
static const std::string CACHE_DIR = "cache/gadm";
// ── country_code ────────────────────────────────────────────────────────────
TEST_CASE("country_code: simple ISO3", "[gadm][util]") {
REQUIRE(country_code("ABW") == "ABW");
}
TEST_CASE("country_code: dotted GID", "[gadm][util]") {
REQUIRE(country_code("AFG.1.1_1") == "AFG");
REQUIRE(country_code("ESP.6.1_1") == "ESP");
}
// ── infer_level ─────────────────────────────────────────────────────────────
TEST_CASE("infer_level: level 0 (country)", "[gadm][util]") {
REQUIRE(infer_level("ABW") == 0);
REQUIRE(infer_level("AFG") == 0);
}
TEST_CASE("infer_level: level 1", "[gadm][util]") {
REQUIRE(infer_level("AFG.1_1") == 1);
}
TEST_CASE("infer_level: level 2", "[gadm][util]") {
REQUIRE(infer_level("AFG.1.1_1") == 2);
}
TEST_CASE("infer_level: level 3", "[gadm][util]") {
REQUIRE(infer_level("ESP.6.1.4_1") == 3);
}
// ── load_boundary_file: ABW level 0 ────────────────────────────────────────
TEST_CASE("Load ABW level 0: basic structure", "[gadm][file]") {
auto res = load_boundary_file(CACHE_DIR + "/boundary_ABW_0.json");
REQUIRE(res.error.empty());
REQUIRE(res.features.size() == 1);
const auto& f = res.features[0];
REQUIRE(f.gid == "ABW");
REQUIRE(f.name == "Aruba");
REQUIRE(f.level == 0);
REQUIRE(f.isOuter == true);
}
TEST_CASE("Load ABW level 0: has rings", "[gadm][file]") {
auto res = load_boundary_file(CACHE_DIR + "/boundary_ABW_0.json");
REQUIRE(res.error.empty());
const auto& f = res.features[0];
REQUIRE(f.rings.size() >= 1);
REQUIRE(f.rings[0].size() > 10); // ABW has ~55 coords
}
TEST_CASE("Load ABW level 0: GHS population data", "[gadm][file]") {
auto res = load_boundary_file(CACHE_DIR + "/boundary_ABW_0.json");
REQUIRE(res.error.empty());
const auto& f = res.features[0];
REQUIRE_THAT(f.ghsPopulation, WithinRel(104847.0, 0.01));
REQUIRE(f.ghsPopCenters.size() == 5);
// First pop center: [-70.04183, 12.53341, 104.0]
REQUIRE_THAT(f.ghsPopCenters[0][0], WithinAbs(-70.04183, 0.0001));
REQUIRE_THAT(f.ghsPopCenters[0][1], WithinAbs(12.53341, 0.0001));
REQUIRE_THAT(f.ghsPopCenters[0][2], WithinAbs(104.0, 0.1));
}
TEST_CASE("Load ABW level 0: GHS built data", "[gadm][file]") {
auto res = load_boundary_file(CACHE_DIR + "/boundary_ABW_0.json");
REQUIRE(res.error.empty());
const auto& f = res.features[0];
REQUIRE_THAT(f.ghsBuiltWeight, WithinRel(22900682.0, 0.01));
REQUIRE(f.ghsBuiltCenters.size() == 5);
REQUIRE_THAT(f.ghsBuiltCenter.lon, WithinAbs(-69.99304, 0.001));
REQUIRE_THAT(f.ghsBuiltCenter.lat, WithinAbs(12.51234, 0.001));
}
TEST_CASE("Load ABW level 0: computed bbox", "[gadm][file]") {
auto res = load_boundary_file(CACHE_DIR + "/boundary_ABW_0.json");
REQUIRE(res.error.empty());
const auto& f = res.features[0];
// ABW bbox should be roughly in the Caribbean
REQUIRE(f.bbox.minLon < -69.8);
REQUIRE(f.bbox.maxLon > -70.1);
REQUIRE(f.bbox.minLat > 12.4);
REQUIRE(f.bbox.maxLat < 12.7);
}
TEST_CASE("Load ABW level 0: computed area", "[gadm][file]") {
auto res = load_boundary_file(CACHE_DIR + "/boundary_ABW_0.json");
REQUIRE(res.error.empty());
const auto& f = res.features[0];
// Aruba is ~180 km²
REQUIRE_THAT(f.areaSqKm, WithinRel(180.0, 0.15)); // 15% tolerance
}
// ── load_boundary_file: AFG level 2 ────────────────────────────────────────
TEST_CASE("Load AFG.1.1_1 level 2: basic structure", "[gadm][file]") {
auto res = load_boundary_file(CACHE_DIR + "/boundary_AFG.1.1_1_2.json");
REQUIRE(res.error.empty());
REQUIRE(res.features.size() == 1);
const auto& f = res.features[0];
REQUIRE(f.gid == "AFG.1.1_1");
REQUIRE(f.name == "Baharak");
REQUIRE(f.level == 2);
}
TEST_CASE("Load AFG.1.1_1 level 2: has GHS data", "[gadm][file]") {
auto res = load_boundary_file(CACHE_DIR + "/boundary_AFG.1.1_1_2.json");
REQUIRE(res.error.empty());
const auto& f = res.features[0];
REQUIRE(f.ghsPopCenters.size() == 5);
REQUIRE(f.ghsBuiltCenters.size() == 5);
REQUIRE(f.ghsPopulation > 0);
}
// ── load_boundary: path resolution ──────────────────────────────────────────
TEST_CASE("load_boundary: direct GID match", "[gadm][resolve]") {
auto res = load_boundary("ABW", 0, CACHE_DIR);
REQUIRE(res.error.empty());
REQUIRE(res.features.size() == 1);
REQUIRE(res.features[0].gid == "ABW");
}
TEST_CASE("load_boundary: sub-region GID", "[gadm][resolve]") {
auto res = load_boundary("AFG.1.1_1", 2, CACHE_DIR);
REQUIRE(res.error.empty());
REQUIRE(res.features[0].gid == "AFG.1.1_1");
}
TEST_CASE("load_boundary: missing file returns error", "[gadm][resolve]") {
auto res = load_boundary("DOESNOTEXIST", 0, CACHE_DIR);
REQUIRE(!res.error.empty());
REQUIRE(res.features.empty());
}
// ── Error handling ──────────────────────────────────────────────────────────
TEST_CASE("load_boundary_file: nonexistent file", "[gadm][error]") {
auto res = load_boundary_file("nonexistent.json");
REQUIRE(!res.error.empty());
REQUIRE(res.features.empty());
}

View File

@ -1,209 +0,0 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "geo/geo.h"
#include <cmath>
using namespace geo;
using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;
// ── Distance ────────────────────────────────────────────────────────────────
TEST_CASE("Haversine distance: known reference values", "[geo][distance]") {
// London to Paris: ~343 km
Coord london{-0.1278, 51.5074};
Coord paris{2.3522, 48.8566};
double d = distance_km(london, paris);
REQUIRE_THAT(d, WithinRel(343.5, 0.02)); // 2% tolerance
// Same point should be zero
REQUIRE_THAT(distance_km(london, london), WithinAbs(0.0, 1e-10));
// Equatorial points 1 degree apart: ~111.32 km
Coord eq0{0, 0};
Coord eq1{1, 0};
REQUIRE_THAT(distance_km(eq0, eq1), WithinRel(111.32, 0.01));
}
TEST_CASE("Haversine distance: antipodal points", "[geo][distance]") {
// North pole to south pole: ~20015 km (half circumference)
Coord north{0, 90};
Coord south{0, -90};
double d = distance_km(north, south);
REQUIRE_THAT(d, WithinRel(20015.0, 0.01));
}
// ── BBox ────────────────────────────────────────────────────────────────────
TEST_CASE("BBox of a simple triangle", "[geo][bbox]") {
std::vector<Coord> triangle = {{0, 0}, {10, 5}, {5, 10}};
BBox b = bbox(triangle);
REQUIRE(b.minLon == 0.0);
REQUIRE(b.minLat == 0.0);
REQUIRE(b.maxLon == 10.0);
REQUIRE(b.maxLat == 10.0);
}
TEST_CASE("BBox center", "[geo][bbox]") {
BBox b{-10, -20, 10, 20};
Coord c = b.center();
REQUIRE(c.lon == 0.0);
REQUIRE(c.lat == 0.0);
}
TEST_CASE("BBox union", "[geo][bbox]") {
std::vector<BBox> boxes = {{0, 0, 5, 5}, {3, 3, 10, 10}};
BBox u = bbox_union(boxes);
REQUIRE(u.minLon == 0.0);
REQUIRE(u.minLat == 0.0);
REQUIRE(u.maxLon == 10.0);
REQUIRE(u.maxLat == 10.0);
}
TEST_CASE("BBox of empty ring returns zeros", "[geo][bbox]") {
std::vector<Coord> empty;
BBox b = bbox(empty);
REQUIRE(b.minLon == 0.0);
REQUIRE(b.maxLon == 0.0);
}
// ── Centroid ────────────────────────────────────────────────────────────────
TEST_CASE("Centroid of a square", "[geo][centroid]") {
std::vector<Coord> square = {{0, 0}, {10, 0}, {10, 10}, {0, 10}, {0, 0}};
Coord c = centroid(square);
REQUIRE_THAT(c.lon, WithinAbs(5.0, 1e-10));
REQUIRE_THAT(c.lat, WithinAbs(5.0, 1e-10));
}
TEST_CASE("Centroid handles closed ring (duplicate first/last)", "[geo][centroid]") {
// Closed triangle — first and last point are the same
std::vector<Coord> closed = {{0, 0}, {6, 0}, {3, 6}, {0, 0}};
Coord c = centroid(closed);
// Average of 3 unique points: (0+6+3)/3 = 3, (0+0+6)/3 = 2
REQUIRE_THAT(c.lon, WithinAbs(3.0, 1e-10));
REQUIRE_THAT(c.lat, WithinAbs(2.0, 1e-10));
}
// ── Area ────────────────────────────────────────────────────────────────────
TEST_CASE("Area of an equatorial 1x1 degree square", "[geo][area]") {
// ~111.32 km × ~110.57 km ≈ ~12,308 km²
std::vector<Coord> sq = {{0, 0}, {1, 0}, {1, 1}, {0, 1}, {0, 0}};
double a = area_sq_km(sq);
REQUIRE_THAT(a, WithinRel(12308.0, 0.05)); // 5% tolerance
}
TEST_CASE("Area of a zero-size polygon is zero", "[geo][area]") {
std::vector<Coord> pt = {{5, 5}};
REQUIRE(area_sq_km(pt) == 0.0);
}
// ── Point-in-polygon ────────────────────────────────────────────────────────
TEST_CASE("PIP: point inside a square", "[geo][pip]") {
std::vector<Coord> sq = {{0, 0}, {10, 0}, {10, 10}, {0, 10}, {0, 0}};
REQUIRE(point_in_polygon({5, 5}, sq) == true);
REQUIRE(point_in_polygon({1, 1}, sq) == true);
}
TEST_CASE("PIP: point outside a square", "[geo][pip]") {
std::vector<Coord> sq = {{0, 0}, {10, 0}, {10, 10}, {0, 10}, {0, 0}};
REQUIRE(point_in_polygon({-1, 5}, sq) == false);
REQUIRE(point_in_polygon({15, 5}, sq) == false);
}
TEST_CASE("PIP: point on edge is indeterminate but consistent", "[geo][pip]") {
std::vector<Coord> sq = {{0, 0}, {10, 0}, {10, 10}, {0, 10}, {0, 0}};
// Edge behavior is implementation-defined but should not crash
(void)point_in_polygon({0, 5}, sq);
(void)point_in_polygon({5, 0}, sq);
}
// ── Bearing ─────────────────────────────────────────────────────────────────
TEST_CASE("Bearing: due north", "[geo][bearing]") {
Coord a{0, 0};
Coord b{0, 10};
REQUIRE_THAT(bearing_deg(a, b), WithinAbs(0.0, 0.1));
}
TEST_CASE("Bearing: due east", "[geo][bearing]") {
Coord a{0, 0};
Coord b{10, 0};
REQUIRE_THAT(bearing_deg(a, b), WithinAbs(90.0, 0.5));
}
// ── Destination ─────────────────────────────────────────────────────────────
TEST_CASE("Destination: 100km north from equator", "[geo][destination]") {
Coord start{0, 0};
Coord dest = destination(start, 0.0, 100.0); // due north
REQUIRE_THAT(dest.lat, WithinRel(0.899, 0.02)); // ~0.9 degrees
REQUIRE_THAT(dest.lon, WithinAbs(0.0, 0.01));
}
TEST_CASE("Destination roundtrip: go 100km then measure distance", "[geo][destination]") {
Coord start{2.3522, 48.8566}; // Paris
Coord dest = destination(start, 45.0, 100.0); // 100km northeast
double d = distance_km(start, dest);
REQUIRE_THAT(d, WithinRel(100.0, 0.01)); // should be ~100km back
}
// ── Square grid ─────────────────────────────────────────────────────────────
TEST_CASE("Square grid: generates cells within bbox", "[geo][grid]") {
BBox extent{0, 0, 1, 1}; // ~111km x ~110km
auto cells = square_grid(extent, 50.0); // 50km cells → ~4 cells
REQUIRE(cells.size() >= 4);
for (const auto& c : cells) {
REQUIRE(c.lon >= extent.minLon);
REQUIRE(c.lon <= extent.maxLon);
REQUIRE(c.lat >= extent.minLat);
REQUIRE(c.lat <= extent.maxLat);
}
}
TEST_CASE("Square grid: zero cell size returns empty", "[geo][grid]") {
BBox extent{0, 0, 10, 10};
auto cells = square_grid(extent, 0.0);
REQUIRE(cells.empty());
}
// ── Hex grid ────────────────────────────────────────────────────────────────
TEST_CASE("Hex grid: generates cells within bbox", "[geo][grid]") {
BBox extent{0, 0, 1, 1};
auto cells = hex_grid(extent, 50.0);
REQUIRE(cells.size() >= 4);
for (const auto& c : cells) {
REQUIRE(c.lon >= extent.minLon);
REQUIRE(c.lon <= extent.maxLon);
REQUIRE(c.lat >= extent.minLat);
REQUIRE(c.lat <= extent.maxLat);
}
}
TEST_CASE("Hex grid: has offset rows", "[geo][grid]") {
BBox extent{0, 0, 2, 2}; // large enough for multiple rows
auto cells = hex_grid(extent, 30.0);
// Find first and second row Y values
if (cells.size() >= 3) {
// Just verify we got some cells (hex pattern is complex to validate)
REQUIRE(cells.size() > 2);
}
}
// ── Viewport estimation ─────────────────────────────────────────────────────
TEST_CASE("Viewport estimation at equator zoom 14", "[geo][viewport]") {
double sq = estimate_viewport_sq_km(0.0, 14);
// At zoom 14, equator: ~9.55 m/px → ~9.78 * 7.33 ≈ 71.7 km²
REQUIRE_THAT(sq, WithinRel(71.7, 0.15)); // 15% tolerance
}
TEST_CASE("Viewport estimation: higher zoom = smaller area", "[geo][viewport]") {
double z14 = estimate_viewport_sq_km(40.0, 14);
double z16 = estimate_viewport_sq_km(40.0, 16);
REQUIRE(z16 < z14);
}

View File

@ -1,235 +0,0 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "grid/grid.h"
#include "gadm_reader/gadm_reader.h"
#include <cmath>
#include <set>
using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;
static const std::string CACHE_DIR = "cache/gadm";
// ── Helper: load ABW boundary ───────────────────────────────────────────────
static gadm::Feature load_abw() {
auto res = gadm::load_boundary_file(CACHE_DIR + "/boundary_ABW_0.json");
REQUIRE(res.error.empty());
REQUIRE(res.features.size() == 1);
return res.features[0];
}
static gadm::Feature load_afg() {
auto res = gadm::load_boundary_file(CACHE_DIR + "/boundary_AFG.1.1_1_2.json");
REQUIRE(res.error.empty());
REQUIRE(res.features.size() == 1);
return res.features[0];
}
// ── Admin mode ──────────────────────────────────────────────────────────────
TEST_CASE("Grid admin: single feature → one waypoint", "[grid][admin]") {
auto feat = load_abw();
grid::GridOptions opts;
opts.gridMode = "admin";
opts.pathOrder = "zigzag";
auto result = grid::generate({feat}, opts);
REQUIRE(result.error.empty());
REQUIRE(result.validCells == 1);
REQUIRE(result.waypoints.size() == 1);
auto& wp = result.waypoints[0];
REQUIRE(wp.step == 1);
REQUIRE(wp.radius_km > 0);
// ABW centroid should be near [-70.0, 12.5]
REQUIRE_THAT(wp.lng, WithinAbs(-70.0, 0.1));
REQUIRE_THAT(wp.lat, WithinAbs(12.5, 0.1));
}
TEST_CASE("Grid admin: multiple features", "[grid][admin]") {
auto abw = load_abw();
auto afg = load_afg();
grid::GridOptions opts;
opts.gridMode = "admin";
auto result = grid::generate({abw, afg}, opts);
REQUIRE(result.error.empty());
REQUIRE(result.validCells == 2);
REQUIRE(result.waypoints.size() == 2);
REQUIRE(result.waypoints[0].step == 1);
REQUIRE(result.waypoints[1].step == 2);
}
TEST_CASE("Grid admin: empty features → error", "[grid][admin]") {
grid::GridOptions opts;
opts.gridMode = "admin";
auto result = grid::generate({}, opts);
REQUIRE(!result.error.empty());
}
// ── Centers mode ────────────────────────────────────────────────────────────
TEST_CASE("Grid centers: ABW generates waypoints from GHS centers", "[grid][centers]") {
auto feat = load_abw();
grid::GridOptions opts;
opts.gridMode = "centers";
opts.cellSize = 5.0;
opts.centroidOverlap = 0.5;
auto result = grid::generate({feat}, opts);
REQUIRE(result.error.empty());
REQUIRE(result.validCells > 0);
REQUIRE(result.waypoints.size() == static_cast<size_t>(result.validCells));
// All waypoints should be near Aruba
for (const auto& wp : result.waypoints) {
REQUIRE(wp.lng > -70.2);
REQUIRE(wp.lng < -69.8);
REQUIRE(wp.lat > 12.4);
REQUIRE(wp.lat < 12.7);
}
}
TEST_CASE("Grid centers: centroid overlap filters nearby centers", "[grid][centers]") {
auto feat = load_abw();
grid::GridOptions opts;
opts.gridMode = "centers";
opts.cellSize = 20.0; // big cells
opts.centroidOverlap = 0.0; // no overlap allowed → aggressive dedup
auto result_aggressive = grid::generate({feat}, opts);
opts.centroidOverlap = 0.9; // allow almost full overlap → more centers pass
auto result_relaxed = grid::generate({feat}, opts);
REQUIRE(result_relaxed.validCells >= result_aggressive.validCells);
}
// ── Hex grid mode ───────────────────────────────────────────────────────────
TEST_CASE("Grid hex: ABW at 3km cells", "[grid][hex]") {
auto feat = load_abw();
grid::GridOptions opts;
opts.gridMode = "hex";
opts.cellSize = 3.0;
auto result = grid::generate({feat}, opts);
REQUIRE(result.error.empty());
REQUIRE(result.validCells > 0);
// Aruba is ~30x10 km, so with 3km cells we expect ~20-60 cells
REQUIRE(result.validCells > 5);
REQUIRE(result.validCells < 200);
}
TEST_CASE("Grid square: ABW at 5km cells", "[grid][square]") {
auto feat = load_abw();
grid::GridOptions opts;
opts.gridMode = "square";
opts.cellSize = 5.0;
auto result = grid::generate({feat}, opts);
REQUIRE(result.error.empty());
REQUIRE(result.validCells > 0);
REQUIRE(result.validCells < 50); // island is small
}
TEST_CASE("Grid hex: too many cells returns error", "[grid][hex]") {
auto feat = load_abw();
grid::GridOptions opts;
opts.gridMode = "hex";
opts.cellSize = 0.01; // tiny cell → huge grid
opts.maxCellsLimit = 100;
auto result = grid::generate({feat}, opts);
REQUIRE(!result.error.empty());
}
// ── Sorting ─────────────────────────────────────────────────────────────────
TEST_CASE("Grid sort: snake vs zigzag differ for multi-row grid", "[grid][sort]") {
auto feat = load_abw();
grid::GridOptions opts;
opts.gridMode = "hex";
opts.cellSize = 3.0;
opts.pathOrder = "zigzag";
auto r1 = grid::generate({feat}, opts);
opts.pathOrder = "snake";
auto r2 = grid::generate({feat}, opts);
REQUIRE(r1.validCells == r2.validCells);
// Snake reverses every other row, so coordinates should differ in order
if (r1.validCells > 5) {
bool anyDiff = false;
for (size_t i = 0; i < r1.waypoints.size(); ++i) {
if (std::abs(r1.waypoints[i].lng - r2.waypoints[i].lng) > 1e-6) {
anyDiff = true;
break;
}
}
REQUIRE(anyDiff);
}
}
TEST_CASE("Grid sort: spiral-out starts near center", "[grid][sort]") {
auto feat = load_abw();
grid::GridOptions opts;
opts.gridMode = "hex";
opts.cellSize = 3.0;
opts.pathOrder = "spiral-out";
auto result = grid::generate({feat}, opts);
REQUIRE(result.validCells > 3);
// Compute center of all waypoints
double cLon = 0, cLat = 0;
for (const auto& wp : result.waypoints) { cLon += wp.lng; cLat += wp.lat; }
cLon /= result.waypoints.size();
cLat /= result.waypoints.size();
// First waypoint should be closer to center than last
double distFirst = std::hypot(result.waypoints.front().lng - cLon, result.waypoints.front().lat - cLat);
double distLast = std::hypot(result.waypoints.back().lng - cLon, result.waypoints.back().lat - cLat);
REQUIRE(distFirst < distLast);
}
TEST_CASE("Grid sort: steps are sequential after sorting", "[grid][sort]") {
auto feat = load_abw();
grid::GridOptions opts;
opts.gridMode = "hex";
opts.cellSize = 3.0;
opts.pathOrder = "shortest";
auto result = grid::generate({feat}, opts);
for (size_t i = 0; i < result.waypoints.size(); ++i) {
REQUIRE(result.waypoints[i].step == static_cast<int>(i + 1));
}
}
// ── GHS Filtering ───────────────────────────────────────────────────────────
TEST_CASE("Grid admin: GHS pop filter skips low-pop features", "[grid][filter]") {
auto feat = load_abw();
grid::GridOptions opts;
opts.gridMode = "admin";
opts.minGhsPop = 999999999; // impossibly high
auto result = grid::generate({feat}, opts);
REQUIRE(result.validCells == 0);
REQUIRE(result.skippedCells == 1);
}
TEST_CASE("Grid admin: bypass filters passes everything", "[grid][filter]") {
auto feat = load_abw();
grid::GridOptions opts;
opts.gridMode = "admin";
opts.minGhsPop = 999999999;
opts.bypassFilters = true;
auto result = grid::generate({feat}, opts);
REQUIRE(result.validCells == 1);
}

View File

@ -1,60 +0,0 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "search/search.h"
// ── Config loading ──────────────────────────────────────────────────────────
TEST_CASE("Config: loads SERPAPI_KEY from postgres.toml", "[search][config]") {
auto cfg = search::load_config("config/postgres.toml");
REQUIRE(!cfg.serpapi_key.empty());
REQUIRE(cfg.serpapi_key.size() > 20); // SHA-like key
}
TEST_CASE("Config: loads GEO_CODER_KEY from postgres.toml", "[search][config]") {
auto cfg = search::load_config("config/postgres.toml");
REQUIRE(!cfg.geocoder_key.empty());
}
TEST_CASE("Config: loads BIG_DATA_KEY from postgres.toml", "[search][config]") {
auto cfg = search::load_config("config/postgres.toml");
REQUIRE(!cfg.bigdata_key.empty());
}
TEST_CASE("Config: loads postgres URL", "[search][config]") {
auto cfg = search::load_config("config/postgres.toml");
REQUIRE(cfg.postgres_url.find("supabase.com") != std::string::npos);
}
TEST_CASE("Config: loads supabase URL and service key", "[search][config]") {
auto cfg = search::load_config("config/postgres.toml");
REQUIRE(cfg.supabase_url.find("supabase.co") != std::string::npos);
REQUIRE(!cfg.supabase_service_key.empty());
}
TEST_CASE("Config: missing file returns empty config", "[search][config]") {
auto cfg = search::load_config("nonexistent.toml");
REQUIRE(cfg.serpapi_key.empty());
REQUIRE(cfg.postgres_url.empty());
}
// ── Search validation (no network) ──────────────────────────────────────────
TEST_CASE("Search: empty key returns error", "[search][validate]") {
search::Config cfg; // all empty
search::SearchOptions opts;
opts.query = "plumbers";
auto res = search::search_google_maps(cfg, opts);
REQUIRE(!res.error.empty());
REQUIRE(res.error.find("key") != std::string::npos);
}
TEST_CASE("Search: empty query returns error", "[search][validate]") {
search::Config cfg;
cfg.serpapi_key = "test_key";
search::SearchOptions opts; // empty query
auto res = search::search_google_maps(cfg, opts);
REQUIRE(!res.error.empty());
REQUIRE(res.error.find("query") != std::string::npos);
}