Files
ersatztv/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs
T

499 lines
21 KiB
C#

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<IMediator>();
_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<CreateProgramSchedule>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreateProgramScheduleResult>(new CreateProgramScheduleResult(5)));
ProgramScheduleViewModel vm = MakeSchedule(5, "Daily");
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
.Returns(Option<ProgramScheduleViewModel>.Some(vm));
IActionResult result = await _controller.Create(MakeScheduleRequest("Daily"), CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
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<CreateProgramSchedule>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, CreateProgramScheduleResult>(BaseError.New("bad")));
IActionResult result = await _controller.Create(MakeScheduleRequest(string.Empty), CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
problem.Status.ShouldBe(422);
problem.Title.ShouldBe("Validation failed");
}
[Test]
public async Task Create_Should_Map_Request_To_Command()
{
_mediator.Send(Arg.Any<CreateProgramSchedule>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreateProgramScheduleResult>(new CreateProgramScheduleResult(5)));
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
.Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(5, "Daily")));
await _controller.Create(MakeScheduleRequest("Daily"), CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<CreateProgramSchedule>(c =>
c.Name == "Daily" &&
c.KeepMultiPartEpisodesTogether &&
c.TreatCollectionsAsShows &&
c.ShuffleScheduleItems &&
c.RandomStartPoint &&
c.FixedStartTimeBehavior == FixedStartTimeBehavior.Flexible),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_200_And_Map_Route_Id()
{
_mediator.Send(Arg.Any<UpdateProgramSchedule>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, UpdateProgramScheduleResult>(new UpdateProgramScheduleResult(7)));
ProgramScheduleViewModel vm = MakeSchedule(7, "Updated");
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
.Returns(Option<ProgramScheduleViewModel>.Some(vm));
IActionResult result = await _controller.Update(7, MakeUpdateScheduleRequest("Updated"), CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(
new ProgramScheduleResponseModel(7, "Updated", true, true, false, false, FixedStartTimeBehavior.Flexible, null));
await _mediator.Received(1).Send(
Arg.Is<UpdateProgramSchedule>(c => c.ProgramScheduleId == 7 && c.Name == "Updated"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_404_For_NotFoundError_With_ProblemDetails()
{
_mediator.Send(Arg.Any<UpdateProgramSchedule>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, UpdateProgramScheduleResult>(new NotFoundError("missing")));
IActionResult result = await _controller.Update(99, MakeUpdateScheduleRequest("Missing"), CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problem = notFound.Value.ShouldBeOfType<ProblemDetails>();
problem.Status.ShouldBe(404);
problem.Title.ShouldBe("Resource not found");
}
[Test]
public async Task Delete_Should_Return_204_On_Success()
{
_mediator.Send(Arg.Any<DeleteProgramSchedule>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.Delete(3, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
}
[Test]
public async Task Delete_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<DeleteProgramSchedule>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
IActionResult result = await _controller.Delete(99, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task GetById_Should_Return_200_For_Some()
{
ProgramScheduleViewModel vm = MakeSchedule(4, "Daily");
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
.Returns(Option<ProgramScheduleViewModel>.Some(vm));
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().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<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
.Returns(Option<ProgramScheduleViewModel>.None);
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task GetItems_Should_Return_200_With_Items()
{
List<ProgramScheduleItemViewModel> items = [MakeOneItem(11)];
var response = new ProgramScheduleItemsWithDurationViewModel(items, TimeSpan.FromMinutes(25));
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
.Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily")));
_mediator.Send(Arg.Any<GetProgramScheduleItemsWithDurations>(), Arg.Any<CancellationToken>())
.Returns(response);
IActionResult result = await _controller.GetItems(4, CancellationToken.None);
var envelope = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<ScheduleItemsResponseModel>();
envelope.TotalDurationEstimate.ShouldBe(TimeSpan.FromMinutes(25));
envelope.Items.Count.ShouldBe(1);
envelope.Items[0].Id.ShouldBe(11);
await _mediator.Received(1).Send(
Arg.Is<GetProgramScheduleItemsWithDurations>(q => q.Id == 4),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetItems_Should_Return_404_When_Schedule_Missing()
{
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
.Returns(Option<ProgramScheduleViewModel>.None);
IActionResult result = await _controller.GetItems(4, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task AddItem_Should_Return_201_With_Location_And_Map_Route_Id()
{
ProgramScheduleItemViewModel item = MakeOneItem(12);
_mediator.Send(Arg.Any<AddProgramScheduleItem>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, ProgramScheduleItemViewModel>(item));
IActionResult result = await _controller.AddItem(4, MakeItemRequest(PlayoutMode.One), CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.Location.ShouldBe("/api/v1/schedules/4/items/12");
created.Value.ShouldBeOfType<ScheduleItemResponseModel>().Id.ShouldBe(12);
await _mediator.Received(1).Send(
Arg.Is<AddProgramScheduleItem>(c => c.ProgramScheduleId == 4 && c.PlayoutMode == PlayoutMode.One),
Arg.Any<CancellationToken>());
}
[Test]
public async Task AddItem_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<AddProgramScheduleItem>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, ProgramScheduleItemViewModel>(BaseError.New("bad")));
IActionResult result = await _controller.AddItem(4, MakeItemRequest(PlayoutMode.Duration), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task ReplaceItems_Should_Return_200_With_Items_And_Map_Route_Id()
{
List<ProgramScheduleItemViewModel> items = [MakeOneItem(21), MakeOneItem(22)];
_mediator.Send(Arg.Any<ReplaceProgramScheduleItems>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, IEnumerable<ProgramScheduleItemViewModel>>(items));
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
.Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily")));
_mediator.Send(Arg.Any<GetProgramScheduleItems>(), Arg.Any<CancellationToken>())
.Returns(items);
IActionResult result = await _controller.ReplaceItems(
4,
new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One), MakeItemRequest(PlayoutMode.Multiple)]),
CancellationToken.None);
var replaced = result.ShouldBeOfType<OkObjectResult>().Value
.ShouldBeOfType<List<ScheduleItemResponseModel>>();
replaced.Select(i => i.Id).ShouldBe([21, 22]);
await _mediator.Received(1).Send(
Arg.Is<ReplaceProgramScheduleItems>(c =>
c.ProgramScheduleId == 4 &&
c.Items.Count == 2 &&
c.Items[0].Index == 0 &&
c.Items[1].PlayoutMode == PlayoutMode.Multiple),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetItems_Should_Set_ETag_From_Schedule_Version()
{
var response = new ProgramScheduleItemsWithDurationViewModel([], null);
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
.Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily", version: 9)));
_mediator.Send(Arg.Any<GetProgramScheduleItemsWithDurations>(), Arg.Any<CancellationToken>())
.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<BadRequestObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<ReplaceProgramScheduleItems>(), Arg.Any<CancellationToken>());
}
[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<ReplaceProgramScheduleItems>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, IEnumerable<ProgramScheduleItemViewModel>>([MakeOneItem(21)]));
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
.Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily", version: 4)));
_mediator.Send(Arg.Any<GetProgramScheduleItems>(), Arg.Any<CancellationToken>())
.Returns([MakeOneItem(21)]);
IActionResult result = await _controller.ReplaceItems(
4,
new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One)]),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>();
// On success the response carries the refreshed schedule's ETag.
_controller.Response.Headers.ETag.ToString().ShouldBe("\"4\"");
await _mediator.Received(1).Send(
Arg.Is<ReplaceProgramScheduleItems>(c => c.ExpectedVersions == Option<Seq<int>>.Some(new[] { 3 }.ToSeq())),
Arg.Any<CancellationToken>());
}
[Test]
public async Task ReplaceItems_Without_If_Match_Should_Force_Write()
{
_mediator.Send(Arg.Any<ReplaceProgramScheduleItems>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, IEnumerable<ProgramScheduleItemViewModel>>([MakeOneItem(21)]));
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
.Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily", version: 1)));
_mediator.Send(Arg.Any<GetProgramScheduleItems>(), Arg.Any<CancellationToken>())
.Returns([MakeOneItem(21)]);
await _controller.ReplaceItems(
4,
new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One)]),
CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<ReplaceProgramScheduleItems>(c => c.ExpectedVersions == Option<Seq<int>>.None),
Arg.Any<CancellationToken>());
}
[Test]
public async Task ReplaceItems_Should_Return_412_On_Precondition_Failed()
{
_controller.Request.Headers.IfMatch = "\"2\"";
_mediator.Send(Arg.Any<ReplaceProgramScheduleItems>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, IEnumerable<ProgramScheduleItemViewModel>>(new PreconditionFailedError("stale")));
IActionResult result = await _controller.ReplaceItems(
4,
new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One)]),
CancellationToken.None);
var objectResult = result.ShouldBeOfType<ObjectResult>();
objectResult.StatusCode.ShouldBe(StatusCodes.Status412PreconditionFailed);
}
[Test]
public async Task DeleteItem_Should_Return_204_And_Map_Route_Ids()
{
_mediator.Send(Arg.Any<DeleteProgramScheduleItem>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.DeleteItem(4, 12, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<DeleteProgramScheduleItem>(c => c.ProgramScheduleId == 4 && c.ProgramScheduleItemId == 12),
Arg.Any<CancellationToken>());
}
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<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain(httpMethod);
attribute.Template.ShouldBe(route);
}
}