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:
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user