diff --git a/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs index 7213d7341..7bd4113de 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs @@ -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); diff --git a/ErsatzTV/Serialization/NewtonsoftSchemaNamingTransformer.cs b/ErsatzTV/Serialization/NewtonsoftSchemaNamingTransformer.cs index d5e90599b..464db31e2 100644 --- a/ErsatzTV/Serialization/NewtonsoftSchemaNamingTransformer.cs +++ b/ErsatzTV/Serialization/NewtonsoftSchemaNamingTransformer.cs @@ -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(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;