Files
ersatztv/ErsatzTV.Tests/Controllers/OpenApiPagingContractTests.cs
T
timothy 33e9abdd20
Review verdict / Set review-verdict status (pull_request) Successful in 4s
review-verdict/h10 Review-verdict: MERGEABLE @ 33e9abd
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 18s
PR Gates / decisions lifecycle (pull_request) Successful in 24s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m46s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 19s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 5m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m13s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m54s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(633): assert the cap set, not the presence of one true cap claim
Round-3 review finding, and a correction to what the previous commit claimed.

That commit said the regex matched the cap "as a whole token" and called the result
"exact". The whole-token part was true and did fix the 100-within-1000 substring hole.
"Exact" was not: `capped at 100(?!\d)` asks only whether a correct claim is PRESENT,
which is not the same as asking whether an incorrect one is ABSENT. A description
reading "not capped at 1000 for this endpoint; capped at 100 …" satisfied it while
publishing a wrong number to every consumer.

Enumerate every `capped at <n>` in the description instead and require the set to be
exactly one number, the right one. Mutation-verified on the constructed case: /logs
naming both 1000 and 100 now reddens the test, where it passed under the previous form.

This is the third round on this one assertion, and each round found the previous fix's
blind spot rather than a fresh mistake — the failure mode was consistently "the new
check tests presence of the right thing, not absence of the wrong thing."

Note on verification: the reviewer could not run the suite (its sandbox could not
create a temp dir, and a direct VSTest invocation could not bind its IPC socket), so it
explicitly flagged the 1905/0 result as unverified rather than trusting it. That figure
comes from my own run in this worktree, re-run after this change, and CI is the
independent confirmation.

Refs #633

Decisions-Edit: yes
2026-07-26 11:31:11 +02:00

184 lines
8.7 KiB
C#

using System.Text.RegularExpressions;
using ErsatzTV.Tests.Support;
using Microsoft.OpenApi;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
/// <summary>
/// Pins the <c>api.paging-zero-based</c> 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
/// <c>default: 0</c>. 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
/// <c>ErsatzTV.Mcp.Tests.ToolCatalogTests</c>; this is the API-side half.
/// </summary>
[TestFixture]
public class OpenApiPagingContractTests
{
/// <summary>
/// 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 <see cref="Paged_Operations_Should_Be_Exactly_The_Pinned_Set" /> 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.
/// </summary>
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<string> 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<string> 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<string, int>(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<int> 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<string> 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);
}
}
}
}