Files
radiantwave/tests/test_api_client.cpp
T
NeilClack 9fead922c4
build-dev / nix-build (push) Successful in 32s
Move Proclamations From Sub-Categories To Groups
The portal's v2 API replaced nested categories (proclamation_categories.parent_id)
with flat categories plus tag-like groups: a proclamation now belongs to one
category and optionally one group. This lets the Proclamation Options page drop
its special-cased two-level tree.

Database:
- Add the proclamation_groups table (id, name, sort_order) and a
  proclamations.group_id column.
- Drop parent_id from the proclamation_categories DDL so fresh databases get a
  flat table.
- runMigrations() creates the new table/column, then runs a one-time
  transactional v1->v2 migration keyed on PRAGMA user_version: every legacy
  sub-category becomes a group (its id is preserved), proclamations that
  pointed at a sub-category are remapped to (parent category, new group), and
  the now-stale sub-category rows are deleted. selected flags are never
  touched, so user choices survive. No network is needed, so offline kiosks
  migrate at startup; re-running is a no-op.
- Add replaceGroups() and getAllGroups(); stop reading/writing parent_id.

API sync:
- Add PATH_GROUPS ("/groups") and parseGroupsJson().
- parseProclamationsJson() now also reads groupId.
- syncProclamationsFromApi() fetches /proclamations, /categories and /groups,
  leaves the database untouched if any request or parse fails, then replaces
  categories, replaces groups and upserts proclamations.

Proclamation Options page:
- FlatItem is now Category / Group / Proclamation.
- Each category lists only the groups that have at least one member in that
  category, so impossible pairings (e.g. Physical/Prayers) never render.
- Proclamations with no group, or with a group the portal has removed, sit
  directly under the category. Groups are collapsible and use a distinct color.

Tests:
- test_db: groups round-trip plus a legacy->v2 migration test asserting the
  group is created, proclamations are remapped, selection is preserved and a
  second initialize() is a no-op.
- test_proclamation_sync / test_api_client: parse groupId, parse groups, and
  the live smoke test now also fetches /groups.

Docs: README database table and AGENTS notes updated for groups.
2026-09-10 19:49:47 -05:00

282 lines
10 KiB
C++

