using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using ErsatzTV.Application.Artworks; using ErsatzTV.Application.Channels; using ErsatzTV.Application.MediaCollections; using ErsatzTV.Core; using ErsatzTV.Core.Api.Channels; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Search; using ErsatzTV.Infrastructure.Data; using LanguageExt; using MediatR; using Microsoft.EntityFrameworkCore; using NSubstitute; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Application.Channels; [TestFixture] public class CreateAutoTunedChannelsHandlerTests { private ISender _mediator = null!; [SetUp] public void SetUp() { _mediator = Substitute.For(); // Smart collection creation always succeeds, echoing an incrementing id. _mediator.Send(Arg.Any(), Arg.Any()) .Returns(ci => { var cmd = ci.Arg(); return (Either) new SmartCollectionViewModel(7, cmd.Name, cmd.Query); }); _mediator.Send(Arg.Any(), Arg.Any()) .Returns((Either)LanguageExt.Unit.Default); } [Test] public async Task Creates_Selected_Channels_And_Reports_Counts() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns((Either) new CreateChannelFromLineupResponseModel(88, null, 1, 2)); var result = await Handle(new CreateAutoTunedChannels( TemplateId: 3, Group: "Auto-Tuned", new List { new(AutoTuneAxis.TvShow, "The Office", "The Office", "500") })); result.CreatedCount.ShouldBe(1); result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Created); result.Results[0].ChannelId.ShouldBe(88); // Smart collection built with the server-generated query. await _mediator.Received().Send( Arg.Is(c => c.Query == "type:episode AND show_title:\"The Office\""), Arg.Any()); // Channel created referencing the smart collection id, number, and SeasonEpisode order. await _mediator.Received().Send( Arg.Is(c => c.Number == "500" && c.TemplateId == 3 && c.Advanced.PlaybackOrder == PlaybackOrder.SeasonEpisode && c.Lineup.Count == 1 && c.Lineup[0].CollectionType == CollectionType.SmartCollection && c.Lineup[0].SmartCollectionId == 7), Arg.Any()); } [Test] public async Task Per_Channel_Overrides_Are_Threaded_Into_The_Create_Command() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns((Either) new CreateChannelFromLineupResponseModel(88, null, 1, 2)); var result = await Handle(new CreateAutoTunedChannels( TemplateId: 3, Group: "Auto-Tuned", new List { new( AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500", TemplateId: 11, Logo: new ArtworkContentTypeModel("iptv/logos/comedy.png", "image/png"), Advanced: new CreateChannelFromLineupAdvancedOptions( PlaybackOrder: PlaybackOrder.Chronological, FFmpegProfileId: 42, ShuffleScheduleItems: true)) })); result.CreatedCount.ShouldBe(1); await _mediator.Received().Send( Arg.Is(c => // per-channel template wins over the batch template c.TemplateId == 11 && // explicit per-channel playback order wins over the axis default (Shuffle) c.Advanced.PlaybackOrder == PlaybackOrder.Chronological && c.Advanced.FFmpegProfileId == 42 && c.Advanced.ShuffleScheduleItems == true && // uploaded logo threaded through c.Logo.Path == "iptv/logos/comedy.png" && c.Logo.ContentType == "image/png"), Arg.Any()); } [Test] public async Task Advanced_Without_Playback_Order_Keeps_The_Axis_Default() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns((Either) new CreateChannelFromLineupResponseModel(88, null, 1, 2)); // Advanced is set but leaves PlaybackOrder null: the axis default (SeasonEpisode for a single // show) must still be filled in, not silently dropped to null. var result = await Handle(new CreateAutoTunedChannels( TemplateId: 3, Group: "Auto-Tuned", new List { new( AutoTuneAxis.TvShow, "The Office", "The Office", "500", Advanced: new CreateChannelFromLineupAdvancedOptions(WatermarkId: 5)) })); result.CreatedCount.ShouldBe(1); await _mediator.Received().Send( Arg.Is(c => c.TemplateId == 3 && c.Advanced.PlaybackOrder == PlaybackOrder.SeasonEpisode && c.Advanced.WatermarkId == 5), Arg.Any()); } [Test] public async Task No_Overrides_Uses_Batch_Template_Axis_Order_And_No_Logo() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns((Either) new CreateChannelFromLineupResponseModel(88, null, 1, 2)); await Handle(new CreateAutoTunedChannels( TemplateId: 3, Group: "Auto-Tuned", new List { new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500") })); await _mediator.Received().Send( Arg.Is(c => c.TemplateId == 3 && c.Advanced.PlaybackOrder == PlaybackOrder.Shuffle && c.Advanced.FFmpegProfileId == null && c.Logo.Path == string.Empty), Arg.Any()); } [Test] public async Task A_Bad_Per_Channel_Override_Fails_Only_That_Channel_Not_The_Batch() { // First channel's override is rejected downstream (e.g. a non-existent FFmpegProfileId); // the second, override-free channel must still be Created. Keyed on channel number so the // two CreateChannelFromLineup sends resolve to different outcomes. _mediator.Send( Arg.Is(c => c.Number == "500"), Arg.Any()) .Returns((Either) BaseError.New("FFmpegProfile 999 does not exist.")); _mediator.Send( Arg.Is(c => c.Number == "501"), Arg.Any()) .Returns((Either) new CreateChannelFromLineupResponseModel(88, null, 1, 2)); var result = await Handle(new CreateAutoTunedChannels( TemplateId: 3, Group: "Auto-Tuned", new List { new( AutoTuneAxis.TvGenre, "Comedy", "Bad", "500", Advanced: new CreateChannelFromLineupAdvancedOptions(FFmpegProfileId: 999)), new(AutoTuneAxis.TvGenre, "Drama", "Good", "501") })); result.FailedCount.ShouldBe(1); result.CreatedCount.ShouldBe(1); result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Failed); result.Results[1].Status.ShouldBe(AutoTuneOutcomeStatus.Created); result.Results[1].ChannelId.ShouldBe(88); } [Test] public async Task Number_Collision_Is_Skipped_Not_Failed() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns((Either) BaseError.New("Channel number must be unique")); var result = await Handle(new CreateAutoTunedChannels(3, "Auto-Tuned", new List { new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500") })); result.SkippedCount.ShouldBe(1); result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Skipped); } [Test] public async Task Number_Collision_Rolls_Back_Orphaned_SmartCollection() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns((Either) BaseError.New("Channel number must be unique")); var result = await Handle(new CreateAutoTunedChannels(3, "Auto-Tuned", new List { new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500") })); result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Skipped); await _mediator.Received().Send( Arg.Is(d => d.SmartCollectionId == 7), Arg.Any()); } [Test] public async Task Other_Errors_Are_Failed() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns((Either) BaseError.New("FFmpegProfile 9 does not exist.")); var result = await Handle(new CreateAutoTunedChannels(3, "Auto-Tuned", new List { new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500") })); result.FailedCount.ShouldBe(1); result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Failed); } // These tests exercise the non-weighted path only (no Sources), so BuildWeightedPlan returns early // before touching the db context / search targets — substitutes are sufficient. private Task Handle(CreateAutoTunedChannels request) => new CreateAutoTunedChannelsHandler( _mediator, Substitute.For>(), Substitute.For(), Substitute.For()) .Handle(request, CancellationToken.None); }