CultLib/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs
Libby 8f71bff69c cultnet error contract: one message_invalid code replaces malformed_document_put/delete
Q6-A of docs/cultnet-error-contract-cut.md. message_invalid joins the closed
cultnet.error.v0 code enum (both schema copies), with details.field naming the
request field at fault ("message" when the whole frame cannot be read).
C# ForMessageInvalid, Rust CultNetErrorCode::MessageInvalid, TS
cultNetMessageInvalidError and Python message_invalid_error all write the same
captured bytes (error-vectors.json plus the C# and Rust cases). cultmesh-py and
cultmesh-ts refuse malformed puts and deletes with it; the C# subscription
servers use it for the missing subscriptionId.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:14:12 +00:00

255 lines
14 KiB
C#

#nullable enable
using System;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using GameCult.Caching;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Networking.Tests
{
// R-S (docs/cultnet-selection-cut.md, "Self's rulings for fix batch 3", F12): the committed
// contracts/cultnet/*.schema.json files describe the bytes MessagePack-CSharp actually writes -
// headers get their own schema (no payload/payloadEncoding, which CultNetRawDocumentHeader has
// no members for at all), the snapshot page's shape matches map-mode output (every declared key
// present, nil included), and fieldPredicate refuses values/number together. This decodes real
// wire bytes from a live CultNetDatabaseServer and checks them against the committed schema
// files with MiniJsonSchemaValidator, rather than asserting on the C# objects alone - a schema
// file can drift from the bytes even while the C# types stay internally consistent, which is
// exactly what F12 was.
public sealed class CultNetSchemaContractTests
{
private static readonly ServerSecurityOptions DevelopmentServerSecurity = ServerSecurityOptions.Development();
private static string SchemaDir() => Path.Combine(RepoRoot(), "contracts", "cultnet");
private static string RepoRoot()
{
var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln")))
directory = directory.Parent;
return directory?.FullName ?? throw new InvalidOperationException("CultLib.sln was not found above the test directory.");
}
private static JsonElement ToJson<T>(T message) where T : ICultNetSchemaMessage
{
var bytes = CultNetSchemaMessageSerialization.Serialize(message);
var json = MessagePackSerializer.ConvertToJson(bytes);
return JsonDocument.Parse(json).RootElement;
}
[Test]
public async Task SnapshotResponseRawV1_HeaderProjectionRealBytes_MatchesItsSchema()
{
var cache = new CultCache();
var database = new CultNetDatabase(cache);
await database.PutAsync(new CultRecordKey("v1:weapon"), new CultNetSelectionEvaluatorTests.SelLeafA
{
Name = "sword",
Kind = "weapon",
Mass = 3
});
using var server = new Server(cache, DevelopmentServerSecurity);
using var databaseServer = new CultNetDatabaseServer(server, database);
var response = databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message
{
MessageId = "header-projection",
Selection = new CultNetSelection() // default projection is "header"
});
Assert.That(response.Headers, Is.Not.Null);
Assert.That(response.Documents, Is.Null);
var validator = new MiniJsonSchemaValidator(SchemaDir());
validator.AssertValid(ToJson(response), "cultnet.snapshot-response-raw.v1.schema.json");
}
[Test]
public async Task SnapshotResponseRawV1_DocumentProjectionRealBytes_MatchesItsSchema()
{
var cache = new CultCache();
var database = new CultNetDatabase(cache);
await database.PutAsync(new CultRecordKey("v1:weapon"), new CultNetSelectionEvaluatorTests.SelLeafA
{
Name = "sword",
Kind = "weapon",
Mass = 3
});
using var server = new Server(cache, DevelopmentServerSecurity);
using var databaseServer = new CultNetDatabaseServer(server, database);
var response = databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message
{
MessageId = "document-projection",
Selection = new CultNetSelection { Projection = CultNetSelectionProjections.Document }
});
Assert.That(response.Documents, Is.Not.Null);
Assert.That(response.Headers, Is.Null);
var validator = new MiniJsonSchemaValidator(SchemaDir());
validator.AssertValid(ToJson(response), "cultnet.snapshot-response-raw.v1.schema.json");
}
[Test]
public async Task SnapshotResponseRawV1_WithEdgesRealBytes_MatchesItsSchema()
{
var cache = new CultCache();
var database = new CultNetDatabase(cache);
var target = new CultNetSelectionEvaluatorTests.SelLeafA { Name = "target", Kind = "k", Mass = 1 };
await database.PutAsync(new CultRecordKey("target"), target);
await database.PutAsync(new CultRecordKey("citer"), new CultNetSelectionEvaluatorTests.SelCiter
{
Name = "citer",
Design = new CultRecordRef<CultNetSelectionEvaluatorTests.SelFixtureMiddle>(new CultRecordKey("target"))
});
using var server = new Server(cache, DevelopmentServerSecurity);
using var databaseServer = new CultNetDatabaseServer(server, database);
var response = databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message
{
MessageId = "with-edges",
Selection = new CultNetSelection
{
Cited = new CultNetIncoming { Role = "Design", Exists = true },
Projection = CultNetSelectionProjections.Document
}
});
Assert.That(response.Edges, Is.Not.Null.And.Length.GreaterThan(0));
var validator = new MiniJsonSchemaValidator(SchemaDir());
validator.AssertValid(ToJson(response), "cultnet.snapshot-response-raw.v1.schema.json");
}
// R-S: fieldPredicate is now exclusive - values and number may not both be present, even
// though each alone satisfies one oneOf branch's `required`. Hand-built JSON, not a C# object,
// because CultNetFieldPredicate itself has no door stopping this shape from being constructed
// in memory - the door is CultNetSelectionValidation, a different layer; this test is about the
// wire schema's own oneOf, independent of that door.
[Test]
public void FieldPredicate_ValuesAndNumberTogether_FailsItsSchema()
{
var instance = JsonDocument.Parse(
"""{"index":"mass","op":"any_of","values":["1"],"number":"1"}""").RootElement;
var validator = new MiniJsonSchemaValidator(SchemaDir());
var errors = validator.Validate(instance, "cultnet.selection.schema.json#/$defs/fieldPredicate");
Assert.That(errors, Is.Not.Empty);
}
[Test]
public void FieldPredicate_ValuesOnly_MatchesItsSchema()
{
var instance = JsonDocument.Parse(
"""{"index":"kind","op":"any_of","values":["weapon"]}""").RootElement;
var validator = new MiniJsonSchemaValidator(SchemaDir());
var errors = validator.Validate(instance, "cultnet.selection.schema.json#/$defs/fieldPredicate");
Assert.That(errors, Is.Empty, string.Join("; ", errors));
}
[Test]
public void FieldPredicate_NumberOnly_MatchesItsSchema()
{
var instance = JsonDocument.Parse(
"""{"index":"mass","op":"gt","number":"1"}""").RootElement;
var validator = new MiniJsonSchemaValidator(SchemaDir());
var errors = validator.Validate(instance, "cultnet.selection.schema.json#/$defs/fieldPredicate");
Assert.That(errors, Is.Empty, string.Join("; ", errors));
}
// R-X (docs/cultnet-selection-cut.md, fix batch 4): CultNetErrorMessage is a MessagePack-CSharp
// map-mode object - routingHint/code/details are always written, nil included, the same as any
// other declared key. Soul's P7 found the schema disagreed with the real bytes on exactly this:
// routingHint undeclared, additionalProperties:false, and details.asOf/current typed as a plain
// integer instead of ["integer","null"]. This decodes real bytes for all four refusal codes.
[Test]
public void ErrorMessage_SelectionInvalidRealBytes_MatchesItsSchema()
{
var ex = new CultNetSelectionInvalidException("schemas", null, "selection.schemas must be non-empty.");
AssertErrorMatchesSchema(CultNetErrorMessage.ForSelectionInvalid(ex));
}
[Test]
public void ErrorMessage_CursorStaleRealBytes_MatchesItsSchema()
{
var ex = new CultNetSelectionCursorException("cursor_stale", "stale", asOf: 3, current: 7);
AssertErrorMatchesSchema(CultNetErrorMessage.ForCursor(ex));
}
[Test]
public void ErrorMessage_CursorInvalidRealBytes_MatchesItsSchema()
{
var ex = new CultNetSelectionCursorException("cursor_invalid", "The cursor does not decode.");
AssertErrorMatchesSchema(CultNetErrorMessage.ForCursor(ex));
}
[Test]
public void ErrorMessage_ReferenceOutsideTargetRealBytes_MatchesItsSchema()
{
var ex = new CultNetSelectionReferenceOutsideTargetException("citer-schema", "citer-key", "Design", "outside-schema", "outside-key");
AssertErrorMatchesSchema(CultNetErrorMessage.ForReferenceOutsideTarget(ex));
}
[Test]
public void ErrorMessage_UnownedSchemaRealBytes_MatchesItsSchema()
{
var message = CultNetErrorMessage.ForUnownedSchema(new CultNetUnownedSchemaException("some.schema.v1", new CultRecordKey("some:key")));
Assert.That(message.Code, Is.EqualTo("unowned_schema"));
Assert.That(message.Details!.Field, Is.EqualTo("schemaId"));
Assert.That(message.Details.Value, Is.EqualTo("some.schema.v1"));
AssertErrorMatchesSchema(message);
}
[Test]
public void ErrorMessage_RemoteWriteRefusedRealBytes_MatchesItsSchema()
{
var request = new CultNetRemoteWriteRequest(null!, CultNetRemoteWriteOperation.Put, "some.schema.v1", new CultRecordKey("some:key"), null, null);
var message = CultNetErrorMessage.ForRemoteWriteRefused(request, "not granted");
Assert.That(message.Code, Is.EqualTo("write_not_authorized"));
Assert.That(message.Details!.Field, Is.EqualTo("recordKey"));
Assert.That(message.Details.Value, Is.EqualTo("some:key"));
AssertErrorMatchesSchema(message);
// The bytes cultnet-rs's error_message.rs write_not_authorized case decodes and re-encodes.
Assert.That(Convert.ToHexString(CultNetSchemaMessageSerialization.Serialize(message)), Is.EqualTo("85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92177726974655F6E6F745F617574686F72697A65643A206E6F74206772616E746564AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A8736F6D653A6B6579A461734F66C0A763757272656E74C0"));
}
[Test]
public void ErrorMessage_UnsupportedSchemaVersionRealBytes_MatchesItsSchema()
{
var message = CultNetErrorMessage.ForUnsupportedSchemaVersion("cultnet.hello.v1");
Assert.That(message.Code, Is.EqualTo("unsupported_schema_version"));
Assert.That(message.Details!.Field, Is.EqualTo("schemaVersion"));
Assert.That(message.Details.Value, Is.EqualTo("cultnet.hello.v1"));
AssertErrorMatchesSchema(message);
// The bytes cultnet-rs's error_message.rs unsupported_schema_version case decodes and re-encodes.
Assert.That(Convert.ToHexString(CultNetSchemaMessageSerialization.Serialize(message)), Is.EqualTo("85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92C756E737570706F727465645F736368656D615F76657273696F6E3A2063756C746E65742E68656C6C6F2E7631AB726F7574696E6748696E74C0A4636F6465BA756E737570706F727465645F736368656D615F76657273696F6EA764657461696C7384A56669656C64AD736368656D6156657273696F6EA576616C7565B063756C746E65742E68656C6C6F2E7631A461734F66C0A763757272656E74C0"));
}
[Test]
public void ErrorMessage_MessageInvalidRealBytes_MatchesItsSchema()
{
var message = CultNetErrorMessage.ForMessageInvalid("document", "Raw put messages must contain a document map.");
Assert.That(message.Code, Is.EqualTo("message_invalid"));
Assert.That(message.Details!.Field, Is.EqualTo("document"));
Assert.That(message.Details.Value, Is.Null);
AssertErrorMatchesSchema(message);
// The bytes cultnet-rs's error_message.rs message_invalid case decodes and re-encodes.
Assert.That(Convert.ToHexString(CultNetSchemaMessageSerialization.Serialize(message)), Is.EqualTo("85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D93E6D6573736167655F696E76616C69643A2052617720707574206D65737361676573206D75737420636F6E7461696E206120646F63756D656E74206D61702EAB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A8646F63756D656E74A576616C7565C0A461734F66C0A763757272656E74C0"));
}
private static void AssertErrorMatchesSchema(CultNetErrorMessage message)
{
var validator = new MiniJsonSchemaValidator(SchemaDir());
validator.AssertValid(ToJson(message), "cultnet.error.schema.json");
}
}
}