Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 18s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Docs update reminder (pull_request) Successful in 21s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 11s
review-verdict/h10 Review-verdict: MERGEABLE @ 95b2700 (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 23s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 8m6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m55s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m35s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Canceled after 2m56s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Canceled after 0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Canceled after 0s
Both issues are #701 deferrals, and they land together because both rewrite the same decision record. #823 -- can a null reach one of the six collection-valued scalar columns? MEASURED against a real TvContext on BOTH providers (SQLite, and MySQL 8.4 on an ephemeral server), because the reasoning available beforehand pointed the wrong way. The two converters differ on their read side -- IntCollectionValueConverter maps null-or-blank to Array.Empty<int>(), while EnumCollectionJsonValueConverter would dereference the result of JsonConvert.DeserializeObject -- so the expectation was that a NULL row behaves differently per column. NEITHER RUNS: EF does not invoke a value converter for a NULL column at all. All six materialize as CLR null, the int converter's null-to-empty branch is dead on this path, and unguarded each .Contains in AlternateScheduleSelector throws NullReferenceException. A NULL reads as UNRESTRICTED -- the All*() sets -- not as empty. This is the whole semantic question and the first draft got it backwards. It is decided by the one NULL reachable WITHOUT any code writing one: Sqlite's 20240113140741_Add_PlayoutTemplate_DaysOfMonth adds the column with nullable:true and NO defaultValue, so a PlayoutTemplate row inserted before it holds NULL and by construction had no day-of-month restriction. Reading that as empty INVERTS the row's meaning and silently stops the template applying at all. All*() preserves it, and is how "no restriction recorded" is already represented (GetPlayoutAlternateSchedulesHandler, PreviewBlockPlayoutHandler). What does NOT decide it, and was wrongly cited in the first draft: the API request records normalize an omitted field with `?? []`, but that is a client omitting a field on a WRITE and says nothing about what a legacy database NULL meant. Two read sites, not one. Guarding only the selector would have left the entity->DTO mappers unguarded, and those feed the SPA: PlayoutScheduleEditors spreads the collection (`[...template.daysOfMonth]` -> TypeError on a JSON null) and playoutTemplateCalendar's appliesToDate -- an exact port of GetScheduleForDate -- calls .includes on it. Both mappers now substitute the SAME defaults, so the preview agrees with what is actually scheduled. Neither guard is assigned back onto the entity, which is the media.nullable-primitive-collection-mutation mechanism. Reachability, stated precisely rather than overclaimed. All six are nullable:true on both providers, but a nullable column does not produce a NULL row: five of the six were present at CreateTable, so a NULL there still needs code to write one, and on MySQL there is NO code-path-free NULL for any of the six. The write path ACCEPTS a null (SaveChanges succeeds, stores SQL NULL) but no caller supplies one today -- every production construction of the two commands goes through the request records. That is a property of the code, not a live caller; claiming otherwise would be the banned "it's AsNoTracking today" argument pointed the other way. #824 -- ElasticSearchIndex.UpdateSong had no regression test Issue option 1 (a non-network transport) shipped, and needed no new package: Elastic.Transport.InMemoryRequestInvoker is public in the pinned version and ElasticsearchClientSettings(NodePool, IRequestInvoker) accepts it, injected into the private _client the way #701 injects the Lucene IndexWriter. UpdateItems never runs `_client ??= CreateClient()`, so the injected instance is the one used. Two traps there are load-bearing, both measured: the canned response must carry an `X-Elastic-Product: Elasticsearch` header or the client's product check throws UnsupportedProductException INTO UpdateSong's catch, and an empty body fails to deserialize the same way. Either turns the fixture into a green measurement of the error path -- which is how it first failed here, caught by the ThrowOnWarningLogger. The document id is asserted as the LAST PATH SEGMENT, not by substring: the index name carries digits, so ShouldContain would stop discriminating for a song whose id collided with one. Six mutations executed, each disarming ITS OWN clause alone: - `??=` restored in ElasticSearchIndex only -> the Elastic fixture reddens on "metadata.Artists should be null but was []" while the LUCENE fixture stays GREEN. The #824 hole demonstrated, not described. - DaysOfWeek guard disarmed in the selector -> 4 red, 3 green (DaysOfMonth and MonthsOfYear unaffected). Each clause is independently load-bearing. - DaysOfMonth guard disarmed in Playouts.Mapper -> 1 red, 2 green. - Elastic dropped from the covered set / mapped to the SAME fixture as Lucene / mapped to a class with no [Test] -> SearchIndexMutationCoverageTests reddens on each. That coverage guard is the boundary fix the issue asked for: the covered set is compared against an ISearchIndex population DERIVED FROM THE ASSEMBLY. Its claim stops where the check does -- no static check can establish that a named fixture actually DRIVES its indexer, so it forces a human to look rather than proving coverage. ThrowOnWarningLogger moved to ErsatzTV.Tests/Support so both fixtures share it; the Lucene fixture's assertions are otherwise untouched, since it is a witnessed proof artifact. No production change in ElasticSearchIndex.cs -- #824 is coverage only. Docs: testing.md gains a "Provider-parity fixtures" section naming all THREE opt-in-MySQL fixtures and recording that CI runs none of them (#627); docs/README.md gains the matching task signal; guard-inventory.md's hand-written C# guard list goes from five files to six. Scheduling/Mapper.cs loses the UTF-8 BOM it inherited, per #311 fix-as-you-touch. Local gate (with the MySQL lane armed): ErsatzTV.Tests 2096 passed / 0 skipped, Core.Tests 693/1, Infrastructure.Tests 114, Architecture.Tests 7, Scanner.Tests 1504 -- 0 failures in each. scripts/tests 1228 passed / 2 skipped. dotnet format whitespace --verify-no-changes clean; BOM check over the touched set with the population COUNT asserted, because a bare zsh loop silently checks one concatenated filename. decisions_validate OK. Fixes #823 Fixes #824 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019zUmJZHhVP7kXg5DV237TW
300 lines
14 KiB
C#
300 lines
14 KiB
C#
using System.Reflection;
|
|
using System.Text;
|
|
using Elastic.Clients.Elasticsearch;
|
|
using Elastic.Transport;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Interfaces.Metadata;
|
|
using ErsatzTV.Core.Interfaces.Repositories;
|
|
using ErsatzTV.Core.Search;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using ErsatzTV.Infrastructure.Extensions;
|
|
using ErsatzTV.Infrastructure.Search;
|
|
using ErsatzTV.Tests.Support;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using NSubstitute;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
|
|
namespace ErsatzTV.Tests.Integration;
|
|
|
|
/// <summary>
|
|
/// ersatztv#824 — the gap ersatztv#701 named rather than papered over.
|
|
/// <para>
|
|
/// <c>ElasticSearchIndex.UpdateSong</c> holds an INDEPENDENT copy of the logic
|
|
/// <see cref="SongIndexerMetadataMutationTests" /> pins on <c>LuceneSearchIndex</c>. #701 removed
|
|
/// <c>metadata.AlbumArtists ??= []; metadata.Artists ??= [];</c> from both, but only Lucene gained
|
|
/// a regression test — so reintroducing the mutation in the Elastic copy ALONE left the whole
|
|
/// suite green. This fixture closes that: the assertions are the same three, driven through the
|
|
/// real <c>ElasticSearchIndex</c> against a real <see cref="TvContext" />.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Why a stubbed transport rather than a live server.</b> #824 listed "inject a non-network
|
|
/// <c>ElasticsearchClient</c> transport" as option 1 and it is what shipped:
|
|
/// <c>Elastic.Transport.InMemoryRequestInvoker</c> is public in the pinned Elastic.Transport, and
|
|
/// <c>ElasticsearchClientSettings(NodePool, IRequestInvoker)</c> accepts it. No server, no socket,
|
|
/// no new package.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The canned response body is load-bearing, not decoration.</b> A bare
|
|
/// <c>InMemoryRequestInvoker()</c> answers with an EMPTY body, which the client cannot deserialize
|
|
/// into an <c>IndexResponse</c>. That throw lands in <c>UpdateSong</c>'s catch, which logs a
|
|
/// warning and assigns <c>metadata.Song = null</c> — so the fixture would measure the ERROR path
|
|
/// while every "did not mutate" assertion below still passed, vacuously. The
|
|
/// <see cref="ThrowOnWarningLogger{T}" /> is the belt to that brace: it fails the test if the
|
|
/// catch ran at all.
|
|
/// </para>
|
|
/// <para>
|
|
/// The client is injected into the private <c>_client</c> field rather than obtained normally,
|
|
/// because <c>CreateClient</c> reads the process-wide static <c>ElasticSearchIndex.Uri</c> and
|
|
/// would open a real socket. <c>UpdateItems</c> — unlike <c>IndexExists</c> and
|
|
/// <c>Initialize</c> — never runs <c>_client ??= CreateClient()</c>, so the injected instance is
|
|
/// the one used and an uninjected one would simply be null.
|
|
/// </para>
|
|
/// </summary>
|
|
[TestFixture]
|
|
[NonParallelizable]
|
|
public class ElasticSongIndexerMetadataMutationTests
|
|
{
|
|
private const string TestIndexName = "etv-824-test";
|
|
|
|
private string? _originalIndexName;
|
|
|
|
/// <summary>
|
|
/// <c>ElasticSearchIndex.IndexName</c> is a process-wide static. Only <c>Startup</c> reads it today,
|
|
/// so leaving it set leaks nothing that currently runs — but a static this fixture writes and never
|
|
/// restores is a cross-test hazard waiting for the first test that does read it.
|
|
/// </summary>
|
|
[SetUp]
|
|
public void SetUp() => _originalIndexName = ElasticSearchIndex.IndexName;
|
|
|
|
[TearDown]
|
|
public void TearDown() => ElasticSearchIndex.IndexName = _originalIndexName;
|
|
|
|
/// <summary>
|
|
/// A well-formed <c>IndexResponse</c>. See the fixture docstring: an empty body diverts the run
|
|
/// into <c>UpdateSong</c>'s catch and makes every assertion below vacuous.
|
|
/// </summary>
|
|
private const string IndexResponseBody =
|
|
"""
|
|
{"_index":"etv-824-test","_id":"1","_version":1,"result":"created",
|
|
"_shards":{"total":1,"successful":1,"failed":0},"_seq_no":0,"_primary_term":1}
|
|
""";
|
|
|
|
[Test]
|
|
public async Task UpdateSong_Must_Not_Mutate_Nullable_Artists_On_A_Tracked_Entity()
|
|
{
|
|
await using var harness = await InMemoryTvContext.CreateAsync();
|
|
|
|
int metadataId;
|
|
int songId;
|
|
await using (TvContext context = harness.CreateContext())
|
|
{
|
|
var library = new LocalLibrary { Name = "Music", MediaKind = LibraryMediaKind.Songs };
|
|
context.Add(library);
|
|
await context.SaveChangesAsync();
|
|
|
|
var libraryPath = new LibraryPath { Path = "/music", LibraryId = library.Id };
|
|
context.Add(libraryPath);
|
|
await context.SaveChangesAsync();
|
|
|
|
var song = new Song
|
|
{
|
|
LibraryPathId = libraryPath.Id,
|
|
MediaVersions = [],
|
|
SongMetadata =
|
|
[
|
|
new SongMetadata
|
|
{
|
|
MetadataKind = MetadataKind.Fallback,
|
|
Title = "Untagged Track",
|
|
SortTitle = "untagged track",
|
|
DateAdded = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
|
|
// The shape FallbackMetadataProvider.GetSongMetadata leaves behind: it never
|
|
// assigns either primitive collection, so both columns persist as NULL.
|
|
Artists = null!,
|
|
AlbumArtists = null!,
|
|
|
|
Genres = [],
|
|
Tags = [],
|
|
Studios = [],
|
|
Actors = [],
|
|
Artwork = [],
|
|
Guids = []
|
|
}
|
|
]
|
|
};
|
|
|
|
context.Add(song);
|
|
await context.SaveChangesAsync();
|
|
metadataId = song.SongMetadata[0].Id;
|
|
songId = song.Id;
|
|
}
|
|
|
|
// The seed must actually have produced NULL columns, or every assertion below is vacuous.
|
|
(await ReadRawArtists(harness, metadataId)).ShouldBeNull();
|
|
|
|
await using (TvContext context = harness.CreateContext())
|
|
{
|
|
// Deliberately TRACKED -- the indexer's own contract is what is being pinned, not the
|
|
// AsNoTracking() habit of today's two callers. See SongIndexerMetadataMutationTests.
|
|
Song tracked = await context.Songs
|
|
.IncludeForSearch()
|
|
.AsSplitQuery()
|
|
.SingleAsync();
|
|
|
|
SongMetadata metadata = tracked.SongMetadata[0];
|
|
metadata.Artists.ShouldBeNull("EF must materialize the NULL column as null, not as an empty list");
|
|
|
|
var logger = new ThrowOnWarningLogger<ElasticSearchIndex>();
|
|
var index = new ElasticSearchIndex(
|
|
new SearchQueryParser(
|
|
Substitute.For<ISmartCollectionCache>(),
|
|
Substitute.For<ILogger<SearchQueryParser>>()),
|
|
logger);
|
|
|
|
var invoker = new CapturingRequestInvoker(
|
|
new InMemoryRequestInvoker(
|
|
Encoding.UTF8.GetBytes(IndexResponseBody),
|
|
200,
|
|
exception: null,
|
|
contentType: "application/json",
|
|
// The X-Elastic-Product header is REQUIRED, not cosmetic. The client runs a product
|
|
// check on its first response and throws UnsupportedProductException ("the server is
|
|
// not a supported distribution of Elasticsearch") without it -- which lands in
|
|
// UpdateSong's catch and makes the fixture measure the error path. Measured: this is
|
|
// exactly how this fixture first failed.
|
|
headers: ProductCheckHeaders()));
|
|
|
|
var settings = new ElasticsearchClientSettings(
|
|
new SingleNodePool(new Uri("http://localhost:9200")),
|
|
invoker)
|
|
.DefaultIndex(TestIndexName);
|
|
|
|
ElasticSearchIndex.IndexName = TestIndexName;
|
|
|
|
typeof(ElasticSearchIndex)
|
|
.GetField("_client", BindingFlags.NonPublic | BindingFlags.Instance)!
|
|
.SetValue(index, new ElasticsearchClient(settings));
|
|
|
|
// A bare substitute returns null from GetAllLanguageCodes, which NPEs inside AddLanguages and
|
|
// would divert the run into UpdateSong's catch.
|
|
var languageCodeService = Substitute.For<ILanguageCodeService>();
|
|
languageCodeService.GetAllLanguageCodes(Arg.Any<List<string>>()).Returns([]);
|
|
languageCodeService.GetAllLanguageCodes(Arg.Any<string>()).Returns([]);
|
|
|
|
await index.UpdateItems(
|
|
Substitute.For<ISearchRepository>(),
|
|
Substitute.For<IFallbackMetadataProvider>(),
|
|
languageCodeService,
|
|
[tracked]);
|
|
|
|
// Surfacing the exception rather than asserting ShouldBeNull: the catch is the fixture's
|
|
// most likely failure mode (see the canned-response note above), and "expected null but was
|
|
// <Exception>" without the message sends the next reader hunting for a cause the fixture
|
|
// already had in its hand.
|
|
if (logger.Failure is not null)
|
|
{
|
|
Assert.Fail("UpdateSong threw and its catch ran, so this probe measured the error path "
|
|
+ $"rather than the indexing path: {logger.Failure}");
|
|
}
|
|
|
|
// POSITIVE CONTROL, and it is not optional: every assertion below says something did NOT
|
|
// happen, so all of them hold vacuously if UpdateSong never ran. The Lucene fixture uses
|
|
// `writer.NumDocs == 1` for exactly this; the transport-level equivalent is that the indexer
|
|
// actually issued the index request for THIS song. Like NumDocs, it proves the song-indexing
|
|
// path ran -- it does NOT prove the artist reads specifically ran.
|
|
invoker.Requests.Count.ShouldBe(
|
|
1,
|
|
"UpdateSong did not issue exactly one index request, so the assertions below would pass "
|
|
+ $"without exercising the code under test. Captured: [{string.Join(", ", invoker.Requests)}]");
|
|
|
|
// The document id is compared as the LAST PATH SEGMENT, not with ShouldContain. A substring
|
|
// test is a false-pass vector here: the index name itself carries digits ("etv-824-test"), so
|
|
// ShouldContain("2") or ShouldContain("4") would be satisfied by the index name alone for a
|
|
// song whose id happened to be 2 or 4, and the assertion would stop discriminating without
|
|
// ever failing.
|
|
string path = invoker.Requests[0].Split(' ')[^1].Split('?')[0];
|
|
path.Split('/')[^1].ShouldBe(
|
|
songId.ToString(),
|
|
$"the index request was not for the seeded song. Captured: {invoker.Requests[0]}");
|
|
|
|
// 1. The indexer left the entity alone.
|
|
metadata.Artists.ShouldBeNull();
|
|
metadata.AlbumArtists.ShouldBeNull();
|
|
|
|
// 2. ...so EF has nothing to persist. This is the assertion that fails loudly the day the
|
|
// mutation returns, even if a later refactor stopped the value from being observable above.
|
|
context.Entry(metadata).State.ShouldBe(EntityState.Unchanged);
|
|
|
|
// 3. And the save that a real caller would go on to make does not rewrite the column.
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
(await ReadRawArtists(harness, metadataId)).ShouldBeNull();
|
|
}
|
|
|
|
private static Dictionary<string, IEnumerable<string>> ProductCheckHeaders() =>
|
|
new(StringComparer.OrdinalIgnoreCase) { ["x-elastic-product"] = ["Elasticsearch"] };
|
|
|
|
/// <summary>
|
|
/// Records every request the client actually issues, so the fixture can prove the code under test
|
|
/// ran. Delegates the answering to a real <see cref="InMemoryRequestInvoker" /> rather than
|
|
/// hand-building a response.
|
|
/// </summary>
|
|
private sealed class CapturingRequestInvoker(InMemoryRequestInvoker inner) : IRequestInvoker
|
|
{
|
|
public List<string> Requests { get; } = [];
|
|
|
|
public ResponseFactory ResponseFactory => inner.ResponseFactory;
|
|
|
|
public TResponse Request<TResponse>(
|
|
Endpoint endpoint,
|
|
BoundConfiguration boundConfiguration,
|
|
PostData? postData)
|
|
where TResponse : TransportResponse, new()
|
|
{
|
|
Requests.Add($"{endpoint.Method} {endpoint.PathAndQuery}");
|
|
return inner.Request<TResponse>(endpoint, boundConfiguration, postData);
|
|
}
|
|
|
|
public Task<TResponse> RequestAsync<TResponse>(
|
|
Endpoint endpoint,
|
|
BoundConfiguration boundConfiguration,
|
|
PostData? postData,
|
|
CancellationToken cancellationToken)
|
|
where TResponse : TransportResponse, new()
|
|
{
|
|
Requests.Add($"{endpoint.Method} {endpoint.PathAndQuery}");
|
|
return inner.RequestAsync<TResponse>(endpoint, boundConfiguration, postData, cancellationToken);
|
|
}
|
|
|
|
// IRequestInvoker extends IDisposable, but InMemoryRequestInvoker holds no disposable state and
|
|
// exposes no Dispose of its own -- there is nothing to forward to.
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
|
|
private static async Task<object?> ReadRawArtists(InMemoryTvContext harness, int metadataId)
|
|
{
|
|
await using TvContext context = harness.CreateContext();
|
|
await using var command = context.Database.GetDbConnection().CreateCommand();
|
|
command.CommandText = $"SELECT Artists FROM SongMetadata WHERE Id = {metadataId}";
|
|
object? value = await command.ExecuteScalarAsync();
|
|
|
|
// ExecuteScalar returns CLR null both for "the column is NULL" and for "there is no such row",
|
|
// and the second is reachable: UpdateSong's catch assigns metadata.Song = null, which severs a
|
|
// required relationship and cascades the row to Deleted, so a SaveChanges on the error path
|
|
// DELETES it and a plain null check would pass for the wrong reason.
|
|
if (value is null)
|
|
{
|
|
Assert.Fail($"SongMetadata row {metadataId} no longer exists, so its Artists column cannot "
|
|
+ "be read -- the probe measured a deleted row rather than a preserved NULL.");
|
|
}
|
|
|
|
return value is DBNull ? null : value;
|
|
}
|
|
}
|