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 <noreply@anthropic.com>
This commit is contained in:
@@ -95,4 +95,12 @@ public sealed class GuildRepository : IGuildRepository
|
|||||||
guild.EmblemId = emblemId;
|
guild.EmblemId = emblemId;
|
||||||
await _db.SaveChangesAsync(ct);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,4 +33,7 @@ public interface IGuildRepository
|
|||||||
|
|
||||||
/// <summary>Overwrites the emblem_id field.</summary>
|
/// <summary>Overwrites the emblem_id field.</summary>
|
||||||
Task UpdateEmblemAsync(int guildId, long emblemId, CancellationToken ct = default);
|
Task UpdateEmblemAsync(int guildId, long emblemId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Updates LeaderViewerId on the Guild row — used by ChangeRoleAsync atomic leader transfer.</summary>
|
||||||
|
Task UpdateLeaderViewerIdAsync(int guildId, long newLeaderViewerId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -403,8 +403,60 @@ public sealed class GuildService : IGuildService
|
|||||||
return GuildOpResult.Ok;
|
return GuildOpResult.Ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<GuildOpResult> ChangeRoleAsync(long callerViewerId, long targetViewerId, int newRoleId, CancellationToken ct = default)
|
public async Task<GuildOpResult> ChangeRoleAsync(long callerViewerId, long targetViewerId, int newRoleId, CancellationToken ct = default)
|
||||||
=> throw new NotImplementedException();
|
{
|
||||||
|
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<GuildConfig>();
|
||||||
|
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<IReadOnlyList<Entities.Guild.GuildInvite>> ListPendingInvitesForMeAsync(long viewerId, CancellationToken ct = default)
|
public Task<IReadOnlyList<Entities.Guild.GuildInvite>> ListPendingInvitesForMeAsync(long viewerId, CancellationToken ct = default)
|
||||||
=> _invites.ListPendingForInviteeAsync(viewerId, ct);
|
=> _invites.ListPendingForInviteeAsync(viewerId, ct);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using SVSim.Database.Entities.Guild;
|
|||||||
using SVSim.Database.Models;
|
using SVSim.Database.Models;
|
||||||
using SVSim.Database.Models.Config;
|
using SVSim.Database.Models.Config;
|
||||||
using SVSim.Database.Services;
|
using SVSim.Database.Services;
|
||||||
|
using SVSim.Database.Services.Friend;
|
||||||
using SVSim.Database.Services.Guild;
|
using SVSim.Database.Services.Guild;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Guild;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Guild;
|
||||||
@@ -20,15 +21,17 @@ public sealed class GuildController : SVSimController
|
|||||||
private readonly IGuildService _guild;
|
private readonly IGuildService _guild;
|
||||||
private readonly IGameConfigService _configs;
|
private readonly IGameConfigService _configs;
|
||||||
private readonly SVSimDbContext _db;
|
private readonly SVSimDbContext _db;
|
||||||
|
private readonly IFriendService _friends;
|
||||||
|
|
||||||
// Wire error code returned when guild operations fail (non-1 result_code).
|
// Wire error code returned when guild operations fail (non-1 result_code).
|
||||||
private const int GuildErrorResultCode = 2;
|
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;
|
_guild = guild;
|
||||||
_configs = configs;
|
_configs = configs;
|
||||||
_db = db;
|
_db = db;
|
||||||
|
_friends = friends;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("info")]
|
[HttpPost("info")]
|
||||||
@@ -171,8 +174,40 @@ public sealed class GuildController : SVSimController
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("friend_list")]
|
[HttpPost("friend_list")]
|
||||||
public Task<ActionResult<GuildFriendListResponse>> FriendList([FromBody] BaseRequest _, CancellationToken ct)
|
public async Task<ActionResult<GuildFriendListResponse>> FriendList([FromBody] BaseRequest _, CancellationToken ct)
|
||||||
=> Task.FromResult<ActionResult<GuildFriendListResponse>>(new GuildFriendListResponse { Friends = new() });
|
{
|
||||||
|
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<GuildInviteCandidateDto>(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")]
|
[HttpPost("invite_user_list")]
|
||||||
public async Task<ActionResult<GuildInviteUserListResponse>> InviteUserList([FromBody] BaseRequest _, CancellationToken ct)
|
public async Task<ActionResult<GuildInviteUserListResponse>> InviteUserList([FromBody] BaseRequest _, CancellationToken ct)
|
||||||
@@ -315,8 +350,23 @@ public sealed class GuildController : SVSimController
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("change_role")]
|
[HttpPost("change_role")]
|
||||||
public Task<ActionResult<GuildChangeRoleResponse>> ChangeRole([FromBody] GuildChangeRoleRequest req, CancellationToken ct)
|
public async Task<ActionResult<GuildChangeRoleResponse>> ChangeRole([FromBody] GuildChangeRoleRequest req, CancellationToken ct)
|
||||||
=> Task.FromResult<ActionResult<GuildChangeRoleResponse>>(new GuildChangeRoleResponse { Members = new() });
|
{
|
||||||
|
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 =====
|
// ===== Private helpers =====
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for:
|
||||||
|
/// /guild/change_role — full HTTP round-trip, response shape, atomic transfer
|
||||||
|
/// /guild/friend_list — response shape with is_join_guild annotation
|
||||||
|
/// </summary>
|
||||||
|
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<int> 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<SVSimDbContext>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
337
SVSim.UnitTests/Services/Guild/GuildServiceChangeRoleTests.cs
Normal file
337
SVSim.UnitTests/Services/Guild/GuildServiceChangeRoleTests.cs
Normal file
@@ -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<SVSimDbContext>();
|
||||||
|
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<IGuildService>();
|
||||||
|
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<IGuildService>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
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<IGuildService>();
|
||||||
|
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<IGuildService>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
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<IGuildService>();
|
||||||
|
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<IGuildService>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
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<IGuildService>();
|
||||||
|
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<IGuildService>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
|
||||||
|
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<IGuildService>();
|
||||||
|
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<IGuildService>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
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<IGuildService>();
|
||||||
|
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<IGuildService>();
|
||||||
|
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<IGuildService>();
|
||||||
|
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<IGuildService>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
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<IGuildService>();
|
||||||
|
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<IGuildService>();
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using NUnit.Framework;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Guild;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Guild;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Guild;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Guild;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace SVSim.UnitTests.Wire;
|
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");
|
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]
|
[Test]
|
||||||
public void GuildInfo_non_joined_serializes_to_prod_shape()
|
public void GuildInfo_non_joined_serializes_to_prod_shape()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user