using System.Reflection; using ErsatzTV.Application.Playouts; using ErsatzTV.Application.ProgramSchedules; using ErsatzTV.Application.Scheduling; using ErsatzTV.Application.Troubleshooting; using ErsatzTV.Application.Troubleshooting.Queries; using ErsatzTV.Controllers.Api; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Playouts; using ErsatzTV.Core.Api.Scheduling; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Domain.Scheduling; using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Scheduling; using LanguageExt; using MediatR; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Routing; using NSubstitute; using NUnit.Framework; using Shouldly; using static LanguageExt.Prelude; using Unit = LanguageExt.Unit; namespace ErsatzTV.Tests.Controllers; [TestFixture] public class PlayoutControllerTests { private PlayoutController _controller = null!; private IMediator _mediator = null!; private IEntityLocker _entityLocker = null!; [SetUp] public void SetUp() { _mediator = Substitute.For(); _entityLocker = Substitute.For(); _controller = new PlayoutController(_mediator, _entityLocker) { // Real HttpContext so the #253 ETag/If-Match concurrency headers can be read/written. ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; } [Test] public void Controller_Should_Expose_Idiomatic_Rest_Routes() { ShouldHaveActionRoute(nameof(PlayoutController.GetAll), "GET", "/api/v1/playouts"); ShouldHaveActionRoute(nameof(PlayoutController.GetById), "GET", "/api/v1/playouts/{id:int}"); ShouldHaveActionRoute(nameof(PlayoutController.GetItems), "GET", "/api/v1/playouts/{id:int}/items"); ShouldHaveActionRoute(nameof(PlayoutController.GetWarningsCount), "GET", "/api/v1/playouts/warnings/count"); ShouldHaveActionRoute(nameof(PlayoutController.Create), "POST", "/api/v1/playouts"); ShouldHaveActionRoute(nameof(PlayoutController.Update), "PUT", "/api/v1/playouts/{id:int}"); ShouldHaveActionRoute(nameof(PlayoutController.ResetAll), "POST", "/api/v1/playouts/reset-all"); ShouldHaveActionRoute(nameof(PlayoutController.Delete), "DELETE", "/api/v1/playouts/{id:int}"); ShouldHaveActionRoute(nameof(PlayoutController.GetAlternateSchedules), "GET", "/api/v1/playouts/{id:int}/alternate-schedules"); ShouldHaveActionRoute(nameof(PlayoutController.ReplaceAlternateSchedules), "PUT", "/api/v1/playouts/{id:int}/alternate-schedules"); ShouldHaveActionRoute(nameof(PlayoutController.GetTemplates), "GET", "/api/v1/playouts/{id:int}/templates"); ShouldHaveActionRoute(nameof(PlayoutController.ReplaceTemplates), "PUT", "/api/v1/playouts/{id:int}/templates"); ShouldHaveActionRoute(nameof(PlayoutController.GetBlocks), "GET", "/api/v1/playouts/{id:int}/blocks"); ShouldHaveActionRoute(nameof(PlayoutController.GetBlockHistory), "GET", "/api/v1/playouts/{id:int}/blocks/{blockId:int}/history"); ShouldHaveActionRoute(nameof(PlayoutController.GetHistoryDetails), "GET", "/api/v1/playouts/history/{id:int}"); ShouldHaveActionRoute(nameof(PlayoutController.EraseItems), "POST", "/api/v1/playouts/{id:int}/erase-items"); ShouldHaveActionRoute( nameof(PlayoutController.EraseItemsAndHistory), "POST", "/api/v1/playouts/{id:int}/erase-items-and-history"); ShouldHaveActionRoute(nameof(PlayoutController.Reshuffle), "POST", "/api/v1/playouts/{id:int}/reshuffle"); ShouldHaveActionRoute( nameof(PlayoutController.GetItemSchedulingContext), "GET", "/api/v1/playouts/items/{id:int}/scheduling-context"); } // ----- Build-lock guard (#215): id-keyed mutations return 409 while the build lock is held ----- [Test] public async Task Delete_Should_Return_409_When_Playout_Locked() { _entityLocker.IsPlayoutLocked(9).Returns(true); IActionResult result = await _controller.Delete(9, CancellationToken.None); var conflict = result.ShouldBeOfType(); conflict.Value.ShouldBeOfType().Status.ShouldBe(409); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task EraseItems_Should_Return_409_When_Playout_Locked() { _entityLocker.IsPlayoutLocked(9).Returns(true); IActionResult result = await _controller.EraseItems(9, CancellationToken.None); result.ShouldBeOfType().Value.ShouldBeOfType().Status.ShouldBe(409); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task EraseItemsAndHistory_Should_Return_409_When_Playout_Locked() { _entityLocker.IsPlayoutLocked(9).Returns(true); IActionResult result = await _controller.EraseItemsAndHistory(9, CancellationToken.None); result.ShouldBeOfType().Value.ShouldBeOfType().Status.ShouldBe(409); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task Update_Should_Return_409_When_Playout_Locked() { _entityLocker.IsPlayoutLocked(9).Returns(true); IActionResult result = await _controller.Update( 9, new UpdatePlayoutDetailsRequest(TimeSpan.FromHours(4), null), CancellationToken.None); result.ShouldBeOfType().Value.ShouldBeOfType().Status.ShouldBe(409); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task UpdateDefaultDeco_Should_Return_409_When_Playout_Locked() { _entityLocker.IsPlayoutLocked(9).Returns(true); IActionResult result = await _controller.UpdateDefaultDeco( 9, new UpdateDefaultDecoRequest(null), CancellationToken.None); result.ShouldBeOfType().Value.ShouldBeOfType().Status.ShouldBe(409); } [Test] public async Task GetAll_Should_Stamp_IsLocked_From_Locker() { _entityLocker.IsPlayoutLocked(9).Returns(true); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new PagedPlayoutsViewModel(1, [MakePlayout(9)])); PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None); result.Page.Single().IsLocked.ShouldBeTrue(); } [Test] public async Task GetById_Should_Surface_Seed() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9) with { Seed = 4242 })); IActionResult result = await _controller.GetById(9, CancellationToken.None); result.ShouldBeOfType() .Value.ShouldBeOfType() .Seed.ShouldBe(4242); } // #616: the playout DETAIL response carried channelName/channelNumber but no channelId, while // reset_channel_playout takes a CHANNEL id. The two id spaces overlap numerically, so a caller // that reached for the row's `id` reset a different channel and got a plausible 202 back. The // list rows gained channelId in #297; this pins the same field on the detail response, and the // distinct ids below prove it is the channel's, not the playout's. [Test] public async Task GetById_Should_Surface_ChannelId_Distinct_From_PlayoutId() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9) with { ChannelId = 400 })); IActionResult result = await _controller.GetById(9, CancellationToken.None); PlayoutResponseModel body = result.ShouldBeOfType() .Value.ShouldBeOfType(); body.Id.ShouldBe(9); body.ChannelId.ShouldBe(400); } [Test] public async Task GetAll_Should_Surface_Seed() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new PagedPlayoutsViewModel(1, new List { MakePlayout(9) with { Seed = 4242 } })); PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None); result.Page[0].Seed.ShouldBe(4242); } // ----- Erase items / history ----- [Test] public async Task EraseItems_Should_Return_404_When_Playout_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.EraseItems(404, CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [TestCase(PlayoutScheduleKind.Classic)] [TestCase(PlayoutScheduleKind.ExternalJson)] public async Task EraseItems_Should_Return_422_For_Unsupported_Kind(PlayoutScheduleKind kind) { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = kind })); IActionResult result = await _controller.EraseItems(9, CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [TestCase(PlayoutScheduleKind.Block)] [TestCase(PlayoutScheduleKind.Sequential)] [TestCase(PlayoutScheduleKind.Scripted)] public async Task EraseItems_Should_Return_204_And_Send_Command_For_Supported_Kind(PlayoutScheduleKind kind) { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = kind })); IActionResult result = await _controller.EraseItems(9, CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is(c => c.PlayoutId == 9), Arg.Any()); } [Test] public async Task EraseItemsAndHistory_Should_Return_404_When_Playout_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.EraseItemsAndHistory(404, CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task EraseItemsAndHistory_Should_Return_422_For_Unsupported_Kind() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.ExternalJson })); IActionResult result = await _controller.EraseItemsAndHistory(9, CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [TestCase(PlayoutScheduleKind.Classic)] [TestCase(PlayoutScheduleKind.Block)] [TestCase(PlayoutScheduleKind.Sequential)] [TestCase(PlayoutScheduleKind.Scripted)] public async Task EraseItemsAndHistory_Should_Return_204_And_Send_Command_For_Supported_Kind( PlayoutScheduleKind kind) { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = kind })); IActionResult result = await _controller.EraseItemsAndHistory(9, CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is(c => c.PlayoutId == 9), Arg.Any()); } // ----- Reshuffle ----- [Test] public async Task Reshuffle_Should_Return_409_When_Playout_Locked() { _entityLocker.IsPlayoutLocked(9).Returns(true); IActionResult result = await _controller.Reshuffle(9, CancellationToken.None); result.ShouldBeOfType().Value.ShouldBeOfType().Status.ShouldBe(409); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task Reshuffle_Should_Return_404_When_Playout_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.Reshuffle(404, CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [TestCase(PlayoutScheduleKind.ExternalJson)] [TestCase(PlayoutScheduleKind.None)] public async Task Reshuffle_Should_Return_422_For_Unsupported_Kind(PlayoutScheduleKind kind) { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = kind })); IActionResult result = await _controller.Reshuffle(9, CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [TestCase(PlayoutScheduleKind.Classic)] [TestCase(PlayoutScheduleKind.Block)] [TestCase(PlayoutScheduleKind.Sequential)] [TestCase(PlayoutScheduleKind.Scripted)] public async Task Reshuffle_Should_Return_202_And_Send_Command_For_Supported_Kind(PlayoutScheduleKind kind) { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = kind })); IActionResult result = await _controller.Reshuffle(9, CancellationToken.None); result.ShouldBeOfType().StatusCode.ShouldBe(202); await _mediator.Received(1).Send( Arg.Is(c => c.PlayoutId == 9), Arg.Any()); } // ----- Playout item scheduling context ----- [Test] public async Task GetItemSchedulingContext_Should_Return_200_With_Decoded_Context() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some("{ \"decoded\": true }")); IActionResult result = await _controller.GetItemSchedulingContext(42, CancellationToken.None); var context = result.ShouldBeOfType().Value .ShouldBeOfType(); context.Context.ShouldBe("{ \"decoded\": true }"); await _mediator.Received(1).Send( Arg.Is(q => q.PlayoutItemId == 42), Arg.Any()); } [Test] public async Task GetItemSchedulingContext_Should_Return_404_For_None() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.GetItemSchedulingContext(404, CancellationToken.None); var notFound = result.ShouldBeOfType(); notFound.Value.ShouldBeOfType().Status.ShouldBe(404); } [Test] public void Create_Should_Use_Stable_Request_Dto() { MethodInfo action = typeof(PlayoutController).GetMethod(nameof(PlayoutController.Create)) ?? throw new AssertionException($"Missing action {nameof(PlayoutController.Create)}"); action.GetParameters()[0].ParameterType.ShouldBe(typeof(CreatePlayoutRequest)); } [Test] public async Task Create_Should_Return_201_With_Location_And_Body() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(new CreatePlayoutResponse(9))); PlayoutNameViewModel vm = MakePlayout(9); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); IActionResult result = await _controller.Create( new CreatePlayoutRequest(3, PlayoutScheduleKind.Classic, 4, null), CancellationToken.None); var created = result.ShouldBeOfType(); created.StatusCode.ShouldBe(201); created.Location.ShouldBe("/api/v1/playouts/9"); created.Value.ShouldBe(ToResponse(vm)); } [Test] public async Task Create_Should_Map_Request_To_Classic_Playout_Command() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(new CreatePlayoutResponse(9))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9))); await _controller.Create(new CreatePlayoutRequest(3, PlayoutScheduleKind.Classic, 4, null), CancellationToken.None); await _mediator.Received(1).Send( Arg.Is(c => c.ChannelId == 3 && c.ProgramScheduleId == 4), Arg.Any()); } [Test] public async Task Create_Should_Return_404_For_NotFoundError_With_ProblemDetails() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(new NotFoundError("missing"))); IActionResult result = await _controller.Create( new CreatePlayoutRequest(404, PlayoutScheduleKind.Classic, 4, null), CancellationToken.None); var notFound = result.ShouldBeOfType(); var problem = notFound.Value.ShouldBeOfType(); problem.Status.ShouldBe(404); problem.Title.ShouldBe("Resource not found"); } [Test] public async Task Create_Should_Return_422_On_Validation_Error_With_ProblemDetails() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("bad"))); IActionResult result = await _controller.Create( new CreatePlayoutRequest(3, PlayoutScheduleKind.Classic, 4, null), CancellationToken.None); var unprocessable = result.ShouldBeOfType(); var problem = unprocessable.Value.ShouldBeOfType(); problem.Status.ShouldBe(422); problem.Title.ShouldBe("Validation failed"); } [Test] public async Task Create_Should_Return_422_When_Classic_Missing_ProgramScheduleId() { IActionResult result = await _controller.Create( new CreatePlayoutRequest(3, PlayoutScheduleKind.Classic, null, null), CancellationToken.None); var unprocessable = result.ShouldBeOfType(); var problem = unprocessable.Value.ShouldBeOfType(); problem.Status.ShouldBe(422); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task Create_Should_Map_Block_Kind_With_No_Extra_Fields() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(new CreatePlayoutResponse(9))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9))); await _controller.Create(new CreatePlayoutRequest(3, PlayoutScheduleKind.Block, null, null), CancellationToken.None); await _mediator.Received(1).Send( Arg.Is(c => c.ChannelId == 3), Arg.Any()); } [TestCase(PlayoutScheduleKind.Sequential)] [TestCase(PlayoutScheduleKind.Scripted)] [TestCase(PlayoutScheduleKind.ExternalJson)] public async Task Create_Should_Return_422_For_File_Backed_Kinds_Missing_ScheduleFile(PlayoutScheduleKind kind) { IActionResult result = await _controller.Create( new CreatePlayoutRequest(3, kind, null, null), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task Create_Should_Map_Sequential_Kind_With_ScheduleFile() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(new CreatePlayoutResponse(9))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9))); await _controller.Create( new CreatePlayoutRequest(3, PlayoutScheduleKind.Sequential, null, "/config/schedule.yml"), CancellationToken.None); await _mediator.Received(1).Send( Arg.Is(c => c.ChannelId == 3 && c.ScheduleFile == "/config/schedule.yml"), Arg.Any()); } [Test] public async Task Update_Should_Return_404_When_Playout_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.Update( 404, new UpdatePlayoutDetailsRequest(null, null), CancellationToken.None); var notFound = result.ShouldBeOfType(); var problem = notFound.Value.ShouldBeOfType(); problem.Status.ShouldBe(404); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task Update_Should_Apply_DailyRebuildTime_And_Return_200() { PlayoutNameViewModel vm = MakePlayout(9); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(vm with { DbDailyRebuildTime = TimeSpan.FromHours(4) })); IActionResult result = await _controller.Update( 9, new UpdatePlayoutDetailsRequest(TimeSpan.FromHours(4), null), CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is(c => c.PlayoutId == 9 && c.DailyRebuildTime == Some(TimeSpan.FromHours(4))), Arg.Any()); } [Test] public async Task Update_Should_Clear_DailyRebuildTime_When_Null() { PlayoutNameViewModel vm = MakePlayout(9); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(vm)); await _controller.Update(9, new UpdatePlayoutDetailsRequest(null, null), CancellationToken.None); await _mediator.Received(1).Send( Arg.Is(c => c.PlayoutId == 9 && c.DailyRebuildTime == Option.None), Arg.Any()); } [Test] public async Task Update_Should_Return_422_When_ScheduleFile_Set_For_Classic_Playout() { PlayoutNameViewModel vm = MakePlayout(9); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); IActionResult result = await _controller.Update( 9, new UpdatePlayoutDetailsRequest(null, "/config/schedule.yml"), CancellationToken.None); var unprocessable = result.ShouldBeOfType(); var problem = unprocessable.Value.ShouldBeOfType(); problem.Status.ShouldBe(422); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task Update_Should_Dispatch_UpdateSequentialPlayout_For_Sequential_Kind() { PlayoutNameViewModel vm = MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Sequential }; _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(vm)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(vm with { ScheduleFile = "/config/new.yml" })); IActionResult result = await _controller.Update( 9, new UpdatePlayoutDetailsRequest(null, "/config/new.yml"), CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is(c => c.PlayoutId == 9 && c.ScheduleFile == "/config/new.yml"), Arg.Any()); } [Test] public async Task Delete_Should_Return_204_On_Success() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); IActionResult result = await _controller.Delete(9, CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is(c => c.PlayoutId == 9), Arg.Any()); } [Test] public async Task Delete_Should_Return_404_For_NotFoundError_With_ProblemDetails() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(new NotFoundError("missing"))); IActionResult result = await _controller.Delete(404, CancellationToken.None); var notFound = result.ShouldBeOfType(); var problem = notFound.Value.ShouldBeOfType(); problem.Status.ShouldBe(404); problem.Title.ShouldBe("Resource not found"); } [Test] public async Task GetById_Should_Return_200_For_Some() { PlayoutNameViewModel vm = MakePlayout(9); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); IActionResult result = await _controller.GetById(9, CancellationToken.None); result.ShouldBeOfType().Value.ShouldBe(ToResponse(vm)); } [Test] public async Task GetById_Should_Expose_IsLocked_From_EntityLocker() { PlayoutNameViewModel vm = MakePlayout(9); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); _entityLocker.IsPlayoutLocked(9).Returns(true); IActionResult result = await _controller.GetById(9, CancellationToken.None); var body = result.ShouldBeOfType().Value.ShouldBeOfType(); body.IsLocked.ShouldBeTrue(); body.ShouldBe(ToResponse(vm, isLocked: true)); } [Test] public async Task GetById_Should_Return_404_For_None_With_ProblemDetails() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.GetById(9, CancellationToken.None); var notFound = result.ShouldBeOfType(); var problem = notFound.Value.ShouldBeOfType(); problem.Status.ShouldBe(404); problem.Title.ShouldBe("Resource not found"); } [Test] public async Task GetAll_Should_Project_Paged_List_With_BuildStatus() { var buildStatus = new PlayoutBuildStatus { LastBuild = new DateTimeOffset(2026, 7, 2, 10, 0, 0, TimeSpan.Zero), Success = false, Message = "boom" }; PlayoutNameViewModel vm = MakePlayout(9) with { BuildStatus = buildStatus, DbDailyRebuildTime = TimeSpan.FromHours(4) }; _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new PagedPlayoutsViewModel(1, [vm])); PagedPlayoutsResponseModel result = await _controller.GetAll("q", 2, 25, CancellationToken.None); result.TotalCount.ShouldBe(1); PlayoutListItemResponseModel item = result.Page.Single(); item.Id.ShouldBe(9); item.ChannelNumber.ShouldBe("101"); item.ChannelName.ShouldBe("Channel"); item.ScheduleKind.ShouldBe(PlayoutScheduleKind.Classic); item.ScheduleName.ShouldBe("Schedule"); item.PlayoutMode.ShouldBe(ChannelPlayoutMode.Continuous); item.DailyRebuildTime.ShouldBe(TimeSpan.FromHours(4)); item.BuildStatus.ShouldNotBeNull(); item.BuildStatus.Success.ShouldBeFalse(); item.BuildStatus.Message.ShouldBe("boom"); item.BuildStatus.LastBuild.ShouldBe(buildStatus.LastBuild); await _mediator.Received(1).Send( Arg.Is(q => q.Query == "q" && q.PageNum == 2 && q.PageSize == 25), Arg.Any()); } [Test] public async Task GetAll_Should_Clamp_PageSize_And_PageNum_Before_Query() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new PagedPlayoutsViewModel(0, [])); await _controller.GetAll("q", -5, 100_000_000, CancellationToken.None); await _mediator.Received(1).Send( Arg.Is(q => q.PageNum == 0 && q.PageSize == 100), Arg.Any()); } [Test] public async Task GetItems_Should_Clamp_PageSize_And_PageNum_Before_Query() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new PagedPlayoutItemsViewModel(0, [])); await _controller.GetItems(9, false, -5, 100_000_000, CancellationToken.None); await _mediator.Received(1).Send( Arg.Is(q => q.PageNum == 0 && q.PageSize == 100), Arg.Any()); } [Test] public async Task GetAll_Should_Emit_Null_BuildStatus_When_Absent() { PlayoutNameViewModel vm = MakePlayout(9) with { BuildStatus = null }; _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new PagedPlayoutsViewModel(1, [vm])); PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None); result.Page.Single().BuildStatus.ShouldBeNull(); } [Test] public async Task GetItems_Should_Project_Items_And_Null_FillerKind_For_Gaps() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9))); var item = new PlayoutItemViewModel( 77, "Movie", new DateTimeOffset(2026, 7, 2, 12, 0, 0, TimeSpan.Zero), new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero), "1:00:00", string.Empty, Some(FillerKind.MidRoll)); var gap = new PlayoutItemViewModel( null, "UNSCHEDULED", new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero), new DateTimeOffset(2026, 7, 2, 13, 30, 0, TimeSpan.Zero), "30:00", string.Empty, Option.None); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new PagedPlayoutItemsViewModel(2, [item, gap])); IActionResult actionResult = await _controller.GetItems(9, showFiller: true, 1, 10, CancellationToken.None); var result = actionResult.ShouldBeOfType().Value.ShouldBeOfType(); result.TotalCount.ShouldBe(2); result.Page[0].Id.ShouldBe(77); result.Page[0].Title.ShouldBe("Movie"); result.Page[0].Duration.ShouldBe("1:00:00"); result.Page[0].FillerKind.ShouldBe(FillerKind.MidRoll); result.Page[1].Id.ShouldBeNull(); result.Page[1].Title.ShouldBe("UNSCHEDULED"); result.Page[1].FillerKind.ShouldBeNull(); await _mediator.Received(1).Send( Arg.Is(q => q.PlayoutId == 9 && q.ShowFiller && q.PageNum == 1 && q.PageSize == 10), Arg.Any()); } [Test] public async Task GetItems_Should_Flag_HasSchedulingContext_Without_Exposing_Raw_Json() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9))); var withContext = new PlayoutItemViewModel( 42, "Movie", new DateTimeOffset(2026, 7, 2, 12, 0, 0, TimeSpan.Zero), new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero), "1:00:00", "{ \"ScheduleId\": 1 }", Option.None); var withoutContext = withContext with { SchedulingContext = " " }; _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new PagedPlayoutItemsViewModel(2, [withContext, withoutContext])); IActionResult actionResult = await _controller.GetItems(9, showFiller: false, 0, 100, CancellationToken.None); var result = actionResult.ShouldBeOfType().Value .ShouldBeOfType(); result.Page[0].HasSchedulingContext.ShouldBeTrue(); result.Page[1].HasSchedulingContext.ShouldBeFalse(); // The raw JSON blob must never appear on the list DTO. typeof(PlayoutItemResponseModel).GetProperty("SchedulingContext").ShouldBeNull(); } [Test] public async Task GetItems_Should_Return_404_For_Unknown_Playout_With_ProblemDetails() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.GetItems(404, showFiller: false, 0, 100, CancellationToken.None); var notFound = result.ShouldBeOfType(); var problem = notFound.Value.ShouldBeOfType(); problem.Status.ShouldBe(404); problem.Title.ShouldBe("Resource not found"); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task GetWarningsCount_Should_Return_Count() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(7); int result = await _controller.GetWarningsCount(CancellationToken.None); result.ShouldBe(7); } [Test] public async Task ResetAll_Should_Return_202_And_Send_Command() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new ResetAllPlayoutsResult([1, 2], [3], [4])); IActionResult result = await _controller.ResetAll(CancellationToken.None); var accepted = result.ShouldBeOfType(); accepted.StatusCode.ShouldBe(202); var body = accepted.Value.ShouldBeOfType(); body.QueuedPlayoutIds.ShouldBe(new List { 1, 2 }); body.SkippedLocked.ShouldBe(new List { 3 }); body.SkippedUnsupported.ShouldBe(new List { 4 }); await _mediator.Received(1).Send(Arg.Any(), Arg.Any()); } // ----- Alternate schedules ----- [Test] public async Task GetAlternateSchedules_Should_Return_404_When_Playout_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.GetAlternateSchedules(404, CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task GetAlternateSchedules_Should_Return_422_For_NonClassic_Playout() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block })); IActionResult result = await _controller.GetAlternateSchedules(9, CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task GetAlternateSchedules_Should_Project_Ordered_List() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns([MakeAltVm(2, 1, 8), MakeAltVm(1, 0, 7)]); IActionResult actionResult = await _controller.GetAlternateSchedules(9, CancellationToken.None); var list = actionResult.ShouldBeOfType().Value .ShouldBeOfType>(); list.Count.ShouldBe(2); list[0].Index.ShouldBe(0); list[0].ProgramScheduleId.ShouldBe(7); list[1].Index.ShouldBe(1); list[1].ProgramScheduleId.ShouldBe(8); } [Test] public async Task ReplaceAlternateSchedules_Should_Return_409_When_Playout_Locked() { _entityLocker.IsPlayoutLocked(9).Returns(true); IActionResult result = await _controller.ReplaceAlternateSchedules( 9, new ReplacePlayoutAlternateSchedulesRequest([MakeAltRequest(7)]), CancellationToken.None); var conflict = result.ShouldBeOfType(); conflict.Value.ShouldBeOfType().Status.ShouldBe(409); await _mediator.DidNotReceive() .Send(Arg.Any(), Arg.Any()); } [Test] public async Task ReplaceAlternateSchedules_Should_Return_404_When_Playout_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.ReplaceAlternateSchedules( 404, new ReplacePlayoutAlternateSchedulesRequest([MakeAltRequest(7)]), CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive() .Send(Arg.Any(), Arg.Any()); } [Test] public async Task ReplaceAlternateSchedules_Should_Return_422_For_NonClassic_Playout() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block })); IActionResult result = await _controller.ReplaceAlternateSchedules( 9, new ReplacePlayoutAlternateSchedulesRequest([MakeAltRequest(7)]), CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive() .Send(Arg.Any(), Arg.Any()); } [Test] public async Task ReplaceAlternateSchedules_Should_Return_422_When_Items_Empty() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9))); IActionResult result = await _controller.ReplaceAlternateSchedules( 9, new ReplacePlayoutAlternateSchedulesRequest([]), CancellationToken.None); result.ShouldBeOfType(); 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)] 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, null, null, null, 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, null, null, null, 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, null, null, null, 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() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns([MakeScheduleVm(7)]); IActionResult result = await _controller.ReplaceAlternateSchedules( 9, new ReplacePlayoutAlternateSchedulesRequest([MakeAltRequest(7), MakeAltRequest(999)]), CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive() .Send(Arg.Any(), Arg.Any()); } [Test] public async Task ReplaceAlternateSchedules_Should_Assign_Index_From_Order_And_Return_200() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns([MakeScheduleVm(7), MakeScheduleVm(8)]); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns([MakeAltVm(1, 0, 7), MakeAltVm(2, 1, 8)]); IActionResult result = await _controller.ReplaceAlternateSchedules( 9, new ReplacePlayoutAlternateSchedulesRequest([MakeAltRequest(7), MakeAltRequest(8)]), CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is(c => c.PlayoutId == 9 && c.Items.Count == 2 && c.Items[0].Index == 0 && c.Items[0].ProgramScheduleId == 7 && c.Items[1].Index == 1 && c.Items[1].ProgramScheduleId == 8), Arg.Any()); } // ----- #880: absent recurrence means UNRESTRICTED, an explicit [] is rejected ----- // Reddens if ToReplaceItem's `?? All*()` is reverted to `?? []`: the counts drop to 0. That is the // point of the test -- the normalization is the fix, so it is what must be pinned. [Test] public async Task ReplaceAlternateSchedules_Should_Normalize_Absent_Recurrence_To_Unrestricted() { _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)]); // All three recurrence arrays omitted -- the shape an API client sends and the SPA never does. var request = new ReplacePlayoutAlternateSchedulesRequest( [new PlayoutAlternateScheduleItemRequest(0, 7, null, null, null, false, 1, 1, null, 12, 31, null)]); IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is(c => c.Items.Count == 1 && c.Items[0].DaysOfWeek.Count == 7 && c.Items[0].DaysOfMonth.Count == 31 && c.Items[0].MonthsOfYear.Count == 12), Arg.Any()); } // The complement of the test above: normalization must fill in ONLY what was absent. Without this a // fix that substituted All*() unconditionally would still pass the normalization test. [Test] public async Task ReplaceAlternateSchedules_Should_Preserve_An_Explicit_Recurrence_Selection() { _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)]); var request = new ReplacePlayoutAlternateSchedulesRequest( [ new PlayoutAlternateScheduleItemRequest( 0, 7, [DayOfWeek.Monday, DayOfWeek.Tuesday], null, [6], false, 1, 1, null, 12, 31, null) ]); IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is(c => c.Items[0].DaysOfWeek.Count == 2 && c.Items[0].DaysOfWeek.Contains(DayOfWeek.Monday) && c.Items[0].DaysOfMonth.Count == 31 && c.Items[0].MonthsOfYear.Count == 1 && c.Items[0].MonthsOfYear.Contains(6)), Arg.Any()); } [Test] public async Task ReplaceTemplates_Should_Normalize_Absent_Recurrence_To_Unrestricted() { _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)]); var request = new ReplacePlayoutTemplatesRequest( [new PlayoutTemplateItemRequest(0, 7, null, null, null, null, false, 1, 1, null, 12, 31, null)]); IActionResult result = await _controller.ReplaceTemplates(9, request, CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is(c => c.Items.Count == 1 && c.Items[0].DaysOfWeek.Count == 7 && c.Items[0].DaysOfMonth.Count == 31 && c.Items[0].MonthsOfYear.Count == 12), Arg.Any()); } // ----- Playout templates ----- [Test] public async Task GetTemplates_Should_Return_404_When_Playout_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.GetTemplates(404, CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task GetTemplates_Should_Return_422_For_NonBlock_Playout() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9))); IActionResult result = await _controller.GetTemplates(9, CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task GetTemplates_Should_Project_Ordered_List() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block })); _mediator.Send(Arg.Any(), Arg.Any()) .Returns([MakeTemplateVm(2, 1, 8, 5), MakeTemplateVm(1, 0, 7, null)]); IActionResult actionResult = await _controller.GetTemplates(9, CancellationToken.None); var list = actionResult.ShouldBeOfType().Value .ShouldBeOfType>(); list.Count.ShouldBe(2); list[0].Index.ShouldBe(0); list[0].TemplateId.ShouldBe(7); list[0].DecoTemplateId.ShouldBeNull(); list[1].Index.ShouldBe(1); list[1].TemplateId.ShouldBe(8); list[1].DecoTemplateId.ShouldBe(5); } [Test] public async Task ReplaceTemplates_Should_Return_409_When_Playout_Locked() { _entityLocker.IsPlayoutLocked(9).Returns(true); IActionResult result = await _controller.ReplaceTemplates( 9, new ReplacePlayoutTemplatesRequest([MakeTemplateRequest(7, null)]), CancellationToken.None); var conflict = result.ShouldBeOfType(); conflict.Value.ShouldBeOfType().Status.ShouldBe(409); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task ReplaceTemplates_Should_Return_404_When_Playout_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.ReplaceTemplates( 404, new ReplacePlayoutTemplatesRequest([MakeTemplateRequest(7, null)]), CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task ReplaceTemplates_Should_Return_422_For_NonBlock_Playout() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9))); IActionResult result = await _controller.ReplaceTemplates( 9, new ReplacePlayoutTemplatesRequest([MakeTemplateRequest(7, null)]), CancellationToken.None); result.ShouldBeOfType(); 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, null, null, 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, null, null, 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() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block })); _mediator.Send(Arg.Any(), Arg.Any()) .Returns([MakeTemplateViewModel(7)]); IActionResult result = await _controller.ReplaceTemplates( 9, new ReplacePlayoutTemplatesRequest([MakeTemplateRequest(999, null)]), CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task ReplaceTemplates_Should_Return_422_When_DecoTemplateId_Unknown() { _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); IActionResult result = await _controller.ReplaceTemplates( 9, new ReplacePlayoutTemplatesRequest([MakeTemplateRequest(7, 999)]), CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task ReplaceTemplates_Should_Assign_Index_From_Order_And_Return_200() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block })); _mediator.Send(Arg.Any(), Arg.Any()) .Returns([MakeTemplateViewModel(7), MakeTemplateViewModel(8)]); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(new DecoTemplateViewModel(5, 1, "G", "DT", 0))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); _mediator.Send(Arg.Any(), Arg.Any()) .Returns([MakeTemplateVm(1, 0, 7, null), MakeTemplateVm(2, 1, 8, 5)]); IActionResult result = await _controller.ReplaceTemplates( 9, new ReplacePlayoutTemplatesRequest([MakeTemplateRequest(7, null), MakeTemplateRequest(8, 5)]), CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is(c => c.PlayoutId == 9 && c.Items.Count == 2 && c.Items[0].Index == 0 && c.Items[0].TemplateId == 7 && c.Items[0].DecoTemplateId == null && c.Items[1].Index == 1 && c.Items[1].TemplateId == 8 && c.Items[1].DecoTemplateId == 5), Arg.Any()); } [Test] public async Task GetBlocks_Should_Return_404_When_Playout_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.GetBlocks(404, CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task GetBlocks_Should_Map_Blocks_To_Response_And_Return_200() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block })); _mediator.Send(Arg.Any(), Arg.Any()) .Returns([new BlockViewModel(10, 1, "Morning", "Toons", 60, BlockStopScheduling.AfterDurationEnd, 1)]); IActionResult result = await _controller.GetBlocks(9, CancellationToken.None); var list = result.ShouldBeOfType().Value.ShouldBeOfType>(); list.Count.ShouldBe(1); list[0].Id.ShouldBe(10); list[0].Name.ShouldBe("Toons"); list[0].GroupName.ShouldBe("Morning"); } [Test] public async Task GetBlockHistory_Should_Return_404_When_Playout_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.GetBlockHistory(404, 10, cancellationToken: CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task GetBlockHistory_Should_Clamp_Paging_And_Map_Entries() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block })); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new PagedPlayoutHistoryViewModel( 1, [new PlayoutHistoryViewModel(99, DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch.AddHours(1), "k", "d")])); IActionResult result = await _controller.GetBlockHistory(9, 10, -5, 999, CancellationToken.None); var paged = result.ShouldBeOfType().Value.ShouldBeOfType(); paged.TotalCount.ShouldBe(1); paged.Page.Count.ShouldBe(1); paged.Page[0].Id.ShouldBe(99); paged.Page[0].Key.ShouldBe("k"); await _mediator.Received(1).Send( Arg.Is(q => q.PlayoutId == 9 && q.BlockId == 10 && q.PageNum == 0 && q.PageSize == 100), Arg.Any()); } [Test] public async Task GetHistoryDetails_Should_Return_200_With_Decoded_Details() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right( new PlayoutHistoryDetailsViewModel( PlaybackOrder.Shuffle, CollectionType.Collection, "Cartoons", "Episode", "S1E1"))); IActionResult result = await _controller.GetHistoryDetails(42, CancellationToken.None); var details = result.ShouldBeOfType().Value.ShouldBeOfType(); details.PlaybackOrder.ShouldBe(PlaybackOrder.Shuffle); details.CollectionType.ShouldBe(CollectionType.Collection); details.Name.ShouldBe("Cartoons"); details.MediaItemType.ShouldBe("Episode"); details.MediaItemTitle.ShouldBe("S1E1"); } [Test] public async Task GetHistoryDetails_Should_Return_404_For_NotFoundError() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(new NotFoundError("missing"))); IActionResult result = await _controller.GetHistoryDetails(404, CancellationToken.None); var notFound = result.ShouldBeOfType(); notFound.Value.ShouldBeOfType().Status.ShouldBe(404); } [Test] public async Task GetHistoryDetails_Should_Return_422_For_Decode_Error() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("bad json"))); IActionResult result = await _controller.GetHistoryDetails(42, CancellationToken.None); var unprocessable = result.ShouldBeOfType(); unprocessable.Value.ShouldBeOfType().Status.ShouldBe(422); } private static PlayoutAlternateScheduleViewModel MakeAltVm(int id, int index, int programScheduleId) => new(id, index, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null); private static PlayoutAlternateScheduleItemRequest MakeAltRequest(int programScheduleId) => new(0, programScheduleId, null, null, null, false, 1, 1, null, 12, 31, null); private static ProgramScheduleViewModel MakeScheduleVm(int id) => new(id, $"Schedule {id}", false, false, false, false, FixedStartTimeBehavior.Strict, null, 0); private static TemplateViewModel MakeTemplateViewModel(int id) => new(id, 1, "Group", $"Template {id}", 0); private static PlayoutTemplateViewModel MakeTemplateVm(int id, int index, int templateId, int? decoTemplateId) => new( id, new TemplateViewModel(templateId, 1, "Group", $"Template {templateId}", 0), decoTemplateId is { } dtId ? new DecoTemplateViewModel(dtId, 1, "DGroup", $"Deco {dtId}", 0) : null, index, [], [], [], false, 1, 1, null, 12, 31, null); private static PlayoutTemplateItemRequest MakeTemplateRequest(int templateId, int? decoTemplateId) => new(0, templateId, decoTemplateId, null, null, null, false, 1, 1, null, 12, 31, null); private static PlayoutNameViewModel MakePlayout(int id) => new( id, PlayoutScheduleKind.Classic, "Channel", "101", 1, ChannelPlayoutMode.Continuous, "Schedule", string.Empty, null, new PlayoutBuildStatus(), null, null, 0, 0); private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm, bool isLocked = false) => PlayoutResponseModel.From( vm.PlayoutId, vm.ScheduleKind, vm.ChannelName, vm.ChannelNumber, vm.ChannelId, vm.PlayoutMode, vm.ScheduleName, vm.ScheduleFile, vm.DbDailyRebuildTime, vm.BuildStatus is null ? null : new PlayoutBuildStatusResponseModel( vm.BuildStatus.LastBuild, vm.BuildStatus.Success, vm.BuildStatus.Message), vm.DecoId, vm.DecoName, isLocked, vm.Seed); private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) { MethodInfo action = typeof(PlayoutController).GetMethod(actionName) ?? throw new AssertionException($"Missing action {actionName}"); HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); attribute.HttpMethods.ShouldContain(httpMethod); attribute.Template.ShouldBe(route); } }