using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using SVSim.Bootstrap.Importers; using SVSim.Database; using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Repositories.Deck; using SVSim.Database.Repositories.Viewer; using SVSim.EmulatedEntrypoint; using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication; namespace SVSim.UnitTests.Infrastructure; /// /// Test host for the EmulatedEntrypoint app. Each instance opens a private SQLite in-memory /// database, swaps the production DbContext + Steam auth handler for SQLite-friendly + /// header-driven test versions, and exposes a helper for tests /// to create realistic viewer rows. /// internal sealed class SVSimTestFactory : WebApplicationFactory { private readonly SqliteConnection _connection; private long _nextSeededShortUdid = 400_000_001; public SVSimTestFactory() { // SQLite :memory: lives only as long as a connection is open — keep ours open for the // factory's lifetime so the DbContext can reattach to the same DB across scopes. _connection = new SqliteConnection("DataSource=:memory:"); _connection.Open(); } protected override void ConfigureWebHost(IWebHostBuilder builder) { // Tell Program.cs we're in tests so it skips UpdateDatabase() — the Postgres-targeted // migrations would fail against SQLite. We call EnsureCreated below instead. builder.UseEnvironment("Testing"); builder.ConfigureTestServices(services => { ReplaceDbContext(services); ReplaceAuthHandler(services); }); } protected override IHost CreateHost(IHostBuilder builder) { var host = base.CreateHost(builder); using var scope = host.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); db.Database.EnsureCreated(); return host; } private void ReplaceDbContext(IServiceCollection services) { // Production registered DbContextOptions with the Npgsql provider; tear // out every related descriptor so AddDbContext below installs a clean SQLite-backed one. foreach (var descriptor in services .Where(d => d.ServiceType == typeof(DbContextOptions) || d.ServiceType == typeof(DbContextOptions) || d.ServiceType == typeof(SVSimDbContext)) .ToList()) { services.Remove(descriptor); } services.AddDbContext(opt => { opt.UseSqlite(_connection); opt.ReplaceService(); }); } private static void ReplaceAuthHandler(IServiceCollection services) { // Production Program.cs registered SteamSessionAuthenticationHandler under the // "SteamAuthentication" scheme. Drop that scheme from BOTH the SchemeMap and the // parallel Schemes list (AddScheme writes to both — and the provider iterates the // list, not the map, so leaving the old builder behind throws "Scheme already exists" // when it re-adds during provider construction). services.AddTransient(); services.PostConfigure(opt => { opt.SchemeMap.Remove(SteamAuthenticationConstants.SchemeName, out _); var schemesList = (IList)opt.Schemes; foreach (var stale in schemesList .Where(s => s.Name == SteamAuthenticationConstants.SchemeName) .ToList()) { schemesList.Remove(stale); } opt.AddScheme(SteamAuthenticationConstants.SchemeName, b => { b.HandlerType = typeof(TestAuthHandler); }); }); } /// /// Creates a fully-formed viewer via the real /// path (so the test exercises the same nav-graph wiring real users hit). The viewer's /// ShortUdid is overwritten to a unique non-zero value because the Postgres sequence /// is disabled on SQLite — without this every test viewer collides on 0. /// public async Task SeedViewerAsync( ulong steamId = 76_561_198_000_000_001UL, string displayName = "Test Viewer") { long viewerId; long shortUdid; using (var scope = Services.CreateScope()) { var repo = scope.ServiceProvider.GetRequiredService(); var v = await repo.RegisterViewer(displayName, SocialAccountType.Steam, steamId); viewerId = v.Id; shortUdid = Interlocked.Increment(ref _nextSeededShortUdid); } // Second scope: assign a real ShortUdid so claim-based lookups in tests have something // to find (and so per-viewer ShortUdids don't collide across SeedViewerAsync calls). using (var scope = Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); v.ShortUdid = shortUdid; await db.SaveChangesAsync(); } return viewerId; } /// /// Runs against the test SQLite DB using the prod captures /// copied into the test output dir (see SVSim.UnitTests.csproj Content Include for /// Data/prod-captures). Idempotent — safe to call multiple times per factory. Tests that /// depend on prod-shaped global content (spot_cards, avatar abilities, etc.) call this once /// during setup; the rest of the test runs against whatever the importer populated. /// public async Task SeedGlobalsAsync(string? capturesDir = null) { capturesDir ??= Path.Combine(AppContext.BaseDirectory, "Data", "prod-captures"); using var scope = Services.CreateScope(); var ctx = scope.ServiceProvider.GetRequiredService(); await new GlobalsImporter().ImportAllAsync(ctx, capturesDir); } /// Convenience: bake the X-Test-Viewer-Id header into a fresh client. public HttpClient CreateAuthenticatedClient(long viewerId) { var client = CreateClient(); client.DefaultRequestHeaders.Add(TestAuthHandler.ViewerIdHeader, viewerId.ToString()); return client; } /// /// Inserts a deck for the viewer via the real /// path. Picks the first seeded class/sleeve/leader-skin from the master tables; tests /// that need specific ids should hit the DB directly. /// public async Task SeedDeckAsync(long viewerId, Format format, int number, string name = "Test Deck") { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var repo = scope.ServiceProvider.GetRequiredService(); var cls = await db.Classes.FirstAsync(); var sleeve = await db.Sleeves.FirstAsync(); var skin = await db.LeaderSkins.FirstAsync(); await repo.UpsertDeck(viewerId, format, number, d => { d.Name = name; d.Class = cls; d.Sleeve = sleeve; d.LeaderSkin = skin; }); } protected override void Dispose(bool disposing) { base.Dispose(disposing); if (disposing) { _connection.Dispose(); } } }