diff --git a/app/Tests/Settings/SettingsStorageTests.cs b/app/Tests/Settings/SettingsStorageTests.cs
new file mode 100644
index 00000000..236fef42
--- /dev/null
+++ b/app/Tests/Settings/SettingsStorageTests.cs
@@ -0,0 +1,159 @@
+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);
+}
\ No newline at end of file