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() {