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 class SVSimTestFactory : WebApplicationFactory { private readonly SqliteConnection _connection; private long _nextSeededShortUdid = 400_000_001; private readonly bool _freeplayEnabled; private readonly bool _useRealAuthHandler; public SVSimTestFactory(bool freeplayEnabled = false, bool useRealAuthHandler = false) { _freeplayEnabled = freeplayEnabled; _useRealAuthHandler = useRealAuthHandler; // 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); if (!_useRealAuthHandler) { ReplaceAuthHandler(services); } else { // Real auth handler stays in place; bypass the live Steam SDK so synthetic // tickets validate without touching Steam. var steamServer = services.FirstOrDefault(d => d.ServiceType == typeof(SVSim.EmulatedEntrypoint.Services.ISteamServer)); if (steamServer is not null) services.Remove(steamServer); services.AddSingleton(); } }); } 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(); if (_freeplayEnabled) { using var seedScope = host.Services.CreateScope(); var seedDb = seedScope.ServiceProvider.GetRequiredService(); const string freeplayJson = "{\"Enabled\":true,\"CurrencyAmount\":99999,\"CardCopies\":3}"; var existing = seedDb.GameConfigs.FirstOrDefault(s => s.SectionName == "Freeplay"); if (existing is null) seedDb.GameConfigs.Add(new SVSim.Database.Models.GameConfigSection { SectionName = "Freeplay", ValueJson = freeplayJson }); else existing.ValueJson = freeplayJson; seedDb.SaveChanges(); } // 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(TextWriter.Null, TextWriter.Null) .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); SeedMinimalPackDrawTable(db); return host; } /// /// Seeds a minimal PackDrawConfig + slot rates + card weights for the test card-set's /// cards (10001001/10001002/10001003) under pack id 10001. Lets PackController.Open /// resolve a draw table without requiring tests to run the full PackDrawTableImporter. /// private static void SeedMinimalPackDrawTable(SVSimDbContext db) { if (db.PackDrawConfigs.Any()) return; const int packId = 10001; db.PackDrawConfigs.Add(new PackDrawConfigEntry { Id = packId, AnimationRatePct = 0 }); // Slot rates: uniform single-tier so any rng lands somewhere valid. db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.General, Tier = DrawTier.Bronze, RatePct = 100 }); db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, RatePct = 100 }); // Card weights for both slots. db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry { PackId = packId, Slot = DrawSlot.General, Tier = DrawTier.Bronze, CardId = 10001001, RatePct = 100 }); db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry { PackId = packId, Slot = DrawSlot.Eighth, Tier = DrawTier.Bronze, CardId = 10001001, RatePct = 100 }); db.SaveChanges(); } 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", int tutorialState = 100) { 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(); } // Third scope: write the requested TutorialState. The parameter defaults to 100 — // the post-tutorial baseline that ~30 existing tests rely on — so callers that don't // care about the tutorial step keep working unchanged. Pass tutorialState: 1 to seed // a fresh-signup viewer, or any other value to land mid-tutorial. RegisterViewer's // own default (set in BuildDefaultViewer) is irrelevant here because this override // always runs. using (var scope = Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var viewer = await db.Viewers.Include(v => v.MissionData).FirstAsync(v => v.Id == viewerId); viewer.MissionData.TutorialState = tutorialState; 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); 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 BotRosterImporter().ImportAsync(ctx, seedDir); await new PaymentItemImporter().ImportAsync(ctx, seedDir); await new ItemImporter().ImportAsync(ctx, seedDir); await new SleeveShopImporter().ImportAsync(ctx, seedDir); await new ItemPurchaseImporter().ImportAsync(ctx, seedDir); await new LeaderSkinShopImporter().ImportAsync(ctx, seedDir); await new SpotCardExchangeImporter().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 mypage.ImportHomeDialogsAsync(ctx, seedDir); await new TutorialPresentsImporter().ImportAsync(ctx, seedDir); await new DefaultDeckImporter().ImportAsync(ctx, seedDir); await new PackImporter().ImportAsync(ctx, seedDir); // PackDrawTableImporter is NOT called here — production draw tables reference real // Cygames card_ids not present in the test's minimal card master. Tests that // exercise /pack/open use SeedPackDrawTableAsync to install a stub draw table // pointing to their seeded test cards. } /// /// Installs a minimal PackDrawConfig + slot rates + per-card weights for , /// pointing the per-card weights at . All cards land in the Bronze tier /// at 100% rate; slot 1-7 and slot 8 both draw from the same pool. Use for tests that need /// /pack/open to succeed against a custom seeded card pool. /// public Task SeedPackDrawTableAsync(int packId, params long[] cardIds) => SeedPackDrawTableAsync(packId, DrawTier.Bronze, cardIds); /// /// Convenience for gacha-point tests: picks Legendary cards from /// (skipping foils) and seeds them as the draw table's Legendary tier for . /// public async Task SeedPackDrawTableFromSetAsync(int packId, int cardSetId) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var legendaryIds = await db.CardSets .Where(s => s.Id == cardSetId) .SelectMany(s => s.Cards) .Where(c => c.Rarity == SVSim.Database.Enums.Rarity.Legendary && !c.IsFoil) .Select(c => c.Id) .ToListAsync(); if (legendaryIds.Count > 0) { await SeedPackDrawTableAsync(packId, DrawTier.Legendary, legendaryIds.ToArray()); } } public async Task SeedPackDrawTableAsync(int packId, DrawTier tier, params long[] cardIds) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); if (await db.PackDrawConfigs.AnyAsync(c => c.Id == packId)) return; db.PackDrawConfigs.Add(new PackDrawConfigEntry { Id = packId, AnimationRatePct = 0 }); db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.General, Tier = tier, RatePct = 100 }); db.PackDrawSlotRates.Add(new PackDrawSlotRateEntry { PackId = packId, Slot = DrawSlot.Eighth, Tier = tier, RatePct = 100 }); foreach (var cid in cardIds) { db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry { PackId = packId, Slot = DrawSlot.General, Tier = tier, CardId = cid, RatePct = 100.0 / cardIds.Length }); db.PackDrawCardWeights.Add(new PackDrawCardWeightEntry { PackId = packId, Slot = DrawSlot.Eighth, Tier = tier, CardId = cid, RatePct = 100.0 / cardIds.Length }); } await db.SaveChangesAsync(); } /// /// Enables Freeplay mode by writing the GameConfigs DB row (tier-1 of GameConfigService). /// Call before issuing the request under test. Idempotent. /// public async Task EnableFreeplayAsync(ulong currencyAmount = 99999, int cardCopies = 3) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var json = System.Text.Json.JsonSerializer.Serialize(new { Enabled = true, CurrencyAmount = currencyAmount, CardCopies = cardCopies, }); var existing = await db.GameConfigs.FirstOrDefaultAsync(s => s.SectionName == "Freeplay"); if (existing is null) db.GameConfigs.Add(new GameConfigSection { SectionName = "Freeplay", ValueJson = json }); else existing.ValueJson = json; await db.SaveChangesAsync(); } /// 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", int? classId = null) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var repo = scope.ServiceProvider.GetRequiredService(); var cls = classId is null ? await db.Classes.FirstAsync() : await db.Classes.FindAsync(classId.Value) ?? throw new InvalidOperationException($"SeedDeckAsync: class {classId} not found"); 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(); } /// /// Seeds a bare (no viewer ownership) and returns its id. /// Used by InventoryGrantCardTests to get a valid card id without also seeding owned state. /// Ids start at 800_000_000 (non-foil) or 800_000_001 (foil) and increment by 2 per call to /// keep foil twins aligned. /// public async Task SeedCardAsync(bool isFoil = false) { using var scope = Services.CreateScope(); var ctx = scope.ServiceProvider.GetRequiredService(); long id = isFoil ? 800_000_001L : 800_000_000L; while (await ctx.Cards.AnyAsync(c => c.Id == id)) id += 2; ctx.Cards.Add(new ShadowverseCardEntry { Id = id, IsFoil = isFoil, Name = $"SeedCard{id}" }); await ctx.SaveChangesAsync(); return id; } /// /// Sets the viewer's RedEther balance to . Call this AFTER /// , which resets RedEther to 0. Create tests use this /// to give the viewer enough vials to craft. /// public async Task SetRedEtherAsync(long viewerId, ulong amount) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId); viewer.Currency.RedEther = amount; 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(); } /// /// Reads the viewer's current TutorialState from the DB. /// Tests use this to verify that /tutorial/update persisted the step. /// public async Task GetViewerTutorialStateAsync(long viewerId) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var viewer = await db.Viewers.Include(v => v.MissionData).FirstAsync(v => v.Id == viewerId); return viewer.MissionData.TutorialState; } /// /// Reads the viewer's current currency balances from the DB. Used by gift_receive tests /// to assert delta grants after claiming tutorial presents. /// public async Task<(ulong Crystals, ulong Rupees, ulong RedEther)> GetViewerCurrencyAsync(long viewerId) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId); return (viewer.Currency.Crystals, viewer.Currency.Rupees, viewer.Currency.RedEther); } /// /// Seeds an OwnedItemEntry for the viewer. Inserts the ItemEntry master row if missing /// (Type defaults to 2 = card-pack ticket since both tutorial gift items 80001 and 90001 /// are tickets). Tests use this to set up the ticket inventory that /tutorial/pack_open /// is supposed to consume. /// public async Task SeedOwnedItemAsync(long viewerId, int itemId, int count, string itemName = "TestItem", int itemType = 2) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var item = await db.Items.FindAsync(itemId); if (item is null) { item = new ItemEntry { Id = itemId, Name = itemName, Type = itemType }; db.Items.Add(item); await db.SaveChangesAsync(); } var viewer = await db.Viewers .Include(v => v.Items).ThenInclude(i => i.Item) .FirstAsync(v => v.Id == viewerId); var existing = viewer.Items.FirstOrDefault(i => i.Item.Id == itemId); if (existing is null) { viewer.Items.Add(new OwnedItemEntry { Item = item, Count = count, Viewer = viewer }); } else { existing.Count = count; } await db.SaveChangesAsync(); } /// /// Seed the tutorial ViewerPresent rows for a test viewer by projecting from the /// TutorialPresentEntries catalogue. RegisterViewer (admin/social path) does NOT auto-seed; /// only the production /tool/signup -> RegisterAnonymousViewer flow does. Tests opt in by /// calling this helper after SeedViewerAsync when they want a tutorial-shaped inbox state. /// If the catalogue is empty (most tests skip SeedGlobalsAsync), this method imports /// tutorial-presents.json on demand so the helper works regardless of test setup ordering. /// public async Task SeedTutorialPresentsAsync(long viewerId) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); if (!await db.TutorialPresentEntries.AnyAsync()) { string seedDir = Path.Combine(AppContext.BaseDirectory, "Data", "seeds"); await new TutorialPresentsImporter().ImportAsync(db, seedDir); } var catalogue = await db.TutorialPresentEntries .AsNoTracking() .OrderBy(p => p.PresentId) .ToListAsync(); var createdAt = DateTime.UtcNow; foreach (var spec in catalogue) { db.ViewerPresents.Add(new ViewerPresent { ViewerId = viewerId, PresentId = spec.PresentId, Status = PresentStatus.Unclaimed, RewardType = spec.RewardType, RewardDetailId = spec.RewardDetailId, RewardCount = spec.RewardCount, ItemType = spec.ItemType, Message = spec.Message, CreatedAt = createdAt, Source = "tutorial", }); } await db.SaveChangesAsync(); } /// /// Reads the viewer's current owned count for . Returns 0 if no /// row exists. Tests use this to assert ticket consumption after /tutorial/pack_open. /// public async Task GetOwnedItemCountAsync(long viewerId, int itemId) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var viewer = await db.Viewers .Include(v => v.Items).ThenInclude(i => i.Item) .FirstAsync(v => v.Id == viewerId); return viewer.Items.FirstOrDefault(i => i.Item.Id == itemId)?.Count ?? 0; } protected override void Dispose(bool disposing) { base.Dispose(disposing); if (disposing) { _connection.Dispose(); } } }