#include "network/api_client.h"
#include "network/json.h"
#include "network/proclamation_sync.h"
#include "db/database_manager.h"
#include <gtest/gtest.h>
#include <cstdlib>
#include <string>
#include <vector>
using namespace radiantwave::api;
namespace {
// Reads an env var, returning "" when unset.
std::string env(const char* name) {
const char* value = std::getenv(name);
return value ? std::string(value) : std::string();
}
} // namespace
// ─── Offline: parse functions ─────────────────────────────────────────
TEST(ApiClientTest, ParseFrequenciesJson) {
const std::string body =
R"([{"id":1,"name":"Crown","value":936},
{"id":5,"name":"Solar Plexus","value":528},
{"id":7,"name":"Root","value":396}])";
std::vector<Frequency> out;
EXPECT_TRUE(parseFrequenciesJson(body, out));
ASSERT_EQ(out.size(), 3u);
EXPECT_EQ(out[0].id, 1);
EXPECT_EQ(out[0].name, "Crown");
EXPECT_EQ(out[0].value, 936);
EXPECT_EQ(out[1].id, 5);
EXPECT_EQ(out[1].name, "Solar Plexus");
EXPECT_EQ(out[1].value, 528);
EXPECT_EQ(out[2].id, 7);
EXPECT_EQ(out[2].name, "Root");
EXPECT_EQ(out[2].value, 396);
}
TEST(ApiClientTest, ParseFrequenciesJsonEmpty) {
std::vector<Frequency> out;
EXPECT_TRUE(parseFrequenciesJson("[]", out));
EXPECT_TRUE(out.empty());
}
TEST(ApiClientTest, ParseFrequenciesJsonRejectsMalformed) {
std::vector<Frequency> out;
EXPECT_FALSE(parseFrequenciesJson("not json", out));
EXPECT_FALSE(parseFrequenciesJson(R"({"id":1})", out)); // object, not array
EXPECT_FALSE(parseFrequenciesJson(R"([{"id":1}", ])", out));
}
TEST(ApiClientTest, ParseFrequenciesJsonSkipsMissingId) {
std::vector<Frequency> out;
EXPECT_TRUE(parseFrequenciesJson(
R"([{"name":"NoId"},{"id":3,"name":"Throat","value":741}])", out));
ASSERT_EQ(out.size(), 1u);
EXPECT_EQ(out[0].id, 3);
}
TEST(ApiClientTest, ParseLicenseJson) {
const std::string body =
R"({"key":"ABCD-EFGH-IJKL-MNOP","email_address":"user@example.com"})";
LicenseInfo info;
EXPECT_TRUE(parseLicenseJson(body, info));
EXPECT_EQ(info.key, "ABCD-EFGH-IJKL-MNOP");
EXPECT_EQ(info.email_address, "user@example.com");
}
TEST(ApiClientTest, ParseLicenseJsonIgnoresSubscriptionFields) {
// The active/inactive model has no subscription level. Legacy responses
// that still carry subscription_level must parse; the field is ignored.
const std::string body =
R"({"key":"ABCD-EFGH-IJKL-MNOP","email_address":"user@example.com",)"
R"("subscription_level":{"id":2,"name":"Advanced","description":"General + Specific"}})";
LicenseInfo info;
EXPECT_TRUE(parseLicenseJson(body, info));
EXPECT_EQ(info.key, "ABCD-EFGH-IJKL-MNOP");
EXPECT_EQ(info.email_address, "user@example.com");
}
TEST(ApiClientTest, ParseLicenseJsonRejectsMalformed) {
LicenseInfo info;
EXPECT_FALSE(parseLicenseJson("garbage", info));
EXPECT_FALSE(parseLicenseJson(R"({"key":""})", info));
}
TEST(ApiClientTest, ParseCreateAccountJson) {
const std::string body =
R"({"success":true,"message":"Account and license key created","user_id":"abc-123"})";
AccountBody account;
EXPECT_TRUE(parseCreateAccountJson(body, account));
EXPECT_TRUE(account.success);
EXPECT_EQ(account.message, "Account and license key created");
EXPECT_EQ(account.user_id, "abc-123");
}
TEST(ApiClientTest, ParseCreateAccountJsonRejectsMalformed) {
AccountBody account;
EXPECT_FALSE(parseCreateAccountJson("nope", account));
EXPECT_FALSE(parseCreateAccountJson("[]", account));
}
// ─── Offline: error message extraction ────────────────────────────────
TEST(ApiClientTest, ExtractErrorMessage) {
EXPECT_EQ(radiantwave::json::extractErrorMessage(R"({"message":"license key not found"})"),
"license key not found");
EXPECT_EQ(radiantwave::json::extractErrorMessage(R"({"error":"API key required"})"),
"API key required");
EXPECT_EQ(radiantwave::json::extractErrorMessage(R"({"code":400})"), "");
EXPECT_EQ(radiantwave::json::extractErrorMessage("not json"), "");
}
// ─── Offline: real-world payload shapes ────────────────────────────────
TEST(ApiClientTest, ParseProclamationsToleratesLegacyLevelField) {
const std::string body =
R"([{"id":"a","name":"Peace","content":"<p>I am peace.</p>","categoryId":5,
"solfeggioFrequencyId":5,"subscriptionLevelId":null},
{"id":"b","name":"Foot","content":"<p>Heal.</p>","categoryId":32,
"solfeggioFrequencyId":7,"subscriptionLevelId":1}])";
std::vector<radiantwave::db::ProclamationRecord> out;
EXPECT_TRUE(radiantwave::sync::parseProclamationsJson(body, out));
ASSERT_EQ(out.size(), 2u);
EXPECT_EQ(out[0].title, "Peace");
EXPECT_EQ(out[0].category_id, 5);
EXPECT_EQ(out[0].solfeggio_frequency_id, 5);
EXPECT_EQ(out[1].category_id, 32);
}
TEST(ApiClientTest, ParseFlatCategoriesAndGroups) {
const std::string categoriesBody =
R"([{"id":4,"name":"Spiritual","sortOrder":0},
{"id":5,"name":"General","sortOrder":1}])";
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(categoriesBody, categories));
ASSERT_EQ(categories.size(), 2u);
EXPECT_EQ(categories[0].id, 4);
EXPECT_EQ(categories[1].id, 5);
const std::string groupsBody =
R"([{"id":9,"name":"Prayers","sortOrder":0}])";
std::vector<radiantwave::db::ProclamationGroupRecord> groups;
EXPECT_TRUE(radiantwave::sync::parseGroupsJson(groupsBody, groups));
ASSERT_EQ(groups.size(), 1u);
EXPECT_EQ(groups[0].id, 9);
EXPECT_EQ(groups[0].name, "Prayers");
}
// ─── Live: GET /frequencies (harmless, always runs) ───────────────────
TEST(ApiClientLiveTest, FetchFrequenciesLive) {
std::vector<Frequency> out;
EXPECT_TRUE(fetchFrequencies(out));
ASSERT_GE(out.size(), 7u);
bool foundSolarPlexus = false;
for (const Frequency& f : out) {
if (f.id == 5) {
foundSolarPlexus = true;
EXPECT_EQ(f.name, "Solar Plexus");
EXPECT_EQ(f.value, 528);
}
}
EXPECT_TRUE(foundSolarPlexus);
}
// ─── Live: proclamation + category parsers against the real API ────────
TEST(ApiClientLiveTest, ProclamationsAndCategoriesLiveSmoke) {
HttpResponse procsResponse = httpGet(PATH_PROCLAMATIONS);
ASSERT_TRUE(procsResponse.transportOk);
ASSERT_EQ(procsResponse.status, 200);
std::vector<radiantwave::db::ProclamationRecord> proclamations;
EXPECT_TRUE(radiantwave::sync::parseProclamationsJson(procsResponse.body, proclamations));
EXPECT_GE(proclamations.size(), 50u);
if (!proclamations.empty()) {
// HTML tags must be stripped from the content.
EXPECT_EQ(proclamations[0].content.find('<'), std::string::npos);
EXPECT_FALSE(proclamations[0].id.empty());
}
HttpResponse catsResponse = httpGet(PATH_CATEGORIES);
ASSERT_TRUE(catsResponse.transportOk);
ASSERT_EQ(catsResponse.status, 200);
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(catsResponse.body, categories));
EXPECT_FALSE(categories.empty());
HttpResponse groupsResponse = httpGet(PATH_GROUPS);
ASSERT_TRUE(groupsResponse.transportOk);
ASSERT_EQ(groupsResponse.status, 200);
std::vector<radiantwave::db::ProclamationGroupRecord> groups;
EXPECT_TRUE(radiantwave::sync::parseGroupsJson(groupsResponse.body, groups));
EXPECT_FALSE(groups.empty());
}
// ─── Live: POST /license error paths (no credentials required) ─────────
TEST(ApiClientLiveTest, ValidateLicenseMissingKeyLive) {
LicenseResult result = validateLicense("", "test@example.com");
ASSERT_FALSE(result.ok());
EXPECT_EQ(result.status, 400);
EXPECT_EQ(result.error_message, "license_key is required");
}
TEST(ApiClientLiveTest, ValidateLicenseMissingEmailLive) {
LicenseResult result = validateLicense("AAAA-AAAA-AAAA-AAAA", "");
ASSERT_FALSE(result.ok());
EXPECT_EQ(result.status, 400);
EXPECT_EQ(result.error_message, "email_address is required");
}
TEST(ApiClientLiveTest, ValidateLicenseUnknownKeyLive) {
LicenseResult result =
validateLicense("AAAA-AAAA-AAAA-AAAA", "nobody@example.com");
ASSERT_FALSE(result.ok());
EXPECT_EQ(result.status, 400);
EXPECT_EQ(result.error_message, "license key not found");
}
// ─── Live: POST /license success path (requires env credentials) ───────
TEST(ApiClientLiveTest, ValidateLicenseSuccessLive) {
const std::string key = env("RW_LIVE_LICENSE_KEY");
const std::string email = env("RW_LIVE_LICENSE_EMAIL");
if (key.empty() || email.empty()) {
GTEST_SKIP() << "RW_LIVE_LICENSE_KEY / RW_LIVE_LICENSE_EMAIL not set";
}
LicenseResult result = validateLicense(key, email);
ASSERT_TRUE(result.ok()) << "status=" << result.status
<< " error=" << result.error_message;
EXPECT_EQ(result.info.key, key);
EXPECT_EQ(result.info.email_address, email);
}
// ─── Live: POST /automations/create/account error paths (harmless) ─────
TEST(ApiClientLiveTest, CreateAccountMissingEmailLive) {
AccountResult result = createAccount("", "");
ASSERT_FALSE(result.ok());
EXPECT_EQ(result.status, 400);
EXPECT_EQ(result.error_message, "email is required");
}
// ─── Live: POST /automations/create/account success (creates a real
// ─── account + emails the user; requires RW_LIVE_ACCOUNT_EMAIL) ────────
TEST(ApiClientLiveTest, CreateAccountSuccessLive) {
const std::string email = env("RW_LIVE_ACCOUNT_EMAIL");
if (email.empty()) {
GTEST_SKIP() << "RW_LIVE_ACCOUNT_EMAIL not set";
}
AccountResult result = createAccount(email, "RadiantWave Test");
ASSERT_TRUE(result.ok()) << "status=" << result.status
<< " error=" << result.error_message;
EXPECT_TRUE(result.body.success);
EXPECT_EQ(result.body.message, "Account and license key created");
EXPECT_FALSE(result.body.user_id.empty());
}