using System.Reflection; using System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.Artworks; using ErsatzTV.Application.Channels; using ErsatzTV.Application.Templates; using Microsoft.AspNetCore.Mvc.Routing; 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.Interfaces.Locking; 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 IEntityLocker _entityLocker = null!; private ChannelController _controller = null!; [SetUp] public void SetUp() { _mediator = Substitute.For(); _workerChannel = System.Threading.Channels.Channel.CreateUnbounded(); _entityLocker = Substitute.For(); _controller = new ChannelController(_workerChannel.Writer, _mediator, _entityLocker); } [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 void MusicVideoCreditsTemplates_And_StreamSelectors_Routes_Are_Literal_And_Precede_Id() { MethodInfo mvct = typeof(ChannelController).GetMethod(nameof(ChannelController.GetMusicVideoCreditsTemplates))!; mvct.GetCustomAttributes(inherit: true).Single().Template .ShouldBe("/api/v1/channels/music-video-credits-templates"); MethodInfo selectors = typeof(ChannelController).GetMethod(nameof(ChannelController.GetStreamSelectors))!; selectors.GetCustomAttributes(inherit: true).Single().Template .ShouldBe("/api/v1/channels/stream-selectors"); } [Test] public async Task GetMusicVideoCreditsTemplates_Should_Return_Names() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(["Default", "Scroll"]); List result = await _controller.GetMusicVideoCreditsTemplates(CancellationToken.None); result.ShouldBe(["Default", "Scroll"]); } [Test] public async Task GetStreamSelectors_Should_Return_Names() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(["selector-a.yml"]); List result = await _controller.GetStreamSelectors(CancellationToken.None); result.ShouldBe(["selector-a.yml"]); } [Test] public async Task Create_Should_Return_201_With_Location_And_ResponseModel_Body() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(new CreateChannelResult(5))); ChannelDetailResponseModel model = MakeDetailModel(5); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(model)); IActionResult result = await _controller.Create(MakeCreateRequest(number: "5"), CancellationToken.None); var created = result.ShouldBeOfType(); created.StatusCode.ShouldBe(201); created.Location.ShouldBe("/api/v1/channels/5"); created.Value.ShouldBe(model); } [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(MakeDetailModel(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/v1/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_With_ResponseModel_And_Map_Route_Id() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(MakeVm(7))); ChannelDetailResponseModel model = MakeDetailModel(7); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(model)); IActionResult result = await _controller.Update(7, MakeUpdateRequest(number: "5"), CancellationToken.None); result.ShouldBeOfType().Value.ShouldBe(model); await _mediator.Received(1).Send( Arg.Is(c => c.ChannelId == 7), Arg.Any()); await _mediator.Received(1).Send( Arg.Is(q => q.Id == 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_With_ResponseModel_For_Some() { ChannelDetailResponseModel model = MakeDetailModel(4); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(model)); IActionResult result = await _controller.GetById(4, CancellationToken.None); result.ShouldBeOfType().Value.ShouldBe(model); } [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 void ResetPlayout_Route_Is_Keyed_By_Int_Id() { MethodInfo reset = typeof(ChannelController).GetMethod(nameof(ChannelController.ResetPlayout))!; reset.GetCustomAttributes(inherit: true).Single().Template .ShouldBe("/api/v1/channels/{id:int}/playout/reset"); } [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"); } [Test] public async Task ResetPlayout_Should_Resolve_Playout_By_Channel_Id() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(9)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakePlayout(9, PlayoutScheduleKind.Classic))); IActionResult result = await _controller.ResetPlayout(42, mode: null, CancellationToken.None); result.ShouldBeOfType().StatusCode.ShouldBe(202); await _mediator.Received(1).Send( Arg.Is(q => q.ChannelId == 42), Arg.Any()); } [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().StatusCode.ShouldBe(202); _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_Return_409_When_Playout_Locked() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(9)); _entityLocker.IsPlayoutLocked(9).Returns(true); IActionResult result = await _controller.ResetPlayout(5, mode: null, CancellationToken.None); var conflict = result.ShouldBeOfType(); conflict.Value.ShouldBeOfType().Status.ShouldBe(409); _workerChannel.Reader.TryRead(out _).ShouldBeFalse(); } [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().StatusCode.ShouldBe(202); _workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue(); request.ShouldBeOfType().Mode.ShouldBe(PlayoutBuildMode.Continue); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public void GetAutoTuneChannelMembers_Route_Is_Literal_And_Precedes_Id() { MethodInfo members = typeof(ChannelController).GetMethod(nameof(ChannelController.GetAutoTuneChannelMembers))!; members.GetCustomAttributes(inherit: true).Single().Template .ShouldBe("/api/v1/channels/auto-tune/members"); } [Test] public async Task GetAutoTuneChannelMembers_Should_Map_Query_And_Return_Model() { var model = new PagedLibraryBrowseItemsResponseModel(1, []); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(model); PagedLibraryBrowseItemsResponseModel result = await _controller.GetAutoTuneChannelMembers( AutoTuneAxis.TvGenre, "Comedy", 2, 25, CancellationToken.None); result.ShouldBe(model); await _mediator.Received(1).Send( Arg.Is(q => q.Axis == AutoTuneAxis.TvGenre && q.Value == "Comedy" && q.PageNum == 2 && q.PageSize == 25), Arg.Any()); } [Test] public async Task GetAutoTuneChannelMembers_Should_Clamp_Paging() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new PagedLibraryBrowseItemsResponseModel(0, [])); // Negative page floors to 0; zero/oversized page size normalizes to the default/max. await _controller.GetAutoTuneChannelMembers(AutoTuneAxis.MovieGenre, "Action", -3, 0, CancellationToken.None); await _controller.GetAutoTuneChannelMembers(AutoTuneAxis.MovieGenre, "Action", 0, 9999, CancellationToken.None); await _mediator.Received(1).Send( Arg.Is(q => q.PageNum == 0 && q.PageSize == 100), Arg.Any()); await _mediator.Received(1).Send( Arg.Is(q => q.PageNum == 0 && q.PageSize == 200), Arg.Any()); } private static PlayoutNameViewModel MakePlayout(int id, PlayoutScheduleKind scheduleKind) => new( id, scheduleKind, "Channel", "5", 1, ChannelPlayoutMode.Continuous, "Schedule", string.Empty, null, null, null, null, 0, 0); private static ChannelDetailResponseModel MakeDetailModel(int id) => new( id, "5", "Test", "ErsatzTV", string.Empty, 1, null, new ChannelLogoResponseModel(string.Empty, string.Empty), 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, [], new ChannelHealthResponseModel(ChannelHealthStatus.Healthy, [], 0, 0)); 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, []); }