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);
}