Files
ersatztv/ErsatzTV.Application/Scheduling/RecurrenceSetBounds.cs
T
timothyandClaude Opus 5 528383cf3a
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
fix(880): an absent recurrence array means unrestricted, an explicit [] is rejected (#892)
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>
2026-08-30 09:29:02 +00:00

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 };
}