Forgot unversioned xd
This commit is contained in:
182
SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs
Normal file
182
SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs
Normal file
@@ -0,0 +1,182 @@
|
||||
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.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
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();
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
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>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;
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
if (disposing)
|
||||
{
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.ValueGeneration;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.UnitTests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the default <see cref="ModelCustomizer"/> in tests. After the normal
|
||||
/// <c>OnModelCreating</c> runs, strips the Postgres sequence the production model declares
|
||||
/// for <c>Viewer.ShortUdid</c> so EnsureCreated can build the schema against SQLite (which
|
||||
/// has no sequence support).
|
||||
/// </summary>
|
||||
internal class SqliteFriendlyModelCustomizer : ModelCustomizer
|
||||
{
|
||||
public SqliteFriendlyModelCustomizer(ModelCustomizerDependencies dependencies) : base(dependencies)
|
||||
{
|
||||
}
|
||||
|
||||
public override void Customize(ModelBuilder modelBuilder, DbContext context)
|
||||
{
|
||||
base.Customize(modelBuilder, context);
|
||||
|
||||
modelBuilder.Model.RemoveSequence("ShortUdidSequence");
|
||||
|
||||
var shortUdidProperty = modelBuilder.Entity<Viewer>().Property(v => v.ShortUdid).Metadata;
|
||||
shortUdidProperty.RemoveAnnotation("Relational:DefaultValueSql");
|
||||
shortUdidProperty.ValueGenerated = ValueGenerated.Never;
|
||||
|
||||
AssignClientSideKeyGenerators(modelBuilder.Model);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owned-collection shadow PKs are <c>ValueGenerated.OnAdd</c> with the production model
|
||||
/// expecting the database to auto-fill (Postgres IDENTITY). On SQLite a composite-PK column
|
||||
/// is not a ROWID alias, so the DB can't auto-fill it and we get NOT NULL violations. Walk
|
||||
/// every owned entity and swap any auto-add primary-key property to use an in-process
|
||||
/// counter instead.
|
||||
/// </summary>
|
||||
private static void AssignClientSideKeyGenerators(IMutableModel model)
|
||||
{
|
||||
foreach (var entityType in model.GetEntityTypes())
|
||||
{
|
||||
if (!entityType.IsOwned()) continue;
|
||||
|
||||
foreach (var key in entityType.GetKeys())
|
||||
{
|
||||
foreach (var property in key.Properties)
|
||||
{
|
||||
if (property.ValueGenerated != ValueGenerated.OnAdd) continue;
|
||||
if (property.ClrType != typeof(int) && property.ClrType != typeof(long)) continue;
|
||||
|
||||
property.SetValueGeneratorFactory((_, _) =>
|
||||
property.ClrType == typeof(int)
|
||||
? (ValueGenerator)new MonotonicIntValueGenerator()
|
||||
: new MonotonicLongValueGenerator());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class MonotonicIntValueGenerator : ValueGenerator<int>
|
||||
{
|
||||
private static int _current;
|
||||
public override bool GeneratesTemporaryValues => false;
|
||||
public override int Next(EntityEntry entry) => Interlocked.Increment(ref _current);
|
||||
}
|
||||
|
||||
internal sealed class MonotonicLongValueGenerator : ValueGenerator<long>
|
||||
{
|
||||
private static long _current;
|
||||
public override bool GeneratesTemporaryValues => false;
|
||||
public override long Next(EntityEntry entry) => Interlocked.Increment(ref _current);
|
||||
}
|
||||
75
SVSim.UnitTests/Infrastructure/TestAuthHandler.cs
Normal file
75
SVSim.UnitTests/Infrastructure/TestAuthHandler.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.EmulatedEntrypoint.Constants;
|
||||
using SVSim.EmulatedEntrypoint.Extensions;
|
||||
using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication;
|
||||
|
||||
namespace SVSim.UnitTests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Replaces <see cref="SteamSessionAuthenticationHandler"/> in tests. Reads the viewer id from
|
||||
/// the <c>X-Test-Viewer-Id</c> header, looks the viewer up, and builds the same claim set the
|
||||
/// real handler would. Registered under the same scheme name so controller <c>[Authorize]</c>
|
||||
/// attributes resolve without modification.
|
||||
/// </summary>
|
||||
internal class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string ViewerIdHeader = "X-Test-Viewer-Id";
|
||||
|
||||
private readonly SVSimDbContext _dbContext;
|
||||
|
||||
public TestAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
SVSimDbContext dbContext)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue(ViewerIdHeader, out var raw))
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
if (!long.TryParse(raw.ToString(), out long viewerId))
|
||||
{
|
||||
return AuthenticateResult.Fail($"{ViewerIdHeader} is not a valid long.");
|
||||
}
|
||||
|
||||
Viewer? viewer = await _dbContext.Viewers
|
||||
.AsNoTracking()
|
||||
.Include(v => v.SocialAccountConnections)
|
||||
.FirstOrDefaultAsync(v => v.Id == viewerId);
|
||||
|
||||
if (viewer is null)
|
||||
{
|
||||
return AuthenticateResult.Fail($"No viewer with id {viewerId} — test forgot to seed.");
|
||||
}
|
||||
|
||||
Context.SetViewer(viewer);
|
||||
|
||||
var identity = new ClaimsIdentity(SteamAuthenticationConstants.SchemeName);
|
||||
identity.AddClaim(new Claim(ClaimTypes.Name, viewer.DisplayName));
|
||||
identity.AddClaim(new Claim(ShadowverseClaimTypes.ShortUdidClaim, viewer.ShortUdid.ToString()));
|
||||
identity.AddClaim(new Claim(ShadowverseClaimTypes.ViewerIdClaim, viewer.Id.ToString()));
|
||||
|
||||
var steamConnection = viewer.SocialAccountConnections.FirstOrDefault();
|
||||
if (steamConnection is not null)
|
||||
{
|
||||
identity.AddClaim(new Claim(SteamAuthenticationConstants.SteamIdClaim, steamConnection.AccountId.ToString()));
|
||||
}
|
||||
|
||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SteamAuthenticationConstants.SchemeName);
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user