diff --git a/.gitignore b/.gitignore index b16286d..3dd929e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ *.exe *.out *.app - +config/ # CMake generated CMakeCache.txt CMakeFiles/ diff --git a/package.json b/package.json index da17795..3771271 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "clean:release": "cmake -E rm -rf build/release", "clean:all": "cmake -E rm -rf build", "rebuild": "npm run clean && npm run config && npm run build", - "run": ".\\build\\dev\\Debug\\polymech-cli.exe" + "run": ".\\build\\dev\\Debug\\polymech-cli.exe --help" }, "repository": { "type": "git", diff --git a/packages/postgres/CMakeLists.txt b/packages/postgres/CMakeLists.txt index db2c230..0f5d1e7 100644 --- a/packages/postgres/CMakeLists.txt +++ b/packages/postgres/CMakeLists.txt @@ -7,5 +7,5 @@ target_include_directories(postgres ) target_link_libraries(postgres - PUBLIC logger + PUBLIC logger http json ) diff --git a/packages/postgres/include/postgres/postgres.h b/packages/postgres/include/postgres/postgres.h index 700e9f1..79e089f 100644 --- a/packages/postgres/include/postgres/postgres.h +++ b/packages/postgres/include/postgres/postgres.h @@ -1,11 +1,34 @@ #pragma once #include +#include namespace postgres { -/// Connect to a PostgreSQL database (stub). -/// Returns a human-readable status string. -std::string connect(const std::string &connection_string); +/// Supabase connection configuration. +struct Config { + std::string supabase_url; + std::string supabase_key; +}; + +/// Initialize the Supabase client with URL and API key. +void init(const Config &config); + +/// Ping the Supabase REST API. Returns "ok" on success, error message on +/// failure. +std::string ping(); + +/// Query a table via the PostgREST API. +/// Returns the raw JSON response body. +/// @param table Table name (e.g. "profiles") +/// @param select Comma-separated columns (e.g. "id,username"), or "*" +/// @param filter PostgREST filter (e.g. "id=eq.abc"), or "" for no filter +/// @param limit Max rows (0 = no limit) +std::string query(const std::string &table, const std::string &select = "*", + const std::string &filter = "", int limit = 0); + +/// Insert a row into a table. Body is a JSON object string. +/// Returns the created row as JSON. +std::string insert(const std::string &table, const std::string &json_body); } // namespace postgres diff --git a/packages/postgres/src/postgres.cpp b/packages/postgres/src/postgres.cpp index 25e59b8..e0d513d 100644 --- a/packages/postgres/src/postgres.cpp +++ b/packages/postgres/src/postgres.cpp @@ -1,12 +1,176 @@ #include "postgres/postgres.h" +#include "http/http.h" #include "logger/logger.h" +#include "json/json.h" + +#include +#include namespace postgres { -std::string connect(const std::string &connection_string) { - logger::debug("postgres::connect → " + connection_string); - // stub — no real connection - return "ok"; +static Config s_config; +static bool s_initialized = false; + +void init(const Config &config) { + s_config = config; + s_initialized = true; + logger::debug("postgres::init → " + config.supabase_url); +} + +static void ensure_init() { + if (!s_initialized) { + throw std::runtime_error("postgres::init() must be called first"); + } +} + +/// Build the REST URL for a table query. +static std::string build_url(const std::string &table, + const std::string &select, + const std::string &filter, int limit) { + std::string url = s_config.supabase_url + "/rest/v1/" + table; + url += "?select=" + select; + if (!filter.empty()) { + url += "&" + filter; + } + if (limit > 0) { + url += "&limit=" + std::to_string(limit); + } + return url; +} + +/// Make an authenticated GET request to the Supabase REST API. +static http::Response supabase_get(const std::string &url) { + // We need custom headers, so we use curl directly + CURL *curl = curl_easy_init(); + http::Response resp{}; + if (!curl) { + resp.status_code = -1; + resp.body = "curl_easy_init failed"; + return resp; + } + + struct curl_slist *headers = nullptr; + headers = + curl_slist_append(headers, ("apikey: " + s_config.supabase_key).c_str()); + headers = curl_slist_append( + headers, ("Authorization: Bearer " + s_config.supabase_key).c_str()); + + auto write_cb = [](void *contents, size_t size, size_t nmemb, void *userp) { + auto *out = static_cast(userp); + out->append(static_cast(contents), size * nmemb); + return size * nmemb; + }; + + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt( + curl, CURLOPT_WRITEFUNCTION, + static_cast(+write_cb)); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp.body); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L); + + CURLcode res = curl_easy_perform(curl); + if (res != CURLE_OK) { + resp.status_code = -1; + resp.body = curl_easy_strerror(res); + } else { + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &resp.status_code); + } + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + return resp; +} + +/// Make an authenticated POST request. +static http::Response supabase_post(const std::string &url, + const std::string &body) { + CURL *curl = curl_easy_init(); + http::Response resp{}; + if (!curl) { + resp.status_code = -1; + resp.body = "curl_easy_init failed"; + return resp; + } + + struct curl_slist *headers = nullptr; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = curl_slist_append(headers, "Prefer: return=representation"); + headers = + curl_slist_append(headers, ("apikey: " + s_config.supabase_key).c_str()); + headers = curl_slist_append( + headers, ("Authorization: Bearer " + s_config.supabase_key).c_str()); + + auto write_cb = [](void *contents, size_t size, size_t nmemb, void *userp) { + auto *out = static_cast(userp); + out->append(static_cast(contents), size * nmemb); + return size * nmemb; + }; + + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str()); + curl_easy_setopt( + curl, CURLOPT_WRITEFUNCTION, + static_cast(+write_cb)); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp.body); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L); + + CURLcode res = curl_easy_perform(curl); + if (res != CURLE_OK) { + resp.status_code = -1; + resp.body = curl_easy_strerror(res); + } else { + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &resp.status_code); + } + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + return resp; +} + +std::string ping() { + ensure_init(); + // Lightweight check: query profiles with limit=0 to verify connectivity + auto resp = supabase_get(s_config.supabase_url + + "/rest/v1/profiles?select=id&limit=0"); + if (resp.status_code >= 200 && resp.status_code < 300) { + logger::info("postgres::ping → ok (HTTP " + + std::to_string(resp.status_code) + ")"); + return "ok"; + } + logger::error("postgres::ping → HTTP " + std::to_string(resp.status_code) + + ": " + resp.body); + return "error: HTTP " + std::to_string(resp.status_code); +} + +std::string query(const std::string &table, const std::string &select, + const std::string &filter, int limit) { + ensure_init(); + auto url = build_url(table, select, filter, limit); + logger::debug("postgres::query → " + url); + + auto resp = supabase_get(url); + if (resp.status_code >= 200 && resp.status_code < 300) { + return resp.body; + } + logger::error("postgres::query → HTTP " + std::to_string(resp.status_code) + + ": " + resp.body); + return resp.body; +} + +std::string insert(const std::string &table, const std::string &json_body) { + ensure_init(); + auto url = s_config.supabase_url + "/rest/v1/" + table; + logger::debug("postgres::insert → " + url); + + auto resp = supabase_post(url, json_body); + if (resp.status_code >= 200 && resp.status_code < 300) { + return resp.body; + } + logger::error("postgres::insert → HTTP " + std::to_string(resp.status_code) + + ": " + resp.body); + return resp.body; } } // namespace postgres diff --git a/src/main.cpp b/src/main.cpp index e08d5da..52d45c8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -10,7 +10,6 @@ #include "postgres/postgres.h" #include "json/json.h" - #ifndef PROJECT_VERSION #define PROJECT_VERSION "0.1.0" #endif @@ -49,6 +48,17 @@ int main(int argc, char *argv[]) { auto *json_cmd = app.add_subcommand("json", "Prettify a JSON string"); json_cmd->add_option("input", json_input, "JSON string")->required(); + // Subcommand: db — connect to Supabase and query + std::string db_config_path = "config/postgres.toml"; + std::string db_table; + int db_limit = 10; + auto *db_cmd = + app.add_subcommand("db", "Connect to Supabase and query a table"); + db_cmd->add_option("-c,--config", db_config_path, "TOML config path") + ->default_val("config/postgres.toml"); + db_cmd->add_option("table", db_table, "Table to query (optional)"); + db_cmd->add_option("-l,--limit", db_limit, "Row limit")->default_val(10); + CLI11_PARSE(app, argc, argv); logger::init("polymech-cli"); @@ -104,9 +114,34 @@ int main(int argc, char *argv[]) { return 0; } - // Default: demo - auto status = postgres::connect("postgresql://localhost:5432/polymech"); - logger::info("polymech-cli " + std::string(PROJECT_VERSION) + - " ready (pg: " + status + ")"); + if (db_cmd->parsed()) { + try { + auto cfg = toml::parse_file(db_config_path); + postgres::Config pg_cfg; + pg_cfg.supabase_url = cfg["supabase"]["url"].value_or(std::string("")); + pg_cfg.supabase_key = + cfg["supabase"]["publishable_key"].value_or(std::string("")); + postgres::init(pg_cfg); + + auto status = postgres::ping(); + logger::info("Supabase: " + status); + + if (!db_table.empty()) { + auto result = postgres::query(db_table, "*", "", db_limit); + if (json::is_valid(result)) { + std::cout << json::prettify(result) << "\n"; + } else { + std::cout << result << "\n"; + } + } + } catch (const std::exception &e) { + logger::error(std::string("db error: ") + e.what()); + return 1; + } + return 0; + } + + // No subcommand — show help + std::cout << app.help() << "\n"; return 0; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6a61697..8a3ec82 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -27,3 +27,8 @@ catch_discover_tests(test_http) add_executable(test_functional functional/test_cli.cpp) target_link_libraries(test_functional PRIVATE Catch2::Catch2WithMain CLI11::CLI11 tomlplusplus::tomlplusplus logger html postgres http json) catch_discover_tests(test_functional) + +# E2E test — real Supabase connection (requires config/postgres.toml + network) +add_executable(test_supabase e2e/test_supabase.cpp) +target_link_libraries(test_supabase PRIVATE Catch2::Catch2WithMain tomlplusplus::tomlplusplus logger postgres json) +catch_discover_tests(test_supabase WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) diff --git a/tests/e2e/test_supabase.cpp b/tests/e2e/test_supabase.cpp new file mode 100644 index 0000000..9caa984 --- /dev/null +++ b/tests/e2e/test_supabase.cpp @@ -0,0 +1,50 @@ +#include +#include +#include + +#include + +#include "logger/logger.h" +#include "postgres/postgres.h" +#include "json/json.h" + + +// ── E2E: Supabase connect via config/postgres.toml ────────────────────────── + +TEST_CASE("E2E: connect to Supabase and ping", "[e2e][postgres]") { + logger::init("e2e-test"); + + // Read config — path relative to CWD (project root) + auto cfg = toml::parse_file("config/postgres.toml"); + postgres::Config pg_cfg; + pg_cfg.supabase_url = cfg["supabase"]["url"].value_or(std::string("")); + pg_cfg.supabase_key = + cfg["supabase"]["publishable_key"].value_or(std::string("")); + + REQUIRE(!pg_cfg.supabase_url.empty()); + REQUIRE(!pg_cfg.supabase_key.empty()); + + postgres::init(pg_cfg); + + auto status = postgres::ping(); + logger::info("E2E ping result: " + status); + CHECK(status == "ok"); +} + +TEST_CASE("E2E: query profiles table", "[e2e][postgres]") { + logger::init("e2e-test"); + + auto cfg = toml::parse_file("config/postgres.toml"); + postgres::Config pg_cfg; + pg_cfg.supabase_url = cfg["supabase"]["url"].value_or(std::string("")); + pg_cfg.supabase_key = + cfg["supabase"]["publishable_key"].value_or(std::string("")); + + postgres::init(pg_cfg); + + auto result = postgres::query("profiles", "id,username", "", 3); + logger::info("E2E query result: " + result); + + // Should be valid JSON array + CHECK(json::is_valid(result)); +} diff --git a/tests/functional/test_cli.cpp b/tests/functional/test_cli.cpp index 1e7654b..f0ea3e3 100644 --- a/tests/functional/test_cli.cpp +++ b/tests/functional/test_cli.cpp @@ -64,9 +64,11 @@ TEST_CASE("Full pipeline: TOML config round-trip", "[functional]") { TEST_CASE("Full pipeline: logger + postgres integration", "[functional]") { REQUIRE_NOTHROW(logger::init("functional-test")); - auto status = postgres::connect("postgresql://localhost:5432/functional"); - REQUIRE(status == "ok"); + // Init with a dummy config (no real connection) + postgres::Config cfg; + cfg.supabase_url = "https://example.supabase.co"; + cfg.supabase_key = "test-key"; + REQUIRE_NOTHROW(postgres::init(cfg)); - REQUIRE_NOTHROW( - logger::info("Functional test passed with pg status: " + status)); + REQUIRE_NOTHROW(logger::info("Functional test: postgres init ok")); } diff --git a/tests/unit/test_postgres.cpp b/tests/unit/test_postgres.cpp index 348c51c..d839e92 100644 --- a/tests/unit/test_postgres.cpp +++ b/tests/unit/test_postgres.cpp @@ -2,13 +2,8 @@ #include "postgres/postgres.h" -TEST_CASE("postgres::connect returns ok for any input (stub)", "[postgres]") { - auto result = postgres::connect("postgresql://localhost:5432/test"); - CHECK(result == "ok"); -} - -TEST_CASE("postgres::connect accepts different connection strings", - "[postgres]") { - CHECK(postgres::connect("postgresql://user:pass@host:5432/db") == "ok"); - CHECK(postgres::connect("") == "ok"); +// Unit tests use a no-op init — no network required +TEST_CASE("postgres::ping throws without init", "[postgres]") { + // If called without init, should throw + CHECK_THROWS(postgres::ping()); }