using System.Text.Json; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Controllers; [TestFixture] public class OpenApiErrorResponseContractTests { [Test] public void Static_OpenApi_Should_Document_Schedule_Item_Discriminator_As_String_Enum() { using JsonDocument document = JsonDocument.Parse(File.ReadAllText(FindOpenApiDocument())); JsonElement playoutMode = document.RootElement .GetProperty("components") .GetProperty("schemas") .GetProperty("PlayoutMode"); playoutMode.GetProperty("type").GetString().ShouldBe("string"); playoutMode.GetProperty("enum").EnumerateArray() .Select(e => e.GetString()) .ShouldContain("One"); } [Test] public void Static_OpenApi_Should_Document_Channel_List_Management_Fields() { using JsonDocument document = JsonDocument.Parse(File.ReadAllText(FindOpenApiDocument())); JsonElement schema = document.RootElement .GetProperty("components") .GetProperty("schemas") .GetProperty("ChannelResponseModel") .GetProperty("properties"); schema.TryGetProperty("group", out _).ShouldBeTrue(); schema.TryGetProperty("isEnabled", out _).ShouldBeTrue(); schema.TryGetProperty("showInEpg", out _).ShouldBeTrue(); schema.TryGetProperty("sortNumber", out _).ShouldBeTrue(); schema.TryGetProperty("categories", out _).ShouldBeTrue(); } [Test] public void Static_OpenApi_Should_Document_Channel_State_Response() { using JsonDocument document = JsonDocument.Parse(File.ReadAllText(FindOpenApiDocument())); JsonElement getState = document.RootElement .GetProperty("paths") .GetProperty("/api/v1/channels/state") .GetProperty("get"); JsonElement schema = getState .GetProperty("responses") .GetProperty("200") .GetProperty("content") .GetProperty("application/json") .GetProperty("schema"); schema.GetProperty("type").GetString().ShouldBe("array"); schema.GetProperty("items").GetProperty("$ref").GetString() .ShouldBe("#/components/schemas/ChannelStateResponseModel"); JsonElement stateProperties = document.RootElement .GetProperty("components") .GetProperty("schemas") .GetProperty("ChannelStateResponseModel") .GetProperty("properties"); stateProperties.TryGetProperty("channelId", out _).ShouldBeTrue(); stateProperties.TryGetProperty("channelNumber", out _).ShouldBeTrue(); stateProperties.TryGetProperty("onAir", out _).ShouldBeTrue(); stateProperties.TryGetProperty("nowPlaying", out JsonElement nowPlaying).ShouldBeTrue(); JsonElement nowPlayingOneOf = nowPlaying.GetProperty("oneOf"); nowPlayingOneOf.EnumerateArray().Count().ShouldBe(2); nowPlayingOneOf.EnumerateArray() .Any(s => s.TryGetProperty("type", out JsonElement type) && type.GetString() == "null") .ShouldBeTrue(); nowPlayingOneOf.EnumerateArray() .Any(s => s.TryGetProperty("$ref", out JsonElement schemaRef) && schemaRef.GetString() == "#/components/schemas/ChannelNowPlayingResponseModel") .ShouldBeTrue(); JsonElement nowPlayingProperties = document.RootElement .GetProperty("components") .GetProperty("schemas") .GetProperty("ChannelNowPlayingResponseModel") .GetProperty("properties"); nowPlayingProperties.TryGetProperty("title", out _).ShouldBeTrue(); nowPlayingProperties.TryGetProperty("startUtc", out JsonElement startUtc).ShouldBeTrue(); nowPlayingProperties.TryGetProperty("finishUtc", out JsonElement finishUtc).ShouldBeTrue(); startUtc.GetProperty("type").GetString().ShouldBe("string"); startUtc.GetProperty("format").GetString().ShouldBe("date-time"); finishUtc.GetProperty("type").GetString().ShouldBe("string"); finishUtc.GetProperty("format").GetString().ShouldBe("date-time"); } [TestCase("/api/v1/channels/{id}", "get", "404")] [TestCase("/api/v1/channels", "post", "404")] [TestCase("/api/v1/channels", "post", "422")] [TestCase("/api/v1/channels/from-lineup", "post", "404")] [TestCase("/api/v1/channels/from-lineup", "post", "422")] [TestCase("/api/v1/channels/{id}", "put", "404")] [TestCase("/api/v1/channels/{id}", "put", "422")] [TestCase("/api/v1/channels/{id}", "delete", "404")] [TestCase("/api/v1/channels/{id}", "delete", "422")] [TestCase("/api/v1/channels/bulk/renumber", "post", "404")] [TestCase("/api/v1/channels/bulk/renumber", "post", "422")] [TestCase("/api/v1/channels/bulk/group", "post", "404")] [TestCase("/api/v1/channels/bulk/group", "post", "422")] [TestCase("/api/v1/channels/bulk/delete", "post", "404")] [TestCase("/api/v1/channels/bulk/delete", "post", "422")] [TestCase("/api/v1/channels/{id}/playout/reset", "post", "404")] [TestCase("/api/v1/channels/{id}/playout/reset", "post", "409")] [TestCase("/api/v1/channel-templates/default", "get", "404")] [TestCase("/api/v1/channel-templates/default/{id}", "put", "404")] [TestCase("/api/v1/channel-templates/default/{id}", "put", "422")] [TestCase("/api/v1/channel-templates/{id}", "get", "404")] [TestCase("/api/v1/channel-templates", "post", "404")] [TestCase("/api/v1/channel-templates", "post", "422")] [TestCase("/api/v1/channel-templates/{id}", "put", "404")] [TestCase("/api/v1/channel-templates/{id}", "put", "422")] [TestCase("/api/v1/channel-templates/{id}", "delete", "404")] [TestCase("/api/v1/channel-templates/{id}", "delete", "422")] [TestCase("/api/v1/collections/{id}", "get", "404")] [TestCase("/api/v1/collections/{id}/items", "get", "404")] [TestCase("/api/v1/collections", "post", "404")] [TestCase("/api/v1/collections", "post", "422")] [TestCase("/api/v1/collections/{id}", "put", "404")] [TestCase("/api/v1/collections/{id}", "put", "422")] [TestCase("/api/v1/collections/{id}", "delete", "404")] [TestCase("/api/v1/collections/{id}", "delete", "422")] [TestCase("/api/v1/collections/{id}/items", "post", "404")] [TestCase("/api/v1/collections/{id}/items", "post", "422")] [TestCase("/api/v1/collections/{id}/items/{mediaItemId}", "delete", "404")] [TestCase("/api/v1/collections/{id}/items/{mediaItemId}", "delete", "422")] [TestCase("/api/v1/smart-collections/{id}", "get", "404")] [TestCase("/api/v1/smart-collections", "post", "404")] [TestCase("/api/v1/smart-collections", "post", "422")] [TestCase("/api/v1/smart-collections/{id}", "put", "404")] [TestCase("/api/v1/smart-collections/{id}", "put", "422")] [TestCase("/api/v1/smart-collections/{id}", "delete", "404")] [TestCase("/api/v1/smart-collections/{id}", "delete", "422")] [TestCase("/api/v1/multi-collections/{id}", "get", "404")] [TestCase("/api/v1/multi-collections", "post", "404")] [TestCase("/api/v1/multi-collections", "post", "422")] [TestCase("/api/v1/multi-collections/{id}", "put", "404")] [TestCase("/api/v1/multi-collections/{id}", "put", "422")] [TestCase("/api/v1/multi-collections/{id}", "delete", "404")] [TestCase("/api/v1/multi-collections/{id}", "delete", "422")] [TestCase("/api/v1/playlists/groups", "post", "422")] [TestCase("/api/v1/playlists/groups/{id}", "put", "404")] [TestCase("/api/v1/playlists/groups/{id}", "put", "422")] [TestCase("/api/v1/playlists/groups/{id}", "delete", "404")] [TestCase("/api/v1/playlists/groups/{id}", "delete", "422")] [TestCase("/api/v1/playlists/{id}", "get", "404")] [TestCase("/api/v1/playlists/{id}/items", "get", "404")] [TestCase("/api/v1/playlists", "post", "422")] [TestCase("/api/v1/playlists/{id}", "put", "404")] [TestCase("/api/v1/playlists/{id}", "put", "422")] [TestCase("/api/v1/playlists/{id}", "delete", "404")] [TestCase("/api/v1/playlists/{id}", "delete", "422")] [TestCase("/api/v1/playlists/preview", "post", "422")] [TestCase("/api/v1/rerun-collections/{id}", "get", "404")] [TestCase("/api/v1/rerun-collections", "post", "404")] [TestCase("/api/v1/rerun-collections", "post", "422")] [TestCase("/api/v1/rerun-collections/{id}", "put", "404")] [TestCase("/api/v1/rerun-collections/{id}", "put", "422")] [TestCase("/api/v1/rerun-collections/{id}", "delete", "404")] [TestCase("/api/v1/rerun-collections/{id}", "delete", "422")] [TestCase("/api/v1/schedules/{id}", "get", "404")] [TestCase("/api/v1/schedules", "post", "404")] [TestCase("/api/v1/schedules", "post", "422")] [TestCase("/api/v1/schedules/{id}", "put", "404")] [TestCase("/api/v1/schedules/{id}", "put", "422")] [TestCase("/api/v1/schedules/{id}", "delete", "404")] [TestCase("/api/v1/schedules/{id}", "delete", "422")] [TestCase("/api/v1/schedules/{id}/items", "get", "404")] [TestCase("/api/v1/schedules/{id}/items", "post", "404")] [TestCase("/api/v1/schedules/{id}/items", "post", "422")] [TestCase("/api/v1/schedules/{id}/items", "put", "404")] [TestCase("/api/v1/schedules/{id}/items", "put", "422")] [TestCase("/api/v1/schedules/{id}/items/{itemId}", "delete", "404")] [TestCase("/api/v1/schedules/{id}/items/{itemId}", "delete", "422")] [TestCase("/api/v1/playouts/{id}", "get", "404")] [TestCase("/api/v1/playouts", "post", "404")] [TestCase("/api/v1/playouts", "post", "422")] [TestCase("/api/v1/playouts/{id}", "put", "404")] [TestCase("/api/v1/playouts/{id}", "put", "409")] [TestCase("/api/v1/playouts/{id}", "put", "422")] [TestCase("/api/v1/playouts/{id}", "delete", "404")] [TestCase("/api/v1/playouts/{id}", "delete", "409")] [TestCase("/api/v1/playouts/{id}", "delete", "422")] [TestCase("/api/v1/playouts/{id}/deco", "put", "409")] [TestCase("/api/v1/playouts/{id}/alternate-schedules", "put", "409")] [TestCase("/api/v1/playouts/{id}/templates", "put", "409")] [TestCase("/api/v1/playouts/{id}/items", "get", "404")] [TestCase("/api/v1/playouts/{id}/erase-items", "post", "404")] [TestCase("/api/v1/playouts/{id}/erase-items", "post", "409")] [TestCase("/api/v1/playouts/{id}/erase-items", "post", "422")] [TestCase("/api/v1/playouts/{id}/erase-items-and-history", "post", "404")] [TestCase("/api/v1/playouts/{id}/erase-items-and-history", "post", "409")] [TestCase("/api/v1/playouts/{id}/erase-items-and-history", "post", "422")] [TestCase("/api/v1/playouts/items/{id}/scheduling-context", "get", "404")] [TestCase("/api/v1/collections/{id}/custom-order", "put", "404")] [TestCase("/api/v1/collections/{id}/custom-order", "put", "422")] [TestCase("/api/v1/artwork/uploads", "post", "422")] [TestCase("/api/v1/ffmpeg/profiles/{id}", "get", "404")] [TestCase("/api/v1/ffmpeg/profiles", "post", "404")] [TestCase("/api/v1/ffmpeg/profiles", "post", "401")] [TestCase("/api/v1/ffmpeg/profiles", "post", "422")] [TestCase("/api/v1/ffmpeg/profiles/{id}", "put", "404")] [TestCase("/api/v1/ffmpeg/profiles/{id}", "put", "401")] [TestCase("/api/v1/ffmpeg/profiles/{id}", "put", "422")] [TestCase("/api/v1/ffmpeg/profiles/{id}", "delete", "404")] [TestCase("/api/v1/ffmpeg/profiles/{id}", "delete", "401")] [TestCase("/api/v1/ffmpeg/profiles/{id}", "delete", "422")] [TestCase("/api/v1/filler-presets/{id}", "get", "404")] [TestCase("/api/v1/filler-presets", "post", "401")] [TestCase("/api/v1/filler-presets", "post", "404")] [TestCase("/api/v1/filler-presets", "post", "422")] [TestCase("/api/v1/filler-presets/{id}", "put", "401")] [TestCase("/api/v1/filler-presets/{id}", "put", "404")] [TestCase("/api/v1/filler-presets/{id}", "put", "422")] [TestCase("/api/v1/filler-presets/{id}", "delete", "401")] [TestCase("/api/v1/filler-presets/{id}", "delete", "404")] [TestCase("/api/v1/filler-presets/{id}", "delete", "422")] [TestCase("/api/v1/watermarks/{id}", "get", "404")] [TestCase("/api/v1/watermarks", "post", "401")] [TestCase("/api/v1/watermarks", "post", "404")] [TestCase("/api/v1/watermarks", "post", "422")] [TestCase("/api/v1/watermarks/{id}", "put", "401")] [TestCase("/api/v1/watermarks/{id}", "put", "404")] [TestCase("/api/v1/watermarks/{id}", "put", "422")] [TestCase("/api/v1/watermarks/{id}", "delete", "401")] [TestCase("/api/v1/watermarks/{id}", "delete", "404")] [TestCase("/api/v1/watermarks/{id}", "delete", "422")] [TestCase("/api/v1/settings/ffmpeg", "put", "401")] [TestCase("/api/v1/settings/ffmpeg", "put", "422")] [TestCase("/api/v1/settings/playout", "put", "401")] [TestCase("/api/v1/settings/playout", "put", "422")] [TestCase("/api/v1/settings/xmltv", "put", "401")] [TestCase("/api/v1/settings/xmltv", "put", "422")] [TestCase("/api/v1/settings/scanner", "put", "401")] [TestCase("/api/v1/settings/scanner", "put", "422")] [TestCase("/api/v1/settings/logging", "put", "401")] [TestCase("/api/v1/settings/logging", "put", "422")] [TestCase("/api/v1/settings/ui", "put", "401")] [TestCase("/api/v1/settings/ui", "put", "422")] [TestCase("/api/v1/settings/hdhr", "put", "401")] [TestCase("/api/v1/settings/hdhr", "put", "422")] [TestCase("/api/v1/settings/resolutions", "post", "401")] [TestCase("/api/v1/settings/resolutions", "post", "422")] [TestCase("/api/v1/settings/resolutions/{id}", "delete", "401")] [TestCase("/api/v1/settings/resolutions/{id}", "delete", "404")] [TestCase("/api/v1/settings/resolutions/{id}", "delete", "422")] [TestCase("/api/v1/search", "get", "422")] [TestCase("/api/v1/media-items", "delete", "422")] [TestCase("/api/v1/trakt/lists/{id}", "get", "404")] [TestCase("/api/v1/trakt/lists", "post", "422")] [TestCase("/api/v1/trakt/lists", "post", "409")] [TestCase("/api/v1/trakt/lists/{id}/match", "post", "404")] [TestCase("/api/v1/trakt/lists/{id}/match", "post", "409")] [TestCase("/api/v1/trakt/lists/{id}", "delete", "404")] [TestCase("/api/v1/trakt/lists/{id}", "delete", "409")] [TestCase("/api/v1/trakt/lists/{id}", "put", "404")] [TestCase("/api/v1/trakt/lists/{id}", "put", "422")] [TestCase("/api/v1/troubleshoot/playback/subtitles/{mediaItemId}", "get", "404")] [TestCase("/api/v1/troubleshoot/playback/start", "post", "404")] [TestCase("/api/v1/troubleshoot/playback/start", "post", "409")] [TestCase("/api/v1/troubleshoot/playback/start", "post", "422")] [TestCase("/api/v1/libraries/{id}/scan-show", "post", "404")] [TestCase("/api/v1/libraries/{id}/scan", "post", "404")] [TestCase("/api/v1/libraries/{id}/scan", "post", "409")] [TestCase("/api/v1/libraries/{id}/scan", "post", "422")] public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses( string path, string method, string statusCode) { using JsonDocument document = JsonDocument.Parse(File.ReadAllText(FindOpenApiDocument())); JsonElement response = document.RootElement .GetProperty("paths") .GetProperty(path) .GetProperty(method) .GetProperty("responses") .GetProperty(statusCode); JsonElement content = response.GetProperty("content"); content.EnumerateObject().ShouldNotBeEmpty(); foreach (JsonProperty mediaType in content.EnumerateObject()) { string? schemaRef = mediaType.Value .GetProperty("schema") .GetProperty("$ref") .GetString(); schemaRef.ShouldBe("#/components/schemas/ProblemDetails", mediaType.Name); } } 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"); } }