using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Infrastructure;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.MySql.Data;
using ErsatzTV.Infrastructure.Sqlite.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using MySqlConnector;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Integration;
///
/// ersatztv#823 — the question ersatztv#701 split out rather than answered: can a runtime null reach
/// one of the SIX collection-valued scalar columns (DaysOfMonth, MonthsOfYear,
/// DaysOfWeek on and )?
///
/// Measured, not reasoned about, because the reasoning available beforehand pointed the
/// wrong way. The two converters differ on their read side —
/// IntCollectionValueConverter maps null-or-blank to Array.Empty<int>() while
/// EnumCollectionJsonValueConverter would dereference the result of
/// JsonConvert.DeserializeObject — so the expectation was that the two behave differently
/// on a NULL row. They do not: EF does not invoke a value converter for a NULL column at
/// all, so all six materialize as CLR null and the int converter's null-to-empty
/// branch is dead on this path. That is the measurement this fixture pins.
///
///
/// It also pins the WRITE half: assigning null to one of the six and calling
/// SaveChanges SUCCEEDS and stores SQL NULL — the converter is skipped on the way out too.
/// State that precisely, because the overclaim is tempting: this is a property of the CODE, not a
/// live caller. NO caller supplies a null today — every production construction of
/// ReplacePlayoutAlternateSchedule / ReplacePlayoutTemplate goes through the HTTP
/// request records, which normalize with ?? []. What makes it a latent gun is that
/// ReplacePlayoutAlternateScheduleItemsHandler / ReplacePlayoutTemplateItemsHandler
/// assign the command value straight onto the entity, so nothing in the write path itself refuses.
///
///
/// The LEGACY route is narrower than "the columns are nullable", and conflating the two is the easy
/// error: all six are nullable: true on both providers, but five of the six were present at
/// CreateTable, so a NULL there still needs code to write one. EXACTLY ONE case is
/// code-path-free — SQLite's 20240113140741_Add_PlayoutTemplate_DaysOfMonth is an
/// AddColumn with nullable: true and NO defaultValue, so PlayoutTemplate
/// rows inserted before it hold NULL, and by construction those rows had no day-of-month
/// restriction. On MySQL PlayoutTemplate arrived whole in
/// 20240114034944_Add_BlockScheduling, so there is no code-path-free NULL for any of the six
/// there. This fixture manufactures its NULL with a raw UPDATE, which is a code path — it
/// measures MATERIALIZATION, and the legacy route above is established by reading the migrations.
///
///
/// What the selector then does with it is pinned by
/// ErsatzTV.Core.Tests.Scheduling.AlternateScheduleSelectorTests.GetScheduleForDate_NullCollections:
/// unguarded, .Contains throws .
///
///
/// Runs against BOTH providers from ONE fixture body, because the question is about provider
/// materialization and a SQLite-only answer would not have settled it. MySQL needs a live server,
/// supplied via ETV_TEST_MYSQL_CONNECTION; without it the MySQL fixture ignores — a
/// visible skip, never a silent pass. ETV_REQUIRE_MYSQL_TESTS turns that skip into a hard
/// failure for a runner that is supposed to have one. This mirrors
/// , which established the pattern.
///
///
/// Cost, stated. Each test migrates a FRESH database (per-test isolation by construction —
/// ersatztv#491 measured what a shared database and a wipe-that-must-succeed cost). Replaying every
/// migration is the expensive part and it is deliberate: a reachability fixture should stand on the
/// SHIPPED schema, not on one EnsureCreated builds from the current model.
///
///
[TestFixture(TestProvider.Sqlite)]
[TestFixture(TestProvider.MySql)]
[NonParallelizable]
public class SchedulingCollectionColumnNullTests(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!;
private string _originalLastInsertedRowId = null!;
private string _originalCollation = null!;
private bool _originalIsSqlite;
private Func _originalUniqueViolation = null!;
///
/// TvContext's provider statics are process-wide, and the MySQL arm sets them to MySQL
/// values. Restoring them is NOT belt-and-braces: — the harness
/// most of this suite uses — resets only three of the five (IsSqlite,
/// IsUniqueConstraintViolation, RegisterUnicodeCaseFunctions) and leaves
/// LastInsertedRowId and CaseInsensitiveCollation alone. So without this, a MySQL arm
/// running before a SQLite test leaves last_insert_id() in place for a SQLite connection,
/// which is an order-dependent failure in a fixture that never mentions MySQL.
/// [NonParallelizable] serialises execution; it does not restore state.
///
[SetUp]
public async Task SetUp()
{
_originalLastInsertedRowId = TvContext.LastInsertedRowId;
_originalCollation = TvContext.CaseInsensitiveCollation;
_originalIsSqlite = TvContext.IsSqlite;
_originalUniqueViolation = TvContext.IsUniqueConstraintViolation;
if (provider is TestProvider.Sqlite)
{
TvContext.IsSqlite = true;
TvContext.LastInsertedRowId = "last_insert_rowid()";
TvContext.CaseInsensitiveCollation = "NOCASE";
TvContext.IsUniqueConstraintViolation = SqliteErrorClassifier.IsUniqueConstraintViolation;
_databasePath = Path.Combine(Path.GetTempPath(), $"etv823-{Guid.NewGuid():N}.sqlite3");
_options = new DbContextOptionsBuilder()
.UseSqlite(
$"Data Source={_databasePath};Foreign Keys=False",
o => o.MigrationsAssembly("ErsatzTV.Infrastructure.Sqlite"))
.Options;
await using TvContext sqlite = Create(_options);
await sqlite.Database.MigrateAsync();
return;
}
string? baseConnectionString = Environment.GetEnvironmentVariable(MySqlConnectionVariable);
if (string.IsNullOrWhiteSpace(baseConnectionString))
{
string message =
$"{MySqlConnectionVariable} is not set, so the MySql half of the #823 measurement cannot "
+ "run. Whether a NULL column materializes as CLR null is a provider question, so a "
+ "SQLite-only answer does not settle it.";
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, and a FRESH one per test: isolation by construction, per #491's finding
// that a shared name trades isolation for a wipe that has to succeed.
_mySqlConnectionString =
new MySqlConnectionStringBuilder(baseConnectionString) { Database = $"etv823_{Guid.NewGuid():N}" }
.ConnectionString;
TvContext.IsSqlite = false;
TvContext.LastInsertedRowId = "last_insert_id()";
TvContext.CaseInsensitiveCollation = "utf8mb4_general_ci";
TvContext.IsUniqueConstraintViolation = MySqlErrorClassifier.IsUniqueConstraintViolation;
ServerVersion serverVersion = ServerVersion.AutoDetect(_mySqlConnectionString);
_options = new DbContextOptionsBuilder()
.UseMySql(
_mySqlConnectionString,
serverVersion,
o => o.MigrationsAssembly("ErsatzTV.Infrastructure.MySql"))
.Options;
// NUnit does not run [TearDown] when [SetUp] throws, and by this point the database exists — the
// migration itself created it. Without this, a migration that fails part way strands a database
// and its connection pool on a SHARED server, once per attempt.
try
{
await using TvContext mysql = Create(_options);
await mysql.Database.MigrateAsync();
}
catch
{
await DropMySqlDatabase();
_mySqlConnectionString = null;
throw;
}
}
private async Task DropMySqlDatabase()
{
try
{
await using (TvContext context = Create(_options))
{
await context.Database.EnsureDeletedAsync();
}
await using var probe = new MySqlConnection(_mySqlConnectionString);
await MySqlConnection.ClearPoolAsync(probe);
}
catch (Exception ex)
{
// Never mask the original failure with a cleanup failure, and never fail a PASSING test in
// teardown because the server went away -- but say so, because a silent cleanup failure is how
// a leak becomes invisible.
await TestContext.Out.WriteLineAsync(
$"WARNING: could not drop the MySql test database {_mySqlConnectionString}: {ex.Message}");
}
}
[TearDown]
public async Task TearDown()
{
TvContext.LastInsertedRowId = _originalLastInsertedRowId;
TvContext.CaseInsensitiveCollation = _originalCollation;
TvContext.IsSqlite = _originalIsSqlite;
TvContext.IsUniqueConstraintViolation = _originalUniqueViolation;
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 DropMySqlDatabase();
_mySqlConnectionString = null;
}
}
///
/// A row whose three columns are NULL materializes as CLR null on every one of them — including
/// the two IntCollectionValueConverter columns, whose converter would have produced
/// Array.Empty<int>() had it been invoked.
///
[Test]
public async Task A_Null_Column_Materializes_As_Clr_Null_On_All_Six()
{
await using (TvContext context = Create(_options))
{
await DisableForeignKeys(context);
context.ProgramScheduleAlternates.Add(NewAlternate(playoutId: 1));
context.PlayoutTemplates.Add(NewTemplate(playoutId: 1));
await context.SaveChangesAsync();
// Out of band, so the row is byte-identical to a legacy one. This UPDATE is itself a code
// path and does NOT demonstrate the legacy route -- see the class docstring for which single
// (column, provider) case is genuinely code-path-free. What is being measured here is what EF
// MATERIALIZES from such a row, which is the same regardless of how the NULL got there.
await context.Database.ExecuteSqlRawAsync(
"UPDATE ProgramScheduleAlternate SET DaysOfWeek = NULL, DaysOfMonth = NULL, MonthsOfYear = NULL");
await context.Database.ExecuteSqlRawAsync(
"UPDATE PlayoutTemplate SET DaysOfWeek = NULL, DaysOfMonth = NULL, MonthsOfYear = NULL");
}
// ANTI-VACUITY: a read that found no row would leave every ShouldBeNull below trivially true,
// so both reads are Single and would throw on an empty table.
await using (TvContext context = Create(_options))
{
ProgramScheduleAlternate alternate =
await context.ProgramScheduleAlternates.AsNoTracking().SingleAsync();
alternate.DaysOfWeek.ShouldBeNull(
"EnumCollectionJsonValueConverter must not be invoked for a NULL column");
alternate.DaysOfMonth.ShouldBeNull(
"IntCollectionValueConverter's null-to-empty branch must not run — EF skips the "
+ "converter for a NULL column, so this is null rather than an empty array");
alternate.MonthsOfYear.ShouldBeNull();
PlayoutTemplate template = await context.PlayoutTemplates.AsNoTracking().SingleAsync();
template.DaysOfWeek.ShouldBeNull();
template.DaysOfMonth.ShouldBeNull();
template.MonthsOfYear.ShouldBeNull();
}
}
///
/// The write half. A caller that hands one of the six a null — which the two Replace* handlers do
/// verbatim from their command, and only the HTTP request records guard against — gets a stored
/// SQL NULL and no error at all.
///
[Test]
public async Task Assigning_Null_Persists_Sql_Null_Rather_Than_Throwing()
{
await using (TvContext context = Create(_options))
{
await DisableForeignKeys(context);
ProgramScheduleAlternate alternate = NewAlternate(playoutId: 2);
alternate.DaysOfWeek = null!;
alternate.DaysOfMonth = null!;
alternate.MonthsOfYear = null!;
context.ProgramScheduleAlternates.Add(alternate);
// Not Should.NotThrow: the point is that this is the SHIPPED behaviour of the write path, so
// the assertion is that the round-trip below finds NULL, not merely that nothing blew up.
await context.SaveChangesAsync();
}
await using (TvContext context = Create(_options))
{
ProgramScheduleAlternate reloaded =
await context.ProgramScheduleAlternates.AsNoTracking().SingleAsync();
reloaded.DaysOfWeek.ShouldBeNull();
reloaded.DaysOfMonth.ShouldBeNull();
reloaded.MonthsOfYear.ShouldBeNull();
}
// ...and it really is SQL NULL in the column, not an empty string the converter round-trips.
// ExecuteScalar hands back CLR null both for a NULL column and for NO SUCH ROW, so the row is
// counted first — otherwise a fixture that silently deleted its row would report the same thing.
await using (TvContext context = Create(_options))
{
(await ScalarAsync(context, "SELECT COUNT(*) FROM ProgramScheduleAlternate"))
.ShouldNotBeNull();
Convert.ToInt32(await ScalarAsync(context, "SELECT COUNT(*) FROM ProgramScheduleAlternate"))
.ShouldBe(1, "the row is gone, so a null read below would prove nothing");
(await ScalarAsync(context, "SELECT DaysOfWeek FROM ProgramScheduleAlternate")).ShouldBeNull();
(await ScalarAsync(context, "SELECT DaysOfMonth FROM ProgramScheduleAlternate")).ShouldBeNull();
(await ScalarAsync(context, "SELECT MonthsOfYear FROM ProgramScheduleAlternate")).ShouldBeNull();
}
}
private static ProgramScheduleAlternate NewAlternate(int playoutId) =>
new()
{
PlayoutId = playoutId,
ProgramScheduleId = 1,
Index = 0,
DaysOfWeek = [],
DaysOfMonth = [],
MonthsOfYear = [],
StartMonth = 1,
StartDay = 1,
EndMonth = 12,
EndDay = 31
};
private static PlayoutTemplate NewTemplate(int playoutId) =>
new()
{
PlayoutId = playoutId,
TemplateId = 1,
Index = 0,
DaysOfWeek = [],
DaysOfMonth = [],
MonthsOfYear = [],
StartMonth = 1,
StartDay = 1,
EndMonth = 12,
EndDay = 31
};
///
/// The rows here are deliberately partial graphs (a PlayoutId pointing at no Playout), so
/// foreign keys are off. SQLite takes it as a connection-string keyword; MySQL's
/// foreign_key_checks is a SESSION variable, so it is set on the context's own connection
/// and lives as long as that context does.
///
private async Task DisableForeignKeys(TvContext context)
{
if (provider is TestProvider.MySql)
{
await context.Database.OpenConnectionAsync();
await context.Database.ExecuteSqlRawAsync("SET SESSION foreign_key_checks = 0");
}
}
private static async Task