test(api): cover [JsonProperty] path + fail loudly on rename collision (#198, review)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m59s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m31s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

Adversarial-review follow-ups:

- OpenApiSerializerContractTests: add a fifth case, a fully-populated
  ChannelResponseModel — the only DTO with a [JsonProperty("ffmpegProfile")]
  override, i.e. the attribute path of NewtonsoftSchemaNamingTransformer that
  the existing four cases never exercised.
- NewtonsoftSchemaNamingTransformer: a rename colliding with an existing schema
  key now throws InvalidOperationException (naming the type and keys) instead of
  silently overwriting/dropping a property — the generator must never emit a
  lossy spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 20:06:26 +02:00
co-authored by Claude Fable 5
parent d9faa8be7b
commit e0d30c9b7b
2 changed files with 28 additions and 2 deletions
@@ -2,6 +2,7 @@ using System.Text.Json;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Watermarks;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.MediaItems;
using ErsatzTV.Core.Api.Settings;
using ErsatzTV.Serialization;
@@ -41,6 +42,11 @@ public class OpenApiSerializerContractTests
yield return new TestCaseData(FullyPopulatedWatermark(), "WatermarkViewModel").SetName("WatermarkViewModel");
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))]
@@ -150,6 +156,19 @@ public class OpenApiSerializerContractTests
[],
[]);
private static ChannelResponseModel FullyPopulatedChannelSummary() => new(
1,
"1",
1.0,
"Name",
"Group",
"Categories",
"1080p H.264",
"en",
"TransportStream",
true,
true);
private static string FindOpenApiDocument()
{
DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory);
@@ -67,11 +67,18 @@ public static class NewtonsoftSchemaNamingTransformer
return Task.CompletedTask;
}
// Rebuild Properties preserving insertion order.
// Rebuild Properties preserving insertion order. A rename colliding with an existing key
// would silently drop a property from the spec — fail loudly instead (near-unreachable,
// but the generator must never emit a lossy document).
var renamedProperties = new Dictionary<string, IOpenApiSchema>(properties.Count, StringComparer.Ordinal);
foreach ((string key, IOpenApiSchema value) in properties)
{
renamedProperties[renames.TryGetValue(key, out string newKey) ? newKey : key] = value;
string finalKey = renames.TryGetValue(key, out string newKey) ? newKey : key;
if (!renamedProperties.TryAdd(finalKey, value))
{
throw new InvalidOperationException(
$"OpenAPI schema property rename collision on '{clrType.FullName}': renaming '{key}' to '{finalKey}' would overwrite an existing property.");
}
}
schema.Properties = renamedProperties;