diff --git a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs index 23811eb96..8cb9dd7f6 100644 --- a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs @@ -549,6 +549,76 @@ public class PlayoutControllerTests .Send(Arg.Any(), Arg.Any()); } + [TestCase(0, 1, 12, 31)] + [TestCase(13, 1, 12, 31)] + [TestCase(1, 1, 0, 31)] + [TestCase(1, 1, 13, 31)] + public async Task ReplaceAlternateSchedules_Should_Return_422_For_OutOfRange_Month_Without_Dispatch( + int startMonth, + int startDay, + int endMonth, + int endDay) + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9))); + + var request = new ReplacePlayoutAlternateSchedulesRequest( + [new PlayoutAlternateScheduleItemRequest(0, 7, [], [], [], true, startMonth, startDay, null, endMonth, endDay, null)]); + + IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None); + + var unprocessable = result.ShouldBeOfType(); + var problem = unprocessable.Value.ShouldBeOfType(); + problem.Status.ShouldBe(422); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + await _mediator.DidNotReceive() + .Send(Arg.Any(), Arg.Any()); + } + + [TestCase(0, 1, 12, 31)] + [TestCase(1, 32, 12, 31)] + public async Task ReplaceAlternateSchedules_Should_Return_422_For_OutOfRange_Day_Without_Dispatch( + int startMonth, + int startDay, + int endMonth, + int endDay) + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9))); + + var request = new ReplacePlayoutAlternateSchedulesRequest( + [new PlayoutAlternateScheduleItemRequest(0, 7, [], [], [], true, startMonth, startDay, null, endMonth, endDay, null)]); + + IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive() + .Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplaceAlternateSchedules_Should_Ignore_DateRange_Fields_When_LimitToDateRange_Is_False() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([MakeScheduleVm(7)]); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([MakeAltVm(1, 0, 7)]); + + // LimitToDateRange is false, so the out-of-range month/day here must not block the save. + var request = new ReplacePlayoutAlternateSchedulesRequest( + [new PlayoutAlternateScheduleItemRequest(0, 7, [], [], [], false, 0, 0, null, 13, 32, null)]); + + IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1) + .Send(Arg.Any(), Arg.Any()); + } + [Test] public async Task ReplaceAlternateSchedules_Should_Return_422_When_ProgramScheduleId_Unknown() { @@ -671,6 +741,55 @@ public class PlayoutControllerTests await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } + [TestCase(0, 1, 12, 31)] + [TestCase(13, 1, 12, 31)] + [TestCase(1, 1, 0, 31)] + [TestCase(1, 1, 13, 31)] + [TestCase(1, 0, 12, 31)] + [TestCase(1, 32, 12, 31)] + public async Task ReplaceTemplates_Should_Return_422_For_OutOfRange_DateRange_Without_Dispatch( + int startMonth, + int startDay, + int endMonth, + int endDay) + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block })); + + var request = new ReplacePlayoutTemplatesRequest( + [new PlayoutTemplateItemRequest(0, 7, null, [], [], [], true, startMonth, startDay, null, endMonth, endDay, null)]); + + IActionResult result = await _controller.ReplaceTemplates(9, request, CancellationToken.None); + + var unprocessable = result.ShouldBeOfType(); + var problem = unprocessable.Value.ShouldBeOfType(); + problem.Status.ShouldBe(422); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplaceTemplates_Should_Ignore_DateRange_Fields_When_LimitToDateRange_Is_False() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block })); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([MakeTemplateViewModel(7)]); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([MakeTemplateVm(1, 0, 7, null)]); + + // LimitToDateRange is false, so the out-of-range month/day here must not block the save. + var request = new ReplacePlayoutTemplatesRequest( + [new PlayoutTemplateItemRequest(0, 7, null, [], [], [], false, 0, 0, null, 13, 32, null)]); + + IActionResult result = await _controller.ReplaceTemplates(9, request, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send(Arg.Any(), Arg.Any()); + } + [Test] public async Task ReplaceTemplates_Should_Return_422_When_TemplateId_Unknown() { diff --git a/ErsatzTV/Controllers/Api/PlayoutController.cs b/ErsatzTV/Controllers/Api/PlayoutController.cs index 2b9d0d51b..c6294b063 100644 --- a/ErsatzTV/Controllers/Api/PlayoutController.cs +++ b/ErsatzTV/Controllers/Api/PlayoutController.cs @@ -306,6 +306,11 @@ public class PlayoutController(IMediator mediator) : ControllerBase return BaseError.New("[Items] must contain at least one alternate schedule").ToErrorResult(); } + foreach (BaseError error in ValidateDateRanges(items.Select(ToDateRangeCheck))) + { + return error.ToErrorResult(); + } + List schedules = await mediator.Send(new GetAllProgramSchedules(), cancellationToken); var scheduleIds = schedules.Select(s => s.Id).ToHashSet(); @@ -389,6 +394,11 @@ public class PlayoutController(IMediator mediator) : ControllerBase List items = request.Items ?? []; + foreach (BaseError error in ValidateDateRanges(items.Select(ToDateRangeCheck))) + { + return error.ToErrorResult(); + } + List templates = await mediator.Send(new GetAllTemplates(), cancellationToken); var templateIds = templates.Select(t => t.Id).ToHashSet(); var missingTemplateIds = items.Select(i => i.TemplateId).Distinct() @@ -444,6 +454,53 @@ public class PlayoutController(IMediator mediator) : ControllerBase return result.ToDeletedResult(); } + // Guards against AlternateScheduleSelector crashing on out-of-range dates: it constructs + // `new DateTime(year, StartMonth, StartDay)` and only recovers from an overflowing *day* + // (rolling to the 1st of the next month); an out-of-range *month* throws again from inside + // that recovery path and is never caught. Only checked when LimitToDateRange is set, since + // the fields are ignored otherwise. + private static (bool LimitToDateRange, int StartMonth, int StartDay, int EndMonth, int EndDay) ToDateRangeCheck( + PlayoutAlternateScheduleItemRequest item) => + (item.LimitToDateRange, item.StartMonth, item.StartDay, item.EndMonth, item.EndDay); + + private static (bool LimitToDateRange, int StartMonth, int StartDay, int EndMonth, int EndDay) ToDateRangeCheck( + PlayoutTemplateItemRequest item) => + (item.LimitToDateRange, item.StartMonth, item.StartDay, item.EndMonth, item.EndDay); + + private static Option ValidateDateRanges( + IEnumerable<(bool LimitToDateRange, int StartMonth, int StartDay, int EndMonth, int EndDay)> items) + { + foreach ((bool limitToDateRange, int startMonth, int startDay, int endMonth, int endDay) in items) + { + if (!limitToDateRange) + { + continue; + } + + if (startMonth is < 1 or > 12) + { + return Some(BaseError.New($"[StartMonth] {startMonth} must be between 1 and 12")); + } + + if (endMonth is < 1 or > 12) + { + return Some(BaseError.New($"[EndMonth] {endMonth} must be between 1 and 12")); + } + + if (startDay is < 1 or > 31) + { + return Some(BaseError.New($"[StartDay] {startDay} must be between 1 and 31")); + } + + if (endDay is < 1 or > 31) + { + return Some(BaseError.New($"[EndDay] {endDay} must be between 1 and 31")); + } + } + + return Option.None; + } + private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm) => PlayoutResponseModel.From( vm.PlayoutId,