using RackPeek.Domain.Persistence.Yaml;
namespace Tests.Yaml;
///
/// Writer-side half of https://github.com/Timmoth/RackPeek/issues/337.
/// The store used to be a bare File.WriteAllTextAsync, which truncates the
/// destination before writing a byte and never flushes — so an interrupted save
/// could leave a truncated config, and a save that had returned successfully
/// could still be lost to power failure. It now writes to a temp file, flushes
/// to disk, and renames over the destination.
///
public class PhysicalTextFileStoreTests : IDisposable {
private readonly string _dir = Path.Combine(
Path.GetTempPath(),
"rackpeek-store-tests",
Guid.NewGuid().ToString("N"));
private readonly PhysicalTextFileStore _store = new();
public PhysicalTextFileStoreTests() => Directory.CreateDirectory(_dir);
public void Dispose() {
if (Directory.Exists(_dir))
Directory.Delete(_dir, true);
GC.SuppressFinalize(this);
}
private string Path_(string name) => Path.Combine(_dir, name);
[Fact]
public async Task writing_a_new_file_round_trips_the_content() {
var path = Path_("config.yaml");
await _store.WriteAllTextAsync(path, "version: 4\n");
Assert.Equal("version: 4\n", await _store.ReadAllTextAsync(path));
}
[Fact]
public async Task overwriting_replaces_the_whole_file() {
var path = Path_("config.yaml");
await _store.WriteAllTextAsync(path, new string('a', 4096));
await _store.WriteAllTextAsync(path, "short");
// A rename replaces the file wholesale; a partial in-place write would
// leave the tail of the longer content behind.
Assert.Equal("short", await _store.ReadAllTextAsync(path));
}
[Fact]
public async Task writing_leaves_no_temp_files_behind() {
var path = Path_("config.yaml");
for (var i = 0; i < 5; i++)
await _store.WriteAllTextAsync(path, $"version: 4 # {i}\n");
Assert.Equal(new[] { "config.yaml" },
Directory.GetFiles(_dir).Select(System.IO.Path.GetFileName).OrderBy(n => n).ToArray());
}
[Fact]
public async Task a_reader_never_observes_a_truncated_file_during_writes() {
var path = Path_("config.yaml");
// Two sizes, neither a prefix of the other: any partially written state is
// detectable as "not equal to either of the two valid contents".
var big = "version: 4\n" + new string('b', 200_000);
var small = "version: 4\n" + new string('s', 50_000);
await _store.WriteAllTextAsync(path, big);
using var cts = new CancellationTokenSource();
var writer = Task.Run(async () => {
for (var i = 0; i < 40; i++)
await _store.WriteAllTextAsync(path, i % 2 == 0 ? small : big);
await cts.CancelAsync();
});
var observations = 0;
while (!cts.IsCancellationRequested) {
string seen;
try {
seen = await File.ReadAllTextAsync(path);
}
catch (IOException) {
// The rename can momentarily deny sharing on Windows; not a torn read.
continue;
}
observations++;
Assert.True(seen == big || seen == small,
$"Observed a partially written config ({seen.Length} bytes; expected {big.Length} or {small.Length}).");
}
await writer;
Assert.True(observations > 0, "The reader never managed to sample the file.");
}
[Fact]
public async Task a_failed_write_leaves_the_original_intact() {
// A directory standing where the temp file wants to be makes the write fail
// after the destination would have been truncated by the old implementation.
var path = Path_("config.yaml");
await _store.WriteAllTextAsync(path, "version: 4\nresources: []\n");
var readOnlyDir = Path_("locked");
Directory.CreateDirectory(readOnlyDir);
var nested = Path.Combine(readOnlyDir, "config.yaml");
await _store.WriteAllTextAsync(nested, "version: 4\n");
// Writing to a path that is itself a directory always fails.
var directoryPath = Path_("a-directory");
Directory.CreateDirectory(directoryPath);
await Assert.ThrowsAnyAsync(
() => _store.WriteAllTextAsync(directoryPath, "anything"));
// The unrelated config is untouched, and no temp debris was left anywhere.
Assert.Equal("version: 4\nresources: []\n", await _store.ReadAllTextAsync(path));
Assert.DoesNotContain(Directory.GetFiles(_dir), f => f.Contains(".tmp-", StringComparison.Ordinal));
}
}