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
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
203 lines
9.7 KiB
C#
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);
|
|
}
|
|
}
|