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 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-09 22:04:04 -04:00
parent 17591a6ebd
commit b5b4781693
2 changed files with 159 additions and 6 deletions

View File

@@ -243,11 +243,19 @@ public sealed class FriendService : IFriendService, IPlayedTogetherWriter
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
} }
public Task RejectAllAppliesAsync(long viewerId, CancellationToken ct) => public async Task RejectAllAppliesAsync(long viewerId, CancellationToken ct)
throw new NotImplementedException(); {
await _db.ViewerFriendApplies
.Where(a => a.ToViewerId == viewerId)
.ExecuteDeleteAsync(ct);
}
public Task CancelAllAppliesAsync(long viewerId, CancellationToken ct) => public async Task CancelAllAppliesAsync(long viewerId, CancellationToken ct)
throw new NotImplementedException(); {
await _db.ViewerFriendApplies
.Where(a => a.FromViewerId == viewerId)
.ExecuteDeleteAsync(ct);
}
public async Task RejectFriendAsync(long viewerId, int targetViewerId, CancellationToken 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); await _db.SaveChangesAsync(ct);
} }
public Task RecordAsync(long ownerViewerId, long opponentViewerId, BattleParticipationContext ctx, CancellationToken ct) => public async Task RecordAsync(long ownerViewerId, long opponentViewerId, BattleParticipationContext ctx, CancellationToken ct)
throw new NotImplementedException(); {
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 --- // --- helpers ---

View File

@@ -535,4 +535,108 @@ public class FriendServiceTests
using var verifyScope = factory.Services.CreateScope(); using var verifyScope = factory.Services.CreateScope();
Assert.That(await Ctx(verifyScope).ViewerFriends.CountAsync(), Is.EqualTo(0)); 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<IPlayedTogetherWriter>();
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<IPlayedTogetherWriter>();
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<IPlayedTogetherWriter>();
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));
}
} }