Review of 1b78dc9e found the pre-filter's correctness claim was false, and the claim was in the decision record as well as the code. F1 (high). The pattern JSON-encoded the whole query prefix on the reasoning that the stored text escapes non-ASCII, so encoding the prefix the same way would line up. It does not: SQL LOWER() lowercases the *escape text* (`É` -> `é`); it cannot case-fold the codepoint that escape denotes. So `q=é` built `%"é%`, the stored `Édith Piaf` never matched, and the row was discarded before the in-memory filter could accept it. Every accented artist — Beyoncé, Björk, Sigur Rós, Édith Piaf — was silently unsuggestable, which in a music library is the common case. The invariant that was missing, now stated in the code: the SQL pre-filter is an OPTIMIZATION. It may over-match; it must never under-match. Correctness lives in the in-memory filter. So the pattern now narrows only on the leading run of characters the JSON writer stores verbatim and stops at the first character it cannot prove — `q=Beyoncé` still narrows on `beyonc`, `q=é` narrows on nothing and leans on the row cap. Soundness rests on two facts now asserted by exhaustive computation rather than argued: no non-ASCII codepoint in U+0080..U+10FFFF OrdinalIgnoreCase-equals a printable ASCII character (false for InvariantCultureIgnoreCase, which folds ~190 — the choice of Ordinal is load-bearing), and the exact set of ASCII the encoder escapes. F1b. `UseRequestLocalization` honours Accept-Language, so the culture was caller-controlled and `ToLower()` plus the default linguistic `StartsWith(string)` let a header change the answer. Comparison is now OrdinalIgnoreCase and ordering StringComparer.Ordinal throughout — including the shared FilterSortTake that state/video_dynamic_range/content_rating also use. Sets unchanged, order now ordinal rather than culture-dependent. F2. The merge comment asserted an exactness the code does not have: sources truncate by their own ordering (DB collation / primary key), not the merge's, so a dropped value can outrank a survivor. Comment and record now say best-effort, exact only below the truncation points. F3/F4. The cap now rides `ORDER BY Id` rather than the JSON column: MySQL sorts TEXT by only max_sort_length bytes, so the old ordering was not deterministic there, and sorting the whole matching set was avoidable work. What the cap still does NOT bound is the scan — a leading-wildcard LIKE cannot seek an index — so that cost is now documented as accepted, with a normalized `SongArtist` table named as the follow-up candidate rather than left implicit. Every clause above is covered by a test verified to FAIL when that clause is mutated (old pattern builder: 5 red; culture chain: 3 red; cap=3 / cap=limit / ORDER BY json / no cap: red each). F5. Converted to a proper supersession. The old record did not merely hold a stale fact — it recorded song/music-video credits as an "intentionally-uncovered gap" and album_artist as unsupported, and this reverses that call, which `docs.decision-lifecycle` says is never a line-edit. `api.search-field-values` is archived with its original prose restored, and `api.search-field-values-sources` replaces it carrying the whole endpoint contract.
604 lines
24 KiB
C#
604 lines
24 KiB
C#
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<Genre>().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<SearchFieldValuesResponseModel> result = await handler.Handle(
|
|
new GetSearchFieldValues("genre", "A", 50),
|
|
CancellationToken.None);
|
|
|
|
result.IsSome.ShouldBeTrue();
|
|
result.IfSome(r => r.Values.ShouldBe(new List<string> { "Action", "Adventure", "Animation" }));
|
|
|
|
Option<SearchFieldValuesResponseModel> limited = await handler.Handle(
|
|
new GetSearchFieldValues("genre", "A", 2),
|
|
CancellationToken.None);
|
|
|
|
limited.IsSome.ShouldBeTrue();
|
|
limited.IfSome(r => r.Values.ShouldBe(new List<string> { "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<SearchFieldValuesResponseModel> result = await handler.Handle(
|
|
new GetSearchFieldValues("content_rating", string.Empty, 50),
|
|
CancellationToken.None);
|
|
|
|
result.IsSome.ShouldBeTrue();
|
|
result.IfSome(r => r.Values.ShouldBe(new List<string> { "PG-13", "TV-14" }));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Returns_MediaItemState_Enum_Names_Without_Seeding()
|
|
{
|
|
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
|
|
|
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
|
new GetSearchFieldValues("state", string.Empty, 50),
|
|
CancellationToken.None);
|
|
|
|
result.IsSome.ShouldBeTrue();
|
|
result.IfSome(r => r.Values.ShouldBe(
|
|
new List<string> { "FileNotFound", "Normal", "RemoteOnly", "Unavailable" }));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Returns_NotFound_For_Excluded_Text_Field_Title()
|
|
{
|
|
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
|
|
|
Option<SearchFieldValuesResponseModel> 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<SearchFieldValuesResponseModel> 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<SearchFieldValuesResponseModel> 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<Genre>().AddRange(
|
|
new Genre { Name = "Action" },
|
|
new Genre { Name = "Comedy" });
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
|
|
|
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
|
new GetSearchFieldValues("genre", "a", 50),
|
|
CancellationToken.None);
|
|
|
|
result.IsSome.ShouldBeTrue();
|
|
result.IfSome(r => r.Values.ShouldBe(new List<string> { "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<Tag>().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<SearchFieldValuesResponseModel> tagResult = await handler.Handle(
|
|
new GetSearchFieldValues("tag", string.Empty, 50),
|
|
CancellationToken.None);
|
|
|
|
tagResult.IsSome.ShouldBeTrue();
|
|
tagResult.IfSome(r => r.Values.ShouldBe(new List<string> { "PlainTag" }));
|
|
|
|
Option<SearchFieldValuesResponseModel> networkResult = await handler.Handle(
|
|
new GetSearchFieldValues("network", string.Empty, 50),
|
|
CancellationToken.None);
|
|
|
|
networkResult.IsSome.ShouldBeTrue();
|
|
networkResult.IfSome(r => r.Values.ShouldBe(new List<string> { "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<SearchFieldValuesResponseModel> result = await handler.Handle(
|
|
new GetSearchFieldValues("artist", "al", 50),
|
|
CancellationToken.None);
|
|
|
|
result.IsSome.ShouldBeTrue();
|
|
result.IfSome(r => r.Values.ShouldBe(
|
|
new List<string> { "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<SearchFieldValuesResponseModel> result = await handler.Handle(
|
|
new GetSearchFieldValues("artist", string.Empty, 50),
|
|
CancellationToken.None);
|
|
|
|
result.IsSome.ShouldBeTrue();
|
|
result.IfSome(r => r.Values.ShouldBe(new List<string> { "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<SearchFieldValuesResponseModel> result = await handler.Handle(
|
|
new GetSearchFieldValues("album_artist", string.Empty, 50),
|
|
CancellationToken.None);
|
|
|
|
result.IsSome.ShouldBeTrue();
|
|
result.IfSome(r => r.Values.ShouldBe(new List<string> { "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" rides along in the SQL superset because "Radiohead" matches; it 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<SearchFieldValuesResponseModel> result = await handler.Handle(
|
|
new GetSearchFieldValues("artist", "radio", 50),
|
|
CancellationToken.None);
|
|
|
|
result.IsSome.ShouldBeTrue();
|
|
result.IfSome(r => r.Values.ShouldBe(new List<string> { "Radio Birdman", "Radiohead" }));
|
|
}
|
|
|
|
[Test]
|
|
public async Task List_Valued_Fields_Survive_Json_Escaping_And_Like_Wildcards()
|
|
{
|
|
await using (TvContext context = _db.CreateContext())
|
|
{
|
|
context.SongMetadata.AddRange(
|
|
// non-ASCII: stored on disk JSON-escaped as é
|
|
Song("One", ["Beyoncé"]),
|
|
// an embedded quote is stored as "
|
|
Song("Two", ["\"Weird Al\" Yankovic"]),
|
|
// LIKE wildcards in the query must be treated 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<string> { "Beyoncé" }));
|
|
|
|
(await handler.Handle(new GetSearchFieldValues("artist", "\"weird", 50), CancellationToken.None))
|
|
.IfSome(r => r.Values.ShouldBe(new List<string> { "\"Weird Al\" Yankovic" }));
|
|
|
|
// "50%" must not behave as the wildcard "50<anything>" — "50 Cent" must not come back
|
|
(await handler.Handle(new GetSearchFieldValues("artist", "50%", 50), CancellationToken.None))
|
|
.IfSome(r => r.Values.ShouldBe(new List<string> { "50% Off" }));
|
|
}
|
|
|
|
[Test]
|
|
public async Task List_Valued_Source_Row_Cap_Is_Exactly_1000_Rows()
|
|
{
|
|
// Pins the cap value in BOTH directions. Asserting only that an over-cap row is dropped would also pass
|
|
// with a cap of 3, or with the handler wrongly using `limit` as the row cap; asserting only that an
|
|
// under-cap row survives would pass with no cap at all. The marker row's element sorts first in the
|
|
// output, so an unbounded implementation cannot help but return it.
|
|
const string marker = "aaa-the-marker-row";
|
|
|
|
// rows are capped by ascending Id, and Id is assigned in insertion order
|
|
async Task SeedFillerThenMarker(int fillerRows)
|
|
{
|
|
await using TvContext context = _db.CreateContext();
|
|
for (var i = 0; i < fillerRows; i++)
|
|
{
|
|
context.SongMetadata.Add(Song($"Song {i}", [$"b{i:D4}"]));
|
|
}
|
|
|
|
context.SongMetadata.Add(Song("Marker", [marker]));
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
|
|
|
// 999 filler rows -> the marker is row 1000 -> inside a cap of 1000
|
|
await SeedFillerThenMarker(999);
|
|
|
|
Option<SearchFieldValuesResponseModel> atCap = await handler.Handle(
|
|
new GetSearchFieldValues("artist", string.Empty, 3),
|
|
CancellationToken.None);
|
|
|
|
atCap.IsSome.ShouldBeTrue();
|
|
atCap.IfSome(
|
|
r => r.Values.ShouldBe(
|
|
new List<string> { marker, "b0000", "b0001" },
|
|
"the 1000th row must still be read - a cap below 1000 would drop it"));
|
|
|
|
// one more filler row pushes the marker to row 1001 -> outside a cap of 1000
|
|
await using (TvContext context = _db.CreateContext())
|
|
{
|
|
context.SongMetadata.Add(Song("Extra", ["b9999"]));
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
await using (TvContext context = _db.CreateContext())
|
|
{
|
|
// move the marker to the end so it sits at Id 1002, past the cap
|
|
SongMetadata existing = await context.SongMetadata.SingleAsync(m => m.Title == "Marker");
|
|
context.SongMetadata.Remove(existing);
|
|
await context.SaveChangesAsync();
|
|
context.SongMetadata.Add(Song("Marker", [marker]));
|
|
await context.SaveChangesAsync();
|
|
|
|
(await context.SongMetadata.CountAsync()).ShouldBe(1001);
|
|
}
|
|
|
|
Option<SearchFieldValuesResponseModel> pastCap = await handler.Handle(
|
|
new GetSearchFieldValues("artist", string.Empty, 3),
|
|
CancellationToken.None);
|
|
|
|
pastCap.IsSome.ShouldBeTrue();
|
|
pastCap.IfSome(
|
|
r => r.Values.ShouldNotContain(
|
|
marker,
|
|
"the 1001st row must not be read - an unbounded query would return it first"));
|
|
pastCap.IfSome(r => r.Values.ShouldBe(new List<string> { "b0000", "b0001", "b0002" }));
|
|
|
|
// positive control: the same row IS reachable once the pre-filter narrows the candidates below the cap,
|
|
// so the assertion above cannot be passing because the value is unreachable for an unrelated reason
|
|
Option<SearchFieldValuesResponseModel> narrowed = await handler.Handle(
|
|
new GetSearchFieldValues("artist", "aaa", 50),
|
|
CancellationToken.None);
|
|
|
|
narrowed.IsSome.ShouldBeTrue();
|
|
narrowed.IfSome(r => r.Values.ShouldBe(new List<string> { marker }));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Regression_Pin_List_Valued_Columns_Are_Stored_As_Ascii_Escaped_Json_Arrays()
|
|
{
|
|
// REGRESSION PIN, not coverage of #578: this asserts pre-existing EF behaviour and passes against the
|
|
// code before this change. It is here because the pre-filter's correctness argument depends on the
|
|
// stored form, so an encoder change must fail loudly rather than silently under-match.
|
|
//
|
|
// Specifically it pins that non-ASCII IS escaped — which is exactly why SQL LOWER() canNOT be used to
|
|
// case-fold it. LOWER() lowercases the escape TEXT (\u00E9 -> \u00e9); it does not touch the codepoint
|
|
// the escape denotes, so the stored `É` never folds to `é`. An earlier version of this change
|
|
// claimed the opposite and JSON-encoded the whole query prefix, which silently made every accented
|
|
// artist unsuggestable. The pattern builder now narrows only on verbatim ASCII; see
|
|
// SearchFieldValuesPrefilterSupersetTests.
|
|
await using (TvContext context = _db.CreateContext())
|
|
{
|
|
context.SongMetadata.Add(Song("One", ["Beyoncé", "\"Q\""]));
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
await using (TvContext context = _db.CreateContext())
|
|
{
|
|
List<string> raw = await context.Database
|
|
.SqlQueryRaw<string>("SELECT Artists AS Value FROM SongMetadata")
|
|
.ToListAsync();
|
|
|
|
raw.ShouldBe(new List<string> { """["Beyonc\u00E9","\u0022Q\u0022"]""" });
|
|
}
|
|
}
|
|
|
|
[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 Prefilter_Never_Under_Matches_A_NonAscii_Value(string query, string stored)
|
|
{
|
|
// The SQL pre-filter narrows on raw JSON, where non-ASCII is stored escaped (\u00C9). Any narrowing
|
|
// that includes an escaped character under-matches, because SQL LOWER() folds the escape TEXT and not
|
|
// the codepoint — every one of these cases returned EMPTY before the pattern builder was restricted to
|
|
// the leading verbatim-ASCII run. Accented artists are the common case in a music library, not an edge.
|
|
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<SearchFieldValuesResponseModel> result = await handler.Handle(
|
|
new GetSearchFieldValues("artist", query, 50),
|
|
CancellationToken.None);
|
|
|
|
result.IsSome.ShouldBeTrue();
|
|
result.IfSome(r => r.Values.ShouldBe(new List<string> { 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<string> { "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<SearchFieldValuesResponseModel> 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<string> { "Apple", "Zulu", "apple", "\u00C9clair" };
|
|
|
|
CultureInfo original = CultureInfo.CurrentCulture;
|
|
try
|
|
{
|
|
foreach (string culture in new[] { "en-US", "sv-SE" })
|
|
{
|
|
CultureInfo.CurrentCulture = new CultureInfo(culture);
|
|
|
|
Option<SearchFieldValuesResponseModel> 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 (SQLite's is ASCII-only), 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.
|
|
await using (TvContext context = _db.CreateContext())
|
|
{
|
|
context.ArtistMetadata.Add(Artist("Zulu"));
|
|
context.ArtistMetadata.Add(Artist("\u00C9clair"));
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
|
|
|
// with room for both, the ordinal merge ranks "Zulu" (ASCII) before "\u00C9clair" (non-ASCII)
|
|
(await handler.Handle(new GetSearchFieldValues("artist", string.Empty, 50), CancellationToken.None))
|
|
.IfSome(r => r.Values.ShouldBe(new List<string> { "Zulu", "\u00C9clair" }));
|
|
|
|
// with limit=1 the database picks the survivor by ITS collation, and the merge only sees that one
|
|
(await handler.Handle(new GetSearchFieldValues("artist", string.Empty, 1), CancellationToken.None))
|
|
.IfSome(r => r.Values.Count.ShouldBe(1));
|
|
}
|
|
|
|
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<string> artists, IList<string> 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<Genre>().AddRange(
|
|
new Genre { Name = "Action" },
|
|
new Genre { Name = "Action" });
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
|
|
|
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
|
new GetSearchFieldValues("genre", string.Empty, 50),
|
|
CancellationToken.None);
|
|
|
|
result.IsSome.ShouldBeTrue();
|
|
result.IfSome(r => r.Values.ShouldBe(new List<string> { "Action" }));
|
|
}
|
|
}
|