using System.Text.RegularExpressions; using ErsatzTV.Tests.Support; using Microsoft.OpenApi; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Controllers; /// /// Pins the api.paging-zero-based contract onto the generated OpenAPI document (ersatztv#633). /// The spec is the contract REST consumers read — and what generated clients surface to their users — /// so a paging parameter that documents nothing forces every consumer to infer the base from /// default: 0. That is exactly the inference that cost ersatztv#487 a verification pass on the /// MCP side, where the description was present but wrong. The MCP wrapper is pinned the same way in /// ErsatzTV.Mcp.Tests.ToolCatalogTests; this is the API-side half. /// [TestFixture] public class OpenApiPagingContractTests { /// /// Every operation that pages. Named explicitly rather than discovered, because a test that only /// FILTERS on "declares pageNum" cannot see the endpoint that should page and does not — the /// defect escapes the filter and the test still passes green over a shrinking scope. That is not /// hypothetical: ersatztv#616 found two MCP tools doing precisely that. So the expected set is /// pinned here, and asserts the /// discovered set equals it in BOTH directions — a new paged endpoint fails until it is added /// (with descriptions), and an endpoint that silently drops paging fails too. /// private static readonly string[] PagedOperations = [ "GET /api/v1/channels/auto-tune/members", "GET /api/v1/collections/{id}/items", "GET /api/v1/library/browse", "GET /api/v1/logs", "GET /api/v1/multi-collections", "GET /api/v1/playouts", "GET /api/v1/playouts/{id}/blocks/{blockId}/history", "GET /api/v1/playouts/{id}/items", "GET /api/v1/rerun-collections", "GET /api/v1/search", "GET /api/v1/search/all-items", "GET /api/v1/trakt/lists" ]; private static OpenApiDocument _document = null!; [OneTimeSetUp] public async Task BuildDocument() => _document = await GeneratedOpenApiDocument.BuildV1Async(); [Test] public void Paged_Operations_Should_Be_Exactly_The_Pinned_Set() { List discovered = EnumerateOperations() .Where(op => ParameterNames(op.Operation).Overlaps(new[] { "pageNum", "pageSize" })) .Select(op => $"{op.Method} {op.Path}") .OrderBy(s => s, StringComparer.Ordinal) .ToList(); discovered.ShouldBe(PagedOperations.OrderBy(s => s, StringComparer.Ordinal).ToList()); } [Test] public void Every_Paged_Operation_Should_Declare_Both_Paging_Parameters() { foreach (string key in PagedOperations) { HashSet names = ParameterNames(Find(key)); names.ShouldContain("pageNum", $"{key} should declare pageNum"); names.ShouldContain("pageSize", $"{key} should declare pageSize"); } } [Test] public void Every_PageNum_Parameter_Should_Document_The_ZeroBased_Contract() { foreach (string key in PagedOperations) { string description = Description(key, "pageNum"); // The whole point of the record: a consumer must not have to infer the base from `default: 0`. description.ShouldContain("0-based", Case.Insensitive, $"{key} pageNum should say it is 0-based"); description.ShouldNotContain("1-based", Case.Insensitive, $"{key} pageNum must not claim 1-based"); } } [Test] public void Every_PageSize_Parameter_Should_Document_The_Cap_And_The_Effective_Offset() { foreach (string key in PagedOperations) { string description = Description(key, "pageSize"); // `api.paging-zero-based` is explicit that the cap is PER-ENDPOINT and must not be documented // as one number, and that the offset derives from the effective (capped) size — so an // over-large pageSize narrows the page without widening the offset. description.ShouldContain("capped at", Case.Insensitive, $"{key} pageSize should state its cap"); description.ShouldContain("this endpoint", Case.Insensitive, $"{key} pageSize cap should be scoped to the endpoint"); description.ShouldContain("effective", Case.Insensitive, $"{key} pageSize should explain the effective-size offset"); } } [Test] public void PageSize_Caps_Should_Match_The_Values_The_Controllers_Actually_Clamp_To() { // The caps genuinely differ per endpoint, which is why the record forbids documenting one number. // A description naming the wrong cap is worse than none — a wrong justification outlives a wrong // line — so pin each against the value its controller clamps to. var expectedCaps = new Dictionary(StringComparer.Ordinal) { ["GET /api/v1/channels/auto-tune/members"] = 200, ["GET /api/v1/collections/{id}/items"] = 100, ["GET /api/v1/library/browse"] = 100, ["GET /api/v1/logs"] = 100, ["GET /api/v1/multi-collections"] = 100, ["GET /api/v1/playouts"] = 100, ["GET /api/v1/playouts/{id}/blocks/{blockId}/history"] = 100, ["GET /api/v1/playouts/{id}/items"] = 100, ["GET /api/v1/rerun-collections"] = 100, ["GET /api/v1/search"] = 100, ["GET /api/v1/search/all-items"] = 1000, ["GET /api/v1/trakt/lists"] = 100 }; // Guard the guard: every pinned operation must carry an expected cap, so adding one above // without its cap here cannot quietly skip this assertion. expectedCaps.Keys.OrderBy(k => k, StringComparer.Ordinal) .ShouldBe(PagedOperations.OrderBy(k => k, StringComparer.Ordinal)); foreach ((string key, int cap) in expectedCaps) { // Enumerate EVERY cap claim in the description and require the set to be exactly one // number, the right one. Two weaker forms were rejected on the way here: // - ShouldContain("capped at 100") is satisfied by the string "capped at 1000", so a // cap-100 endpoint claiming 1000 passed — the very defect this test exists to catch. // - Matching one occurrence as a whole token ("capped at 100(?!\d)") fixes that, but // still passes a description that names a wrong cap somewhere ELSE in the sentence // and the right one later. Presence of a true claim is not absence of a false one. List claimedCaps = Regex .Matches(Description(key, "pageSize"), @"capped at (\d+)", RegexOptions.IgnoreCase) .Select(match => int.Parse(match.Groups[1].Value)) .ToList(); claimedCaps.ShouldBe([cap], $"{key} pageSize should make exactly one cap claim, of {cap}"); } } private static string Description(string key, string parameterName) { // Not `First(...)`: a missing parameter would throw "Sequence contains no matching element", // which names neither the endpoint nor the parameter and reads as a broken test rather than // the contract violation it is. IOpenApiParameter parameter = (Find(key).Parameters ?? []) .FirstOrDefault(p => string.Equals(p.Name, parameterName, StringComparison.Ordinal)) .ShouldNotBeNull($"{key} should declare a {parameterName} parameter"); string? description = parameter.Description; description.ShouldNotBeNullOrWhiteSpace($"{key} {parameterName} should carry a description"); return description!; } private static OpenApiOperation Find(string key) => EnumerateOperations() .Where(op => string.Equals($"{op.Method} {op.Path}", key, StringComparison.Ordinal)) .Select(op => op.Operation) .FirstOrDefault() .ShouldNotBeNull($"{key} should exist in the generated document"); private static HashSet ParameterNames(OpenApiOperation operation) => (operation.Parameters ?? []).Select(p => p.Name ?? string.Empty).ToHashSet(StringComparer.Ordinal); private static IEnumerable<(string Method, string Path, OpenApiOperation Operation)> EnumerateOperations() { foreach ((string path, IOpenApiPathItem item) in _document.Paths) { foreach ((HttpMethod method, OpenApiOperation operation) in item.Operations!) { yield return (method.Method.ToUpperInvariant(), path, operation); } } } }