Files
ersatztv/ErsatzTV.Tests/Application/Scheduling/DecoInvalidationTests.cs
T
timothyandClaude Opus 4.8 51ac11e5aa
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 4s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m55s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(review): close post-commit cancellation window + guard default-deco enqueue (Codex Medium/Low)
Independent Codex review of the fix diff surfaced two real findings the fork pass missed:

- Medium: the #251 affected-playout QUERIES in ReplaceDecoTemplateItemsHandler and
  UpdateDecoHandler still ran on the request `cancellationToken`, so a cancellation
  landing after SaveChanges committed but before those queries executed would throw
  before the CancellationToken.None enqueue — the edit committed but no playout Reset,
  re-opening the stale-content bug in that window. Run the entire post-commit
  invalidation (queries + enqueue) on CancellationToken.None so the side effect can't
  be half-aborted once the data has changed.
- Low: UpdateDefaultDecoHandler enqueued a Reset for request.PlayoutId even when
  ExecuteUpdateAsync matched 0 rows (nonexistent playout), creating a background build
  request for an id that isn't there. Guard the enqueue on rows-updated > 0 so the
  enqueued set equals the affected set. Added a regression test.

Also corrected the ReplaceProgramScheduleItemsHandler comments: the schedule-item
hierarchy is TPT (table-per-type), not TPH — the SetValues reconcile is safe either way
(same-runtime-type guard; no discriminator to corrupt), Codex confirmed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:18:38 +02:00

197 lines
7.6 KiB
C#

