using System.Text.Json; using System.Text.Json.Serialization.Metadata; namespace DodoSSH.Contracts.Tests; /// /// Applying the contract's settings to options this process does not own. /// /// /// The host cannot be handed StrictRequestOptions — ASP.NET Core exposes its serialiser options as /// a get-only instance already built from the web defaults — so they have to be copied in. Copying is /// exactly the hazard the context's own documentation warns about, which is why every setting that /// distinguishes the contract from those defaults is asserted here rather than assumed. /// /// The consequence of getting this wrong is not subtle: the web defaults write an enum as a number, and /// every request DTO carrying one then fails to bind against a real client. /// /// public sealed class ApplyToTests { [Fact] public void AppliedOptions_WriteEnumsAsStrings() { // The setting that actually broke. A number here means the sync surface is unreachable. var change = new SyncChange( SyncEntityType.Host, Guid.CreateVersion7(), SyncOperation.Upsert, Version: 1, ChangeSequence: 1, Payload: null, PlaintextFields: null, UpdatedAt: DateTimeOffset.UnixEpoch); var json = JsonSerializer.Serialize(change, Applied()); json.ShouldContain("\"Host\""); json.ShouldContain("\"Upsert\""); json.ShouldNotContain("\"entityType\":1"); } [Fact] public void AppliedOptions_ReadEnumsAsStrings() { var request = new SyncPullRequest("cursor", 100, [SyncEntityType.Host, SyncEntityType.Credential]); var options = Applied(); var restored = JsonSerializer.Deserialize( JsonSerializer.Serialize(request, options), options); restored.ShouldNotBeNull(); restored.EntityTypes.ShouldBe(request.EntityTypes); } [Fact] public void AppliedOptions_RejectAnUnmappedMember() { // The documented reason StrictRequestOptions exists: a renamed or misspelled property surfaces as // a 400 rather than as a silently missing value that later looks like data loss. const string Body = """{"cursor":"c","limit":10,"entityTypes":null,"typo":true}"""; Should.Throw(() => JsonSerializer.Deserialize(Body, Applied())); } [Fact] public void AppliedOptions_RejectANumberInAString() { // JsonSerializerDefaults.Web replaces Strict with AllowReadingFromString, and two implementations // that disagree about whether "1" is a number disagree silently. const string Body = """ {"envelope":"AQID","wrappedDataKey":"BAU=", "dataKeyId":"0192f0c8-0000-7000-8000-000000000000", "keyGeneration":"1","aadVersion":1} """; Should.Throw(() => JsonSerializer.Deserialize(Body, Applied())); } [Fact] public void AppliedOptions_UseCamelCase() { // Compared exactly. Shouldly's ShouldNotContain is case-insensitive, so asserting the absence of // "Cursor" would pass on "cursor" and prove nothing. JsonSerializer.Serialize(new SyncPullRequest("c", 1, null), Applied()) .ShouldBe("""{"cursor":"c","limit":1}"""); } [Fact] public void AppliedOptions_MatchTheContractsOwnSettings() { // A blanket comparison, so a setting added to the source-generation attributes and forgotten in // ApplyTo fails here rather than at a wire boundary. var applied = Applied(); var contract = DodoSshJsonContext.StrictRequestOptions; applied.PropertyNamingPolicy.ShouldBe(contract.PropertyNamingPolicy); applied.NumberHandling.ShouldBe(contract.NumberHandling); applied.DefaultIgnoreCondition.ShouldBe(contract.DefaultIgnoreCondition); applied.UnmappedMemberHandling.ShouldBe(contract.UnmappedMemberHandling); applied.PropertyNameCaseInsensitive.ShouldBe(contract.PropertyNameCaseInsensitive); applied.DictionaryKeyPolicy.ShouldBe(contract.DictionaryKeyPolicy); } [Fact] public void AppliedOptions_LeaveTheCallersOwnResolverInPlace() { // The guarantee a host depends on. The context goes in front so contract types use the generated // metadata, and whatever the host already installed keeps handling everything else — // ProblemDetails among them. Replacing the chain would break every framework type a server writes. JsonSerializer.Serialize(new Unrelated("value"), Applied()).ShouldBe("""{"name":"value"}"""); } [Fact] public void WithoutAFallbackResolver_TypesOutsideTheContractAreRefused() { // Stated rather than assumed, because it is the trade being made: a reflection fallback inside // ApplyTo would cost trimmability, which is half the reason this context is source-generated. // Options carrying no resolver are the caller's problem, loudly rather than silently. var bare = new JsonSerializerOptions(JsonSerializerDefaults.Web); DodoSshJsonContext.ApplyTo(bare); Should.Throw(() => JsonSerializer.Serialize(new Unrelated("v"), bare)); // Contract types still work, so the failure is narrow rather than total. JsonSerializer.Serialize(new SyncPullRequest("c", 1, null), bare).ShouldNotBeNullOrEmpty(); } /// /// Mirrors ASP.NET Core's own options: web defaults with a reflection resolver already /// installed, which is the state ConfigureHttpJsonOptions hands over. /// private static JsonSerializerOptions Applied() { var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { TypeInfoResolver = new DefaultJsonTypeInfoResolver(), }; DodoSshJsonContext.ApplyTo(options); return options; } private sealed record Unrelated(string Name); }