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);
}
}
}
}