From b5b4781693005495c95ae6fd0de8ae49d5cacaa5 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 9 Jun 2026 22:04:04 -0400 Subject: [PATCH] feat(friend): bulk apply ops + IPlayedTogetherWriter with retention cap Implements RejectAllAppliesAsync, CancelAllAppliesAsync (ExecuteDelete bulk deletes on incoming/outgoing applies respectively) and RecordAsync (upsert played-together row with 50-row per-viewer retention eviction). 4 new tests added; all 1186 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- .../Services/Friend/FriendService.cs | 61 +++++++++- .../Services/FriendServiceTests.cs | 104 ++++++++++++++++++ 2 files changed, 159 insertions(+), 6 deletions(-) diff --git a/SVSim.Database/Services/Friend/FriendService.cs b/SVSim.Database/Services/Friend/FriendService.cs index 2e74a5a..acf2cd2 100644 --- a/SVSim.Database/Services/Friend/FriendService.cs +++ b/SVSim.Database/Services/Friend/FriendService.cs @@ -243,11 +243,19 @@ public sealed class FriendService : IFriendService, IPlayedTogetherWriter await _db.SaveChangesAsync(ct); } - public Task RejectAllAppliesAsync(long viewerId, CancellationToken ct) => - throw new NotImplementedException(); + public async Task RejectAllAppliesAsync(long viewerId, CancellationToken ct) + { + await _db.ViewerFriendApplies + .Where(a => a.ToViewerId == viewerId) + .ExecuteDeleteAsync(ct); + } - public Task CancelAllAppliesAsync(long viewerId, CancellationToken ct) => - throw new NotImplementedException(); + public async Task CancelAllAppliesAsync(long viewerId, CancellationToken ct) + { + await _db.ViewerFriendApplies + .Where(a => a.FromViewerId == viewerId) + .ExecuteDeleteAsync(ct); + } public async Task RejectFriendAsync(long viewerId, int targetViewerId, CancellationToken ct) { @@ -261,8 +269,49 @@ public sealed class FriendService : IFriendService, IPlayedTogetherWriter await _db.SaveChangesAsync(ct); } - public Task RecordAsync(long ownerViewerId, long opponentViewerId, BattleParticipationContext ctx, CancellationToken ct) => - throw new NotImplementedException(); + public async Task RecordAsync(long ownerViewerId, long opponentViewerId, BattleParticipationContext ctx, CancellationToken ct) + { + if (ownerViewerId == opponentViewerId) return; + + var now = DateTime.UtcNow; + var existing = await _db.ViewerPlayedTogethers + .FirstOrDefaultAsync(p => p.OwnerViewerId == ownerViewerId && p.OpponentViewerId == opponentViewerId, ct); + + if (existing is null) + { + // Enforce per-viewer retention BEFORE insert: if at cap, drop the oldest first. + int currentCount = await _db.ViewerPlayedTogethers.CountAsync(p => p.OwnerViewerId == ownerViewerId, ct); + if (currentCount >= PlayedTogetherRetention) + { + var toEvict = await _db.ViewerPlayedTogethers + .Where(p => p.OwnerViewerId == ownerViewerId) + .OrderBy(p => p.PlayedAt).ThenBy(p => p.OpponentViewerId) + .FirstAsync(ct); + _db.ViewerPlayedTogethers.Remove(toEvict); + } + + _db.ViewerPlayedTogethers.Add(new ViewerPlayedTogether + { + OwnerViewerId = ownerViewerId, + OpponentViewerId = opponentViewerId, + PlayedAt = now, + PlayedMode = ctx.PlayedMode, + BattleType = ctx.BattleType, + DeckFormat = ctx.DeckFormat, + TwoPickType = ctx.TwoPickType, + }); + } + else + { + existing.PlayedAt = now; + existing.PlayedMode = ctx.PlayedMode; + existing.BattleType = ctx.BattleType; + existing.DeckFormat = ctx.DeckFormat; + existing.TwoPickType = ctx.TwoPickType; + } + + await _db.SaveChangesAsync(ct); + } // --- helpers --- diff --git a/SVSim.UnitTests/Services/FriendServiceTests.cs b/SVSim.UnitTests/Services/FriendServiceTests.cs index e7b3955..46128e4 100644 --- a/SVSim.UnitTests/Services/FriendServiceTests.cs +++ b/SVSim.UnitTests/Services/FriendServiceTests.cs @@ -535,4 +535,108 @@ public class FriendServiceTests using var verifyScope = factory.Services.CreateScope(); Assert.That(await Ctx(verifyScope).ViewerFriends.CountAsync(), Is.EqualTo(0)); } + + [Test] + public async Task RejectAllAppliesAsync_deletes_only_incoming_for_caller() + { + using var factory = new SVSimTestFactory(); + long me = await SeedViewer(factory, 76_561_198_000_002_001UL); + long other = await SeedViewer(factory, 76_561_198_000_002_002UL); + + using (var scope = factory.Services.CreateScope()) + { + var ctx = Ctx(scope); + ctx.ViewerFriendApplies.Add(new ViewerFriendApply { FromViewerId = other, ToViewerId = me, CreatedAt = DateTime.UtcNow }); + ctx.ViewerFriendApplies.Add(new ViewerFriendApply { FromViewerId = me, ToViewerId = other, CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + } + + var svc = Service(factory, out var scope2); + using (scope2) await svc.RejectAllAppliesAsync(me, default); + + using var verifyScope = factory.Services.CreateScope(); + var remaining = await Ctx(verifyScope).ViewerFriendApplies.AsNoTracking().ToListAsync(); + Assert.That(remaining, Has.Count.EqualTo(1)); + Assert.That(remaining[0].FromViewerId, Is.EqualTo(me), "Outgoing must survive"); + } + + [Test] + public async Task CancelAllAppliesAsync_deletes_only_outgoing_for_caller() + { + using var factory = new SVSimTestFactory(); + long me = await SeedViewer(factory, 76_561_198_000_002_003UL); + long other = await SeedViewer(factory, 76_561_198_000_002_004UL); + + using (var scope = factory.Services.CreateScope()) + { + var ctx = Ctx(scope); + ctx.ViewerFriendApplies.Add(new ViewerFriendApply { FromViewerId = me, ToViewerId = other, CreatedAt = DateTime.UtcNow }); + ctx.ViewerFriendApplies.Add(new ViewerFriendApply { FromViewerId = other, ToViewerId = me, CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + } + + var svc = Service(factory, out var scope2); + using (scope2) await svc.CancelAllAppliesAsync(me, default); + + using var verifyScope = factory.Services.CreateScope(); + var remaining = await Ctx(verifyScope).ViewerFriendApplies.AsNoTracking().ToListAsync(); + Assert.That(remaining, Has.Count.EqualTo(1)); + Assert.That(remaining[0].ToViewerId, Is.EqualTo(me), "Incoming must survive"); + } + + [Test] + public async Task RecordAsync_upserts_PlayedAt_for_existing_pair() + { + using var factory = new SVSimTestFactory(); + long me = await SeedViewer(factory, 76_561_198_000_002_005UL); + long opp = await SeedViewer(factory, 76_561_198_000_002_006UL); + + var ctxFactory = factory.Services; + IPlayedTogetherWriter writer; + using (var scope = ctxFactory.CreateScope()) + { + writer = scope.ServiceProvider.GetRequiredService(); + await writer.RecordAsync(me, opp, new BattleParticipationContext(1, 2, 3, 4), default); + } + DateTime firstTimestamp; + using (var scope = ctxFactory.CreateScope()) + { + firstTimestamp = (await Ctx(scope).ViewerPlayedTogethers.AsNoTracking() + .FirstAsync(p => p.OwnerViewerId == me && p.OpponentViewerId == opp)).PlayedAt; + } + + // Wait a tick, record again. + await Task.Delay(20); + using (var scope = ctxFactory.CreateScope()) + { + writer = scope.ServiceProvider.GetRequiredService(); + await writer.RecordAsync(me, opp, new BattleParticipationContext(5, 6, 7, 8), default); + } + + using var verifyScope = ctxFactory.CreateScope(); + var rows = await Ctx(verifyScope).ViewerPlayedTogethers.AsNoTracking() + .Where(p => p.OwnerViewerId == me).ToListAsync(); + Assert.That(rows, Has.Count.EqualTo(1), "Upsert — no duplicate row"); + Assert.That(rows[0].PlayedAt, Is.GreaterThan(firstTimestamp)); + Assert.That(rows[0].PlayedMode, Is.EqualTo(5), "Latest context wins"); + Assert.That(rows[0].BattleType, Is.EqualTo(6)); + Assert.That(rows[0].DeckFormat, Is.EqualTo(7)); + Assert.That(rows[0].TwoPickType, Is.EqualTo(8)); + } + + [Test] + public async Task RecordAsync_no_op_when_owner_equals_opponent() + { + using var factory = new SVSimTestFactory(); + long me = await SeedViewer(factory, 76_561_198_000_002_007UL); + + using (var scope = factory.Services.CreateScope()) + { + var writer = scope.ServiceProvider.GetRequiredService(); + await writer.RecordAsync(me, me, new BattleParticipationContext(0, 0, 0, 0), default); + } + + using var verifyScope = factory.Services.CreateScope(); + Assert.That(await Ctx(verifyScope).ViewerPlayedTogethers.CountAsync(), Is.EqualTo(0)); + } }