Files
SVSimServer/SVSim.UnitTests/Services/BattlePassServiceTests.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

128 lines
5.5 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.BattlePass;
using SVSim.EmulatedEntrypoint.Services;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Services;
public class BattlePassServiceTests
{
// Reward Id formula matches BattlePassRewardImporter.MakeId — keep in sync.
private static long MakeRewardId(int seasonId, BattlePassTrack track, int level)
=> seasonId * 10_000L + (long)track * 1_000 + level;
private static async Task<long> SeedViewerAndSeason23(SVSimTestFactory f, bool isPremium = false)
{
long viewerId = await f.SeedViewerAsync();
using var scope = f.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
// Zero out rupees so post-state totals in reward assertions equal the delta amounts.
var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId);
viewer.Currency.Rupees = 0;
await db.SaveChangesAsync();
if (await db.BattlePassLevels.CountAsync() == 0)
{
for (int i = 1; i <= 100; i++)
db.BattlePassLevels.Add(new BattlePassLevelEntry { Level = i, RequiredPoint = (i - 1) * 500 });
}
db.BattlePassSeasons.Add(new BattlePassSeasonEntry
{
Id = 23, Name = "Season 23", MaxLevel = 100,
StartDate = DateTimeOffset.UtcNow.AddDays(-30),
EndDate = DateTimeOffset.UtcNow.AddDays(30),
CanPurchase = true, PriceCrystal = 980, Description = "",
});
// Normal level-2 = rupy 50; premium level-2 = rupy 20.
db.BattlePassRewards.Add(new BattlePassRewardEntry
{
Id = MakeRewardId(23, BattlePassTrack.Normal, 2),
SeasonId = 23, Track = BattlePassTrack.Normal, Level = 2, RewardType = 9,
RewardDetailId = 0, RewardNumber = 50, IsAppealExclusion = false,
});
db.BattlePassRewards.Add(new BattlePassRewardEntry
{
Id = MakeRewardId(23, BattlePassTrack.Premium, 2),
SeasonId = 23, Track = BattlePassTrack.Premium, Level = 2, RewardType = 9,
RewardDetailId = 0, RewardNumber = 20, IsAppealExclusion = false,
});
if (isPremium)
{
db.ViewerBattlePassProgress.Add(new ViewerBattlePassProgressEntry
{
ViewerId = viewerId, SeasonId = 23, IsPremium = true,
});
}
await db.SaveChangesAsync();
return viewerId;
}
[Test]
public async Task AddPoints_crossing_one_level_grants_normal_reward_only_when_not_premium()
{
using var factory = new SVSimTestFactory();
long viewerId = await SeedViewerAndSeason23(factory, isPremium: false);
using var scope = factory.Services.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<IBattlePassService>();
var grant = await svc.AddPointsAsync(viewerId, BattlePassPointSource.BattleResult, 500, CancellationToken.None);
Assert.That(grant.BeforeLevel, Is.EqualTo(1));
Assert.That(grant.AfterLevel, Is.EqualTo(2));
Assert.That(grant.PointAdd, Is.EqualTo(500));
Assert.That(grant.NewlyClaimed.Count, Is.EqualTo(1), "premium level-2 must be skipped");
Assert.That(grant.NewlyClaimed[0].RewardType, Is.EqualTo((int)UserGoodsType.Rupy));
Assert.That(grant.NewlyClaimed[0].RewardNum, Is.EqualTo(50), "post-state rupy total");
}
[Test]
public async Task AddPoints_with_premium_grants_both_tracks_at_level_crossed()
{
using var factory = new SVSimTestFactory();
long viewerId = await SeedViewerAndSeason23(factory, isPremium: true);
using var scope = factory.Services.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<IBattlePassService>();
var grant = await svc.AddPointsAsync(viewerId, BattlePassPointSource.BattleResult, 500, CancellationToken.None);
Assert.That(grant.NewlyClaimed.Count, Is.EqualTo(2));
}
[Test]
public async Task AddPoints_weekly_cap_caps_second_grant_to_remaining_headroom()
{
using var factory = new SVSimTestFactory();
long viewerId = await SeedViewerAndSeason23(factory);
using var scope = factory.Services.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<IBattlePassService>();
var first = await svc.AddPointsAsync(viewerId, BattlePassPointSource.BattleResult, 2000, CancellationToken.None);
var second = await svc.AddPointsAsync(viewerId, BattlePassPointSource.BattleResult, 2000, CancellationToken.None);
Assert.That(first.PointAdd, Is.EqualTo(2000));
Assert.That(second.PointAdd, Is.EqualTo(1000),
$"cap = {BattlePassService.WeeklyLimitPointDefault}; first burned 2000; second caps to 1000");
}
[Test]
public async Task AddPoints_when_no_season_active_returns_zero_grant()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var scope = factory.Services.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<IBattlePassService>();
var grant = await svc.AddPointsAsync(viewerId, BattlePassPointSource.BattleResult, 500, CancellationToken.None);
Assert.That(grant.PointAdd, Is.EqualTo(0));
Assert.That(grant.AfterLevel, Is.EqualTo(0));
Assert.That(grant.NewlyClaimed, Is.Empty);
}
}