Files
ersatztv/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs
T
timothyandClaude Fable 5.1 3df98d247f docs(563): state each binder test's mechanism instead of its mutant colour, per the amended CLAIMS rule
`testing.mutation-claims-are-executed` was amended on main while this branch was in review (#881,
merged as #914): a sentence asserting that a specific mutation reddens — or does not redden — a named
test is now either a `CLAIMS` entry in `scripts/tests/mutation_manifest.py` that executes every run,
or it is not written. This branch carried six such sentences and none of them can be declared:
`Claim.node_id` resolves a proof to `scripts/tests/<node id>` and `run_pytest` invokes pytest, so an
NUnit proof has no representation in that harness at all.

Durable prose now states the mechanism each test is built on — which serializer difference, which
engine branch — which a reader re-checks by reading the code rather than by trusting a remembered
outcome. The record says that in one paragraph, so the limit is stated rather than papered over.

The outcomes themselves are here. Re-measured 2026-09-05 on this branch's tree (the commit before
this one), each mutant applied to the working tree and restored from the index between runs, tree
verified clean afterwards:

  positive control          ScriptedScheduleControllerTests   Passed: 9, Failed: 0
                            OpenApiSerializerContractTests    Passed: 4, Failed: 0
  Bind<T> -> System.Text.Json with JsonSerializerDefaults.Web
                            Failed: 2, Passed: 7 — Production_Body_Binder_Ignores_Required_Members,
                            Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null
  BodyBinderSettings = ApiJsonSettings.Create() -> new JsonSerializerSettings()
                            Failed: 1, Passed: 8 —
                            Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null
  OpenApiSerializerContractTests RuntimeSettings -> new JsonSerializerSettings()
                            Failed: 4, Passed: 0 — all four cases, on PascalCase keys
  ScriptedScheduleController AddDuration(..., request.Trim, ...) -> false
                            Failed: 1, Passed: 8 — Committed_Script_Fixture_Produces_The_Pinned_Snapshot
  ScriptedScheduleController PadUntilExact(..., request.Trim, ...) -> false
                            Failed: 1, Passed: 8 — Committed_Script_Fixture_Produces_The_Pinned_Snapshot

The last one is the round-two finding closed and re-witnessed: before the fixture's pad target moved
off the content boundary, that mutant left all nine green.

A squash merge writes its own message, so these figures also belong in the PR description.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 09:31:42 +02:00

181 lines
6.5 KiB
C#

