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()
{