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; /// /// 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 LOWER() folds ASCII only and UNDER-matched /// a stored Édith, 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.) /// /// covers the /// SQLite semantics in depth against in-memory SQLite, and /// SearchFieldValuesQueryShapeTests 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 /// LOWER() 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. /// /// /// MySQL needs a live server via ETV_TEST_MYSQL_CONNECTION. Without it the MySQL fixture /// ignores — a visible skip, never a silent pass. Setting ETV_REQUIRE_MYSQL_TESTS=1 /// turns that skip into a hard failure, so an ARMED lane cannot degrade into "connected to nothing /// and passed". /// /// /// CI does not currently arm it, 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 /// .gitea/workflows/docker-build.yml; 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 deliberately; the two fixtures share the /// contract, not code, because their setup needs differ. /// /// [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 _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() .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() .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; } } /// /// 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. /// /// Negative controls: "Zulu" (trivially unrelated) and "Edith" (unaccented, the near miss). /// Be precise about what "Edith" does and does not prove. It was added expecting MySQL to /// OVER-match it — the column collation is utf8mb4_0900_ai_ci, so é equals e /// — 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. /// /// [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().AddRange( new Genre { Name = "Édith" }, new Genre { Name = "Edith" }, new Genre { Name = "Zulu" }); await context.SaveChangesAsync(); } var handler = new GetSearchFieldValuesHandler(new TestDbContextFactory(_options)); Option result = await handler.Handle( new GetSearchFieldValues("genre", query, 50), CancellationToken.None); result.IsSome.ShouldBeTrue(); result.IfSome(r => r.Values.ShouldBe(new List { "Édith" })); } private static bool IsTrue(string? value) => value is "1" || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); private static TvContext Create(DbContextOptions options) => new( options, NullLoggerFactory.Instance, new SlowQueryInterceptor(NullLogger.Instance)); private sealed class TestDbContextFactory(DbContextOptions options) : IDbContextFactory { public TvContext CreateDbContext() => Create(options); } }