using ErsatzTV.Application.Channels; using ErsatzTV.Core; using LanguageExt; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Tests.Support; using Microsoft.EntityFrameworkCore; using NSubstitute; using NUnit.Framework; using Shouldly; using static LanguageExt.Prelude; namespace ErsatzTV.Tests.Application.Channels; [TestFixture] public class UpdateChannelHandlerTests : ChannelHandlerTestBase { private UpdateChannelHandler MakeHandler() => new(Worker, Db.Factory, SearchTargets, RemoteLogoCacher); [Test] public async Task Should_Return_NotFoundError_When_Channel_Missing() { Either result = await MakeHandler().Handle(MakeUpdate(999, number: "5"), CancellationToken.None); BaseError error = LeftOf(result); error.ShouldBeOfType(); } [Test] public async Task Should_Update_Existing_Channel() { await SeedFFmpegProfile(); await SeedChannel(1, "5", "Old Name"); Either result = await MakeHandler().Handle(MakeUpdate(1, number: "5", name: "New Name"), CancellationToken.None); result.IsRight.ShouldBeTrue(); await using TvContext context = Db.CreateContext(); var channel = await context.Channels.SingleAsync(c => c.Id == 1); channel.Name.ShouldBe("New Name"); } [Test] public async Task Should_Allow_Keeping_Own_Number() { await SeedFFmpegProfile(); await SeedChannel(1, "5", "Old Name"); Either result = await MakeHandler().Handle(MakeUpdate(1, number: "5", name: "Renamed"), CancellationToken.None); result.IsRight.ShouldBeTrue(); } [Test] public async Task Should_Reject_Number_Used_By_Another_Channel() { await SeedFFmpegProfile(); await SeedChannel(1, "5"); await SeedChannel(2, "6"); Either result = await MakeHandler().Handle(MakeUpdate(1, number: "6"), CancellationToken.None); BaseError error = LeftOf(result); error.ShouldNotBeOfType(); error.Value.ShouldContain("unique"); } [Test] public async Task Should_Reject_ShowInEpg_When_Disabled() { await SeedFFmpegProfile(); await SeedChannel(1, "5"); Either result = await MakeHandler().Handle( MakeUpdate(1, number: "5", isEnabled: false, showInEpg: true), CancellationToken.None); BaseError error = LeftOf(result); error.ShouldNotBeOfType(); error.Value.ShouldContain("EPG"); } [Test] public async Task Should_Reject_Empty_Group() { await SeedFFmpegProfile(); await SeedChannel(1, "5"); Either result = await MakeHandler().Handle(MakeUpdate(1, number: "5", group: ""), CancellationToken.None); BaseError error = LeftOf(result); error.ShouldNotBeOfType(); error.Value.ShouldContain("group"); } [Test] public async Task Should_Reject_Nonexistent_FFmpegProfile() { await SeedFFmpegProfile(); await SeedChannel(1, "5"); Either result = await MakeHandler().Handle( MakeUpdate(1, number: "5", ffmpegProfileId: 999), CancellationToken.None); BaseError error = LeftOf(result); error.ShouldNotBeOfType(); error.Value.ShouldContain("FFmpegProfile"); } // the applicative validation accumulates every failure; the 400 body must carry all of them, // not just the first (regression guard for the #525 handler refactor — errors.Join, not .Head). [Test] public async Task Should_Report_All_Validation_Errors_Not_Just_The_First() { await SeedFFmpegProfile(); await SeedChannel(1, "5"); Either result = await MakeHandler().Handle( MakeUpdate(1, number: "5", group: "", ffmpegProfileId: 999), CancellationToken.None); BaseError error = LeftOf(result); error.Value.ShouldContain("group"); error.Value.ShouldContain("FFmpegProfile"); } [Test] public async Task Should_Reject_Mirror_Transition_When_Channel_Has_Playout() { await SeedFFmpegProfile(); await SeedChannel(1, "5"); // has a playout below await SeedChannel(2, "6"); // valid mirror source (Generated, no playouts of its own) await SeedPlayout(1, channelId: 1); UpdateChannel update = MakeUpdate(1, number: "5") with { PlayoutSource = ChannelPlayoutSource.Mirror, MirrorSourceChannelId = 2 }; Either result = await MakeHandler().Handle(update, CancellationToken.None); BaseError error = LeftOf(result); error.ShouldNotBeOfType(); error.Value.ShouldContain("Mirror"); // the channel must NOT have been silently coerced/saved as Generated (issue #401: no // silent 200, the caller's requested transition is rejected outright) await using TvContext context = Db.CreateContext(); var channel = await context.Channels.SingleAsync(c => c.Id == 1); channel.PlayoutSource.ShouldBe(ChannelPlayoutSource.Generated); channel.MirrorSourceChannelId.ShouldBeNull(); } [Test] public async Task Should_Allow_GetPut_Roundtrip_Of_Generated_Channel_With_Playout() { await SeedFFmpegProfile(); await SeedChannel(1, "5"); await SeedPlayout(1, channelId: 1); // client GETs the channel (PlayoutSource: Generated) and PUTs the same value back // unchanged; this must still succeed even though the channel has a playout. Either result = await MakeHandler().Handle(MakeUpdate(1, number: "5", name: "Renamed"), CancellationToken.None); result.IsRight.ShouldBeTrue(); } [Test] public async Task Should_Null_Mirror_Only_Fields_When_Saving_Generated_Channel_With_Playout() { await SeedFFmpegProfile(); await SeedChannel(1, "5"); // has a playout below await SeedChannel(2, "6"); // stray reference target await SeedPlayout(1, channelId: 1); // request keeps PlayoutSource: Generated but carries stray Mirror-only fields — e.g. a // client that never cleared the fields after flipping the UI back from Mirror. These // must never persist onto a Generated channel. UpdateChannel update = MakeUpdate(1, number: "5") with { MirrorSourceChannelId = 2, PlayoutOffset = TimeSpan.FromHours(1) }; Either result = await MakeHandler().Handle(update, CancellationToken.None); result.IsRight.ShouldBeTrue(); await using TvContext context = Db.CreateContext(); var channel = await context.Channels.SingleAsync(c => c.Id == 1); channel.PlayoutSource.ShouldBe(ChannelPlayoutSource.Generated); channel.MirrorSourceChannelId.ShouldBeNull(); channel.PlayoutOffset.ShouldBeNull(); } [Test] public async Task Should_Download_External_Url_Logo_And_Store_Cache_Name() { await SeedFFmpegProfile(); Channel channel = await SeedChannel(1, "5"); RemoteLogoCacher.CacheFromUrl(Arg.Any(), Arg.Any()) .Returns(Right("cachedhash")); Either result = await MakeHandler().Handle( MakeUpdate(channel.Id, number: "5", logoPath: "https://example.com/logo.png"), CancellationToken.None); result.IsRight.ShouldBeTrue(); await using TvContext db = Db.CreateContext(); Artwork logo = db.Channels.Include(c => c.Artwork).Single(c => c.Id == channel.Id) .Artwork.Single(a => a.ArtworkKind == ArtworkKind.Logo); logo.Path.ShouldBe("cachedhash"); logo.IsExternalUrl().ShouldBeFalse(); } [Test] public async Task Should_Fail_The_Save_When_The_Logo_Download_Fails() { await SeedFFmpegProfile(); Channel channel = await SeedChannel(1, "5"); RemoteLogoCacher.CacheFromUrl(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("Could not download logo: host timed out"))); Either result = await MakeHandler().Handle( MakeUpdate(channel.Id, number: "5", logoPath: "https://example.com/logo.png"), CancellationToken.None); result.IsLeft.ShouldBeTrue(); LeftOf(result).Value.ShouldContain("Could not download logo"); } [Test] public async Task Should_Not_Call_The_Cacher_For_An_Uploaded_Logo_Path() { await SeedFFmpegProfile(); Channel channel = await SeedChannel(1, "5"); await MakeHandler().Handle( MakeUpdate(channel.Id, number: "5", logoPath: "iptv/logos/deadbeef"), CancellationToken.None); await RemoteLogoCacher.DidNotReceive().CacheFromUrl(Arg.Any(), Arg.Any()); } private static BaseError LeftOf(Either either) => either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result")); }