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; /// /// 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 + /// ) 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. /// [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 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 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 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"); } }