Files
ersatztv/ErsatzTV.Tests/Application/Channels/UpdateChannelGraphicsElementsTests.cs
T
timothyandClaude Fable 5.1 e7f794057d fix(568): bound the client-supplied id lists, name their field, and make Kind part of the built-in identity
Three of the four findings standing on the 2026-09-05 16:24 review verdict, which the
branch had not answered.

The count cap is the blocking one. The three id-list validators took whatever the
request carried, so the only bound on `graphicsElementIds`/`watermarkIds` was the
Kestrel body cap -- a transport limit, not a collection limit. The earlier disposition
deferred it to #917 on the grounds that `ApplyUpdateRequest` reconciles the same list
uncapped anyway; that is true and does not answer the ask, because the reconcile is
downstream of a validator that can refuse the request outright. One shared
`Validators.IdsMustExist` now carries the cap for all three, counted on the RAW list
before `Distinct` (a million copies of one id costs the same to parse and materialize
whatever the distinct count is) and before any database work.

The same helper is where the field name and the diagnostic cap now live. The 422 said
"Graphics element(s) do not exist: 999" without naming which request field carried the
999, and echoed every rejected id -- an oversized request answered with an oversized
response. Both fixed once, in the shared place, so the three sites cannot drift.

`Kind` moves into `GraphicsElementDefaults.IsOnNowNext`. The seeder required
`Kind == Text` and the API's `builtIn` did not, so an Image row at the exact seeded path
was `builtIn:true` on the wire while `GetBuiltInElementId` refused to treat it as the
built-in element -- two sites disagreeing about one row, which is the shape #568 exists
to close. Identity is now one predicate applied whole at both sites; the seeder's SQL
`Kind` filter is gone rather than kept as a duplicate, since a duplicate guard would mask
the predicate's own clause.

Also the fourth finding, the check-then-write race: `RefreshGraphicsElements` can delete a
validated element between `Validate` and `SaveChangesAsync`, handing the join insert the
FK violation the validator exists to prevent. A transaction does not close it -- neither
provider locks rows the validator merely read -- so both handlers catch `DbUpdateException`,
re-ask the existence question on a fresh context, and return the validator's own 422 when
an id has since gone; anything else keeps its own exception. Foreign keys are off in
`InMemoryTvContext`, so the trigger is simulated by an armed save-failure interceptor while
the recovery itself runs against real post-delete state.

Refs #568

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 21:35:15 +02:00

274 lines
12 KiB
C#

