Files
ersatztv/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs
T
timothyandClaude Fable 5 73455aae28
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m52s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(api): surface programs not filler in channel state, tighten queries
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>
2026-07-02 21:53:21 +02:00

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);
}