Files
ersatztv/ErsatzTV.Tests/Application/Channels/UpdateChannelHandlerTests.cs
T
timothy 8930972a0a fix(525): address final-review findings (migration host-crash guard, multi-error 400s)
- Medium-1: wrap ExternalLogoMigratorService.ExecuteAsync in try/catch — a DB
  exception (e.g. a channel deleted mid-migration -> DbUpdateConcurrencyException)
  no longer trips BackgroundServiceExceptionBehavior.StopHost and kills the app;
  it logs and self-heals on the next boot. Caller-cancel path handled separately.
- Low-2: CreateChannelHandler/UpdateChannelHandler validation failure now returns
  errors.Join() (all accumulated errors) not errors.Head (first only), restoring
  the repo-wide convention; regression test added.
- Low-4: corrected the Startup registration comment (migrator self-awaits
  WaitForDatabase; order is not load-bearing).

Final whole-branch review: MERGEABLE @ 6d5f6b24 (fable). Carried Minors adjudicated
acceptable-defer.
2026-07-21 13:18:48 +02:00

259 lines
9.5 KiB
C#

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<BaseError, ChannelViewModel> result =
await MakeHandler().Handle(MakeUpdate(999, number: "5"), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task Should_Update_Existing_Channel()
{
await SeedFFmpegProfile();
await SeedChannel(1, "5", "Old Name");
Either<BaseError, ChannelViewModel> 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<BaseError, ChannelViewModel> 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<BaseError, ChannelViewModel> result =
await MakeHandler().Handle(MakeUpdate(1, number: "6"), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("unique");
}
[Test]
public async Task Should_Reject_ShowInEpg_When_Disabled()
{
await SeedFFmpegProfile();
await SeedChannel(1, "5");
Either<BaseError, ChannelViewModel> result =
await MakeHandler().Handle(
MakeUpdate(1, number: "5", isEnabled: false, showInEpg: true),
CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("EPG");
}
[Test]
public async Task Should_Reject_Empty_Group()
{
await SeedFFmpegProfile();
await SeedChannel(1, "5");
Either<BaseError, ChannelViewModel> result =
await MakeHandler().Handle(MakeUpdate(1, number: "5", group: ""), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("group");
}
[Test]
public async Task Should_Reject_Nonexistent_FFmpegProfile()
{
await SeedFFmpegProfile();
await SeedChannel(1, "5");
Either<BaseError, ChannelViewModel> result =
await MakeHandler().Handle(
MakeUpdate(1, number: "5", ffmpegProfileId: 999),
CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
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<BaseError, ChannelViewModel> 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<BaseError, ChannelViewModel> result = await MakeHandler().Handle(update, CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
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<BaseError, ChannelViewModel> 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<BaseError, ChannelViewModel> 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<Uri>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, string>("cachedhash"));
Either<BaseError, ChannelViewModel> 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<Uri>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, string>(BaseError.New("Could not download logo: host timed out")));
Either<BaseError, ChannelViewModel> 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<Uri>(), Arg.Any<CancellationToken>());
}
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
}