ExistingHeader read the whole snapshot and swallowed every failure into "no header": a truncated v3 file got a v1 raw write and an unknown v9 file was overwritten, where TypeScript, Python and Rust refuse. CultDocumentMessagePackSerialization.ReadStoreHeader reads the prefix header, refuses a file that is not one complete MessagePack value or whose format this runtime cannot read, and the raw write keeps a whole marked file marked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
178 lines
8.1 KiB
C#
178 lines
8.1 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using FluentAssertions;
|
|
using GameCult.Caching;
|
|
using GameCult.Caching.MessagePack;
|
|
using MessagePack;
|
|
using NUnit.Framework;
|
|
|
|
#nullable enable
|
|
|
|
namespace GameCult.Mesh.Tests;
|
|
|
|
// C2a: a single-file document written or read through CultMesh is judged as a cache judges it: ids minted, a list that
|
|
// cannot hold ids refused, the header decided by what the file holds.
|
|
public sealed class CultMeshSingleFileElementIdTests
|
|
{
|
|
private const string HexA = "aaaaaaaaaaaa";
|
|
private static readonly CultRecordKey Key = new("publication");
|
|
|
|
private string _root = null!;
|
|
|
|
[SetUp]
|
|
public void SetUp()
|
|
{
|
|
_root = Path.Combine(Path.GetTempPath(), "cultmesh-single-file-ids", Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(_root);
|
|
}
|
|
|
|
[TearDown]
|
|
public void TearDown()
|
|
{
|
|
if (Directory.Exists(_root)) Directory.Delete(_root, true);
|
|
}
|
|
|
|
private static CultMeshBodyPublicationDocument Publication(params CultMeshBodyDescriptor[] representations) => new()
|
|
{
|
|
BodyId = "aetheria:entities",
|
|
ProducerId = "aetheria",
|
|
SchemaId = "eve.entity_soa.v1",
|
|
Representations = representations
|
|
};
|
|
|
|
private static string HeaderOf(string path) =>
|
|
CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).FormatVersion;
|
|
|
|
private static CultMeshBodyDescriptor Descriptor(string id = "") => new() { Id = id, BodyId = "body" };
|
|
|
|
private string Path_(string name) => Path.Combine(_root, name);
|
|
|
|
[Test]
|
|
public void TypedWriteMintsTheElementIdsAndMarksTheFileThatHoldsThem()
|
|
{
|
|
var path = Path_("ids.cc");
|
|
var publication = Publication(Descriptor(), Descriptor());
|
|
|
|
CultMesh.WriteSingleFileDocument(path, Key, publication);
|
|
|
|
publication.Representations.Select(entry => entry.Id).Should().OnlyContain(id => id.Length == 12).And.OnlyHaveUniqueItems();
|
|
HeaderOf(path).Should().Be("cultcache.store.v3");
|
|
CultMesh.ReadSingleFileDocument<CultMeshBodyPublicationDocument>(path, Key).Representations.Select(entry => entry.Id)
|
|
.Should().Equal(publication.Representations.Select(entry => entry.Id));
|
|
}
|
|
|
|
// A typed write replaces the whole file with a document it can see, so the ids that document holds decide the header.
|
|
[Test]
|
|
public void TypedWriteDecidesTheHeaderByTheDocumentItWritesEvenOverAMarkedFile()
|
|
{
|
|
var path = Path_("none.cc");
|
|
CultMesh.WriteSingleFileDocument(path, Key, Publication());
|
|
HeaderOf(path).Should().Be("cultcache.store.v1");
|
|
|
|
CultMesh.WriteSingleFileDocument(path, Key, Publication(Descriptor()));
|
|
HeaderOf(path).Should().Be("cultcache.store.v3");
|
|
CultMesh.WriteSingleFileDocument(path, Key, Publication());
|
|
HeaderOf(path).Should().Be("cultcache.store.v1", "the whole file is replaced by a document that holds no id");
|
|
}
|
|
|
|
// A raw payload is opaque: the writer cannot see what it holds, so a marked file stays marked.
|
|
[Test]
|
|
public void RawPayloadWriteOverAMarkedFileKeepsItMarkedAndOverAnUnmarkedOneStaysV1()
|
|
{
|
|
var path = Path_("raw.cc");
|
|
var schema = new CultMeshSingleFileDocumentSchema("raw:schema", "RawSchema", "1");
|
|
var payload = new byte[] { 0x90 };
|
|
|
|
CultMesh.WriteSingleFileDocumentPayload(path, Key, schema, null, payload);
|
|
HeaderOf(path).Should().Be("cultcache.store.v1");
|
|
|
|
CultMesh.WriteSingleFileDocument(path, Key, Publication(Descriptor()));
|
|
HeaderOf(path).Should().Be("cultcache.store.v3");
|
|
CultMesh.WriteSingleFileDocumentPayload(path, Key, schema, null, payload);
|
|
HeaderOf(path).Should().Be("cultcache.store.v3", "the payload's ids are not the writer's to see");
|
|
}
|
|
|
|
// A raw write does not read the store's records, but it does not overwrite a file it cannot read either: the runtimes agree.
|
|
[Test]
|
|
public void RawPayloadWriteRefusesAFileItCannotReadAndLeavesItUntouched()
|
|
{
|
|
var schema = new CultMeshSingleFileDocumentSchema("raw:schema", "RawSchema", "1");
|
|
var marked = Path_("marked.cc");
|
|
CultMesh.WriteSingleFileDocument(marked, Key, Publication(Descriptor()));
|
|
var whole = File.ReadAllBytes(marked);
|
|
|
|
var truncated = Path_("truncated.cc");
|
|
File.WriteAllBytes(truncated, whole[..^1]);
|
|
var unknown = Path_("v9.cc");
|
|
File.WriteAllBytes(unknown, MessagePackSerializer.Serialize(new object[] { "cultcache.store.v9", Array.Empty<object>(), Array.Empty<object>() }));
|
|
|
|
foreach (var path in new[] { truncated, unknown })
|
|
{
|
|
var before = File.ReadAllBytes(path);
|
|
Assert.Throws<NotSupportedException>(() => CultMesh.WriteSingleFileDocumentPayload(path, Key, schema, null, new byte[] { 0x90 }), path);
|
|
File.ReadAllBytes(path).Should().Equal(before, path + " was rewritten");
|
|
}
|
|
|
|
CultMesh.WriteSingleFileDocumentPayload(marked, Key, schema, null, new byte[] { 0x90 });
|
|
HeaderOf(marked).Should().Be("cultcache.store.v3", "a whole marked file is still written and stays marked");
|
|
}
|
|
|
|
[Test]
|
|
public void TypedWriteRefusesADuplicateIdAndAnElementObjectTwiceAndLeavesTheFileAlone()
|
|
{
|
|
var path = Path_("refused.cc");
|
|
CultMesh.WriteSingleFileDocument(path, Key, Publication(Descriptor()));
|
|
var before = File.ReadAllBytes(path);
|
|
|
|
var duplicate = Publication(Descriptor(HexA), Descriptor(HexA));
|
|
var refusal = Assert.Throws<CultElementIdException>(() => CultMesh.WriteSingleFileDocument(path, Key, duplicate))!;
|
|
refusal.ElementId.Should().Be(HexA);
|
|
|
|
var once = Descriptor();
|
|
var twice = Publication(once, once);
|
|
Assert.Throws<CultElementIdException>(() => CultMesh.WriteSingleFileDocument(path, Key, twice))!.Message.Should().Contain("twice");
|
|
once.Id.Should().BeEmpty("a refusal mints nothing");
|
|
|
|
File.ReadAllBytes(path).Should().Equal(before);
|
|
}
|
|
|
|
[Test]
|
|
public void AFailedTypedWriteGivesTheMintedIdsBack()
|
|
{
|
|
var blocked = Path_("blocked.cc");
|
|
Directory.CreateDirectory(blocked);
|
|
var element = Descriptor();
|
|
|
|
Assert.That(() => CultMesh.WriteSingleFileDocument(blocked, Key, Publication(element)), Throws.Exception);
|
|
|
|
element.Id.Should().BeEmpty("a write that does not land gives its ids back");
|
|
}
|
|
|
|
[Test]
|
|
public void TypedReadRefusesWhatALoadRefusesAndMintsWhatALoadMints()
|
|
{
|
|
var path = Path_("disk.cc");
|
|
CultMesh.WriteSingleFileDocument(path, Key, Publication(Descriptor()));
|
|
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
|
|
|
|
var duplicate = Publication(Descriptor(HexA), Descriptor(HexA));
|
|
snapshot.Records[0].Payload = MessagePackSerializer.Serialize(duplicate);
|
|
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
|
|
var refusal = Assert.Throws<CultElementIdException>(() => CultMesh.ReadSingleFileDocument<CultMeshBodyPublicationDocument>(path, Key))!;
|
|
refusal.ElementId.Should().Be(HexA);
|
|
|
|
// A payload written before ids: two reads, and a cache load, agree on the ids minted.
|
|
snapshot.Records[0].Payload = MessagePackSerializer.Serialize(Publication(Descriptor(), Descriptor()));
|
|
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
|
|
var first = CultMesh.ReadSingleFileDocument<CultMeshBodyPublicationDocument>(path, Key).Representations.Select(entry => entry.Id).ToArray();
|
|
first.Should().OnlyContain(id => id.Length == 12);
|
|
CultMesh.ReadSingleFileDocument<CultMeshBodyPublicationDocument>(path, Key).Representations.Select(entry => entry.Id).Should().Equal(first);
|
|
using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions
|
|
{
|
|
Registry = CultDocumentRegistry.ForTypes(new[] { typeof(CultMeshBodyPublicationDocument) }),
|
|
ReadOnly = true
|
|
});
|
|
cache.Get<CultMeshBodyPublicationDocument>(Key)!.Representations.Select(entry => entry.Id).Should().Equal(first);
|
|
}
|
|
}
|