using System.Text.Json; using AIStudio.Settings; using AIStudio.Settings.DataModel; using Microsoft.Extensions.Logging.Abstractions; using Version = AIStudio.Settings.Version; namespace AIStudio.Tests.Settings; /// /// Checks what settings operations do to each other when they overlap. /// /// /// The settings are written from everywhere: a timer firing on its own thread, a dialog the user /// just closed, a configuration plugin which arrived over the network. Nothing keeps two of those /// from meeting, and what they must never leave behind is a settings file nobody can read -- it is /// the file the app starts from the next morning. These tests arrange the meeting on purpose and /// look at what is on the disk afterward. /// [TestFixture] [NonParallelizable] public sealed class SettingsStorageTests { /// /// How many operations are set against each other. /// /// /// High enough that the operations genuinely overlap on any machine, low enough that the test /// stays a test. A race which needs more than this to show up would not be one the app meets. /// private const int CONCURRENT_OPERATIONS = 50; private const string SETTINGS_FILENAME = "settings.json"; private const string BACKUP_FILENAME = "settings.v6.json"; private string? previousConfigDirectory; private string? previousDataDirectory; private string testDirectory = string.Empty; [SetUp] public void PrepareTestDirectory() { // // Both directories are static state of the whole application, which is why this fixture // does not run alongside others. They are put back in the teardown so that a later test // does not inherit a directory which is gone by then. // this.previousConfigDirectory = SettingsManager.ConfigDirectory; this.previousDataDirectory = SettingsManager.DataDirectory; this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-settings-{Guid.NewGuid():N}"); Directory.CreateDirectory(this.testDirectory); SettingsManager.ConfigDirectory = this.testDirectory; SettingsManager.DataDirectory = this.testDirectory; } [TearDown] public void RemoveTestDirectory() { SettingsManager.ConfigDirectory = this.previousConfigDirectory; SettingsManager.DataDirectory = this.previousDataDirectory; try { Directory.Delete(this.testDirectory, true); } catch (IOException) { // A temporary directory we could not remove says nothing about the code under test. } } [Test] public async Task OverlappingStoresLeaveBothFilesReadable() { var settingsManager = CreateSettingsManager(); await Task.WhenAll(Enumerable.Range(0, CONCURRENT_OPERATIONS).Select(_ => settingsManager.StoreSettings())); var settingsPath = Path.Combine(this.testDirectory, SETTINGS_FILENAME); var backupPath = Path.Combine(this.testDirectory, BACKUP_FILENAME); Assert.Multiple(() => { Assert.That(File.Exists(settingsPath), Is.True, "The settings file was never written."); Assert.That(File.Exists(backupPath), Is.True, "The settings backup file was never written."); Assert.That(ReadSettingsFile(settingsPath)?.Version, Is.EqualTo(Version.V6), "The settings file could not be read back."); Assert.That(ReadSettingsFile(backupPath)?.Version, Is.EqualTo(Version.V6), "The settings backup file could not be read back."); }); } [Test] public async Task OverlappingStoresLeaveNoTemporaryFilesBehind() { var settingsManager = CreateSettingsManager(); await Task.WhenAll(Enumerable.Range(0, CONCURRENT_OPERATIONS).Select(_ => settingsManager.StoreSettings())); // // Every store writes its settings next to the previous ones and renames afterwards. The // temporary file carries a name of its own, so two stores cannot collide over it -- but a // store which gave up halfway would leave one lying around, and the next start would find // a configuration directory filling up with them. // var leftovers = Directory.GetFiles(this.testDirectory, "*.tmp-*").Select(Path.GetFileName).ToList(); Assert.That(leftovers, Is.Empty, $"Temporary settings files were left behind: {string.Join(", ", leftovers)}."); } [Test] public async Task AStoreCannotSlipThroughWhileAReadReconsidersTheWriteBlock() { var settingsPath = Path.Combine(this.testDirectory, SETTINGS_FILENAME); // // Settings written by a newer app than this one. Reading them blocks every write, so that // this app cannot replace settings it does not understand with the little it does. What // makes this the interesting case is how a read arrives at that verdict: it clears the // block first and only re-establishes it once it has seen the file. A store meeting that // moment would find nothing standing in its way and overwrite the very file the block // exists for -- which is why a read holds the same lock a store does. // await File.WriteAllTextAsync(settingsPath, """{"Version": "V99"}"""); var settingsManager = CreateSettingsManager(); await settingsManager.TryReadSettingsSnapshot(); Assert.That(settingsManager.SettingsWriteBlockReason, Is.EqualTo(SettingsWriteBlockReason.VERSION_NEWER_THAN_APP), "The newer settings file did not block writes in the first place."); var operations = new List(); for (var i = 0; i < CONCURRENT_OPERATIONS; i++) { operations.Add(settingsManager.StoreSettings()); operations.Add(settingsManager.TryReadSettingsSnapshot()); } await Task.WhenAll(operations); using var settingsDocument = JsonDocument.Parse(await File.ReadAllTextAsync(settingsPath)); Assert.Multiple(() => { Assert.That(settingsDocument.RootElement.GetProperty("Version").GetString(), Is.EqualTo("V99"), "A store overwrote the newer settings file while a read was reconsidering the write block."); Assert.That(settingsManager.SettingsWriteBlockReason, Is.EqualTo(SettingsWriteBlockReason.VERSION_NEWER_THAN_APP), "The write block did not survive the reads which re-established it."); }); } /// /// Builds a settings manager the way these tests need it. /// /// /// The rust service is handed in as null on purpose: neither storing nor reading settings ever /// asks it anything. Only the active language is read through it, and that is not what is being /// checked here. Should a future store reach for it, the test says so by failing loudly rather /// than by quietly testing a different thing. /// private static SettingsManager CreateSettingsManager() => new(NullLogger.Instance, null!); private static Data? ReadSettingsFile(string settingsPath) => JsonSerializer.Deserialize(File.ReadAllText(settingsPath), SettingsManager.JSON_OPTIONS); }