fix(scheduling): stop silently resetting fill-group state + invalidate deco edits (#251, #252) #258

Merged
timothy merged 2 commits from fix/251-252-scheduling-invalidation into main 2026-07-11 15:26:13 +02:00
6 changed files with 646 additions and 16 deletions
@@ -38,12 +38,56 @@ public class ReplaceProgramScheduleItemsHandler(
ProgramSchedule programSchedule,
CancellationToken cancellationToken)
{
dbContext.RemoveRange(programSchedule.Items);
// reset index starting with zero
programSchedule.Items = [];
// Positional in-place reconcile (rather than delete-and-reinsert): a schedule item owns the
// persisted fill-group/shuffle enumerator state via PlayoutScheduleItemFillGroupIndex, whose
// ProgramScheduleItemId FK is OnDelete(Cascade). Removing and re-inserting every item — as the
// original handler did on every save, including a no-op PUT-back — cascade-deleted that state for
// all playouts using the schedule (#252). Reusing the existing item row for the same-typed slot
// keeps its id, so the cascade never fires and progression survives. The request DTO carries no
// stable item id, so position is the only key available here; true content-aware stable identity
// is deferred to the shared concurrency/round-trip contract in #253.
var orderedItems = request.Items.OrderBy(i => i.Index).ToList();
for (var i = 0; i < orderedItems.Count; i++)
List<ProgramScheduleItem> existingItems = programSchedule.Items.OrderBy(i => i.Index).ToList();
// load the watermark/graphics join rows for the existing items so they can be rebuilt in place
await dbContext.Entry(programSchedule)
.Collection(ps => ps.Items)
.Query()
.Include(i => i.ProgramScheduleItemWatermarks)
.Include(i => i.ProgramScheduleItemGraphicsElements)
.LoadAsync(cancellationToken);
int commonCount = Math.Min(existingItems.Count, orderedItems.Count);
for (var i = 0; i < commonCount; i++)
{
ProgramScheduleItem existing = existingItems[i];
ProgramScheduleItem built = BuildItem(programSchedule, i, orderedItems[i]);
if (existing.GetType() == built.GetType())
{
// same TPT subtype: copy all scalar values in place (BuildItem is the single source of
// item construction, so no field is silently dropped) and rebuild the join rows, keeping
// the item's id — and with it the fill-group index that would otherwise cascade away.
built.Id = existing.Id;
dbContext.Entry(existing).CurrentValues.SetValues(built);
RebuildChildren(existing, orderedItems[i]);
}
else
{
// EF can't change a TPT row's type in place; this slot must be replaced (its fill-group
// index resets, which is acceptable — the item fundamentally changed).
dbContext.Remove(existing);
programSchedule.Items.Add(built);
}
}
// remove surplus existing items
for (int i = commonCount; i < existingItems.Count; i++)
{
dbContext.Remove(existingItems[i]);
}
// add surplus incoming items
for (int i = commonCount; i < orderedItems.Count; i++)
{
programSchedule.Items.Add(BuildItem(programSchedule, i, orderedItems[i]));
}
@@ -68,6 +112,36 @@ public class ReplaceProgramScheduleItemsHandler(
return persisted.Map(ProjectToViewModel).ToList();
}
// Rebuild the watermark/graphics join rows for an item updated in place. Clearing severs the required
// relationship so EF deletes the orphaned join rows (same net effect the delete-and-reinsert path had),
// then re-add from the request. Only the parent item row is preserved — the join rows carry no state.
private static void RebuildChildren(ProgramScheduleItem item, ReplaceProgramScheduleItem request)
{
item.ProgramScheduleItemWatermarks ??= [];
item.ProgramScheduleItemWatermarks.Clear();
foreach (int watermarkId in request.WatermarkIds)
{
item.ProgramScheduleItemWatermarks.Add(
new ProgramScheduleItemWatermark
{
ProgramScheduleItem = item,
WatermarkId = watermarkId
});
}
item.ProgramScheduleItemGraphicsElements ??= [];
item.ProgramScheduleItemGraphicsElements.Clear();
foreach (int graphicsElementId in request.GraphicsElementIds)
{
item.ProgramScheduleItemGraphicsElements.Add(
new ProgramScheduleItemGraphicsElement
{
ProgramScheduleItem = item,
GraphicsElementId = graphicsElementId
});
}
}
private static async Task<Validation<BaseError, ProgramSchedule>> Validate(
TvContext dbContext,
ReplaceProgramScheduleItems request,
@@ -1,12 +1,17 @@
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Scheduling;
public class ReplaceDecoTemplateItemsHandler(IDbContextFactory<TvContext> dbContextFactory)
public class ReplaceDecoTemplateItemsHandler(
IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> channel)
: IRequestHandler<ReplaceDecoTemplateItems, Either<BaseError, List<DecoTemplateItemViewModel>>>
{
public async Task<Either<BaseError, List<DecoTemplateItemViewModel>>> Handle(
@@ -18,7 +23,7 @@ public class ReplaceDecoTemplateItemsHandler(IDbContextFactory<TvContext> dbCont
return await validation.Apply(ps => Persist(dbContext, request, ps, cancellationToken));
}
private static async Task<List<DecoTemplateItemViewModel>> Persist(
private async Task<List<DecoTemplateItemViewModel>> Persist(
TvContext dbContext,
ReplaceDecoTemplateItems request,
DecoTemplate decoTemplate,
@@ -33,11 +38,23 @@ public class ReplaceDecoTemplateItemsHandler(IDbContextFactory<TvContext> dbCont
await dbContext.SaveChangesAsync(cancellationToken);
// TODO: refresh any playouts that use this schedule
// foreach (Playout playout in programSchedule.Playouts)
// {
// await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh));
// }
// Deco/break/default-filler content is only (re)applied during a Reset build (a Continue keeps the
// frozen DecoDefault filler items), and BlockKey change-detection has no deco dimension, so nothing
// self-heals a deco-template edit — the editor returned 200 but built filler stayed stale until a
// manual Reset (#251). Enqueue a Reset for every playout that references this deco template. This
// whole post-commit invalidation runs with CancellationToken.None (audit #22 policy): once the edit
// is committed, a late request cancellation must not be able to abort the affected-playout query OR
// the enqueue and leave content stale.
List<int> playoutIds = await dbContext.PlayoutTemplates
.Where(pt => pt.DecoTemplateId == decoTemplate.Id)
.Select(pt => pt.PlayoutId)
.Distinct()
.ToListAsync(CancellationToken.None);
foreach (int playoutId in playoutIds)
{
await channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Reset), CancellationToken.None);
}
await dbContext.Entry(decoTemplate)
.Collection(t => t.Items)
@@ -1,13 +1,18 @@
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Scheduling;
public class UpdateDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
public class UpdateDecoHandler(
IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> channel)
: IRequestHandler<UpdateDeco, Either<BaseError, Unit>>
{
public async Task<Either<BaseError, Unit>> Handle(UpdateDeco request, CancellationToken cancellationToken)
@@ -17,7 +22,7 @@ public class UpdateDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request, cancellationToken));
}
private static async Task<Unit> ApplyUpdateRequest(
private async Task<Unit> ApplyUpdateRequest(
TvContext dbContext,
Deco existing,
UpdateDeco request,
@@ -177,6 +182,34 @@ public class UpdateDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
await dbContext.SaveChangesAsync(cancellationToken);
// Deco content (break/default filler/etc.) is only (re)applied during a Reset build, and BlockKey
// change-detection has no deco dimension, so editing a deco left already-built filler/break content
// stale until a manual Reset (#251). Enqueue a Reset for every playout that references this deco —
// directly (Playout.DecoId) or through a deco template that includes it. This whole post-commit
// invalidation runs with CancellationToken.None (audit #22 policy): once the edit is committed, a
// late request cancellation must not be able to abort either the affected-playout queries OR the
// enqueue and leave content stale — the entire side effect has to complete.
List<int> directPlayoutIds = await dbContext.Playouts
.Where(p => p.DecoId == request.DecoId)
.Select(p => p.Id)
.ToListAsync(CancellationToken.None);
List<int> decoTemplateIds = await dbContext.DecoTemplateItems
.Where(i => i.DecoId == request.DecoId)
.Select(i => i.DecoTemplateId)
.Distinct()
.ToListAsync(CancellationToken.None);
List<int> templatePlayoutIds = await dbContext.PlayoutTemplates
.Where(pt => pt.DecoTemplateId.HasValue && decoTemplateIds.Contains(pt.DecoTemplateId.Value))
.Select(pt => pt.PlayoutId)
.ToListAsync(CancellationToken.None);
foreach (int playoutId in directPlayoutIds.Concat(templatePlayoutIds).Distinct())
{
await channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Reset), CancellationToken.None);
}
return Unit.Default;
}
@@ -1,10 +1,15 @@
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Scheduling;
public class UpdateDefaultDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
public class UpdateDefaultDecoHandler(
IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> channel)
: IRequestHandler<UpdateDefaultDeco, Option<BaseError>>
{
public async Task<Option<BaseError>> Handle(UpdateDefaultDeco request, CancellationToken cancellationToken)
@@ -13,10 +18,23 @@ public class UpdateDefaultDecoHandler(IDbContextFactory<TvContext> dbContextFact
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await dbContext.Playouts
int updated = await dbContext.Playouts
.Where(p => p.Id == request.PlayoutId)
.ExecuteUpdateAsync(u => u.SetProperty(p => p.DecoId, p => request.DecoId), cancellationToken);
// Changing a playout's default deco only takes effect on a Reset build (deco content is applied
// during Reset, and BlockKey has no deco dimension to self-heal) — same stale-until-Reset class
// as #251. Enqueue a Reset for the affected playout, but only if a row was actually updated: a
// request for a nonexistent playout must not queue a build for an id that isn't there (keeps the
// enqueued set equal to the affected set). CancellationToken.None so the rebuild isn't dropped
// after the assignment has already been persisted.
if (updated > 0)
{
await channel.WriteAsync(
new BuildPlayout(request.PlayoutId, PlayoutBuildMode.Reset),
CancellationToken.None);
}
return Option<BaseError>.None;
}
catch (Exception ex)
@@ -0,0 +1,292 @@
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.ProgramSchedules;
/// <summary>
/// Regression tests for #252: the ReplaceProgramScheduleItems write path used to delete every existing
/// item and re-insert with fresh ids, which cascade-deleted <see cref="PlayoutScheduleItemFillGroupIndex" />
/// (FK OnDelete(Cascade)) — silently resetting persisted fill-group/shuffle progression on every save,
/// even a no-op PUT-back. The handler now reconciles in place, keeping the item id (and its fill-group
/// index) for a same-typed slot. The primary assertion is on the mechanism — item ids stay stable across
/// a no-op save — because the in-memory harness runs with foreign_keys=OFF, so an assertion on the cascade
/// alone would be FK-pragma dependent.
/// </summary>
[TestFixture]
public class ReplaceProgramScheduleItemsReconcileTests
{
private InMemoryTvContext _db = null!;
private ChannelWriter<IBackgroundServiceRequest> _worker = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_worker = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task NoOp_Replace_Should_Preserve_Item_Ids_And_FillGroup_State()
{
int scheduleId = await SeedSchedule();
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
var items = new List<ReplaceProgramScheduleItem>
{
MakeItem(0, PlayoutMode.Multiple, "group") with { FillWithGroupMode = FillWithGroupMode.FillWithShuffledGroups },
MakeItem(1, PlayoutMode.One, "news")
};
(await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, items), CancellationToken.None))
.IsRight.ShouldBeTrue();
List<int> originalIds;
int fillGroupItemId;
await using (TvContext ctx = _db.CreateContext())
{
List<ProgramScheduleItem> persisted = await ctx.ProgramScheduleItems
.Where(i => i.ProgramScheduleId == scheduleId)
.OrderBy(i => i.Index)
.ToListAsync();
originalIds = persisted.Select(i => i.Id).ToList();
fillGroupItemId = persisted[0].Id;
// persisted fill-group enumerator state pointing at item 0 (foreign_keys=OFF, so no Playout row needed)
ctx.Add(new PlayoutScheduleItemFillGroupIndex
{
PlayoutId = 1,
ProgramScheduleItemId = fillGroupItemId,
EnumeratorState = new CollectionEnumeratorState { Seed = 12345, Index = 7 }
});
await ctx.SaveChangesAsync();
}
// Act: PUT the identical items back — a no-op save.
(await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, items), CancellationToken.None))
.IsRight.ShouldBeTrue();
await using (TvContext ctx = _db.CreateContext())
{
List<int> afterIds = await ctx.ProgramScheduleItems
.Where(i => i.ProgramScheduleId == scheduleId)
.OrderBy(i => i.Index)
.Select(i => i.Id)
.ToListAsync();
afterIds.ShouldBe(originalIds);
PlayoutScheduleItemFillGroupIndex fillGroup = await ctx.Set<PlayoutScheduleItemFillGroupIndex>()
.Include(x => x.EnumeratorState)
.SingleAsync();
fillGroup.ProgramScheduleItemId.ShouldBe(fillGroupItemId);
fillGroup.EnumeratorState.Seed.ShouldBe(12345);
fillGroup.EnumeratorState.Index.ShouldBe(7);
}
}
[Test]
public async Task Reorder_Should_Preserve_Untouched_Item_Id_And_FillGroup_State()
{
int scheduleId = await SeedSchedule();
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
var items = new List<ReplaceProgramScheduleItem>
{
MakeItem(0, PlayoutMode.One, "a"),
MakeItem(1, PlayoutMode.One, "b"),
MakeItem(2, PlayoutMode.Multiple, "c") with { FillWithGroupMode = FillWithGroupMode.FillWithOrderedGroups }
};
(await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, items), CancellationToken.None))
.IsRight.ShouldBeTrue();
List<int> originalIds;
int untouchedItemId;
await using (TvContext ctx = _db.CreateContext())
{
List<ProgramScheduleItem> persisted = await ctx.ProgramScheduleItems
.Where(i => i.ProgramScheduleId == scheduleId)
.OrderBy(i => i.Index)
.ToListAsync();
originalIds = persisted.Select(i => i.Id).OrderBy(id => id).ToList();
untouchedItemId = persisted[2].Id; // the item at index 2 ("c") is not reordered below
ctx.Add(new PlayoutScheduleItemFillGroupIndex
{
PlayoutId = 1,
ProgramScheduleItemId = untouchedItemId,
EnumeratorState = new CollectionEnumeratorState { Seed = 99, Index = 4 }
});
await ctx.SaveChangesAsync();
}
// Act: swap the first two items; the third ("c") is unchanged and stays at index 2.
var reordered = new List<ReplaceProgramScheduleItem>
{
items[1] with { Index = 0 },
items[0] with { Index = 1 },
items[2]
};
(await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, reordered), CancellationToken.None))
.IsRight.ShouldBeTrue();
await using (TvContext ctx = _db.CreateContext())
{
// no item row was cascade-deleted: the full id set is intact
List<int> afterIds = await ctx.ProgramScheduleItems
.Where(i => i.ProgramScheduleId == scheduleId)
.Select(i => i.Id)
.OrderBy(id => id)
.ToListAsync();
afterIds.ShouldBe(originalIds);
// the untouched item kept its id and its fill-group state
PlayoutScheduleItemFillGroupIndex fillGroup = await ctx.Set<PlayoutScheduleItemFillGroupIndex>()
.Include(x => x.EnumeratorState)
.SingleAsync();
fillGroup.ProgramScheduleItemId.ShouldBe(untouchedItemId);
fillGroup.EnumeratorState.Index.ShouldBe(4);
}
}
[Test]
public async Task InPlace_Update_Should_Still_Apply_Watermark_Edits()
{
int scheduleId = await SeedSchedule();
await using (TvContext ctx = _db.CreateContext())
{
// the response projection hydrates watermark navs, so the referenced rows must exist
ctx.Add(new ChannelWatermark { Id = 1, Name = "WM One" });
ctx.Add(new ChannelWatermark { Id = 2, Name = "WM Two" });
await ctx.SaveChangesAsync();
}
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
var initial = new List<ReplaceProgramScheduleItem> { MakeItem(0, PlayoutMode.One, "a") with { WatermarkIds = [1] } };
(await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, initial), CancellationToken.None))
.IsRight.ShouldBeTrue();
int itemId;
await using (TvContext ctx = _db.CreateContext())
{
itemId = await ctx.ProgramScheduleItems.Where(i => i.ProgramScheduleId == scheduleId).Select(i => i.Id).SingleAsync();
}
// change only the watermark set; same slot, same subtype -> in-place update
var edited = new List<ReplaceProgramScheduleItem> { MakeItem(0, PlayoutMode.One, "a") with { WatermarkIds = [2] } };
(await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, edited), CancellationToken.None))
.IsRight.ShouldBeTrue();
await using (TvContext ctx = _db.CreateContext())
{
ProgramScheduleItem persisted = await ctx.ProgramScheduleItems
.Include(i => i.ProgramScheduleItemWatermarks)
.Where(i => i.ProgramScheduleId == scheduleId)
.SingleAsync();
persisted.Id.ShouldBe(itemId); // in-place: id preserved
persisted.ProgramScheduleItemWatermarks.Select(w => w.WatermarkId).ShouldBe([2]); // edit applied, old removed
}
}
[Test]
public async Task Subtype_Change_Should_Replace_The_Item()
{
int scheduleId = await SeedSchedule();
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
var initial = new List<ReplaceProgramScheduleItem> { MakeItem(0, PlayoutMode.One, "a") };
(await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, initial), CancellationToken.None))
.IsRight.ShouldBeTrue();
int originalId;
await using (TvContext ctx = _db.CreateContext())
{
originalId = await ctx.ProgramScheduleItems.Where(i => i.ProgramScheduleId == scheduleId).Select(i => i.Id).SingleAsync();
}
// EF cannot change a TPH row's type in place, so a mode change replaces the item (new id).
var changed = new List<ReplaceProgramScheduleItem>
{
MakeItem(0, PlayoutMode.Duration, "a") with { PlayoutDuration = TimeSpan.FromMinutes(30), DiscardToFillAttempts = 0 }
};
(await handler.Handle(new ReplaceProgramScheduleItems(scheduleId, changed), CancellationToken.None))
.IsRight.ShouldBeTrue();
await using (TvContext ctx = _db.CreateContext())
{
ProgramScheduleItem persisted = await ctx.ProgramScheduleItems
.Where(i => i.ProgramScheduleId == scheduleId)
.SingleAsync();
persisted.ShouldBeOfType<ProgramScheduleItemDuration>();
persisted.Id.ShouldNotBe(originalId);
}
}
private async Task<int> SeedSchedule()
{
await using TvContext ctx = _db.CreateContext();
var schedule = new ProgramSchedule
{
Name = "Reconcile",
Items = [],
Playouts = [],
ProgramScheduleAlternates = []
};
ctx.ProgramSchedules.Add(schedule);
await ctx.SaveChangesAsync();
return schedule.Id;
}
private static ReplaceProgramScheduleItem MakeItem(int index, PlayoutMode mode, string searchQuery) =>
new(
index,
StartType.Dynamic,
StartTime: null,
FixedStartTimeBehavior: null,
mode,
CollectionType.SearchQuery,
CollectionId: null,
MultiCollectionId: null,
SmartCollectionId: null,
RerunCollectionId: null,
MediaItemId: null,
PlaylistId: null,
SearchTitle: searchQuery,
SearchQuery: searchQuery,
PlaybackOrder.Shuffle,
MarathonGroupBy.None,
MarathonShuffleGroups: false,
MarathonShuffleItems: false,
MarathonBatchSize: null,
FillWithGroupMode.None,
MultipleMode.Count,
MultipleCount: "1",
PlayoutDuration: null,
TailMode.None,
DiscardToFillAttempts: null,
CustomTitle: null,
GuideMode.Normal,
PreRollFillerId: null,
MidRollFillerId: null,
PostRollFillerId: null,
TailFillerId: null,
FallbackFillerId: null,
WatermarkIds: [],
GraphicsElementIds: [],
PreferredAudioLanguageCode: null,
PreferredAudioTitle: null,
PreferredSubtitleLanguageCode: null,
SubtitleMode: null);
}
@@ -0,0 +1,196 @@
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);
}