Files
radiantwave/tests/test_proclamation_sync.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

123 lines
4.4 KiB
C++

#include "network/proclamation_sync.h"
#include <gtest/gtest.h>
using namespace radiantwave::db;
using namespace radiantwave::sync;
TEST(ProclamationSyncTest, ParseProclamationsJson) {
const std::string body =
R"([{"id":"p1","name":"Peace","content":"<p>I am <b>peace</b>.</p>","categoryId":3,"groupId":7,"solfeggioFrequencyId":2},
{"id":"p2","name":"Joy &amp; Calm","content":"text","categoryId":0,"groupId":0,"solfeggioFrequencyId":0},
{"notAnObject":true}])";
std::vector<ProclamationRecord> out;
EXPECT_TRUE(parseProclamationsJson(body, out));
ASSERT_EQ(out.size(), 2u);
EXPECT_EQ(out[0].id, "p1");
EXPECT_EQ(out[0].title, "Peace");
EXPECT_EQ(out[0].content, "I am peace.");
EXPECT_EQ(out[0].category_id, 3);
EXPECT_EQ(out[0].group_id, 7);
EXPECT_EQ(out[0].solfeggio_frequency_id, 2);
EXPECT_EQ(out[1].id, "p2");
EXPECT_EQ(out[1].title, "Joy & Calm");
EXPECT_EQ(out[1].content, "text");
EXPECT_EQ(out[1].category_id, 0);
EXPECT_EQ(out[1].group_id, 0);
EXPECT_EQ(out[1].solfeggio_frequency_id, 0);
}
TEST(ProclamationSyncTest, ParseIgnoresLegacySubscriptionLevelField) {
std::vector<ProclamationRecord> out;
EXPECT_TRUE(parseProclamationsJson(
R"([{"id":"a","name":"A","subscriptionLevelId":0},
{"id":"b","name":"B","subscriptionLevelId":2}])", out));
ASSERT_EQ(out.size(), 2u);
EXPECT_EQ(out[0].id, "a");
EXPECT_EQ(out[1].id, "b");
}
TEST(ProclamationSyncTest, ParseCategoriesJson) {
// Categories are flat now; a legacy parentId field is simply ignored.
const std::string body =
R"([{"id":3,"name":"Spiritual","sortOrder":1},
{"id":7,"name":"Physical","parentId":3,"sortOrder":2}])";
std::vector<ProclamationCategoryRecord> out;
EXPECT_TRUE(parseCategoriesJson(body, out));
ASSERT_EQ(out.size(), 2u);
EXPECT_EQ(out[0].id, 3);
EXPECT_EQ(out[0].name, "Spiritual");
EXPECT_EQ(out[0].sort_order, 1);
EXPECT_EQ(out[1].id, 7);
EXPECT_EQ(out[1].name, "Physical");
EXPECT_EQ(out[1].sort_order, 2);
}
TEST(ProclamationSyncTest, ParseGroupsJson) {
const std::string body =
R"([{"id":5,"name":"Prayers","sortOrder":0},
{"id":8,"name":"Affirmations","sortOrder":3}])";
std::vector<ProclamationGroupRecord> out;
EXPECT_TRUE(parseGroupsJson(body, out));
ASSERT_EQ(out.size(), 2u);
EXPECT_EQ(out[0].id, 5);
EXPECT_EQ(out[0].name, "Prayers");
EXPECT_EQ(out[0].sort_order, 0);
EXPECT_EQ(out[1].id, 8);
EXPECT_EQ(out[1].name, "Affirmations");
EXPECT_EQ(out[1].sort_order, 3);
}
TEST(ProclamationSyncTest, RejectMalformedJson) {
std::vector<ProclamationRecord> procs;
std::vector<ProclamationCategoryRecord> cats;
std::vector<ProclamationGroupRecord> groups;
EXPECT_FALSE(parseProclamationsJson("not json", procs));
EXPECT_FALSE(parseProclamationsJson(R"({"id":"p1"})", procs)); // object, not array
EXPECT_FALSE(parseProclamationsJson(R"([{"id":"p1"}", ])", procs));
EXPECT_FALSE(parseCategoriesJson("garbage", cats));
// Non-numeric ids are tolerated (row skipped), not fatal.
EXPECT_TRUE(parseCategoriesJson(R"([{"id":"x"}])", cats));
EXPECT_EQ(cats.size(), 0u);
EXPECT_FALSE(parseGroupsJson("garbage", groups));
EXPECT_TRUE(parseGroupsJson(R"([{"id":"x"}])", groups));
EXPECT_EQ(groups.size(), 0u);
}
TEST(ProclamationSyncTest, ParseSkipsMissingIdAndZeroCategory) {
std::vector<ProclamationRecord> procs;
EXPECT_TRUE(parseProclamationsJson(R"([{"name":"NoId"},{"id":"ok","name":"Fine","categoryId":4}])", procs));
ASSERT_EQ(procs.size(), 1u);
EXPECT_EQ(procs[0].id, "ok");
std::vector<ProclamationCategoryRecord> cats;
EXPECT_TRUE(parseCategoriesJson(R"([{"id":0,"name":"Bad"},{"id":9,"name":"Good","sortOrder":1}])", cats));
ASSERT_EQ(cats.size(), 1u);
EXPECT_EQ(cats[0].id, 9);
std::vector<ProclamationGroupRecord> groups;
EXPECT_TRUE(parseGroupsJson(R"([{"id":0,"name":"Bad"},{"id":4,"name":"Good","sortOrder":1}])", groups));
ASSERT_EQ(groups.size(), 1u);
EXPECT_EQ(groups[0].id, 4);
}
TEST(ProclamationSyncTest, StripHtmlEntities) {
std::vector<ProclamationRecord> out;
const std::string body =
R"([{"id":"p1","name":"N","content":"A &amp; B &lt;C&gt; &#39;quoted&#39; &nbsp;end"}] )";
EXPECT_TRUE(parseProclamationsJson(body, out));
ASSERT_EQ(out.size(), 1u);
EXPECT_EQ(out[0].content, "A & B <C> 'quoted' end");
}