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.BattlePass; 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(); db.EnsureSeedDataAsync().GetAwaiter().GetResult(); // Reference data is no longer HasData-seeded; load the CSVs via the same importer // production uses so tests exercise the same code path. CardCosmeticRewards skipped — // FK to Cards would reject every row against the minimal 3-card test seed below. var dataDir = Path.Combine(AppContext.BaseDirectory, "Data"); new ReferenceDataImporter().ImportAllAsync(db, dataDir).GetAwaiter().GetResult(); // Seed a minimal card set so card-pool tests can resolve a non-empty pool without // requiring the full CardImporter tool or a cards.json file. The set is marked // IsInRotation so both standard-pack (by setId) and special-pack (rotation scan) // tests see real data. SeedMinimalCardSet(db); return host; } private static void SeedMinimalCardSet(SVSimDbContext db) { if (db.CardSets.Any()) return; // Already seeded (e.g. if CreateHost is called more than once) var set = new ShadowverseCardSetEntry { Id = 10001, Name = "TestSet", IsInRotation = true, IsBasic = false, Cards = [ new ShadowverseCardEntry { Id = 10001001L, Name = "TestCard1", Rarity = Rarity.Bronze }, new ShadowverseCardEntry { Id = 10001002L, Name = "TestCard2", Rarity = Rarity.Gold }, new ShadowverseCardEntry { Id = 10001003L, Name = "TestCard3", Rarity = Rarity.Legendary }, ] }; db.CardSets.Add(set); db.SaveChanges(); } 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 the per-domain seed importers against the test SQLite DB using the seed JSON /// copied into the test output dir (see SVSim.UnitTests.csproj Content Includes for /// Data/seeds and Data/test-fixtures/seeds). Idempotent — safe to call multiple times. /// 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 importers /// populated. Mirrors the wiring in . /// public async Task SeedGlobalsAsync() { string seedDir = Path.Combine(AppContext.BaseDirectory, "Data", "seeds"); using var scope = Services.CreateScope(); var ctx = scope.ServiceProvider.GetRequiredService(); // RotationConfigImporter must precede RotationFlagUpdater; CardListsImporter is // ordered after the GameConfig importers for tidiness (no FK dependency). await new RotationConfigImporter().ImportAsync(ctx, seedDir); await new MyRotationImporter().ImportAsync(ctx, seedDir); await new AvatarAbilityImporter().ImportAsync(ctx, seedDir); await new ArenaSeasonImporter().ImportAsync(ctx, seedDir); await new BattlePassImporter().ImportAsync(ctx, seedDir); // Reset the process-level level-curve cache so the next HTTP call reads freshly-seeded rows. BattlePassRepository.ResetLevelCurveCache(); await new BattlePassSeasonImporter().ImportAsync(ctx, seedDir); await new BattlePassRewardImporter().ImportAsync(ctx, seedDir); await new DailyLoginBonusImporter().ImportAsync(ctx, seedDir); await new PreReleaseInfoImporter().ImportAsync(ctx, seedDir); await new CardListsImporter().ImportAsync(ctx, seedDir); await new RotationFlagUpdater().UpdateAsync(ctx); await new PracticeOpponentImporter().ImportAsync(ctx, seedDir); await new PaymentItemImporter().ImportAsync(ctx, seedDir); var puzzleImporter = new PuzzleImporter(); await puzzleImporter.ImportGroupsAsync(ctx, seedDir); await puzzleImporter.ImportPuzzlesAsync(ctx, seedDir); await puzzleImporter.ImportMissionsAsync(ctx, seedDir); var mypage = new MyPageGlobalsImporter(); await mypage.ImportBannersAsync(ctx, seedDir); await mypage.ImportColosseumAsync(ctx, seedDir); await mypage.ImportSealedAsync(ctx, seedDir); await mypage.ImportMasterPointRankingPeriodAsync(ctx, seedDir); await mypage.ImportSpecialDeckFormatsAsync(ctx, seedDir); await new DefaultDeckImporter().ImportAsync(ctx, seedDir); await new PackImporter().ImportAsync(ctx, seedDir); } /// 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; }); } /// /// Seeds an OwnedCardEntry for the viewer. Uses an existing card from the minimal test set /// when matches one (10001001/10001002/10001003); otherwise the /// caller must have inserted the card row themselves. is written /// onto the card's CollectionInfo so destruct tests can compute expected vials. /// /// NOTE: This helper ALWAYS resets the viewer's RedEther to 0 (so destruct tests can assert /// literal post-state totals). Callers that need a non-zero balance should re-assign after seeding. /// public async Task SeedOwnedCardAsync( long viewerId, long cardId, int count, int dustReward = 50, int craftCost = 200, bool isProtected = false) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var card = await db.Cards.FirstOrDefaultAsync(c => c.Id == cardId); if (card is null) { card = new ShadowverseCardEntry { Id = cardId, Name = $"SeededCard{cardId}", Rarity = Rarity.Bronze, CollectionInfo = new CardCollectionInfo { CraftCost = craftCost, DustReward = dustReward }, }; db.Cards.Add(card); await db.SaveChangesAsync(); } else if (card.CollectionInfo is null || card.CollectionInfo.DustReward != dustReward || card.CollectionInfo.CraftCost != craftCost) { card.CollectionInfo = new CardCollectionInfo { CraftCost = craftCost, DustReward = dustReward }; await db.SaveChangesAsync(); } var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId); var owned = viewer.Cards.FirstOrDefault(c => c.Card.Id == cardId); if (owned is null) { viewer.Cards.Add(new OwnedCardEntry { Card = card, Count = count, IsProtected = isProtected }); } else { owned.Count = count; owned.IsProtected = isProtected; } viewer.Currency.RedEther = 0; // Reset RedEther so destruct tests can assert literal post-state totals await db.SaveChangesAsync(); } /// /// Puts copies of into the viewer's deck /// in the given format + slot. Tests use this to set up deck-strip scenarios for /card/destruct. /// The card must already exist (typically via SeedOwnedCardAsync, which inserts the card row). /// public async Task AddCardToDeckAsync(long viewerId, Format format, int deckNumber, long cardId, int count) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var viewer = await db.Viewers .Include(v => v.Decks).ThenInclude(d => d.Cards).ThenInclude(c => c.Card) .FirstAsync(v => v.Id == viewerId); var deck = viewer.Decks.First(d => d.Format == format && d.Number == deckNumber); var card = await db.Cards.FirstAsync(c => c.Id == cardId); var existing = deck.Cards.FirstOrDefault(c => c.Card.Id == cardId); if (existing is null) { deck.Cards.Add(new DeckCard { Card = card, Count = count }); } else { existing.Count = count; } await db.SaveChangesAsync(); } protected override void Dispose(bool disposing) { base.Dispose(disposing); if (disposing) { _connection.Dispose(); } } }