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));
+ }
+}