519 lines
19 KiB
C#
519 lines
19 KiB
C#
using System.Threading.Channels;
|
|
using ErsatzTV.Application;
|
|
using ErsatzTV.Application.Artworks;
|
|
using ErsatzTV.Application.Channels;
|
|
using ErsatzTV.Application.Playouts;
|
|
using ErsatzTV.Controllers.Api;
|
|
using ErsatzTV.Controllers.Api.Requests;
|
|
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Api.Channels;
|
|
using ErsatzTV.Core.Api.LibraryBrowse;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Errors;
|
|
using ErsatzTV.Core.Scheduling;
|
|
using LanguageExt;
|
|
using static LanguageExt.Prelude;
|
|
using MediatR;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using NSubstitute;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
using Unit = LanguageExt.Unit;
|
|
|
|
namespace ErsatzTV.Tests.Controllers;
|
|
|
|
[TestFixture]
|
|
public class ChannelControllerTests
|
|
{
|
|
private IMediator _mediator = null!;
|
|
private Channel<IBackgroundServiceRequest> _workerChannel = null!;
|
|
private ChannelController _controller = null!;
|
|
|
|
[SetUp]
|
|
public void SetUp()
|
|
{
|
|
_mediator = Substitute.For<IMediator>();
|
|
_workerChannel = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
|
|
_controller = new ChannelController(_workerChannel.Writer, _mediator);
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetState_Should_Return_200_With_Channel_State()
|
|
{
|
|
var state = new ChannelStateResponseModel(
|
|
7,
|
|
"7.1",
|
|
true,
|
|
new ChannelNowPlayingResponseModel(
|
|
"Retro Cartoons",
|
|
new DateTimeOffset(2026, 7, 2, 17, 0, 0, TimeSpan.Zero),
|
|
new DateTimeOffset(2026, 7, 2, 17, 30, 0, TimeSpan.Zero)));
|
|
_mediator.Send(Arg.Any<GetChannelStatesForApi>(), Arg.Any<CancellationToken>())
|
|
.Returns([state]);
|
|
|
|
List<ChannelStateResponseModel> result = await _controller.GetState(CancellationToken.None);
|
|
|
|
result.ShouldBe(new List<ChannelStateResponseModel> { state });
|
|
}
|
|
|
|
[Test]
|
|
public async Task Create_Should_Return_201_With_Location_And_Body()
|
|
{
|
|
_mediator.Send(Arg.Any<CreateChannel>(), Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, CreateChannelResult>(new CreateChannelResult(5)));
|
|
ChannelViewModel vm = MakeVm(5);
|
|
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<ChannelViewModel>.Some(vm));
|
|
|
|
IActionResult result = await _controller.Create(MakeCreateRequest(number: "5"), CancellationToken.None);
|
|
|
|
var created = result.ShouldBeOfType<CreatedResult>();
|
|
created.StatusCode.ShouldBe(201);
|
|
created.Location.ShouldBe("/api/channels/5");
|
|
created.Value.ShouldBe(vm);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Create_Should_Map_Request_To_Command()
|
|
{
|
|
_mediator.Send(Arg.Any<CreateChannel>(), Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, CreateChannelResult>(new CreateChannelResult(5)));
|
|
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<ChannelViewModel>.Some(MakeVm(5)));
|
|
|
|
await _controller.Create(MakeCreateRequest(number: "12", name: "Movies"), CancellationToken.None);
|
|
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<CreateChannel>(c => c.Number == "12" && c.Name == "Movies"),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Create_Should_Return_422_On_Validation_Error()
|
|
{
|
|
_mediator.Send(Arg.Any<CreateChannel>(), Arg.Any<CancellationToken>())
|
|
.Returns(Left<BaseError, CreateChannelResult>(BaseError.New("bad")));
|
|
|
|
IActionResult result = await _controller.Create(MakeCreateRequest(), CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
|
}
|
|
|
|
[Test]
|
|
public async Task CreateFromLineup_Should_Return_201_With_Location_And_Body()
|
|
{
|
|
var response = new CreateChannelFromLineupResponseModel(5, 6, 7, 8);
|
|
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, CreateChannelFromLineupResponseModel>(response));
|
|
|
|
IActionResult result = await _controller.CreateFromLineup(MakeLineupRequest(), CancellationToken.None);
|
|
|
|
var created = result.ShouldBeOfType<CreatedResult>();
|
|
created.StatusCode.ShouldBe(201);
|
|
created.Location.ShouldBe("/api/channels/5");
|
|
created.Value.ShouldBe(response);
|
|
}
|
|
|
|
[Test]
|
|
public async Task CreateFromLineup_Should_Map_Request_To_Command()
|
|
{
|
|
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, CreateChannelFromLineupResponseModel>(
|
|
new CreateChannelFromLineupResponseModel(5, 6, 7, 8)));
|
|
|
|
await _controller.CreateFromLineup(
|
|
MakeLineupRequest(number: "12", name: "Movies", templateId: 9),
|
|
CancellationToken.None);
|
|
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<CreateChannelFromLineup>(c =>
|
|
c.Number == "12" &&
|
|
c.Name == "Movies" &&
|
|
c.TemplateId == 9 &&
|
|
c.Lineup.Count == 1 &&
|
|
c.Lineup[0].CollectionType == CollectionType.Movie &&
|
|
c.Lineup[0].MediaItemId == 42),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task CreateFromLineup_Should_Return_404_For_NotFoundError()
|
|
{
|
|
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
|
|
.Returns(Left<BaseError, CreateChannelFromLineupResponseModel>(new NotFoundError("missing")));
|
|
|
|
IActionResult result = await _controller.CreateFromLineup(MakeLineupRequest(), CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<NotFoundObjectResult>();
|
|
}
|
|
|
|
[Test]
|
|
public async Task Update_Should_Return_200_And_Map_Route_Id()
|
|
{
|
|
ChannelViewModel vm = MakeVm(7);
|
|
_mediator.Send(Arg.Any<UpdateChannel>(), Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, ChannelViewModel>(vm));
|
|
|
|
IActionResult result = await _controller.Update(7, MakeUpdateRequest(number: "5"), CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<UpdateChannel>(c => c.ChannelId == 7),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Update_Should_Return_404_For_NotFoundError()
|
|
{
|
|
_mediator.Send(Arg.Any<UpdateChannel>(), Arg.Any<CancellationToken>())
|
|
.Returns(Left<BaseError, ChannelViewModel>(new NotFoundError("missing")));
|
|
|
|
IActionResult result = await _controller.Update(99, MakeUpdateRequest(), CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<NotFoundObjectResult>();
|
|
}
|
|
|
|
[Test]
|
|
public async Task Delete_Should_Return_204_On_Success()
|
|
{
|
|
_mediator.Send(Arg.Any<DeleteChannel>(), 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<DeleteChannel>(), 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 BulkRenumber_Should_Return_204_And_Map_Request()
|
|
{
|
|
_mediator.Send(Arg.Any<UpdateChannelNumbers>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<BaseError>.None);
|
|
|
|
IActionResult result = await _controller.BulkRenumber(
|
|
new BulkRenumberChannelsRequest([new BulkRenumberChannelRequest(1, "10")]),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<NoContentResult>();
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<UpdateChannelNumbers>(
|
|
c => c.Channels.Count == 1 && c.Channels[0].Id == 1 && c.Channels[0].Number == "10"),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetGuide_Should_Map_Query_And_Return_Model()
|
|
{
|
|
var start = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
var end = new DateTimeOffset(2026, 1, 3, 0, 0, 0, TimeSpan.Zero);
|
|
var model = new ChannelGuideResponseModel(start, end, []);
|
|
_mediator.Send(Arg.Any<GetChannelGuideData>(), Arg.Any<CancellationToken>()).Returns(model);
|
|
|
|
ChannelGuideResponseModel result = await _controller.GetGuide(start, end, CancellationToken.None);
|
|
|
|
result.ShouldBe(model);
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<GetChannelGuideData>(q => q.Start == start && q.End == end),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task BulkRenumber_Should_Return_422_On_Validation_Error()
|
|
{
|
|
_mediator.Send(Arg.Any<UpdateChannelNumbers>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<BaseError>.Some(BaseError.New("bad")));
|
|
|
|
IActionResult result = await _controller.BulkRenumber(
|
|
new BulkRenumberChannelsRequest([new BulkRenumberChannelRequest(1, "bad")]),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
|
}
|
|
|
|
[Test]
|
|
public async Task BulkMoveToGroup_Should_Return_204_And_Map_Request()
|
|
{
|
|
_mediator.Send(Arg.Any<BulkMoveChannelsToGroup>(), Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, Unit>(Unit.Default));
|
|
|
|
IActionResult result = await _controller.BulkMoveToGroup(
|
|
new BulkMoveChannelsToGroupRequest([1, 2], "Movies"),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<NoContentResult>();
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<BulkMoveChannelsToGroup>(
|
|
c => c.ChannelIds.SequenceEqual(new[] { 1, 2 }) && c.Group == "Movies"),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetGuide_Should_Pass_Null_Bounds_Through()
|
|
{
|
|
var model = new ChannelGuideResponseModel(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, []);
|
|
_mediator.Send(Arg.Any<GetChannelGuideData>(), Arg.Any<CancellationToken>()).Returns(model);
|
|
|
|
await _controller.GetGuide(null, null, CancellationToken.None);
|
|
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<GetChannelGuideData>(q => q.Start == null && q.End == null),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task BulkMoveToGroup_Should_Return_404_For_NotFoundError()
|
|
{
|
|
_mediator.Send(Arg.Any<BulkMoveChannelsToGroup>(), Arg.Any<CancellationToken>())
|
|
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
|
|
|
|
IActionResult result = await _controller.BulkMoveToGroup(
|
|
new BulkMoveChannelsToGroupRequest([99], "Movies"),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<NotFoundObjectResult>();
|
|
}
|
|
|
|
[Test]
|
|
public async Task BulkDelete_Should_Return_204_And_Map_Request()
|
|
{
|
|
_mediator.Send(Arg.Any<BulkDeleteChannels>(), Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, Unit>(Unit.Default));
|
|
|
|
IActionResult result = await _controller.BulkDelete(
|
|
new BulkDeleteChannelsRequest([1, 2]),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<NoContentResult>();
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<BulkDeleteChannels>(c => c.ChannelIds.SequenceEqual(new[] { 1, 2 })),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task BulkDelete_Should_Return_404_For_NotFoundError()
|
|
{
|
|
_mediator.Send(Arg.Any<BulkDeleteChannels>(), Arg.Any<CancellationToken>())
|
|
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
|
|
|
|
IActionResult result = await _controller.BulkDelete(
|
|
new BulkDeleteChannelsRequest([99]),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<NotFoundObjectResult>();
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetById_Should_Return_200_For_Some()
|
|
{
|
|
ChannelViewModel vm = MakeVm(4);
|
|
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<ChannelViewModel>.Some(vm));
|
|
|
|
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetById_Should_Return_404_For_None()
|
|
{
|
|
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<ChannelViewModel>.None);
|
|
|
|
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
|
|
|
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
|
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
|
|
problemDetails.Status.ShouldBe(404);
|
|
problemDetails.Title.ShouldBe("Resource not found");
|
|
}
|
|
|
|
[Test]
|
|
public async Task ResetPlayout_Should_Return_ProblemDetails_404_For_Missing_Channel()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<int>.None);
|
|
|
|
IActionResult result = await _controller.ResetPlayout("404", mode: null, CancellationToken.None);
|
|
|
|
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
|
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
|
|
problemDetails.Status.ShouldBe(404);
|
|
problemDetails.Title.ShouldBe("Resource not found");
|
|
}
|
|
|
|
[TestCase(PlayoutScheduleKind.Classic, PlayoutBuildMode.Refresh)]
|
|
[TestCase(PlayoutScheduleKind.Block, PlayoutBuildMode.Reset)]
|
|
[TestCase(PlayoutScheduleKind.Sequential, PlayoutBuildMode.Reset)]
|
|
public async Task ResetPlayout_Should_Default_Mode_By_ScheduleKind(
|
|
PlayoutScheduleKind scheduleKind,
|
|
PlayoutBuildMode expectedMode)
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<int>.Some(9));
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9, scheduleKind)));
|
|
|
|
IActionResult result = await _controller.ResetPlayout("5", mode: null, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<OkResult>();
|
|
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
|
|
var buildPlayout = request.ShouldBeOfType<BuildPlayout>();
|
|
buildPlayout.PlayoutId.ShouldBe(9);
|
|
buildPlayout.Mode.ShouldBe(expectedMode);
|
|
}
|
|
|
|
[Test]
|
|
public async Task ResetPlayout_Should_Honor_Explicit_Mode()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<int>.Some(9));
|
|
|
|
IActionResult result = await _controller.ResetPlayout("5", PlayoutBuildMode.Continue, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<OkResult>();
|
|
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
|
|
request.ShouldBeOfType<BuildPlayout>().Mode.ShouldBe(PlayoutBuildMode.Continue);
|
|
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
private static PlayoutNameViewModel MakePlayout(int id, PlayoutScheduleKind scheduleKind) =>
|
|
new(
|
|
id,
|
|
scheduleKind,
|
|
"Channel",
|
|
"5",
|
|
ChannelPlayoutMode.Continuous,
|
|
"Schedule",
|
|
string.Empty,
|
|
null,
|
|
null);
|
|
|
|
private static ChannelViewModel MakeVm(int id) =>
|
|
new(
|
|
id,
|
|
"5",
|
|
"Test",
|
|
"ErsatzTV",
|
|
string.Empty,
|
|
1,
|
|
null,
|
|
ArtworkContentTypeModel.None,
|
|
ChannelStreamSelectorMode.Default,
|
|
string.Empty,
|
|
string.Empty,
|
|
string.Empty,
|
|
ChannelPlayoutSource.Generated,
|
|
ChannelPlayoutMode.Continuous,
|
|
null,
|
|
null,
|
|
StreamingMode.TransportStreamHybrid,
|
|
null,
|
|
null,
|
|
0,
|
|
string.Empty,
|
|
ChannelSubtitleMode.None,
|
|
ChannelMusicVideoCreditsMode.None,
|
|
string.Empty,
|
|
ChannelSongVideoMode.Default,
|
|
ChannelTranscodeMode.OnDemand,
|
|
ChannelIdleBehavior.StopOnDisconnect,
|
|
true,
|
|
false);
|
|
|
|
private static CreateChannelRequest MakeCreateRequest(string number = "5", string name = "Test") =>
|
|
new(
|
|
name,
|
|
number,
|
|
"ErsatzTV",
|
|
string.Empty,
|
|
1,
|
|
null,
|
|
ArtworkContentTypeModel.None,
|
|
ChannelStreamSelectorMode.Default,
|
|
string.Empty,
|
|
string.Empty,
|
|
string.Empty,
|
|
ChannelPlayoutSource.Generated,
|
|
ChannelPlayoutMode.Continuous,
|
|
null,
|
|
null,
|
|
StreamingMode.TransportStreamHybrid,
|
|
null,
|
|
null,
|
|
string.Empty,
|
|
ChannelSubtitleMode.None,
|
|
ChannelMusicVideoCreditsMode.None,
|
|
string.Empty,
|
|
ChannelSongVideoMode.Default,
|
|
ChannelTranscodeMode.OnDemand,
|
|
ChannelIdleBehavior.StopOnDisconnect,
|
|
true,
|
|
false);
|
|
|
|
private static CreateChannelFromLineupRequest MakeLineupRequest(
|
|
string number = "5",
|
|
string name = "Test",
|
|
int templateId = 1) =>
|
|
new(
|
|
name,
|
|
number,
|
|
"ErsatzTV",
|
|
string.Empty,
|
|
ArtworkContentTypeModel.None,
|
|
true,
|
|
true,
|
|
templateId,
|
|
null,
|
|
[new CreateChannelFromLineupItemRequest(
|
|
LibraryBrowseMediaType.Movie,
|
|
CollectionType.Movie,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
42,
|
|
null)]);
|
|
|
|
private static UpdateChannelRequest MakeUpdateRequest(string number = "5", string name = "Test") =>
|
|
new(
|
|
name,
|
|
number,
|
|
"ErsatzTV",
|
|
string.Empty,
|
|
1,
|
|
null,
|
|
ArtworkContentTypeModel.None,
|
|
ChannelStreamSelectorMode.Default,
|
|
string.Empty,
|
|
string.Empty,
|
|
string.Empty,
|
|
ChannelPlayoutSource.Generated,
|
|
ChannelPlayoutMode.Continuous,
|
|
null,
|
|
null,
|
|
StreamingMode.TransportStreamHybrid,
|
|
null,
|
|
null,
|
|
string.Empty,
|
|
ChannelSubtitleMode.None,
|
|
ChannelMusicVideoCreditsMode.None,
|
|
string.Empty,
|
|
ChannelSongVideoMode.Default,
|
|
ChannelTranscodeMode.OnDemand,
|
|
ChannelIdleBehavior.StopOnDisconnect,
|
|
true,
|
|
false);
|
|
}
|