Fix batch 3 tests: R-N wire decode, R-P wire-id authorization, R-T removal/cites
Four CultNetErrorMessage wire round-trip tests (decode the bytes a peer actually receives) proving code+details for selection_invalid, cursor_stale, cursor_invalid, reference_outside_target. A subscription-server round trip proving snapshot and first live change are authorized by the same wire id (R-P). A subscription-server round trip proving a removal under a field-predicate selection is delivered with no exception (R-T(a)). An evaluator test proving an implicitly-typed many reference still refuses an out-of-target edge (R-T(b)). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
66b5e60b43
commit
e8a3decbab
2 changed files with 300 additions and 0 deletions
|
|
@ -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<CultNetSelectionInvalidException>(
|
||||
() => new CultNetSelection { Schemas = Array.Empty<string>() }.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<CultNetSelectionCursorException>(
|
||||
() => 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<CultNetSelectionCursorException>(
|
||||
() => 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<SelLeafA>(new CultRecordKey("b")) };
|
||||
var narrowRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(SelLeafA), typeof(SelLeafB), typeof(SelCiterNarrow) });
|
||||
var rows = new[]
|
||||
{
|
||||
new CultNetSelectionEvaluator.Row(narrowRegistry.GetRequired<SelLeafB>(), new CultRecordKey("b"), leafB, 1),
|
||||
new CultNetSelectionEvaluator.Row(narrowRegistry.GetRequired<SelCiterNarrow>(), new CultRecordKey("bad"), offTargetCiter, 2)
|
||||
};
|
||||
var selection = new CultNetSelection { Cited = new CultNetIncoming { Role = "NarrowRef", Exists = true } };
|
||||
var ex = Assert.Throws<CultNetSelectionReferenceOutsideTargetException>(
|
||||
() => 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<T> 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<SelLeafA> 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<SelLeafA>(new CultRecordKey("a")) }
|
||||
};
|
||||
var offTargetCiter = new SelCiterManyUntyped
|
||||
{
|
||||
Name = "bad",
|
||||
ManyRefs = new[] { new CultRecordRef<SelLeafA>(new CultRecordKey("b")) }
|
||||
};
|
||||
|
||||
var okRows = new[]
|
||||
{
|
||||
new CultNetSelectionEvaluator.Row(registry.GetRequired<SelLeafA>(), new CultRecordKey("a"), leafA, 1),
|
||||
new CultNetSelectionEvaluator.Row(registry.GetRequired<SelCiterManyUntyped>(), 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<SelLeafB>(), new CultRecordKey("b"), leafB, 1),
|
||||
new CultNetSelectionEvaluator.Row(registry.GetRequired<SelCiterManyUntyped>(), new CultRecordKey("bad"), offTargetCiter, 2)
|
||||
};
|
||||
Assert.Throws<CultNetSelectionReferenceOutsideTargetException>(
|
||||
() => 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<SelLeafA> 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<SelLeafA> element alone (R-T(b)).
|
||||
[Key(1)]
|
||||
[CultReference(many: true)]
|
||||
public CultRecordRef<SelLeafA>[] ManyRefs = Array.Empty<CultRecordRef<SelLeafA>>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<NetworkSchemaNote>(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<CultNetSnapshotResponseRawMessage>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var changed = new TaskCompletionSource<CultNetDatabaseChangeRawMessage>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
client.OnCultNet<CultNetSnapshotResponseRawMessage>(message => subscribed.TrySetResult(message));
|
||||
client.OnCultNet<CultNetDatabaseChangeRawMessage>(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<CultNetSelectionEvaluatorTests.SelLeafA>().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<CultNetSnapshotResponseRawV1Message>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var changes = new ConcurrentQueue<CultNetDatabaseChangeRawMessage>();
|
||||
client.OnCultNet<CultNetSnapshotResponseRawV1Message>(message => subscribed.TrySetResult(message));
|
||||
client.OnCultNet<CultNetDatabaseChangeRawMessage>(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<CultNetSelectionEvaluatorTests.SelLeafA>(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
|
||||
|
|
|
|||
Loading…
Reference in a new issue