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:
@@ -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 ---
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user