import_viewer: clean orphan ViewerEventCounter rows on shrinking re-import

When missions/achievements shrink between re-imports, Pass B and Pass C
now compute the set of (EventKey, Period) counter keys from OLD rows
(before Clear()) and delete any that are not referenced by the new
payload. Prevents stale counters accumulating across import cycles.

TDD: two new failing tests written first (RED), then the fix (GREEN).
All 1383 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-23 20:37:11 -04:00
parent 79e936dcf2
commit 3a52115551
2 changed files with 204 additions and 0 deletions

View File

@@ -897,6 +897,140 @@ public class AdminControllerTests
Assert.That(verifyDb.ViewerStoryProgress.Any(), Is.False);
}
[Test]
public async Task ImportViewer_Missions_ShrinkingReImport_OrphanCounterRemoved()
{
// First import: two missions with distinct event types → two ViewerEventCounter rows.
// Second import: only the "kept" mission re-sent → orphan counter for "orphan_event"
// must be deleted; kept_event counter must remain with the new TotalCount.
using var factory = new SVSimTestFactory();
using (var seedScope = factory.Services.CreateScope())
{
var db = seedScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.MissionCatalog.AddRange(
new MissionCatalogEntry { Id = 9100, LotType = 6, EventType = "orphan_event", EventArg = null },
new MissionCatalogEntry { Id = 9101, LotType = 6, EventType = "kept_event", EventArg = null });
await db.SaveChangesAsync();
}
var client = factory.CreateClient();
const ulong steamId = 70000000000000020UL;
// First POST: both missions present (explicit slots to avoid the (ViewerId,Slot) unique index).
var resp1 = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
{
SteamId = steamId,
Missions = new List<ImportMission>
{
new() { MissionId = 9100, MissionStatus = 1, TotalCount = 10, Slot = 0 },
new() { MissionId = 9101, MissionStatus = 1, TotalCount = 5, Slot = 1 },
}
});
resp1.EnsureSuccessStatusCode();
long viewerId;
using (var scope = factory.Services.CreateScope())
{
var verifyDb = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
viewerId = await verifyDb.Viewers
.Where(v => v.SocialAccountConnections
.Any(s => s.AccountType == SocialAccountType.Steam && s.AccountId == steamId))
.Select(v => v.Id).SingleAsync();
// Sanity: two counters after first import.
Assert.That(verifyDb.ViewerEventCounters.Count(c => c.ViewerId == viewerId), Is.EqualTo(2),
"After first import, both orphan_event and kept_event counters must exist.");
}
// Second POST: only the kept mission.
var resp2 = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
{
SteamId = steamId,
Missions = new List<ImportMission>
{
new() { MissionId = 9101, MissionStatus = 1, TotalCount = 7, Slot = 1 },
}
});
resp2.EnsureSuccessStatusCode();
using var verifyScope = factory.Services.CreateScope();
var db2 = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var counters = db2.ViewerEventCounters.Where(c => c.ViewerId == viewerId).ToList();
Assert.That(counters.Count, Is.EqualTo(1),
"After shrinking re-import, the orphan_event counter must be deleted; only kept_event survives.");
Assert.That(counters.Single().EventKey, Is.EqualTo("kept_event"),
"Surviving counter must be for kept_event.");
Assert.That(counters.Single().Count, Is.EqualTo(7),
"kept_event counter must reflect the new TotalCount from the second import.");
}
[Test]
public async Task ImportViewer_Achievements_ShrinkingReImport_OrphanCounterRemoved()
{
// First import: two achievements with distinct event types → two ViewerEventCounter rows.
// Second import: only the "kept" achievement re-sent → orphan counter for "orphan_ach"
// must be deleted; kept_ach counter must remain with the new TotalCount.
using var factory = new SVSimTestFactory();
using (var seedScope = factory.Services.CreateScope())
{
var db = seedScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.AchievementCatalog.AddRange(
new AchievementCatalogEntry { AchievementType = 9200, Level = 1, EventType = "orphan_ach", EventArg = null },
new AchievementCatalogEntry { AchievementType = 9201, Level = 1, EventType = "kept_ach", EventArg = null });
await db.SaveChangesAsync();
}
var client = factory.CreateClient();
const ulong steamId = 70000000000000021UL;
// First POST: both achievements present.
var resp1 = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
{
SteamId = steamId,
Achievements = new List<ImportAchievement>
{
new() { AchievementType = 9200, Level = 1, NowAchievedLevel = 1, ResultAnnounceSawLevel = 0, TotalCount = 15 },
new() { AchievementType = 9201, Level = 1, NowAchievedLevel = 1, ResultAnnounceSawLevel = 0, TotalCount = 8 },
}
});
resp1.EnsureSuccessStatusCode();
long viewerId;
using (var scope = factory.Services.CreateScope())
{
var verifyDb = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
viewerId = await verifyDb.Viewers
.Where(v => v.SocialAccountConnections
.Any(s => s.AccountType == SocialAccountType.Steam && s.AccountId == steamId))
.Select(v => v.Id).SingleAsync();
// Sanity: two counters after first import.
Assert.That(verifyDb.ViewerEventCounters.Count(c => c.ViewerId == viewerId), Is.EqualTo(2),
"After first import, both orphan_ach and kept_ach counters must exist.");
}
// Second POST: only the kept achievement.
var resp2 = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
{
SteamId = steamId,
Achievements = new List<ImportAchievement>
{
new() { AchievementType = 9201, Level = 1, NowAchievedLevel = 1, ResultAnnounceSawLevel = 0, TotalCount = 12 },
}
});
resp2.EnsureSuccessStatusCode();
using var verifyScope = factory.Services.CreateScope();
var db2 = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var counters = db2.ViewerEventCounters.Where(c => c.ViewerId == viewerId).ToList();
Assert.That(counters.Count, Is.EqualTo(1),
"After shrinking re-import, the orphan_ach counter must be deleted; only kept_ach survives.");
Assert.That(counters.Single().EventKey, Is.EqualTo("kept_ach"),
"Surviving counter must be for kept_ach.");
Assert.That(counters.Single().Count, Is.EqualTo(12),
"kept_ach counter must reflect the new TotalCount from the second import.");
}
[Test]
public async Task ImportViewer_AllNewSections_AreIdempotentOnReImport()
{