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; /// /// ersatztv#824 — the gap ersatztv#701 named rather than papered over. /// /// ElasticSearchIndex.UpdateSong holds an INDEPENDENT copy of the logic /// pins on LuceneSearchIndex. #701 removed /// metadata.AlbumArtists ??= []; metadata.Artists ??= []; 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 ElasticSearchIndex against a real . /// /// /// Why a stubbed transport rather than a live server. #824 listed "inject a non-network /// ElasticsearchClient transport" as option 1 and it is what shipped: /// Elastic.Transport.InMemoryRequestInvoker is public in the pinned Elastic.Transport, and /// ElasticsearchClientSettings(NodePool, IRequestInvoker) accepts it. No server, no socket, /// no new package. /// /// /// The canned response body is load-bearing, not decoration. A bare /// InMemoryRequestInvoker() answers with an EMPTY body, which the client cannot deserialize /// into an IndexResponse. That throw lands in UpdateSong's catch, which logs a /// warning and assigns metadata.Song = null — so the fixture would measure the ERROR path /// while every "did not mutate" assertion below still passed, vacuously. The /// is the belt to that brace: it fails the test if the /// catch ran at all. /// /// /// The client is injected into the private _client field rather than obtained normally, /// because CreateClient reads the process-wide static ElasticSearchIndex.Uri and /// would open a real socket. UpdateItems — unlike IndexExists and /// Initialize — never runs _client ??= CreateClient(), so the injected instance is /// the one used and an uninjected one would simply be null. /// /// [TestFixture] [NonParallelizable] public class ElasticSongIndexerMetadataMutationTests { private const string TestIndexName = "etv-824-test"; private string? _originalIndexName; /// /// ElasticSearchIndex.IndexName is a process-wide static. Only Startup 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. /// [SetUp] public void SetUp() => _originalIndexName = ElasticSearchIndex.IndexName; [TearDown] public void TearDown() => ElasticSearchIndex.IndexName = _originalIndexName; /// /// A well-formed IndexResponse. See the fixture docstring: an empty body diverts the run /// into UpdateSong's catch and makes every assertion below vacuous. /// 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(); var index = new ElasticSearchIndex( new SearchQueryParser( Substitute.For(), Substitute.For>()), 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(); languageCodeService.GetAllLanguageCodes(Arg.Any>()).Returns([]); languageCodeService.GetAllLanguageCodes(Arg.Any()).Returns([]); await index.UpdateItems( Substitute.For(), Substitute.For(), 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 // " 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> ProductCheckHeaders() => new(StringComparer.OrdinalIgnoreCase) { ["x-elastic-product"] = ["Elasticsearch"] }; /// /// Records every request the client actually issues, so the fixture can prove the code under test /// ran. Delegates the answering to a real rather than /// hand-building a response. /// private sealed class CapturingRequestInvoker(InMemoryRequestInvoker inner) : IRequestInvoker { public List Requests { get; } = []; public ResponseFactory ResponseFactory => inner.ResponseFactory; public TResponse Request( Endpoint endpoint, BoundConfiguration boundConfiguration, PostData? postData) where TResponse : TransportResponse, new() { Requests.Add($"{endpoint.Method} {endpoint.PathAndQuery}"); return inner.Request(endpoint, boundConfiguration, postData); } public Task RequestAsync( Endpoint endpoint, BoundConfiguration boundConfiguration, PostData? postData, CancellationToken cancellationToken) where TResponse : TransportResponse, new() { Requests.Add($"{endpoint.Method} {endpoint.PathAndQuery}"); return inner.RequestAsync(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 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; } }