using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using FluentAssertions; using GameCult.Caching; using GameCult.Caching.MessagePack; using NUnit.Framework; using R3; namespace GameCult.Mesh.Tests; public sealed class CultMeshDiscoveryServiceTests { [Test] public async Task Resolve_QueriesSourcesConcurrentlyAndKeepsSuccessfulResult() { var clock = new ManualClock(new DateTimeOffset(2026, 7, 12, 20, 0, 0, TimeSpan.Zero)); var bothStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var started = 0; async Task> Lookup(bool fail) { if (Interlocked.Increment(ref started) == 2) bothStarted.TrySetResult(); await bothStarted.Task; if (fail) throw new IOException("source offline"); return new[] { Observation("aetheria", "good", clock, TimeSpan.FromMinutes(1), CultMeshDiscoveryTrust.Signed) }; } using var service = new CultMeshDiscoveryService( new[] { new DelegateSource("dead", _ => Lookup(true)), new DelegateSource("good", _ => Lookup(false)) }, new CultMeshDiscoveryServiceOptions { Clock = clock }); var observed = new List(); using var subscription = service.Watch().Subscribe(state => observed.Add(state)); var state = await service.ResolveAsync(new CultMeshDiscoveryQuery("odin:aetheria")); state.Freshness.Should().Be(CultMeshDiscoveryFreshness.Degraded); state.Candidates.Should().ContainSingle().Which.Descriptor.VerseId.Should().Be("aetheria"); state.FailedSourceIds.Should().Equal("dead"); observed.Should().ContainSingle().Which.Should().BeSameAs(state); } [Test] public async Task Resolve_SharesInflightLookupAcrossConcurrentCallers() { var clock = new ManualClock(new DateTimeOffset(2026, 7, 12, 20, 0, 0, TimeSpan.Zero)); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var source = new DelegateSource("odin", async _ => { await release.Task; return new[] { Observation("aetheria", "odin", clock, TimeSpan.FromMinutes(1), CultMeshDiscoveryTrust.Signed) }; }); using var service = new CultMeshDiscoveryService(new[] { source }, new CultMeshDiscoveryServiceOptions { Clock = clock }); var query = new CultMeshDiscoveryQuery("odin:aetheria"); var first = service.ResolveAsync(query); var second = service.ResolveAsync(query); await WaitUntilAsync(() => source.LookupCount == 1); release.TrySetResult(); var states = await Task.WhenAll(first, second); source.LookupCount.Should().Be(1); states[0].Should().BeSameAs(states[1]); } [Test] public async Task Resolve_CallerCancellationDoesNotCancelSharedLookup() { var clock = new ManualClock(new DateTimeOffset(2026, 7, 12, 20, 0, 0, TimeSpan.Zero)); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var source = new DelegateSource("odin", async _ => { await release.Task; return new[] { Observation("aetheria", "odin", clock, TimeSpan.FromMinutes(1), CultMeshDiscoveryTrust.Signed) }; }); using var service = new CultMeshDiscoveryService(new[] { source }, new CultMeshDiscoveryServiceOptions { Clock = clock }); using var cancelledCaller = new CancellationTokenSource(); var query = new CultMeshDiscoveryQuery("odin:aetheria"); var cancelled = service.ResolveAsync(query, cancelledCaller.Token); var survivor = service.ResolveAsync(query); await WaitUntilAsync(() => source.LookupCount == 1); cancelledCaller.Cancel(); Assert.ThrowsAsync(async () => await cancelled); release.TrySetResult(); (await survivor).Candidates.Should().ContainSingle(); source.LookupCount.Should().Be(1); } [Test] public async Task Resolve_HonorsNegativeExpiryWithoutRepeatingLookup() { var clock = new ManualClock(new DateTimeOffset(2026, 7, 12, 20, 0, 0, TimeSpan.Zero)); var source = new DelegateSource("odin", _ => Task.FromException>(new IOException("offline"))); using var service = new CultMeshDiscoveryService( new[] { source }, new CultMeshDiscoveryServiceOptions { Clock = clock, NegativeTtl = TimeSpan.FromSeconds(30) }); var query = new CultMeshDiscoveryQuery("odin:aetheria"); (await service.ResolveAsync(query)).Freshness.Should().Be(CultMeshDiscoveryFreshness.Unavailable); (await service.ResolveAsync(query)).Freshness.Should().Be(CultMeshDiscoveryFreshness.Unavailable); source.LookupCount.Should().Be(1); clock.Advance(TimeSpan.FromSeconds(31)); await service.ResolveAsync(query); source.LookupCount.Should().Be(2); } [Test] public async Task Resolve_KeepsDifferentlyConstrainedProjectionsIsolated() { var clock = new ManualClock(new DateTimeOffset(2026, 7, 12, 20, 0, 0, TimeSpan.Zero)); var source = new DelegateSource("odin", _ => Task.FromResult>( new[] { Observation("aetheria", "odin", clock, TimeSpan.FromMinutes(1), CultMeshDiscoveryTrust.Signed), Observation("norn", "odin", clock, TimeSpan.FromMinutes(1), CultMeshDiscoveryTrust.Signed) })); using var service = new CultMeshDiscoveryService(new[] { source }, new CultMeshDiscoveryServiceOptions { Clock = clock }); var aetheria = await service.ResolveAsync(new CultMeshDiscoveryQuery("odin:games", new[] { "aetheria" })); var norn = await service.ResolveAsync(new CultMeshDiscoveryQuery("odin:games", new[] { "norn" })); aetheria.Candidates.Select(candidate => candidate.Descriptor.VerseId).Should().Equal("aetheria"); norn.Candidates.Select(candidate => candidate.Descriptor.VerseId).Should().Equal("norn"); aetheria.QueryKey.Should().NotBe(norn.QueryKey); source.LookupCount.Should().Be(2); } [Test] public async Task Resolve_PreservesExpiredLastKnownGoodAsStaleAfterFailure() { var clock = new ManualClock(new DateTimeOffset(2026, 7, 12, 20, 0, 0, TimeSpan.Zero)); var failing = false; var source = new DelegateSource("odin", _ => failing ? Task.FromException>(new IOException("partition")) : Task.FromResult>( new[] { Observation("aetheria", "odin", clock, TimeSpan.FromSeconds(5), CultMeshDiscoveryTrust.Signed) })); using var service = new CultMeshDiscoveryService(new[] { source }, new CultMeshDiscoveryServiceOptions { Clock = clock }); var query = new CultMeshDiscoveryQuery("odin:aetheria"); (await service.ResolveAsync(query)).Freshness.Should().Be(CultMeshDiscoveryFreshness.Fresh); clock.Advance(TimeSpan.FromSeconds(6)); failing = true; var stale = await service.ResolveAsync(query); stale.Freshness.Should().Be(CultMeshDiscoveryFreshness.Stale); stale.Candidates.Should().ContainSingle().Which.Descriptor.VerseId.Should().Be("aetheria"); } [Test] public async Task Current_DerivesStaleStateImmediatelyAfterCandidateExpiry() { var clock = new ManualClock(new DateTimeOffset(2026, 7, 12, 20, 0, 0, TimeSpan.Zero)); using var service = new CultMeshDiscoveryService( new[] { new DelegateSource("odin", _ => Task.FromResult>( new[] { Observation("aetheria", "odin", clock, TimeSpan.FromSeconds(5), CultMeshDiscoveryTrust.Signed) })) }, new CultMeshDiscoveryServiceOptions { Clock = clock }); await service.ResolveAsync(new CultMeshDiscoveryQuery("odin:aetheria")); clock.Advance(TimeSpan.FromSeconds(6)); service.Current("odin:aetheria")!.Freshness.Should().Be(CultMeshDiscoveryFreshness.Stale); } [Test] public async Task Resolve_SignedCandidateCannotBeOverriddenByRejectedObservation() { var clock = new ManualClock(new DateTimeOffset(2026, 7, 12, 20, 0, 0, TimeSpan.Zero)); var signed = Observation("aetheria", "odin", clock, TimeSpan.FromMinutes(1), CultMeshDiscoveryTrust.Signed, "rudp://trusted:3076"); var poisoned = Observation("aetheria", "gossip", clock, TimeSpan.FromMinutes(2), CultMeshDiscoveryTrust.Rejected, "rudp://poison:3076"); using var service = new CultMeshDiscoveryService( new[] { new DelegateSource("odin", _ => Task.FromResult>(new[] { signed })), new DelegateSource("gossip", _ => Task.FromResult>(new[] { poisoned })) }, new CultMeshDiscoveryServiceOptions { Clock = clock }); var state = await service.ResolveAsync(new CultMeshDiscoveryQuery("odin:aetheria")); state.Candidates.Should().ContainSingle(); state.Candidates[0].SourceId.Should().Be("odin"); state.Candidates[0].Descriptor.DiscoveryEndpoints.Should().Equal("rudp://trusted:3076"); } [Test] public async Task CultCacheStore_ReconstructsLastKnownGoodAfterRestart() { var root = Path.Combine(Path.GetTempPath(), "cultmesh-discovery-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var path = Path.Combine(root, "discovery.cc"); try { var clock = new ManualClock(new DateTimeOffset(2026, 7, 12, 20, 0, 0, TimeSpan.Zero)); using (var cache = new CultCache()) { cache.AddBackingStore(new SingleFileMessagePackBackingStore(path)); using var writer = new CultMeshDiscoveryService( new[] { new DelegateSource("odin", _ => Task.FromResult>( new[] { Observation("aetheria", "odin", clock, TimeSpan.FromMinutes(1), CultMeshDiscoveryTrust.Signed) })) }, new CultMeshDiscoveryServiceOptions { Clock = clock, Store = new CultMeshCultCacheDiscoveryStore(cache) }); (await writer.ResolveAsync(new CultMeshDiscoveryQuery("odin:aetheria"))).Freshness .Should().Be(CultMeshDiscoveryFreshness.Fresh); } clock.Advance(TimeSpan.FromMinutes(2)); using var restoredCache = new CultCache(); restoredCache.AddBackingStore(new SingleFileMessagePackBackingStore(path)); await restoredCache.PullAllBackingStoresAsync(); using var reader = new CultMeshDiscoveryService( new[] { new DelegateSource("odin", _ => Task.FromException>(new IOException("offline"))) }, new CultMeshDiscoveryServiceOptions { Clock = clock, Store = new CultMeshCultCacheDiscoveryStore(restoredCache) }); var restored = await reader.ResolveAsync(new CultMeshDiscoveryQuery("odin:aetheria")); restored.Freshness.Should().Be(CultMeshDiscoveryFreshness.Stale); restored.Candidates.Should().ContainSingle().Which.SourceId.Should().Be("odin"); } finally { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); } } [Test] public async Task Invalidate_BypassesFreshMemoryAndPersistedRoutesForTheNextLookup() { var clock = new ManualClock(new DateTimeOffset(2026, 8, 18, 0, 0, 0, TimeSpan.Zero)); var route = "rudp://first:3076"; var source = new DelegateSource("odin", _ => Task.FromResult>( new[] { Observation("aetheria", "odin", clock, TimeSpan.FromMinutes(5), CultMeshDiscoveryTrust.Signed, route) })); using var cache = new CultCache(); using var service = new CultMeshDiscoveryService( new[] { source }, new CultMeshDiscoveryServiceOptions { Clock = clock, Store = new CultMeshCultCacheDiscoveryStore(cache) }); var query = new CultMeshDiscoveryQuery("aetheria", new[] { "aetheria" }); var first = await service.ResolveAsync(query); route = "rudp://second:3076"; service.Invalidate(query); var second = await service.ResolveAsync(query); first.Candidates.Single().Descriptor.DiscoveryEndpoints.Should().Equal("rudp://first:3076"); second.Candidates.Single().Descriptor.DiscoveryEndpoints.Should().Equal("rudp://second:3076"); source.LookupCount.Should().Be(2); } [Test] public async Task Resolve_RejectsVerseRouteThatDoesNotAdvertiseRequestedProvider() { var clock = new ManualClock(new DateTimeOffset(2026, 8, 18, 0, 0, 0, TimeSpan.Zero)); var source = new DelegateSource("odin", _ => Task.FromResult>( new[] { Observation("aetheria", "odin", clock, TimeSpan.FromMinutes(1), CultMeshDiscoveryTrust.Signed) })); using var service = new CultMeshDiscoveryService( new[] { source }, new CultMeshDiscoveryServiceOptions { Clock = clock }); var state = await service.ResolveAsync(new CultMeshDiscoveryQuery( "forged-provider", new[] { "aetheria" }, "forged-provider")); state.Freshness.Should().Be(CultMeshDiscoveryFreshness.Unavailable); state.Candidates.Should().BeEmpty(); } private static CultMeshDiscoveryObservation Observation( string verseId, string sourceId, ManualClock clock, TimeSpan ttl, CultMeshDiscoveryTrust trust, string endpoint = "rudp://aetheria:3076") => new( new CultMeshVerseDescriptor( verseId, "Aetheria", CultMeshVerseAuthorityModel.OperatorCluster, new CultMeshVerseCompatibility("cultmesh.v0", "rules"), new[] { endpoint }, new[] { "aetheria" }), sourceId, clock.UtcNow, clock.UtcNow + ttl, trust); private static async Task WaitUntilAsync(Func condition) { var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2); while (!condition() && DateTime.UtcNow < deadline) await Task.Delay(1); condition().Should().BeTrue(); } private sealed class DelegateSource : ICultMeshLookupSource { private readonly Func>> _lookup; public DelegateSource(string sourceId, Func>> lookup) { SourceId = sourceId; _lookup = lookup; } public string SourceId { get; } public int LookupCount => Volatile.Read(ref _lookupCount); public Task> LookupAsync( CultMeshDiscoveryQuery query, CancellationToken cancellationToken = default) { Interlocked.Increment(ref _lookupCount); return _lookup(query); } private int _lookupCount; } private sealed class ManualClock : ICultMeshClock { public ManualClock(DateTimeOffset utcNow) => UtcNow = utcNow; public DateTimeOffset UtcNow { get; private set; } public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); UtcNow += delay; return Task.CompletedTask; } public void Advance(TimeSpan duration) => UtcNow += duration; } }