diff --git a/SVSim.EmulatedEntrypoint/Controllers/FriendController.cs b/SVSim.EmulatedEntrypoint/Controllers/FriendController.cs new file mode 100644 index 0000000..527718c --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Controllers/FriendController.cs @@ -0,0 +1,185 @@ +using Microsoft.AspNetCore.Mvc; +using SVSim.Database.Services.Friend; +using SVSim.EmulatedEntrypoint.Models.Dtos.Friend; + +namespace SVSim.EmulatedEntrypoint.Controllers; + +/// +/// /friend/* — viewer-scoped friend system. 5 reads + 7 writes. All writes are +/// "silent rejection" on failure (cap exceeded, not addressed to caller, etc.) — the client +/// pass-through Parse()s don't differentiate. +/// +[Route("friend")] +public sealed class FriendController : SVSimController +{ + private readonly IFriendService _friend; + + public FriendController(IFriendService friend) => _friend = friend; + + [HttpPost("info")] + public async Task> Info(CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + var result = await _friend.GetFriendsAsync(viewerId, ct); + return new FriendInfoResponse + { + Friends = result.Friends.Select(ToWire).ToList(), + FriendCount = result.Count, + FriendMaxCount = result.MaxCount, + }; + } + + [HttpPost("receive_apply_info")] + public async Task> ReceiveApplyInfo(CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + var result = await _friend.GetReceiveAppliesAsync(viewerId, ct); + return new ReceiveApplyInfoResponse + { + ReceiveApplies = result.ReceiveApplies.Select(ToWire).ToList(), + ApproveApplyCount = result.ApproveApplyCount, + }; + } + + [HttpPost("send_apply_info")] + public async Task> SendApplyInfo(CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + var result = await _friend.GetSendAppliesAsync(viewerId, ct); + return new SendApplyInfoResponse + { + SendApplies = result.SendApplies.Select(ToWire).ToList(), + RemainingApplyCount = result.RemainingApplyCount, + SendApplyMaxCount = result.SendApplyMaxCount, + }; + } + + [HttpPost("played_together_info")] + public async Task> PlayedTogetherInfo(CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + var result = await _friend.GetPlayedTogetherAsync(viewerId, ct); + return new PlayedTogetherInfoResponse + { + Histories = result.Histories.Select(ToWire).ToList(), + }; + } + + [HttpPost("search_user")] + public async Task> SearchUser([FromBody] SearchUserRequest req, CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + var hit = await _friend.SearchAsync(viewerId, req.SearchViewerId, ct); + return new SearchUserResponse + { + UserInfo = hit is null ? new object() : ToWire(hit), + }; + } + + [HttpPost("send_apply")] + public async Task SendApply([FromBody] SendApplyRequest req, CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + await _friend.SendApplyAsync(viewerId, req.FriendId, ct); + return Ok(new { }); + } + + [HttpPost("approve_apply")] + public async Task ApproveApply([FromBody] ApplyIdRequest req, CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + await _friend.ApproveApplyAsync(viewerId, req.ApplyId, ct); + return Ok(new { }); + } + + [HttpPost("reject_apply")] + public async Task RejectApply([FromBody] ApplyIdRequest req, CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + await _friend.RejectApplyAsync(viewerId, req.ApplyId, ct); + return Ok(new { }); + } + + [HttpPost("cancel_apply")] + public async Task CancelApply([FromBody] ApplyIdRequest req, CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + await _friend.CancelApplyAsync(viewerId, req.ApplyId, ct); + return Ok(new { }); + } + + [HttpPost("reject_apply_all")] + public async Task RejectApplyAll(CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + await _friend.RejectAllAppliesAsync(viewerId, ct); + return Ok(new { }); + } + + [HttpPost("cancel_apply_all")] + public async Task CancelApplyAll(CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + await _friend.CancelAllAppliesAsync(viewerId, ct); + return Ok(new { }); + } + + [HttpPost("reject_friend")] + public async Task RejectFriend([FromBody] RejectFriendRequest req, CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + await _friend.RejectFriendAsync(viewerId, req.FriendId, ct); + return Ok(new { }); + } + + private static FriendEntryDto ToWire(FriendEntry e) => new() + { + DeviceType = e.DeviceType, + Name = e.Name, + CountryCode = e.CountryCode, + MaxFriend = e.MaxFriend, + LastPlayTime = e.LastPlayTime, + IsReceivedTwoPickMission = e.IsReceivedTwoPickMission, + Birth = e.Birth, + MissionChangeTime = e.MissionChangeTime, + MissionReceiveType = e.MissionReceiveType, + IsOfficial = e.IsOfficial, + IsOfficialMarkDisplayed = e.IsOfficialMarkDisplayed, + ViewerId = e.ViewerId, + Rank = e.Rank, + EmblemId = e.EmblemId, + DegreeId = e.DegreeId, + }; + + private static FriendApplyEntryDto ToWire(FriendApplyEntry e) => new() + { + Id = e.Id, + ViewerId = e.ViewerId, + Name = e.Name, + CountryCode = e.CountryCode, + Rank = e.Rank, + EmblemId = e.EmblemId, + DegreeId = e.DegreeId, + LastPlayTime = e.LastPlayTime, + CreateTime = e.CreateTime, + MissionType = e.MissionType, + }; + + private static PlayedTogetherEntryDto ToWire(PlayedTogetherEntry e) => new() + { + ViewerId = e.ViewerId, + Name = e.Name, + CountryCode = e.CountryCode, + Rank = e.Rank, + EmblemId = e.EmblemId, + DegreeId = e.DegreeId, + LastPlayTime = e.LastPlayTime, + PlayedTime = e.PlayedTime, + FriendStatus = e.FriendStatus, + FriendApplyId = e.FriendApplyId, + PlayedMode = e.PlayedMode, + BattleType = e.BattleType, + DeckFormat = e.DeckFormat, + TwoPickType = e.TwoPickType, + }; +} diff --git a/SVSim.UnitTests/Controllers/FriendControllerTests.cs b/SVSim.UnitTests/Controllers/FriendControllerTests.cs new file mode 100644 index 0000000..8a6eafb --- /dev/null +++ b/SVSim.UnitTests/Controllers/FriendControllerTests.cs @@ -0,0 +1,287 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Models; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Controllers; + +public class FriendControllerTests +{ + private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json"); + + private static async Task SeedViewer(SVSimTestFactory factory, ulong steamId, string name = "Test Viewer") + => await factory.SeedViewerAsync(steamId: steamId, displayName: name); + + [Test] + public async Task FriendInfo_returns_empty_friends_for_fresh_viewer() + { + using var factory = new SVSimTestFactory(); + long viewerId = await SeedViewer(factory, 76_561_198_000_010_001UL); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var response = await client.PostAsync("/friend/info", JsonBody("{}")); + var raw = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), raw); + + using var doc = JsonDocument.Parse(raw); + Assert.That(doc.RootElement.GetProperty("friends").GetArrayLength(), Is.EqualTo(0)); + Assert.That(doc.RootElement.GetProperty("friend_count").GetInt32(), Is.EqualTo(0)); + Assert.That(doc.RootElement.GetProperty("friend_max_count").GetInt32(), Is.EqualTo(110)); + } + + [Test] + public async Task ReceiveApplyInfo_returns_empty_for_fresh_viewer() + { + using var factory = new SVSimTestFactory(); + long viewerId = await SeedViewer(factory, 76_561_198_000_010_002UL); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var response = await client.PostAsync("/friend/receive_apply_info", JsonBody("{}")); + var raw = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), raw); + + using var doc = JsonDocument.Parse(raw); + Assert.That(doc.RootElement.GetProperty("receive_applies").GetArrayLength(), Is.EqualTo(0)); + Assert.That(doc.RootElement.GetProperty("approve_apply_count").GetInt32(), Is.EqualTo(0)); + } + + [Test] + public async Task SendApplyInfo_returns_empty_with_full_remaining_for_fresh_viewer() + { + using var factory = new SVSimTestFactory(); + long viewerId = await SeedViewer(factory, 76_561_198_000_010_003UL); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var response = await client.PostAsync("/friend/send_apply_info", JsonBody("{}")); + var raw = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), raw); + + using var doc = JsonDocument.Parse(raw); + Assert.That(doc.RootElement.GetProperty("send_applies").GetArrayLength(), Is.EqualTo(0)); + Assert.That(doc.RootElement.GetProperty("remaining_apply_count").GetInt32(), Is.EqualTo(110)); + Assert.That(doc.RootElement.GetProperty("send_apply_max_count").GetInt32(), Is.EqualTo(110)); + } + + [Test] + public async Task PlayedTogetherInfo_returns_empty_histories() + { + using var factory = new SVSimTestFactory(); + long viewerId = await SeedViewer(factory, 76_561_198_000_010_004UL); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var response = await client.PostAsync("/friend/played_together_info", JsonBody("{}")); + var raw = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), raw); + + using var doc = JsonDocument.Parse(raw); + Assert.That(doc.RootElement.GetProperty("histories").GetArrayLength(), Is.EqualTo(0)); + } + + [Test] + public async Task SearchUser_returns_empty_object_for_unknown_id() + { + using var factory = new SVSimTestFactory(); + long viewerId = await SeedViewer(factory, 76_561_198_000_010_005UL); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var response = await client.PostAsync("/friend/search_user", JsonBody("""{"search_viewer_id":999999}""")); + var raw = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), raw); + + using var doc = JsonDocument.Parse(raw); + var userInfo = doc.RootElement.GetProperty("user_info"); + Assert.That(userInfo.ValueKind, Is.EqualTo(JsonValueKind.Object)); + Assert.That(userInfo.EnumerateObject().Count(), Is.EqualTo(0), "no match → {}"); + } + + [Test] + public async Task SearchUser_returns_populated_user_info_for_existing_viewer() + { + using var factory = new SVSimTestFactory(); + long me = await SeedViewer(factory, 76_561_198_000_010_006UL); + long target = await SeedViewer(factory, 76_561_198_000_010_007UL, "Target"); + using var client = factory.CreateAuthenticatedClient(me); + + var response = await client.PostAsync("/friend/search_user", JsonBody($$"""{"search_viewer_id":{{(int)target}}}""")); + var raw = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), raw); + + using var doc = JsonDocument.Parse(raw); + var userInfo = doc.RootElement.GetProperty("user_info"); + Assert.That(userInfo.GetProperty("name").GetString(), Is.EqualTo("Target")); + Assert.That(userInfo.GetProperty("viewer_id").GetInt32(), Is.EqualTo((int)target)); + } + + [Test] + public async Task SendApply_persists_apply_row() + { + using var factory = new SVSimTestFactory(); + long me = await SeedViewer(factory, 76_561_198_000_010_008UL); + long target = await SeedViewer(factory, 76_561_198_000_010_009UL); + using var client = factory.CreateAuthenticatedClient(me); + + var response = await client.PostAsync("/friend/send_apply", JsonBody($$"""{"friend_id":{{(int)target}}}""")); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + using var verifyScope = factory.Services.CreateScope(); + var ctx = verifyScope.ServiceProvider.GetRequiredService(); + Assert.That(await ctx.ViewerFriendApplies.CountAsync(a => a.FromViewerId == me && a.ToViewerId == target), Is.EqualTo(1)); + } + + [Test] + public async Task ApproveApply_creates_friendship() + { + using var factory = new SVSimTestFactory(); + long me = await SeedViewer(factory, 76_561_198_000_010_010UL); + long sender = await SeedViewer(factory, 76_561_198_000_010_011UL); + + int applyId; + using (var scope = factory.Services.CreateScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var apply = new ViewerFriendApply { FromViewerId = sender, ToViewerId = me, CreatedAt = DateTime.UtcNow }; + ctx.ViewerFriendApplies.Add(apply); + await ctx.SaveChangesAsync(); + applyId = apply.Id; + } + + using var client = factory.CreateAuthenticatedClient(me); + var response = await client.PostAsync("/friend/approve_apply", JsonBody($$"""{"apply_id":{{applyId}}}""")); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + using var verifyScope = factory.Services.CreateScope(); + var ctx2 = verifyScope.ServiceProvider.GetRequiredService(); + Assert.That(await ctx2.ViewerFriends.CountAsync(), Is.EqualTo(2)); + } + + [Test] + public async Task RejectApply_deletes_incoming_apply() + { + using var factory = new SVSimTestFactory(); + long me = await SeedViewer(factory, 76_561_198_000_010_012UL); + long sender = await SeedViewer(factory, 76_561_198_000_010_013UL); + + int applyId; + using (var scope = factory.Services.CreateScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var apply = new ViewerFriendApply { FromViewerId = sender, ToViewerId = me, CreatedAt = DateTime.UtcNow }; + ctx.ViewerFriendApplies.Add(apply); + await ctx.SaveChangesAsync(); + applyId = apply.Id; + } + + using var client = factory.CreateAuthenticatedClient(me); + var response = await client.PostAsync("/friend/reject_apply", JsonBody($$"""{"apply_id":{{applyId}}}""")); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + using var verifyScope = factory.Services.CreateScope(); + Assert.That(await verifyScope.ServiceProvider.GetRequiredService().ViewerFriendApplies.CountAsync(), Is.EqualTo(0)); + } + + [Test] + public async Task CancelApply_deletes_outgoing_apply() + { + using var factory = new SVSimTestFactory(); + long me = await SeedViewer(factory, 76_561_198_000_010_014UL); + long target = await SeedViewer(factory, 76_561_198_000_010_015UL); + + int applyId; + using (var scope = factory.Services.CreateScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var apply = new ViewerFriendApply { FromViewerId = me, ToViewerId = target, CreatedAt = DateTime.UtcNow }; + ctx.ViewerFriendApplies.Add(apply); + await ctx.SaveChangesAsync(); + applyId = apply.Id; + } + + using var client = factory.CreateAuthenticatedClient(me); + var response = await client.PostAsync("/friend/cancel_apply", JsonBody($$"""{"apply_id":{{applyId}}}""")); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + using var verifyScope = factory.Services.CreateScope(); + Assert.That(await verifyScope.ServiceProvider.GetRequiredService().ViewerFriendApplies.CountAsync(), Is.EqualTo(0)); + } + + [Test] + public async Task RejectAllApplies_clears_incoming() + { + using var factory = new SVSimTestFactory(); + long me = await SeedViewer(factory, 76_561_198_000_010_016UL); + long sender = await SeedViewer(factory, 76_561_198_000_010_017UL); + + using (var scope = factory.Services.CreateScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.ViewerFriendApplies.Add(new ViewerFriendApply { FromViewerId = sender, ToViewerId = me, CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(me); + var response = await client.PostAsync("/friend/reject_apply_all", JsonBody("{}")); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + using var verifyScope = factory.Services.CreateScope(); + Assert.That(await verifyScope.ServiceProvider.GetRequiredService().ViewerFriendApplies.CountAsync(), Is.EqualTo(0)); + } + + [Test] + public async Task CancelAllApplies_clears_outgoing() + { + using var factory = new SVSimTestFactory(); + long me = await SeedViewer(factory, 76_561_198_000_010_018UL); + long target = await SeedViewer(factory, 76_561_198_000_010_019UL); + + using (var scope = factory.Services.CreateScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.ViewerFriendApplies.Add(new ViewerFriendApply { FromViewerId = me, ToViewerId = target, CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(me); + var response = await client.PostAsync("/friend/cancel_apply_all", JsonBody("{}")); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + using var verifyScope = factory.Services.CreateScope(); + Assert.That(await verifyScope.ServiceProvider.GetRequiredService().ViewerFriendApplies.CountAsync(), Is.EqualTo(0)); + } + + [Test] + public async Task RejectFriend_removes_both_friendship_rows() + { + using var factory = new SVSimTestFactory(); + long me = await SeedViewer(factory, 76_561_198_000_010_020UL); + long friend = await SeedViewer(factory, 76_561_198_000_010_021UL); + + using (var scope = factory.Services.CreateScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.ViewerFriends.Add(new ViewerFriend { OwnerViewerId = me, FriendViewerId = friend, CreatedAt = DateTime.UtcNow }); + ctx.ViewerFriends.Add(new ViewerFriend { OwnerViewerId = friend, FriendViewerId = me, CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(me); + var response = await client.PostAsync("/friend/reject_friend", JsonBody($$"""{"friend_id":{{(int)friend}}}""")); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + using var verifyScope = factory.Services.CreateScope(); + Assert.That(await verifyScope.ServiceProvider.GetRequiredService().ViewerFriends.CountAsync(), Is.EqualTo(0)); + } + + [Test] + public async Task FriendInfo_without_auth_returns_401() + { + using var factory = new SVSimTestFactory(); + var client = factory.CreateClient(); + + var response = await client.PostAsync("/friend/info", JsonBody("{}")); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } +}