Files
SVSimServer/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs

370 lines
16 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;
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<SVSimDbContext>();
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<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")
{
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();
}
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);
// 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);
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);
}
/// <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>
/// 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();
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing)
{
_connection.Dispose();
}
}
}