package config import ( "encoding/json" "fmt" "os" "path/filepath" "sync" "testing" "gitgud.io/mike/mpv-manager/pkg/constants" "github.com/stretchr/testify/assert" ) // TestConcurrentConfigAccess exercises the exported config mutators and // accessors from many goroutines. Run with -race to catch data races on // globalConfig. func TestConcurrentConfigAccess(t *testing.T) { resetGlobalConfig() tempDir := t.TempDir() isolateConfigHome(t, tempDir) if _, err := Load(); err != nil { t.Fatalf("Load failed: %v", err) } const workers = 8 const iterations = 25 var wg sync.WaitGroup for w := 0; w < workers; w++ { wg.Add(1) go func(id int) { defer wg.Done() name := fmt.Sprintf("APP-%d", id%4) app := InstalledApp{AppName: name, AppType: "flatpak", InstallMethod: "mpv-flatpak"} for i := 0; i < iterations; i++ { _ = AddInstalledApp(app) _ = IsAppInstalled(name) _ = len(GetInstalledApps()) _ = RemoveInstalledApp(name) _ = GetUOSCVersion() _ = GetInstallPath() _ = GetInstalledUIType() } }(w) } wg.Wait() } // TestGetInstalledAppsReturnsCopy verifies callers cannot mutate shared // config state through the returned slice. func TestGetInstalledAppsReturnsCopy(t *testing.T) { resetGlobalConfig() tempDir := t.TempDir() isolateConfigHome(t, tempDir) assert.NoError(t, AddInstalledApp(InstalledApp{AppName: "MPV"})) apps := GetInstalledApps() assert.Len(t, apps, 1) apps[0].AppName = "MUTATED" fresh := GetInstalledApps() assert.Len(t, fresh, 1) assert.Equal(t, "MPV", fresh[0].AppName) } func TestUpdateInstalledAppUISelectionByIDPersistsBothFields(t *testing.T) { resetGlobalConfig() home := t.TempDir() isolateConfigHome(t, home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) assert.NoError(t, AddInstalledApp(InstalledApp{AppName: "MPV", InstallMethod: "mpv-binary"})) apps := GetInstalledApps() if !assert.Len(t, apps, 1) { return } assert.NoError(t, UpdateInstalledAppUISelectionByID(apps[0].ID, "modernz", "modernz")) apps = GetInstalledApps() if !assert.Len(t, apps, 1) { return } assert.Equal(t, "modernz", apps[0].UIType) assert.Equal(t, "modernz", GetInstalledUIType()) } // TestLoadQuarantinesCorruptConfig verifies a corrupt config file is renamed // aside instead of being overwritten with defaults. func TestLoadQuarantinesCorruptConfig(t *testing.T) { resetGlobalConfig() tempDir := t.TempDir() isolateConfigHome(t, tempDir) // Installer config is stored in the MPV config directory configDir := testConfigDir(tempDir) configFile := filepath.Join(configDir, constants.ConfigFileName) assert.NoError(t, os.MkdirAll(configDir, 0755)) corrupt := []byte("{ this is not valid json !!!") assert.NoError(t, os.WriteFile(configFile, corrupt, 0644)) cfg, err := Load() assert.NoError(t, err) assert.NotNil(t, cfg) // In-memory config is fresh defaults assert.False(t, cfg.AutoUpdate) assert.Empty(t, cfg.InstalledApps) // The corrupt content was quarantined aside, not destroyed matches, globErr := filepath.Glob(configFile + ".corrupt-*") assert.NoError(t, globErr) assert.Len(t, matches, 1) data, readErr := os.ReadFile(matches[0]) assert.NoError(t, readErr) assert.Equal(t, string(corrupt), string(data)) // A fresh, valid config file is written in place of the corrupt one data, readErr = os.ReadFile(configFile) assert.NoError(t, readErr) var loaded Config assert.NoError(t, json.Unmarshal(data, &loaded)) assert.Empty(t, loaded.InstalledApps) } func TestReloadQuarantinesCorruptConfig(t *testing.T) { resetGlobalConfig() tempDir := t.TempDir() isolateConfigHome(t, tempDir) configDir := testConfigDir(tempDir) configFile := filepath.Join(configDir, constants.ConfigFileName) assert.NoError(t, os.MkdirAll(configDir, 0755)) assert.NoError(t, os.WriteFile(configFile, []byte("{broken"), 0644)) cfg, err := Reload() assert.NoError(t, err) assert.NotNil(t, cfg) matches, globErr := filepath.Glob(configFile + ".corrupt-*") assert.NoError(t, globErr) assert.Len(t, matches, 1) data, readErr := os.ReadFile(configFile) assert.NoError(t, readErr) var loaded Config assert.NoError(t, json.Unmarshal(data, &loaded)) assert.False(t, loaded.AutoUpdate) } func TestSetInstallPathRollsBackWhenPersistenceFails(t *testing.T) { resetGlobalConfig() blockingHome := filepath.Join(t.TempDir(), "not-a-directory") assert.NoError(t, os.WriteFile(blockingHome, []byte("file"), 0600)) isolateConfigHome(t, blockingHome) err := SetInstallPath("/custom/install") assert.Error(t, err) globalMu.RLock() defer globalMu.RUnlock() assert.Empty(t, globalConfig.InstallPath) } func TestRejectedConfigMutationIsNeverPublishedOrPersistedLater(t *testing.T) { resetGlobalConfig() t.Cleanup(resetGlobalConfig) home := t.TempDir() isolateConfigHome(t, home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) _, err := Load() assert.NoError(t, err) configPath := filepath.Join(testConfigDir(home), constants.ConfigFileName) configDir := filepath.Dir(configPath) // Replace the config directory with a regular file after a successful load // so persistence fails while a known-good snapshot remains in memory. assert.NoError(t, os.Remove(configPath)) assert.NoError(t, os.Remove(configDir)) assert.NoError(t, os.WriteFile(configDir, []byte("block mkdir"), 0600)) assert.Error(t, SetModernZVersion("rejected-version")) assert.Empty(t, GetModernZVersion(), "failed candidate leaked into live state") assert.NoError(t, os.Remove(configDir)) assert.NoError(t, os.MkdirAll(configDir, 0755)) assert.NoError(t, SetFFmpegVersion("accepted-version")) data, err := os.ReadFile(configPath) assert.NoError(t, err) var persisted Config assert.NoError(t, json.Unmarshal(data, &persisted)) assert.Empty(t, persisted.ModernZVersion, "later save persisted a rejected mutation") assert.Equal(t, "accepted-version", persisted.FFmpegVersion) } func TestUpdateRebasesOnLatestDiskSnapshot(t *testing.T) { resetGlobalConfig() t.Cleanup(resetGlobalConfig) home := t.TempDir() isolateConfigHome(t, home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) _, err := Load() assert.NoError(t, err) configPath := filepath.Join(testConfigDir(home), constants.ConfigFileName) data, err := os.ReadFile(configPath) assert.NoError(t, err) var external Config assert.NoError(t, json.Unmarshal(data, &external)) external.UOSCVersion = "external-writer" data, err = json.MarshalIndent(external, "", " ") assert.NoError(t, err) assert.NoError(t, os.WriteFile(configPath, data, 0o644)) assert.NoError(t, SetModernZVersion("local-writer")) data, err = os.ReadFile(configPath) assert.NoError(t, err) var persisted Config assert.NoError(t, json.Unmarshal(data, &persisted)) assert.Equal(t, "external-writer", persisted.UOSCVersion) assert.Equal(t, "local-writer", persisted.ModernZVersion) } func TestLoadReadErrorDoesNotReplaceConfigWithDefaults(t *testing.T) { resetGlobalConfig() t.Cleanup(resetGlobalConfig) home := t.TempDir() isolateConfigHome(t, home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) configPath := filepath.Join(testConfigDir(home), constants.ConfigFileName) assert.NoError(t, os.MkdirAll(configPath, 0755)) // reading a directory is an I/O error, not absence loaded, err := Load() assert.Error(t, err) assert.NotNil(t, loaded) info, statErr := os.Stat(configPath) assert.NoError(t, statErr) assert.True(t, info.IsDir(), "read error path was replaced with a default file") assert.Error(t, SetModernZVersion("must-not-write"), "mutations must remain blocked after a failed load") } func TestReloadReadErrorPreservesLastKnownGoodState(t *testing.T) { resetGlobalConfig() t.Cleanup(resetGlobalConfig) home := t.TempDir() isolateConfigHome(t, home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) assert.NoError(t, SetModernZVersion("known-good")) configPath := filepath.Join(testConfigDir(home), constants.ConfigFileName) assert.NoError(t, os.Remove(configPath)) assert.NoError(t, os.Mkdir(configPath, 0755)) loaded, err := Reload() assert.Error(t, err) assert.Equal(t, "known-good", loaded.ModernZVersion) assert.Equal(t, "known-good", GetModernZVersion()) assert.Error(t, SetFFmpegVersion("must-not-write")) info, statErr := os.Stat(configPath) assert.NoError(t, statErr) assert.True(t, info.IsDir()) } func TestReloadCorruptFilePreservesLastKnownGoodState(t *testing.T) { resetGlobalConfig() t.Cleanup(resetGlobalConfig) home := t.TempDir() isolateConfigHome(t, home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) assert.NoError(t, SetModernZVersion("known-good")) configPath := filepath.Join(testConfigDir(home), constants.ConfigFileName) assert.NoError(t, os.WriteFile(configPath, []byte("{partial"), 0644)) loaded, err := Reload() assert.Error(t, err) assert.Equal(t, "known-good", loaded.ModernZVersion) assert.Equal(t, "known-good", GetModernZVersion()) assert.Error(t, SetFFmpegVersion("must-not-write")) data, readErr := os.ReadFile(configPath) assert.NoError(t, readErr) assert.Equal(t, "{partial", string(data), "reload must not quarantine or replace a transient malformed file") } func TestResetToDefaultsCreatesUniqueDurableBackupBeforePublish(t *testing.T) { resetGlobalConfig() t.Cleanup(resetGlobalConfig) home := t.TempDir() isolateConfigHome(t, home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) assert.NoError(t, SetModernZVersion("before-reset")) configPath := filepath.Join(testConfigDir(home), constants.ConfigFileName) before, err := os.ReadFile(configPath) assert.NoError(t, err) assert.NoError(t, ResetToDefaults()) assert.Empty(t, GetModernZVersion()) backups, err := filepath.Glob(filepath.Join(filepath.Dir(configPath), filepath.Base(configPath)+".backup-*")) assert.NoError(t, err) if assert.Len(t, backups, 1) { backup, readErr := os.ReadFile(backups[0]) assert.NoError(t, readErr) assert.Equal(t, before, backup) } } // TestSaveLoadRoundTripCustomInstallPath verifies Load and Save resolve the // config file through the same function so a custom install path survives // across runs. func TestSaveLoadRoundTripCustomInstallPath(t *testing.T) { resetGlobalConfig() tempDir := t.TempDir() isolateConfigHome(t, tempDir) SetInstallPath(filepath.Join(tempDir, "custom-mpv")) globalMu.Lock() pathAfterSave := resolveConfigPath() globalMu.Unlock() resetGlobalConfig() cfg, err := Load() assert.NoError(t, err) assert.Equal(t, filepath.Join(tempDir, "custom-mpv"), cfg.InstallPath) assert.Equal(t, filepath.Join(tempDir, "custom-mpv"), GetInstallPath()) globalMu.Lock() pathAfterLoad := resolveConfigPath() globalMu.Unlock() assert.Equal(t, pathAfterSave, pathAfterLoad) } // TestInstalledUIType verifies the installed UI type persists and defaults // to uOSC when nothing was ever saved. func TestInstalledUIType(t *testing.T) { resetGlobalConfig() tempDir := t.TempDir() isolateConfigHome(t, tempDir) // Defaults to uOSC when nothing was ever saved assert.Equal(t, constants.UITypeUOSC, GetInstalledUIType()) assert.NoError(t, SetInstalledUIType(constants.UITypeModernZ)) assert.Equal(t, constants.UITypeModernZ, GetInstalledUIType()) resetGlobalConfig() _, err := Load() assert.NoError(t, err) assert.Equal(t, constants.UITypeModernZ, GetInstalledUIType()) } // TestSaveAtomicNoTempLeftBehind verifies Save leaves no temp file behind. func TestSaveAtomicNoTempLeftBehind(t *testing.T) { resetGlobalConfig() tempDir := t.TempDir() isolateConfigHome(t, tempDir) _, err := Load() assert.NoError(t, err) assert.NoError(t, Save()) globalMu.Lock() configPath := resolveConfigPath() globalMu.Unlock() _, statErr := os.Stat(configPath + ".tmp") assert.True(t, os.IsNotExist(statErr), "temp file should not exist after Save") }