using ErsatzTV.Application.MediaCollections; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Tests.Support; using LanguageExt; using NSubstitute; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Application.MediaCollections; /// /// Pins the per-source Weight bounds (#70) on BOTH write paths, driven from one shared case list. /// /// The bug this guards against was create and update disagreeing, not either one being wrong on its /// own: EF's HasDefaultValue(1) substitutes 1 for a 0 on INSERT (0 reads as "not set") while an /// UPDATE writes the 0 through, so the same request body landed differently depending on the verb. Testing the /// two paths against the same inputs is the point — a per-path test would have passed while they diverged. /// /// [TestFixture] public class MultiCollectionWeightValidationTests : MediaCollectionHandlerTestBase { private const int CollectionId = 1; private const int SmartCollectionId = 2; private static readonly int[] RejectedWeights = [0, -1, -5, MultiCollectionItemWeight.Maximum + 1, int.MaxValue]; private static readonly int[] AcceptedWeights = [MultiCollectionItemWeight.Minimum, 2, 500, MultiCollectionItemWeight.Maximum]; [SetUp] public async Task SetUp() { await SeedCollection(CollectionId); await SeedSmartCollection(SmartCollectionId); } private CreateMultiCollectionHandler CreateHandler() => new(Db.Factory, SearchTargets); private UpdateMultiCollectionHandler UpdateHandler() { var repo = Substitute.For(); // the handler enqueues a playout rebuild for every playout using the collection once a real change // lands; an unstubbed mock returns null there and the NRE looks like a product bug repo.PlayoutIdsUsingMultiCollection(Arg.Any()).Returns([]); return new UpdateMultiCollectionHandler(Db.Factory, repo, Worker, SearchTargets); } private static CreateMultiCollection CreateCommand(string name, int weight) => new(name, [new CreateMultiCollectionItem(CollectionId, null, true, PlaybackOrder.WeightedShuffle, weight)]); private static CreateMultiCollection CreateSmartCommand(string name, int weight) => new( name, [new CreateMultiCollectionItem(null, SmartCollectionId, true, PlaybackOrder.WeightedShuffle, weight)]); private async Task SeedMultiCollection(string name) { Either created = await CreateHandler().Handle(CreateCommand(name, MultiCollectionItemWeight.Minimum), CancellationToken.None); created.IsRight.ShouldBeTrue(); return created.RightToSeq().Head().Id; } [Test] public async Task Create_Rejects_Out_Of_Range_Weight([ValueSource(nameof(RejectedWeights))] int weight) { Either result = await CreateHandler().Handle(CreateCommand($"create-{weight}", weight), CancellationToken.None); result.IsLeft.ShouldBeTrue($"weight {weight} must be rejected"); result.LeftToSeq().Head().Value.ShouldContain("Weight must be between"); } [Test] public async Task Update_Rejects_Out_Of_Range_Weight([ValueSource(nameof(RejectedWeights))] int weight) { // the path that actually persisted a 0 before #402 -- EF's INSERT default masked it on create int id = await SeedMultiCollection($"update-{weight}"); Either result = await UpdateHandler().Handle( new UpdateMultiCollection( id, $"update-{weight}", [new UpdateMultiCollectionItem(CollectionId, null, true, PlaybackOrder.WeightedShuffle, weight)]), CancellationToken.None); result.IsLeft.ShouldBeTrue($"weight {weight} must be rejected"); result.LeftToSeq().Head().Value.ShouldContain("Weight must be between"); } [Test] public async Task Create_Accepts_In_Range_Weight([ValueSource(nameof(AcceptedWeights))] int weight) { Either result = await CreateHandler().Handle(CreateCommand($"ok-create-{weight}", weight), CancellationToken.None); result.IsRight.ShouldBeTrue($"weight {weight} must be accepted"); } [Test] public async Task Update_Accepts_In_Range_Weight([ValueSource(nameof(AcceptedWeights))] int weight) { int id = await SeedMultiCollection($"ok-update-{weight}"); Either result = await UpdateHandler().Handle( new UpdateMultiCollection( id, $"ok-update-{weight}", [new UpdateMultiCollectionItem(CollectionId, null, true, PlaybackOrder.WeightedShuffle, weight)]), CancellationToken.None); result.IsRight.ShouldBeTrue($"weight {weight} must be accepted"); } [Test] public async Task Create_Rejects_Out_Of_Range_Weight_On_A_Smart_Item() { // the smart mirror is the half that gets forgotten; the gate must cover it too Either result = await CreateHandler().Handle(CreateSmartCommand("smart-bad", 0), CancellationToken.None); result.IsLeft.ShouldBeTrue(); result.LeftToSeq().Head().Value.ShouldContain("Weight must be between"); } [Test] public async Task Update_And_Create_Agree_On_Every_Case() { // the regression that started B2: same input, different verb, different outcome. Asserted directly // rather than inferred from the two suites above passing. foreach (int weight in RejectedWeights.Concat(AcceptedWeights)) { Either created = await CreateHandler() .Handle(CreateCommand($"agree-{weight}", weight), CancellationToken.None); int id = await SeedMultiCollection($"agree-base-{weight}"); Either updated = await UpdateHandler().Handle( new UpdateMultiCollection( id, $"agree-base-{weight}", [new UpdateMultiCollectionItem(CollectionId, null, true, PlaybackOrder.WeightedShuffle, weight)]), CancellationToken.None); updated.IsRight.ShouldBe( created.IsRight, $"create and update disagreed on weight {weight}"); } } }