Two holes the review round found in the previous fix, both of the same shape: a guard that names its own fields instead of deriving them. The deco validators short-circuited the entire Validators.IdsMustExist call when the DecoMode does not consume the ids, which took the 512-item raw-count cap with it -- an arbitrarily large array under Inherit/Disable parsed and materialized with nothing bounding it. Only the EXISTENCE half is the apply path's business, so the mode predicate is now a required argument of the shared validator and gates that half alone; the cap runs under every mode. The channel recovery path rechecked GraphicsElementIdsMustExist alone, so a watermark deleted between validation and SaveChangesAsync still surfaced as the unhandled 500 the fix exists to remove -- WatermarkId, FFmpegProfileId, FallbackFillerId and MirrorSourceChannelId are all written by the same save and lose the same race. Both handlers now re-ask the whole of Validate on DbUpdateException, so a validator added later is covered without editing the recovery path. The API-site outside-folder discriminator test seeded an Image row, so the Kind conjunct rejected it whatever the path comparison did: a composite revert to Path.GetFileName(path) == filename && kind == Text passed every API test. It now carries the seeded Kind, mirroring the seeder-site twin, so only the path half can reject it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
171 lines
7.0 KiB
C#
171 lines
7.0 KiB
C#
using ErsatzTV.Application.Graphics;
|
|
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Api.Graphics;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Graphics;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using ErsatzTV.Tests.Support;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
|
|
namespace ErsatzTV.Tests.Application.Graphics;
|
|
|
|
[TestFixture]
|
|
public class GraphicsElementHandlerTests
|
|
{
|
|
private InMemoryTvContext _db = null!;
|
|
|
|
[SetUp]
|
|
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
|
|
|
[TearDown]
|
|
public async Task TearDown() => await _db.DisposeAsync();
|
|
|
|
[Test]
|
|
public async Task GetAllGraphicsElementsForApi_Should_Return_All_Elements()
|
|
{
|
|
await SeedElement(1, "watermark.png", GraphicsElementKind.Image, "Custom Watermark");
|
|
await SeedElement(2, "clock.png", GraphicsElementKind.Image, string.Empty);
|
|
|
|
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
|
|
|
|
List<GraphicsElementResponseModel> result =
|
|
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
|
|
|
|
result.Count.ShouldBe(2);
|
|
result.Select(e => e.Id).ShouldBe([1, 2]);
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetAllGraphicsElementsForApi_Should_Order_Named_Elements_Before_Unnamed()
|
|
{
|
|
// unnamed element's projected Name equals its FileName -> should sort after named elements
|
|
await SeedElement(1, "aaa.png", GraphicsElementKind.Image, string.Empty);
|
|
await SeedElement(2, "zzz.png", GraphicsElementKind.Image, "A Custom Name");
|
|
|
|
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
|
|
|
|
List<GraphicsElementResponseModel> result =
|
|
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
|
|
|
|
result.Count.ShouldBe(2);
|
|
result[0].Id.ShouldBe(2);
|
|
result[1].Id.ShouldBe(1);
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetAllGraphicsElementsForApi_Should_Return_Empty_List_When_None_Exist()
|
|
{
|
|
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
|
|
|
|
List<GraphicsElementResponseModel> result =
|
|
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
|
|
|
|
result.ShouldBeEmpty();
|
|
}
|
|
|
|
// #568: the discriminator used to be Path.GetFileName(e.Path) == OnNowNextFileName, which is
|
|
// folder-agnostic -- a user element named exactly "on-now-next.yml" outside the seeded text
|
|
// template folder would also report builtIn:true. The row carries the SAME Kind as the real
|
|
// seeded element, deliberately: a wrong-kind row here would be rejected by the Kind conjunct
|
|
// whatever the path comparison is, so the composite revert (filename AND Kind == Text) would
|
|
// pass. Only the PATH half can reject a Text row in another folder, which is what this pins --
|
|
// it mirrors the seeder-site Ignores_A_Same_Named_Same_Kind_Element_Outside_The_Seeded_Folder.
|
|
[Test]
|
|
public async Task GetAllGraphicsElementsForApi_Should_Not_Mark_Same_Filename_Outside_Seeded_Folder_As_BuiltIn()
|
|
{
|
|
string userElementPath = System.IO.Path.Combine(
|
|
"/config/graphics-elements/text/some-subfolder",
|
|
GraphicsElementDefaults.OnNowNextFileName);
|
|
await SeedElement(1, userElementPath, GraphicsElementKind.Text, string.Empty);
|
|
|
|
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
|
|
|
|
List<GraphicsElementResponseModel> result =
|
|
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
|
|
|
|
result.Count.ShouldBe(1);
|
|
result[0].BuiltIn.ShouldBeFalse();
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetAllGraphicsElementsForApi_Should_Mark_The_Seeded_Path_As_BuiltIn()
|
|
{
|
|
await SeedElement(
|
|
1,
|
|
GraphicsElementDefaults.OnNowNextSeededPath,
|
|
GraphicsElementKind.Text,
|
|
GraphicsElementDefaults.OnNowNextName);
|
|
|
|
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
|
|
|
|
List<GraphicsElementResponseModel> result =
|
|
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
|
|
|
|
result.Count.ShouldBe(1);
|
|
result[0].BuiltIn.ShouldBeTrue();
|
|
}
|
|
|
|
// #568: identity is GraphicsElementDefaults.IsOnNowNext, an ORDINAL comparison, so a row whose
|
|
// path differs from the seeded one only in case is a different element. Reddens if that
|
|
// comparison is loosened to OrdinalIgnoreCase. It does NOT pin provider independence: under
|
|
// SQLite's BINARY collation a `Where(e => e.Path == ...)` in SQL answers identically, which is
|
|
// why the comparison is kept in memory rather than pinned here (see IsOnNowNext's remarks).
|
|
[Test]
|
|
public async Task GetAllGraphicsElementsForApi_Should_Not_Mark_A_Case_Variant_Of_The_Seeded_Path_As_BuiltIn()
|
|
{
|
|
await SeedElement(1, CaseVariantOfSeededPath(), GraphicsElementKind.Text, string.Empty);
|
|
|
|
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
|
|
|
|
List<GraphicsElementResponseModel> result =
|
|
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
|
|
|
|
result.Count.ShouldBe(1);
|
|
result[0].BuiltIn.ShouldBeFalse();
|
|
}
|
|
|
|
// #568: Kind is part of the built-in element's identity, not a second test the seeder applies
|
|
// and the API skips. GetBuiltInElementId refuses a wrong-kind row at the seeded path -- it has
|
|
// to, since EnsureBuiltInElementRow asks it whether the Text row it is about to create already
|
|
// exists -- so an API that reported the same row as builtIn:true would have the two sites
|
|
// disagreeing about one row. Reddens if the Kind conjunct is dropped from
|
|
// GraphicsElementDefaults.IsOnNowNext (row 18 of the mutation table in docs/graphics-elements.md).
|
|
[Test]
|
|
public async Task GetAllGraphicsElementsForApi_Should_Not_Mark_A_Wrong_Kind_Row_At_The_Seeded_Path_As_BuiltIn()
|
|
{
|
|
await SeedElement(
|
|
1,
|
|
GraphicsElementDefaults.OnNowNextSeededPath,
|
|
GraphicsElementKind.Image,
|
|
string.Empty);
|
|
|
|
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
|
|
|
|
List<GraphicsElementResponseModel> result =
|
|
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
|
|
|
|
result.Count.ShouldBe(1);
|
|
result[0].BuiltIn.ShouldBeFalse();
|
|
}
|
|
|
|
// The seeded path with only the FILENAME's case changed -- same folder, same spelling.
|
|
private static string CaseVariantOfSeededPath() =>
|
|
System.IO.Path.Combine(
|
|
System.IO.Path.GetDirectoryName(GraphicsElementDefaults.OnNowNextSeededPath)!,
|
|
GraphicsElementDefaults.OnNowNextFileName.ToUpperInvariant());
|
|
|
|
private async Task SeedElement(int id, string path, GraphicsElementKind kind, string name)
|
|
{
|
|
await using TvContext context = _db.CreateContext();
|
|
context.GraphicsElements.Add(new GraphicsElement
|
|
{
|
|
Id = id,
|
|
Path = path,
|
|
Name = name,
|
|
Kind = kind
|
|
});
|
|
await context.SaveChangesAsync();
|
|
}
|
|
}
|