Files
ersatztv/ErsatzTV.Tests/Application/Search/GetSearchFieldValuesHandlerTests.cs
T
timothy 1641ca8305 fix(578): the LIKE prefilter under-matched every accented artist; make the superset provable
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.
2026-07-27 03:10:35 +02:00

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" }));
}
}