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 _workerChannel = null!; private ChannelController _controller = null!; [SetUp] public void SetUp() { _mediator = Substitute.For(); _workerChannel = System.Threading.Channels.Channel.CreateUnbounded(); _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(), Arg.Any()) .Returns([state]); List result = await _controller.GetState(CancellationToken.None); result.ShouldBe(new List { state }); } [Test] public async Task Create_Should_Return_201_With_Location_And_Body() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(new CreateChannelResult(5))); ChannelViewModel vm = MakeVm(5); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); IActionResult result = await _controller.Create(MakeCreateRequest(number: "5"), CancellationToken.None); var created = result.ShouldBeOfType(); 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(), Arg.Any()) .Returns(Right(new CreateChannelResult(5))); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakeVm(5))); await _controller.Create(MakeCreateRequest(number: "12", name: "Movies"), CancellationToken.None); await _mediator.Received(1).Send( Arg.Is(c => c.Number == "12" && c.Name == "Movies"), Arg.Any()); } [Test] public async Task Create_Should_Return_422_On_Validation_Error() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("bad"))); IActionResult result = await _controller.Create(MakeCreateRequest(), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task CreateFromLineup_Should_Return_201_With_Location_And_Body() { var response = new CreateChannelFromLineupResponseModel(5, 6, 7, 8); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(response)); IActionResult result = await _controller.CreateFromLineup(MakeLineupRequest(), CancellationToken.None); var created = result.ShouldBeOfType(); 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(), Arg.Any()) .Returns(Right( 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(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()); } [Test] public async Task CreateFromLineup_Should_Return_404_For_NotFoundError() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(new NotFoundError("missing"))); IActionResult result = await _controller.CreateFromLineup(MakeLineupRequest(), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task Update_Should_Return_200_And_Map_Route_Id() { ChannelViewModel vm = MakeVm(7); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(vm)); IActionResult result = await _controller.Update(7, MakeUpdateRequest(number: "5"), CancellationToken.None); result.ShouldBeOfType().Value.ShouldBe(vm); await _mediator.Received(1).Send( Arg.Is(c => c.ChannelId == 7), Arg.Any()); } [Test] public async Task Update_Should_Return_404_For_NotFoundError() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(new NotFoundError("missing"))); IActionResult result = await _controller.Update(99, MakeUpdateRequest(), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task Delete_Should_Return_204_On_Success() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); IActionResult result = await _controller.Delete(3, CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task Delete_Should_Return_404_For_NotFoundError() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(new NotFoundError("missing"))); IActionResult result = await _controller.Delete(99, CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task BulkRenumber_Should_Return_204_And_Map_Request() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.BulkRenumber( new BulkRenumberChannelsRequest([new BulkRenumberChannelRequest(1, "10")]), CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is( c => c.Channels.Count == 1 && c.Channels[0].Id == 1 && c.Channels[0].Number == "10"), Arg.Any()); } [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(), Arg.Any()).Returns(model); ChannelGuideResponseModel result = await _controller.GetGuide(start, end, CancellationToken.None); result.ShouldBe(model); await _mediator.Received(1).Send( Arg.Is(q => q.Start == start && q.End == end), Arg.Any()); } [Test] public async Task BulkRenumber_Should_Return_422_On_Validation_Error() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(BaseError.New("bad"))); IActionResult result = await _controller.BulkRenumber( new BulkRenumberChannelsRequest([new BulkRenumberChannelRequest(1, "bad")]), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task BulkMoveToGroup_Should_Return_204_And_Map_Request() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); IActionResult result = await _controller.BulkMoveToGroup( new BulkMoveChannelsToGroupRequest([1, 2], "Movies"), CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is( c => c.ChannelIds.SequenceEqual(new[] { 1, 2 }) && c.Group == "Movies"), Arg.Any()); } [Test] public async Task GetGuide_Should_Pass_Null_Bounds_Through() { var model = new ChannelGuideResponseModel(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, []); _mediator.Send(Arg.Any(), Arg.Any()).Returns(model); await _controller.GetGuide(null, null, CancellationToken.None); await _mediator.Received(1).Send( Arg.Is(q => q.Start == null && q.End == null), Arg.Any()); } [Test] public async Task BulkMoveToGroup_Should_Return_404_For_NotFoundError() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(new NotFoundError("missing"))); IActionResult result = await _controller.BulkMoveToGroup( new BulkMoveChannelsToGroupRequest([99], "Movies"), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task BulkDelete_Should_Return_204_And_Map_Request() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); IActionResult result = await _controller.BulkDelete( new BulkDeleteChannelsRequest([1, 2]), CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is(c => c.ChannelIds.SequenceEqual(new[] { 1, 2 })), Arg.Any()); } [Test] public async Task BulkDelete_Should_Return_404_For_NotFoundError() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(new NotFoundError("missing"))); IActionResult result = await _controller.BulkDelete( new BulkDeleteChannelsRequest([99]), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task GetById_Should_Return_200_For_Some() { ChannelViewModel vm = MakeVm(4); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(vm)); IActionResult result = await _controller.GetById(4, CancellationToken.None); result.ShouldBeOfType().Value.ShouldBe(vm); } [Test] public async Task GetById_Should_Return_404_For_None() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.GetById(4, CancellationToken.None); var notFound = result.ShouldBeOfType(); var problemDetails = notFound.Value.ShouldBeOfType(); 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(), Arg.Any()) .Returns(Option.None); IActionResult result = await _controller.ResetPlayout("404", mode: null, CancellationToken.None); var notFound = result.ShouldBeOfType(); var problemDetails = notFound.Value.ShouldBeOfType(); 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(), Arg.Any()) .Returns(Option.Some(9)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9, scheduleKind))); IActionResult result = await _controller.ResetPlayout("5", mode: null, CancellationToken.None); result.ShouldBeOfType(); _workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue(); var buildPlayout = request.ShouldBeOfType(); buildPlayout.PlayoutId.ShouldBe(9); buildPlayout.Mode.ShouldBe(expectedMode); } [Test] public async Task ResetPlayout_Should_Honor_Explicit_Mode() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(9)); IActionResult result = await _controller.ResetPlayout("5", PlayoutBuildMode.Continue, CancellationToken.None); result.ShouldBeOfType(); _workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue(); request.ShouldBeOfType().Mode.ShouldBe(PlayoutBuildMode.Continue); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } 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); }