using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Scheduling;
/// <summary>
/// Regression tests for #251: deco / deco-template CONTENT edits (and default-deco assignment) only take
/// effect on a playout Reset build — deco/break/default-filler content is applied during Reset, a Continue
/// keeps the frozen filler items, and BlockKey change-detection has no deco dimension to self-heal. The
/// editors previously enqueued nothing, so filler/break content stayed stale indefinitely. These tests
/// assert the handlers now enqueue a BuildPlayout(Reset) for exactly the affected playouts.
/// </summary>
[TestFixture]
public class DecoInvalidationTests
{
private InMemoryTvContext _db = null!;
private Channel<IBackgroundServiceRequest> _channel = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_channel = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private List<BuildPlayout> DrainResetEnqueues()
{
var result = new List<BuildPlayout>();
while (_channel.Reader.TryRead(out IBackgroundServiceRequest? req))
{
if (req is BuildPlayout bp)
{
result.Add(bp);
}
}
return result;
}
[Test]
public async Task ReplaceDecoTemplateItems_Should_Reset_Playouts_Using_That_Deco_Template()
{
await using (TvContext ctx = _db.CreateContext())
{
ctx.Add(new Deco { Id = 1, DecoGroupId = 1, Name = "D" });
ctx.Add(new DecoTemplate { Id = 1, DecoTemplateGroupId = 1, Name = "T", Items = [] });
ctx.Add(new Playout { Id = 100, ChannelId = 1, Items = [] });
ctx.Add(new Playout { Id = 200, ChannelId = 1, Items = [] });
ctx.Add(MakePlayoutTemplate(playoutId: 100, decoTemplateId: 1));
ctx.Add(MakePlayoutTemplate(playoutId: 200, decoTemplateId: null)); // control: no deco template
await ctx.SaveChangesAsync();
}
var handler = new ReplaceDecoTemplateItemsHandler(_db.Factory, _channel.Writer);
Either<BaseError, List<DecoTemplateItemViewModel>> result = await handler.Handle(
new ReplaceDecoTemplateItems(
DecoTemplateId: 1,
DecoTemplateGroupId: 1,
Name: "T",
Items: [new ReplaceDecoTemplateItem(DecoId: 1, StartTime: TimeSpan.Zero, EndTime: TimeSpan.FromHours(1))]),
CancellationToken.None);
result.IsRight.ShouldBeTrue(result.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));
List<BuildPlayout> reset = DrainResetEnqueues();
reset.Select(bp => bp.PlayoutId).ShouldBe([100]);
reset.ShouldAllBe(bp => bp.Mode == PlayoutBuildMode.Reset);
}
[Test]
public async Task UpdateDeco_Should_Reset_Playouts_Referencing_The_Deco_Directly_And_By_Template()
{
await using (TvContext ctx = _db.CreateContext())
{
ctx.Add(new Deco
{
Id = 1,
DecoGroupId = 1,
Name = "D",
BreakContent = [],
DecoWatermarks = [],
DecoGraphicsElements = []
});
// direct reference
ctx.Add(new Playout { Id = 100, ChannelId = 1, DecoId = 1, Items = [] });
// reference via a deco template that includes this deco
ctx.Add(new DecoTemplate
{
Id = 1,
DecoTemplateGroupId = 1,
Name = "T",
Items = [new DecoTemplateItem { DecoTemplateId = 1, DecoId = 1, StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromHours(1) }]
});
ctx.Add(new Playout { Id = 200, ChannelId = 1, Items = [] });
ctx.Add(MakePlayoutTemplate(playoutId: 200, decoTemplateId: 1));
// control: unrelated playout
ctx.Add(new Playout { Id = 300, ChannelId = 1, Items = [] });
await ctx.SaveChangesAsync();
}
var handler = new UpdateDecoHandler(_db.Factory, _channel.Writer);
Either<BaseError, Unit> result = await handler.Handle(MakeUpdateDeco(decoId: 1), CancellationToken.None);
result.IsRight.ShouldBeTrue(result.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));
List<BuildPlayout> reset = DrainResetEnqueues();
reset.Select(bp => bp.PlayoutId).OrderBy(id => id).ShouldBe([100, 200]);
reset.ShouldAllBe(bp => bp.Mode == PlayoutBuildMode.Reset);
}
[Test]
public async Task UpdateDefaultDeco_Should_Reset_The_Affected_Playout()
{
await using (TvContext ctx = _db.CreateContext())
{
ctx.Add(new Playout { Id = 100, ChannelId = 1, Items = [] });
await ctx.SaveChangesAsync();
}
var handler = new UpdateDefaultDecoHandler(_db.Factory, _channel.Writer);
Option<BaseError> result = await handler.Handle(new UpdateDefaultDeco(PlayoutId: 100, DecoId: 1), CancellationToken.None);
result.IsNone.ShouldBeTrue();
List<BuildPlayout> reset = DrainResetEnqueues();
reset.Select(bp => bp.PlayoutId).ShouldBe([100]);
reset.ShouldAllBe(bp => bp.Mode == PlayoutBuildMode.Reset);
}
[Test]
public async Task UpdateDefaultDeco_Should_Not_Enqueue_For_A_Nonexistent_Playout()
{
// no playout seeded — ExecuteUpdateAsync matches 0 rows
var handler = new UpdateDefaultDecoHandler(_db.Factory, _channel.Writer);
Option<BaseError> result = await handler.Handle(new UpdateDefaultDeco(PlayoutId: 999, DecoId: 1), CancellationToken.None);
result.IsNone.ShouldBeTrue();
DrainResetEnqueues().ShouldBeEmpty(); // enqueued set must equal the affected (empty) set
}
private static PlayoutTemplate MakePlayoutTemplate(int playoutId, int? decoTemplateId) =>
new()
{
PlayoutId = playoutId,
TemplateId = 1,
DecoTemplateId = decoTemplateId,
Index = 0,
DaysOfWeek = [],
DaysOfMonth = [],
MonthsOfYear = []
};
private static UpdateDeco MakeUpdateDeco(int decoId) =>
new(
decoId,
DecoGroupId: 1,
Name: "D",
WatermarkMode: DecoMode.Inherit,
WatermarkIds: [],
UseWatermarkDuringFiller: false,
GraphicsElementsMode: DecoMode.Inherit,
GraphicsElementIds: [],
UseGraphicsElementsDuringFiller: false,
BreakContentMode: DecoMode.Inherit,
BreakContent: [],
DefaultFillerMode: DecoMode.Inherit,
DefaultFillerCollectionType: CollectionType.Collection,
DefaultFillerCollectionId: null,
DefaultFillerMediaItemId: null,
DefaultFillerMultiCollectionId: null,
DefaultFillerSmartCollectionId: null,
DefaultFillerTrimToFit: false,
DeadAirFallbackMode: DecoMode.Inherit,
DeadAirFallbackCollectionType: CollectionType.Collection,
DeadAirFallbackCollectionId: null,
DeadAirFallbackMediaItemId: null,
DeadAirFallbackMultiCollectionId: null,
DeadAirFallbackSmartCollectionId: null);
}