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.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using SVSim.Database.Models;
|
||||||
|
|
||||||
namespace SVSim.Database.Services.Friend;
|
namespace SVSim.Database.Services.Friend;
|
||||||
|
|
||||||
@@ -142,17 +143,105 @@ public sealed class FriendService : IFriendService, IPlayedTogetherWriter
|
|||||||
return await BuildFriendEntryAsync(targetViewerId, ct);
|
return await BuildFriendEntryAsync(targetViewerId, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SendApplyAsync(long viewerId, int targetViewerId, CancellationToken ct) =>
|
public async Task SendApplyAsync(long viewerId, int targetViewerId, CancellationToken ct)
|
||||||
throw new NotImplementedException();
|
{
|
||||||
|
if (targetViewerId == (int)viewerId)
|
||||||
|
{
|
||||||
|
_log.LogDebug("SendApply self-target ignored for viewer {ViewerId}", viewerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
public Task ApproveApplyAsync(long viewerId, int applyId, CancellationToken ct) =>
|
bool targetExists = await _db.Viewers.AsNoTracking().AnyAsync(v => v.Id == targetViewerId, ct);
|
||||||
throw new NotImplementedException();
|
if (!targetExists)
|
||||||
|
{
|
||||||
|
_log.LogDebug("SendApply target {Target} not found", targetViewerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
public Task RejectApplyAsync(long viewerId, int applyId, CancellationToken ct) =>
|
bool alreadyFriends = await _db.ViewerFriends.AsNoTracking()
|
||||||
throw new NotImplementedException();
|
.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) =>
|
bool alreadyPending = await _db.ViewerFriendApplies.AsNoTracking()
|
||||||
throw new NotImplementedException();
|
.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) =>
|
public Task RejectAllAppliesAsync(long viewerId, CancellationToken ct) =>
|
||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
@@ -160,8 +249,17 @@ public sealed class FriendService : IFriendService, IPlayedTogetherWriter
|
|||||||
public Task CancelAllAppliesAsync(long viewerId, CancellationToken ct) =>
|
public Task CancelAllAppliesAsync(long viewerId, CancellationToken ct) =>
|
||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
|
|
||||||
public Task RejectFriendAsync(long viewerId, int targetViewerId, CancellationToken ct) =>
|
public async Task RejectFriendAsync(long viewerId, int targetViewerId, CancellationToken ct)
|
||||||
throw new NotImplementedException();
|
{
|
||||||
|
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) =>
|
public Task RecordAsync(long ownerViewerId, long opponentViewerId, BattleParticipationContext ctx, CancellationToken ct) =>
|
||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
|
|||||||
@@ -293,4 +293,246 @@ public class FriendServiceTests
|
|||||||
Assert.That(result, Is.Null);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user