diff --git a/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs b/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs index b0ddc692..e3794a88 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs @@ -174,13 +174,13 @@ public sealed class GuildController : SVSimController } [HttpPost("friend_list")] - public async Task> FriendList([FromBody] BaseRequest _, CancellationToken ct) + 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() }; + return new List(); // For each friend, determine if they are already in a guild (is_join_guild). var friendViewerIds = friendInfo.Friends.Select(f => (long)f.ViewerId).ToList(); @@ -206,7 +206,7 @@ public sealed class GuildController : SVSimController }); } - return new GuildFriendListResponse { Friends = candidates }; + return candidates; } [HttpPost("invite_user_list")] diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Guild/GuildFriendListResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Guild/GuildInviteCandidateDto.cs similarity index 66% rename from SVSim.EmulatedEntrypoint/Models/Dtos/Guild/GuildFriendListResponse.cs rename to SVSim.EmulatedEntrypoint/Models/Dtos/Guild/GuildInviteCandidateDto.cs index aa8b1bf3..8ff9d127 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Guild/GuildFriendListResponse.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Guild/GuildInviteCandidateDto.cs @@ -1,26 +1,14 @@ using MessagePack; using System.Text.Json.Serialization; -using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Guild; namespace SVSim.EmulatedEntrypoint.Models.Dtos.Guild; /// -/// Response for POST /guild/friend_list. -/// NOTE: per guild-friend_list.md, `data` is an array at the root — BUT we return this -/// as a wrapper for now; the controller returns the List directly. -/// TODO(task-14): verify array-root shape via capture; may need a custom response wrapper. +/// One entry in the /guild/friend_list response. +/// GuildFriendListTask.Parse() iterates base.ResponseData["data"][i] directly — data is a +/// bare JSON array, not a wrapper object. This DTO is the element type of that array. /// [MessagePackObject] -public class GuildFriendListResponse -{ - // Placeholder — see controller; actual wire shape is array-at-root per spec. - // The controller returns List directly for now. - [JsonPropertyName("friends"), Key("friends")] - public List Friends { get; set; } = new(); -} - -/// InviteCandidate — UserInfoBase + is_join_guild flag. -[MessagePackObject] public class GuildInviteCandidateDto { [JsonPropertyName("viewer_id"), Key("viewer_id"), JsonConverter(typeof(SVSim.EmulatedEntrypoint.Models.Dtos.Common.StringifiedLongConverter))] diff --git a/SVSim.UnitTests/Integration/Guild/GuildChangeRoleFriendListFlowTests.cs b/SVSim.UnitTests/Integration/Guild/GuildChangeRoleFriendListFlowTests.cs index 9e0f6d15..c85ebdc2 100644 --- a/SVSim.UnitTests/Integration/Guild/GuildChangeRoleFriendListFlowTests.cs +++ b/SVSim.UnitTests/Integration/Guild/GuildChangeRoleFriendListFlowTests.cs @@ -163,8 +163,9 @@ public class GuildChangeRoleFriendListFlowTests // ─── /guild/friend_list ─────────────────────────────────────────────────────── [Test] - public async Task FriendList_returns_friends_array_with_is_join_guild_field() + 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"); @@ -186,16 +187,12 @@ public class GuildChangeRoleFriendListFlowTests 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 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"); - // 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]; + 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. @@ -237,10 +234,13 @@ public class GuildChangeRoleFriendListFlowTests 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)); + // 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 = friends[0]; + 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"); } @@ -257,10 +257,10 @@ public class GuildChangeRoleFriendListFlowTests 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"); + // 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"); } } diff --git a/SVSim.UnitTests/Wire/GuildWireShape.cs b/SVSim.UnitTests/Wire/GuildWireShape.cs index 0288ebd6..a9efb2a6 100644 --- a/SVSim.UnitTests/Wire/GuildWireShape.cs +++ b/SVSim.UnitTests/Wire/GuildWireShape.cs @@ -157,56 +157,58 @@ public class GuildWireShape } [Test] - public void GuildFriendList_response_serializes_friends_array_with_is_join_guild() + public void GuildFriendList_response_is_bare_array_at_root_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 + // GuildFriendListTask.Parse() reads base.ResponseData["data"] and iterates with data[i] directly. + // data must be a bare JSON array — NOT an object with a "friends" key. + var list = new List { - Friends = new() + new GuildInviteCandidateDto { - 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, - }, - } + 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); + var json = JsonSerializer.Serialize(list, 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)); + // Root must be an array directly — GuildFriendListTask reads data[i], not data["friends"][i]. + Assert.That(root.ValueKind, Is.EqualTo(JsonValueKind.Array), + "friend_list data must be a bare array, not a wrapper object"); + Assert.That(root.GetArrayLength(), Is.EqualTo(2)); // First entry: is_join_guild = false. - var first = friends[0]; + var first = root[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]; + var second = root[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"); + + // No "friends" wrapper key must exist. + Assert.That(root.ValueKind, Is.Not.EqualTo(JsonValueKind.Object), + "root must not be an object — no 'friends' wrapper allowed"); } [Test]