- 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.
259 lines
9.5 KiB
C#
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"));
|
|
}
|