Files
ersatztv/ErsatzTV.Tests/Integration/ScheduleItemTptIntegrationTests.cs
T
timothyandClaude Opus 4.8 02a493e95e feat(#259): id-based reconcile for schedule-items replace (backend + DTO)
Add optional `int? Id` to ScheduleItemRequest/ReplaceProgramScheduleItem so
a client can round-trip each existing item's server id. When ids are present,
ReplaceProgramScheduleItemsHandler reconciles by id (not array position), so an
item's persisted fill-group/shuffle state (PlayoutScheduleItemFillGroupIndex,
FK OnDelete Cascade) follows the logical item across reorders/inserts instead of
being inherited by whatever previously occupied its new slot (#259, split from
#252/#253). A fully id-less payload keeps the verbatim positional fallback.

Guards (inside PersistItems, after CheckVersion so 412 precedes 422): duplicate
id -> 422; id not in this schedule -> 422 (a stale id under Phase-1 force-write is
a live lost-update signal, not a new item). Index stays array-position derived.

Regenerated v1.json + TS client.

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

190 lines
6.6 KiB
C#

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.Integration;
[TestFixture]
public class ScheduleItemTptIntegrationTests
{
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 AddProgramScheduleItem_Should_Create_Tpt_Subtype_Row_For_Each_PlayoutMode()
{
int scheduleId = await SeedSchedule();
var handler = new AddProgramScheduleItemHandler(_db.Factory, _worker);
foreach (PlayoutMode playoutMode in new[]
{
PlayoutMode.One,
PlayoutMode.Multiple,
PlayoutMode.Flood,
PlayoutMode.Duration
})
{
Either<BaseError, ProgramScheduleItemViewModel> result =
await handler.Handle(MakeAdd(scheduleId, playoutMode), CancellationToken.None);
result.IsRight.ShouldBeTrue();
}
await using TvContext context = _db.CreateContext();
(await CountRows(context, "ProgramScheduleOneItem")).ShouldBe(1);
(await CountRows(context, "ProgramScheduleMultipleItem")).ShouldBe(1);
(await CountRows(context, "ProgramScheduleFloodItem")).ShouldBe(1);
(await CountRows(context, "ProgramScheduleDurationItem")).ShouldBe(1);
}
[Test]
public async Task ReplaceProgramScheduleItems_Should_Create_Tpt_Subtype_Rows_For_Replaced_Items()
{
int scheduleId = await SeedSchedule();
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result =
await handler.Handle(
new ReplaceProgramScheduleItems(
scheduleId,
[
MakeReplace(0, PlayoutMode.One),
MakeReplace(1, PlayoutMode.Multiple),
MakeReplace(2, PlayoutMode.Flood),
MakeReplace(3, PlayoutMode.Duration)
]),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext context = _db.CreateContext();
(await CountRows(context, "ProgramScheduleOneItem")).ShouldBe(1);
(await CountRows(context, "ProgramScheduleMultipleItem")).ShouldBe(1);
(await CountRows(context, "ProgramScheduleFloodItem")).ShouldBe(1);
(await CountRows(context, "ProgramScheduleDurationItem")).ShouldBe(1);
}
private async Task<int> SeedSchedule()
{
await using TvContext context = _db.CreateContext();
var schedule = new ProgramSchedule
{
Name = "Integration",
Items = [],
Playouts = [],
ProgramScheduleAlternates = []
};
context.ProgramSchedules.Add(schedule);
await context.SaveChangesAsync();
return schedule.Id;
}
private static AddProgramScheduleItem MakeAdd(int scheduleId, PlayoutMode playoutMode) =>
new(
scheduleId,
StartType.Dynamic,
StartTime: null,
FixedStartTimeBehavior: null,
playoutMode,
CollectionType.SearchQuery,
CollectionId: null,
MultiCollectionId: null,
SmartCollectionId: null,
RerunCollectionId: null,
MediaItemId: null,
PlaylistId: null,
SearchTitle: "News",
SearchQuery: "news",
PlaybackOrder: PlaybackOrder.Shuffle,
MarathonGroupBy: MarathonGroupBy.None,
MarathonShuffleGroups: false,
MarathonShuffleItems: false,
MarathonBatchSize: null,
FillWithGroupMode: FillWithGroupMode.None,
MultipleMode: MultipleMode.Count,
MultipleCount: "2",
PlayoutDuration: TimeSpan.FromMinutes(30),
TailMode: TailMode.None,
DiscardToFillAttempts: 3,
CustomTitle: null,
GuideMode: GuideMode.Normal,
PreRollFillerId: null,
MidRollFillerId: null,
PostRollFillerId: null,
TailFillerId: null,
FallbackFillerId: null,
WatermarkIds: [],
GraphicsElementIds: [],
PreferredAudioLanguageCode: null,
PreferredAudioTitle: null,
PreferredSubtitleLanguageCode: null,
SubtitleMode: null);
private static ReplaceProgramScheduleItem MakeReplace(int index, PlayoutMode playoutMode)
{
AddProgramScheduleItem add = MakeAdd(1, playoutMode);
return new ReplaceProgramScheduleItem(
null,
index,
add.StartType,
add.StartTime,
add.FixedStartTimeBehavior,
add.PlayoutMode,
add.CollectionType,
add.CollectionId,
add.MultiCollectionId,
add.SmartCollectionId,
add.RerunCollectionId,
add.MediaItemId,
add.PlaylistId,
add.SearchTitle,
add.SearchQuery,
add.PlaybackOrder,
add.MarathonGroupBy,
add.MarathonShuffleGroups,
add.MarathonShuffleItems,
add.MarathonBatchSize,
add.FillWithGroupMode,
add.MultipleMode,
add.MultipleCount,
add.PlayoutDuration,
add.TailMode,
add.DiscardToFillAttempts,
add.CustomTitle,
add.GuideMode,
add.PreRollFillerId,
add.MidRollFillerId,
add.PostRollFillerId,
add.TailFillerId,
add.FallbackFillerId,
add.WatermarkIds,
add.GraphicsElementIds,
add.PreferredAudioLanguageCode,
add.PreferredAudioTitle,
add.PreferredSubtitleLanguageCode,
add.SubtitleMode);
}
private static Task<int> CountRows(TvContext context, string tableName) =>
context.Database.SqlQueryRaw<int>($"SELECT COUNT(*) AS Value FROM {tableName}").SingleAsync();
}