From 17591a6ebd4916b0d402d472f15e8f939687f686 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 9 Jun 2026 21:59:32 -0400 Subject: [PATCH] feat(friend): implement single-target write methods on FriendService SendApplyAsync, ApproveApplyAsync, RejectApplyAsync, CancelApplyAsync, RejectFriendAsync all implemented. 11 new tests appended; all 23 friend tests pass, full suite 1182/1182 green. --- .../Services/Friend/FriendService.cs | 118 ++++++++- .../Services/FriendServiceTests.cs | 242 ++++++++++++++++++ 2 files changed, 350 insertions(+), 10 deletions(-) diff --git a/SVSim.Database/Services/Friend/FriendService.cs b/SVSim.Database/Services/Friend/FriendService.cs index d2693ec..2e74a5a 100644 --- a/SVSim.Database/Services/Friend/FriendService.cs +++ b/SVSim.Database/Services/Friend/FriendService.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using SVSim.Database.Models; namespace SVSim.Database.Services.Friend; @@ -142,17 +143,105 @@ public sealed class FriendService : IFriendService, IPlayedTogetherWriter return await BuildFriendEntryAsync(targetViewerId, ct); } - public Task SendApplyAsync(long viewerId, int targetViewerId, CancellationToken ct) => - throw new NotImplementedException(); + public async Task SendApplyAsync(long viewerId, int targetViewerId, CancellationToken ct) + { + if (targetViewerId == (int)viewerId) + { + _log.LogDebug("SendApply self-target ignored for viewer {ViewerId}", viewerId); + return; + } - public Task ApproveApplyAsync(long viewerId, int applyId, CancellationToken ct) => - throw new NotImplementedException(); + bool targetExists = await _db.Viewers.AsNoTracking().AnyAsync(v => v.Id == targetViewerId, ct); + if (!targetExists) + { + _log.LogDebug("SendApply target {Target} not found", targetViewerId); + return; + } - public Task RejectApplyAsync(long viewerId, int applyId, CancellationToken ct) => - throw new NotImplementedException(); + bool alreadyFriends = await _db.ViewerFriends.AsNoTracking() + .AnyAsync(f => f.OwnerViewerId == viewerId && f.FriendViewerId == targetViewerId, ct); + if (alreadyFriends) + { + _log.LogDebug("SendApply ignored — viewer {ViewerId} already friends with {Target}", viewerId, targetViewerId); + return; + } - public Task CancelApplyAsync(long viewerId, int applyId, CancellationToken ct) => - throw new NotImplementedException(); + bool alreadyPending = await _db.ViewerFriendApplies.AsNoTracking() + .AnyAsync(a => a.FromViewerId == viewerId && a.ToViewerId == targetViewerId, ct); + if (alreadyPending) return; + + int outgoingCount = await _db.ViewerFriendApplies.CountAsync(a => a.FromViewerId == viewerId, ct); + if (outgoingCount >= SendApplyMaxCount) + { + _log.LogInformation("SendApply hit cap of {Cap} for viewer {ViewerId}", SendApplyMaxCount, viewerId); + return; + } + + _db.ViewerFriendApplies.Add(new ViewerFriendApply + { + FromViewerId = viewerId, + ToViewerId = targetViewerId, + CreatedAt = DateTime.UtcNow, + MissionType = 0, + }); + await _db.SaveChangesAsync(ct); + } + + public async Task ApproveApplyAsync(long viewerId, int applyId, CancellationToken ct) + { + var apply = await _db.ViewerFriendApplies + .FirstOrDefaultAsync(a => a.Id == applyId && a.ToViewerId == viewerId, ct); + if (apply is null) + { + _log.LogDebug("ApproveApply {ApplyId} not addressed to viewer {ViewerId}", applyId, viewerId); + return; + } + + long otherViewer = apply.FromViewerId; + + int myFriendCount = await _db.ViewerFriends.CountAsync(f => f.OwnerViewerId == viewerId, ct); + int otherFriendCount = await _db.ViewerFriends.CountAsync(f => f.OwnerViewerId == otherViewer, ct); + if (myFriendCount >= FriendMaxCount || otherFriendCount >= FriendMaxCount) + { + _log.LogInformation("ApproveApply hit friend cap (me={Me}, other={Other})", myFriendCount, otherFriendCount); + return; + } + + var now = DateTime.UtcNow; + + await using var tx = await _db.Database.BeginTransactionAsync(ct); + + _db.ViewerFriendApplies.Remove(apply); + + // Clean reverse-direction apply if it exists. + var reverse = await _db.ViewerFriendApplies + .FirstOrDefaultAsync(a => a.FromViewerId == viewerId && a.ToViewerId == otherViewer, ct); + if (reverse is not null) _db.ViewerFriendApplies.Remove(reverse); + + _db.ViewerFriends.Add(new ViewerFriend { OwnerViewerId = viewerId, FriendViewerId = otherViewer, CreatedAt = now }); + _db.ViewerFriends.Add(new ViewerFriend { OwnerViewerId = otherViewer, FriendViewerId = viewerId, CreatedAt = now }); + + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + + public async Task RejectApplyAsync(long viewerId, int applyId, CancellationToken ct) + { + var apply = await _db.ViewerFriendApplies + .FirstOrDefaultAsync(a => a.Id == applyId && a.ToViewerId == viewerId, ct); + if (apply is null) return; + _db.ViewerFriendApplies.Remove(apply); + await _db.SaveChangesAsync(ct); + } + + public async Task CancelApplyAsync(long viewerId, int applyId, CancellationToken ct) + { + var apply = await _db.ViewerFriendApplies + .FirstOrDefaultAsync(a => a.Id == applyId && a.FromViewerId == viewerId, ct); + if (apply is null) return; + _db.ViewerFriendApplies.Remove(apply); + await _db.SaveChangesAsync(ct); + } public Task RejectAllAppliesAsync(long viewerId, CancellationToken ct) => throw new NotImplementedException(); @@ -160,8 +249,17 @@ public sealed class FriendService : IFriendService, IPlayedTogetherWriter public Task CancelAllAppliesAsync(long viewerId, CancellationToken ct) => throw new NotImplementedException(); - public Task RejectFriendAsync(long viewerId, int targetViewerId, CancellationToken ct) => - throw new NotImplementedException(); + public async Task RejectFriendAsync(long viewerId, int targetViewerId, CancellationToken ct) + { + var rows = await _db.ViewerFriends + .Where(f => + (f.OwnerViewerId == viewerId && f.FriendViewerId == targetViewerId) || + (f.OwnerViewerId == targetViewerId && f.FriendViewerId == viewerId)) + .ToListAsync(ct); + if (rows.Count == 0) return; + _db.ViewerFriends.RemoveRange(rows); + await _db.SaveChangesAsync(ct); + } public Task RecordAsync(long ownerViewerId, long opponentViewerId, BattleParticipationContext ctx, CancellationToken ct) => throw new NotImplementedException(); diff --git a/SVSim.UnitTests/Services/FriendServiceTests.cs b/SVSim.UnitTests/Services/FriendServiceTests.cs index 589c299..e7b3955 100644 --- a/SVSim.UnitTests/Services/FriendServiceTests.cs +++ b/SVSim.UnitTests/Services/FriendServiceTests.cs @@ -293,4 +293,246 @@ public class FriendServiceTests Assert.That(result, Is.Null); } } + + [Test] + public async Task SendApplyAsync_creates_apply_row() + { + using var factory = new SVSimTestFactory(); + long sender = await SeedViewer(factory, 76_561_198_000_001_001UL); + long target = await SeedViewer(factory, 76_561_198_000_001_002UL); + + var svc = Service(factory, out var scope); + using (scope) await svc.SendApplyAsync(sender, (int)target, default); + + using var verifyScope = factory.Services.CreateScope(); + var ctx = Ctx(verifyScope); + var applies = await ctx.ViewerFriendApplies.AsNoTracking().Where(a => a.FromViewerId == sender).ToListAsync(); + Assert.That(applies, Has.Count.EqualTo(1)); + Assert.That(applies[0].ToViewerId, Is.EqualTo(target)); + } + + [Test] + public async Task SendApplyAsync_no_op_for_self() + { + using var factory = new SVSimTestFactory(); + long sender = await SeedViewer(factory, 76_561_198_000_001_003UL); + + var svc = Service(factory, out var scope); + using (scope) await svc.SendApplyAsync(sender, (int)sender, default); + + using var verifyScope = factory.Services.CreateScope(); + var count = await Ctx(verifyScope).ViewerFriendApplies.CountAsync(); + Assert.That(count, Is.EqualTo(0)); + } + + [Test] + public async Task SendApplyAsync_no_op_for_unknown_target() + { + using var factory = new SVSimTestFactory(); + long sender = await SeedViewer(factory, 76_561_198_000_001_004UL); + + var svc = Service(factory, out var scope); + using (scope) await svc.SendApplyAsync(sender, 999_999_999, default); + + using var verifyScope = factory.Services.CreateScope(); + var count = await Ctx(verifyScope).ViewerFriendApplies.CountAsync(); + Assert.That(count, Is.EqualTo(0)); + } + + [Test] + public async Task SendApplyAsync_no_op_when_already_friends() + { + using var factory = new SVSimTestFactory(); + long me = await SeedViewer(factory, 76_561_198_000_001_005UL); + long friend = await SeedViewer(factory, 76_561_198_000_001_006UL); + + using (var scope = factory.Services.CreateScope()) + { + var ctx = Ctx(scope); + ctx.ViewerFriends.Add(new ViewerFriend { OwnerViewerId = me, FriendViewerId = friend, CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + } + + var svc = Service(factory, out var scope2); + using (scope2) await svc.SendApplyAsync(me, (int)friend, default); + + using var verifyScope = factory.Services.CreateScope(); + var count = await Ctx(verifyScope).ViewerFriendApplies.CountAsync(); + Assert.That(count, Is.EqualTo(0)); + } + + [Test] + public async Task SendApplyAsync_no_op_when_already_pending() + { + using var factory = new SVSimTestFactory(); + long sender = await SeedViewer(factory, 76_561_198_000_001_007UL); + long target = await SeedViewer(factory, 76_561_198_000_001_008UL); + + using (var scope = factory.Services.CreateScope()) + { + var ctx = Ctx(scope); + ctx.ViewerFriendApplies.Add(new ViewerFriendApply { FromViewerId = sender, ToViewerId = target, CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + } + + var svc = Service(factory, out var scope2); + using (scope2) await svc.SendApplyAsync(sender, (int)target, default); + + using var verifyScope = factory.Services.CreateScope(); + var count = await Ctx(verifyScope).ViewerFriendApplies.CountAsync(); + Assert.That(count, Is.EqualTo(1), "Pre-existing apply must not be duplicated"); + } + + [Test] + public async Task ApproveApplyAsync_creates_two_friend_rows_and_deletes_apply() + { + using var factory = new SVSimTestFactory(); + long target = await SeedViewer(factory, 76_561_198_000_001_009UL); + long sender = await SeedViewer(factory, 76_561_198_000_001_010UL); + + int applyId; + using (var scope = factory.Services.CreateScope()) + { + var ctx = Ctx(scope); + var apply = new ViewerFriendApply { FromViewerId = sender, ToViewerId = target, CreatedAt = DateTime.UtcNow }; + ctx.ViewerFriendApplies.Add(apply); + await ctx.SaveChangesAsync(); + applyId = apply.Id; + } + + var svc = Service(factory, out var scope2); + using (scope2) await svc.ApproveApplyAsync(target, applyId, default); + + using var verifyScope = factory.Services.CreateScope(); + var ctx2 = Ctx(verifyScope); + var friends = await ctx2.ViewerFriends.AsNoTracking().ToListAsync(); + Assert.That(friends, Has.Count.EqualTo(2)); + Assert.That(friends.Any(f => f.OwnerViewerId == target && f.FriendViewerId == sender)); + Assert.That(friends.Any(f => f.OwnerViewerId == sender && f.FriendViewerId == target)); + Assert.That(await ctx2.ViewerFriendApplies.CountAsync(), Is.EqualTo(0)); + } + + [Test] + public async Task ApproveApplyAsync_cleans_reverse_direction_apply_if_present() + { + using var factory = new SVSimTestFactory(); + long target = await SeedViewer(factory, 76_561_198_000_001_011UL); + long sender = await SeedViewer(factory, 76_561_198_000_001_012UL); + + int applyId; + using (var scope = factory.Services.CreateScope()) + { + var ctx = Ctx(scope); + var a = new ViewerFriendApply { FromViewerId = sender, ToViewerId = target, CreatedAt = DateTime.UtcNow }; + // The reverse-direction apply that should get cleaned. + var b = new ViewerFriendApply { FromViewerId = target, ToViewerId = sender, CreatedAt = DateTime.UtcNow }; + ctx.ViewerFriendApplies.AddRange(a, b); + await ctx.SaveChangesAsync(); + applyId = a.Id; + } + + var svc = Service(factory, out var scope2); + using (scope2) await svc.ApproveApplyAsync(target, applyId, default); + + using var verifyScope = factory.Services.CreateScope(); + Assert.That(await Ctx(verifyScope).ViewerFriendApplies.CountAsync(), Is.EqualTo(0), + "Both directions' applies must be cleaned"); + } + + [Test] + public async Task ApproveApplyAsync_no_op_when_apply_not_addressed_to_caller() + { + using var factory = new SVSimTestFactory(); + long imposter = await SeedViewer(factory, 76_561_198_000_001_013UL); + long target = await SeedViewer(factory, 76_561_198_000_001_014UL); + long sender = await SeedViewer(factory, 76_561_198_000_001_015UL); + + int applyId; + using (var scope = factory.Services.CreateScope()) + { + var ctx = Ctx(scope); + var apply = new ViewerFriendApply { FromViewerId = sender, ToViewerId = target, CreatedAt = DateTime.UtcNow }; + ctx.ViewerFriendApplies.Add(apply); + await ctx.SaveChangesAsync(); + applyId = apply.Id; + } + + var svc = Service(factory, out var scope2); + using (scope2) await svc.ApproveApplyAsync(imposter, applyId, default); + + using var verifyScope = factory.Services.CreateScope(); + var ctx2 = Ctx(verifyScope); + Assert.That(await ctx2.ViewerFriendApplies.CountAsync(), Is.EqualTo(1), "Apply must survive"); + Assert.That(await ctx2.ViewerFriends.CountAsync(), Is.EqualTo(0), "No friendship created"); + } + + [Test] + public async Task RejectApplyAsync_deletes_apply_only_for_correct_recipient() + { + using var factory = new SVSimTestFactory(); + long target = await SeedViewer(factory, 76_561_198_000_001_016UL); + long sender = await SeedViewer(factory, 76_561_198_000_001_017UL); + + int applyId; + using (var scope = factory.Services.CreateScope()) + { + var ctx = Ctx(scope); + var apply = new ViewerFriendApply { FromViewerId = sender, ToViewerId = target, CreatedAt = DateTime.UtcNow }; + ctx.ViewerFriendApplies.Add(apply); + await ctx.SaveChangesAsync(); + applyId = apply.Id; + } + + var svc = Service(factory, out var scope2); + using (scope2) await svc.RejectApplyAsync(target, applyId, default); + + using var verifyScope = factory.Services.CreateScope(); + Assert.That(await Ctx(verifyScope).ViewerFriendApplies.CountAsync(), Is.EqualTo(0)); + } + + [Test] + public async Task CancelApplyAsync_deletes_apply_only_for_correct_sender() + { + using var factory = new SVSimTestFactory(); + long sender = await SeedViewer(factory, 76_561_198_000_001_018UL); + long target = await SeedViewer(factory, 76_561_198_000_001_019UL); + + int applyId; + using (var scope = factory.Services.CreateScope()) + { + var ctx = Ctx(scope); + var apply = new ViewerFriendApply { FromViewerId = sender, ToViewerId = target, CreatedAt = DateTime.UtcNow }; + ctx.ViewerFriendApplies.Add(apply); + await ctx.SaveChangesAsync(); + applyId = apply.Id; + } + + var svc = Service(factory, out var scope2); + using (scope2) await svc.CancelApplyAsync(sender, applyId, default); + + using var verifyScope = factory.Services.CreateScope(); + Assert.That(await Ctx(verifyScope).ViewerFriendApplies.CountAsync(), Is.EqualTo(0)); + } + + [Test] + public async Task RejectFriendAsync_deletes_both_directions() + { + using var factory = new SVSimTestFactory(); + long me = await SeedViewer(factory, 76_561_198_000_001_020UL); + long other = await SeedViewer(factory, 76_561_198_000_001_021UL); + + using (var scope = factory.Services.CreateScope()) + { + var ctx = Ctx(scope); + ctx.ViewerFriends.Add(new ViewerFriend { OwnerViewerId = me, FriendViewerId = other, CreatedAt = DateTime.UtcNow }); + ctx.ViewerFriends.Add(new ViewerFriend { OwnerViewerId = other, FriendViewerId = me, CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + } + + var svc = Service(factory, out var scope2); + using (scope2) await svc.RejectFriendAsync(me, (int)other, default); + + using var verifyScope = factory.Services.CreateScope(); + Assert.That(await Ctx(verifyScope).ViewerFriends.CountAsync(), Is.EqualTo(0)); + } }