Adversarial review fixes for the #97 endpoint: - nowPlaying now resolves like the XMLTV guide: when the wall-clock item is pre/mid-roll filler, surface the guide group's program item (title + full program bounds) instead of the filler; a guide entry with no program item (e.g. fallback loop) reports null. Previously an ad break would retitle the channel and reset progress every 30 seconds. - covering-item lookup now runs one cheap projected query per distinct playout offset instead of hydrating the full metadata include tree for a window widened by the largest offset across ALL channels (up to 24h); metadata is fetched in a second query scoped to the covering guide groups, time-bounded to stay safe against GuideGroup recycling (mod 10000). Regression caught while testing: covering items are keyed by (source channel, lookup time) so a source channel no longer inherits its offset mirror's lookup result. - GetChannelStatesForApi now carries Now explicitly (controller passes DateTime.UtcNow), making boundary semantics testable: new tests pin now == Start (playing) and now == Finish (not playing), plus mid-roll, filler-only, and mirror-source-row cases. - ChannelNowPlayingResponseModel title is non-nullable in the schema (#nullable enable) — GetDisplayTitle always returns a string. - GetState uses the typed-return controller convention; Playouts.Mapper reverted to internal (handler lives in the same assembly). - Spec doc notes the OnDemand drift caveat and filler semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
369 lines
13 KiB
C#
369 lines
13 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 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 ChannelController _controller = null!;
|
|
|
|
[SetUp]
|
|
public void SetUp()
|
|
{
|
|
_mediator = Substitute.For<IMediator>();
|
|
ChannelWriter<IBackgroundServiceRequest> writer =
|
|
System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
|
|
_controller = new ChannelController(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 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 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 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");
|
|
|
|
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
|
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
|
|
problemDetails.Status.ShouldBe(404);
|
|
problemDetails.Title.ShouldBe("Resource not found");
|
|
}
|
|
|
|
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);
|
|
}
|