Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 10m54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 7m34s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m59s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 1m27s
The three recurrence arrays are read CONJUNCTIVELY by AlternateScheduleSelector.GetScheduleForDate, so an empty set matches no date. `?? []` on an omitted array therefore returned HTTP 200 while storing an alternate-schedule or template item that could never apply, silently -- while the read side (#823) already read a NULL column as the All*() sets. Absent and explicitly-empty are two different requests and get two answers: ABSENT (missing, or explicit null) normalizes to AlternateScheduleSelector.All*(), the same symbols the read side substitutes; EXPLICIT [] is rejected with a 422 naming the consequence, via RecurrenceSetBounds called from both replace handlers. The rejection lives in the handlers, not the controller, because api.ffmpeg-profile-numeric-bounds' "accept an UNCHANGED bad value" rule binds hardest here: both PUT paths are whole-list replaces, so rejecting a pre-existing empty set would make every OTHER item in the list uneditable. That comparison needs the stored row. The validated set is derived from `incoming`, so the highest-Index catch-all -- whose recurrence the handler discards -- is excluded by construction. Verified: full ErsatzTV.Tests suite green; three mutation proofs with disjoint reddened sets; live-E2E against a real instance confirmed an OMITTED property round-trips as unrestricted (the Newtonsoft missing-property chain unit tests cannot reach), an explicit [] returns the 422, and [] on the catch-all is accepted. Cross-family cold review BLOCKED the first implementation with 3 findings, all real and all fixed; re-review returned MERGEABLE. Follow-up #894 filed: the SPA can still build the empty state the server rejects. fixes #880 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
75 lines
3.7 KiB
C#
75 lines
3.7 KiB
C#
using ErsatzTV.Core;
|
|
|
|
namespace ErsatzTV.Application.Scheduling;
|
|
|
|
/// <summary>
|
|
/// Validates the three recurrence sets shared by <c>ProgramScheduleAlternate</c> and
|
|
/// <c>PlayoutTemplate</c> (ersatztv#880). One validator called from BOTH replace handlers, mirroring
|
|
/// <c>FFmpegProfileBounds</c> — the exemplar for `api.ffmpeg-profile-numeric-bounds`, whose shape this
|
|
/// follows deliberately.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// An EMPTY set is rejected because the three are read CONJUNCTIVELY by
|
|
/// <c>AlternateScheduleSelector.GetScheduleForDate</c> — a miss on any one continues to the next
|
|
/// item — so an empty one matches NO date and stores an item that can never apply. Rejecting
|
|
/// rather than substituting is the point: accept-then-rewrite would make an explicit `[]`
|
|
/// indistinguishable from an omitted field, which is the very collapse this issue removed.
|
|
/// </para>
|
|
/// <para>
|
|
/// An UNCHANGED empty set that the row ALREADY holds is let through. Both PUT paths are
|
|
/// whole-list replaces, so a hard rejection would make every OTHER item in the playout
|
|
/// uneditable over a row the operator never touched — the same reason
|
|
/// `api.ffmpeg-profile-numeric-bounds` rejects only a NEWLY submitted out-of-range value. A row
|
|
/// whose stored set is NULL is NOT exempt: null means unrestricted, so submitting `[]` for it is
|
|
/// a new emptying, not an unchanged legacy value.
|
|
/// </para>
|
|
/// <para>
|
|
/// This runs on the COMMAND, after the request records have normalized an ABSENT array to the
|
|
/// All*() sets, so an empty set reaching here is one a caller sent EXPLICITLY. That also means a
|
|
/// direct (non-HTTP) caller is held to the same rule rather than being able to write a dead row.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class RecurrenceSetBounds
|
|
{
|
|
public static Option<BaseError> Validate(
|
|
ICollection<DayOfWeek> daysOfWeek,
|
|
ICollection<int> daysOfMonth,
|
|
ICollection<int> monthsOfYear,
|
|
ICollection<DayOfWeek> storedDaysOfWeek,
|
|
ICollection<int> storedDaysOfMonth,
|
|
ICollection<int> storedMonthsOfYear)
|
|
{
|
|
if (IsNewlyEmpty(daysOfWeek, storedDaysOfWeek))
|
|
{
|
|
return Some(BaseError.New(Message("DaysOfWeek", "no day of the week")));
|
|
}
|
|
|
|
if (IsNewlyEmpty(daysOfMonth, storedDaysOfMonth))
|
|
{
|
|
return Some(BaseError.New(Message("DaysOfMonth", "no day of the month")));
|
|
}
|
|
|
|
if (IsNewlyEmpty(monthsOfYear, storedMonthsOfYear))
|
|
{
|
|
return Some(BaseError.New(Message("MonthsOfYear", "no month")));
|
|
}
|
|
|
|
return Option<BaseError>.None;
|
|
}
|
|
|
|
// "send null" rather than "omit the property": all three are listed in the schema's `required` array
|
|
// in v1.json (they are nullable, not optional), so a client generated from the published contract
|
|
// cannot omit them. Omitting also works at runtime -- Newtonsoft maps a missing property and an
|
|
// explicit null to the same thing -- but naming only that would tell a conforming client to send
|
|
// something its own schema forbids.
|
|
private static string Message(string field, string consequence) =>
|
|
$"[{field}] must not be empty; an empty set matches {consequence}, so the item would never apply. " +
|
|
"Send null to leave it unrestricted";
|
|
|
|
// A new item (no stored row) has `stored` null, so an empty set is newly empty and is rejected.
|
|
// Only a stored set that is ITSELF already empty exempts an empty submission.
|
|
private static bool IsNewlyEmpty<T>(ICollection<T> submitted, ICollection<T> stored) =>
|
|
submitted is { Count: 0 } && stored is not { Count: 0 };
|
|
}
|