CultLib/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs

215 lines
10 KiB
C#
Raw Normal View History

Land fix batch 3's R-S: the schemas describe the bytes C# writes New contracts/cultnet/cultnet.raw-document-header.schema.json for CultNetRawDocumentHeader - it has no payload/payloadEncoding members at all, so headers must not $ref the record schema (which required both). The response page's own schema now lists every property CultNetSnapshotResponseRawV1Message declares as `required` (present on the wire, nil included) rather than only the four that happened to be non-nullable - MessagePack-CSharp's map-mode formatter writes every declared key regardless of null, so `headers`/`documents` are always both present, one an array and the other an explicit null by projection. The old `oneOf: [{required:[headers]},{required:[documents]}]` matched both branches on every real page (both keys present) and so never validated anything; it is now `oneOf` over each key's type (array vs null), which is mutually exclusive. cultnet.raw-document-record.schema.json gained schemaName/schemaVersion/schemaContentHash (present on every real record, not previously in the schema at all - any real page would have failed additionalProperties:false on them). fieldPredicate's two oneOf branches each gained `not: {required: [the other]}`, so `values` and `number` cannot both be present (previously only `required` was checked, which does not exclude the sibling key). Added MiniJsonSchemaValidator, a deliberately small draft-2020-12 subset validator (type/required/additionalProperties/properties/items/const/enum/ pattern/minLength/minItems/oneOf/$ref, same-document and cross-document) - no schema-validation dependency existed in this repo, and this only needs to cover what contracts/cultnet/*.schema.json actually use. CultNetSchemaContractTests decodes real wire bytes from a live CultNetDatabaseServer (header projection, document projection, and a hop with edges) through MessagePackSerializer.ConvertToJson and checks them against the committed schema files, plus three fieldPredicate-shape tests pinning the values/number exclusivity. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:12:58 +02:00
#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));
}
Fix batch 4 (R-X, S-4/S-10): the error schema matches the wire, and the validator refuses a keyword it does not implement CultNetErrorMessage is a MessagePack-CSharp map-mode object, so every declared key is written on the wire, nil included - the same fact R-S already established for the raw document header. cultnet.error.schema.json was missing routingHint entirely and typed code/details as always-present non-null values; Soul's P7 found real bytes failing it on exactly this. routingHint, code and details are now `required`, with nullable types matching CultNetErrorMessage/CultNetErrorDetails. A new test decodes real bytes for all four refusal codes (selection_invalid, cursor_stale, cursor_invalid, reference_outside_target) against the schema. MiniJsonSchemaValidator: - implements minimum, maximum, maxLength and maxItems, which were declared in committed schemas (fieldPredicate's number pattern, selection.limit, ...) but silently never checked; - anchors `pattern` to a full match (Index==0 && Length==value.Length) instead of Regex.IsMatch's "search anywhere", so .NET's `$` (which, unlike ECMA-262's, matches immediately before one trailing newline even without RegexOptions.Multiline) can no longer let e.g. "5\n" pass a schema author wrote as "exactly one digit"; - refuses a schema node that uses a keyword this validator gives no constraint semantics to, instead of silently ignoring it. Running every committed contracts/cultnet/*.schema.json schema through this found 12 such uses, none reached by any existing test: `allOf`/`if`/`then`/`else` in cultnet.database-change-raw.schema.json, and `uniqueItems` (six places) plus one `anyOf` in the cultmesh.verse-catalog-*.schema.json pair. contentEncoding, $schema/$id/$defs/title/description/default and the x-cult-runtime-type vendor extension are treated as metadata with no constraint semantics to implement, not refused. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:12:23 +02:00
// 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);
}
Fix batch 4 (R-X, S-4/S-10): the error schema matches the wire, and the validator refuses a keyword it does not implement CultNetErrorMessage is a MessagePack-CSharp map-mode object, so every declared key is written on the wire, nil included - the same fact R-S already established for the raw document header. cultnet.error.schema.json was missing routingHint entirely and typed code/details as always-present non-null values; Soul's P7 found real bytes failing it on exactly this. routingHint, code and details are now `required`, with nullable types matching CultNetErrorMessage/CultNetErrorDetails. A new test decodes real bytes for all four refusal codes (selection_invalid, cursor_stale, cursor_invalid, reference_outside_target) against the schema. MiniJsonSchemaValidator: - implements minimum, maximum, maxLength and maxItems, which were declared in committed schemas (fieldPredicate's number pattern, selection.limit, ...) but silently never checked; - anchors `pattern` to a full match (Index==0 && Length==value.Length) instead of Regex.IsMatch's "search anywhere", so .NET's `$` (which, unlike ECMA-262's, matches immediately before one trailing newline even without RegexOptions.Multiline) can no longer let e.g. "5\n" pass a schema author wrote as "exactly one digit"; - refuses a schema node that uses a keyword this validator gives no constraint semantics to, instead of silently ignoring it. Running every committed contracts/cultnet/*.schema.json schema through this found 12 such uses, none reached by any existing test: `allOf`/`if`/`then`/`else` in cultnet.database-change-raw.schema.json, and `uniqueItems` (six places) plus one `anyOf` in the cultmesh.verse-catalog-*.schema.json pair. contentEncoding, $schema/$id/$defs/title/description/default and the x-cult-runtime-type vendor extension are treated as metadata with no constraint semantics to implement, not refused. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:12:23 +02:00
private static void AssertErrorMatchesSchema(CultNetErrorMessage message)
{
var validator = new MiniJsonSchemaValidator(SchemaDir());
validator.AssertValid(ToJson(message), "cultnet.error.schema.json");
}
Land fix batch 3's R-S: the schemas describe the bytes C# writes New contracts/cultnet/cultnet.raw-document-header.schema.json for CultNetRawDocumentHeader - it has no payload/payloadEncoding members at all, so headers must not $ref the record schema (which required both). The response page's own schema now lists every property CultNetSnapshotResponseRawV1Message declares as `required` (present on the wire, nil included) rather than only the four that happened to be non-nullable - MessagePack-CSharp's map-mode formatter writes every declared key regardless of null, so `headers`/`documents` are always both present, one an array and the other an explicit null by projection. The old `oneOf: [{required:[headers]},{required:[documents]}]` matched both branches on every real page (both keys present) and so never validated anything; it is now `oneOf` over each key's type (array vs null), which is mutually exclusive. cultnet.raw-document-record.schema.json gained schemaName/schemaVersion/schemaContentHash (present on every real record, not previously in the schema at all - any real page would have failed additionalProperties:false on them). fieldPredicate's two oneOf branches each gained `not: {required: [the other]}`, so `values` and `number` cannot both be present (previously only `required` was checked, which does not exclude the sibling key). Added MiniJsonSchemaValidator, a deliberately small draft-2020-12 subset validator (type/required/additionalProperties/properties/items/const/enum/ pattern/minLength/minItems/oneOf/$ref, same-document and cross-document) - no schema-validation dependency existed in this repo, and this only needs to cover what contracts/cultnet/*.schema.json actually use. CultNetSchemaContractTests decodes real wire bytes from a live CultNetDatabaseServer (header projection, document projection, and a hop with edges) through MessagePackSerializer.ConvertToJson and checks them against the committed schema files, plus three fieldPredicate-shape tests pinning the values/number exclusivity. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:12:58 +02:00
}
}