using System; using System.IO; using FluentAssertions; using GameCult.Caching; using GameCult.Caching.MessagePack; using NUnit.Framework; #nullable enable namespace GameCult.Mesh.Tests; // A CultMesh single-file write goes through the cache's single-file store: it reads the file under the store lock, applies its // one record, and keeps every other record. A store stamped with a newer format version or one it cannot read is refused and // left byte-identical. public sealed class CultMeshSingleFileWriteGuardTests { private static readonly CultMeshSingleFileDocumentSchema Schema = new("sha256:guard", "guard.item", "1"); private string _root = null!; private string _path = null!; [SetUp] public void SetUp() { _root = Path.Combine(Path.GetTempPath(), "cultmesh-write-guard", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_root); _path = Path.Combine(_root, "store.cc"); } [TearDown] public void TearDown() { if (Directory.Exists(_root)) Directory.Delete(_root, true); } private void Write(string key, byte payload) => CultMesh.WriteSingleFileDocumentPayload(_path, new CultRecordKey(key), Schema, null, new[] { payload }); [TestCase("cultcache.store.v4")] [TestCase("cultcache.store.v9")] public void WriteRefusesAStoreStampedWithANewerFormat(string newer) { Write("item:a", 1); var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(_path)); snapshot.FormatVersion = newer; File.WriteAllBytes(_path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); var before = File.ReadAllBytes(_path); var error = Assert.Throws(() => Write("item:a", 2))!; error.Message.Should().Contain(newer); File.ReadAllBytes(_path).Should().Equal(before); } [Test] public void WriteRefusesAFileItCannotRead() { var garbage = new byte[] { 0xc1, 0xc1, 0xc1, 0xc1 }; File.WriteAllBytes(_path, garbage); Assert.Catch(() => Write("item:a", 1)); File.ReadAllBytes(_path).Should().Equal(garbage); } // Another writer's record, under a schema this write does not touch, is still in the file after the write. [Test] public void WriteKeepsAnotherWritersRecord() { var other = new CultMeshSingleFileDocumentSchema("sha256:other", "other.item", "1"); Write("item:a", 1); CultMesh.WriteSingleFileDocumentPayload(_path, new CultRecordKey("item:b"), other, null, new byte[] { 7 }); Write("item:a", 2); CultMesh.ReadSingleFileDocumentPayload(_path, new CultRecordKey("item:a"), Schema.SchemaId).Should().Equal(2); CultMesh.ReadSingleFileDocumentPayload(_path, new CultRecordKey("item:b"), other.SchemaId).Should().Equal(7); } [Test] public void WriteReplacesTheSameRecordAndCreatesAMissingFile() { Write("item:a", 1); Write("item:a", 2); CultMesh.ReadSingleFileDocumentPayload(_path, new CultRecordKey("item:a"), Schema.SchemaId).Should().Equal(2); } // The write takes the same .lock a CultCache single-file store takes: while another holder has it, the write waits. [Test] public void WriteWaitsForTheStoreLockAnotherWriterHolds() { Write("item:a", 1); var write = default(System.Threading.Tasks.Task); using (new FileStream(_path + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None)) { write = System.Threading.Tasks.Task.Run(() => Write("item:a", 2)); write.Wait(TimeSpan.FromMilliseconds(750)).Should().BeFalse("the lock is held, so the write must wait"); CultMesh.ReadSingleFileDocumentPayload(_path, new CultRecordKey("item:a"), Schema.SchemaId).Should().Equal(1); } write.Wait(TimeSpan.FromSeconds(10)).Should().BeTrue(); CultMesh.ReadSingleFileDocumentPayload(_path, new CultRecordKey("item:a"), Schema.SchemaId).Should().Equal(2); } // A variant at the very key being written is still not this write's to replace: a plain record would flatten it. [Test] public void WriteRefusesToReplaceAVariantAtTheSameKey() { Write("item:a", 1); var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(_path)); snapshot.FormatVersion = CultPersistedStoreSnapshot.FormatV2; snapshot.Records[0].Payload = Array.Empty(); snapshot.Records[0].Variant = new CultVariantDelta("item:base", Array.Empty()); File.WriteAllBytes(_path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); var before = File.ReadAllBytes(_path); Assert.Throws(() => Write("item:a", 2)); File.ReadAllBytes(_path).Should().Equal(before); } }