Files
SVSimServer/SVSim.UnitTests/Integration/Guild/GuildChangeRoleFriendListFlowTests.cs
gamer147 484b51086a fix(guild): friend_list returns bare array-at-root (matches GuildFriendListTask.Parse)
GuildFriendListTask.Parse() reads base.ResponseData[data][i] directly —
data must be a bare JSON array, not {friends:[...]}.  Delete the
GuildFriendListResponse wrapper DTO; promote GuildInviteCandidateDto to its
own file.  Controller now returns List<GuildInviteCandidateDto> so the
middleware wraps it as data:[…].  Update wire-shape and integration tests to
assert array-at-root.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 14:35:20 -04:00

267 lines
13 KiB
C#

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_bare_array_with_is_join_guild_field()
{
// GuildFriendListTask.Parse() reads base.ResponseData["data"][i] directly — data is a bare array.
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;
// Response must be a bare JSON array at root — NOT an object with a "friends" key.
Assert.That(root.ValueKind, Is.EqualTo(JsonValueKind.Array),
$"friend_list data must be a bare array at root: {body}");
Assert.That(root.GetArrayLength(), Is.EqualTo(1), "viewerA has 1 friend");
var friend = root[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);
// Response is a bare array — GuildFriendListTask reads data[i] directly.
var arr = doc.RootElement;
Assert.That(arr.ValueKind, Is.EqualTo(JsonValueKind.Array),
$"friend_list data must be a bare array at root: {body}");
Assert.That(arr.GetArrayLength(), Is.EqualTo(1));
var friend = arr[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);
// Response is a bare array at root — no wrapper object.
var root = doc.RootElement;
Assert.That(root.ValueKind, Is.EqualTo(JsonValueKind.Array),
$"friend_list must return a bare array even when empty: {body}");
Assert.That(root.GetArrayLength(), Is.EqualTo(0), "No friends means empty array");
}
}