The channels API could not answer "will this channel play?", which #72 needs to flag a broken channel in the lineup at a glance. Two defects, one root cause each: 1. `ChannelRepository.GetChannel` never included `Playouts`. The read is AsNoTracking with no lazy-loading proxies, so the navigation came back empty and `GetChannelByIdForApiHandler`'s `channel.Playouts?.Count ?? 0` could only ever evaluate to 0 — `GET /api/v1/channels/{id}` reported `playoutCount: 0` for every channel on the system. That silently disabled the channel editor's playout-source guard (ChannelEditScreen:820, gated on `playoutCount > 0`), so the "Cannot be changed once a generated channel has a playout" control was always live. The server still enforces the invariant (UpdateChannelHandler coerces Mirror back to Generated), so nothing was corrupted — but the user's change was silently discarded. That silent coercion is filed separately as #401. 2. The detail path counted only the channel's own playouts, never the mirror source's, so a working Mirror channel would read as "no playout" even once the include landed. Both call sites now share `Mapper.GetPlayoutsCount` (previously private to GetAllChannelsHandler), which handles the Mirror case. `ChannelResponseModel` gains `PlayoutCount` so the list — #72's actual surface — can render it; the count is free there, since `GetAll` already includes `Playouts` and `MirrorSourceChannel.Playouts` and simply discarded them. Tests run the real repository against a real context on purpose: a handler test with a substituted IChannelRepository populates `Playouts` itself, so it passes whether or not the query includes them. Proven non-vacuous — removing the include again turns the 2-playout and mirror cases red (0 CS errors, so no stale-dll false pass). Refs #72
174 lines
5.8 KiB
C#
174 lines
5.8 KiB
C#
using System.Text.Json;
|
|
using ErsatzTV.Core.Api.Channels;
|
|
using ErsatzTV.Core.Api.MediaItems;
|
|
using ErsatzTV.Core.Api.Settings;
|
|
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
|
|
{
|
|
// Mirrors Startup.ConfigureServices -> AddNewtonsoftJson exactly.
|
|
private static readonly JsonSerializerSettings RuntimeSettings = new()
|
|
{
|
|
NullValueHandling = NullValueHandling.Ignore,
|
|
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
|
|
ContractResolver = new CustomContractResolver(),
|
|
Converters = { new StringEnumConverter() }
|
|
};
|
|
|
|
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);
|
|
|
|
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);
|
|
|
|
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");
|
|
}
|
|
}
|