test(563): bind the fixture with the production body binder, and name the wrapper that is left
The docstring, the decision record and docs/testing.md all claimed the replay covered everything
except two hops. MVC model binding was a third: production binds /api/* bodies with Newtonsoft
(Startup -> AddNewtonsoftJson -> CustomContractResolver + StringEnumConverter) while the replay
deserialized with System.Text.Json. Measured on this tree: for the fixture's own bodies the two
agree, but for a body omitting the `required` member "collection" they diverge -- System.Text.Json
throws, Newtonsoft binds Collection = null and the action runs. So the fixture's stated purpose
("field names and casing match what the HTTP body binder accepts") was asserted by nothing, and a
fixture production would bind differently could still go green.
Rather than only widening the residue list, bind the way production binds. The registration moves
into ErsatzTV/Serialization/ApiJsonSettings.cs, Startup applies it from there, and both
OpenApiSerializerContractTests (which had its own mirror of the settings) and the scripted replay
now call that same function -- one definition, no copies to drift.
Production_Body_Binder_Ignores_Required_Members asserts both halves of the divergence THROUGH the
replay's own Bind helper, so pointing the replayer at another serializer reddens; the fixture's own
bodies cannot witness that swap.
The residue is now named honestly in all four places: the binding WRAPPER (input formatter, the
[ApiController] automatic 400 before an action runs) is uncovered, the serializer inside it is not.
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:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"description": "Committed stand-in for a user-authored scripted schedule: the ordered sequence of calls such a script makes against /api/v1/scripted/playout/build/{buildId}, replayed through the real ScriptedScheduleController by ScriptedScheduleControllerTests. Field names and casing match what the HTTP body binder accepts. Deliberately avoids wait_until / pad_until / pad_to_next (local day and time-of-day) and shuffle order, which would make the pinned snapshot machine-timezone- or seed-dependent.",
|
||||
"description": "Committed stand-in for a user-authored scripted schedule: the ordered sequence of calls such a script makes against /api/v1/scripted/playout/build/{buildId}, replayed through the real ScriptedScheduleController by ScriptedScheduleControllerTests. Field names and casing match what the HTTP body binder accepts \u2014 the replay deserializes each body with ApiJsonSettings, the configuration Startup applies to AddNewtonsoftJson. Deliberately avoids wait_until / pad_until / pad_to_next (local day and time-of-day) and shuffle order, which would make the pinned snapshot machine-timezone- or seed-dependent.",
|
||||
"script": [
|
||||
{
|
||||
"action": "add_collection",
|
||||
|
||||
@@ -24,14 +24,9 @@ namespace ErsatzTV.Tests.Controllers;
|
||||
[TestFixture]
|
||||
public class OpenApiSerializerContractTests
|
||||
{
|
||||
// Mirrors Startup.ConfigureServices -> AddNewtonsoftJson exactly.
|
||||
private static readonly JsonSerializerSettings RuntimeSettings = new()
|
||||
{
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
|
||||
ContractResolver = new CustomContractResolver(),
|
||||
Converters = { new StringEnumConverter() }
|
||||
};
|
||||
// The configuration Startup.ConfigureServices -> AddNewtonsoftJson applies, from the same function
|
||||
// rather than a mirror of it: a mirror drifts silently and this test would keep passing.
|
||||
private static readonly JsonSerializerSettings RuntimeSettings = ApiJsonSettings.Create();
|
||||
|
||||
private static IEnumerable<TestCaseData> Cases()
|
||||
{
|
||||
|
||||
@@ -11,9 +11,11 @@ using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling.Engine;
|
||||
using ErsatzTV.Core.Scheduling.ScriptedScheduling;
|
||||
using ErsatzTV.Serialization;
|
||||
using LanguageExt;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
@@ -28,11 +30,18 @@ namespace ErsatzTV.Tests.Controllers;
|
||||
/// snapshot in the same line format the playout goldens use. The engine is registered under a fixed build
|
||||
/// id via <see cref="ScriptedPlayoutBuilderService.MockSession" />, the seam that exists for exactly this.
|
||||
/// <para>
|
||||
/// This covers everything the production pipeline does EXCEPT the two hops it cannot host: the
|
||||
/// <c>Cli.Wrap</c> launch of the user's own program (exit code, timeout, stdout capture) and the
|
||||
/// Kestrel/middleware/auth transport the program calls back over. Those are permanently out of the
|
||||
/// automated suite; the reasoning and the named residue are in docs/testing.md → "Scripted playout
|
||||
/// coverage" and the decision record <c>testing.scripted-engine-in-process-net</c>.
|
||||
/// 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
|
||||
/// 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,
|
||||
/// which <see cref="Production_Body_Binder_Ignores_Required_Members" /> pins. The reasoning for
|
||||
/// the scope-out is in docs/testing.md → "Scripted playout coverage" and the decision record
|
||||
/// <c>testing.scripted-engine-in-process-net</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deviation from the three golden-file nets (docs/contributing.md §10): the expected output is a
|
||||
@@ -50,6 +59,12 @@ public class ScriptedScheduleControllerTests
|
||||
private static readonly Guid BuildId = Guid.Parse("00000000-0000-0000-0000-000000000563");
|
||||
private static readonly DateTimeOffset Start = new(2026, 1, 15, 6, 0, 0, TimeSpan.Zero);
|
||||
|
||||
// What Startup hands to AddNewtonsoftJson, from the same function rather than a mirror of it.
|
||||
private static readonly JsonSerializerSettings BodyBinderSettings = ApiJsonSettings.Create();
|
||||
|
||||
// A body that omits the `required` member "collection"; the two serializers disagree about it.
|
||||
private const string BodyMissingRequiredMember = """{"key":"content","order":"chronological"}""";
|
||||
|
||||
// Every action the fixture is expected to exercise. A fixture edit that drops one must fail loudly
|
||||
// rather than quietly shrinking what the snapshot covers.
|
||||
private static readonly string[] ExpectedActions =
|
||||
@@ -113,6 +128,28 @@ public class ScriptedScheduleControllerTests
|
||||
items[6].Finish.ShouldBe(new DateTime(2026, 1, 15, 10, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Production_Body_Binder_Ignores_Required_Members()
|
||||
{
|
||||
// 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.
|
||||
using JsonDocument document = JsonDocument.Parse(BodyMissingRequiredMember);
|
||||
|
||||
ContentCollection bound = Bind<ContentCollection>(document.RootElement).ShouldNotBeNull();
|
||||
bound.Key.ShouldBe(ContentKey);
|
||||
bound.Collection.ShouldBeNull();
|
||||
|
||||
// the negative control: the same body under the serializer a swap would reach for
|
||||
Should.Throw<System.Text.Json.JsonException>(
|
||||
() => System.Text.Json.JsonSerializer.Deserialize<ContentCollection>(
|
||||
BodyMissingRequiredMember,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Unknown_Build_Id_Returns_404()
|
||||
{
|
||||
@@ -248,7 +285,6 @@ public class ScriptedScheduleControllerTests
|
||||
// asserting a 200 each time. Returns the actions actually replayed, so the caller can assert coverage.
|
||||
private static async Task<List<string>> ReplayFixture(ScriptedScheduleController controller)
|
||||
{
|
||||
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
string json = await File.ReadAllTextAsync(Path.Combine(FixtureDir(), "scripted-build.json"));
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(json);
|
||||
@@ -265,26 +301,26 @@ public class ScriptedScheduleControllerTests
|
||||
ShouldBeOk(
|
||||
await controller.AddCollection(
|
||||
BuildId,
|
||||
Bind<ContentCollection>(body, options),
|
||||
Bind<ContentCollection>(body),
|
||||
CancellationToken.None));
|
||||
break;
|
||||
case "start_epg_group":
|
||||
ShouldBeOk(controller.StartEpgGroup(BuildId, Bind<ControlStartEpgGroup>(body, options)));
|
||||
ShouldBeOk(controller.StartEpgGroup(BuildId, Bind<ControlStartEpgGroup>(body)));
|
||||
break;
|
||||
case "stop_epg_group":
|
||||
ShouldBeOk(controller.StopEpgGroup(BuildId));
|
||||
break;
|
||||
case "add_count":
|
||||
ShouldBeOkContext(controller.AddCount(BuildId, Bind<PlayoutCount>(body, options)));
|
||||
ShouldBeOkContext(controller.AddCount(BuildId, Bind<PlayoutCount>(body)));
|
||||
break;
|
||||
case "add_all":
|
||||
ShouldBeOkContext(controller.AddAll(BuildId, Bind<ContentAll>(body, options)));
|
||||
ShouldBeOkContext(controller.AddAll(BuildId, Bind<ContentAll>(body)));
|
||||
break;
|
||||
case "add_duration":
|
||||
ShouldBeOkContext(controller.AddDuration(BuildId, Bind<PlayoutDuration>(body, options)));
|
||||
ShouldBeOkContext(controller.AddDuration(BuildId, Bind<PlayoutDuration>(body)));
|
||||
break;
|
||||
case "pad_until_exact":
|
||||
ShouldBeOkContext(controller.PadUntilExact(BuildId, Bind<PlayoutPadUntilExact>(body, options)));
|
||||
ShouldBeOkContext(controller.PadUntilExact(BuildId, Bind<PlayoutPadUntilExact>(body)));
|
||||
break;
|
||||
default:
|
||||
Assert.Fail($"Fixture uses action '{action}', which the replayer does not implement.");
|
||||
@@ -297,10 +333,13 @@ public class ScriptedScheduleControllerTests
|
||||
return replayed;
|
||||
}
|
||||
|
||||
private static T Bind<T>(JsonElement body, JsonSerializerOptions options)
|
||||
// Deserializes a fixture body with the SAME configuration the production body binder uses
|
||||
// (Startup -> AddNewtonsoftJson -> ApiJsonSettings), so the fixture's field names and casing are
|
||||
// asserted against the serializer MVC actually runs rather than a lookalike that agrees by luck.
|
||||
private static T Bind<T>(JsonElement body)
|
||||
{
|
||||
body.ValueKind.ShouldBe(JsonValueKind.Object);
|
||||
return body.Deserialize<T>(options);
|
||||
return JsonConvert.DeserializeObject<T>(body.GetRawText(), BodyBinderSettings);
|
||||
}
|
||||
|
||||
private static void ShouldBeOk(IActionResult result) => result.ShouldBeOfType<OkResult>();
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
|
||||
namespace ErsatzTV.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// The Newtonsoft settings the MVC JSON formatters read and write `/api/*` bodies with. Defined once,
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class ApiJsonSettings
|
||||
{
|
||||
/// <summary>Applies the production configuration to an existing settings object (the MVC path).</summary>
|
||||
public static JsonSerializerSettings Apply(JsonSerializerSettings settings)
|
||||
{
|
||||
settings.NullValueHandling = NullValueHandling.Ignore;
|
||||
settings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
|
||||
settings.ContractResolver = new CustomContractResolver();
|
||||
settings.Converters.Add(new StringEnumConverter());
|
||||
return settings;
|
||||
}
|
||||
|
||||
/// <summary>A standalone serializer configured the way MVC's is.</summary>
|
||||
public static JsonSerializerSettings Create() => Apply(new JsonSerializerSettings());
|
||||
}
|
||||
+3
-8
@@ -104,7 +104,6 @@ using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.IO;
|
||||
using Microsoft.OpenApi;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using Refit;
|
||||
using Scalar.AspNetCore;
|
||||
using Serilog;
|
||||
@@ -503,13 +502,9 @@ public class Startup
|
||||
options.OutputFormatters.Insert(0, new HdhrJsonOutputFormatter());
|
||||
options.Filters.AddService<ApiAuthorizationFilter>();
|
||||
})
|
||||
.AddNewtonsoftJson(opt =>
|
||||
{
|
||||
opt.SerializerSettings.NullValueHandling = NullValueHandling.Ignore;
|
||||
opt.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
|
||||
opt.SerializerSettings.ContractResolver = new CustomContractResolver();
|
||||
opt.SerializerSettings.Converters.Add(new StringEnumConverter());
|
||||
});
|
||||
// ApiJsonSettings rather than an inline lambda: the request-body binder's semantics are asserted
|
||||
// by ScriptedScheduleControllerTests, which has to configure the SAME object, not a mirror of it.
|
||||
.AddNewtonsoftJson(opt => ApiJsonSettings.Apply(opt.SerializerSettings));
|
||||
|
||||
services.AddScoped(_ => new ConditionalIptvAuthorizeFilter("JwtOnlyScheme"));
|
||||
|
||||
|
||||
@@ -641,7 +641,11 @@ Runtime `/api/*` JSON is serialized by **Newtonsoft** (`AddNewtonsoftJson` in `S
|
||||
`ErsatzTV/Serialization/CustomContractResolver.cs` → `CustomNamingStrategy` (camelCase **plus** a
|
||||
special case mapping any `FFmpegProfileId` member to `"ffmpegProfileId"`, and honoring any
|
||||
`[JsonProperty("...")]` attribute, e.g. `ChannelResponseModel.FFmpegProfile` →
|
||||
`[JsonProperty("ffmpegProfile")]`). The OpenAPI document, however, is generated from
|
||||
`[JsonProperty("ffmpegProfile")]`). The settings themselves live in
|
||||
`ErsatzTV/Serialization/ApiJsonSettings.cs`, which `Startup` applies — call it from a test that needs to
|
||||
serialize or bind the way the runtime does rather than restating the settings, which drifts silently
|
||||
(`OpenApiSerializerContractTests`, `ScriptedScheduleControllerTests`). The OpenAPI document, however, is
|
||||
generated from
|
||||
**System.Text.Json** metadata, whose camelCase can differ (it emitted `fFmpegProfileId` /
|
||||
`fFmpegProfile`). That drift silently gave the SPA the wrong key to read (issue #198).
|
||||
|
||||
|
||||
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. The `Cli.Wrap` launch of the user-authored program (exit code, timeout, stdout capture) and the Kestrel/middleware/`ApiAuthorizationFilter` transport it calls back over 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 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.'
|
||||
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"
|
||||
---
|
||||
@@ -36,11 +36,21 @@ wrong — the four adapter mappings above are real behaviour that only a test ca
|
||||
`Cli.Wrap` timeout nested inside CI's `--blame-hang-timeout 2m` — a new flake class on a shared runner.
|
||||
4. *The stand-in loses nothing the harness would have added at the engine level.* Replaying the fixture
|
||||
through the real controller and the real service keeps a single action-to-engine mapping under test;
|
||||
only the two transport hops are absent.
|
||||
what is absent is the two transport hops plus MVC binding as a wrapper, and the wrapper is the shallow
|
||||
half — the serializer inside it is configured from `ApiJsonSettings`, the production object itself.
|
||||
|
||||
**The residue, named.** Uncovered: process launch, exit code and timeout handling; Kestrel plus the
|
||||
forwarded-headers / compression / SPA-hosting middleware; and `ApiAuthorizationFilter`. That last one is
|
||||
not hypothetical — `ScriptedScheduleController` carries no `[SkipApiAuthorization]` while
|
||||
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:
|
||||
`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.
|
||||
|
||||
`ApiAuthorizationFilter` on that list is not hypothetical — `ScriptedScheduleController` carries no
|
||||
`[SkipApiAuthorization]` while
|
||||
`ScriptedPlayoutBuilder` passes the script only host, build id and mode, with no `X-Api-Key`, and
|
||||
`entrypoint.py` builds its client with no credential. Whether a scripted build therefore 401s on its first
|
||||
POST in this fork is unverified against a live instance and is tracked separately; it is recorded here as
|
||||
|
||||
+17
-6
@@ -71,7 +71,7 @@ at all (ersatztv#563, decision `testing.scripted-engine-in-process-net`).
|
||||
| Where | Covers |
|
||||
| --- | --- |
|
||||
| `ErsatzTV.Core.Tests/Scheduling/Engine/SchedulingEngineTests.cs` | The engine build API a script drives: `AddCollection`, `AddCount`, `AddAll`, `AddDuration` (stop-before-end and trim), `PadUntilExact`, EPG guide-group locking, per-item `PlayoutHistory`, the `IsDone` no-progress halt and its reset, and the `GetAnchor`/`RestoreOrReset` round-trip a Continue build resumes from. Substituted repositories, no database. |
|
||||
| `ErsatzTV.Tests/Controllers/ScriptedScheduleControllerTests.cs` | A committed script fixture (`Controllers/Fixtures/scripted-build.json`) replayed through the real `ScriptedScheduleController` + `ScriptedPlayoutBuilderService.MockSession` + `SchedulingEngine`, with a pinned 13-item snapshot in the same line format the playout goldens use, plus the four adapter mappings: 404 on an unknown build id, 400 on an unparseable playback order, a silent fall back to `FillerKind.None` on an unparseable filler kind, and the engine's no-progress `InvalidOperationException` translated to a 400. |
|
||||
| `ErsatzTV.Tests/Controllers/ScriptedScheduleControllerTests.cs` | A committed script fixture (`Controllers/Fixtures/scripted-build.json`), bound with the production body-binder configuration (`ApiJsonSettings`) and replayed through the real `ScriptedScheduleController` + `ScriptedPlayoutBuilderService.MockSession` + `SchedulingEngine`, with a pinned 13-item snapshot in the same line format the playout goldens use, plus the four adapter mappings: 404 on an unknown build id, 400 on an unparseable playback order, a silent fall back to `FillerKind.None` on an unparseable filler kind, and the engine's no-progress `InvalidOperationException` translated to a 400. |
|
||||
| `ErsatzTV.Core.Tests/Scheduling/ContentEnumeratorBuilderTests.cs` | The enumerator-construction helper the Scripted and Sequential/YAML engines share (ersatztv#395). |
|
||||
|
||||
The expected snapshot is a string constant in the test rather than a fourth golden file: the golden
|
||||
@@ -80,12 +80,23 @@ create a second action-to-engine mapping — the thing the single-mapping design
|
||||
|
||||
**Not covered, and not scheduled to be** (measured 2026-09-05): the `Cli.Wrap` launch of the user's own
|
||||
program — exit code, the `PlayoutScriptedScheduleTimeoutSeconds` timeout, stdout capture; Kestrel and the
|
||||
`Startup` middleware, including the host/`Settings.UiPort` check that 404s a foreign request; and
|
||||
`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]`. 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.
|
||||
`[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
|
||||
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.
|
||||
|
||||
## Timezone independence
|
||||
|
||||
|
||||
Reference in New Issue
Block a user