#include "db/database_manager.h" #include #include using namespace radiantwave::db; TEST(DatabaseManagerTest, InitializeAndMigrate) { std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_db1.db").string(); if (std::filesystem::exists(temp_path)) { std::filesystem::remove(temp_path); } DatabaseManager db; EXPECT_TRUE(db.initialize(temp_path)); EXPECT_TRUE(std::filesystem::exists(temp_path)); db.close(); std::filesystem::remove(temp_path); } TEST(DatabaseManagerTest, KeyValueStoreOperations) { std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_db2.db").string(); if (std::filesystem::exists(temp_path)) { std::filesystem::remove(temp_path); } DatabaseManager db; ASSERT_TRUE(db.initialize(temp_path)); EXPECT_FALSE(db.hasKV("device_token")); EXPECT_EQ(db.getKV("device_token", "default_token"), "default_token"); EXPECT_TRUE(db.setKV("device_token", "ABC-123-XYZ")); EXPECT_TRUE(db.hasKV("device_token")); EXPECT_EQ(db.getKV("device_token"), "ABC-123-XYZ"); // Upsert test EXPECT_TRUE(db.setKV("device_token", "NEW-TOKEN-789")); EXPECT_EQ(db.getKV("device_token"), "NEW-TOKEN-789"); // Delete test EXPECT_TRUE(db.deleteKV("device_token")); EXPECT_FALSE(db.hasKV("device_token")); db.close(); std::filesystem::remove(temp_path); } TEST(DatabaseManagerTest, ProclamationsAndDownloads) { std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_db3.db").string(); if (std::filesystem::exists(temp_path)) { std::filesystem::remove(temp_path); } DatabaseManager db; ASSERT_TRUE(db.initialize(temp_path)); ProclamationRecord proc; proc.id = "p1"; proc.title = "Peace"; proc.content = "I am peace."; proc.solfeggio_freq_hz = 528.0; EXPECT_TRUE(db.saveProclamation(proc)); auto active = db.getActiveProclamations(); ASSERT_EQ(active.size(), 1u); EXPECT_EQ(active[0].title, "Peace"); EXPECT_DOUBLE_EQ(active[0].solfeggio_freq_hz, 528.0); AssetDownloadRecord dl; dl.id = "a1"; dl.filename = "audio1.wav"; dl.url = "https://example.com/audio1.wav"; dl.status = "pending"; EXPECT_TRUE(db.updateAssetDownload(dl)); auto pending = db.getPendingAssetDownloads(); ASSERT_EQ(pending.size(), 1u); EXPECT_EQ(pending[0].filename, "audio1.wav"); EXPECT_TRUE(db.logEvent("INFO", "Test", "All systems operational")); db.close(); std::filesystem::remove(temp_path); } namespace { // Helper: build a fresh in-memory-less temp DatabaseManager. struct DbFixture { std::string path; DatabaseManager db; DbFixture(const char* tag) { path = (std::filesystem::temp_directory_path() / tag).string(); if (std::filesystem::exists(path)) { std::filesystem::remove(path); } db.initialize(path); } ~DbFixture() { db.close(); std::filesystem::remove(path); } }; ProclamationRecord makeProc(const std::string& id, const std::string& title, int cat) { ProclamationRecord p; p.id = id; p.title = title; p.content = "content of " + title; p.category_id = cat; return p; } } // namespace TEST(DatabaseManagerTest, SyncProclamationsPreservesSelectionAndDeletesRemoved) { const std::string defId = "default-1"; DbFixture fx("test_rw_db_sync.db"); ProclamationRecord a = makeProc("a", "Alpha", 1); ProclamationRecord b = makeProc("b", "Beta", 1); // First sync inserts both. The incoming `selected` flag is never trusted. ASSERT_TRUE(fx.db.syncProclamations({a, b}, defId)); // Simulate the user picking "a". ASSERT_TRUE(fx.db.setProclamationSelected("a", true)); // Re-sync: "a" stays selected, "b" is removed because the API no longer // returns it. ASSERT_TRUE(fx.db.syncProclamations({a}, defId)); auto all = fx.db.getAllProclamations(); ASSERT_EQ(all.size(), 1u); EXPECT_EQ(all[0].id, "a"); EXPECT_TRUE(all[0].selected); } TEST(DatabaseManagerTest, SyncForcesDefaultProclamationSelected) { const std::string defId = "default-1"; DbFixture fx("test_rw_db_def.db"); ProclamationRecord def = makeProc(defId, "Default", 3); ProclamationRecord other = makeProc("x", "Other", 3); def.selected = false; ASSERT_TRUE(fx.db.syncProclamations({def, other}, defId)); ASSERT_TRUE(fx.db.ensureDefaultProclamationSelected(defId)); auto all = fx.db.getAllProclamations(); for (const auto& p : all) { if (p.id == defId) { EXPECT_TRUE(p.selected); } } } TEST(DatabaseManagerTest, SaveSelectedProclamationsClearsOthers) { const std::string defId = "default-1"; DbFixture fx("test_rw_db_save.db"); ProclamationRecord def = makeProc(defId, "Default", 1); ProclamationRecord a = makeProc("a", "A", 1); ProclamationRecord b = makeProc("b", "B", 1); ASSERT_TRUE(fx.db.syncProclamations({def, a, b}, defId)); ASSERT_TRUE(fx.db.setProclamationSelected("a", true)); // Deselect everything except a; the default must still end up selected. ASSERT_TRUE(fx.db.saveSelectedProclamations({"a"}, defId)); auto all = fx.db.getAllProclamations(); for (const auto& p : all) { bool expected = (p.id == "a" || p.id == defId); EXPECT_EQ(p.selected, expected) << "proclamation " << p.id; } } TEST(DatabaseManagerTest, ReplaceAndReadCategories) { DbFixture fx("test_rw_db_cats.db"); std::vector cats; 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].name, "Spiritual"); EXPECT_EQ(all[0].sort_order, 1); EXPECT_EQ(all[1].id, 7); 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 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"); std::vector first = { {0, "174 Hz Solfeggio", "174Hz.wav", 1, 1}, {0, "Original Audio", "OriginalAudio.wav", 2, 0}, {0, "Alpha Waves", "AlphaWaves.wav", 3, 13}, }; ASSERT_TRUE(fx.db.syncAudioFiles(first)); auto rows = fx.db.getAudioFiles(); ASSERT_EQ(rows.size(), 3u); EXPECT_EQ(rows[0].filename, "174Hz.wav"); EXPECT_EQ(rows[0].display_name, "174 Hz Solfeggio"); EXPECT_EQ(rows[0].display_order, 1); EXPECT_EQ(rows[0].solfeggio_frequency_id, 1); EXPECT_EQ(rows[1].filename, "OriginalAudio.wav"); EXPECT_EQ(rows[1].solfeggio_frequency_id, 0); EXPECT_EQ(rows[2].filename, "AlphaWaves.wav"); EXPECT_EQ(rows[2].solfeggio_frequency_id, 13); // Manifest update: renamed display name, reordered, one file removed. std::vector second = { {0, "174 Hz Solfeggio (Updated)", "174Hz.wav", 2, 1}, {0, "Alpha Waves", "AlphaWaves.wav", 1, 13}, }; ASSERT_TRUE(fx.db.syncAudioFiles(second)); rows = fx.db.getAudioFiles(); ASSERT_EQ(rows.size(), 2u); EXPECT_EQ(rows[0].filename, "AlphaWaves.wav"); EXPECT_EQ(rows[0].display_order, 1); EXPECT_EQ(rows[1].filename, "174Hz.wav"); EXPECT_EQ(rows[1].display_name, "174 Hz Solfeggio (Updated)"); EXPECT_EQ(rows[1].display_order, 2); } TEST(DatabaseManagerTest, SyncAudioFilesClearsEverythingWhenEmpty) { DbFixture fx("test_rw_db_audio_empty.db"); ASSERT_TRUE(fx.db.syncAudioFiles({{0, "Original Audio", "OriginalAudio.wav", 1, 0}})); ASSERT_EQ(fx.db.getAudioFiles().size(), 1u); ASSERT_TRUE(fx.db.syncAudioFiles({})); EXPECT_EQ(fx.db.getAudioFiles().size(), 0u); } TEST(DatabaseManagerTest, ReplaceAndReadScheduleBlocks) { DbFixture fx("test_rw_db_schedule.db"); ScheduleBlockRecord morning; morning.id = "b1"; morning.start_minutes = 360; morning.end_minutes = 480; morning.overnight = false; morning.duration_minutes = 120; morning.dynamic_audio = false; morning.proclamation_ids = {"p2", "p1"}; ScheduleBlockRecord night; night.id = "b2"; night.start_minutes = 1260; night.end_minutes = 540; night.overnight = true; night.duration_minutes = 720; night.dynamic_audio = true; night.proclamation_ids = {"p3"}; ASSERT_TRUE(fx.db.replaceScheduleBlocks({morning, night})); auto rows = fx.db.getScheduleBlocks(); ASSERT_EQ(rows.size(), 2u); EXPECT_EQ(rows[0].id, "b1"); EXPECT_EQ(rows[0].start_minutes, 360); EXPECT_EQ(rows[0].end_minutes, 480); EXPECT_FALSE(rows[0].overnight); EXPECT_EQ(rows[0].duration_minutes, 120); EXPECT_FALSE(rows[0].dynamic_audio); // Play order is preserved, not sorted. ASSERT_EQ(rows[0].proclamation_ids.size(), 2u); EXPECT_EQ(rows[0].proclamation_ids[0], "p2"); EXPECT_EQ(rows[0].proclamation_ids[1], "p1"); EXPECT_EQ(rows[1].id, "b2"); EXPECT_TRUE(rows[1].overnight); EXPECT_TRUE(rows[1].dynamic_audio); ASSERT_EQ(rows[1].proclamation_ids.size(), 1u); EXPECT_EQ(rows[1].proclamation_ids[0], "p3"); // A re-sync replaces the whole schedule. ASSERT_TRUE(fx.db.replaceScheduleBlocks({})); EXPECT_EQ(fx.db.getScheduleBlocks().size(), 0u); } TEST(DatabaseManagerTest, PalettesSeededAndRead) { DbFixture fx("test_rw_db_palette.db"); auto palette = fx.db.getPaletteForFrequencyId(5); // 528 Hz ASSERT_EQ(palette.size(), 7u); EXPECT_EQ(palette[0].r, 0x32); EXPECT_EQ(palette[0].g, 0xCD); EXPECT_EQ(palette[0].b, 0x32); EXPECT_EQ(palette[0].a, 255u); EXPECT_EQ(palette[6].r, 0xF0); EXPECT_EQ(palette[6].g, 0xFF); EXPECT_EQ(palette[6].b, 0xF0); auto red = fx.db.getPaletteForFrequencyId(2); // 396 Hz ASSERT_EQ(red.size(), 7u); EXPECT_EQ(red[0].r, 0x8B); EXPECT_EQ(red[0].g, 0x00); EXPECT_EQ(red[0].b, 0x00); } TEST(DatabaseManagerTest, UnknownFrequencyFallsBackTo528) { DbFixture fx("test_rw_db_palette_fb.db"); auto palette = fx.db.getPaletteForFrequencyId(9999); ASSERT_EQ(palette.size(), 7u); EXPECT_EQ(palette[0].r, 0x32); EXPECT_EQ(palette[0].g, 0xCD); EXPECT_EQ(palette[0].b, 0x32); } TEST(DatabaseManagerTest, PaletteSeedIsIdempotent) { DbFixture fx("test_rw_db_palette_seed.db"); // Re-initialize on the same file; seeding must not duplicate rows. fx.db.close(); ASSERT_TRUE(fx.db.initialize(fx.path)); auto palette = fx.db.getPaletteForFrequencyId(9); // 963 Hz ASSERT_EQ(palette.size(), 7u); EXPECT_EQ(palette[0].r, 0x94); EXPECT_EQ(palette[0].g, 0x00); EXPECT_EQ(palette[0].b, 0xD3); }