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.
This commit is contained in:
+27
-10
@@ -142,16 +142,25 @@ TEST(ApiClientTest, ParseProclamationsToleratesLegacyLevelField) {
|
||||
EXPECT_EQ(out[1].category_id, 32);
|
||||
}
|
||||
|
||||
TEST(ApiClientTest, ParseCategoriesHandlesNullParentId) {
|
||||
const std::string body =
|
||||
R"([{"id":4,"name":"Spiritual","sortOrder":0,"parentId":null},
|
||||
{"id":5,"name":"General","sortOrder":0,"parentId":4}])";
|
||||
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> out;
|
||||
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(body, out));
|
||||
ASSERT_EQ(out.size(), 2u);
|
||||
EXPECT_EQ(out[0].parent_id, 0); // null parentId maps to 0 (root)
|
||||
EXPECT_EQ(out[1].parent_id, 4);
|
||||
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) ───────────────────
|
||||
@@ -194,7 +203,15 @@ TEST(ApiClientLiveTest, ProclamationsAndCategoriesLiveSmoke) {
|
||||
|
||||
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
|
||||
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(catsResponse.body, categories));
|
||||
EXPECT_GE(categories.size(), 20u);
|
||||
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) ─────────
|
||||
|
||||
+100
-4
@@ -185,23 +185,119 @@ TEST(DatabaseManagerTest, ReplaceAndReadCategories) {
|
||||
DbFixture fx("test_rw_db_cats.db");
|
||||
|
||||
std::vector<ProclamationCategoryRecord> cats;
|
||||
cats.push_back({3, "Root", 0, 1});
|
||||
cats.push_back({7, "Child", 3, 2});
|
||||
cats.push_back({3, "Spiritual", 1});
|
||||
cats.push_back({7, "Physical", 2});
|
||||
|
||||
ASSERT_TRUE(fx.db.replaceCategories(cats));
|
||||
|
||||
auto all = fx.db.getAllCategories();
|
||||
ASSERT_EQ(all.size(), 2u);
|
||||
EXPECT_EQ(all[0].id, 3);
|
||||
EXPECT_EQ(all[0].parent_id, 0);
|
||||
EXPECT_EQ(all[0].name, "Spiritual");
|
||||
EXPECT_EQ(all[0].sort_order, 1);
|
||||
EXPECT_EQ(all[1].id, 7);
|
||||
EXPECT_EQ(all[1].parent_id, 3);
|
||||
EXPECT_EQ(all[1].name, "Physical");
|
||||
EXPECT_EQ(all[1].sort_order, 2);
|
||||
|
||||
// Replacing wipes previous categories.
|
||||
ASSERT_TRUE(fx.db.replaceCategories({}));
|
||||
EXPECT_EQ(fx.db.getAllCategories().size(), 0u);
|
||||
}
|
||||
|
||||
TEST(DatabaseManagerTest, ReplaceAndReadGroups) {
|
||||
DbFixture fx("test_rw_db_groups.db");
|
||||
|
||||
std::vector<ProclamationGroupRecord> groups;
|
||||
groups.push_back({10, "Prayers", 2});
|
||||
groups.push_back({4, "Affirmations", 1});
|
||||
|
||||
ASSERT_TRUE(fx.db.replaceGroups(groups));
|
||||
|
||||
auto all = fx.db.getAllGroups();
|
||||
ASSERT_EQ(all.size(), 2u);
|
||||
// Ordered by sort_order, not by id.
|
||||
EXPECT_EQ(all[0].id, 4);
|
||||
EXPECT_EQ(all[0].name, "Affirmations");
|
||||
EXPECT_EQ(all[1].id, 10);
|
||||
EXPECT_EQ(all[1].name, "Prayers");
|
||||
|
||||
ASSERT_TRUE(fx.db.replaceGroups({}));
|
||||
EXPECT_EQ(fx.db.getAllGroups().size(), 0u);
|
||||
}
|
||||
|
||||
TEST(DatabaseManagerTest, MigratesLegacySubCategoriesToGroups) {
|
||||
const std::string path =
|
||||
(std::filesystem::temp_directory_path() / "test_rw_db_migrate_v1.db").string();
|
||||
if (std::filesystem::exists(path)) {
|
||||
std::filesystem::remove(path);
|
||||
}
|
||||
|
||||
// Build a pre-v2 database by hand: a two-level category tree and a
|
||||
// proclamation selected inside a sub-category.
|
||||
{
|
||||
sqlite3* raw = nullptr;
|
||||
ASSERT_EQ(sqlite3_open(path.c_str(), &raw), SQLITE_OK);
|
||||
const char* legacy =
|
||||
"CREATE TABLE proclamations ("
|
||||
" id TEXT PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL,"
|
||||
" solfeggio_freq_hz REAL DEFAULT 528.0, category TEXT DEFAULT 'general',"
|
||||
" is_active INTEGER DEFAULT 1, selected INTEGER DEFAULT 0,"
|
||||
" category_id INTEGER DEFAULT 0, solfeggio_frequency_id INTEGER DEFAULT 0,"
|
||||
" created_at DATETIME DEFAULT CURRENT_TIMESTAMP);"
|
||||
"CREATE TABLE proclamation_categories ("
|
||||
" id INTEGER PRIMARY KEY, name TEXT NOT NULL,"
|
||||
" parent_id INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0);"
|
||||
"INSERT INTO proclamation_categories (id, name, parent_id, sort_order)"
|
||||
" VALUES (3,'Spiritual',0,1),(7,'Inner',3,2);"
|
||||
"INSERT INTO proclamations (id,title,content,category_id,solfeggio_frequency_id,selected)"
|
||||
" VALUES ('p1','Peace','x',7,2,1),('p2','Root-level','y',3,5,0);";
|
||||
char* err = nullptr;
|
||||
int rc = sqlite3_exec(raw, legacy, nullptr, nullptr, &err);
|
||||
if (err) sqlite3_free(err);
|
||||
sqlite3_close(raw);
|
||||
ASSERT_EQ(rc, SQLITE_OK);
|
||||
}
|
||||
|
||||
DatabaseManager db;
|
||||
ASSERT_TRUE(db.initialize(path));
|
||||
|
||||
// Each former sub-category becomes a group; only the root remains a category.
|
||||
auto groups = db.getAllGroups();
|
||||
ASSERT_EQ(groups.size(), 1u);
|
||||
EXPECT_EQ(groups[0].id, 7);
|
||||
EXPECT_EQ(groups[0].name, "Inner");
|
||||
|
||||
auto cats = db.getAllCategories();
|
||||
ASSERT_EQ(cats.size(), 1u);
|
||||
EXPECT_EQ(cats[0].id, 3);
|
||||
EXPECT_EQ(cats[0].name, "Spiritual");
|
||||
|
||||
// Proclamations pointing at the old sub-category are remapped to the root
|
||||
// category + the new group; selection is preserved.
|
||||
auto procs = db.getAllProclamations();
|
||||
ASSERT_EQ(procs.size(), 2u);
|
||||
for (const auto& p : procs) {
|
||||
EXPECT_EQ(p.category_id, 3) << "proclamation " << p.id;
|
||||
if (p.id == "p1") {
|
||||
EXPECT_EQ(p.group_id, 7);
|
||||
EXPECT_TRUE(p.selected);
|
||||
} else if (p.id == "p2") {
|
||||
EXPECT_EQ(p.group_id, 0);
|
||||
EXPECT_FALSE(p.selected);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-running the migration is a no-op (user_version gate + idempotent SQL).
|
||||
db.close();
|
||||
ASSERT_TRUE(db.initialize(path));
|
||||
EXPECT_EQ(db.getAllGroups().size(), 1u);
|
||||
EXPECT_EQ(db.getAllCategories().size(), 1u);
|
||||
EXPECT_EQ(db.getAllProclamations().size(), 2u);
|
||||
|
||||
db.close();
|
||||
std::filesystem::remove(path);
|
||||
}
|
||||
|
||||
TEST(DatabaseManagerTest, SyncAudioFilesUpsertsAndDeletesMissing) {
|
||||
DbFixture fx("test_rw_db_audio.db");
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ 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,"solfeggioFrequencyId":2},
|
||||
{"id":"p2","name":"Joy & Calm","content":"text","categoryId":0,"solfeggioFrequencyId":0},
|
||||
R"([{"id":"p1","name":"Peace","content":"<p>I am <b>peace</b>.</p>","categoryId":3,"groupId":7,"solfeggioFrequencyId":2},
|
||||
{"id":"p2","name":"Joy & Calm","content":"text","categoryId":0,"groupId":0,"solfeggioFrequencyId":0},
|
||||
{"notAnObject":true}])";
|
||||
|
||||
std::vector<ProclamationRecord> out;
|
||||
@@ -18,12 +18,14 @@ TEST(ProclamationSyncTest, ParseProclamationsJson) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -38,9 +40,10 @@ TEST(ProclamationSyncTest, ParseIgnoresLegacySubscriptionLevelField) {
|
||||
}
|
||||
|
||||
TEST(ProclamationSyncTest, ParseCategoriesJson) {
|
||||
// Categories are flat now; a legacy parentId field is simply ignored.
|
||||
const std::string body =
|
||||
R"([{"id":3,"name":"Spiritual","parentId":0,"sortOrder":1},
|
||||
{"id":7,"name":"Inner","parentId":3,"sortOrder":2}])";
|
||||
R"([{"id":3,"name":"Spiritual","sortOrder":1},
|
||||
{"id":7,"name":"Physical","parentId":3,"sortOrder":2}])";
|
||||
|
||||
std::vector<ProclamationCategoryRecord> out;
|
||||
EXPECT_TRUE(parseCategoriesJson(body, out));
|
||||
@@ -48,18 +51,35 @@ TEST(ProclamationSyncTest, ParseCategoriesJson) {
|
||||
|
||||
EXPECT_EQ(out[0].id, 3);
|
||||
EXPECT_EQ(out[0].name, "Spiritual");
|
||||
EXPECT_EQ(out[0].parent_id, 0);
|
||||
EXPECT_EQ(out[0].sort_order, 1);
|
||||
|
||||
EXPECT_EQ(out[1].id, 7);
|
||||
EXPECT_EQ(out[1].name, "Inner");
|
||||
EXPECT_EQ(out[1].parent_id, 3);
|
||||
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
|
||||
@@ -69,6 +89,10 @@ TEST(ProclamationSyncTest, RejectMalformedJson) {
|
||||
// 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) {
|
||||
@@ -78,9 +102,14 @@ TEST(ProclamationSyncTest, ParseSkipsMissingIdAndZeroCategory) {
|
||||
EXPECT_EQ(procs[0].id, "ok");
|
||||
|
||||
std::vector<ProclamationCategoryRecord> cats;
|
||||
EXPECT_TRUE(parseCategoriesJson(R"([{"id":0,"name":"Bad"},{"id":9,"name":"Good","parentId":0}])", 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) {
|
||||
|
||||
Reference in New Issue
Block a user