diff --git a/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs b/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs index 2567e3e1..9e96d0e3 100644 --- a/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs @@ -160,6 +160,94 @@ namespace GameCult.Networking.Tests .With.Property(nameof(CultNetSelectionCursorException.Code)).EqualTo("cursor_invalid")); } + // R-N (docs/cultnet-selection-cut.md, Self's rulings for fix batch 3): every refusal maps to a + // wire code, and selection_invalid/cursor_stale carry structured details, on cultnet.error.v0. + // Each test here decodes the bytes a peer actually receives (CultNetSchemaMessageSerialization + // round trip), not the in-process exception or message object. + [Test] + public void ErrorMessage_WireDecodesSelectionInvalidCodeAndDetails() + { + var registry = Registry(); + var ex = Assert.Throws( + () => new CultNetSelection { Schemas = Array.Empty() }.Validate(registry.AllDescriptors.ToArray()))!; + + var payload = CultNetSchemaMessageSerialization.Serialize(CultNetErrorMessage.ForSelectionInvalid(ex)); + var decoded = (CultNetErrorMessage)CultNetSchemaMessageSerialization.Deserialize(payload); + + Assert.That(decoded.Code, Is.EqualTo("selection_invalid")); + Assert.That(decoded.Details, Is.Not.Null); + Assert.That(decoded.Details!.Field, Is.EqualTo("schemas")); + Assert.That(decoded.Details!.Value, Is.Null); + } + + [Test] + public void ErrorMessage_WireDecodesCursorStaleCodeAndDetails() + { + var registry = Registry(); + var rows = new[] + { + Row(registry, new SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1), + Row(registry, new SelLeafA { Name = "b", Kind = "k", Mass = 1 }, "b", 2) + }; + var selection = new CultNetSelection { Limit = 1 }; + var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + selection.Cursor = first.NextCursor; + var ex = Assert.Throws( + () => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 2))!; + + var payload = CultNetSchemaMessageSerialization.Serialize(CultNetErrorMessage.ForCursor(ex)); + var decoded = (CultNetErrorMessage)CultNetSchemaMessageSerialization.Deserialize(payload); + + Assert.That(decoded.Code, Is.EqualTo("cursor_stale")); + Assert.That(decoded.Details, Is.Not.Null); + Assert.That(decoded.Details!.AsOf, Is.EqualTo(1ul)); + Assert.That(decoded.Details!.Current, Is.EqualTo(2ul)); + } + + [Test] + public void ErrorMessage_WireDecodesCursorInvalidCodeWithNoDetails() + { + var registry = Registry(); + var rows = new[] + { + Row(registry, new SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1), + Row(registry, new SelLeafA { Name = "b", Kind = "k", Mass = 1 }, "b", 2) + }; + var selection = new CultNetSelection { Limit = 1 }; + var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + var mismatched = new CultNetSelection { Limit = 1, Cursor = first.NextCursor, Descending = true }; + var ex = Assert.Throws( + () => CultNetSelectionEvaluator.Select(registry, rows, mismatched, asOf: 1))!; + + var payload = CultNetSchemaMessageSerialization.Serialize(CultNetErrorMessage.ForCursor(ex)); + var decoded = (CultNetErrorMessage)CultNetSchemaMessageSerialization.Deserialize(payload); + + Assert.That(decoded.Code, Is.EqualTo("cursor_invalid")); + Assert.That(decoded.Details, Is.Null); + } + + [Test] + public void ErrorMessage_WireDecodesReferenceOutsideTargetCodeWithNoDetails() + { + var leafB = new SelLeafB { Name = "b", Kind = "k", Mass = 1 }; + var offTargetCiter = new SelCiterNarrow { Name = "bad", NarrowRef = new CultRecordRef(new CultRecordKey("b")) }; + var narrowRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(SelLeafA), typeof(SelLeafB), typeof(SelCiterNarrow) }); + var rows = new[] + { + new CultNetSelectionEvaluator.Row(narrowRegistry.GetRequired(), new CultRecordKey("b"), leafB, 1), + new CultNetSelectionEvaluator.Row(narrowRegistry.GetRequired(), new CultRecordKey("bad"), offTargetCiter, 2) + }; + var selection = new CultNetSelection { Cited = new CultNetIncoming { Role = "NarrowRef", Exists = true } }; + var ex = Assert.Throws( + () => CultNetSelectionEvaluator.Select(narrowRegistry, rows, selection, asOf: 1))!; + + var payload = CultNetSchemaMessageSerialization.Serialize(CultNetErrorMessage.ForReferenceOutsideTarget(ex)); + var decoded = (CultNetErrorMessage)CultNetSchemaMessageSerialization.Deserialize(payload); + + Assert.That(decoded.Code, Is.EqualTo("reference_outside_target")); + Assert.That(decoded.Details, Is.Null); + } + // S6: the hop follows one declared reference by role; a second role is not followed. [Test] public void EvaluatorHopsOneEdgeByDeclaredReferenceAndRole() @@ -290,6 +378,48 @@ namespace GameCult.Networking.Tests () => CultNetSelectionEvaluator.Select(narrowRegistry, rows, selection, asOf: 1)); } + // R-T(b) (docs/cultnet-selection-cut.md, Self's rulings for fix batch 3): EnsureWithinDeclaredTarget + // checks every edge against its declared target, including a many reference declared with no + // explicit [CultReference(typeof(...))] - CultDocumentRegistry.PersistedMember used to leave + // TargetType null for that shape (only the explicit-target and scalar CultRecordRef cases were + // inferred), which made EnsureWithinDeclaredTarget skip the check outright for every edge through + // it. SelCiterManyUntyped's ManyRefs declares many:true with no explicit target, so its target is + // inferred from the CultRecordRef element alone; a SelLeafB target must still refuse. + [Test] + public void EvaluatorRefusesAnOutOfTargetEdgeThroughAnImplicitlyTypedManyReference() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SelLeafA), typeof(SelLeafB), typeof(SelCiterManyUntyped) }); + var leafA = new SelLeafA { Name = "a", Kind = "k", Mass = 1 }; + var leafB = new SelLeafB { Name = "b", Kind = "k", Mass = 1 }; + var okCiter = new SelCiterManyUntyped + { + Name = "ok", + ManyRefs = new[] { new CultRecordRef(new CultRecordKey("a")) } + }; + var offTargetCiter = new SelCiterManyUntyped + { + Name = "bad", + ManyRefs = new[] { new CultRecordRef(new CultRecordKey("b")) } + }; + + var okRows = new[] + { + new CultNetSelectionEvaluator.Row(registry.GetRequired(), new CultRecordKey("a"), leafA, 1), + new CultNetSelectionEvaluator.Row(registry.GetRequired(), new CultRecordKey("ok"), okCiter, 2) + }; + var selection = new CultNetSelection { Cited = new CultNetIncoming { Role = "ManyRefs", Exists = true } }; + var okEvaluation = CultNetSelectionEvaluator.Select(registry, okRows, selection, asOf: 1); + Assert.That(okEvaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a" })); + + var badRows = new[] + { + new CultNetSelectionEvaluator.Row(registry.GetRequired(), new CultRecordKey("b"), leafB, 1), + new CultNetSelectionEvaluator.Row(registry.GetRequired(), new CultRecordKey("bad"), offTargetCiter, 2) + }; + Assert.Throws( + () => CultNetSelectionEvaluator.Select(registry, badRows, selection, asOf: 1)); + } + // S23: the evaluator matches every row sharing an index value, never the cache's own // last-writer-wins unique-index map. Three rows share "shared" here. [Test] @@ -365,5 +495,20 @@ namespace GameCult.Networking.Tests [CultReference(typeof(SelLeafA))] public CultRecordRef NarrowRef; } + + [CultDocument("cultnet.selection-tests.citer_many_untyped", "cultnet.selection-tests.citer_many_untyped.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class SelCiterManyUntyped + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + // No explicit [CultReference(typeof(...))] target - the target is inferred from the + // CultRecordRef element alone (R-T(b)). + [Key(1)] + [CultReference(many: true)] + public CultRecordRef[] ManyRefs = Array.Empty>(); + } } } diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 6d28c2b7..55a7d28d 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -1886,6 +1886,161 @@ namespace GameCult.Networking.Tests } } + // R-P (docs/cultnet-selection-cut.md, Self's rulings for fix batch 3): one id reaches the + // authorizer, and it is the wire id, on both the snapshot and the live path. The binding below + // overrides NetworkSchemaNote's wire schema id away from its descriptor's own id + // ("tests.networking_note"); the authorizer allowlists only the wire id. Before the fix, the + // live fast path (CreateMatchedRecord) passed descriptor.SchemaId instead of the wire id, so a + // row delivered by the snapshot was refused on its first live change. + [Test] + public async Task DatabaseSubscriptionServer_AuthorizesSnapshotAndFirstLiveChangeByTheSameWireId() + { + const string wireSchemaId = "tests.networking_note.wire-alias"; + const string recordKey = "tests:wire-id:note"; + var cache = new CultCache(); + var registry = new CultNetDocumentRegistry(cache.Registry) + .Register(CultNetDocumentBinding.ForDocument(cache.Registry, schemaId: wireSchemaId)); + var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = registry }); + await database.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "initial" + }); + + using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions + { + RuntimeId = "database-wire-id-subscription-server", + Socket = BindUdpSocket() + }); + using var subscriptions = new CultNetDatabaseSubscriptionServer( + server, + database, + authorizeRequest: (_, _) => true, + authorizeRecord: (_, _, _, schemaId) => schemaId == wireSchemaId); + using var cancellation = new CancellationTokenSource(); + var serverThread = new Thread(() => + { + while (!cancellation.IsCancellationRequested) + { + _ = server.PollOnceAsync().GetAwaiter().GetResult(); + Thread.Sleep(1); + } + }) { IsBackground = true }; + serverThread.Start(); + try + { + using var client = CultNetSchemaClients.CreateRudp("database-wire-id-subscription-client"); + var subscribed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var changed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + client.OnCultNet(message => subscribed.TrySetResult(message)); + client.OnCultNet(message => changed.TrySetResult(message)); + client.Connect("127.0.0.1", server.LocalEndPoint.Port); + await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2)); + client.SendCultNet(new CultNetDatabaseSubscribeMessage + { + MessageId = "subscribe-wire-id", + SubscriptionId = "wire-id", + RecordKeys = new[] { recordKey }, + IncludeSnapshot = true + }); + + var snapshot = await AwaitWithTimeout(subscribed.Task, TimeSpan.FromSeconds(2)); + Assert.That(snapshot.Documents.Select(document => document.RecordKey), Is.EqualTo(new[] { recordKey })); + + await database.PutAsync(new CultRecordKey(recordKey), new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "updated" + }); + var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); + Assert.That(update.Document, Is.Not.Null); + Assert.That(update.Document!.RecordKey, Is.EqualTo(recordKey)); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } + } + + // R-T(a) (docs/cultnet-selection-cut.md, Self's rulings for fix batch 3): a removed change is + // evaluated without reading a document - it matches on the selection's keys and schemas alone, + // and a field predicate counts as unknown rather than excluding the row or throwing + // TryGetIndexValue(null). A subscriber holding the row under a selection with a field predicate + // must still receive the removal. + [Test] + public async Task DatabaseSubscriptionServer_DeliversARemovalUnderAFieldPredicateSelectionWithNoException() + { + const string recordKey = "v1:field-predicate-removal:sword"; + var cache = new CultCache(); + var database = new CultNetDatabase(cache); + await database.PutAsync(new CultRecordKey(recordKey), new CultNetSelectionEvaluatorTests.SelLeafA + { + Name = "sword", + Kind = "weapon", + Mass = 3 + }); + var schemaId = cache.Registry.GetRequired().SchemaId; + + using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions + { + RuntimeId = "database-field-predicate-removal-server", + Socket = BindUdpSocket() + }); + using var subscriptions = new CultNetDatabaseSubscriptionServer(server, database); + using var cancellation = new CancellationTokenSource(); + var serverThread = new Thread(() => + { + while (!cancellation.IsCancellationRequested) + { + _ = server.PollOnceAsync().GetAwaiter().GetResult(); + Thread.Sleep(1); + } + }) { IsBackground = true }; + serverThread.Start(); + try + { + using var client = CultNetSchemaClients.CreateRudp("database-field-predicate-removal-client"); + var subscribed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var changes = new ConcurrentQueue(); + client.OnCultNet(message => subscribed.TrySetResult(message)); + client.OnCultNet(message => changes.Enqueue(message)); + client.Connect("127.0.0.1", server.LocalEndPoint.Port); + await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2)); + + client.SendCultNet(new CultNetDatabaseSubscribeV1Message + { + MessageId = "subscribe-field-predicate-removal", + SubscriptionId = "field-predicate-removal", + IncludeSnapshot = true, + Selection = new CultNetSelection + { + Schemas = new[] { schemaId }, + Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon" } } } + } + }); + var snapshot = await AwaitWithTimeout(subscribed.Task, TimeSpan.FromSeconds(2)); + Assert.That(snapshot.Matched, Is.EqualTo(1u)); + + // Before R-T(a), evaluating this delete against the field predicate threw + // TryGetIndexValue(null) on the server's poll thread instead of delivering a removal. + await database.DeleteAsync(new CultRecordKey(recordKey)); + + await WaitUntilAsync( + () => changes.Any(change => change.ChangeKind == "removed" && change.RecordKey == recordKey), + TimeSpan.FromSeconds(2)); + Assert.That(changes.Any(change => change.ChangeKind == "removed" && change.RecordKey == recordKey), Is.True); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } + } + // R-A (docs/cultnet-selection-cut.md, Self's rulings for the Cut 1 fix batch): CultNetDatabaseServer // answers a v1 snapshot request against the selection's own Fields predicate, not a selection // quietly lowered to v0's two allowlists. CultNetDatabaseServer pairs with Server (the