Handler tests (in-memory SQLite harness): ShowInEpg filtering, decimal channel ordering, time-window filtering, custom-title passthrough, leading pre-roll filler merge, block split-time-evenly, default start/end from config, empty guide. Controller tests: query mapping and null-bounds passthrough for GetGuide. refs #102 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
338 lines
12 KiB
C#
338 lines
12 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.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 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 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 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 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 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 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);
|
|
}
|