From d078f275f8ed26c397e1ad5cbf7125a79d85203c Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 9 Jun 2026 21:53:10 -0400 Subject: [PATCH] feat(friend): implement 5 read methods on FriendService + register DI + read test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetFriendsAsync, GetReceiveAppliesAsync, GetSendAppliesAsync, GetPlayedTogetherAsync, SearchAsync all implemented. LoadViewerProjectionAsync materialises the full Viewer entity (with Include/ThenInclude for SelectedEmblem/Degree) then projects in-memory — avoids the EF Core limitation where Include is silently ignored under Select. FriendService + IPlayedTogetherWriter registered as Scoped in Program.cs. 12 read tests, all green; full suite 1171/1171 still passing. Co-Authored-By: Claude Sonnet 4.6 --- .../Services/Friend/FriendService.cs | 208 +++++++++++- SVSim.EmulatedEntrypoint/Program.cs | 4 + .../Services/FriendServiceTests.cs | 296 ++++++++++++++++++ 3 files changed, 498 insertions(+), 10 deletions(-) create mode 100644 SVSim.UnitTests/Services/FriendServiceTests.cs diff --git a/SVSim.Database/Services/Friend/FriendService.cs b/SVSim.Database/Services/Friend/FriendService.cs index c235de1..d2693ec 100644 --- a/SVSim.Database/Services/Friend/FriendService.cs +++ b/SVSim.Database/Services/Friend/FriendService.cs @@ -28,20 +28,119 @@ public sealed class FriendService : IFriendService, IPlayedTogetherWriter _log = log; } - public Task GetFriendsAsync(long viewerId, CancellationToken ct) => - throw new NotImplementedException(); + public async Task GetFriendsAsync(long viewerId, CancellationToken ct) + { + var friendIds = await _db.ViewerFriends + .AsNoTracking() + .Where(f => f.OwnerViewerId == viewerId) + .OrderBy(f => f.CreatedAt).ThenBy(f => f.FriendViewerId) + .Select(f => f.FriendViewerId) + .ToListAsync(ct); - public Task GetReceiveAppliesAsync(long viewerId, CancellationToken ct) => - throw new NotImplementedException(); + var friends = new List(friendIds.Count); + foreach (var friendId in friendIds) + { + var entry = await BuildFriendEntryAsync(friendId, ct); + if (entry is not null) friends.Add(entry); + } - public Task GetSendAppliesAsync(long viewerId, CancellationToken ct) => - throw new NotImplementedException(); + return new FriendInfoResult(friends, friends.Count, FriendMaxCount); + } - public Task GetPlayedTogetherAsync(long viewerId, CancellationToken ct) => - throw new NotImplementedException(); + public async Task GetReceiveAppliesAsync(long viewerId, CancellationToken ct) + { + var rows = await _db.ViewerFriendApplies + .Where(a => a.ToViewerId == viewerId) + .OrderBy(a => a.CreatedAt).ThenBy(a => a.Id) + .AsNoTracking() + .ToListAsync(ct); - public Task SearchAsync(long viewerId, int targetViewerId, CancellationToken ct) => - throw new NotImplementedException(); + var applies = new List(rows.Count); + foreach (var row in rows) + applies.Add(await BuildApplyEntryAsync(row.Id, row.FromViewerId, row.CreatedAt, row.MissionType, ct)); + + return new ReceiveApplyInfoResult(applies, ApproveApplyCount: 0); + } + + public async Task GetSendAppliesAsync(long viewerId, CancellationToken ct) + { + var rows = await _db.ViewerFriendApplies + .Where(a => a.FromViewerId == viewerId) + .OrderBy(a => a.CreatedAt).ThenBy(a => a.Id) + .AsNoTracking() + .ToListAsync(ct); + + var applies = new List(rows.Count); + foreach (var row in rows) + applies.Add(await BuildApplyEntryAsync(row.Id, row.ToViewerId, row.CreatedAt, row.MissionType, ct)); + + int remaining = Math.Max(0, SendApplyMaxCount - rows.Count); + return new SendApplyInfoResult(applies, remaining, SendApplyMaxCount); + } + + public async Task GetPlayedTogetherAsync(long viewerId, CancellationToken ct) + { + var rows = await _db.ViewerPlayedTogethers + .Where(p => p.OwnerViewerId == viewerId) + .OrderByDescending(p => p.PlayedAt) + .AsNoTracking() + .ToListAsync(ct); + + var entries = new List(rows.Count); + foreach (var row in rows) + { + var opp = await LoadViewerProjectionAsync(row.OpponentViewerId, ct); + if (opp is null) continue; // opponent deleted; skip the dead row + + bool isFriend = await _db.ViewerFriends.AsNoTracking() + .AnyAsync(f => f.OwnerViewerId == viewerId && f.FriendViewerId == row.OpponentViewerId, ct); + + int friendStatus = 0; + int friendApplyId = 0; + if (isFriend) + { + friendStatus = 1; + } + else + { + var sent = await _db.ViewerFriendApplies.AsNoTracking() + .Where(a => a.FromViewerId == viewerId && a.ToViewerId == row.OpponentViewerId) + .Select(a => (int?)a.Id).FirstOrDefaultAsync(ct); + if (sent is { } sId) { friendStatus = 2; friendApplyId = sId; } + else + { + var recv = await _db.ViewerFriendApplies.AsNoTracking() + .Where(a => a.FromViewerId == row.OpponentViewerId && a.ToViewerId == viewerId) + .Select(a => (int?)a.Id).FirstOrDefaultAsync(ct); + if (recv is { } rId) { friendStatus = 3; friendApplyId = rId; } + } + } + + entries.Add(new PlayedTogetherEntry( + (int)opp.Id, + opp.DisplayName, + opp.CountryCode, + ResolveRank(opp.DisplayName), + opp.EmblemId, + opp.DegreeId, + FormatWireTimestamp(opp.LastLogin), + FormatWireTimestamp(row.PlayedAt), + friendStatus, + friendApplyId, + row.PlayedMode, + row.BattleType, + row.DeckFormat, + row.TwoPickType)); + } + + return new PlayedTogetherResult(entries); + } + + public async Task SearchAsync(long viewerId, int targetViewerId, CancellationToken ct) + { + if (targetViewerId == (int)viewerId) return null; + return await BuildFriendEntryAsync(targetViewerId, ct); + } public Task SendApplyAsync(long viewerId, int targetViewerId, CancellationToken ct) => throw new NotImplementedException(); @@ -66,4 +165,93 @@ public sealed class FriendService : IFriendService, IPlayedTogetherWriter public Task RecordAsync(long ownerViewerId, long opponentViewerId, BattleParticipationContext ctx, CancellationToken ct) => throw new NotImplementedException(); + + // --- helpers --- + + private sealed record ViewerProjection( + long Id, + string DisplayName, + DateTime LastLogin, + string CountryCode, + long EmblemId, + int DegreeId); + + /// + /// Loads a Viewer with Info + cosmetic nav refs, then projects to a slim record. + /// We materialise the full entity rather than using Select() because EF Core + /// ignores Include/ThenInclude when a Select projection is present. + /// + private async Task LoadViewerProjectionAsync(long viewerId, CancellationToken ct) + { + var v = await _db.Viewers + .AsNoTracking() + .Where(x => x.Id == viewerId) + .Include(x => x.Info).ThenInclude(i => i.SelectedEmblem) + .Include(x => x.Info).ThenInclude(i => i.SelectedDegree) + .FirstOrDefaultAsync(ct); + + if (v is null) return null; + + return new ViewerProjection( + v.Id, + v.DisplayName, + v.LastLogin, + v.Info.CountryCode, + v.Info.SelectedEmblem?.Id ?? 0, + v.Info.SelectedDegree?.Id ?? 0); + } + + private async Task BuildFriendEntryAsync(long friendViewerId, CancellationToken ct) + { + var v = await LoadViewerProjectionAsync(friendViewerId, ct); + if (v is null) return null; + + return new FriendEntry( + ViewerId: (int)v.Id, + Name: v.DisplayName, + CountryCode: v.CountryCode, + Rank: ResolveRank(v.DisplayName), + EmblemId: v.EmblemId, + DegreeId: v.DegreeId, + LastPlayTime: FormatWireTimestamp(v.LastLogin), + DeviceType: DefaultDeviceType, + MaxFriend: DefaultMaxFriend, + IsReceivedTwoPickMission: DefaultIsReceivedTwoPickMission, + Birth: DefaultBirth, + MissionChangeTime: DefaultMissionChangeTime, + MissionReceiveType: DefaultMissionReceiveType, + IsOfficial: DefaultIsOfficial, + IsOfficialMarkDisplayed: DefaultIsOfficialMarkDisplayed); + } + + private async Task BuildApplyEntryAsync(int applyId, long otherViewerId, DateTime createdAt, int missionType, CancellationToken ct) + { + var v = await LoadViewerProjectionAsync(otherViewerId, ct); + // If viewer was deleted between apply creation and now, emit a placeholder so the wire doesn't break. + var displayName = v?.DisplayName ?? string.Empty; + var lastLogin = v?.LastLogin ?? DateTime.UnixEpoch; + var countryCode = v?.CountryCode ?? string.Empty; + var emblemId = v?.EmblemId ?? 0; + var degreeId = v?.DegreeId ?? 0; + + return new FriendApplyEntry( + Id: applyId, + ViewerId: (int)otherViewerId, + Name: displayName, + CountryCode: countryCode, + Rank: ResolveRank(displayName), + EmblemId: emblemId, + DegreeId: degreeId, + LastPlayTime: FormatWireTimestamp(lastLogin), + CreateTime: FormatWireTimestamp(createdAt), + MissionType: missionType); + } + + /// + /// Rank derivation. We don't track per-viewer rank yet; always 1. Hook here when rank data lands. + /// + private static int ResolveRank(string _) => 1; + + private static string FormatWireTimestamp(DateTime dt) => + dt.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture); } diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index 4553aa0..03a6399 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -11,6 +11,7 @@ using SVSim.Database.Repositories.Pack; using SVSim.Database.Repositories.Story; using SVSim.Database.Repositories.Viewer; using SVSim.Database.Services; +using SVSim.Database.Services.Friend; using SVSim.EmulatedEntrypoint.Configuration; using SVSim.EmulatedEntrypoint.Extensions; using SVSim.EmulatedEntrypoint.Matching; @@ -112,6 +113,9 @@ public class Program builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + // Deck-code mint/resolve uses IMemoryCache for ephemeral (3-min TTL) storage; no DB // row, no migration. Singleton because the cache + RNG seam are process-wide. builder.Services.AddMemoryCache(); diff --git a/SVSim.UnitTests/Services/FriendServiceTests.cs b/SVSim.UnitTests/Services/FriendServiceTests.cs new file mode 100644 index 0000000..589c299 --- /dev/null +++ b/SVSim.UnitTests/Services/FriendServiceTests.cs @@ -0,0 +1,296 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Models; +using SVSim.Database.Services.Friend; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services; + +public class FriendServiceTests +{ + private static async Task SeedViewer(SVSimTestFactory factory, ulong steamId, string name = "Test Viewer") + => await factory.SeedViewerAsync(steamId: steamId, displayName: name); + + private static IFriendService Service(SVSimTestFactory factory, out IServiceScope scope) + { + scope = factory.Services.CreateScope(); + return scope.ServiceProvider.GetRequiredService(); + } + + private static SVSimDbContext Ctx(IServiceScope scope) => + scope.ServiceProvider.GetRequiredService(); + + [Test] + public async Task GetFriendsAsync_returns_empty_for_fresh_viewer() + { + using var factory = new SVSimTestFactory(); + long viewerId = await SeedViewer(factory, 76_561_198_000_000_001UL); + + var svc = Service(factory, out var scope); + using (scope) + { + var result = await svc.GetFriendsAsync(viewerId, default); + Assert.That(result.Friends, Is.Empty); + Assert.That(result.Count, Is.EqualTo(0)); + Assert.That(result.MaxCount, Is.EqualTo(110)); + } + } + + [Test] + public async Task GetFriendsAsync_returns_15_field_entries_for_seeded_friend() + { + using var factory = new SVSimTestFactory(); + long owner = await SeedViewer(factory, 76_561_198_000_000_002UL, "Owner"); + long friend = await SeedViewer(factory, 76_561_198_000_000_003UL, "Friend"); + + using (var scope = factory.Services.CreateScope()) + { + var ctx = Ctx(scope); + ctx.ViewerFriends.Add(new ViewerFriend { OwnerViewerId = owner, FriendViewerId = friend, CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + } + + var svc = Service(factory, out var scope2); + using (scope2) + { + var result = await svc.GetFriendsAsync(owner, default); + Assert.That(result.Friends, Has.Count.EqualTo(1)); + Assert.That(result.Count, Is.EqualTo(1)); + var entry = result.Friends[0]; + Assert.That(entry.ViewerId, Is.EqualTo((int)friend)); + Assert.That(entry.Name, Is.EqualTo("Friend")); + Assert.That(entry.DeviceType, Is.EqualTo("2")); + Assert.That(entry.MaxFriend, Is.EqualTo("110")); + Assert.That(entry.IsOfficial, Is.EqualTo("0")); + } + } + + [Test] + public async Task GetReceiveAppliesAsync_returns_incoming_with_correct_viewer_id_and_id() + { + using var factory = new SVSimTestFactory(); + long target = await SeedViewer(factory, 76_561_198_000_000_004UL, "Target"); + long sender = await SeedViewer(factory, 76_561_198_000_000_005UL, "Sender"); + + 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) + { + var result = await svc.GetReceiveAppliesAsync(target, default); + Assert.That(result.ReceiveApplies, Has.Count.EqualTo(1)); + Assert.That(result.ReceiveApplies[0].Id, Is.EqualTo(applyId)); + Assert.That(result.ReceiveApplies[0].ViewerId, Is.EqualTo((int)sender), "viewer_id is the SENDER's id"); + Assert.That(result.ReceiveApplies[0].Name, Is.EqualTo("Sender")); + } + } + + [Test] + public async Task GetSendAppliesAsync_returns_outgoing_with_remaining_count() + { + using var factory = new SVSimTestFactory(); + long sender = await SeedViewer(factory, 76_561_198_000_000_006UL, "Sender"); + long target = await SeedViewer(factory, 76_561_198_000_000_007UL, "Target"); + + 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) + { + var result = await svc.GetSendAppliesAsync(sender, default); + Assert.That(result.SendApplies, Has.Count.EqualTo(1)); + Assert.That(result.SendApplyMaxCount, Is.EqualTo(110)); + Assert.That(result.RemainingApplyCount, Is.EqualTo(109)); + Assert.That(result.SendApplies[0].ViewerId, Is.EqualTo((int)target), "viewer_id is the TARGET's id"); + } + } + + [Test] + public async Task GetPlayedTogetherAsync_returns_empty_for_fresh_viewer() + { + using var factory = new SVSimTestFactory(); + long viewerId = await SeedViewer(factory, 76_561_198_000_000_008UL); + + var svc = Service(factory, out var scope); + using (scope) + { + var result = await svc.GetPlayedTogetherAsync(viewerId, default); + Assert.That(result.Histories, Is.Empty); + } + } + + [Test] + public async Task GetPlayedTogetherAsync_computes_friend_status_NO_ACTION_for_stranger() + { + using var factory = new SVSimTestFactory(); + long me = await SeedViewer(factory, 76_561_198_000_000_009UL, "Me"); + long opponent = await SeedViewer(factory, 76_561_198_000_000_010UL, "Opponent"); + + using (var scope = factory.Services.CreateScope()) + { + var ctx = Ctx(scope); + ctx.ViewerPlayedTogethers.Add(new ViewerPlayedTogether + { + OwnerViewerId = me, OpponentViewerId = opponent, + PlayedAt = DateTime.UtcNow, PlayedMode = 1, BattleType = 2, DeckFormat = 3, TwoPickType = 4, + }); + await ctx.SaveChangesAsync(); + } + + var svc = Service(factory, out var scope2); + using (scope2) + { + var result = await svc.GetPlayedTogetherAsync(me, default); + Assert.That(result.Histories, Has.Count.EqualTo(1)); + Assert.That(result.Histories[0].FriendStatus, Is.EqualTo(0), "no apply, no friendship → NO_ACTION"); + Assert.That(result.Histories[0].FriendApplyId, Is.EqualTo(0)); + } + } + + [Test] + public async Task GetPlayedTogetherAsync_computes_friend_status_IS_FRIEND_for_friend() + { + using var factory = new SVSimTestFactory(); + long me = await SeedViewer(factory, 76_561_198_000_000_011UL, "Me"); + long friend = await SeedViewer(factory, 76_561_198_000_000_012UL, "Friend"); + + using (var scope = factory.Services.CreateScope()) + { + var ctx = Ctx(scope); + ctx.ViewerFriends.Add(new ViewerFriend { OwnerViewerId = me, FriendViewerId = friend, CreatedAt = DateTime.UtcNow }); + ctx.ViewerPlayedTogethers.Add(new ViewerPlayedTogether + { + OwnerViewerId = me, OpponentViewerId = friend, + PlayedAt = DateTime.UtcNow, PlayedMode = 1, BattleType = 2, DeckFormat = 3, TwoPickType = 4, + }); + await ctx.SaveChangesAsync(); + } + + var svc = Service(factory, out var scope2); + using (scope2) + { + var result = await svc.GetPlayedTogetherAsync(me, default); + Assert.That(result.Histories[0].FriendStatus, Is.EqualTo(1), "IS_FRIEND"); + } + } + + [Test] + public async Task GetPlayedTogetherAsync_computes_friend_status_IS_SEND_with_apply_id() + { + using var factory = new SVSimTestFactory(); + long me = await SeedViewer(factory, 76_561_198_000_000_013UL, "Me"); + long target = await SeedViewer(factory, 76_561_198_000_000_014UL, "Target"); + + int applyId; + using (var scope = factory.Services.CreateScope()) + { + var ctx = Ctx(scope); + var apply = new ViewerFriendApply { FromViewerId = me, ToViewerId = target, CreatedAt = DateTime.UtcNow }; + ctx.ViewerFriendApplies.Add(apply); + ctx.ViewerPlayedTogethers.Add(new ViewerPlayedTogether + { + OwnerViewerId = me, OpponentViewerId = target, + PlayedAt = DateTime.UtcNow, PlayedMode = 1, BattleType = 2, DeckFormat = 3, TwoPickType = 4, + }); + await ctx.SaveChangesAsync(); + applyId = apply.Id; + } + + var svc = Service(factory, out var scope2); + using (scope2) + { + var result = await svc.GetPlayedTogetherAsync(me, default); + Assert.That(result.Histories[0].FriendStatus, Is.EqualTo(2), "IS_SEND"); + Assert.That(result.Histories[0].FriendApplyId, Is.EqualTo(applyId)); + } + } + + [Test] + public async Task GetPlayedTogetherAsync_computes_friend_status_IS_RECEIVED_with_apply_id() + { + using var factory = new SVSimTestFactory(); + long me = await SeedViewer(factory, 76_561_198_000_000_015UL, "Me"); + long sender = await SeedViewer(factory, 76_561_198_000_000_016UL, "Sender"); + + int applyId; + using (var scope = factory.Services.CreateScope()) + { + var ctx = Ctx(scope); + var apply = new ViewerFriendApply { FromViewerId = sender, ToViewerId = me, CreatedAt = DateTime.UtcNow }; + ctx.ViewerFriendApplies.Add(apply); + ctx.ViewerPlayedTogethers.Add(new ViewerPlayedTogether + { + OwnerViewerId = me, OpponentViewerId = sender, + PlayedAt = DateTime.UtcNow, PlayedMode = 1, BattleType = 2, DeckFormat = 3, TwoPickType = 4, + }); + await ctx.SaveChangesAsync(); + applyId = apply.Id; + } + + var svc = Service(factory, out var scope2); + using (scope2) + { + var result = await svc.GetPlayedTogetherAsync(me, default); + Assert.That(result.Histories[0].FriendStatus, Is.EqualTo(3), "IS_RECEIVED"); + Assert.That(result.Histories[0].FriendApplyId, Is.EqualTo(applyId)); + } + } + + [Test] + public async Task SearchAsync_returns_entry_for_existing_viewer() + { + using var factory = new SVSimTestFactory(); + long me = await SeedViewer(factory, 76_561_198_000_000_017UL, "Me"); + long target = await SeedViewer(factory, 76_561_198_000_000_018UL, "Target"); + + var svc = Service(factory, out var scope); + using (scope) + { + var result = await svc.SearchAsync(me, (int)target, default); + Assert.That(result, Is.Not.Null); + Assert.That(result!.Name, Is.EqualTo("Target")); + } + } + + [Test] + public async Task SearchAsync_returns_null_for_self_search() + { + using var factory = new SVSimTestFactory(); + long me = await SeedViewer(factory, 76_561_198_000_000_019UL); + + var svc = Service(factory, out var scope); + using (scope) + { + var result = await svc.SearchAsync(me, (int)me, default); + Assert.That(result, Is.Null); + } + } + + [Test] + public async Task SearchAsync_returns_null_for_unknown_viewer_id() + { + using var factory = new SVSimTestFactory(); + long me = await SeedViewer(factory, 76_561_198_000_000_020UL); + + var svc = Service(factory, out var scope); + using (scope) + { + var result = await svc.SearchAsync(me, 999_999_999, default); + Assert.That(result, Is.Null); + } + } +}