#nullable enable using System; using System.Collections.Concurrent; using System.Linq; using System.Threading.Tasks; using GameCult.Caching; using NUnit.Framework; using R3; using static GameCult.Networking.Tests.NetworkingTests; namespace GameCult.Networking.Tests { [CultDocument("tests.publication_throwing_name", "tests.publication_throwing_name.v1")] [MessagePack.MessagePackObject] public sealed class PublicationThrowingName { [MessagePack.Key(0)] public string Text { get; set; } = string.Empty; [MessagePack.Key(1)] [CultName] public string Name { get => Text == "boom" ? throw new InvalidOperationException("name getter boom") : Text; set { } } } // Cache publication cuts 1 and 2 seen through CultNetDatabase: a refused or failing change never stalls the // publication and log of the changes after it. (Soul pass 3 probes P4 and P3.) [NonParallelizable] public sealed class CultCachePublicationIsolationTests { private static NetworkSchemaNote Note(string text) => new() { Schema = "tests.networking_note.v1", Text = text }; private static string SchemaId => CultDocumentRegistry.Shared.GetRequired().SchemaId; // An admission that a [CultName] getter refuses publishes nothing, leaves nothing in memory, and stalls nothing after it. [Test] public async Task ARefusedAdmissionLeavesNothingAndLaterChangesStillPublishAndLog() { var cache = new CultCache(); var database = new CultNetDatabase(cache); var seen = new ConcurrentQueue(); using var watch = database.Watch().Subscribe(change => seen.Enqueue(change.Document?.Text)); var okKey = new CultRecordKey("pub:ok"); var refusal = Assert.Throws(() => cache.Commit(batch => { batch.Upsert(new PublicationThrowingName { Text = "ok" }, new CultRecordHandle(okKey)); batch.Upsert(new PublicationThrowingName { Text = "boom" }, new CultRecordHandle(new CultRecordKey("pub:boomname"))); }))!; var after = new CultRecordKey("pub:after"); await database.PutAsync(after, Note("after")); Assert.Multiple(() => { Assert.That(refusal.Message, Does.Contain("pub:boomname").And.Contain("name getter boom")); Assert.That(cache.Get(okKey), Is.Null, "the good record of a refused batch did not land"); Assert.That(seen, Is.EqualTo(new[] { "after" })); Assert.That(database.LastWriteSequence(SchemaId, after), Is.Not.Null, "a later commit was never logged"); }); } // One observer's escaped exception (a fail-fast R3 handler) reaches the writer and does not stall later changes. // A database write is published by the database after its cache write returns, so the failed write itself is not // published here; that ordering belongs to the CultNetDatabase cut. [Test] public async Task AnEscapedObserverExceptionReachesTheWriterAndDoesNotStallLaterChanges() { var previous = ObservableSystem.GetUnhandledExceptionHandler(); ObservableSystem.RegisterUnhandledExceptionHandler(exception => throw exception); try { var cache = new CultCache(); using var thrower = cache.Watch().Subscribe(change => { if (change.Document?.Text == "boom") throw new InvalidOperationException("observer boom"); }); var database = new CultNetDatabase(cache); var seen = new ConcurrentQueue(); using var watch = database.Watch().Subscribe(change => seen.Enqueue(change.Document?.Text)); var boom = new CultRecordKey("pub:boom"); var escaped = Assert.ThrowsAsync(() => database.PutAsync(boom, Note("boom"))); ObservableSystem.RegisterUnhandledExceptionHandler(previous); var after = new CultRecordKey("pub:after"); await database.PutAsync(after, Note("after")); Assert.Multiple(() => { Assert.That(escaped!.Message, Is.EqualTo("observer boom")); Assert.That(seen, Does.Contain("after"), "a later commit was never published"); Assert.That(database.LastWriteSequence(SchemaId, after), Is.Not.Null, "a later commit was never logged"); }); } finally { ObservableSystem.RegisterUnhandledExceptionHandler(previous); } } } }