using System.Globalization;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class UpdateChannelGraphicsElementsTests : ChannelHandlerTestBase
{
private UpdateChannelHandler MakeHandler() => new(Worker, Db.Factory, SearchTargets, RemoteLogoCacher);
private static BaseError LeftOf(Either<BaseError, ChannelViewModel> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
private async Task<(int ElementAId, int ElementBId)> SeedGraphicsElements()
{
await using TvContext context = Db.CreateContext();
var elementA = new GraphicsElement { Path = "element-a.yml" };
var elementB = new GraphicsElement { Path = "element-b.yml" };
context.GraphicsElements.AddRange(elementA, elementB);
await context.SaveChangesAsync();
return (elementA.Id, elementB.Id);
}
private async Task<List<int>> SeedGraphicsElements(int count)
{
await using TvContext context = Db.CreateContext();
List<GraphicsElement> elements = Enumerable.Range(0, count)
.Select(i => new GraphicsElement { Path = $"element-{i}.yml" })
.ToList();
context.GraphicsElements.AddRange(elements);
await context.SaveChangesAsync();
return elements.Select(e => e.Id).ToList();
}
// Replaces the harness the base class built with one whose SaveChangesAsync can be made to fail
// on demand. Foreign keys are off in InMemoryTvContext, so the FK violation a concurrent delete
// really produces cannot be raised by seeding alone.
private async Task<ArmedSaveFailureInterceptor> UseFailingSaveHarness()
{
await Db.DisposeAsync();
var interceptor = new ArmedSaveFailureInterceptor();
Db = await InMemoryTvContext.CreateAsync(interceptor);
return interceptor;
}
[Test]
public async Task Should_Reconcile_GraphicsElement_Join_Add_Then_Remove()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
(int elementAId, int elementBId) = await SeedGraphicsElements();
Either<BaseError, ChannelViewModel> addResult = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [elementAId, elementBId]),
CancellationToken.None);
addResult.IsRight.ShouldBeTrue();
await using (TvContext context = Db.CreateContext())
{
Channel reloaded = await context.Channels.Include(c => c.ChannelGraphicsElements)
.SingleAsync(c => c.Id == channel.Id);
reloaded.ChannelGraphicsElements.Select(x => x.GraphicsElementId)
.OrderBy(id => id)
.ShouldBe(new[] { elementAId, elementBId }.OrderBy(id => id));
}
Either<BaseError, ChannelViewModel> removeResult = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [elementAId]),
CancellationToken.None);
removeResult.IsRight.ShouldBeTrue();
await using (TvContext context = Db.CreateContext())
{
Channel reloaded = await context.Channels.Include(c => c.ChannelGraphicsElements)
.SingleAsync(c => c.Id == channel.Id);
reloaded.ChannelGraphicsElements.Select(x => x.GraphicsElementId).ShouldBe(new[] { elementAId });
}
Either<BaseError, ChannelViewModel> clearResult = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: []),
CancellationToken.None);
clearResult.IsRight.ShouldBeTrue();
await using (TvContext context = Db.CreateContext())
{
Channel reloaded = await context.Channels.Include(c => c.ChannelGraphicsElements)
.SingleAsync(c => c.Id == channel.Id);
reloaded.ChannelGraphicsElements.ShouldBeEmpty();
}
}
// #568: an unknown graphicsElementIds entry used to reach ApplyUpdateRequest unchecked, which
// blindly Adds a ChannelGraphicsElement and lets SaveChangesAsync hit
// FK_ChannelGraphicsElement_GraphicsElement_GraphicsElementId -> unhandled 500. Removing the
// GraphicsElementIdsMustExist validator alone from UpdateChannelHandler.Validate is row 35 of the
// mutation table in docs/graphics-elements.md, measured against the whole ErsatzTV.Tests project.
[Test]
public async Task Should_Reject_Unknown_GraphicsElementId_With_422_Not_500()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [999]),
CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain("999");
// no partial write: the channel keeps no graphics element association
await using TvContext context = Db.CreateContext();
Channel reloaded = await context.Channels.Include(c => c.ChannelGraphicsElements)
.SingleAsync(c => c.Id == channel.Id);
reloaded.ChannelGraphicsElements.ShouldBeEmpty();
}
[Test]
public async Task Should_Reject_When_One_Of_Several_GraphicsElementIds_Is_Unknown()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
(int elementAId, _) = await SeedGraphicsElements();
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [elementAId, 12345]),
CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain("12345");
}
// #568: the id list is client-supplied and was bounded only by the Kestrel body cap, which is a
// transport limit and not a collection limit. The cap is Validators.MaximumIdListCount, shared
// by all three id-list validators; these three tests pin its two edges and the shape that makes
// its placement matter. Removing the cap from Validators.IdsMustExist is row 41 of the mutation
// table in docs/graphics-elements.md.
[Test]
public async Task Should_Accept_Exactly_The_Maximum_Number_Of_GraphicsElementIds()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
List<int> ids = await SeedGraphicsElements(Validators.MaximumIdListCount);
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: ids),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext context = Db.CreateContext();
Channel reloaded = await context.Channels.Include(c => c.ChannelGraphicsElements)
.SingleAsync(c => c.Id == channel.Id);
reloaded.ChannelGraphicsElements.Count.ShouldBe(Validators.MaximumIdListCount);
}
[Test]
public async Task Should_Reject_One_More_Than_The_Maximum_Number_Of_GraphicsElementIds()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
List<int> ids = Enumerable.Range(1, Validators.MaximumIdListCount + 1).ToList();
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: ids),
CancellationToken.None);
BaseError error = LeftOf(result);
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain((Validators.MaximumIdListCount + 1).ToString(CultureInfo.InvariantCulture));
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
}
// The cap counts the RAW list, before Distinct: every one of these ids exists and they collapse
// to a single distinct id, so a cap applied after deduplication would accept this request and
// leave the parse and materialization it costs unbounded.
[Test]
public async Task Should_Reject_A_Duplicate_Heavy_List_On_Its_Raw_Count()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
(int elementAId, _) = await SeedGraphicsElements();
List<int> ids = Enumerable.Repeat(elementAId, Validators.MaximumIdListCount + 1).ToList();
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: ids),
CancellationToken.None);
BaseError error = LeftOf(result);
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain((Validators.MaximumIdListCount + 1).ToString(CultureInfo.InvariantCulture));
error.Value.ShouldNotContain("do not exist");
}
// A 422 that echoes every rejected id turns an oversized request into an oversized response.
// Removing the truncation from Validators.DescribeIds is row 42 of the mutation table.
[Test]
public async Task Should_Cap_The_Ids_Echoed_Back_In_The_Unknown_Id_422()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
List<int> ids = Enumerable.Range(1001, 30).ToList();
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: ids),
CancellationToken.None);
BaseError error = LeftOf(result);
error.Value.ShouldContain("1001");
error.Value.ShouldContain("(and 20 more)");
error.Value.ShouldNotContain("1030");
}
// #568: validation and the write are two statements, so RefreshGraphicsElements can delete a
// validated element in between and hand the join insert the FK violation the validator exists to
// prevent -- the unhandled 500 again. Removing the DbUpdateException catch from
// ApplyUpdateRequestTranslatingLostRace is row 43 of the mutation table.
[Test]
public async Task Should_Translate_An_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422()
{
ArmedSaveFailureInterceptor interceptor = await UseFailingSaveHarness();
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
(int elementAId, _) = await SeedGraphicsElements();
interceptor.SqlBeforeFailing =
$"DELETE FROM GraphicsElement WHERE Id = {elementAId.ToString(CultureInfo.InvariantCulture)}";
interceptor.Armed = true;
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [elementAId]),
CancellationToken.None);
BaseError error = LeftOf(result);
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain(elementAId.ToString(CultureInfo.InvariantCulture));
}
// The other half of that catch: a DbUpdateException whose cause is NOT a missing graphics
// element is a real fault and must keep its own exception rather than be reported to the client
// as a validation error about ids that are all still present.
[Test]
public async Task Should_Not_Report_An_Unrelated_DbUpdateException_As_A_Graphics_Element_422()
{
ArmedSaveFailureInterceptor interceptor = await UseFailingSaveHarness();
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
(int elementAId, _) = await SeedGraphicsElements();
interceptor.Armed = true;
await Should.ThrowAsync<DbUpdateException>(
async () => await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [elementAId]),
CancellationToken.None));
}
}