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>
58 lines
2.5 KiB
C#
58 lines
2.5 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using SVSim.Database.Models;
|
|
|
|
namespace SVSim.Database.Repositories.BattlePass;
|
|
|
|
public sealed class BattlePassRepository : IBattlePassRepository
|
|
{
|
|
private readonly SVSimDbContext _db;
|
|
private readonly IMemoryCache _cache;
|
|
|
|
// Per-host cache for the immutable level curve, scoped via the DI-registered IMemoryCache.
|
|
// In production "host == process"; in tests each WebApplicationFactory builds its own
|
|
// service provider so the cache is naturally isolated per fixture — avoids the pre-refactor
|
|
// race where a process-static cache populated from one test's DbContext served stale data
|
|
// to a parallel test reading from a different DB.
|
|
private const string LevelCurveCacheKey = "battlepass:level-curve";
|
|
|
|
public BattlePassRepository(SVSimDbContext db, IMemoryCache cache)
|
|
{
|
|
_db = db;
|
|
_cache = cache;
|
|
}
|
|
|
|
public async Task<BattlePassSeasonEntry?> GetActiveSeasonAsync(DateTimeOffset when, CancellationToken ct)
|
|
{
|
|
// Use UtcDateTime for the LINQ comparison so the query translates on both Postgres and
|
|
// SQLite. DateTimeOffset arithmetic in LINQ isn't supported by the SQLite provider;
|
|
// DateTime (UTC) is stored and compared as ISO-8601 text which SQLite handles fine.
|
|
var utcNow = when.UtcDateTime;
|
|
var candidates = await _db.BattlePassSeasons
|
|
.AsNoTracking()
|
|
.ToListAsync(ct);
|
|
return candidates
|
|
.Where(s => s.StartDate.UtcDateTime <= utcNow && s.EndDate.UtcDateTime > utcNow)
|
|
.OrderByDescending(s => s.StartDate)
|
|
.FirstOrDefault();
|
|
}
|
|
|
|
public Task<BattlePassSeasonEntry?> GetSeasonAsync(int seasonId, CancellationToken ct) =>
|
|
_db.BattlePassSeasons.AsNoTracking().FirstOrDefaultAsync(s => s.Id == seasonId, ct);
|
|
|
|
public async Task<List<BattlePassRewardEntry>> GetSeasonRewardsAsync(int seasonId, CancellationToken ct) =>
|
|
await _db.BattlePassRewards.AsNoTracking()
|
|
.Where(r => r.SeasonId == seasonId)
|
|
.OrderBy(r => r.Track).ThenBy(r => r.Level)
|
|
.ToListAsync(ct);
|
|
|
|
public async Task<IReadOnlyList<BattlePassLevelEntry>> GetLevelCurveAsync(CancellationToken ct)
|
|
{
|
|
var cached = await _cache.GetOrCreateAsync(LevelCurveCacheKey, async _ =>
|
|
(IReadOnlyList<BattlePassLevelEntry>)await _db.BattlePassLevels.AsNoTracking()
|
|
.OrderBy(e => e.Level)
|
|
.ToListAsync(ct));
|
|
return cached!;
|
|
}
|
|
}
|