using ErsatzTV.Infrastructure; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Sqlite.Data; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Logging.Abstractions; namespace ErsatzTV.Tests.Support; /// /// A multi-connection in-memory SQLite harness (shared cache) so several /// instances on DISTINCT connections hit the same database and real unique-constraint enforcement /// applies across connections. Unlike (a single shared connection), /// this can reproduce a genuine cross-connection write race — needed to exercise the #308 composite-PK /// collision through the real handler. A keep-alive connection holds the shared in-memory DB open for /// the harness lifetime. /// public sealed class SharedCacheTvContext : IAsyncDisposable { private readonly string _connectionString; private readonly SqliteConnection _keepAlive; private SharedCacheTvContext(string connectionString, SqliteConnection keepAlive) { _connectionString = connectionString; _keepAlive = keepAlive; } public static async Task CreateAsync(string name) { TvContext.IsSqlite = true; TvContext.IsUniqueConstraintViolation = SqliteErrorClassifier.IsUniqueConstraintViolation; // Foreign keys OFF (connection-string keyword, inherited by every connection) so partial object // graphs can be seeded without satisfying every FK — matches InMemoryTvContext. The composite-PK / // UNIQUE constraint under test is enforced regardless of this pragma. var connectionString = $"Data Source={name};Mode=Memory;Cache=Shared;Foreign Keys=False"; var keepAlive = new SqliteConnection(connectionString); await keepAlive.OpenAsync(); await using (TvContext context = Create(BuildOptions(connectionString, null))) { await context.Database.EnsureCreatedAsync(); } return new SharedCacheTvContext(connectionString, keepAlive); } /// A factory whose contexts each open their own connection to the shared DB. public IDbContextFactory Factory(IInterceptor? interceptor = null) => new PerConnectionFactory(_connectionString, interceptor); public TvContext CreateContext() => Create(BuildOptions(_connectionString, null)); /// A freshly opened raw connection to the shared DB (used to stage a concurrent writer). public SqliteConnection OpenConnection() { var connection = new SqliteConnection(_connectionString); connection.Open(); return connection; } public async ValueTask DisposeAsync() => await _keepAlive.DisposeAsync(); private static DbContextOptions BuildOptions(string connectionString, IInterceptor? interceptor) { DbContextOptionsBuilder builder = new DbContextOptionsBuilder() .UseSqlite(connectionString); if (interceptor is not null) { builder.AddInterceptors(interceptor); } return builder.Options; } private static TvContext Create(DbContextOptions options) => new( options, NullLoggerFactory.Instance, new SlowQueryInterceptor(NullLogger.Instance)); private sealed class PerConnectionFactory(string connectionString, IInterceptor? interceptor) : IDbContextFactory { public TvContext CreateDbContext() => Create(BuildOptions(connectionString, interceptor)); } }