PR Gates / CI image pin matches docker/ci (pull_request) Successful in 8s
PR Gates / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 19s
review-verdict/h10 Review-verdict: MERGEABLE @ b5dee26 (base: main)
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 15s
Review verdict / Set review-verdict status (pull_request_target) Successful in 6s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 4m32s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m53s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
Both search indexers opened UpdateSong with
metadata.AlbumArtists ??= [];
metadata.Artists ??= [];
Artists/AlbumArtists hold the whole list in ONE COLUMN rather than
being navigations. So unlike the same `??= []` idiom on
Genres/Tags/Artwork all around them, the property IS the column value:
assigning it on a TRACKED entity flips the entry to Modified and the
next SaveChanges writes [] over a NULL column. This is the mechanism an
adversarial review demonstrated in #691, which is why that issue's
entity-level guard was reverted in favour of guarding at the read site.
Measured rather than reasoned about, per the issue's first done-when
box. Restoring ONLY the `??= []` clause (the real predecessor lines,
not a hand-written mutant) reddens the new fixture on
`metadata.Artists should be null but was []`; a probe variant with the
first two assertions replaced by prints reports STATE=Modified and the
raw column moving from NULL to "[]". Today's two feeds are both
AsNoTracking (SearchRepository.GetItemToIndex and GetAllSongs), so no
shipped caller loses data -- but that is a property of two callers, not
of the indexer, and #691 already recorded it as a loaded gun. The
fixture pins the indexer's own contract instead.
Removing the assignment is not sufficient alone: it was load-bearing
for the four reads below it, and deleting it by itself converts a
silent write into a live throw on every untagged song. Measured by
deleting only those two lines from the real predecessor file:
NullReferenceException, thrown at the foreach (cited by symbol: a line
number in a mutant that exists in no committed tree is unreproducible
by construction). The
exception type follows the read FORM, not the field -- foreach yields
NRE, string.Join/ToList yield ArgumentNullException -- and this PR
contains two of each, which is why no single exception-name grep
characterises the class. So each site moves together with its reads:
- LuceneSearchIndex.UpdateSong / ElasticSearchIndex.UpdateSong: hoist
Optional(...).Flatten().ToList() locals and read those.
- RefreshChannelDataHandler: the Scriban context took the raw nullable
lists (the issue's second item). The shipped _song.sbntxt only does
array.join, but a custom template is free to do anything.
The population was derived from the MODEL rather than from the issue's
file list, and the obvious derivation is wrong: "the IList<string>
properties under ErsatzTV.Core/Domain" returns two of eight. It misses
the six value-converted collections (ProgramScheduleAlternate and
PlayoutTemplate each carrying DaysOfMonth, MonthsOfYear, DaysOfWeek),
declared as plain ICollection<T> and made single columns only in
Data/Configurations -- and their storage differs (comma-separated text
for the int converter, JSON for the enum one), so the shared property
is "one scalar column", not the serialization. No site applies `??=`
to any of the six, so this defect has no instance there; whether a null
can REACH one at runtime is unverified and is filed as #823 rather than
asserted either way. Only the SongMetadata pair is left NULL in
practice, by FallbackMetadataProvider. Every site touching either field
was then swept; the remaining readers were already guarded by #691.
The fixture carries two anti-vacuity guards, both witnessed:
- A POSITIVE CONTROL (`writer.NumDocs.ShouldBe(1)`). Every other
assertion says something did NOT happen, so all of them hold
vacuously if UpdateSong never runs -- and it silently stops running
if a future refactor gates UpdateItems on `_initialized`, which this
fixture bypasses by injecting the writer. Verified BOTH directions:
with that gate added the control fails `NumDocs should be 1 but was
0`, and with the control removed the whole test PASSES while the code
under test is unreachable.
- A capturing logger, because UpdateSong wraps its body in a catch that
assigns metadata.Song = null -- severing a required relationship and
cascading the metadata to Deleted. Without it the probe silently
measures the error path; on the first run it did exactly that (a bare
ILanguageCodeService substitute NPEs inside AddLanguages). The raw
column helper also fails loudly on a missing row, since ExecuteScalar
returns CLR null for both "NULL column" and "no such row".
ElasticSearchIndex has no equivalent fixture -- it needs a stubbed
transport -- so its change is by inspection against the Lucene one, and
the gap is filed as #824 rather than covered by a source-text guard.
The whitespace-only churn in ElasticSearchIndex.cs is the #311
fix-as-you-touch format gate: it scopes to whole changed FILES.
`git diff -w` over that file shows only the two hunks above.
Local gate: ErsatzTV.Tests 2006 passed / 4 pre-existing skips,
Core.Tests 685/1 skip, Infrastructure.Tests 114, Architecture.Tests 7,
Scanner.Tests 1504 -- 0 failures in each. scripts/tests 874 passed / 2
skipped. dotnet format whitespace --verify-no-changes clean on the four
touched files, no BOM on any. decisions_validate OK.
Fixes #701
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
231 lines
11 KiB
C#
231 lines
11 KiB
C#
using System.Reflection;
|
|
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 Lucene.Net.Analysis.Standard;
|
|
using Lucene.Net.Index;
|
|
using Lucene.Net.Store;
|
|
using Lucene.Net.Util;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using NSubstitute;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
|
|
namespace ErsatzTV.Tests.Integration;
|
|
|
|
/// <summary>
|
|
/// ersatztv#701, EXECUTED against a real <see cref="TvContext" /> on SQLite.
|
|
/// <para>
|
|
/// <b>The defect.</b> <c>LuceneSearchIndex.UpdateSong</c> opened with
|
|
/// <c>metadata.AlbumArtists ??= []; metadata.Artists ??= [];</c>. Unlike the navigation
|
|
/// collections guarded the same way all around them, these two are SCALAR COLUMNS: the whole
|
|
/// list lives in one column, so the property IS the column value. They are two of EIGHT such
|
|
/// columns in the model — the population is derived from the MODEL CONFIGURATION (EF-native
|
|
/// primitive collections plus the six <c>HasConversion<*CollectionValueConverter></c>
|
|
/// columns on <c>ProgramScheduleAlternate</c>/<c>PlayoutTemplate</c>), NOT by grepping the
|
|
/// domain classes for <c>IList<string></c>, which finds only two of the eight. See
|
|
/// the decision record <c>media.nullable-primitive-collection-mutation</c>.
|
|
/// Assigning one on a TRACKED entity flips it to <see cref="EntityState.Modified" />, and the next
|
|
/// <c>SaveChanges</c> writes <c>[]</c> over what the database held as <c>NULL</c> — the exact
|
|
/// mechanism an adversarial review demonstrated in ersatztv#691, which is why that issue's
|
|
/// entity-level guard was reverted in favour of guarding at the READ SITE.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Why the fixture loads the song TRACKED even though production does not.</b> Both feeds into
|
|
/// the indexer are <c>AsNoTracking()</c> today — <c>SearchRepository.GetItemToIndex</c> and
|
|
/// <c>SearchRepository.GetAllSongs</c> — so no shipped caller loses data. That is a property of
|
|
/// today's two callers, not of the indexer, and it is exactly what ersatztv#691 recorded as "a
|
|
/// loaded gun". This fixture therefore pins the INDEXER's own contract: handed a tracked entity it
|
|
/// must not mutate it. Run against the real pre-fix file, the FIRST of the numbered assertions
|
|
/// below fails (<c>metadata.Artists should be null but was []</c>) and the run stops there;
|
|
/// reaching the persistence half needs a probe variant with assertions 1 and 2 replaced by
|
|
/// prints, which reports <c>Modified</c> and the column moving from <c>NULL</c> to <c>[]</c>.
|
|
/// Each was separately shown discriminating. A future caller that drops <c>AsNoTracking</c>
|
|
/// therefore cannot reintroduce the data loss silently.
|
|
/// </para>
|
|
/// <para>
|
|
/// The Lucene <see cref="IndexWriter" /> is injected into the private field rather than obtained via
|
|
/// <c>Initialize</c>, because <c>Initialize</c> writes to <c>FileSystemLayout.SearchIndexFolder</c> —
|
|
/// a process-wide static resolved once from <c>ETV_CONFIG_FOLDER</c>, i.e. the developer's real
|
|
/// application data folder. Letting the writer throw instead is NOT an option here: the
|
|
/// <c>catch</c> in <c>UpdateSong</c> assigns <c>metadata.Song = null</c>, which would itself dirty
|
|
/// the entity under test and make the probe report the wrong cause.
|
|
/// </para>
|
|
/// </summary>
|
|
[TestFixture]
|
|
public class SongIndexerMetadataMutationTests
|
|
{
|
|
[Test]
|
|
public async Task UpdateSong_Must_Not_Mutate_Nullable_Artists_On_A_Tracked_Entity()
|
|
{
|
|
await using var harness = await InMemoryTvContext.CreateAsync();
|
|
|
|
int metadataId;
|
|
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;
|
|
}
|
|
|
|
// 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 -- see the fixture docstring.
|
|
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");
|
|
|
|
// UpdateSong wraps its whole body in a catch that logs a warning and assigns
|
|
// `metadata.Song = null` -- which severs a required relationship and cascades the metadata to
|
|
// Deleted. A silently-exercised catch would therefore make every assertion below report the
|
|
// wrong cause, so the logger fails the test instead of swallowing.
|
|
var logger = new ThrowOnWarningLogger<LuceneSearchIndex>();
|
|
var index = new LuceneSearchIndex(
|
|
new SearchQueryParser(
|
|
Substitute.For<ISmartCollectionCache>(),
|
|
Substitute.For<ILogger<SearchQueryParser>>()),
|
|
logger);
|
|
|
|
using var directory = new RAMDirectory();
|
|
using var writer = new IndexWriter(
|
|
directory,
|
|
new IndexWriterConfig(LuceneVersion.LUCENE_48, new StandardAnalyzer(LuceneVersion.LUCENE_48)));
|
|
|
|
typeof(LuceneSearchIndex)
|
|
.GetField("_writer", BindingFlags.NonPublic | BindingFlags.Instance)!
|
|
.SetValue(index, writer);
|
|
|
|
// A bare substitute returns null from GetAllLanguageCodes, which NPEs inside AddLanguages and
|
|
// would divert the run into the catch above.
|
|
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]);
|
|
|
|
logger.Failure.ShouldBeNull("UpdateSong threw and its catch ran, so this probe measured the "
|
|
+ "error path rather than the indexing path");
|
|
|
|
// POSITIVE CONTROL. Every assertion below asserts that something did NOT happen, so all of
|
|
// them hold vacuously if UpdateSong never ran at all -- and it silently stops running if a
|
|
// future refactor gates UpdateItems on `_initialized`, which this fixture deliberately
|
|
// bypasses by injecting the writer. Verified BOTH ways by adding
|
|
// `if (!_initialized) { return Unit.Default; }` to UpdateItems (a bare `return;` does not
|
|
// compile there -- CS0126): with this line present it is the only failure, and with it
|
|
// removed the whole test PASSES while the code under test is unreachable.
|
|
// NumDocs == 1 proves the song-indexing path ran; it does NOT prove the artist loops
|
|
// specifically ran, which would need a second seeded song asserting ArtistField.
|
|
writer.NumDocs.ShouldBe(1, "UpdateSong did not index the song, so the assertions below "
|
|
+ "would pass without exercising the code under test");
|
|
|
|
// 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 sealed class ThrowOnWarningLogger<T> : ILogger<T>
|
|
{
|
|
public Exception? Failure { get; private set; }
|
|
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
|
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
public void Log<TState>(
|
|
LogLevel logLevel,
|
|
EventId eventId,
|
|
TState state,
|
|
Exception? exception,
|
|
Func<TState, Exception?, string> formatter)
|
|
{
|
|
if (logLevel >= LogLevel.Warning)
|
|
{
|
|
Failure ??= exception ?? new InvalidOperationException(formatter(state, exception));
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|