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)
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
207 lines
10 KiB
C#
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);
|
|
}
|
|
}
|