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}");
}
}
}