package datastore import ( "encoding/xml" "os" "path/filepath" "testing" "github.com/gesellix/bose-soundtouch/pkg/models" ) func TestDataStore(t *testing.T) { tempDir, err := os.MkdirTemp("", "st-test-*") if err != nil { t.Fatal(err) } defer func() { _ = os.RemoveAll(tempDir) }() ds := NewDataStore(tempDir) account := "test-account" device := "test-device" // Test Save/Get DeviceInfo info := &models.ServiceDeviceInfo{ DeviceID: device, Name: "Test Speaker", AccountID: account, } err = ds.SaveDeviceInfo(account, device, info) if err != nil { t.Errorf("SaveDeviceInfo failed: %v", err) } loadedInfo, err := ds.GetDeviceInfo(account, device) if err != nil { t.Errorf("GetDeviceInfo failed: %v", err) } if loadedInfo.Name != info.Name { t.Errorf("Expected name %s, got %s", info.Name, loadedInfo.Name) } // Test Presets presets := []models.ServicePreset{ { ServiceContentItem: models.ServiceContentItem{ Name: "Preset 1", }, }, } err = ds.SavePresets(account, device, presets) if err != nil { t.Errorf("SavePresets failed: %v", err) } loadedPresets, err := ds.GetPresets(account, device) if err != nil { t.Errorf("GetPresets failed: %v", err) } if len(loadedPresets) != 1 || loadedPresets[0].Name != "Preset 1" { t.Errorf("Unexpected presets: %+v", loadedPresets) } // Test Recents recents := []models.ServiceRecent{ { ServiceContentItem: models.ServiceContentItem{ Name: "Recent 1", }, }, } err = ds.SaveRecents(account, device, recents) if err != nil { t.Errorf("SaveRecents failed: %v", err) } loadedRecents, err := ds.GetRecents(account, device) if err != nil { t.Errorf("GetRecents failed: %v", err) } if len(loadedRecents) != 1 || loadedRecents[0].Name != "Recent 1" { t.Errorf("Unexpected recents: %+v", loadedRecents) } // Test path helpers expectedAccountDir := filepath.Join(tempDir, "accounts", account) if ds.AccountDir(account) != expectedAccountDir { t.Errorf("Expected account dir %s, got %s", expectedAccountDir, ds.AccountDir(account)) } // Test GetAccountInfo with placeholder accInfo, err := ds.GetAccountInfo("non-existent") if err != nil { t.Errorf("GetAccountInfo failed: %v", err) } if !accInfo.IsPlaceholder { t.Errorf("Expected IsPlaceholder to be true for non-existent account") } // Test SaveAccountInfo and GetAccountInfo accountID := "acc-123" info2 := &models.ServiceAccountInfo{ AccountID: accountID, PreferredLanguage: "en", } err = ds.SaveAccountInfo(accountID, info2) if err != nil { t.Errorf("SaveAccountInfo failed: %v", err) } loadedAccInfo, err := ds.GetAccountInfo(accountID) if err != nil { t.Errorf("GetAccountInfo failed: %v", err) } if loadedAccInfo.PreferredLanguage != "en" { t.Errorf("Expected language en, got %s", loadedAccInfo.PreferredLanguage) } if loadedAccInfo.IsPlaceholder { t.Errorf("Expected IsPlaceholder to be false for existing account") } } func TestListAllDevices_Empty(t *testing.T) { tempDir, err := os.MkdirTemp("", "st-empty-test-*") if err != nil { t.Fatal(err) } defer func() { _ = os.RemoveAll(tempDir) }() ds := NewDataStore(tempDir) // Case 1: DataDir does not exist _ = os.RemoveAll(tempDir) devices, err := ds.ListAllDevices() if err != nil { t.Errorf("ListAllDevices should not return error when DataDir does not exist, got %v", err) } if devices == nil || len(devices) != 0 { t.Errorf("Expected empty slice when DataDir does not exist, got %+v", devices) } // Case 2: DataDir is empty _ = os.MkdirAll(tempDir, 0755) devices, err = ds.ListAllDevices() if err != nil { t.Errorf("ListAllDevices failed on empty dir: %v", err) } if devices == nil { t.Errorf("Expected empty slice (not nil) when no devices exist") } if len(devices) != 0 { t.Errorf("Expected 0 devices, got %d", len(devices)) } } func TestListAllDevices(t *testing.T) { tempDir, err := os.MkdirTemp("", "st-list-test-*") if err != nil { t.Fatal(err) } defer func() { _ = os.RemoveAll(tempDir) }() ds := NewDataStore(tempDir) account := "default" deviceID := "BO5EBO5E-F00D-F00D-FEED-AABBCCDDEE0A" info := &models.ServiceDeviceInfo{ DeviceID: deviceID, Name: "Test Speaker", IPAddress: "192.0.2.100", DeviceSerialNumber: deviceID, ProductCode: "SoundTouch 10", FirmwareVersion: "1.2.3", AccountID: account, } err = ds.SaveDeviceInfo(account, deviceID, info) if err != nil { t.Fatalf("SaveDeviceInfo failed: %v", err) } devices, err := ds.ListAllDevices() if err != nil { t.Fatalf("ListAllDevices failed: %v", err) } if len(devices) != 1 { t.Fatalf("Expected 1 device, got %d", len(devices)) } if devices[0].DeviceID != deviceID { t.Errorf("Expected DeviceID %s, got %s", deviceID, devices[0].DeviceID) } } func TestListAllDevices_EmptyDeviceID(t *testing.T) { tempDir, err := os.MkdirTemp("", "st-empty-id-test-*") if err != nil { t.Fatal(err) } defer func() { _ = os.RemoveAll(tempDir) }() ds := NewDataStore(tempDir) account := "default" deviceID := "" info := &models.ServiceDeviceInfo{ DeviceID: deviceID, Name: "Empty ID Speaker", AccountID: account, } // Use IP as fallback for device ID if it is empty key := deviceID if key == "" { key = "127.0.0.1" } err = ds.SaveDeviceInfo(account, key, info) if err != nil { t.Fatalf("SaveDeviceInfo failed: %v", err) } devices, err := ds.ListAllDevices() if err != nil { t.Fatalf("ListAllDevices failed: %v", err) } if len(devices) != 1 { t.Fatalf("Expected 1 device, got %d", len(devices)) } if devices[0].Name != "Empty ID Speaker" { t.Errorf("Expected Name 'Empty ID Speaker', got %s", devices[0].Name) } } func TestListAllDevices_MultipleEmptyIDs(t *testing.T) { tempDir, err := os.MkdirTemp("", "st-multi-empty-test-*") if err != nil { t.Fatal(err) } defer func() { _ = os.RemoveAll(tempDir) }() ds := NewDataStore(tempDir) account := "default" // Save two devices with empty ID but different IPs info1 := &models.ServiceDeviceInfo{ DeviceID: "", Name: "Speaker 1", IPAddress: "192.0.2.1", AccountID: account, } info2 := &models.ServiceDeviceInfo{ DeviceID: "", Name: "Speaker 2", IPAddress: "192.0.2.2", AccountID: account, } // We use the same logic as in main.go: use IP as fallback for directory name err = ds.SaveDeviceInfo(account, info1.IPAddress, info1) if err != nil { t.Fatalf("SaveDeviceInfo 1 failed: %v", err) } err = ds.SaveDeviceInfo(account, info2.IPAddress, info2) if err != nil { t.Fatalf("SaveDeviceInfo 2 failed: %v", err) } devices, err := ds.ListAllDevices() if err != nil { t.Fatalf("ListAllDevices failed: %v", err) } if len(devices) != 2 { t.Fatalf("Expected 2 devices, got %d", len(devices)) } } func TestListAllDevices_MalformedXML(t *testing.T) { tempDir, err := os.MkdirTemp("", "st-malformed-test-*") if err != nil { t.Fatal(err) } defer func() { _ = os.RemoveAll(tempDir) }() ds := NewDataStore(tempDir) account := "default" deviceID := "malformed-device" dir := ds.AccountDeviceDir(account, deviceID) _ = os.MkdirAll(dir, 0755) _ = os.WriteFile(filepath.Join(dir, "DeviceInfo.xml"), []byte("not even closed"), 0644) devices, err := ds.ListAllDevices() if err != nil { t.Fatalf("ListAllDevices should not return error on malformed XML: %v", err) } if len(devices) != 0 { t.Errorf("Expected 0 devices, got %d", len(devices)) } } func TestConfiguredSources(t *testing.T) { tempDir, _ := os.MkdirTemp("", "datastore-sources-*") defer func() { _ = os.RemoveAll(tempDir) }() ds := NewDataStore(tempDir) account := "test-acc" sources := []models.ConfiguredSource{ { DisplayName: "Source 1", ID: "101", Secret: "secret1", SecretType: "type1", SourceKey: struct { Type string `xml:"type,attr"` Account string `xml:"account,attr"` }{Type: "TUNEIN", Account: "user1"}, SourceKeyType: "TUNEIN", SourceKeyAccount: "user1", }, { DisplayName: "Source 2", ID: "102", Secret: "secret2", SecretType: "type2", SourceKey: struct { Type string `xml:"type,attr"` Account string `xml:"account,attr"` }{Type: "PANDORA", Account: "user2"}, SourceKeyType: "PANDORA", SourceKeyAccount: "user2", }, } err := ds.SaveConfiguredSources(account, "any", sources) if err != nil { t.Fatalf("SaveConfiguredSources failed: %v", err) } loadedSources, err := ds.GetConfiguredSources(account, "any") if err != nil { t.Fatalf("GetConfiguredSources failed: %v", err) } if len(loadedSources) != len(sources) { t.Fatalf("Expected %d sources, got %d", len(sources), len(loadedSources)) } for i := range sources { ls := loadedSources[i] expected := sources[i] expected.Secret = "" expected.SecretType = "" expected.Type = ls.Type // Ignore Type mismatch in this test if it's auto-populated // Clear secrets for comparison since they are not loaded by GetConfiguredSources ls.Secret = "" ls.SecretType = "" if ls.DisplayName != expected.DisplayName || ls.ID != expected.ID || ls.Secret != expected.Secret || ls.SecretType != expected.SecretType || ls.SourceKeyType != expected.SourceKeyType || ls.SourceKeyAccount != expected.SourceKeyAccount || ls.Type != expected.Type { // Clean XMLName for comparison ls.XMLName = xml.Name{} if ls.DisplayName != expected.DisplayName || ls.ID != expected.ID || ls.Secret != expected.Secret || ls.SecretType != expected.SecretType || ls.SourceKeyType != expected.SourceKeyType || ls.SourceKeyAccount != expected.SourceKeyAccount || ls.Type != expected.Type { t.Errorf("Source %d mismatch. Expected %+v, got %+v", i, expected, ls) } } } // Test with missing ID (GetConfiguredSources should auto-assign) sources2 := []models.ConfiguredSource{ { DisplayName: "Source No ID", SourceKeyType: "LOCAL", SourceKeyAccount: "user3", }, } err = ds.SaveConfiguredSources(account, "any", sources2) if err != nil { t.Fatal(err) } loadedSources2, err := ds.GetConfiguredSources(account, "any") if err != nil { t.Fatal(err) } if loadedSources2[0].ID == "" { t.Error("Expected auto-assigned ID for source with empty ID") } } func TestSettingsPersistence(t *testing.T) { tempDir, err := os.MkdirTemp("", "settings-test-*") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) ds := NewDataStore(tempDir) settings := Settings{ ServerURL: "http://myserver:8000", LogBodies: true, DiscoveryInterval: "10m", DiscoveryEnabled: true, UpdateCheckInterval: "12h", UpdateCheckEnabled: true, } err = ds.SaveSettings(settings) if err != nil { t.Fatalf("SaveSettings failed: %v", err) } loaded, err := ds.GetSettings() if err != nil { t.Fatalf("GetSettings failed: %v", err) } if loaded.ServerURL != settings.ServerURL { t.Errorf("Expected ServerURL %s, got %s", settings.ServerURL, loaded.ServerURL) } if loaded.LogBodies != settings.LogBodies { t.Errorf("Expected LogBodies %v, got %v", settings.LogBodies, loaded.LogBodies) } if loaded.DiscoveryInterval != settings.DiscoveryInterval { t.Errorf("Expected DiscoveryInterval %s, got %s", settings.DiscoveryInterval, loaded.DiscoveryInterval) } if loaded.DiscoveryEnabled != settings.DiscoveryEnabled { t.Errorf("Expected DiscoveryEnabled %v, got %v", settings.DiscoveryEnabled, loaded.DiscoveryEnabled) } if loaded.UpdateCheckInterval != settings.UpdateCheckInterval { t.Errorf("Expected UpdateCheckInterval %s, got %s", settings.UpdateCheckInterval, loaded.UpdateCheckInterval) } if loaded.UpdateCheckEnabled != settings.UpdateCheckEnabled { t.Errorf("Expected UpdateCheckEnabled %v, got %v", settings.UpdateCheckEnabled, loaded.UpdateCheckEnabled) } } // TestUpdateCheckState_MissingFileReturnsZeroValue verifies a fresh install // (or one where the update check has never run) gets a zero-value state, // not an error — same shape as GetSettings on a missing settings.json. func TestUpdateCheckState_MissingFileReturnsZeroValue(t *testing.T) { tempDir, err := os.MkdirTemp("", "update-check-missing-test-*") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) ds := NewDataStore(tempDir) state, err := ds.GetUpdateCheckState() if err != nil { t.Fatalf("GetUpdateCheckState on a fresh install should not error, got: %v", err) } if state != (UpdateCheckState{}) { t.Errorf("Expected zero-value state, got %+v", state) } } // TestUpdateCheckState_Persistence is the roundtrip test, mirroring // TestSettingsPersistence. func TestUpdateCheckState_Persistence(t *testing.T) { tempDir, err := os.MkdirTemp("", "update-check-persist-test-*") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) ds := NewDataStore(tempDir) state := UpdateCheckState{ LastCheckedAt: "2026-08-09T12:00:00Z", LastSeenVersion: "v0.122.0", LastReleaseURL: "https://github.com/gesellix/Bose-SoundTouch/releases/tag/v0.122.0", } if err := ds.SaveUpdateCheckState(state); err != nil { t.Fatalf("SaveUpdateCheckState failed: %v", err) } loaded, err := ds.GetUpdateCheckState() if err != nil { t.Fatalf("GetUpdateCheckState failed: %v", err) } if loaded != state { t.Errorf("Expected %+v, got %+v", state, loaded) } } // TestRecordActivity_EmptyKindReturnsNilNotError verifies GetActivityRecords // for a kind that was never recorded returns an empty, non-error result — // the "nothing recorded yet" case, not a failure. func TestRecordActivity_EmptyKindReturnsNilNotError(t *testing.T) { tempDir, err := os.MkdirTemp("", "activity-empty-test-*") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) ds := NewDataStore(tempDir) records, err := ds.GetActivityRecords("notification_dismissed") if err != nil { t.Fatalf("GetActivityRecords on empty kind should not error, got: %v", err) } if len(records) != 0 { t.Errorf("Expected no records, got %d", len(records)) } } // TestRecordActivity_SameIDRecursWithNewTimestamp is the regression test for // the append-only shape agreed in the #419 design: dismissing the same // announcement twice must produce two records, not overwrite one — this is // a log, not a keyed map. func TestRecordActivity_SameIDRecursWithNewTimestamp(t *testing.T) { tempDir, err := os.MkdirTemp("", "activity-recur-test-*") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) ds := NewDataStore(tempDir) if err := ds.RecordActivity("notification_dismissed", "admin-gate-notice", nil); err != nil { t.Fatalf("First RecordActivity failed: %v", err) } if err := ds.RecordActivity("notification_dismissed", "admin-gate-notice", nil); err != nil { t.Fatalf("Second RecordActivity failed: %v", err) } records, err := ds.GetActivityRecords("notification_dismissed") if err != nil { t.Fatalf("GetActivityRecords failed: %v", err) } if len(records) != 2 { t.Fatalf("Expected 2 records for the same recurring id, got %d: %+v", len(records), records) } for _, r := range records { if r.ID != "admin-gate-notice" || r.Kind != "notification_dismissed" || r.Timestamp == "" { t.Errorf("Unexpected record shape: %+v", r) } } } // TestRecordActivity_DetailRoundTrips verifies the optional detail payload // survives a write/read round trip. func TestRecordActivity_DetailRoundTrips(t *testing.T) { tempDir, err := os.MkdirTemp("", "activity-detail-test-*") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) ds := NewDataStore(tempDir) if err := ds.RecordActivity("some_kind", "some-id", map[string]interface{}{"note": "hello"}); err != nil { t.Fatalf("RecordActivity failed: %v", err) } records, err := ds.GetActivityRecords("some_kind") if err != nil { t.Fatalf("GetActivityRecords failed: %v", err) } if len(records) != 1 { t.Fatalf("Expected 1 record, got %d", len(records)) } if records[0].Detail["note"] != "hello" { t.Errorf("Expected detail to round-trip, got: %+v", records[0].Detail) } } func TestMoveDeviceMigratesData(t *testing.T) { tempDir := t.TempDir() ds := NewDataStore(tempDir) oldAccount := "default" newAccount := "8637922" deviceID := "F4E11E930BEB" // Seed the device under the old account with presets presets := []models.ServicePreset{ {ID: "1", ServiceContentItem: models.ServiceContentItem{Name: "Radio Preset", ContentItemType: "stationurl"}}, } if err := ds.SavePresets(oldAccount, deviceID, presets); err != nil { t.Fatalf("SavePresets under %s: %v", oldAccount, err) } // Move the device to the new account if err := ds.MoveDevice(oldAccount, newAccount, deviceID); err != nil { t.Fatalf("MoveDevice %s → %s: %v", oldAccount, newAccount, err) } // Verify presets survived the move under the new account moved, err := ds.GetPresets(newAccount, deviceID) if err != nil { t.Fatalf("GetPresets under %s after move: %v", newAccount, err) } if len(moved) != 1 || moved[0].Name != "Radio Preset" { t.Errorf("presets not preserved: got %+v", moved) } // Verify the old account no longer has the device oldPresets, err := ds.GetPresets(oldAccount, deviceID) if err == nil && len(oldPresets) > 0 { t.Errorf("old account %s still has presets after move: %+v", oldAccount, oldPresets) } }