Files
ersatztv/ErsatzTV.Tests/Integration/SearchFieldValuesProviderTests.cs
T
timothy dda98efcc4
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 17s
review-verdict/h10 Awaiting review verdict for dda98ef
PR Gates / decisions lifecycle (pull_request) Successful in 25s
Review verdict / Set review-verdict status (pull_request) Successful in 13s
PR Gates / Script tests (pytest) (pull_request) Successful in 55s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m32s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m18s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m5s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(668): review round 3 -- MySQL does NOT over-match; correct the claim everywhere
Review BLOCKED on a false CI claim I copied from the sibling fixture ("CI sets
ETV_REQUIRE_MYSQL_TESTS=1"). Nothing sets it; the doc now says plainly that CI
does not arm this lane and points at ersatztv#627. That was the blocker.

Chasing the reviewer's second finding then overturned something bigger. It
predicted that seeding an unaccented "Edith" would make the in-memory ordinal
filter load-bearing on MySQL, since utf8mb4_0900_ai_ci treats é as e. Mutation
test says otherwise: with the filter deleted the MySQL test stays GREEN.

Measured against a live 8.4 to find out why:

    LOWER(Name) LIKE 'é%'          (literal)          -> Édith AND Edith
    LOWER(Name) LIKE @v            (ai_ci variable)   -> Édith AND Edith
    LOWER(Name) LIKE @v COLLATE _bin                  -> Édith only
    the EF query, executed                            -> Édith only

The driver binds the pattern with a BINARY collation, so the executed
comparison is accent-SENSITIVE and MySQL does not over-match at all. MySQL's
correctness rests on its Unicode-aware LOWER(), not on the collation.

My earlier probe used a LITERAL pattern -- a different query from the one the
code runs -- and I wrote its result into the handler comment, the decision
record and the PR body. All three now say what actually happens, and the record
carries the lesson: measure the query the CODE runs, not one you type.

The "Edith" row stays as a near-miss control, with a docstring that says what it
does and does not prove rather than the over-match story it was added for.

Also moved EnsureCreatedAsync out of [SetUp]: NUnit skips [TearDown] when
[SetUp] throws, so a mid-create failure would strand the database.

Decisions-Edit: yes
2026-07-27 21:52:16 +02:00

203 lines
9.7 KiB
C#

using ErsatzTV.Application.Search.Queries;
using ErsatzTV.Core.Api.Search;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.MySql.Data;
using ErsatzTV.Infrastructure.Sqlite.Data;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using MySqlConnector;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Integration;
/// <summary>
/// ersatztv#668, EXECUTED on both providers. The bug was a collation/fold difference, so it lives exactly
/// where a single-provider test cannot see it: SQLite's <c>LOWER()</c> folds ASCII only and UNDER-matched
/// a stored <c>Édith</c>, while MySQL's is Unicode-aware and its ci collation OVER-matches instead.
/// <para>
/// <see cref="ErsatzTV.Tests.Application.Search.GetSearchFieldValuesHandlerTests" /> covers the
/// SQLite semantics in depth against in-memory SQLite, and
/// <c>SearchFieldValuesQueryShapeTests</c> pins the generated SQL for both providers without a
/// server. Neither can show that a REAL MySQL server returns the accented value — the fix's central
/// claim is "on both providers", and on MySQL that rests on the server's collation rather than on
/// any code this repo owns. That is precisely the kind of assumption worth executing.
/// </para>
/// <para>
/// MySQL needs a live server via <c>ETV_TEST_MYSQL_CONNECTION</c>. Without it the MySQL fixture
/// <b>ignores</b> — a visible skip, never a silent pass. Setting <c>ETV_REQUIRE_MYSQL_TESTS=1</c>
/// turns that skip into a hard failure, so an ARMED lane cannot degrade into "connected to nothing
/// and passed".
/// </para>
/// <para>
/// <b>CI does not currently arm it</b>, so in CI this half SKIPS. Running MySQL fixtures against the
/// live service was implemented and then removed as non-deterministic — see the note in
/// <c>.gitea/workflows/docker-build.yml</c>; re-arming is tracked by ersatztv#627. Do not read the
/// REQUIRE variable above as a guarantee that something enforces this today: nothing does. This
/// mirrors <see cref="LibraryFolderDedupeMigrationTests" /> deliberately; the two fixtures share the
/// contract, not code, because their setup needs differ.
/// </para>
/// </summary>
[TestFixture(TestProvider.Sqlite)]
[TestFixture(TestProvider.MySql)]
[NonParallelizable]
public class SearchFieldValuesProviderTests(TestProvider provider)
{
private const string MySqlConnectionVariable = "ETV_TEST_MYSQL_CONNECTION";
private const string MySqlRequiredVariable = "ETV_REQUIRE_MYSQL_TESTS";
private string _databasePath = null!;
private string? _mySqlConnectionString;
private DbContextOptions<TvContext> _options = null!;
[SetUp]
public async Task SetUp()
{
if (provider is TestProvider.Sqlite)
{
TvContext.IsSqlite = true;
TvContext.LastInsertedRowId = "last_insert_rowid()";
TvContext.CaseInsensitiveCollation = "NOCASE";
TvContext.IsUniqueConstraintViolation = SqliteErrorClassifier.IsUniqueConstraintViolation;
TvContext.RegisterUnicodeCaseFunctions = SqliteUnicodeFunctions.Register;
_databasePath = Path.Combine(Path.GetTempPath(), $"etv668-{Guid.NewGuid():N}.sqlite3");
_options = new DbContextOptionsBuilder<TvContext>()
.UseSqlite($"Data Source={_databasePath}")
.Options;
}
else
{
string? baseConnectionString = Environment.GetEnvironmentVariable(MySqlConnectionVariable);
if (string.IsNullOrWhiteSpace(baseConnectionString))
{
string message =
$"{MySqlConnectionVariable} is not set, so the MySql half of the #668 facet-value fixture "
+ "cannot run. This endpoint's correctness is collation-dependent and therefore "
+ "provider-specific, so the coverage is not optional in CI.";
if (IsTrue(Environment.GetEnvironmentVariable(MySqlRequiredVariable)))
{
Assert.Fail($"{message} {MySqlRequiredVariable} is set, so this is a failure, not a skip.");
}
Assert.Ignore($"{message} Set it to run this locally.");
}
// A database of our own with a name that has never been used, so isolation does not depend on a
// wipe succeeding. Dropped and its pool cleared in TearDown.
_mySqlConnectionString =
new MySqlConnectionStringBuilder(baseConnectionString) { Database = $"etv668_{Guid.NewGuid():N}" }
.ConnectionString;
TvContext.IsSqlite = false;
TvContext.LastInsertedRowId = "last_insert_id()";
TvContext.CaseInsensitiveCollation = "utf8mb4_general_ci";
TvContext.IsUniqueConstraintViolation = MySqlErrorClassifier.IsUniqueConstraintViolation;
// Explicitly the no-op: MySQL's own LOWER() is Unicode-aware, so the handler must reach the
// accented value WITHOUT any custom fold. Wiring SQLite's here would mask that.
TvContext.RegisterUnicodeCaseFunctions = static _ => { };
_options = new DbContextOptionsBuilder<TvContext>()
.UseMySql(_mySqlConnectionString, ServerVersion.AutoDetect(_mySqlConnectionString))
.Options;
}
// Schema creation deliberately does NOT happen here: NUnit skips [TearDown] when [SetUp] throws, so
// a failure part-way through EnsureCreatedAsync would strand the created database (and its pooled
// connection) with nothing to drop it. The test body creates it instead, matching the sibling
// fixture, whose SetUp likewise cannot strand one.
}
[TearDown]
public async Task TearDown()
{
if (provider is TestProvider.Sqlite)
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
foreach (string path in new[] { _databasePath, $"{_databasePath}-wal", $"{_databasePath}-shm" })
{
if (File.Exists(path))
{
File.Delete(path);
}
}
return;
}
if (_mySqlConnectionString is not null)
{
await using (TvContext context = Create(_options))
{
await context.Database.EnsureDeletedAsync();
}
// MySqlConnector keys pools by connection string; a fresh database name means a fresh pool, and
// leaving it uncleared leaks a server thread per test until max_connections is exhausted.
await using var probe = new MySqlConnection(_mySqlConnectionString);
await MySqlConnection.ClearPoolAsync(probe);
_mySqlConnectionString = null;
}
}
/// <summary>
/// The #668 headline, executed: a stored value whose prefix carries an UPPERCASE non-ASCII character
/// is reachable from both casings of the query, on whichever provider this fixture is running.
/// <para>
/// Negative controls: "Zulu" (trivially unrelated) and "Edith" (unaccented, the near miss).
/// <b>Be precise about what "Edith" does and does not prove.</b> It was added expecting MySQL to
/// OVER-match it — the column collation is <c>utf8mb4_0900_ai_ci</c>, so <c>é</c> equals <c>e</c>
/// — which would have made the in-memory ordinal filter load-bearing here. Measured against a
/// live 8.4 server, it does not: deleting that filter leaves this test green, because the driver
/// binds the LIKE pattern with a BINARY collation and the executed comparison is therefore
/// accent-SENSITIVE. (A literal pattern typed by hand DOES over-match — a different query from
/// the one the handler runs.) So the row pins the accent-sensitive result on both providers and
/// documents the near miss; it does NOT exercise an over-match correction, because on this path
/// there is nothing to correct.
/// </para>
/// </summary>
[TestCase("é", TestName = "Uppercase_Accent_Reachable_From_Lowercase_Query")]
[TestCase("É", TestName = "Uppercase_Accent_Reachable_From_Uppercase_Query")]
public async Task Stored_Uppercase_Accent_Is_Reachable(string query)
{
await using (TvContext context = Create(_options))
{
await context.Database.EnsureCreatedAsync();
context.Set<Genre>().AddRange(
new Genre { Name = "Édith" },
new Genre { Name = "Edith" },
new Genre { Name = "Zulu" });
await context.SaveChangesAsync();
}
var handler = new GetSearchFieldValuesHandler(new TestDbContextFactory(_options));
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
new GetSearchFieldValues("genre", query, 50),
CancellationToken.None);
result.IsSome.ShouldBeTrue();
result.IfSome(r => r.Values.ShouldBe(new List<string> { "Édith" }));
}
private static bool IsTrue(string? value) =>
value is "1" || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
private static TvContext Create(DbContextOptions<TvContext> options) =>
new(
options,
NullLoggerFactory.Instance,
new SlowQueryInterceptor(NullLogger<SlowQueryInterceptor>.Instance));
private sealed class TestDbContextFactory(DbContextOptions<TvContext> options) : IDbContextFactory<TvContext>
{
public TvContext CreateDbContext() => Create(options);
}
}