Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 35s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 57s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 37s
PR Gates / Docs update reminder (pull_request) Successful in 1m0s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
review-verdict/h10 Review-verdict: MERGEABLE @ a7d91bf (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 45s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m27s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m4s
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 7s
`docs.no-session-narrative` reaches every durable artifact, but its detector scanned only `docs/**/*.md` and root markdown, and nothing had ever swept the rest. The issue named four sites from one grep and called them a floor. Deriving the population instead — a whitespace-joined sweep over every tracked file outside the detector, for the detector's own phrasings plus the attribution and review-round class #812 found — gave 453 sites in 108 files at `fb5592971`, and a second pass for phrasings the first list missed (hyphenated `round-N`, "an earlier version", "the reviewer proved") added residuals in the same files. Every site was classified with #812's three dispositions (CUT / SEVER / KEEP with its sub-kind) under the who-benefits test; the per-site manifests are on the PR. The rejected designs, tested-and-rejected fixtures, measurements and traps stay; the attribution of who found them and the round in which they were found go. The detector's population grows to `.claude/`, `.gitea/`, `.husky/` and `scripts/` regardless of extension, minus the detector and its own test (whose fixtures ARE the phrasings) and minus `scripts/tests/fixtures/` (test data, including decision-record copies — the same reasoning as the records' own exemption, and what keeps the record's depth measurement true), and `--all` lists tracked REGULAR files only — a symlink's content is its target and a gitlink has none. The #812 argument for leaving `docs/superpowers/**` in the population runs the other way here: `--diff` sees only ADDED lines, and 287 of the 453 sites were under 30 days old — this corpus is where narrative is being added, so the advisory nudge has reach. Density agrees: 56 line-mode hits over the 113 regular files the predicate admits, against 9 over 66 docs files before #812. `web/` and C# stay out on the same measurement (3 of 74 PATTERNS-matching sites, ~4,600 files). The predicate did not grow: PATTERNS matched 74 of 453 sites, and widening the word list to the attribution class is the treadmill the withdrawn parity test ran on. The population oracle is restated over segments with the new arms, the synthetic cross product gains the process heads and non-markdown extensions, a fixture witnesses that a tracked symlink is neither scanned nor counted, a `.py.bak` axis separates a by-name exemption from a `startswith` over the same tuple, and eight mutants (drop the process arm, drop the by-name exemption, exempt by `startswith`, drop or add a prefix, drop the fixtures exemption, list only markdown, drop the symlink filter, test the mode per row instead of per path) each redden it. A pre-existing silent drop in `--diff` goes with it: git tab-terminates a `+++` filename that contains a space, and the kept tab made `is_scanned_path` refuse the file with no notice — fixed, with a positive control and its own mutant. Code is unchanged by construction, measured per file type against `origin/main`: Python modules are AST-equal with docstrings stripped, except `#` lines inside the embedded fixture programs (string literals) of three test modules; workflows differ only in `#` lines inside `run:` block scalars; shell, C#, TypeScript and jq are equal with comment lines stripped. The stated exceptions: the detector and its test, 26 vitest titles that carried review-round or severity labels or a reviewer attribution (call sites whose title changed — every changed title line walked back to its `it(` / `it.each(...)(` anchor, so a `' + '` concatenation counts once), two registry note strings and the mutation manifest's prose fields. scripts/tests: 1565 passed. Web: lint, typecheck, 1319 tests green. Closes #876. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEcBoFw7ctrf3Nb7R7x7wk
209 lines
11 KiB
C#
209 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 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 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;
|
|
}
|
|
}
|