feat(persistence): add ConfigStore with atomic JSON writes and corruption-safe load
Some checks failed
CI / build-and-test (push) Failing after 22s

This commit is contained in:
Zac Gaetano 2026-05-07 15:12:01 +00:00
parent 464f559576
commit 3f8b5f1a7b
2 changed files with 136 additions and 0 deletions

View file

@ -0,0 +1,59 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using TeamsISO.Engine.Domain;
namespace TeamsISO.Engine.Persistence;
/// <summary>
/// Loads and saves <see cref="EngineConfig"/> as JSON.
/// Writes are atomic: serialize to a temp file in the same directory, then <c>File.Move</c> with overwrite.
/// </summary>
public sealed class ConfigStore
{
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() }
};
private readonly string _path;
private readonly ILogger<ConfigStore> _logger;
public ConfigStore(string path, ILogger<ConfigStore> logger)
{
_path = path;
_logger = logger;
}
public EngineConfig Load()
{
if (!File.Exists(_path))
{
_logger.LogInformation("Config file not found at {Path}; using defaults.", _path);
return EngineConfig.Default;
}
try
{
var json = File.ReadAllText(_path);
var loaded = JsonSerializer.Deserialize<EngineConfig>(json, Options);
return loaded ?? EngineConfig.Default;
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Config file at {Path} is not valid JSON; using defaults.", _path);
return EngineConfig.Default;
}
}
public void Save(EngineConfig config)
{
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
var temp = _path + ".tmp";
var json = JsonSerializer.Serialize(config, Options);
File.WriteAllText(temp, json);
File.Move(temp, _path, overwrite: true);
}
}

View file

@ -0,0 +1,77 @@
using Microsoft.Extensions.Logging.Abstractions;
using TeamsISO.Engine.Domain;
using TeamsISO.Engine.Persistence;
namespace TeamsISO.Engine.Tests.Persistence;
public class ConfigStoreTests : IDisposable
{
private readonly string _dir;
public ConfigStoreTests()
{
_dir = Path.Combine(Path.GetTempPath(), $"teamsiso-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(_dir);
}
public void Dispose() => Directory.Delete(_dir, recursive: true);
private ConfigStore NewStore() =>
new(Path.Combine(_dir, "config.json"), NullLogger<ConfigStore>.Instance);
[Fact]
public void Load_WhenFileMissing_ReturnsDefault()
{
var store = NewStore();
var config = store.Load();
config.Should().Be(EngineConfig.Default);
}
[Fact]
public void SaveThenLoad_RoundTripsExactly()
{
var store = NewStore();
var input = new EngineConfig(
new FrameProcessingSettings(TargetFramerate.Fps59_94, TargetResolution.R720p,
AspectMode.Letterbox, AudioMode.Isolated),
new[]
{
new IsoAssignment(Guid.NewGuid(), IsEnabled: true, CustomOutputName: "TEAMSISO_A"),
new IsoAssignment(Guid.NewGuid(), IsEnabled: false, CustomOutputName: null),
});
store.Save(input);
var loaded = store.Load();
loaded.Should().BeEquivalentTo(input);
}
[Fact]
public void Load_WhenFileCorrupt_ReturnsDefault()
{
var path = Path.Combine(_dir, "config.json");
File.WriteAllText(path, "not valid json {{{");
var store = NewStore();
var config = store.Load();
config.Should().Be(EngineConfig.Default);
}
[Fact]
public void Save_WritesAtomically_NoPartialFileVisible()
{
var store = NewStore();
var path = Path.Combine(_dir, "config.json");
store.Save(EngineConfig.Default);
var firstWrite = File.ReadAllText(path);
store.Save(new EngineConfig(
FrameProcessingSettings.Default with { Framerate = TargetFramerate.Fps60 },
Array.Empty<IsoAssignment>()));
File.Exists(path).Should().BeTrue();
File.ReadAllText(path).Should().NotBe(firstWrite);
Directory.GetFiles(_dir).Should().HaveCount(1);
}
}