Files
ersatztv/ErsatzTV.Tests/Integration/SearchFieldValuesProviderTests.cs
T
timothy f2d9c0dc8e
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 14s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 21s
Review verdict / Set review-verdict status (pull_request) Successful in 15s
PR Gates / decisions lifecycle (pull_request) Successful in 26s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 15s
PR Gates / Script tests (pytest) (pull_request) Successful in 53s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m0s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 14m33s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
review-verdict/h10 Review-verdict: MERGEABLE @ f2d9c0d (base: main)
fix(668): review round 5 -- three prose nits, including an off-by-one I filed
Final sweep confirmed the retracted MySQL over-match claim survives in no file
on the branch (only in two immutable commit messages, which stay -- rewriting
history would invalidate every sha-bound review verdict). Three nits remained.

- The fixture's class docstring said the on-MySQL claim "rests on the server's
  collation", which is the one thing the decision record says it does NOT rest
  on. It rests on Unicode-aware LOWER(); the executed comparison bypasses the
  collation entirely. Reworded.
- The record's `rule:` enumerated the covered fields but omitted show_genre,
  which GetSource and the fold both handle ("genre" or "show_genre"). Added.
- My own #688 write-up was wrong twice: the 60-line ceiling warning is
  NON-blocking by design, and the calibration pytest reds at >=61, not >=60 --
  main's p90 is 59, so a 60-line record makes p90 == ceiling and PASSES. The
  bullet even contradicted itself, since the next sentence relies on 60 passing.
  Corrected in the PR body and in the issue.

Decisions-Edit: yes
2026-07-27 22:21:55 +02:00

207 lines
10 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 reaches it unaided. (Its column collation
/// is accent-INsensitive, but the executed comparison is not — see the method docstring below.)
/// <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 Unicode-aware
/// <c>LOWER()</c> rather than on any code this repo owns — explicitly NOT on its collation, which
/// the executed comparison bypasses. 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 with the
/// CURRENT driver there is nothing to correct. That is a driver-contingent fact, not a law: a
/// driver or protocol change that made the pattern ci-collated would restore the over-match, and
/// the ordinal filter — which stays regardless — would then be doing real work here.
/// </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);
}
}