using System.Linq; using System.Reflection; using ErsatzTV.Application.ProgramSchedules; using ErsatzTV.Controllers.Api; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Scheduling; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; 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 ScheduleControllerTests { private ScheduleController _controller = null!; private IMediator _mediator = null!; [SetUp] public void SetUp() { _mediator = Substitute.For(); _controller = new ScheduleController(_mediator) { // Provide a real HttpContext so the ETag/If-Match concurrency headers (#253) can be // read from Request and written to Response. ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; } [Test] public void Controller_Should_Expose_Idiomatic_Rest_Routes() { ShouldHaveActionRoute(nameof(ScheduleController.GetAll), "GET", "/api/v1/schedules"); ShouldHaveActionRoute(nameof(ScheduleController.GetById), "GET", "/api/v1/schedules/{id:int}"); ShouldHaveActionRoute(nameof(ScheduleController.Create), "POST", "/api/v1/schedules"); ShouldHaveActionRoute(nameof(ScheduleController.Update), "PUT", "/api/v1/schedules/{id:int}"); ShouldHaveActionRoute(nameof(ScheduleController.Delete), "DELETE", "/api/v1/schedules/{id:int}"); ShouldHaveActionRoute(nameof(ScheduleController.GetItems), "GET", "/api/v1/schedules/{id:int}/items"); ShouldHaveActionRoute(nameof(ScheduleController.AddItem), "POST", "/api/v1/schedules/{id:int}/items"); ShouldHaveActionRoute(nameof(ScheduleController.ReplaceItems), "PUT", "/api/v1/schedules/{id:int}/items"); ShouldHaveActionRoute( nameof(ScheduleController.DeleteItem), "DELETE", "/api/v1/schedules/{id:int}/items/{itemId:int}"); } [Test] public void Update_Should_Use_Stable_Update_Request_Dto() { MethodInfo action = typeof(ScheduleController).GetMethod(nameof(ScheduleController.Update)) ?? throw new AssertionException($"Missing action {nameof(ScheduleController.Update)}"); action.GetParameters()[1].ParameterType.ShouldBe(typeof(UpdateScheduleRequest)); } [Test] public async Task Create_Should_Return_201_With_Location_And_Body() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(new CreateProgramScheduleResult(5))); ProgramScheduleViewModel vm = MakeSchedule(5, "Daily"); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); IActionResult result = await _controller.Create(MakeScheduleRequest("Daily"), CancellationToken.None); var created = result.ShouldBeOfType(); created.StatusCode.ShouldBe(201); created.Location.ShouldBe("/api/v1/schedules/5"); created.Value.ShouldBe( new ProgramScheduleResponseModel(5, "Daily", true, true, false, false, FixedStartTimeBehavior.Flexible, null)); } [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(MakeScheduleRequest(string.Empty), 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_Map_Request_To_Command() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(new CreateProgramScheduleResult(5))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakeSchedule(5, "Daily"))); await _controller.Create(MakeScheduleRequest("Daily"), CancellationToken.None); await _mediator.Received(1).Send( Arg.Is(c => c.Name == "Daily" && c.KeepMultiPartEpisodesTogether && c.TreatCollectionsAsShows && c.ShuffleScheduleItems && c.RandomStartPoint && c.FixedStartTimeBehavior == FixedStartTimeBehavior.Flexible), Arg.Any()); } [Test] public async Task Update_Should_Return_200_And_Map_Route_Id() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(new UpdateProgramScheduleResult(7))); ProgramScheduleViewModel vm = MakeSchedule(7, "Updated"); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); IActionResult result = await _controller.Update(7, MakeUpdateScheduleRequest("Updated"), CancellationToken.None); result.ShouldBeOfType().Value.ShouldBe( new ProgramScheduleResponseModel(7, "Updated", true, true, false, false, FixedStartTimeBehavior.Flexible, null)); await _mediator.Received(1).Send( Arg.Is(c => c.ProgramScheduleId == 7 && c.Name == "Updated"), Arg.Any()); } [Test] public async Task Update_Should_Return_404_For_NotFoundError_With_ProblemDetails() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(new NotFoundError("missing"))); IActionResult result = await _controller.Update(99, MakeUpdateScheduleRequest("Missing"), 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 Delete_Should_Return_204_On_Success() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); IActionResult result = await _controller.Delete(3, CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task Delete_Should_Return_404_For_NotFoundError() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(new NotFoundError("missing"))); IActionResult result = await _controller.Delete(99, CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task GetById_Should_Return_200_For_Some() { ProgramScheduleViewModel vm = MakeSchedule(4, "Daily"); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); IActionResult result = await _controller.GetById(4, CancellationToken.None); result.ShouldBeOfType().Value.ShouldBe( new ProgramScheduleResponseModel(4, "Daily", true, true, false, false, FixedStartTimeBehavior.Flexible, null)); } [Test] public async Task GetById_Should_Return_404_For_None() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.GetById(4, CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task GetItems_Should_Return_200_With_Items() { List items = [MakeOneItem(11)]; var response = new ProgramScheduleItemsWithDurationViewModel(items, TimeSpan.FromMinutes(25)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakeSchedule(4, "Daily"))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(response); IActionResult result = await _controller.GetItems(4, CancellationToken.None); var envelope = result.ShouldBeOfType().Value.ShouldBeOfType(); envelope.TotalDurationEstimate.ShouldBe(TimeSpan.FromMinutes(25)); envelope.Items.Count.ShouldBe(1); envelope.Items[0].Id.ShouldBe(11); await _mediator.Received(1).Send( Arg.Is(q => q.Id == 4), Arg.Any()); } [Test] public async Task GetItems_Should_Return_404_When_Schedule_Missing() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.GetItems(4, CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task AddItem_Should_Return_201_With_Location_And_Map_Route_Id() { ProgramScheduleItemViewModel item = MakeOneItem(12); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(item)); IActionResult result = await _controller.AddItem(4, MakeItemRequest(PlayoutMode.One), CancellationToken.None); var created = result.ShouldBeOfType(); created.Location.ShouldBe("/api/v1/schedules/4/items/12"); created.Value.ShouldBeOfType().Id.ShouldBe(12); await _mediator.Received(1).Send( Arg.Is(c => c.ProgramScheduleId == 4 && c.PlayoutMode == PlayoutMode.One), Arg.Any()); } [Test] public async Task AddItem_Should_Return_422_On_Validation_Error() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("bad"))); IActionResult result = await _controller.AddItem(4, MakeItemRequest(PlayoutMode.Duration), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task ReplaceItems_Should_Return_200_With_Items_And_Map_Route_Id() { List items = [MakeOneItem(21), MakeOneItem(22)]; _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right>(items)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakeSchedule(4, "Daily"))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(items); IActionResult result = await _controller.ReplaceItems( 4, new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One), MakeItemRequest(PlayoutMode.Multiple)]), CancellationToken.None); var replaced = result.ShouldBeOfType().Value .ShouldBeOfType>(); replaced.Select(i => i.Id).ShouldBe([21, 22]); await _mediator.Received(1).Send( Arg.Is(c => c.ProgramScheduleId == 4 && c.Items.Count == 2 && c.Items[0].Index == 0 && c.Items[1].PlayoutMode == PlayoutMode.Multiple), Arg.Any()); } [Test] public async Task GetItems_Should_Set_ETag_From_Schedule_Version() { var response = new ProgramScheduleItemsWithDurationViewModel([], null); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakeSchedule(4, "Daily", version: 9))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(response); await _controller.GetItems(4, CancellationToken.None); _controller.Response.Headers.ETag.ToString().ShouldBe("\"9\""); } [Test] public async Task ReplaceItems_Should_Return_400_On_Malformed_If_Match() { _controller.Request.Headers.IfMatch = "not-an-etag"; IActionResult result = await _controller.ReplaceItems( 4, new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One)]), CancellationToken.None); result.ShouldBeOfType(); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task ReplaceItems_Should_Thread_If_Match_Version_Into_Command_And_Set_New_ETag() { _controller.Request.Headers.IfMatch = "\"3\""; _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right>([MakeOneItem(21)])); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakeSchedule(4, "Daily", version: 4))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns([MakeOneItem(21)]); IActionResult result = await _controller.ReplaceItems( 4, new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One)]), CancellationToken.None); result.ShouldBeOfType(); // On success the response carries the refreshed schedule's ETag. _controller.Response.Headers.ETag.ToString().ShouldBe("\"4\""); await _mediator.Received(1).Send( Arg.Is(c => c.ExpectedVersions == Option>.Some(new[] { 3 }.ToSeq())), Arg.Any()); } [Test] public async Task ReplaceItems_Without_If_Match_Should_Force_Write() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right>([MakeOneItem(21)])); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakeSchedule(4, "Daily", version: 1))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns([MakeOneItem(21)]); await _controller.ReplaceItems( 4, new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One)]), CancellationToken.None); await _mediator.Received(1).Send( Arg.Is(c => c.ExpectedVersions == Option>.None), Arg.Any()); } [Test] public async Task ReplaceItems_Should_Return_412_On_Precondition_Failed() { _controller.Request.Headers.IfMatch = "\"2\""; _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left>(new PreconditionFailedError("stale"))); IActionResult result = await _controller.ReplaceItems( 4, new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One)]), CancellationToken.None); var objectResult = result.ShouldBeOfType(); objectResult.StatusCode.ShouldBe(StatusCodes.Status412PreconditionFailed); } [Test] public async Task DeleteItem_Should_Return_204_And_Map_Route_Ids() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); IActionResult result = await _controller.DeleteItem(4, 12, CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is(c => c.ProgramScheduleId == 4 && c.ProgramScheduleItemId == 12), Arg.Any()); } private static CreateScheduleRequest MakeScheduleRequest(string name) => new( name, KeepMultiPartEpisodesTogether: true, TreatCollectionsAsShows: true, ShuffleScheduleItems: true, RandomStartPoint: true, FixedStartTimeBehavior: FixedStartTimeBehavior.Flexible, PadToNearestMinute: null); private static UpdateScheduleRequest MakeUpdateScheduleRequest(string name) => new( name, KeepMultiPartEpisodesTogether: true, TreatCollectionsAsShows: true, ShuffleScheduleItems: true, RandomStartPoint: true, FixedStartTimeBehavior: FixedStartTimeBehavior.Flexible, PadToNearestMinute: null); private static ScheduleItemRequest MakeItemRequest(PlayoutMode playoutMode) => new( Id: null, StartType.Dynamic, StartTime: null, FixedStartTimeBehavior: null, playoutMode, CollectionType.SearchQuery, CollectionId: null, MultiCollectionId: null, SmartCollectionId: null, RerunCollectionId: null, MediaItemId: null, PlaylistId: null, SearchTitle: "News", SearchQuery: "news", PlaybackOrder: PlaybackOrder.Shuffle, MarathonGroupBy: MarathonGroupBy.None, MarathonShuffleGroups: false, MarathonShuffleItems: false, MarathonBatchSize: null, FillWithGroupMode: FillWithGroupMode.None, MultipleMode: MultipleMode.Count, MultipleCount: "2", PlayoutDuration: TimeSpan.FromMinutes(30), TailMode: TailMode.None, DiscardToFillAttempts: 3, CustomTitle: null, GuideMode: GuideMode.Normal, PreRollFillerId: null, MidRollFillerId: null, PostRollFillerId: null, TailFillerId: null, FallbackFillerId: null, WatermarkIds: [], GraphicsElementIds: [], PreferredAudioLanguageCode: null, PreferredAudioTitle: null, PreferredSubtitleLanguageCode: null, SubtitleMode: null); private static ProgramScheduleViewModel MakeSchedule(int id, string name, int version = 0) => new(id, name, true, true, false, false, FixedStartTimeBehavior.Flexible, null, version); private static ProgramScheduleItemOneViewModel MakeOneItem(int id) => new( id, 0, StartType.Dynamic, null, null, CollectionType.SearchQuery, null, null, null, null, null, null, "News", "news", PlaybackOrder.Shuffle, MarathonGroupBy.None, false, false, null, FillWithGroupMode.None, null, GuideMode.Normal, null, null, null, null, null, [], [], null, null, null, null); private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) { MethodInfo action = typeof(ScheduleController).GetMethod(actionName) ?? throw new AssertionException($"Missing action {actionName}"); HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); attribute.HttpMethods.ShouldContain(httpMethod); attribute.Template.ShouldBe(route); } }