Files
ersatztv/ErsatzTV.Tests/Application/ProgramSchedules/ProgramScheduleHandlerTests.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

219 lines
7.2 KiB
C#

using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Application.ProgramSchedules;
[TestFixture]
public class ProgramScheduleHandlerTests
{
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 Update_Should_Return_NotFoundError_When_Schedule_Missing()
{
var handler = new UpdateProgramScheduleHandler(_db.Factory, _worker);
Either<BaseError, UpdateProgramScheduleResult> result =
await handler.Handle(MakeUpdate(999, "Missing"), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task Delete_Should_Return_NotFoundError_When_Schedule_Missing()
{
var handler = new DeleteProgramScheduleHandler(_db.Factory);
Either<BaseError, Unit> result =
await handler.Handle(new DeleteProgramSchedule(999), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task AddItem_Should_Return_NotFoundError_When_Schedule_Missing()
{
var handler = new AddProgramScheduleItemHandler(_db.Factory, _worker);
Either<BaseError, ProgramScheduleItemViewModel> result =
await handler.Handle(MakeAdd(999, CollectionType.SearchQuery), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task AddItem_Should_Return_ValidationError_When_Collection_Type_Is_Invalid()
{
int scheduleId = await SeedSchedule();
var handler = new AddProgramScheduleItemHandler(_db.Factory, _worker);
Either<BaseError, ProgramScheduleItemViewModel> result =
await handler.Handle(MakeAdd(scheduleId, CollectionType.Collection), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("[Collection] is required");
}
[Test]
public async Task ReplaceItems_Should_Return_NotFoundError_When_Schedule_Missing()
{
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result =
await handler.Handle(
new ReplaceProgramScheduleItems(999, [MakeReplace(0)]),
CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task DeleteItem_Should_Return_NotFoundError_When_Item_Missing()
{
int scheduleId = await SeedSchedule();
var handler = new DeleteProgramScheduleItemHandler(_db.Factory, _worker);
Either<BaseError, Unit> result =
await handler.Handle(new DeleteProgramScheduleItem(scheduleId, 999), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
private async Task<int> SeedSchedule()
{
await using TvContext context = _db.CreateContext();
var schedule = new ProgramSchedule
{
Name = "Handlers",
Items = [],
Playouts = [],
ProgramScheduleAlternates = []
};
context.ProgramSchedules.Add(schedule);
await context.SaveChangesAsync();
return schedule.Id;
}
private static UpdateProgramSchedule MakeUpdate(int scheduleId, string name) =>
new(
scheduleId,
name,
KeepMultiPartEpisodesTogether: true,
TreatCollectionsAsShows: true,
ShuffleScheduleItems: false,
RandomStartPoint: false,
FixedStartTimeBehavior.Flexible);
private static AddProgramScheduleItem MakeAdd(int scheduleId, CollectionType collectionType) =>
new(
scheduleId,
StartType.Dynamic,
StartTime: null,
FixedStartTimeBehavior: null,
PlayoutMode.One,
collectionType,
CollectionId: null,
MultiCollectionId: null,
SmartCollectionId: null,
RerunCollectionId: null,
MediaItemId: null,
PlaylistId: null,
SearchTitle: "News",
SearchQuery: "news",
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);
private static ReplaceProgramScheduleItem MakeReplace(int index)
{
AddProgramScheduleItem add = MakeAdd(1, CollectionType.SearchQuery);
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 BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
}