From f2b996a593f25ed281dee71ab2015e60b00933b3 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 27 Jun 2026 14:27:13 -0400 Subject: [PATCH] feat(guild): change_role atomic transfer + friend_list - ChangeRoleAsync: Leader-only role management with SubLeader cap check. Atomic leader transfer loads both member rows + Guild.LeaderViewerId in one _db.SaveChangesAsync call (no split saves). Emits ChangeLeader (6) or ChangeSubLeader (7) chat events. Same-role is no-op / returns Ok. - /guild/change_role controller: calls ChangeRoleAsync, returns full updated member list (GuildChangeRoleResponse.Members[]). - /guild/friend_list controller: calls IFriendService.GetFriendsAsync, then annotates each friend with is_join_guild from Viewer.GuildId column. - IGuildRepository.UpdateLeaderViewerIdAsync: added for completeness even though the atomic transfer path bypasses it via direct EF mutation. - Tests: 8 GuildServiceChangeRoleTests (role changes, cap, atomic transfer, no-op, permission); 3 GuildChangeRoleFriendListFlowTests (HTTP roundtrip, is_join_guild); 2 GuildWireShape tests (literal JSON shape); 1508 pass. Co-Authored-By: Claude Sonnet 4.6 --- .../Repositories/Guild/GuildRepository.cs | 8 + .../Repositories/Guild/IGuildRepository.cs | 3 + SVSim.Database/Services/Guild/GuildService.cs | 56 ++- .../Controllers/GuildController.cs | 60 +++- .../GuildChangeRoleFriendListFlowTests.cs | 266 ++++++++++++++ .../Guild/GuildServiceChangeRoleTests.cs | 337 ++++++++++++++++++ SVSim.UnitTests/Wire/GuildWireShape.cs | 110 ++++++ 7 files changed, 833 insertions(+), 7 deletions(-) create mode 100644 SVSim.UnitTests/Integration/Guild/GuildChangeRoleFriendListFlowTests.cs create mode 100644 SVSim.UnitTests/Services/Guild/GuildServiceChangeRoleTests.cs diff --git a/SVSim.Database/Repositories/Guild/GuildRepository.cs b/SVSim.Database/Repositories/Guild/GuildRepository.cs index d874e332..77383418 100644 --- a/SVSim.Database/Repositories/Guild/GuildRepository.cs +++ b/SVSim.Database/Repositories/Guild/GuildRepository.cs @@ -95,4 +95,12 @@ public sealed class GuildRepository : IGuildRepository guild.EmblemId = emblemId; await _db.SaveChangesAsync(ct); } + + public async Task UpdateLeaderViewerIdAsync(int guildId, long newLeaderViewerId, CancellationToken ct = default) + { + var guild = await _db.Guilds.FirstOrDefaultAsync(g => g.GuildId == guildId, ct); + if (guild is null) return; + guild.LeaderViewerId = newLeaderViewerId; + await _db.SaveChangesAsync(ct); + } } diff --git a/SVSim.Database/Repositories/Guild/IGuildRepository.cs b/SVSim.Database/Repositories/Guild/IGuildRepository.cs index ac5a7446..d2d9b9eb 100644 --- a/SVSim.Database/Repositories/Guild/IGuildRepository.cs +++ b/SVSim.Database/Repositories/Guild/IGuildRepository.cs @@ -33,4 +33,7 @@ public interface IGuildRepository /// Overwrites the emblem_id field. Task UpdateEmblemAsync(int guildId, long emblemId, CancellationToken ct = default); + + /// Updates LeaderViewerId on the Guild row — used by ChangeRoleAsync atomic leader transfer. + Task UpdateLeaderViewerIdAsync(int guildId, long newLeaderViewerId, CancellationToken ct = default); } diff --git a/SVSim.Database/Services/Guild/GuildService.cs b/SVSim.Database/Services/Guild/GuildService.cs index 1df4f18d..5efc540f 100644 --- a/SVSim.Database/Services/Guild/GuildService.cs +++ b/SVSim.Database/Services/Guild/GuildService.cs @@ -403,8 +403,60 @@ public sealed class GuildService : IGuildService return GuildOpResult.Ok; } - public Task ChangeRoleAsync(long callerViewerId, long targetViewerId, int newRoleId, CancellationToken ct = default) - => throw new NotImplementedException(); + public async Task ChangeRoleAsync(long callerViewerId, long targetViewerId, int newRoleId, CancellationToken ct = default) + { + if (newRoleId is < 0 or > 2) return new(GuildOpResultCode.InvalidRoleTransition); + var newRole = (GuildRole)newRoleId; + + var caller = await _members.GetMembershipAsync(callerViewerId, ct); + if (caller is null) return new(GuildOpResultCode.NotInGuild); + if (caller.Role != GuildRole.Leader) return new(GuildOpResultCode.PermissionDenied); + + var target = await _members.GetMembershipAsync(targetViewerId, ct); + if (target is null || target.GuildId != caller.GuildId) return new(GuildOpResultCode.TargetNotInGuild); + + // No-op: same role. + if (target.Role == newRole) return GuildOpResult.Ok; + + // SubLeader cap check. + if (newRole == GuildRole.SubLeader) + { + var subs = await _members.CountByGuildAndRoleAsync(caller.GuildId, GuildRole.SubLeader, ct); + var cfg = _config.Get(); + if (subs >= cfg.MaxSubLeaderNum) return new(GuildOpResultCode.SubLeaderCapReached); + } + + if (newRole == GuildRole.Leader) + { + // Atomic leader transfer: load both member rows and the guild row, mutate all + // in one scope, then save once — no individual repository SaveChanges calls. + var guildRow = await _db.Guilds.FirstOrDefaultAsync(g => g.GuildId == caller.GuildId, ct); + var targetMember = await _db.GuildMembers + .FirstOrDefaultAsync(m => m.GuildId == caller.GuildId && m.ViewerId == targetViewerId, ct); + var callerMember = await _db.GuildMembers + .FirstOrDefaultAsync(m => m.GuildId == caller.GuildId && m.ViewerId == callerViewerId, ct); + + if (guildRow is null || targetMember is null || callerMember is null) + return new(GuildOpResultCode.TargetNotInGuild); + + targetMember.Role = GuildRole.Leader; + callerMember.Role = GuildRole.Regular; + guildRow.LeaderViewerId = targetViewerId; + + await _db.SaveChangesAsync(ct); + await _chat.EmitSystemEventAsync(caller.GuildId, callerViewerId, GuildChatMessageType.ChangeLeader, + body: targetViewerId.ToString(), ct); + } + else + { + await _members.UpdateRoleAsync(caller.GuildId, targetViewerId, newRole, ct); + if (newRole == GuildRole.SubLeader) + await _chat.EmitSystemEventAsync(caller.GuildId, callerViewerId, GuildChatMessageType.ChangeSubLeader, + body: targetViewerId.ToString(), ct); + } + + return GuildOpResult.Ok; + } public Task> ListPendingInvitesForMeAsync(long viewerId, CancellationToken ct = default) => _invites.ListPendingForInviteeAsync(viewerId, ct); diff --git a/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs b/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs index 2f99184b..b0ddc692 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs @@ -5,6 +5,7 @@ using SVSim.Database.Entities.Guild; using SVSim.Database.Models; using SVSim.Database.Models.Config; using SVSim.Database.Services; +using SVSim.Database.Services.Friend; using SVSim.Database.Services.Guild; using SVSim.EmulatedEntrypoint.Models.Dtos.Common; using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Guild; @@ -20,15 +21,17 @@ public sealed class GuildController : SVSimController private readonly IGuildService _guild; private readonly IGameConfigService _configs; private readonly SVSimDbContext _db; + private readonly IFriendService _friends; // Wire error code returned when guild operations fail (non-1 result_code). private const int GuildErrorResultCode = 2; - public GuildController(IGuildService guild, IGameConfigService configs, SVSimDbContext db) + public GuildController(IGuildService guild, IGameConfigService configs, SVSimDbContext db, IFriendService friends) { _guild = guild; _configs = configs; _db = db; + _friends = friends; } [HttpPost("info")] @@ -171,8 +174,40 @@ public sealed class GuildController : SVSimController } [HttpPost("friend_list")] - public Task> FriendList([FromBody] BaseRequest _, CancellationToken ct) - => Task.FromResult>(new GuildFriendListResponse { Friends = new() }); + public async Task> FriendList([FromBody] BaseRequest _, CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + + var friendInfo = await _friends.GetFriendsAsync(viewerId, ct); + if (friendInfo.Friends.Count == 0) + return new GuildFriendListResponse { Friends = new() }; + + // For each friend, determine if they are already in a guild (is_join_guild). + var friendViewerIds = friendInfo.Friends.Select(f => (long)f.ViewerId).ToList(); + var viewerRows = await _db.Viewers + .AsNoTracking() + .Where(v => friendViewerIds.Contains(v.Id)) + .Select(v => new { v.Id, v.GuildId }) + .ToDictionaryAsync(v => v.Id, v => v.GuildId, ct); + + var candidates = new List(friendInfo.Friends.Count); + foreach (var f in friendInfo.Friends) + { + viewerRows.TryGetValue(f.ViewerId, out var guildId); + candidates.Add(new GuildInviteCandidateDto + { + ViewerId = f.ViewerId, + Name = f.Name, + EmblemId = f.EmblemId, + CountryCode = f.CountryCode, + Rank = f.Rank, + DegreeId = f.DegreeId, + IsJoinGuild = guildId is not null, + }); + } + + return new GuildFriendListResponse { Friends = candidates }; + } [HttpPost("invite_user_list")] public async Task> InviteUserList([FromBody] BaseRequest _, CancellationToken ct) @@ -315,8 +350,23 @@ public sealed class GuildController : SVSimController } [HttpPost("change_role")] - public Task> ChangeRole([FromBody] GuildChangeRoleRequest req, CancellationToken ct) - => Task.FromResult>(new GuildChangeRoleResponse { Members = new() }); + public async Task> ChangeRole([FromBody] GuildChangeRoleRequest req, CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + var r = await _guild.ChangeRoleAsync(viewerId, req.TargetViewerId, req.RoleId, ct); + if (!r.IsOk) return WireError(); + + // Load caller's membership to get the guild id, then return the full updated member list. + var membership = await _db.GuildMembers.FirstOrDefaultAsync(m => m.ViewerId == viewerId, ct); + if (membership is null) return WireError(); + + var members = await _db.GuildMembers + .Where(m => m.GuildId == membership.GuildId) + .ToListAsync(ct); + + var memberDtos = await ToMemberDtoListAsync(members, viewerId, ct); + return new GuildChangeRoleResponse { Members = memberDtos }; + } // ===== Private helpers ===== diff --git a/SVSim.UnitTests/Integration/Guild/GuildChangeRoleFriendListFlowTests.cs b/SVSim.UnitTests/Integration/Guild/GuildChangeRoleFriendListFlowTests.cs new file mode 100644 index 00000000..9e0f6d15 --- /dev/null +++ b/SVSim.UnitTests/Integration/Guild/GuildChangeRoleFriendListFlowTests.cs @@ -0,0 +1,266 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Entities.Guild; +using SVSim.Database.Models; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Integration.Guild; + +/// +/// Integration tests for: +/// /guild/change_role — full HTTP round-trip, response shape, atomic transfer +/// /guild/friend_list — response shape with is_join_guild annotation +/// +public class GuildChangeRoleFriendListFlowTests +{ + private const string Vid = "0"; + private const int Sid = 0; + private const string Stk = ""; + + private static object BaseReq() => new { viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }; + + private static int GetStringifiedInt(JsonElement el, string prop) + => int.Parse(el.GetProperty(prop).GetString()!); + + private static long GetStringifiedLong(JsonElement el, string prop) + => long.Parse(el.GetProperty(prop).GetString()!); + + // ─── Helpers ───────────────────────────────────────────────────────────────── + + private static async Task CreateGuildAndGetIdAsync(HttpClient client, string name, int joinCondition = 1) + { + var createResp = await client.PostAsync("/guild/create", + JsonContent.Create(new + { + guild_name = name, + activity = 1, + join_condition = joinCondition, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk + })); + Assert.That(createResp.IsSuccessStatusCode, Is.True, + $"create failed: {await createResp.Content.ReadAsStringAsync()}"); + + var infoResp = await client.PostAsync("/guild/info", + JsonContent.Create(BaseReq())); + var infoJson = await infoResp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(infoJson); + return GetStringifiedInt(doc.RootElement.GetProperty("guild").GetProperty("detail"), "guild_id"); + } + + private static async Task AddMemberDirectlyAsync(SVSimTestFactory factory, int guildId, long viewerId, GuildRole role) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.GuildMembers.Add(new GuildMember + { + GuildId = guildId, + ViewerId = viewerId, + Role = role, + JoinedAt = DateTime.UtcNow, + }); + var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId); + viewer.GuildId = guildId; + await db.SaveChangesAsync(); + } + + // ─── /guild/change_role ─────────────────────────────────────────────────────── + + [Test] + public async Task ChangeRole_leader_promotes_regular_returns_full_member_list() + { + using var factory = new SVSimTestFactory(); + long leaderId = await factory.SeedViewerAsync(76_561_198_500_000_001UL, "CrfLeader1"); + long memberId = await factory.SeedViewerAsync(76_561_198_500_000_002UL, "CrfMember1"); + + using var clientLeader = factory.CreateAuthenticatedClient(leaderId); + int guildId = await CreateGuildAndGetIdAsync(clientLeader, "CrfGuild1"); + await AddMemberDirectlyAsync(factory, guildId, memberId, GuildRole.Regular); + + // POST /guild/change_role — promote memberId to SubLeader (role_id=2) + var resp = await clientLeader.PostAsync("/guild/change_role", + JsonContent.Create(new + { + target_viewer_id = memberId, + role_id = 2, // SubLeader + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk + })); + var body = await resp.Content.ReadAsStringAsync(); + Assert.That(resp.IsSuccessStatusCode, Is.True, $"HTTP failed: {body}"); + + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + // Must not be a wire error. + if (root.TryGetProperty("result_code", out var rc)) + Assert.That(rc.GetInt32(), Is.Not.EqualTo(2), $"change_role returned error: {body}"); + + // Response must include "members" array. + Assert.That(root.TryGetProperty("members", out var members), Is.True, + $"response must have 'members' array: {body}"); + Assert.That(members.ValueKind, Is.EqualTo(JsonValueKind.Array)); + + // Should have 2 members (leader + promoted member). + Assert.That(members.GetArrayLength(), Is.EqualTo(2), + "members list must contain both leader and the promoted member"); + + // Find the promoted member entry. + var promoted = members.EnumerateArray() + .FirstOrDefault(m => GetStringifiedLong(m, "viewer_id") == memberId); + Assert.That(promoted.ValueKind, Is.Not.EqualTo(JsonValueKind.Undefined), + "Promoted member must be in the members list"); + Assert.That(GetStringifiedInt(promoted, "role"), Is.EqualTo(2), + "Promoted member's role must be 2 (SubLeader)"); + + // viewer_id must be stringified. + Assert.That(promoted.GetProperty("viewer_id").ValueKind, Is.EqualTo(JsonValueKind.String), + "viewer_id in members must be a string (stringified)"); + } + + [Test] + public async Task ChangeRole_atomic_leader_transfer_response_reflects_new_roles() + { + using var factory = new SVSimTestFactory(); + long leaderId = await factory.SeedViewerAsync(76_561_198_500_000_003UL, "CrfLeader2"); + long targetId = await factory.SeedViewerAsync(76_561_198_500_000_004UL, "CrfTarget2"); + + using var clientLeader = factory.CreateAuthenticatedClient(leaderId); + int guildId = await CreateGuildAndGetIdAsync(clientLeader, "CrfGuild2"); + await AddMemberDirectlyAsync(factory, guildId, targetId, GuildRole.Regular); + + // Transfer leadership (role_id=1). + var resp = await clientLeader.PostAsync("/guild/change_role", + JsonContent.Create(new + { + target_viewer_id = targetId, + role_id = 1, // Leader + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk + })); + var body = await resp.Content.ReadAsStringAsync(); + Assert.That(resp.IsSuccessStatusCode, Is.True, $"HTTP failed: {body}"); + + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + if (root.TryGetProperty("result_code", out var rc)) + Assert.That(rc.GetInt32(), Is.Not.EqualTo(2), $"change_role returned error: {body}"); + + // Both members must be in the response. + Assert.That(root.TryGetProperty("members", out var members), Is.True); + Assert.That(members.GetArrayLength(), Is.EqualTo(2)); + + var newLeaderEntry = members.EnumerateArray() + .FirstOrDefault(m => GetStringifiedLong(m, "viewer_id") == targetId); + Assert.That(GetStringifiedInt(newLeaderEntry, "role"), Is.EqualTo(1), "Target must have role=1 (Leader)"); + + var formerLeaderEntry = members.EnumerateArray() + .FirstOrDefault(m => GetStringifiedLong(m, "viewer_id") == leaderId); + Assert.That(GetStringifiedInt(formerLeaderEntry, "role"), Is.EqualTo(0), "Former leader must have role=0 (Regular)"); + } + + // ─── /guild/friend_list ─────────────────────────────────────────────────────── + + [Test] + public async Task FriendList_returns_friends_array_with_is_join_guild_field() + { + using var factory = new SVSimTestFactory(); + long viewerA = await factory.SeedViewerAsync(76_561_198_500_000_005UL, "CrfViewerA"); + long viewerB = await factory.SeedViewerAsync(76_561_198_500_000_006UL, "CrfViewerB"); + + // Make viewerB a friend of viewerA by inserting both directions directly. + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.ViewerFriends.Add(new ViewerFriend { OwnerViewerId = viewerA, FriendViewerId = viewerB, CreatedAt = DateTime.UtcNow }); + db.ViewerFriends.Add(new ViewerFriend { OwnerViewerId = viewerB, FriendViewerId = viewerA, CreatedAt = DateTime.UtcNow }); + await db.SaveChangesAsync(); + } + + using var clientA = factory.CreateAuthenticatedClient(viewerA); + var resp = await clientA.PostAsync("/guild/friend_list", JsonContent.Create(BaseReq())); + var body = await resp.Content.ReadAsStringAsync(); + Assert.That(resp.IsSuccessStatusCode, Is.True, $"HTTP failed: {body}"); + + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + if (root.TryGetProperty("result_code", out var rc)) + Assert.That(rc.GetInt32(), Is.Not.EqualTo(2), $"friend_list returned error: {body}"); + + // Response must have "friends" array. + Assert.That(root.TryGetProperty("friends", out var friends), Is.True, + $"response must have 'friends' key: {body}"); + Assert.That(friends.ValueKind, Is.EqualTo(JsonValueKind.Array)); + Assert.That(friends.GetArrayLength(), Is.EqualTo(1), "viewerA has 1 friend"); + + var friend = friends[0]; + Assert.That(GetStringifiedLong(friend, "viewer_id"), Is.EqualTo(viewerB)); + + // is_join_guild must be present — viewerB has no guild, so false. + Assert.That(friend.TryGetProperty("is_join_guild", out var isJoin), Is.True, + "is_join_guild must be present in friend_list entries"); + Assert.That(isJoin.GetBoolean(), Is.False, "viewerB is not in a guild so is_join_guild must be false"); + } + + [Test] + public async Task FriendList_marks_friend_in_guild_as_is_join_guild_true() + { + using var factory = new SVSimTestFactory(); + long viewerA = await factory.SeedViewerAsync(76_561_198_500_000_007UL, "CrfViewerC"); + long viewerB = await factory.SeedViewerAsync(76_561_198_500_000_008UL, "CrfViewerD"); + + // viewerB creates a guild. + using var clientB = factory.CreateAuthenticatedClient(viewerB); + await clientB.PostAsync("/guild/create", + JsonContent.Create(new + { + guild_name = "CrfGuildB", + activity = 1, + join_condition = 1, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk + })); + + // Make viewerA ↔ viewerB friends. + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.ViewerFriends.Add(new ViewerFriend { OwnerViewerId = viewerA, FriendViewerId = viewerB, CreatedAt = DateTime.UtcNow }); + db.ViewerFriends.Add(new ViewerFriend { OwnerViewerId = viewerB, FriendViewerId = viewerA, CreatedAt = DateTime.UtcNow }); + await db.SaveChangesAsync(); + } + + using var clientA = factory.CreateAuthenticatedClient(viewerA); + var resp = await clientA.PostAsync("/guild/friend_list", JsonContent.Create(BaseReq())); + var body = await resp.Content.ReadAsStringAsync(); + Assert.That(resp.IsSuccessStatusCode, Is.True); + + using var doc = JsonDocument.Parse(body); + var friends = doc.RootElement.GetProperty("friends"); + Assert.That(friends.GetArrayLength(), Is.EqualTo(1)); + + var friend = friends[0]; + Assert.That(friend.GetProperty("is_join_guild").GetBoolean(), Is.True, + "viewerB is in a guild so is_join_guild must be true"); + } + + [Test] + public async Task FriendList_empty_for_viewer_with_no_friends() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(76_561_198_500_000_009UL, "CrfLoneViewer"); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var resp = await client.PostAsync("/guild/friend_list", JsonContent.Create(BaseReq())); + var body = await resp.Content.ReadAsStringAsync(); + Assert.That(resp.IsSuccessStatusCode, Is.True); + + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("result_code", out var rc)) + Assert.That(rc.GetInt32(), Is.Not.EqualTo(2), $"friend_list error: {body}"); + + Assert.That(doc.RootElement.TryGetProperty("friends", out var friends), Is.True); + Assert.That(friends.GetArrayLength(), Is.EqualTo(0), "No friends means empty array"); + } +} diff --git a/SVSim.UnitTests/Services/Guild/GuildServiceChangeRoleTests.cs b/SVSim.UnitTests/Services/Guild/GuildServiceChangeRoleTests.cs new file mode 100644 index 00000000..bd54d4d0 --- /dev/null +++ b/SVSim.UnitTests/Services/Guild/GuildServiceChangeRoleTests.cs @@ -0,0 +1,337 @@ +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Entities.Guild; +using SVSim.Database.Services.Guild; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services.Guild; + +public class GuildServiceChangeRoleTests +{ + // ────────────────────────────────────────────────────────────────────────── + // Helper: add a member row + set Viewer.GuildId + // ────────────────────────────────────────────────────────────────────────── + private static async Task AddMemberDirectlyAsync( + SVSimTestFactory factory, + int guildId, + long viewerId, + GuildRole role = GuildRole.Regular) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.GuildMembers.Add(new GuildMember + { + GuildId = guildId, + ViewerId = viewerId, + Role = role, + JoinedAt = DateTime.UtcNow, + }); + var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId); + viewer.GuildId = guildId; + await db.SaveChangesAsync(); + } + + // ────────────────────────────────────────────────────────────────────────── + // ChangeRoleAsync tests + // ────────────────────────────────────────────────────────────────────────── + + [Test] + public async Task ChangeRoleAsync_leader_promotes_regular_to_subleader_succeeds() + { + using var factory = new SVSimTestFactory(); + var leaderId = await factory.SeedViewerAsync(76_561_198_300_000_001UL, "CrLeader1"); + var memberId = await factory.SeedViewerAsync(76_561_198_300_000_002UL, "CrMember1"); + + int guildId; + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.CreateAsync(leaderId, new("CrGuild1", (int)GuildActivity.All, (int)GuildJoinCondition.Free)); + Assert.That(r.IsOk, Is.True); + guildId = r.GuildId!.Value; + } + + await AddMemberDirectlyAsync(factory, guildId, memberId, GuildRole.Regular); + + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.ChangeRoleAsync(leaderId, memberId, (int)GuildRole.SubLeader); + Assert.That(r.IsOk, Is.True); + } + + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var m = await db.GuildMembers.FirstOrDefaultAsync(m => m.GuildId == guildId && m.ViewerId == memberId); + Assert.That(m, Is.Not.Null); + Assert.That(m!.Role, Is.EqualTo(GuildRole.SubLeader), "Member must be promoted to SubLeader"); + + // Leader's role must be unchanged. + var leader = await db.GuildMembers.FirstOrDefaultAsync(m => m.GuildId == guildId && m.ViewerId == leaderId); + Assert.That(leader!.Role, Is.EqualTo(GuildRole.Leader), "Leader role must be unchanged after promotion"); + } + } + + [Test] + public async Task ChangeRoleAsync_promote_blocked_when_subleader_cap_reached() + { + using var factory = new SVSimTestFactory(); + var leaderId = await factory.SeedViewerAsync(76_561_198_300_000_003UL, "CrLeader2"); + var sub1Id = await factory.SeedViewerAsync(76_561_198_300_000_004UL, "CrSub1"); + var sub2Id = await factory.SeedViewerAsync(76_561_198_300_000_005UL, "CrSub2"); + var regularId = await factory.SeedViewerAsync(76_561_198_300_000_006UL, "CrReg1"); + + int guildId; + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.CreateAsync(leaderId, new("CrGuild2", (int)GuildActivity.All, (int)GuildJoinCondition.Free)); + Assert.That(r.IsOk, Is.True); + guildId = r.GuildId!.Value; + } + + // Seed 2 existing subleaders (MaxSubLeaderNum = 2 by default). + await AddMemberDirectlyAsync(factory, guildId, sub1Id, GuildRole.SubLeader); + await AddMemberDirectlyAsync(factory, guildId, sub2Id, GuildRole.SubLeader); + await AddMemberDirectlyAsync(factory, guildId, regularId, GuildRole.Regular); + + // Attempt to promote regularId to SubLeader — should fail. + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.ChangeRoleAsync(leaderId, regularId, (int)GuildRole.SubLeader); + Assert.That(r.Code, Is.EqualTo(GuildOpResultCode.SubLeaderCapReached), + "Promoting to SubLeader beyond cap must be blocked"); + } + + // regularId's role must still be Regular. + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var m = await db.GuildMembers.FirstOrDefaultAsync(m => m.GuildId == guildId && m.ViewerId == regularId); + Assert.That(m!.Role, Is.EqualTo(GuildRole.Regular), "Role must not change on cap-blocked promote"); + } + } + + [Test] + public async Task ChangeRoleAsync_leader_demotes_subleader_to_regular_succeeds() + { + using var factory = new SVSimTestFactory(); + var leaderId = await factory.SeedViewerAsync(76_561_198_300_000_007UL, "CrLeader3"); + var subId = await factory.SeedViewerAsync(76_561_198_300_000_008UL, "CrSub2"); + + int guildId; + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.CreateAsync(leaderId, new("CrGuild3", (int)GuildActivity.All, (int)GuildJoinCondition.Free)); + Assert.That(r.IsOk, Is.True); + guildId = r.GuildId!.Value; + } + + await AddMemberDirectlyAsync(factory, guildId, subId, GuildRole.SubLeader); + + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.ChangeRoleAsync(leaderId, subId, (int)GuildRole.Regular); + Assert.That(r.IsOk, Is.True); + } + + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var m = await db.GuildMembers.FirstOrDefaultAsync(m => m.GuildId == guildId && m.ViewerId == subId); + Assert.That(m!.Role, Is.EqualTo(GuildRole.Regular), "SubLeader must be demoted to Regular"); + } + } + + [Test] + public async Task ChangeRoleAsync_atomic_leader_transfer_sets_target_leader_and_caller_regular() + { + using var factory = new SpyGuildChatFactory(); + var leaderId = await factory.SeedViewerAsync(76_561_198_300_000_009UL, "CrLeader4"); + var targetId = await factory.SeedViewerAsync(76_561_198_300_000_010UL, "CrTarget4"); + + int guildId; + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.CreateAsync(leaderId, new("CrGuild4", (int)GuildActivity.All, (int)GuildJoinCondition.Free)); + Assert.That(r.IsOk, Is.True); + guildId = r.GuildId!.Value; + } + + await AddMemberDirectlyAsync(factory, guildId, targetId, GuildRole.Regular); + + // Leader transfers leadership to targetId. + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.ChangeRoleAsync(leaderId, targetId, (int)GuildRole.Leader); + Assert.That(r.IsOk, Is.True); + } + + // Verify roles + guild.LeaderViewerId atomically committed. + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + + var targetMember = await db.GuildMembers.FirstOrDefaultAsync(m => m.GuildId == guildId && m.ViewerId == targetId); + Assert.That(targetMember!.Role, Is.EqualTo(GuildRole.Leader), "Target must become Leader"); + + var callerMember = await db.GuildMembers.FirstOrDefaultAsync(m => m.GuildId == guildId && m.ViewerId == leaderId); + Assert.That(callerMember!.Role, Is.EqualTo(GuildRole.Regular), "Caller must become Regular after transfer"); + + var guild = await db.Guilds.FirstAsync(g => g.GuildId == guildId); + Assert.That(guild.LeaderViewerId, Is.EqualTo(targetId), "Guild.LeaderViewerId must point to new leader"); + } + + // ChangeLeader (6) event must have been emitted. + var leaderEmissions = factory.ChatSpy.EmissionsWithBody + .Where(e => e.Type == GuildChatMessageType.ChangeLeader && e.GuildId == guildId) + .ToList(); + Assert.That(leaderEmissions, Has.Count.GreaterThanOrEqualTo(1), "ChangeLeader event must be emitted"); + Assert.That(leaderEmissions[0].Body, Does.Contain(targetId.ToString()), + "ChangeLeader body must contain new leader's viewer_id"); + } + + [Test] + public async Task ChangeRoleAsync_atomic_leader_transfer_from_subleader_target() + { + using var factory = new SpyGuildChatFactory(); + var leaderId = await factory.SeedViewerAsync(76_561_198_300_000_011UL, "CrLeader5"); + var subId = await factory.SeedViewerAsync(76_561_198_300_000_012UL, "CrSub5"); + + int guildId; + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.CreateAsync(leaderId, new("CrGuild5", (int)GuildActivity.All, (int)GuildJoinCondition.Free)); + Assert.That(r.IsOk, Is.True); + guildId = r.GuildId!.Value; + } + + await AddMemberDirectlyAsync(factory, guildId, subId, GuildRole.SubLeader); + + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.ChangeRoleAsync(leaderId, subId, (int)GuildRole.Leader); + Assert.That(r.IsOk, Is.True); + } + + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var target = await db.GuildMembers.FirstAsync(m => m.GuildId == guildId && m.ViewerId == subId); + Assert.That(target.Role, Is.EqualTo(GuildRole.Leader)); + var caller = await db.GuildMembers.FirstAsync(m => m.GuildId == guildId && m.ViewerId == leaderId); + Assert.That(caller.Role, Is.EqualTo(GuildRole.Regular)); + var guild = await db.Guilds.FirstAsync(g => g.GuildId == guildId); + Assert.That(guild.LeaderViewerId, Is.EqualTo(subId)); + } + } + + [Test] + public async Task ChangeRoleAsync_non_leader_caller_rejected() + { + using var factory = new SVSimTestFactory(); + var leaderId = await factory.SeedViewerAsync(76_561_198_300_000_013UL, "CrLeader6"); + var subId = await factory.SeedViewerAsync(76_561_198_300_000_014UL, "CrSub6"); + var regularId = await factory.SeedViewerAsync(76_561_198_300_000_015UL, "CrReg6"); + + int guildId; + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.CreateAsync(leaderId, new("CrGuild6", (int)GuildActivity.All, (int)GuildJoinCondition.Free)); + Assert.That(r.IsOk, Is.True); + guildId = r.GuildId!.Value; + } + + await AddMemberDirectlyAsync(factory, guildId, subId, GuildRole.SubLeader); + await AddMemberDirectlyAsync(factory, guildId, regularId, GuildRole.Regular); + + // SubLeader tries to change someone's role — must be rejected. + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.ChangeRoleAsync(subId, regularId, (int)GuildRole.SubLeader); + Assert.That(r.Code, Is.EqualTo(GuildOpResultCode.PermissionDenied), + "Non-leader must not be able to change roles"); + } + } + + [Test] + public async Task ChangeRoleAsync_same_role_is_noop_returns_ok() + { + using var factory = new SVSimTestFactory(); + var leaderId = await factory.SeedViewerAsync(76_561_198_300_000_016UL, "CrLeader7"); + var subId = await factory.SeedViewerAsync(76_561_198_300_000_017UL, "CrSub7"); + + int guildId; + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.CreateAsync(leaderId, new("CrGuild7", (int)GuildActivity.All, (int)GuildJoinCondition.Free)); + Assert.That(r.IsOk, Is.True); + guildId = r.GuildId!.Value; + } + + await AddMemberDirectlyAsync(factory, guildId, subId, GuildRole.SubLeader); + + // Setting same role (SubLeader → SubLeader) — must succeed as no-op. + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.ChangeRoleAsync(leaderId, subId, (int)GuildRole.SubLeader); + Assert.That(r.IsOk, Is.True, "Same-role set must return Ok (no-op)"); + } + + // Role must be unchanged. + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var m = await db.GuildMembers.FirstAsync(m => m.GuildId == guildId && m.ViewerId == subId); + Assert.That(m.Role, Is.EqualTo(GuildRole.SubLeader), "No-op must leave role unchanged"); + } + } + + [Test] + public async Task ChangeRoleAsync_promotes_subleader_and_emits_change_subleader_event() + { + using var factory = new SpyGuildChatFactory(); + var leaderId = await factory.SeedViewerAsync(76_561_198_300_000_018UL, "CrLeader8"); + var memberId = await factory.SeedViewerAsync(76_561_198_300_000_019UL, "CrMember8"); + + int guildId; + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.CreateAsync(leaderId, new("CrGuild8", (int)GuildActivity.All, (int)GuildJoinCondition.Free)); + Assert.That(r.IsOk, Is.True); + guildId = r.GuildId!.Value; + } + + await AddMemberDirectlyAsync(factory, guildId, memberId, GuildRole.Regular); + + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.ChangeRoleAsync(leaderId, memberId, (int)GuildRole.SubLeader); + Assert.That(r.IsOk, Is.True); + } + + // ChangeSubLeader (7) event must be emitted. + var subLeaderEmissions = factory.ChatSpy.EmissionsWithBody + .Where(e => e.Type == GuildChatMessageType.ChangeSubLeader && e.GuildId == guildId) + .ToList(); + Assert.That(subLeaderEmissions, Has.Count.GreaterThanOrEqualTo(1), "ChangeSubLeader event must be emitted on promotion"); + Assert.That(subLeaderEmissions[0].Body, Does.Contain(memberId.ToString()), + "ChangeSubLeader body must contain target's viewer_id"); + } +} diff --git a/SVSim.UnitTests/Wire/GuildWireShape.cs b/SVSim.UnitTests/Wire/GuildWireShape.cs index 146e726f..0288ebd6 100644 --- a/SVSim.UnitTests/Wire/GuildWireShape.cs +++ b/SVSim.UnitTests/Wire/GuildWireShape.cs @@ -2,6 +2,7 @@ using NUnit.Framework; using System.Text.Json; using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Guild; using SVSim.EmulatedEntrypoint.Models.Dtos.Guild; +using System.Text.Json.Serialization; namespace SVSim.UnitTests.Wire; @@ -99,6 +100,115 @@ public class GuildWireShape Assert.That(arr[0].TryGetProperty("detail", out _), Is.False, "search list entries are flat, no detail wrapper"); } + [Test] + public void GuildChangeRole_response_serializes_members_array_with_role_and_stringified_ids() + { + // GuildChangeRoleTask.Parse() reads base.ResponseData["data"]["members"] — it must be + // an array of GuildMemberInfo objects, each having role + viewer_id (stringified). + var resp = new GuildChangeRoleResponse + { + Members = new() + { + new GuildMemberInfoDto + { + ViewerId = 76_561_198_300_000_009L, + Name = "TestLeader", + EmblemId = 100_000_000L, + CountryCode = "JP", + Rank = 1, + DegreeId = 0, + IsOfficialMarkDisplayed = 0, + Role = 1, // Leader + }, + new GuildMemberInfoDto + { + ViewerId = 76_561_198_300_000_010L, + Name = "TestMember", + EmblemId = 100_000_000L, + CountryCode = "", + Rank = 1, + DegreeId = 0, + IsOfficialMarkDisplayed = 0, + Role = 0, // Regular + }, + } + }; + var json = JsonSerializer.Serialize(resp, Opts); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.That(root.TryGetProperty("members", out var members), Is.True, "response must have 'members' key"); + Assert.That(members.ValueKind, Is.EqualTo(JsonValueKind.Array), "members must be an array"); + Assert.That(members.GetArrayLength(), Is.EqualTo(2)); + + var first = members[0]; + // viewer_id must be stringified (StringifiedLongConverter). + Assert.That(first.GetProperty("viewer_id").ValueKind, Is.EqualTo(JsonValueKind.String), + "viewer_id must be stringified"); + Assert.That(first.GetProperty("role").ValueKind, Is.EqualTo(JsonValueKind.String), + "role must be stringified"); + Assert.That(first.GetProperty("role").GetString(), Is.EqualTo("1"), + "Leader role must serialize as '1'"); + + // is_official_mark_displayed must always be present (JsonIgnore Never). + Assert.That(first.TryGetProperty("is_official_mark_displayed", out var isMark), Is.True, + "is_official_mark_displayed must always be emitted"); + Assert.That(isMark.GetInt32(), Is.EqualTo(0)); + } + + [Test] + public void GuildFriendList_response_serializes_friends_array_with_is_join_guild() + { + // GuildFriendListTask.Parse() iterates data[i] and reads jsonData2["is_join_guild"].ToBoolean() + // from each entry. The root wrapper is "friends" in our DTO. + var resp = new GuildFriendListResponse + { + Friends = new() + { + new GuildInviteCandidateDto + { + ViewerId = 76_561_198_300_000_001L, + Name = "Friend1", + EmblemId = 100_000_000L, + CountryCode = "JP", + Rank = 1, + DegreeId = 0, + IsJoinGuild = false, + }, + new GuildInviteCandidateDto + { + ViewerId = 76_561_198_300_000_002L, + Name = "Friend2", + EmblemId = 100_000_000L, + CountryCode = "", + Rank = 1, + DegreeId = 0, + IsJoinGuild = true, + }, + } + }; + var json = JsonSerializer.Serialize(resp, Opts); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.That(root.TryGetProperty("friends", out var friends), Is.True, "response must have 'friends' key"); + Assert.That(friends.ValueKind, Is.EqualTo(JsonValueKind.Array)); + Assert.That(friends.GetArrayLength(), Is.EqualTo(2)); + + // First entry: is_join_guild = false. + var first = friends[0]; + Assert.That(first.TryGetProperty("is_join_guild", out var jg1), Is.True, "is_join_guild must be present"); + Assert.That(jg1.GetBoolean(), Is.False); + + // Second entry: is_join_guild = true. + var second = friends[1]; + Assert.That(second.GetProperty("is_join_guild").GetBoolean(), Is.True); + + // viewer_id must be stringified. + Assert.That(first.GetProperty("viewer_id").ValueKind, Is.EqualTo(JsonValueKind.String), + "viewer_id must be stringified in friend_list entries"); + } + [Test] public void GuildInfo_non_joined_serializes_to_prod_shape() {