Files
SVSimServer/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs
gamer147 b1397e3a3e refactor(repositories): move static caches into IMemoryCache, enable within-fixture parallelism
BattlePassRepository._curveCache and MissionCatalogRepository._maxLevelCache
were private-static fields populated lazily on first read from whatever
DbContext happened to be in scope. In production "one DbContext lineage
per process" makes that fine. Under parallel test execution each
SVSimTestFactory owns its own SQLite :memory: DB, so the first reader's
DB (often empty, in tests that don't seed BP) poisoned the cache for
concurrent readers from a seeded DB — assertions like "BP level info
must be present after seeding" failed because the process-static cache
returned an empty list populated by the other test's empty DB.

The first patch attempted a `BypassCacheForTests` static flag, which is
exactly the kind of test-only seam that rots the production code: future
caches get the same flag, repos accumulate hidden knobs, and the
underlying invariant ("a cache populated from arbitrary scope serves
arbitrary scope") goes unaddressed.

Instead, move both caches into the DI-registered IMemoryCache.
AddMemoryCache() registers it as singleton-per-service-provider:
production has one provider → one IMemoryCache → identical caching
semantics to before. Each WebApplicationFactory builds its own
provider → its own IMemoryCache → cache is naturally scoped per fixture,
no cross-test bleed possible.

The ResetLevelCurveCache() method and its three call sites
(SVSimTestFactory.SeedGlobalsAsync, BattlePassServiceTests,
LoadControllerTests) are deleted — a fresh factory owns a fresh empty
cache, no manual invalidation needed.

With this and the previous StoryService fixture-instance fix in place,
ParallelScope.All works: 776/776 in 57s wall clock (down from 59s on
Fixtures, 2m13s pre-parallelism).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 00:41:09 -04:00

583 lines
26 KiB
C#

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;
/// <summary>
/// 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 <see cref="SeedViewerAsync"/> helper for tests
/// to create realistic viewer rows.
/// </summary>
internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
{
private readonly SqliteConnection _connection;
private long _nextSeededShortUdid = 400_000_001;
private readonly bool _freeplayEnabled;
public SVSimTestFactory(bool freeplayEnabled = false)
{
_freeplayEnabled = freeplayEnabled;
// 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<SVSimDbContext>();
db.Database.EnsureCreated();
db.EnsureSeedDataAsync().GetAwaiter().GetResult();
if (_freeplayEnabled)
{
using var seedScope = host.Services.CreateScope();
var seedDb = seedScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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;
}
/// <summary>
/// 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.
/// </summary>
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<SVSimDbContext> 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<SVSimDbContext>)
|| d.ServiceType == typeof(DbContextOptions)
|| d.ServiceType == typeof(SVSimDbContext))
.ToList())
{
services.Remove(descriptor);
}
services.AddDbContext<SVSimDbContext>(opt =>
{
opt.UseSqlite(_connection);
opt.ReplaceService<Microsoft.EntityFrameworkCore.Infrastructure.IModelCustomizer, SqliteFriendlyModelCustomizer>();
});
}
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<TestAuthHandler>();
services.PostConfigure<AuthenticationOptions>(opt =>
{
opt.SchemeMap.Remove(SteamAuthenticationConstants.SchemeName, out _);
var schemesList = (IList<AuthenticationSchemeBuilder>)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);
});
});
}
/// <summary>
/// Creates a fully-formed viewer via the real <see cref="IViewerRepository.RegisterViewer"/>
/// path (so the test exercises the same nav-graph wiring real users hit). The viewer's
/// <c>ShortUdid</c> is overwritten to a unique non-zero value because the Postgres sequence
/// is disabled on SQLite — without this every test viewer collides on 0.
/// </summary>
public async Task<long> 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<IViewerRepository>();
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<SVSimDbContext>();
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<SVSimDbContext>();
var viewer = await db.Viewers.Include(v => v.MissionData).FirstAsync(v => v.Id == viewerId);
viewer.MissionData.TutorialState = tutorialState;
await db.SaveChangesAsync();
}
return viewerId;
}
/// <summary>
/// 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 <see cref="SVSim.Bootstrap.Program"/>.
/// </summary>
public async Task SeedGlobalsAsync()
{
string seedDir = Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
using var scope = Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
// 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 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 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.
}
/// <summary>
/// Installs a minimal PackDrawConfig + slot rates + per-card weights for <paramref name="packId"/>,
/// pointing the per-card weights at <paramref name="cardIds"/>. 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.
/// </summary>
public Task SeedPackDrawTableAsync(int packId, params long[] cardIds)
=> SeedPackDrawTableAsync(packId, DrawTier.Bronze, cardIds);
/// <summary>
/// Convenience for gacha-point tests: picks Legendary cards from <paramref name="cardSetId"/>
/// (skipping foils) and seeds them as the draw table's Legendary tier for <paramref name="packId"/>.
/// </summary>
public async Task SeedPackDrawTableFromSetAsync(int packId, int cardSetId)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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<SVSimDbContext>();
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();
}
/// <summary>
/// Enables Freeplay mode by writing the GameConfigs DB row (tier-1 of GameConfigService).
/// Call before issuing the request under test. Idempotent.
/// </summary>
public async Task EnableFreeplayAsync(ulong currencyAmount = 99999, int cardCopies = 3)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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();
}
/// <summary>Convenience: bake the X-Test-Viewer-Id header into a fresh client.</summary>
public HttpClient CreateAuthenticatedClient(long viewerId)
{
var client = CreateClient();
client.DefaultRequestHeaders.Add(TestAuthHandler.ViewerIdHeader, viewerId.ToString());
return client;
}
/// <summary>
/// Inserts a deck for the viewer via the real <see cref="IDeckRepository.UpsertDeck"/>
/// path. Picks the first seeded class/sleeve/leader-skin from the master tables; tests
/// that need specific ids should hit the DB directly.
/// </summary>
public async Task SeedDeckAsync(long viewerId, Format format, int number, string name = "Test Deck")
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var repo = scope.ServiceProvider.GetRequiredService<IDeckRepository>();
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;
});
}
/// <summary>
/// Seeds an OwnedCardEntry for the viewer. Uses an existing card from the minimal test set
/// when <paramref name="cardId"/> matches one (10001001/10001002/10001003); otherwise the
/// caller must have inserted the card row themselves. <paramref name="dustReward"/> 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.
/// </summary>
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<SVSimDbContext>();
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();
}
/// <summary>
/// Seeds a bare <see cref="ShadowverseCardEntry"/> (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.
/// </summary>
public async Task<long> SeedCardAsync(bool isFoil = false)
{
using var scope = Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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;
}
/// <summary>
/// Sets the viewer's RedEther balance to <paramref name="amount"/>. Call this AFTER
/// <see cref="SeedOwnedCardAsync"/>, which resets RedEther to 0. Create tests use this
/// to give the viewer enough vials to craft.
/// </summary>
public async Task SetRedEtherAsync(long viewerId, ulong amount)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId);
viewer.Currency.RedEther = amount;
await db.SaveChangesAsync();
}
/// <summary>
/// Puts <paramref name="count"/> copies of <paramref name="cardId"/> 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).
/// </summary>
public async Task AddCardToDeckAsync(long viewerId, Format format, int deckNumber, long cardId, int count)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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();
}
/// <summary>
/// Reads the viewer's current <c>TutorialState</c> from the DB.
/// Tests use this to verify that <c>/tutorial/update</c> persisted the step.
/// </summary>
public async Task<int> GetViewerTutorialStateAsync(long viewerId)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var viewer = await db.Viewers.Include(v => v.MissionData).FirstAsync(v => v.Id == viewerId);
return viewer.MissionData.TutorialState;
}
/// <summary>
/// Reads the viewer's current currency balances from the DB. Used by gift_receive tests
/// to assert delta grants after claiming tutorial presents.
/// </summary>
public async Task<(ulong Crystals, ulong Rupees, ulong RedEther)> GetViewerCurrencyAsync(long viewerId)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId);
return (viewer.Currency.Crystals, viewer.Currency.Rupees, viewer.Currency.RedEther);
}
/// <summary>
/// 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.
/// </summary>
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<SVSimDbContext>();
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();
}
/// <summary>
/// Reads the viewer's current owned count for <paramref name="itemId"/>. Returns 0 if no
/// row exists. Tests use this to assert ticket consumption after /tutorial/pack_open.
/// </summary>
public async Task<int> GetOwnedItemCountAsync(long viewerId, int itemId)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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();
}
}
}