Re-review of the fix commit returned MERGEABLE-WITH-NITS. It verified the gate is now complete by enumerating the writers itself (no fourth persisting writer) and proved B2's fix works by writing throwaway handler tests — which was also its point: the fix shipped with none. B2 was create and update silently DISAGREEING on the same input, and the fix re-established agreement with nothing pinning it. Both paths are now driven from one shared case list, plus an explicit test that create and update agree on every case — the per-path tests would both have passed while the two diverged, which is how the bug existed in the first place. Non-vacuity proven: inverting only the update path's validation fails 10 of 20 on a clean build (0 errors, so not a stale-dll pass), and the agreement test is among the failures. The rest is my own prose contradicting my own code. The commit that added EffectiveWeight removed the weight filter, then left four statements asserting a 0-weight source "is filtered out" — two of them authored by that same commit, including the stated justification for Minimum=1 in MultiCollectionItemWeight. A future agent could have read that and deleted the clamp or the floor as redundant; they are belt-and-braces and neither is. Corrected to describe what the code now does: the gate refuses input that means nothing on a share-of-airtime scale, the clamp protects rows predating the gate. Also corrected the writer count in the very bullet whose lesson is "grep every writer of the field": ReplaceBlockItems writes BlockItem.PlaybackOrder, not PlaylistItem.PlaybackOrder. There are TWO persisting writers of PlaylistItem's, and the correction itself had miscounted by conflating the two fields — so the lesson now says to grep each field separately. Core.Tests 565 passed, ErsatzTV.Tests 1673 passed, 0 failed. Refs #70 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
154 lines
6.5 KiB
C#
154 lines
6.5 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Pins the per-source <c>Weight</c> bounds (#70) on BOTH write paths, driven from one shared case list.
|
|
/// <para>
|
|
/// The bug this guards against was create and update <i>disagreeing</i>, not either one being wrong on its
|
|
/// own: EF's <c>HasDefaultValue(1)</c> substitutes 1 for a <c>0</c> 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.
|
|
/// </para>
|
|
/// </summary>
|
|
[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<IMediaCollectionRepository>();
|
|
|
|
// 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<int>()).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<int> SeedMultiCollection(string name)
|
|
{
|
|
Either<BaseError, MultiCollectionViewModel> 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<BaseError, MultiCollectionViewModel> 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<BaseError, Unit> 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<BaseError, MultiCollectionViewModel> 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<BaseError, Unit> 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<BaseError, MultiCollectionViewModel> 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<BaseError, MultiCollectionViewModel> created = await CreateHandler()
|
|
.Handle(CreateCommand($"agree-{weight}", weight), CancellationToken.None);
|
|
|
|
int id = await SeedMultiCollection($"agree-base-{weight}");
|
|
Either<BaseError, Unit> 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}");
|
|
}
|
|
}
|
|
}
|