Files
Tobias Gesellchen 9f61d00b2d feat(admin): live Settings-page toggle for the opt-in update check
Follow-up to #591: UpdateCheckEnabled/UpdateCheckInterval are now
persisted, live-reloaded Settings fields (mirroring the discovery
enabled/interval pattern), editable from the admin Settings page
without a restart. The env var/CLI flag remains the seed value for a
fresh install with no settings.json yet.

The background goroutine now always runs and polls the live settings
every minute (updateCheckPollTick), instead of being started only if
enabled at process launch, so flipping the toggle takes effect within
a minute rather than requiring a restart.
2026-08-10 23:17:36 +02:00

639 lines
17 KiB
Go

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("<info>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)
}
}