Compare commits

...
Author SHA1 Message Date
timothyandClaude Opus 4.8 10d891ba63 fix(401): null Mirror-only fields for non-Mirror channels (repair dead else-branch)
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 15s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 2m53s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m53s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m34s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m47s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Closes review finding: stray MirrorSourceChannelId/PlayoutOffset could persist onto a Generated channel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:57:24 +02:00
timothyandClaude Opus 4.8 a3080ca42b fix(401): reject Mirror channel with playout instead of silently coercing to Generated
fixes #401

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:50:32 +02:00
3 changed files with 101 additions and 13 deletions
@@ -52,16 +52,6 @@ public class UpdateChannelHandler(
UpdateChannel update,
CancellationToken cancellationToken)
{
// don't save mirror when playout exists
if (c.Playouts.Count > 0)
{
update = update with
{
PlayoutSource = ChannelPlayoutSource.Generated,
MirrorSourceChannelId = null
};
}
bool hasEpgChange = c.PlayoutSource != update.PlayoutSource || c.ShowInEpg != update.ShowInEpg;
c.Name = update.Name;
@@ -140,6 +130,8 @@ public class UpdateChannelHandler(
c.PlayoutMode = ChannelPlayoutMode.Continuous;
hasEpgChange |= c.MirrorSourceChannelId != update.MirrorSourceChannelId;
hasEpgChange |= c.PlayoutOffset != update.PlayoutOffset;
c.MirrorSourceChannelId = update.MirrorSourceChannelId;
c.PlayoutOffset = update.PlayoutOffset;
}
else
{
@@ -147,8 +139,6 @@ public class UpdateChannelHandler(
c.PlayoutOffset = null;
}
c.MirrorSourceChannelId = update.MirrorSourceChannelId;
c.PlayoutOffset = update.PlayoutOffset;
c.StreamingMode = update.StreamingMode;
c.WatermarkId = update.WatermarkId;
c.FallbackFillerId = update.FallbackFillerId;
@@ -194,7 +184,7 @@ public class UpdateChannelHandler(
{
Validation<BaseError, Channel> channelValidation = (ValidateName(request),
await ValidateNumber(dbContext, request, cancellationToken),
await MirrorSourceMustBeValid(dbContext, request, cancellationToken),
await MirrorSourceMustBeValid(dbContext, request, channel, cancellationToken),
ValidateShowInEpg(request.IsEnabled, request.ShowInEpg),
ValidateLogo(request.Logo?.Path))
.Apply((_, _, _, _, _) => channel);
@@ -269,6 +259,7 @@ public class UpdateChannelHandler(
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
TvContext dbContext,
UpdateChannel request,
Channel channel,
CancellationToken cancellationToken)
{
if (request.PlayoutSource is not ChannelPlayoutSource.Mirror)
@@ -276,6 +267,18 @@ public class UpdateChannelHandler(
return Unit.Default;
}
// a channel with its own playout already built (Generated mode) cannot become a Mirror —
// Mirror channels relay another channel's playout and never build one of their own, so
// switching this transition on would strand the existing playout. This used to be
// silently coerced back to Generated (issue #401); reject the transition instead so the
// caller sees why the requested Mirror source was not applied. A round-trip that keeps
// PlayoutSource as Generated never reaches this check.
if (channel.Playouts.Count > 0)
{
return BaseError.New(
"Channel cannot switch to Mirror playout source while it has a playout; reset or delete the existing playout first.");
}
Option<Channel> maybeMirrorSource = await dbContext.Channels
.AsNoTracking()
.SelectOneAsync(
@@ -1,6 +1,7 @@
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;
@@ -114,6 +115,77 @@ public class UpdateChannelHandlerTests : ChannelHandlerTestBase
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();
}
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
}
@@ -64,6 +64,19 @@ public abstract class ChannelHandlerTestBase
await context.SaveChangesAsync();
}
protected async Task SeedPlayout(int id, int channelId)
{
await using TvContext context = Db.CreateContext();
context.Playouts.Add(
new Playout
{
Id = id,
ChannelId = channelId,
ScheduleKind = PlayoutScheduleKind.Classic
});
await context.SaveChangesAsync();
}
protected static CreateChannel MakeCreate(
string number = "5",
int ffmpegProfileId = 1,