using System.Text.Json;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.MediaItems;
using ErsatzTV.Core.Api.Settings;
using ErsatzTV.Core.Domain;
using ErsatzTV.Serialization;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
/// <summary>
/// Guards that the generated OpenAPI document's property names for a set of DTOs exactly match the
/// JSON keys the runtime MVC serializer (Newtonsoft, via <see cref="CustomContractResolver" /> +
/// <see cref="StringEnumConverter" />) actually emits. The spec is generated from System.Text.Json
/// metadata, whose camelCase can drift from Newtonsoft's (e.g. "ffmpegProfileId" special case, or a
/// [JsonProperty] override). See issue #198 — a schema transformer now mirrors the runtime resolver,
/// and this test fails if that mirroring is ever removed or broken.
/// </summary>
[TestFixture]
public class OpenApiSerializerContractTests
{
// The configuration Startup.ConfigureServices -> AddNewtonsoftJson applies, from the same function
// rather than a hand-copy of it. This fixture is the WRITE-side witness for the naming strategy: the
// four cases below assert camelCase keys, which CustomContractResolver produces and a bare
// JsonSerializerSettings does not. It says nothing about NullValueHandling (every DTO member below is
// populated, so nothing is dropped either way) or the StringEnumConverter (it compares key NAMES, not
// values). docs/testing.md -> "Scripted playout coverage" tabulates which suite witnesses which half.
private static readonly JsonSerializerSettings RuntimeSettings = ApiJsonSettings.Create();
private static IEnumerable<TestCaseData> Cases()
{
yield return new TestCaseData(FullyPopulatedChannelDetail(), "ChannelDetailResponseModel")
.SetName("ChannelDetailResponseModel");
yield return new TestCaseData(FullyPopulatedFFmpegSettings(), "FFmpegSettingsResponseModel")
.SetName("FFmpegSettingsResponseModel");
// WatermarkViewModel was dropped from the API surface by #126: schedule-item endpoints no longer
// expose the polymorphic ProgramScheduleItemViewModel (which transitively pulled in WatermarkViewModel
// and the other collection VMs) — they return the flat ScheduleItemResponseModel, embedding watermarks
// as NamedIdResponseModel. ChannelDetailResponseModel above still exercises the "ffmpegProfileId"
// special-case naming path of the schema transformer.
yield return new TestCaseData(FullyPopulatedMediaItemInfo(), "MediaItemInfoResponseModel")
.SetName("MediaItemInfoResponseModel");
// The only DTO with a [JsonProperty] override (FFmpegProfile -> "ffmpegProfile") — covers
// the attribute path of NewtonsoftSchemaNamingTransformer, which the cases above don't.
yield return new TestCaseData(FullyPopulatedChannelSummary(), "ChannelResponseModel")
.SetName("ChannelResponseModel");
}
[TestCaseSource(nameof(Cases))]
public void Runtime_Serialized_Keys_Should_Match_OpenApi_Schema_Properties(object dto, string schemaName)
{
// Every member of dto is non-null, so NullValueHandling.Ignore drops nothing: the emitted
// top-level keys are the complete runtime property set for this type.
var serialized = JObject.Parse(JsonConvert.SerializeObject(dto, RuntimeSettings));
List<string> runtimeKeys = serialized.Properties().Select(p => p.Name).OrderBy(n => n).ToList();
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(FindOpenApiDocument()));
JsonElement properties = document.RootElement
.GetProperty("components")
.GetProperty("schemas")
.GetProperty(schemaName)
.GetProperty("properties");
List<string> schemaKeys = properties.EnumerateObject().Select(p => p.Name).OrderBy(n => n).ToList();
runtimeKeys.ShouldBe(
schemaKeys,
$"OpenAPI schema '{schemaName}' property names must match the runtime Newtonsoft JSON keys.");
}
private static ChannelDetailResponseModel FullyPopulatedChannelDetail() => new(
1,
"1",
"Name",
"Group",
"Categories",
1,
1.0,
new ChannelLogoResponseModel("path", "image/png"),
default,
"selector",
"en",
"Audio Title",
default,
default,
1,
TimeSpan.Zero,
default,
1,
1,
1,
"en",
default,
default,
"template",
default,
default,
default,
true,
true,
[1],
new ChannelHealthResponseModel(ChannelHealthStatus.Healthy, [], 1, 0));
private static FFmpegSettingsResponseModel FullyPopulatedFFmpegSettings() => new(
"/usr/bin/ffmpeg",
"/usr/bin/ffprobe",
1,
"en",
true,
true,
true,
true,
1,
1,
1,
1,
1,
default,
"script");
private static MediaItemInfoResponseModel FullyPopulatedMediaItemInfo() => new(
1,
"Title",
"Movie",
"Local",
"Server",
"Library",
default,
TimeSpan.Zero,
"1:1",
"16:9",
"30/1",
default,
1.0,
1,
1,
[],
[]);
private static ChannelResponseModel FullyPopulatedChannelSummary() => new(
1,
"1",
1.0,
"Name",
"Group",
"Categories",
"1080p H.264",
"en",
"TransportStream",
true,
true,
2,
"/iptv/logos/logo.png",
Mapper.GetPreview(StreamingMode.HttpLiveStreamingSegmenter, "1", true, 2),
ChannelOrigin.AutoTuned,
new ChannelHealthResponseModel(ChannelHealthStatus.Healthy, [], 2, 0));
private static string FindOpenApiDocument()
{
DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory);
while (directory is not null)
{
string candidate = Path.Combine(directory.FullName, "ErsatzTV", "wwwroot", "openapi", "v1.json");
if (File.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
throw new FileNotFoundException("Could not find ErsatzTV/wwwroot/openapi/v1.json");
}
}