PhysicalTextFileStoreTests.cs 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. using RackPeek.Domain.Persistence.Yaml;
  2. namespace Tests.Yaml;
  3. /// <summary>
  4. /// Writer-side half of https://github.com/Timmoth/RackPeek/issues/337.
  5. /// The store used to be a bare File.WriteAllTextAsync, which truncates the
  6. /// destination before writing a byte and never flushes — so an interrupted save
  7. /// could leave a truncated config, and a save that had returned successfully
  8. /// could still be lost to power failure. It now writes to a temp file, flushes
  9. /// to disk, and renames over the destination.
  10. /// </summary>
  11. public class PhysicalTextFileStoreTests : IDisposable {
  12. private readonly string _dir = Path.Combine(
  13. Path.GetTempPath(),
  14. "rackpeek-store-tests",
  15. Guid.NewGuid().ToString("N"));
  16. private readonly PhysicalTextFileStore _store = new();
  17. public PhysicalTextFileStoreTests() => Directory.CreateDirectory(_dir);
  18. public void Dispose() {
  19. if (Directory.Exists(_dir))
  20. Directory.Delete(_dir, true);
  21. GC.SuppressFinalize(this);
  22. }
  23. private string Path_(string name) => Path.Combine(_dir, name);
  24. [Fact]
  25. public async Task writing_a_new_file_round_trips_the_content() {
  26. var path = Path_("config.yaml");
  27. await _store.WriteAllTextAsync(path, "version: 4\n");
  28. Assert.Equal("version: 4\n", await _store.ReadAllTextAsync(path));
  29. }
  30. [Fact]
  31. public async Task overwriting_replaces_the_whole_file() {
  32. var path = Path_("config.yaml");
  33. await _store.WriteAllTextAsync(path, new string('a', 4096));
  34. await _store.WriteAllTextAsync(path, "short");
  35. // A rename replaces the file wholesale; a partial in-place write would
  36. // leave the tail of the longer content behind.
  37. Assert.Equal("short", await _store.ReadAllTextAsync(path));
  38. }
  39. [Fact]
  40. public async Task writing_leaves_no_temp_files_behind() {
  41. var path = Path_("config.yaml");
  42. for (var i = 0; i < 5; i++)
  43. await _store.WriteAllTextAsync(path, $"version: 4 # {i}\n");
  44. Assert.Equal(new[] { "config.yaml" },
  45. Directory.GetFiles(_dir).Select(System.IO.Path.GetFileName).OrderBy(n => n).ToArray());
  46. }
  47. [Fact]
  48. public async Task a_reader_never_observes_a_truncated_file_during_writes() {
  49. var path = Path_("config.yaml");
  50. // Two sizes, neither a prefix of the other: any partially written state is
  51. // detectable as "not equal to either of the two valid contents".
  52. var big = "version: 4\n" + new string('b', 200_000);
  53. var small = "version: 4\n" + new string('s', 50_000);
  54. await _store.WriteAllTextAsync(path, big);
  55. using var cts = new CancellationTokenSource();
  56. var writer = Task.Run(async () => {
  57. for (var i = 0; i < 40; i++)
  58. await _store.WriteAllTextAsync(path, i % 2 == 0 ? small : big);
  59. await cts.CancelAsync();
  60. });
  61. var observations = 0;
  62. while (!cts.IsCancellationRequested) {
  63. string seen;
  64. try {
  65. seen = await File.ReadAllTextAsync(path);
  66. }
  67. catch (IOException) {
  68. // The rename can momentarily deny sharing on Windows; not a torn read.
  69. continue;
  70. }
  71. observations++;
  72. Assert.True(seen == big || seen == small,
  73. $"Observed a partially written config ({seen.Length} bytes; expected {big.Length} or {small.Length}).");
  74. }
  75. await writer;
  76. Assert.True(observations > 0, "The reader never managed to sample the file.");
  77. }
  78. [Fact]
  79. public async Task a_failed_write_leaves_the_original_intact() {
  80. // A directory standing where the temp file wants to be makes the write fail
  81. // after the destination would have been truncated by the old implementation.
  82. var path = Path_("config.yaml");
  83. await _store.WriteAllTextAsync(path, "version: 4\nresources: []\n");
  84. var readOnlyDir = Path_("locked");
  85. Directory.CreateDirectory(readOnlyDir);
  86. var nested = Path.Combine(readOnlyDir, "config.yaml");
  87. await _store.WriteAllTextAsync(nested, "version: 4\n");
  88. // Writing to a path that is itself a directory always fails.
  89. var directoryPath = Path_("a-directory");
  90. Directory.CreateDirectory(directoryPath);
  91. await Assert.ThrowsAnyAsync<Exception>(
  92. () => _store.WriteAllTextAsync(directoryPath, "anything"));
  93. // The unrelated config is untouched, and no temp debris was left anywhere.
  94. Assert.Equal("version: 4\nresources: []\n", await _store.ReadAllTextAsync(path));
  95. Assert.DoesNotContain(Directory.GetFiles(_dir), f => f.Contains(".tmp-", StringComparison.Ordinal));
  96. }
  97. }