370 lines
16 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|