using System.Globalization; using ErsatzTV.Application.Search.Queries; using ErsatzTV.Core.Api.Search; using ErsatzTV.Core.Domain; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Tests.Support; using LanguageExt; using Microsoft.EntityFrameworkCore; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Application.Search; [TestFixture] public class GetSearchFieldValuesHandlerTests { 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 Returns_Distinct_Whole_Values_For_Genre_With_Prefix_And_Limit() { await using (TvContext context = _db.CreateContext()) { context.Set().AddRange( new Genre { Name = "Action" }, new Genre { Name = "Adventure" }, new Genre { Name = "Animation" }, new Genre { Name = "Comedy" }); await context.SaveChangesAsync(); } var handler = new GetSearchFieldValuesHandler(_db.Factory); Option result = await handler.Handle( new GetSearchFieldValues("genre", "A", 50), CancellationToken.None); result.IsSome.ShouldBeTrue(); result.IfSome(r => r.Values.ShouldBe(new List { "Action", "Adventure", "Animation" })); Option limited = await handler.Handle( new GetSearchFieldValues("genre", "A", 2), CancellationToken.None); limited.IsSome.ShouldBeTrue(); limited.IfSome(r => r.Values.ShouldBe(new List { "Action", "Adventure" })); } [Test] public async Task Splits_Content_Rating_On_Slash() { await using (TvContext context = _db.CreateContext()) { context.MovieMetadata.Add(new MovieMetadata { MetadataKind = MetadataKind.External, DateAdded = DateTime.UtcNow, DateUpdated = DateTime.UtcNow, ContentRating = "PG-13/TV-14" }); await context.SaveChangesAsync(); } var handler = new GetSearchFieldValuesHandler(_db.Factory); Option result = await handler.Handle( new GetSearchFieldValues("content_rating", string.Empty, 50), CancellationToken.None); result.IsSome.ShouldBeTrue(); result.IfSome(r => r.Values.ShouldBe(new List { "PG-13", "TV-14" })); } [Test] public async Task Returns_MediaItemState_Enum_Names_Without_Seeding() { var handler = new GetSearchFieldValuesHandler(_db.Factory); Option result = await handler.Handle( new GetSearchFieldValues("state", string.Empty, 50), CancellationToken.None); result.IsSome.ShouldBeTrue(); result.IfSome(r => r.Values.ShouldBe( new List { "FileNotFound", "Normal", "RemoteOnly", "Unavailable" })); } [Test] public async Task Returns_NotFound_For_Excluded_Text_Field_Title() { var handler = new GetSearchFieldValuesHandler(_db.Factory); Option result = await handler.Handle( new GetSearchFieldValues("title", string.Empty, 50), CancellationToken.None); result.IsNone.ShouldBeTrue(); } [Test] public async Task Returns_NotFound_For_NonText_Field() { var handler = new GetSearchFieldValuesHandler(_db.Factory); // "minutes" is a "number" field in SearchFieldCatalog, not "text" Option result = await handler.Handle( new GetSearchFieldValues("minutes", string.Empty, 50), CancellationToken.None); result.IsNone.ShouldBeTrue(); } [Test] public async Task Returns_NotFound_For_Unknown_Field() { var handler = new GetSearchFieldValuesHandler(_db.Factory); Option result = await handler.Handle( new GetSearchFieldValues("nope", string.Empty, 50), CancellationToken.None); result.IsNone.ShouldBeTrue(); } [Test] public async Task Matches_Case_Insensitive_Prefix() { await using (TvContext context = _db.CreateContext()) { context.Set().AddRange( new Genre { Name = "Action" }, new Genre { Name = "Comedy" }); await context.SaveChangesAsync(); } var handler = new GetSearchFieldValuesHandler(_db.Factory); Option result = await handler.Handle( new GetSearchFieldValues("genre", "a", 50), CancellationToken.None); result.IsSome.ShouldBeTrue(); result.IfSome(r => r.Values.ShouldBe(new List { "Action" })); } [Test] public async Task Excludes_Network_And_Country_Tags_From_Tag_Field_And_Routes_Network_Tags_To_Network_Field() { await using (TvContext context = _db.CreateContext()) { context.Set().AddRange( new Tag { Name = "PlainTag" }, new Tag { Name = "HBO", ExternalTypeId = Tag.PlexNetworkTypeId }, new Tag { Name = "USA", ExternalTypeId = Tag.NfoCountryTypeId }); await context.SaveChangesAsync(); } var handler = new GetSearchFieldValuesHandler(_db.Factory); Option tagResult = await handler.Handle( new GetSearchFieldValues("tag", string.Empty, 50), CancellationToken.None); tagResult.IsSome.ShouldBeTrue(); tagResult.IfSome(r => r.Values.ShouldBe(new List { "PlainTag" })); Option networkResult = await handler.Handle( new GetSearchFieldValues("network", string.Empty, 50), CancellationToken.None); networkResult.IsSome.ShouldBeTrue(); networkResult.IfSome(r => r.Values.ShouldBe(new List { "HBO" })); } [Test] public async Task Artist_Merges_Entity_Artists_Music_Video_Credits_And_Song_Credits() { await using (TvContext context = _db.CreateContext()) { context.ArtistMetadata.Add(Artist("Alpha Entity")); // negative control: an entity artist that must NOT match the "al" prefix context.ArtistMetadata.Add(Artist("Zeta Entity")); context.MusicVideoMetadata.AddRange( MusicVideo("MV One", "Alpha Credit", "Alpha Shared"), // "Alpha Shared" appears in two rows, so DISTINCT has something to collapse MusicVideo("MV Two", "Alpha Shared"), MusicVideo("MV Three", "Zeta Credit")); context.SongMetadata.AddRange( Song("Song One", ["Alpha Song", "Zeta Song"]), Song("Song Two", ["Alpha Song"]), Song("Song Three", ["Zeta Only"])); await context.SaveChangesAsync(); } var handler = new GetSearchFieldValuesHandler(_db.Factory); Option result = await handler.Handle( new GetSearchFieldValues("artist", "al", 50), CancellationToken.None); result.IsSome.ShouldBeTrue(); result.IfSome(r => r.Values.ShouldBe( new List { "Alpha Credit", "Alpha Entity", "Alpha Shared", "Alpha Song" })); } [Test] public async Task Artist_Returns_Every_Source_For_Empty_Query() { await using (TvContext context = _db.CreateContext()) { context.ArtistMetadata.Add(Artist("Entity")); context.MusicVideoMetadata.Add(MusicVideo("MV", "Credit")); context.SongMetadata.Add(Song("Song", ["SongArtist"])); await context.SaveChangesAsync(); } var handler = new GetSearchFieldValuesHandler(_db.Factory); Option result = await handler.Handle( new GetSearchFieldValues("artist", string.Empty, 50), CancellationToken.None); result.IsSome.ShouldBeTrue(); result.IfSome(r => r.Values.ShouldBe(new List { "Credit", "Entity", "SongArtist" })); } [Test] public async Task Album_Artist_Returns_Song_Album_Artists_Instead_Of_NotFound() { await using (TvContext context = _db.CreateContext()) { context.SongMetadata.AddRange( Song("One", ["Performer"], ["Alpha Album Artist", "Beta Album Artist"]), // repeated across rows so DISTINCT is exercised Song("Two", ["Performer"], ["Alpha Album Artist"]), // negative control: a row whose album artists are absent entirely Song("Three", ["Performer"], null)); await context.SaveChangesAsync(); } var handler = new GetSearchFieldValuesHandler(_db.Factory); Option result = await handler.Handle( new GetSearchFieldValues("album_artist", string.Empty, 50), CancellationToken.None); result.IsSome.ShouldBeTrue(); result.IfSome(r => r.Values.ShouldBe(new List { "Alpha Album Artist", "Beta Album Artist" })); // the performers on the same rows must not leak into album_artist result.IfSome(r => r.Values.ShouldNotContain("Performer")); } [Test] public async Task List_Valued_Fields_Match_Whole_Elements_Not_Substrings_And_Ignore_Neighbours() { await using (TvContext context = _db.CreateContext()) { context.SongMetadata.AddRange( // "Neighbour" arrives on the same row as "Radiohead" -- rows are read whole -- and must be // dropped by the in-memory exact prefix filter. Song("One", ["Radiohead", "Neighbour"]), // "The Radio Dept." contains "radio" but does not start with it Song("Two", ["The Radio Dept."]), Song("Three", ["Radio Birdman"])); await context.SaveChangesAsync(); } var handler = new GetSearchFieldValuesHandler(_db.Factory); Option result = await handler.Handle( new GetSearchFieldValues("artist", "radio", 50), CancellationToken.None); result.IsSome.ShouldBeTrue(); result.IfSome(r => r.Values.ShouldBe(new List { "Radio Birdman", "Radiohead" })); } [Test] public async Task List_Valued_Fields_Match_Literally_Including_Json_Escaped_And_Wildcard_Characters() { await using (TvContext context = _db.CreateContext()) { context.SongMetadata.AddRange( // non-ASCII: stored on disk JSON-escaped as \u00E9, and must survive the round trip Song("One", ["Beyoncé"]), // an embedded quote is stored as \u0022 Song("Two", ["\"Weird Al\" Yankovic"]), // SQL wildcards must be ordinary characters here, matched literally Song("Three", ["50% Off"]), Song("Four", ["50 Cent"])); await context.SaveChangesAsync(); } var handler = new GetSearchFieldValuesHandler(_db.Factory); (await handler.Handle(new GetSearchFieldValues("artist", "beyoncé", 50), CancellationToken.None)) .IfSome(r => r.Values.ShouldBe(new List { "Beyoncé" })); (await handler.Handle(new GetSearchFieldValues("artist", "\"weird", 50), CancellationToken.None)) .IfSome(r => r.Values.ShouldBe(new List { "\"Weird Al\" Yankovic" })); // "50%" must not behave as the wildcard "50" — "50 Cent" must not come back (await handler.Handle(new GetSearchFieldValues("artist", "50%", 50), CancellationToken.None)) .IfSome(r => r.Values.ShouldBe(new List { "50% Off" })); } /// /// Seeds non-matching songs through raw SQL — 20k rows via the change /// tracker is minutes, this is milliseconds. /// private static Task SeedFiller(TvContext context, int fillerRows) => context.Database.ExecuteSqlRawAsync( $""" WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < {fillerRows}) INSERT INTO SongMetadata (SongId, MetadataKind, Title, Artists, DateAdded, DateUpdated) SELECT 0, 0, 'Filler ' || n, '["zzz-filler"]', '2026-01-01', '2026-01-01' FROM seq """); [Test] public async Task List_Valued_Walk_Reads_At_Most_20000_Rows() { // Pinned in both directions so the ceiling itself is nailed down: a match in row 20000 is read, the same // match in row 20001 is not. The query has no RESIDUAL predicate -- only the cursor -- so "rows read" is // what LIMIT returns. That bounds LOGICAL rows, not physical work: the engine may still traverse more // index records than it returns (MySQL purge lag), and row width is unbounded. const string needle = "\u00E9clair-the-needle"; await using (TvContext context = _db.CreateContext()) { await SeedFiller(context, 19999); context.SongMetadata.Add(Song("Needle", [needle])); await context.SaveChangesAsync(); (await context.SongMetadata.CountAsync()).ShouldBe(20000); } var handler = new GetSearchFieldValuesHandler(_db.Factory); (await handler.Handle(new GetSearchFieldValues("artist", "\u00E9", 50), CancellationToken.None)) .IfSome(r => r.Values.ShouldBe(new List { needle }, "row 20000 is inside the ceiling")); await using (TvContext context = _db.CreateContext()) { SongMetadata existing = await context.SongMetadata.SingleAsync(m => m.Title == "Needle"); context.SongMetadata.Remove(existing); await SeedFiller(context, 1); await context.SaveChangesAsync(); context.SongMetadata.Add(Song("Needle", [needle])); await context.SaveChangesAsync(); (await context.SongMetadata.CountAsync()).ShouldBe(20001); } (await handler.Handle(new GetSearchFieldValues("artist", "\u00E9", 50), CancellationToken.None)) .IfSome( r => r.Values.ShouldBeEmpty( "row 20001 is past the ceiling; this false negative is the documented bounded-best-effort " + "contract, deliberately pinned rather than papered over")); } [Test] public async Task List_Valued_Walk_Reads_Live_Rows_Regardless_Of_Id_Density() { // Bounding the Id KEYSPACE is the killer here: keyspace is not rows — with 20,000 // historical rows deleted and one live song at Id 20001, the walk spent its whole allowance on empty // ranges and returned [] for a table containing exactly one row. Capacity degraded linearly with // deletion ratio, and no ratio was safe -- one placed gap hid the next match. // // Paging by row position rather than Id value makes density irrelevant: LIMIT @Batch returns @Batch // ROWS, wherever they sit in the keyspace. await using (TvContext context = _db.CreateContext()) { await SeedFiller(context, 20000); await context.Database.ExecuteSqlRawAsync("DELETE FROM SongMetadata"); context.SongMetadata.Add(Song("Survivor", ["Queen"])); await context.SaveChangesAsync(); // one live row, sitting past the old keyspace allowance (await context.SongMetadata.CountAsync()).ShouldBe(1); (await context.SongMetadata.Select(m => m.Id).SingleAsync()).ShouldBeGreaterThan(20000); } var handler = new GetSearchFieldValuesHandler(_db.Factory); (await handler.Handle(new GetSearchFieldValues("artist", "que", 50), CancellationToken.None)) .IfSome( r => r.Values.ShouldBe( new List { "Queen" }, "a one-row table must be fully readable no matter where its Id sits")); // and a leading gap must not hide a later match either await using (TvContext context = _db.CreateContext()) { context.SongMetadata.Add(Song("Second", ["Queens of the Stone Age"])); await context.SaveChangesAsync(); } (await handler.Handle(new GetSearchFieldValues("artist", "que", 50), CancellationToken.None)) .IfSome(r => r.Values.ShouldBe(new List { "Queen", "Queens of the Stone Age" })); } [Test] [TestCase("é", "\u00C9dith Piaf")] [TestCase("\u00C9", "\u00C9dith Piaf")] [TestCase("\u00E9dith", "\u00C9dith Piaf")] [TestCase("bj", "Bj\u00F6rk")] [TestCase("bj\u00F6", "Bj\u00F6rk")] [TestCase("BJ\u00D6RK", "Bj\u00F6rk")] [TestCase("beyonc\u00E9", "Beyonc\u00E9")] [TestCase("sigur r", "Sigur R\u00F3s")] [TestCase("\u00D6", "\u00D6zdemir")] public async Task Matches_NonAscii_Values_In_Any_Casing(string query, string stored) { // Accented artists are the common case in a music library, so non-ASCII matching is pinned end to // end, in both casings of the query. // // Historical note, because it is why this suite exists: revision 1b78dc9e narrowed rows in SQL // with a LIKE built by JSON-encoding the query, which cannot work -- non-ASCII is stored escaped // (\u00C9) and SQL LOWER() folds the escape TEXT, not the codepoint it denotes. THREE of these nine // cases fail against that revision (the ones where query and stored casing differ, so \u00e9 and // \u00C9 diverge); the other six pass it, because when the casings agree the escape texts line up. // The SQL now has no residual predicate at all -- matching happens in memory, where a string is just // a string -- so these cases pin current behaviour rather than guard that revision. await using (TvContext context = _db.CreateContext()) { context.SongMetadata.AddRange( Song("Hit", [stored]), // negative control: a row that must never come back for any of these queries Song("Other", ["Nothing Relevant"])); await context.SaveChangesAsync(); } var handler = new GetSearchFieldValuesHandler(_db.Factory); Option result = await handler.Handle( new GetSearchFieldValues("artist", query, 50), CancellationToken.None); result.IsSome.ShouldBeTrue(); result.IfSome(r => r.Values.ShouldBe(new List { stored })); } [Test] public async Task Results_Do_Not_Depend_On_The_Request_Culture() { // UseRequestLocalization honours Accept-Language, so CurrentCulture is caller-controlled. Under tr-TR // the old `q.ToLower()` turned "I" into "\u0131" and the default linguistic StartsWith(string) compounded // it, so the same library answered differently per caller. The contract is ordinal: "I" matches // "Istanbul" and does NOT match "\u0131pek", in every culture. await using (TvContext context = _db.CreateContext()) { context.SongMetadata.Add(Song("One", ["Istanbul Orkestrasi", "\u0131pek"])); context.ArtistMetadata.Add(Artist("Idil Biret")); await context.SaveChangesAsync(); } var handler = new GetSearchFieldValuesHandler(_db.Factory); var expected = new List { "Idil Biret", "Istanbul Orkestrasi" }; CultureInfo original = CultureInfo.CurrentCulture; try { foreach (string culture in new[] { "en-US", "tr-TR", "az-AZ", "lt-LT" }) { CultureInfo.CurrentCulture = new CultureInfo(culture); Option result = await handler.Handle( new GetSearchFieldValues("artist", "I", 50), CancellationToken.None); result.IsSome.ShouldBeTrue(); result.IfSome(r => r.Values.ShouldBe(expected, $"culture {culture} changed the result")); } } finally { CultureInfo.CurrentCulture = original; } } [Test] public async Task Ordering_Is_Ordinal_And_Culture_Independent() { // The merge sorts ordinally rather than by culture, so the response order does not depend on the caller // either. Ordinal puts all ASCII uppercase before ASCII lowercase, and non-ASCII last. await using (TvContext context = _db.CreateContext()) { context.SongMetadata.Add(Song("One", ["Zulu", "apple", "\u00C9clair", "Apple"])); await context.SaveChangesAsync(); } var handler = new GetSearchFieldValuesHandler(_db.Factory); var expected = new List { "Apple", "Zulu", "apple", "\u00C9clair" }; CultureInfo original = CultureInfo.CurrentCulture; try { foreach (string culture in new[] { "en-US", "sv-SE" }) { CultureInfo.CurrentCulture = new CultureInfo(culture); Option result = await handler.Handle( new GetSearchFieldValues("artist", string.Empty, 50), CancellationToken.None); result.IfSome(r => r.Values.ShouldBe(expected, $"culture {culture} changed the order")); } } finally { CultureInfo.CurrentCulture = original; } } [Test] public async Task Ordering_Is_Best_Effort_When_A_Source_Truncates() { // Documents the acknowledged imprecision rather than claiming exactness the code does not have. The EF // source truncates by the DATABASE collation, which is NOT the ordinal ordering the merge then applies — // so a value the database ranked outside its first `limit` never reaches the merge, even if the merge // would have ranked it first. // // "Zulu" vs "apple" is the pair that actually diverges: ordinal puts every ASCII uppercase letter before // every lowercase one, so ordinal ranks "Zulu" first, while a case-insensitive database ordering ranks // "apple" first. (An earlier version used "Zulu"/"Éclair", where BOTH orderings pick "Zulu" — it could // not have told the two apart, and the divergence it claimed to show did not exist.) await using (TvContext context = _db.CreateContext()) { context.ArtistMetadata.Add(Artist("Zulu")); context.ArtistMetadata.Add(Artist("apple")); await context.SaveChangesAsync(); } var handler = new GetSearchFieldValuesHandler(_db.Factory); // with room for both, the ordinal merge ranks "Zulu" first (await handler.Handle(new GetSearchFieldValues("artist", string.Empty, 50), CancellationToken.None)) .IfSome(r => r.Values.ShouldBe(new List { "Zulu", "apple" })); // with limit=1 the database picks the survivor by ITS ordering, and the merge only ever sees that one (await handler.Handle(new GetSearchFieldValues("artist", string.Empty, 1), CancellationToken.None)) .IfSome(r => r.Values.ShouldBe(new List { "apple" })); } [Test] public async Task A_Match_Behind_Many_NonMatching_Rows_Is_Still_Found() { // Fails a883e5f0, which capped rows at a fixed 1000 AFTER a deliberately over-matching SQL pre-filter: // the 1001st row -- the only exact match -- was discarded before the in-memory filter ever saw it and // the endpoint returned []. The pre-filter is gone, and the property it broke now holds for any match // within the read ceiling: preceding non-matching rows do not hide it. Past the ceiling it is still // lost by design -- see List_Valued_Walk_Reads_At_Most_20000_Rows, which pins that boundary. await using (TvContext context = _db.CreateContext()) { for (var i = 0; i < 1000; i++) { context.SongMetadata.Add(Song($"Filler {i}", ["zzz-filler"], ["zzz-filler-album"])); } context.SongMetadata.Add(Song("Needle", ["\u00E9clair"], ["\u00E9clair"])); await context.SaveChangesAsync(); } var handler = new GetSearchFieldValuesHandler(_db.Factory); Option albumArtist = await handler.Handle( new GetSearchFieldValues("album_artist", "\u00E9", 50), CancellationToken.None); albumArtist.IsSome.ShouldBeTrue(); albumArtist.IfSome(r => r.Values.ShouldBe(new List { "\u00E9clair" })); // same starvation shape on the merged `artist` field Option artist = await handler.Handle( new GetSearchFieldValues("artist", "\u00E9", 50), CancellationToken.None); artist.IsSome.ShouldBeTrue(); artist.IfSome(r => r.Values.ShouldBe(new List { "\u00E9clair" })); // ... and for a prefix beginning with a character that JSON escapes on disk. That used to collapse the // SQL pattern to the bare anchor; there is no prefix predicate at all now, so it is simply an ordinary // prefix -- kept because it is the input shape that broke the old scheme. await using (TvContext context = _db.CreateContext()) { context.SongMetadata.Add(Song("Ampersand", ["&Me"])); await context.SaveChangesAsync(); } Option escapedPrefix = await handler.Handle( new GetSearchFieldValues("artist", "&M", 50), CancellationToken.None); escapedPrefix.IsSome.ShouldBeTrue(); escapedPrefix.IfSome(r => r.Values.ShouldBe(new List { "&Me" })); } private static ArtistMetadata Artist(string title) => new() { MetadataKind = MetadataKind.External, DateAdded = DateTime.UtcNow, DateUpdated = DateTime.UtcNow, Title = title }; private static MusicVideoMetadata MusicVideo(string title, params string[] artists) => new() { MetadataKind = MetadataKind.External, DateAdded = DateTime.UtcNow, DateUpdated = DateTime.UtcNow, Title = title, Artists = artists.Map(a => new MusicVideoArtist { Name = a }).ToList() }; private static SongMetadata Song(string title, IList artists, IList albumArtists = null) => new() { MetadataKind = MetadataKind.External, DateAdded = DateTime.UtcNow, DateUpdated = DateTime.UtcNow, Title = title, Artists = artists, AlbumArtists = albumArtists }; [Test] public async Task Dedupes_Repeated_Values() { await using (TvContext context = _db.CreateContext()) { context.Set().AddRange( new Genre { Name = "Action" }, new Genre { Name = "Action" }); await context.SaveChangesAsync(); } var handler = new GetSearchFieldValuesHandler(_db.Factory); Option result = await handler.Handle( new GetSearchFieldValues("genre", string.Empty, 50), CancellationToken.None); result.IsSome.ShouldBeTrue(); result.IfSome(r => r.Values.ShouldBe(new List { "Action" })); } /// /// ersatztv#668. The EF-sourced fields prefix-match through SQL LOWER(), which on SQLite folds /// ASCII only: lower('Édith') returns 'Édith' unchanged, so a stored value whose /// prefix carries an uppercase non-ASCII character is unreachable from any query long enough to reach it. /// The stored-LOWERCASE case already worked (the handler lowercases the query before it reaches SQL, so /// both casings of the query fold to the same pattern) and is pinned alongside it, because the fix must /// SUPPLEMENT that path rather than replace it. /// [TestCase("genre", "é")] [TestCase("genre", "É")] public async Task Ef_Sourced_Stored_Uppercase_Accent_Is_Reachable(string field, string query) { await using (TvContext context = _db.CreateContext()) { context.Set().AddRange( new Genre { Name = "Édith" }, new Genre { Name = "Zulu" }); await context.SaveChangesAsync(); } var handler = new GetSearchFieldValuesHandler(_db.Factory); Option result = await handler.Handle( new GetSearchFieldValues(field, query, 50), CancellationToken.None); result.IsSome.ShouldBeTrue(); result.IfSome(r => r.Values.ShouldBe(new List { "Édith" })); } /// [TestCase("genre", "é")] [TestCase("genre", "É")] public async Task Ef_Sourced_Stored_Lowercase_Accent_Stays_Reachable(string field, string query) { await using (TvContext context = _db.CreateContext()) { context.Set().AddRange( new Genre { Name = "édith" }, new Genre { Name = "Zulu" }); await context.SaveChangesAsync(); } var handler = new GetSearchFieldValuesHandler(_db.Factory); Option result = await handler.Handle( new GetSearchFieldValues(field, query, 50), CancellationToken.None); result.IsSome.ShouldBeTrue(); result.IfSome(r => r.Values.ShouldBe(new List { "édith" })); } /// /// ersatztv#668. The Unicode fold added for the non-ASCII branch may OVER-match — the in-memory /// filter runs afterwards and drops the extras — /// but it must never UNDER-match. Each case pins the endpoint's answer against what that filter /// alone would say, so a fold that starts dropping rows fails here. It does NOT catch removal of the /// in-memory filter — every case here is either a positive that SQL alone returns, or an ASCII-query /// negative that SQL alone rejects. That direction is /// 's job. /// /// The negative cases here have ASCII queries, so they exercise the FAST PATH (the fold is /// skipped entirely) and pin that it is exact: "ſweet".StartsWith("S", OrdinalIgnoreCase) /// is false even though char.ToUpperInvariant('ſ') IS 'S'. The over-match the fold /// itself produces is a different path and is covered by /// . /// /// [TestCase("Édith", "é", true, TestName = "Fold_UppercaseAccent_LowercaseQuery")] [TestCase("Édith", "É", true, TestName = "Fold_UppercaseAccent_UppercaseQuery")] [TestCase("Özdemir", "ö", true, TestName = "Fold_Umlaut")] [TestCase("Sigur Rós", "sigur", true, TestName = "Fold_AsciiPrefix_NonAsciiLater")] [TestCase("Straße", "stra", true, TestName = "Fold_Eszett_AsciiQuery")] // explicit escapes: these three are visually indistinguishable from their ASCII lookalikes in a diff, // and an ASCII 'K' here would silently turn the KELVIN SIGN case into a trivially-true one [TestCase("\u017Fweet", "S", false, TestName = "Fold_LongS_IsNotOrdinalEqualToS")] [TestCase("\u212Aelvin", "k", false, TestName = "Fold_KelvinSign_IsNotOrdinalEqualToK")] [TestCase("\u0130stanbul", "i", false, TestName = "Fold_DottedCapitalI_IsNotOrdinalEqualToI")] public async Task Unicode_Fold_Agrees_With_The_Ordinal_Filter(string stored, string query, bool expected) { await using (TvContext context = _db.CreateContext()) { context.Set().Add(new Genre { Name = stored }); await context.SaveChangesAsync(); } // the oracle: what the endpoint's own final filter says, computed independently of the database stored.StartsWith(query, StringComparison.OrdinalIgnoreCase).ShouldBe(expected); var handler = new GetSearchFieldValuesHandler(_db.Factory); Option result = await handler.Handle( new GetSearchFieldValues("genre", query, 50), CancellationToken.None); result.IsSome.ShouldBeTrue(); result.IfSome(r => r.Values.ShouldBe(expected ? new List { stored } : [])); } /// /// ersatztv#668. Drives a row THROUGH the fold that the ordinal filter must then discard — the /// harmless over-match direction the whole design rests on, which the ASCII-query negative cases /// above cannot reach. q="ſ" is non-ASCII so the fold runs; ToUpperInvariant('ſ') is 'S', so /// the SQL pattern is S% and SQLite genuinely returns "Sword" — and the response must still /// be empty, because "Sword".StartsWith("ſ", OrdinalIgnoreCase) is false. /// [Test] public async Task Unicode_Fold_Over_Match_Is_Discarded_By_The_Ordinal_Filter() { await using (TvContext context = _db.CreateContext()) { context.Set().Add(new Genre { Name = "Sword" }); await context.SaveChangesAsync(); } // Premises, asserted because the expectation is an EMPTY list and would otherwise pass for the // wrong reason -- e.g. if the branch stopped running, or a hand-rolled fold stopped mapping ſ to S, // SQL would return nothing and this test would still be green. GetSearchFieldValuesHandler.ContainsNonAscii("\u017F").ShouldBeTrue(); char.ToUpperInvariant('\u017F').ShouldBe('S'); "Sword".StartsWith("\u017F", StringComparison.OrdinalIgnoreCase).ShouldBeFalse(); var handler = new GetSearchFieldValuesHandler(_db.Factory); Option result = await handler.Handle( new GetSearchFieldValues("genre", "\u017F", 50), CancellationToken.None); result.IsSome.ShouldBeTrue(); result.IfSome(r => r.Values.ShouldBeEmpty()); } /// /// ersatztv#668. The escaping's load-bearing role is NOT filtering — the in-memory ordinal filter /// already drops an over-match, which is why a plain count assertion stays green even with the /// escaping removed. It is preventing LIMIT CROWDING: an unescaped _ also matches the space, /// binary ORDER BY ranks "100 Édith" first, LIMIT 1 returns only that, the filter discards it, and /// the genuine "100_Édith" is never returned at all. This case fails if the escaping is removed. /// [Test] public async Task Unicode_Fold_Escaping_Prevents_Limit_Crowding() { await using (TvContext context = _db.CreateContext()) { context.Set().AddRange( new Genre { Name = "100 \u00C9dith" }, new Genre { Name = "100_\u00C9dith" }); await context.SaveChangesAsync(); } var handler = new GetSearchFieldValuesHandler(_db.Factory); Option result = await handler.Handle( new GetSearchFieldValues("genre", "100_\u00C9", 1), CancellationToken.None); result.IsSome.ShouldBeTrue(); result.IfSome(r => r.Values.ShouldBe(new List { "100_\u00C9dith" })); } /// /// ersatztv#668. The non-ASCII branch is raw SQL, so it gets none of the LIKE-wildcard escaping EF /// does for StartsWith. An unescaped % or _ in the query would match anything. /// [TestCase("100%É", 1, TestName = "Escapes_Percent")] [TestCase("100_É", 0, TestName = "Escapes_Underscore")] public async Task Unicode_Fold_Escapes_Like_Wildcards(string query, int expectedCount) { await using (TvContext context = _db.CreateContext()) { context.Set().AddRange( new Genre { Name = "100%Édith" }, new Genre { Name = "100XÉdith" }); await context.SaveChangesAsync(); } var handler = new GetSearchFieldValuesHandler(_db.Factory); Option result = await handler.Handle( new GetSearchFieldValues("genre", query, 50), CancellationToken.None); result.IsSome.ShouldBeTrue(); result.IfSome(r => r.Values.Count.ShouldBe(expectedCount)); } /// /// ersatztv#668. The non-ASCII branch duplicates each field's discriminator predicate in raw SQL, so /// it must reproduce EF's NULL semantics: EF compiles ExternalTypeId != NfoCountryTypeId with /// null semantics, which INCLUDES a NULL-typed row. Plain SQL <> would silently drop it. /// [Test] public async Task Unicode_Fold_Tag_Discriminator_Matches_Ef_Null_Semantics() { await using (TvContext context = _db.CreateContext()) { context.Set().AddRange( new Tag { Name = "Édith", ExternalTypeId = null }, new Tag { Name = "Éclair", ExternalTypeId = Tag.PlexNetworkTypeId }, new Tag { Name = "Ézra", ExternalTypeId = Tag.NfoCountryTypeId }); await context.SaveChangesAsync(); } var handler = new GetSearchFieldValuesHandler(_db.Factory); Option tags = await handler.Handle( new GetSearchFieldValues("tag", "é", 50), CancellationToken.None); // the NULL-typed row is a tag; the network- and country-typed rows are excluded tags.IsSome.ShouldBeTrue(); tags.IfSome(r => r.Values.ShouldBe(new List { "Édith" })); Option networks = await handler.Handle( new GetSearchFieldValues("network", "é", 50), CancellationToken.None); networks.IsSome.ShouldBeTrue(); networks.IfSome(r => r.Values.ShouldBe(new List { "Éclair" })); } }