feat(friend): add ViewerFriend + ViewerFriendApply + ViewerPlayedTogether entities

Lays the persistence foundation for the /friend/* API surface. Three new
model classes with composite PKs / unique constraints / FK cascades registered
on SVSimDbContext; 4/4 persistence tests pass on SQLite in-memory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-09 21:40:08 -04:00
parent c1eec9057a
commit 1813217c16
5 changed files with 187 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
namespace SVSim.Database.Models;
/// <summary>
/// One row per direction of a friendship. Approving an apply creates two rows
/// (A → B and B → A). <see cref="FriendViewerId"/> from a played-together row
/// can be self-joined against this table to detect an existing friendship.
/// </summary>
public class ViewerFriend
{
public long OwnerViewerId { get; set; }
public long FriendViewerId { get; set; }
public DateTime CreatedAt { get; set; }
}

View File

@@ -0,0 +1,17 @@
namespace SVSim.Database.Models;
/// <summary>
/// One pending friend application. <see cref="Id"/> is the wire <c>apply_id</c>
/// (auto-generated). Unique on <c>(FromViewerId, ToViewerId)</c> — a viewer can only
/// have one outstanding apply to any given target.
/// </summary>
public class ViewerFriendApply
{
public int Id { get; set; }
public long FromViewerId { get; set; }
public long ToViewerId { get; set; }
public DateTime CreatedAt { get; set; }
/// <summary>Beginner-friend campaign tag. Defaults to 0 (no campaign). Surfaces as optional <c>mission_type</c> on the wire.</summary>
public int MissionType { get; set; }
}

View File

@@ -0,0 +1,17 @@
namespace SVSim.Database.Models;
/// <summary>
/// One row per (owner, opponent) pair. Upserted on each new battle so the table
/// holds at most one row per opponent. Per-viewer 50-row retention cap pruned
/// by <c>IPlayedTogetherWriter.RecordAsync</c>.
/// </summary>
public class ViewerPlayedTogether
{
public long OwnerViewerId { get; set; }
public long OpponentViewerId { get; set; }
public DateTime PlayedAt { get; set; }
public int PlayedMode { get; set; }
public int BattleType { get; set; }
public int DeckFormat { get; set; }
public int TwoPickType { get; set; }
}

View File

@@ -112,6 +112,10 @@ public class SVSimDbContext : DbContext
public DbSet<SerialCodeRewardEntry> SerialCodeRewards => Set<SerialCodeRewardEntry>();
public DbSet<ViewerSerialCodeRedemption> ViewerSerialCodeRedemptions => Set<ViewerSerialCodeRedemption>();
public DbSet<ViewerFriend> ViewerFriends => Set<ViewerFriend>();
public DbSet<ViewerFriendApply> ViewerFriendApplies => Set<ViewerFriendApply>();
public DbSet<ViewerPlayedTogether> ViewerPlayedTogethers => Set<ViewerPlayedTogether>();
#endregion
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
@@ -455,6 +459,31 @@ public class SVSimDbContext : DbContext
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<ViewerFriend>(b =>
{
b.HasKey(e => new { e.OwnerViewerId, e.FriendViewerId });
b.HasIndex(e => new { e.OwnerViewerId, e.CreatedAt });
b.HasOne<Viewer>().WithMany().HasForeignKey(e => e.OwnerViewerId).OnDelete(DeleteBehavior.Cascade);
b.HasOne<Viewer>().WithMany().HasForeignKey(e => e.FriendViewerId).OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<ViewerFriendApply>(b =>
{
b.HasKey(e => e.Id);
b.HasIndex(e => new { e.FromViewerId, e.ToViewerId }).IsUnique();
b.HasIndex(e => e.ToViewerId);
b.HasOne<Viewer>().WithMany().HasForeignKey(e => e.FromViewerId).OnDelete(DeleteBehavior.Cascade);
b.HasOne<Viewer>().WithMany().HasForeignKey(e => e.ToViewerId).OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<ViewerPlayedTogether>(b =>
{
b.HasKey(e => new { e.OwnerViewerId, e.OpponentViewerId });
b.HasIndex(e => new { e.OwnerViewerId, e.PlayedAt });
b.HasOne<Viewer>().WithMany().HasForeignKey(e => e.OwnerViewerId).OnDelete(DeleteBehavior.Cascade);
// OpponentViewerId is NOT an FK — we want survivors' history to outlive a deleted opponent.
});
base.OnModelCreating(modelBuilder);
}

View File

@@ -0,0 +1,111 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Models;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Persistence;
public class FriendPersistenceTests
{
[Test]
public async Task ViewerFriend_round_trips_composite_PK_row()
{
using var factory = new SVSimTestFactory();
long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_001UL);
long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_002UL);
using (var seedScope = factory.Services.CreateScope())
{
var ctx = seedScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
ctx.ViewerFriends.Add(new ViewerFriend
{
OwnerViewerId = viewerA,
FriendViewerId = viewerB,
CreatedAt = new DateTime(2026, 6, 9, 12, 0, 0, DateTimeKind.Utc),
});
await ctx.SaveChangesAsync();
}
using var verifyScope = factory.Services.CreateScope();
var ctx2 = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var row = await ctx2.ViewerFriends.AsNoTracking()
.FirstAsync(f => f.OwnerViewerId == viewerA && f.FriendViewerId == viewerB);
Assert.That(row.CreatedAt.Year, Is.EqualTo(2026));
}
[Test]
public async Task ViewerFriend_composite_PK_rejects_duplicate_pair()
{
using var factory = new SVSimTestFactory();
long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_003UL);
long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_004UL);
using var scope = factory.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
ctx.ViewerFriends.Add(new ViewerFriend { OwnerViewerId = viewerA, FriendViewerId = viewerB, CreatedAt = DateTime.UtcNow });
await ctx.SaveChangesAsync();
using var dupScope = factory.Services.CreateScope();
var dupCtx = dupScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
dupCtx.ViewerFriends.Add(new ViewerFriend { OwnerViewerId = viewerA, FriendViewerId = viewerB, CreatedAt = DateTime.UtcNow });
Assert.That(async () => await dupCtx.SaveChangesAsync(), Throws.Exception);
}
[Test]
public async Task ViewerFriendApply_unique_constraint_rejects_duplicate_from_to_pair()
{
using var factory = new SVSimTestFactory();
long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_005UL);
long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_006UL);
using var scope = factory.Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
ctx.ViewerFriendApplies.Add(new ViewerFriendApply
{
FromViewerId = viewerA, ToViewerId = viewerB, CreatedAt = DateTime.UtcNow,
});
await ctx.SaveChangesAsync();
using var dupScope = factory.Services.CreateScope();
var dupCtx = dupScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
dupCtx.ViewerFriendApplies.Add(new ViewerFriendApply
{
FromViewerId = viewerA, ToViewerId = viewerB, CreatedAt = DateTime.UtcNow,
});
Assert.That(async () => await dupCtx.SaveChangesAsync(), Throws.Exception);
}
[Test]
public async Task ViewerPlayedTogether_round_trips_all_columns()
{
using var factory = new SVSimTestFactory();
long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_007UL);
long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_008UL);
using (var seedScope = factory.Services.CreateScope())
{
var ctx = seedScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
ctx.ViewerPlayedTogethers.Add(new ViewerPlayedTogether
{
OwnerViewerId = viewerA,
OpponentViewerId = viewerB,
PlayedAt = new DateTime(2026, 6, 9, 12, 0, 0, DateTimeKind.Utc),
PlayedMode = 1,
BattleType = 2,
DeckFormat = 3,
TwoPickType = 4,
});
await ctx.SaveChangesAsync();
}
using var verifyScope = factory.Services.CreateScope();
var ctx2 = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var row = await ctx2.ViewerPlayedTogethers.AsNoTracking()
.FirstAsync(p => p.OwnerViewerId == viewerA && p.OpponentViewerId == viewerB);
Assert.That(row.PlayedMode, Is.EqualTo(1));
Assert.That(row.BattleType, Is.EqualTo(2));
Assert.That(row.DeckFormat, Is.EqualTo(3));
Assert.That(row.TwoPickType, Is.EqualTo(4));
}
}