From 3a52115551e31f7bcf2e26adbe90e0044a0c405c Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 23 Jun 2026 20:37:11 -0400 Subject: [PATCH] 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 --- .../Controllers/AdminController.cs | 70 +++++++++ .../Controllers/AdminControllerTests.cs | 134 ++++++++++++++++++ 2 files changed, 204 insertions(+) diff --git a/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs b/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs index c643c2fa..d0139df0 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs @@ -274,6 +274,19 @@ public class AdminController : SVSimController var skippedMissionCounterIds = new HashSet(); if (request.Missions is { } missions) { + // Snapshot the OLD mission catalog ids BEFORE clearing, then load their catalog + // entries so we can compute which (EventKey, Period) counter keys are going away. + var oldMissionCatalogIds = viewer.Missions.Select(m => m.MissionCatalogId).Distinct().ToList(); + var nowUtcForOld = DateTimeOffset.UtcNow; + var oldCatalogs = await _dbContext.MissionCatalog + .Where(c => oldMissionCatalogIds.Contains(c.Id)) + .ToListAsync(); + var oldMissionCounterKeys = oldCatalogs + .Select(c => ResolveMissionCounter(c, nowUtcForOld)) + .Where(r => r is not null) + .Select(r => r!.Value) + .ToHashSet(); + viewer.Missions.Clear(); var missionIds = missions.Select(m => m.MissionId).Distinct().ToList(); var catalogs = await _dbContext.MissionCatalog @@ -293,6 +306,28 @@ public class AdminController : SVSimController resolvedCounterKeys[m.MissionId] = r.Value; } + // Delete orphan ViewerEventCounter rows: keys that existed for OLD missions but are + // NOT referenced by any mission in the new payload. + var newMissionCounterKeys = resolvedCounterKeys.Values.ToHashSet(); + var orphanMissionKeys = oldMissionCounterKeys + .Where(k => !newMissionCounterKeys.Contains(k)) + .ToList(); + if (orphanMissionKeys.Count > 0) + { + var orphanEventKeys = orphanMissionKeys.Select(k => k.EventKey).Distinct().ToList(); + var orphanPeriods = orphanMissionKeys.Select(k => k.Period).Distinct().ToList(); + var orphanCounters = await _dbContext.ViewerEventCounters + .Where(c => c.ViewerId == viewer.Id + && orphanEventKeys.Contains(c.EventKey) + && orphanPeriods.Contains(c.Period)) + .ToListAsync(); + // Filter in-memory to the exact (EventKey, Period) pairs (the DB query widens slightly). + var toDelete = orphanCounters + .Where(c => orphanMissionKeys.Contains((c.EventKey, c.Period))) + .ToList(); + _dbContext.ViewerEventCounters.RemoveRange(toDelete); + } + // Collect all distinct (EventKey, Period) pairs we will touch. var allEventKeys = resolvedCounterKeys.Values.Select(v => v.EventKey).Distinct().ToList(); var allPeriods = resolvedCounterKeys.Values.Select(v => v.Period).Distinct().ToList(); @@ -348,6 +383,19 @@ public class AdminController : SVSimController var skippedAchievementCounterTypes = new HashSet(); if (request.Achievements is { } achievements) { + // Snapshot the OLD achievement types BEFORE clearing, then load their catalog entries + // so we can compute which (EventKey, Period) counter keys are going away. + var oldAchievementTypes = viewer.Achievements.Select(a => a.AchievementType).Distinct().ToList(); + var oldAchCatalogs = await _dbContext.AchievementCatalog + .Where(c => oldAchievementTypes.Contains(c.AchievementType)) + .GroupBy(c => c.AchievementType) + .ToDictionaryAsync(g => g.Key, g => g.First()); + var oldAchievementCounterKeys = oldAchCatalogs.Values + .Select(c => ResolveAchievementCounter(c)) + .Where(r => r is not null) + .Select(r => r!.Value) + .ToHashSet(); + viewer.Achievements.Clear(); var types = achievements.Select(a => a.AchievementType).Distinct().ToList(); var catalogs = await _dbContext.AchievementCatalog @@ -367,6 +415,28 @@ public class AdminController : SVSimController resolvedAchievementCounterKeys[a.AchievementType] = r.Value; } + // Delete orphan ViewerEventCounter rows: keys that existed for OLD achievements but + // are NOT referenced by any achievement in the new payload. + var newAchievementCounterKeys = resolvedAchievementCounterKeys.Values.ToHashSet(); + var orphanAchKeys = oldAchievementCounterKeys + .Where(k => !newAchievementCounterKeys.Contains(k)) + .ToList(); + if (orphanAchKeys.Count > 0) + { + var orphanAchEventKeys = orphanAchKeys.Select(k => k.EventKey).Distinct().ToList(); + var orphanAchPeriods = orphanAchKeys.Select(k => k.Period).Distinct().ToList(); + var orphanAchCounters = await _dbContext.ViewerEventCounters + .Where(c => c.ViewerId == viewer.Id + && orphanAchEventKeys.Contains(c.EventKey) + && orphanAchPeriods.Contains(c.Period)) + .ToListAsync(); + // Filter in-memory to the exact (EventKey, Period) pairs. + var toDeleteAch = orphanAchCounters + .Where(c => orphanAchKeys.Contains((c.EventKey, c.Period))) + .ToList(); + _dbContext.ViewerEventCounters.RemoveRange(toDeleteAch); + } + // Collect all distinct (EventKey, Period) pairs we will touch. var allAchEventKeys = resolvedAchievementCounterKeys.Values.Select(v => v.EventKey).Distinct().ToList(); var allAchPeriods = resolvedAchievementCounterKeys.Values.Select(v => v.Period).Distinct().ToList(); diff --git a/SVSim.UnitTests/Controllers/AdminControllerTests.cs b/SVSim.UnitTests/Controllers/AdminControllerTests.cs index 3f468b77..bc2bbe2c 100644 --- a/SVSim.UnitTests/Controllers/AdminControllerTests.cs +++ b/SVSim.UnitTests/Controllers/AdminControllerTests.cs @@ -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(); + 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 + { + 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(); + 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 + { + new() { MissionId = 9101, MissionStatus = 1, TotalCount = 7, Slot = 1 }, + } + }); + resp2.EnsureSuccessStatusCode(); + + using var verifyScope = factory.Services.CreateScope(); + var db2 = verifyScope.ServiceProvider.GetRequiredService(); + + 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(); + 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 + { + 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(); + 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 + { + new() { AchievementType = 9201, Level = 1, NowAchievedLevel = 1, ResultAnnounceSawLevel = 0, TotalCount = 12 }, + } + }); + resp2.EnsureSuccessStatusCode(); + + using var verifyScope = factory.Services.CreateScope(); + var db2 = verifyScope.ServiceProvider.GetRequiredService(); + + 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() {