docs(563): scope the required-member claim to the serializer, and name model validation as residue

The previous commit said a body omitting a `required` member "reaches the action" in production. That
overreaches what was measured: MVC adds an implicit required check for non-nullable reference types
(ErsatzTV.Core.Nullable has <Nullable>enable</Nullable>, and Startup configures no ApiBehaviorOptions,
so the [ApiController] automatic 400 is live), which would very likely reject that body before the
action. What is measured is the SERIALIZER: Newtonsoft deserializes it to a default, System.Text.Json
throws. The prose in the test, ApiJsonSettings, the record and docs/testing.md now stops there and puts
MVC model validation on the uncovered-wrapper list where it belongs.

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
This commit is contained in:
2026-09-05 09:25:11 +02:00
co-authored by Claude Fable 5.1
parent 16a6e2790f
commit 4bd5cf9e91
5 changed files with 31 additions and 24 deletions
@@ -33,9 +33,9 @@ namespace ErsatzTV.Tests.Controllers;
/// The hops this cannot host, named rather than implied: the <c>Cli.Wrap</c> launch of the user's
/// own program (exit code, timeout, stdout capture); the Kestrel/middleware/auth transport the
/// program calls back over; and MVC's binding <i>wrapper</i> — the input formatter's content-type
/// selection and malformed-body handling, and the <c>[ApiController]</c> automatic 400 a failed
/// bind produces before an action runs, since every test here hands an action an already-bound
/// object. The serializer <i>inside</i> that wrapper is not residue: request bodies are
/// selection and malformed-body handling, model validation (a non-nullable reference type picks
/// up an implicit required check there), and the <c>[ApiController]</c> automatic 400 either
/// produces before an action runs, since every test here hands an action an already-bound object. The serializer <i>inside</i> that wrapper is not residue: request bodies are
/// deserialized with <see cref="ApiJsonSettings" />, the same configuration <c>Startup</c> hands
/// to <c>AddNewtonsoftJson</c>, so the fixture is bound the way production binds it. That is not
/// a formality — Newtonsoft and System.Text.Json disagree on the DTOs' <c>required</c> members,
@@ -133,10 +133,11 @@ public class ScriptedScheduleControllerTests
{
// The replay above is only faithful while Bind uses MVC's own serializer, and "the two agree" is
// not a safe assumption to leave unstated: Newtonsoft has no notion of the C# `required` keyword,
// so a body omitting one binds to a default and reaches the action, where System.Text.Json
// rejects the body outright. This goes through Bind itself, which is what gives it teeth — the
// fixture's own bodies parse identically under either serializer, so only a body like this one
// can witness a swap.
// so a body omitting one deserializes to a default, where System.Text.Json rejects it outright.
// The claim is about the SERIALIZER only — what MVC's validation layer then does with such a
// body (non-nullable reference types pick up an implicit required check) is the uncovered
// wrapper, not this. Binding through Bind itself is what gives this teeth: the fixture's own
// bodies parse identically under either serializer, so only a body like this one witnesses a swap.
using JsonDocument document = JsonDocument.Parse(BodyMissingRequiredMember);
ContentCollection bound = Bind<ContentCollection>(document.RootElement).ShouldNotBeNull();
+1 -1
View File
@@ -8,7 +8,7 @@ namespace ErsatzTV.Serialization;
/// here, so a test can deserialize a request body exactly the way the production body binder does
/// instead of mirroring <see cref="Startup" />'s registration — a mirror drifts silently, and the
/// divergence is not cosmetic: Newtonsoft has no notion of the C# <c>required</c> keyword, so a body
/// omitting a required member binds to a default here where System.Text.Json would throw.
/// omitting a required member deserializes to a default here where System.Text.Json would throw.
/// </summary>
public static class ApiJsonSettings
{
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@ status: active
since: '2026-09-05'
supersedes: testing.scripted-playout-golden-deferred@2026-07-22
superseded-by: none
rule: 'Scripted playout is characterized at two in-process levels and no further. (1) `SchedulingEngineTests` drives the engine build API directly with substituted repositories — the same setup order `ScriptedPlayoutBuilder` uses (`WithPlayoutId`/`WithMode`/`WithSeed`/`BuildBetween`/`WithReferenceData`/`RestoreOrReset`, and `WithReferenceData` MUST precede the last two or `PlayoutReferenceData.PlayoutHistory` NREs). (2) `ScriptedScheduleControllerTests` replays a committed script fixture (`ErsatzTV.Tests/Controllers/Fixtures/scripted-build.json`) through the REAL `ScriptedScheduleController` + `ScriptedPlayoutBuilderService.MockSession` + `SchedulingEngine` and pins the resulting `PlayoutItem`s as a snapshot in the golden line format — so exactly ONE action-to-engine mapping exists in the repo, the production one. Fixture bodies in that replay are deserialized with `ApiJsonSettings`, the configuration `Startup` applies to `AddNewtonsoftJson`, invoked from the same function rather than mirrored: Newtonsoft and System.Text.Json disagree about the DTO `required` members, so a lookalike binder would pass a fixture the production binder would bind differently. The `Cli.Wrap` launch of the user-authored program (exit code, timeout, stdout capture), the Kestrel/middleware/`ApiAuthorizationFilter` transport it calls back over, and MVC model binding as a WRAPPER (the input formatter and the `[ApiController]` automatic 400 a failed bind produces before an action runs — the serializer inside it is covered, the wrapper around it is not) are NOT tested and are not scheduled to be; that residue is named below rather than argued away. The controller is a thin adapter, NOT a 1:1 pass-through: an unparseable playback order is a 400, an unparseable filler kind SILENTLY degrades to `FillerKind.None`, an unknown build id is a 404, and the engine no-progress `InvalidOperationException` is translated to a 400 — each pinned by a test, because a prose claim of equivalence decays.'
rule: 'Scripted playout is characterized at two in-process levels and no further. (1) `SchedulingEngineTests` drives the engine build API directly with substituted repositories — the same setup order `ScriptedPlayoutBuilder` uses (`WithPlayoutId`/`WithMode`/`WithSeed`/`BuildBetween`/`WithReferenceData`/`RestoreOrReset`, and `WithReferenceData` MUST precede the last two or `PlayoutReferenceData.PlayoutHistory` NREs). (2) `ScriptedScheduleControllerTests` replays a committed script fixture (`ErsatzTV.Tests/Controllers/Fixtures/scripted-build.json`) through the REAL `ScriptedScheduleController` + `ScriptedPlayoutBuilderService.MockSession` + `SchedulingEngine` and pins the resulting `PlayoutItem`s as a snapshot in the golden line format — so exactly ONE action-to-engine mapping exists in the repo, the production one. Fixture bodies in that replay are deserialized with `ApiJsonSettings`, the configuration `Startup` applies to `AddNewtonsoftJson`, invoked from the same function rather than mirrored: Newtonsoft and System.Text.Json disagree about the DTO `required` members, so a lookalike binder would pass a fixture the production binder would bind differently. The `Cli.Wrap` launch of the user-authored program (exit code, timeout, stdout capture), the Kestrel/middleware/`ApiAuthorizationFilter` transport it calls back over, and MVC model binding as a WRAPPER (the input formatter, model validation and the `[ApiController]` automatic 400 either produces before an action runs — the serializer inside that wrapper is covered, the wrapper around it is not) are NOT tested and are not scheduled to be; that residue is named below rather than argued away. The controller is a thin adapter, NOT a 1:1 pass-through: an unparseable playback order is a 400, an unparseable filler kind SILENTLY degrades to `FillerKind.None`, an unknown build id is a 404, and the engine no-progress `InvalidOperationException` is translated to a 400 — each pinned by a test, because a prose claim of equivalence decays.'
signals: 'why is there no scripted golden; scripted integration harness; scripted transport vs engine; SchedulingEngine is in-process testable; Cli.Wrap external process is transport only; shell-out scoped out; MockSession seam; in-process stand-in for a scripted script; engine build API characterization; committed script fixture replay; ScriptedScheduleController is an adapter not a pass-through; scripted filler kind silently falls back to None; scripted playout no-progress halt maps to 400; scripted build 401 blind spot; EnumeratorForContent · paths: `ErsatzTV.Core.Tests/Scheduling/Engine/SchedulingEngineTests.cs`, `ErsatzTV.Tests/Controllers/ScriptedScheduleControllerTests.cs`, `ErsatzTV.Tests/Controllers/Fixtures/scripted-build.json`, `ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs`, `ErsatzTV/Controllers/Api/ScriptedScheduleController.cs`, `ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs`, `ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs` · issues: #563, #381, #395, #163'
mechanics: docs/testing.md → "Scripted playout coverage" and "Golden-file nets"
---
@@ -41,13 +41,16 @@ wrong — the four adapter mappings above are real behaviour that only a test ca
**The residue, named.** Uncovered: process launch, exit code and timeout handling; Kestrel plus the
forwarded-headers / compression / SPA-hosting middleware; MVC model binding as a *wrapper* — the input
formatter (content-type selection, malformed-body handling) and the `[ApiController]` automatic 400 a
failed bind produces before an action runs, since every test hands an action an already-bound object;
and `ApiAuthorizationFilter`. The *serializer* inside that wrapper is deliberately not on this list:
formatter (content-type selection, malformed-body handling), model validation (a non-nullable reference
type picks up an implicit required check there) and the `[ApiController]` automatic 400 either produces
before an action runs, since every test hands an action an already-bound object; and
`ApiAuthorizationFilter`. The *serializer* inside that wrapper is deliberately not on this list:
`ScriptedScheduleControllerTests` binds with `ApiJsonSettings` itself, and
`Production_Body_Binder_Ignores_Required_Members` pins the Newtonsoft semantics that choice buys — a
body omitting a `required` member reaches the action with a default rather than being rejected, which is
what a System.Text.Json stand-in would silently change.
body omitting a `required` member deserializes to a default rather than failing, which a System.Text.Json
stand-in would silently turn into a rejection. That is a claim about the SERIALIZER and is scoped to it:
what the wrapper then does with such a body — non-nullable reference types pick up an implicit required
check in MVC validation — is on the uncovered list above, not measured here.
`ApiAuthorizationFilter` on that list is not hypothetical — `ScriptedScheduleController` carries no
`[SkipApiAuthorization]` while
+12 -9
View File
@@ -82,21 +82,24 @@ create a second action-to-engine mapping — the thing the single-mapping design
program — exit code, the `PlayoutScriptedScheduleTimeoutSeconds` timeout, stdout capture; Kestrel and the
`Startup` middleware, including the host/`Settings.UiPort` check that 404s a foreign request;
`ApiAuthorizationFilter`, which fail-closes every mutating verb for an endpoint without
`[SkipApiAuthorization]`; and MVC model binding as a *wrapper* — the input formatter and the
`[ApiController]` automatic 400 a failed bind produces before the action runs, since the replay hands
each action an already-bound object. Hosting the real `Startup` would drag in the whole DI graph, a
`[SkipApiAuthorization]`; and MVC model binding as a *wrapper* — the input formatter, model validation
(a non-nullable reference type picks up an implicit required check there) and the `[ApiController]`
automatic 400 either produces before the action runs, since the replay hands each action an
already-bound object. Hosting the real `Startup` would drag in the whole DI graph, a
hand-rolled minimal host would test a transport the product does not have, and the script is
user-authored by definition — so any committed script is a stand-in either way. The decision record
names the concrete blind spot this leaves.
The *serializer* inside that binding wrapper is covered rather than scoped out, and the distinction is
load-bearing: production binds `/api/*` bodies with Newtonsoft (`Startup` → `AddNewtonsoftJson`), which
has no notion of the C# `required` keyword, so a body omitting a required member binds to a default and
reaches the action where System.Text.Json would reject it outright. The replay therefore deserializes
fixture bodies through `ErsatzTV.Serialization.ApiJsonSettings` — the same function `Startup` calls, not
a mirror of it — and `Production_Body_Binder_Ignores_Required_Members` pins both halves of that
divergence, so a swap to a lookalike serializer reddens instead of passing on bodies the two happen to
agree about. `OpenApiSerializerContractTests` reads the same settings from the same place.
has no notion of the C# `required` keyword, so a body omitting a required member deserializes to a
default where System.Text.Json rejects it outright. That is a statement about the serializer and stops
there — what MVC validation then does with such a body belongs to the wrapper named above. The replay
deserializes fixture bodies through `ErsatzTV.Serialization.ApiJsonSettings`, the same function
`Startup` calls rather than a mirror of it, and `Production_Body_Binder_Ignores_Required_Members` pins
both halves of the divergence through the replay's own bind helper — so a swap to a lookalike serializer
reddens instead of passing on the fixture bodies the two happen to agree about.
`OpenApiSerializerContractTests` reads the same settings from the same place.
## Timezone independence