From d7f9ba8f2af71e73d9045eb9037017f7700e322c Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 27 Jun 2026 13:44:04 -0400 Subject: [PATCH] feat(guild): join / cancel_join_request / join_request_list Implements three join-path state machine endpoints: - /guild/join: FREE (instant), APPROVAL (pending row + idempotent re-apply), ONLY_INVITE (pending invite required); CommitJoin consumes invites + cancels join requests; MaxMemberNum cap enforced. - /guild/cancel_join_request: cancels all pending requests for caller (no request_id on wire per GuildJoinRequestCancelTask decompile, composite PK kept). - /guild/join_request_list: leader/subleader sees pending applicants with viewer profile (name/emblem/degree/request_time unix secs). IGuildService.CancelJoinRequestAsync signature changed from (viewerId, guildId) to (viewerId) since the client sends no guildId. GuildJoinRequestEntry enriched record added to GuildServiceTypes. Re-apply after cancel resets existing row in-place to avoid composite-PK conflict. 11 integration tests, all green. Co-Authored-By: Claude Sonnet 4.6 --- .../Migrations/SVSimDbContextModelSnapshot.cs | 3 +- SVSim.Database/Services/Guild/GuildService.cs | 116 ++++- .../Services/Guild/GuildServiceTypes.cs | 14 + .../Services/Guild/IGuildService.cs | 4 +- .../Controllers/GuildController.cs | 39 +- .../Integration/Guild/GuildJoinFlowTests.cs | 457 ++++++++++++++++++ 6 files changed, 619 insertions(+), 14 deletions(-) create mode 100644 SVSim.UnitTests/Integration/Guild/GuildJoinFlowTests.cs diff --git a/SVSim.Database/Migrations/SVSimDbContextModelSnapshot.cs b/SVSim.Database/Migrations/SVSimDbContextModelSnapshot.cs index 438faba9..49a3179c 100644 --- a/SVSim.Database/Migrations/SVSimDbContextModelSnapshot.cs +++ b/SVSim.Database/Migrations/SVSimDbContextModelSnapshot.cs @@ -198,7 +198,8 @@ namespace SVSim.Database.Migrations b.HasKey("Id"); b.HasIndex("GuildId", "InviteeViewerId") - .IsUnique(); + .IsUnique() + .HasFilter("\"Status\" = 0"); b.HasIndex("InviteeViewerId", "Status"); diff --git a/SVSim.Database/Services/Guild/GuildService.cs b/SVSim.Database/Services/Guild/GuildService.cs index ba8d9c8e..cb02ac51 100644 --- a/SVSim.Database/Services/Guild/GuildService.cs +++ b/SVSim.Database/Services/Guild/GuildService.cs @@ -250,11 +250,82 @@ public sealed class GuildService : IGuildService return GuildOpResult.Ok; } - public Task JoinAsync(long viewerId, int guildId, CancellationToken ct = default) - => throw new NotImplementedException(); + public async Task JoinAsync(long viewerId, int guildId, CancellationToken ct = default) + { + if (await _members.GetMembershipAsync(viewerId, ct) is not null) + return new(GuildOpResultCode.AlreadyInGuild); - public Task CancelJoinRequestAsync(long viewerId, int guildId, CancellationToken ct = default) - => throw new NotImplementedException(); + var g = await _guilds.GetActiveByIdAsync(guildId, ct); + if (g is null) return new(GuildOpResultCode.GuildNotFound); + + var cfg = _config.Get(); + var memberCount = await _members.CountByGuildAsync(guildId, ct); + if (memberCount >= cfg.MaxMemberNum) return new(GuildOpResultCode.MemberCapReached); + + var now = DateTime.UtcNow; + bool hasPendingInvite = await _invites.GetAsync(guildId, viewerId, ct) + is { Status: GuildInviteStatus.Pending }; + + if (g.JoinCondition == GuildJoinCondition.OnlyInvite && !hasPendingInvite) + return new(GuildOpResultCode.PermissionDenied); + + if (g.JoinCondition == GuildJoinCondition.Approval && !hasPendingInvite) + { + var existing = await _joinRequests.GetAsync(guildId, viewerId, ct); + if (existing is { Status: GuildJoinRequestStatus.Pending }) + { + // Already pending — idempotent, no-op. + return GuildOpResult.Ok; + } + + if (existing is not null) + { + // Row exists but was previously Canceled/Rejected — reset to Pending in place. + existing.Status = GuildJoinRequestStatus.Pending; + existing.CreatedAt = now; + existing.RespondedAt = null; + await _db.SaveChangesAsync(ct); + } + else + { + await _joinRequests.AddAsync(new GuildJoinRequest + { + GuildId = guildId, + ViewerId = viewerId, + Status = GuildJoinRequestStatus.Pending, + CreatedAt = now, + }, ct); + } + return GuildOpResult.Ok; + } + + await CommitJoinAsync(viewerId, guildId, now, ct); + return GuildOpResult.Ok; + } + + private async Task CommitJoinAsync(long viewerId, int guildId, DateTime now, CancellationToken ct) + { + await _members.AddAsync( + new GuildMember { GuildId = guildId, ViewerId = viewerId, Role = GuildRole.Regular, JoinedAt = now }, + ct); + await _viewers.SetGuildIdAsync(viewerId, guildId, ct); + await _invites.ConsumePendingForViewerAsync(viewerId, now, ct); + await _joinRequests.CancelPendingForViewerAsync(viewerId, now, ct); + await _chat.EmitSystemEventAsync(guildId, viewerId, GuildChatMessageType.Join, body: null, ct); + } + + public async Task CancelJoinRequestAsync(long viewerId, CancellationToken ct = default) + { + // Cancel all pending requests for this viewer (client sends no guildId — see decompile). + var pending = await _joinRequests.ListPendingForViewerAsync(viewerId, ct); + if (pending.Count == 0) return new(GuildOpResultCode.JoinRequestNotFound); + + var now = DateTime.UtcNow; + foreach (var req in pending) + await _joinRequests.UpdateStatusAsync(req.GuildId, viewerId, GuildJoinRequestStatus.Canceled, now, ct); + + return GuildOpResult.Ok; + } public Task AcceptJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default) => throw new NotImplementedException(); @@ -328,6 +399,39 @@ public sealed class GuildService : IGuildService LeaderName: leaderNames.GetValueOrDefault(invite.Guild.LeaderViewerId, ""))).ToList(); } - public Task> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default) - => throw new NotImplementedException(); + public async Task> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default) + { + var membership = await _members.GetMembershipAsync(viewerId, ct); + if (membership is null) return Array.Empty(); + // Only leader/subleader may view the list — return empty for regular members. + if (membership.Role is not (GuildRole.Leader or GuildRole.SubLeader)) + return Array.Empty(); + + var requests = await _joinRequests.ListPendingForGuildAsync(membership.GuildId, ct); + if (requests.Count == 0) return Array.Empty(); + + var applicantIds = requests.Select(r => r.ViewerId).Distinct().ToList(); + var names = await _viewers.LoadDisplayNamesAsync(applicantIds, ct); + + var viewerRows = await _db.Viewers + .AsNoTracking() + .Include(v => v.Info.SelectedEmblem) + .Include(v => v.Info.SelectedDegree) + .Where(v => applicantIds.Contains(v.Id)) + .ToDictionaryAsync(v => v.Id, ct); + + return requests.Select(r => + { + viewerRows.TryGetValue(r.ViewerId, out var v); + return new GuildJoinRequestEntry( + ApplicantViewerId: r.ViewerId, + Name: names.GetValueOrDefault(r.ViewerId, ""), + EmblemId: v?.Info?.SelectedEmblem?.Id is > 0 ? v.Info.SelectedEmblem.Id : 100_000_000L, + CountryCode: v?.Info?.CountryCode ?? "", + Rank: 1, + DegreeId: v?.Info?.SelectedDegree?.Id ?? 0, + IsOfficialMarkDisplayed: v?.Info?.IsOfficialMarkDisplayed == true, + RequestedAt: r.CreatedAt); + }).ToList(); + } } diff --git a/SVSim.Database/Services/Guild/GuildServiceTypes.cs b/SVSim.Database/Services/Guild/GuildServiceTypes.cs index 9257b5cc..59b3b02c 100644 --- a/SVSim.Database/Services/Guild/GuildServiceTypes.cs +++ b/SVSim.Database/Services/Guild/GuildServiceTypes.cs @@ -62,3 +62,17 @@ public sealed record GuildReceivedInviteEntry( Entities.Guild.Guild Guild, int MemberNum, string LeaderName); + +/// +/// Enriched join-request entry returned by . +/// Combines applicant profile data with the request timestamp. +/// +public sealed record GuildJoinRequestEntry( + long ApplicantViewerId, + string Name, + long EmblemId, + string CountryCode, + int Rank, + int DegreeId, + bool IsOfficialMarkDisplayed, + DateTime RequestedAt); diff --git a/SVSim.Database/Services/Guild/IGuildService.cs b/SVSim.Database/Services/Guild/IGuildService.cs index bd5e29ef..f568ccca 100644 --- a/SVSim.Database/Services/Guild/IGuildService.cs +++ b/SVSim.Database/Services/Guild/IGuildService.cs @@ -27,7 +27,7 @@ public interface IGuildService Task RejectInviteAsync(long callerViewerId, long inviteId, CancellationToken ct = default); Task JoinAsync(long viewerId, int guildId, CancellationToken ct = default); - Task CancelJoinRequestAsync(long viewerId, int guildId, CancellationToken ct = default); + Task CancelJoinRequestAsync(long viewerId, CancellationToken ct = default); Task AcceptJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default); Task RejectJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default); @@ -38,5 +38,5 @@ public interface IGuildService Task> ListPendingInvitesForMeAsync(long viewerId, CancellationToken ct = default); Task> ListOutgoingInvitesAsync(long callerViewerId, CancellationToken ct = default); Task> ListInvitedGuildsAsync(long viewerId, CancellationToken ct = default); - Task> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default); + Task> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default); } diff --git a/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs b/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs index 899d00a3..6b245f65 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs @@ -243,15 +243,44 @@ public sealed class GuildController : SVSimController } [HttpPost("join")] - public Task> Join([FromBody] GuildJoinEndpointRequest req, CancellationToken ct) - => Task.FromResult>(new GuildJoinResponse { GuildStatus = 0 }); + public async Task> Join([FromBody] GuildJoinEndpointRequest req, CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + var r = await _guild.JoinAsync(viewerId, req.GuildId, ct); + if (!r.IsOk) return WireError(); + // guild_status=2 (JOINING) on successful join; APPROVAL path also returns 2 as the viewer + // is now "awaiting" — client drives the display from result_code, not guild_status alone. + return new GuildJoinResponse { GuildStatus = 2 }; + } [HttpPost("cancel_join_request")] - public Task> CancelJoinRequest([FromBody] GuildCancelJoinRequestRequest req, CancellationToken ct) => Stub(); + public async Task> CancelJoinRequest([FromBody] GuildCancelJoinRequestRequest req, CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + var r = await _guild.CancelJoinRequestAsync(viewerId, ct); + return r.IsOk ? new EmptyResponse() : MapErrorToWire(r); + } [HttpPost("join_request_list")] - public Task> JoinRequestList([FromBody] GuildJoinRequestListRequest req, CancellationToken ct) - => Task.FromResult>(new GuildJoinRequestListResponse { Users = new() }); + public async Task> JoinRequestList([FromBody] GuildJoinRequestListRequest req, CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + var entries = await _guild.ListPendingJoinRequestsForMyGuildAsync(viewerId, ct); + return new GuildJoinRequestListResponse + { + Users = entries.Select(e => new GuildJoinRequestEntryDto + { + ViewerId = e.ApplicantViewerId, + Name = e.Name, + EmblemId = e.EmblemId, + CountryCode = e.CountryCode, + Rank = e.Rank, + DegreeId = e.DegreeId, + IsOfficialMarkDisplayed = e.IsOfficialMarkDisplayed ? 1 : 0, + RequestTime = new DateTimeOffset(e.RequestedAt, TimeSpan.Zero).ToUnixTimeSeconds(), + }).ToList(), + }; + } [HttpPost("join_request_accept")] public Task> JoinRequestAccept([FromBody] GuildJoinRequestAcceptRequest req, CancellationToken ct) => Stub(); diff --git a/SVSim.UnitTests/Integration/Guild/GuildJoinFlowTests.cs b/SVSim.UnitTests/Integration/Guild/GuildJoinFlowTests.cs new file mode 100644 index 00000000..dd7d4679 --- /dev/null +++ b/SVSim.UnitTests/Integration/Guild/GuildJoinFlowTests.cs @@ -0,0 +1,457 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Integration.Guild; + +using GuildEntity = SVSim.Database.Entities.Guild.Guild; + +/// +/// Integration tests for the join-path state machine: +/// /guild/join (FREE / APPROVAL / ONLY_INVITE branches), +/// /guild/cancel_join_request, +/// /guild/join_request_list. +/// +public class GuildJoinFlowTests +{ + 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 }; + + // Many wire fields use StringifiedIntConverter — values arrive as "123" not 123. + 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 ───────────────────────────────────────────────────────────────── + + /// Creates a guild as the given client and returns the guild_id from /guild/info. + 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(new { viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + var infoJson = await infoResp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(infoJson); + return GetStringifiedInt(doc.RootElement.GetProperty("guild").GetProperty("detail"), "guild_id"); + } + + // ─── FREE branch ───────────────────────────────────────────────────────────── + + [Test] + public async Task Free_join_makes_viewer_a_member_instantly() + { + using var factory = new SVSimTestFactory(); + + long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_001UL, displayName: "FreeLeader"); + using var clientA = factory.CreateAuthenticatedClient(viewerA); + int guildId = await CreateGuildAndGetIdAsync(clientA, "FreeGuild", joinCondition: 1); + + long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_002UL, displayName: "FreeJoiner"); + using var clientB = factory.CreateAuthenticatedClient(viewerB); + + var joinResp = await clientB.PostAsync("/guild/join", + JsonContent.Create(new { guild_id = guildId, from_invite = false, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + var joinJson = await joinResp.Content.ReadAsStringAsync(); + Assert.That(joinResp.IsSuccessStatusCode, Is.True, $"join HTTP failed: {joinJson}"); + using var joinDoc = JsonDocument.Parse(joinJson); + if (joinDoc.RootElement.TryGetProperty("result_code", out var rc)) + Assert.That(rc.GetInt32(), Is.Not.EqualTo(2), $"join returned error: {joinJson}"); + + // guild_status must be 2 (JOINING) — value is stringified. + Assert.That(GetStringifiedInt(joinDoc.RootElement, "guild_status"), Is.EqualTo(2), + $"guild_status must be 2 after FREE join, got: {joinJson}"); + + // B calling /guild/info should now be a member (guild_status=2). + var bInfoResp = await clientB.PostAsync("/guild/info", + JsonContent.Create(new { viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + var bInfoJson = await bInfoResp.Content.ReadAsStringAsync(); + using var bInfoDoc = JsonDocument.Parse(bInfoJson); + Assert.That(GetStringifiedInt(bInfoDoc.RootElement, "guild_status"), Is.EqualTo(2), + $"B should be a member after FREE join, got: {bInfoJson}"); + } + + [Test] + public async Task Free_join_consumes_pending_invite() + { + using var factory = new SVSimTestFactory(); + + long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_011UL, displayName: "FreeLeader2"); + using var clientA = factory.CreateAuthenticatedClient(viewerA); + int guildId = await CreateGuildAndGetIdAsync(clientA, "FreeGuild2", joinCondition: 1); + + long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_012UL, displayName: "FreeJoinerInvited"); + using var clientB = factory.CreateAuthenticatedClient(viewerB); + + // A invites B. + await clientA.PostAsync("/guild/invite", + JsonContent.Create(new { invited_viewer_id = (int)viewerB, invite_message = "", + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + + // B joins via FREE path. + var joinResp = await clientB.PostAsync("/guild/join", + JsonContent.Create(new { guild_id = guildId, from_invite = false, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + Assert.That(joinResp.IsSuccessStatusCode, Is.True); + + // After joining, B's invited_guild_list should be empty (invite consumed). + var invitedList = await clientB.PostAsync("/guild/invited_guild_list", + JsonContent.Create(new { page = 0, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + using var ilDoc = JsonDocument.Parse(await invitedList.Content.ReadAsStringAsync()); + Assert.That(ilDoc.RootElement.GetProperty("list").GetArrayLength(), Is.EqualTo(0), + "After joining, pending invites for the viewer should be consumed"); + } + + // ─── APPROVAL branch ───────────────────────────────────────────────────────── + + [Test] + public async Task Approval_join_creates_pending_request_not_membership() + { + using var factory = new SVSimTestFactory(); + + long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_021UL, displayName: "ApprovalLeader"); + using var clientA = factory.CreateAuthenticatedClient(viewerA); + int guildId = await CreateGuildAndGetIdAsync(clientA, "ApprovalGuild", joinCondition: 2); + + long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_022UL, displayName: "ApprovalApplicant"); + using var clientB = factory.CreateAuthenticatedClient(viewerB); + + // B applies. + var joinResp = await clientB.PostAsync("/guild/join", + JsonContent.Create(new { guild_id = guildId, from_invite = false, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + var joinJson = await joinResp.Content.ReadAsStringAsync(); + Assert.That(joinResp.IsSuccessStatusCode, Is.True, $"join HTTP failed: {joinJson}"); + using var joinDoc = JsonDocument.Parse(joinJson); + if (joinDoc.RootElement.TryGetProperty("result_code", out var rc)) + Assert.That(rc.GetInt32(), Is.Not.EqualTo(2), $"APPROVAL join returned error: {joinJson}"); + + // B must NOT be a member yet (guild_status=0, no guild sub-object). + var bInfoResp = await clientB.PostAsync("/guild/info", + JsonContent.Create(new { viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + var bInfoJson = await bInfoResp.Content.ReadAsStringAsync(); + using var bInfoDoc = JsonDocument.Parse(bInfoJson); + Assert.That(GetStringifiedInt(bInfoDoc.RootElement, "guild_status"), Is.EqualTo(0), + $"B must NOT be a member after APPROVAL apply, got: {bInfoJson}"); + + // A (leader) should see B in the join_request_list. + var jrlResp = await clientA.PostAsync("/guild/join_request_list", + JsonContent.Create(new { page = 0, oldest_time = 0, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + var jrlJson = await jrlResp.Content.ReadAsStringAsync(); + Assert.That(jrlResp.IsSuccessStatusCode, Is.True, $"join_request_list HTTP failed: {jrlJson}"); + using var jrlDoc = JsonDocument.Parse(jrlJson); + var list = jrlDoc.RootElement.GetProperty("list"); + Assert.That(list.GetArrayLength(), Is.EqualTo(1), $"Leader should see 1 pending applicant, got: {jrlJson}"); + // viewer_id in list entries is StringifiedLong. + Assert.That(GetStringifiedLong(list[0], "viewer_id"), Is.EqualTo(viewerB), + "The applicant must be viewer B"); + } + + [Test] + public async Task Approval_join_is_idempotent() + { + using var factory = new SVSimTestFactory(); + + long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_031UL, displayName: "IdempotentLeader"); + using var clientA = factory.CreateAuthenticatedClient(viewerA); + int guildId = await CreateGuildAndGetIdAsync(clientA, "IdempotentGuild", joinCondition: 2); + + long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_032UL, displayName: "IdempotentApplicant"); + using var clientB = factory.CreateAuthenticatedClient(viewerB); + + var joinPayload = new { guild_id = guildId, from_invite = false, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }; + + // B applies twice. + await clientB.PostAsync("/guild/join", JsonContent.Create(joinPayload)); + var join2 = await clientB.PostAsync("/guild/join", JsonContent.Create(joinPayload)); + var join2Json = await join2.Content.ReadAsStringAsync(); + Assert.That(join2.IsSuccessStatusCode, Is.True, $"Second apply must not HTTP-fail: {join2Json}"); + using var join2Doc = JsonDocument.Parse(join2Json); + if (join2Doc.RootElement.TryGetProperty("result_code", out var rc)) + Assert.That(rc.GetInt32(), Is.Not.EqualTo(2), $"Second apply must return Ok: {join2Json}"); + + // Still only 1 entry in the list. + var jrlResp = await clientA.PostAsync("/guild/join_request_list", + JsonContent.Create(new { page = 0, oldest_time = 0, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + using var jrlDoc = JsonDocument.Parse(await jrlResp.Content.ReadAsStringAsync()); + Assert.That(jrlDoc.RootElement.GetProperty("list").GetArrayLength(), Is.EqualTo(1), + "Idempotent re-apply must not duplicate the pending row"); + } + + // ─── ONLY_INVITE branch ────────────────────────────────────────────────────── + + [Test] + public async Task OnlyInvite_without_invite_is_rejected() + { + using var factory = new SVSimTestFactory(); + + long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_041UL, displayName: "InviteOnlyLeader"); + using var clientA = factory.CreateAuthenticatedClient(viewerA); + int guildId = await CreateGuildAndGetIdAsync(clientA, "InviteOnlyGuild", joinCondition: 3); + + long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_042UL, displayName: "UninvitedB"); + using var clientB = factory.CreateAuthenticatedClient(viewerB); + + var joinResp = await clientB.PostAsync("/guild/join", + JsonContent.Create(new { guild_id = guildId, from_invite = false, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + var joinJson = await joinResp.Content.ReadAsStringAsync(); + Assert.That(joinResp.IsSuccessStatusCode, Is.True, "HTTP must be 200"); + using var joinDoc = JsonDocument.Parse(joinJson); + Assert.That(joinDoc.RootElement.TryGetProperty("result_code", out var rc), Is.True, + $"Expected error result_code, got: {joinJson}"); + Assert.That(rc.GetInt32(), Is.EqualTo(2), $"Must be rejected (result_code=2), got: {joinJson}"); + } + + [Test] + public async Task OnlyInvite_with_pending_invite_joins_and_consumes_invite() + { + using var factory = new SVSimTestFactory(); + + long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_051UL, displayName: "InviteOnlyLeader2"); + using var clientA = factory.CreateAuthenticatedClient(viewerA); + int guildId = await CreateGuildAndGetIdAsync(clientA, "InviteOnlyGuild2", joinCondition: 3); + + long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_052UL, displayName: "InvitedB"); + using var clientB = factory.CreateAuthenticatedClient(viewerB); + + // A invites B. + await clientA.PostAsync("/guild/invite", + JsonContent.Create(new { invited_viewer_id = (int)viewerB, invite_message = "", + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + + // B joins via ONLY_INVITE path. + var joinResp = await clientB.PostAsync("/guild/join", + JsonContent.Create(new { guild_id = guildId, from_invite = true, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + var joinJson = await joinResp.Content.ReadAsStringAsync(); + Assert.That(joinResp.IsSuccessStatusCode, Is.True, $"join HTTP failed: {joinJson}"); + using var joinDoc = JsonDocument.Parse(joinJson); + if (joinDoc.RootElement.TryGetProperty("result_code", out var rc)) + Assert.That(rc.GetInt32(), Is.Not.EqualTo(2), $"ONLY_INVITE join with invite returned error: {joinJson}"); + + // B is now a member. + var bInfoResp = await clientB.PostAsync("/guild/info", + JsonContent.Create(new { viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + var bInfoJson = await bInfoResp.Content.ReadAsStringAsync(); + using var bInfoDoc = JsonDocument.Parse(bInfoJson); + Assert.That(GetStringifiedInt(bInfoDoc.RootElement, "guild_status"), Is.EqualTo(2), + $"B must be a member after ONLY_INVITE join, got: {bInfoJson}"); + + // Invite was consumed — B's invited_guild_list is now empty. + var invitedList = await clientB.PostAsync("/guild/invited_guild_list", + JsonContent.Create(new { page = 0, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + using var ilDoc = JsonDocument.Parse(await invitedList.Content.ReadAsStringAsync()); + Assert.That(ilDoc.RootElement.GetProperty("list").GetArrayLength(), Is.EqualTo(0), + "Invite must be consumed (Pending→Consumed) after ONLY_INVITE join"); + } + + // ─── Member cap ────────────────────────────────────────────────────────────── + + [Test] + public async Task Full_guild_rejects_free_join() + { + using var factory = new SVSimTestFactory(); + + // Seed a GuildConfig with MaxMemberNum = 1 so the guild fills at the leader. + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + const string json = "{\"MaxMemberNum\":1,\"MaxSubLeaderNum\":3,\"SearchResultCap\":20,\"UsableStampList\":[1,2,3,4,5]}"; + var existing = db.GameConfigs.FirstOrDefault(s => s.SectionName == "Guild"); + if (existing is null) + db.GameConfigs.Add(new SVSim.Database.Models.GameConfigSection { SectionName = "Guild", ValueJson = json }); + else + existing.ValueJson = json; + db.SaveChanges(); + } + + long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_061UL, displayName: "CapLeader"); + using var clientA = factory.CreateAuthenticatedClient(viewerA); + int guildId = await CreateGuildAndGetIdAsync(clientA, "CapGuild", joinCondition: 1); + + long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_062UL, displayName: "CapJoiner"); + using var clientB = factory.CreateAuthenticatedClient(viewerB); + + var joinResp = await clientB.PostAsync("/guild/join", + JsonContent.Create(new { guild_id = guildId, from_invite = false, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + var joinJson = await joinResp.Content.ReadAsStringAsync(); + Assert.That(joinResp.IsSuccessStatusCode, Is.True, "HTTP must be 200"); + using var joinDoc = JsonDocument.Parse(joinJson); + Assert.That(joinDoc.RootElement.TryGetProperty("result_code", out var rc), Is.True, + $"Expected error result_code when guild is full, got: {joinJson}"); + Assert.That(rc.GetInt32(), Is.EqualTo(2), $"Full guild must reject join (result_code=2), got: {joinJson}"); + } + + // ─── cancel_join_request ───────────────────────────────────────────────────── + + [Test] + public async Task Cancel_join_request_removes_pending_row() + { + using var factory = new SVSimTestFactory(); + + long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_071UL, displayName: "CancelReqLeader"); + using var clientA = factory.CreateAuthenticatedClient(viewerA); + int guildId = await CreateGuildAndGetIdAsync(clientA, "CancelReqGuild", joinCondition: 2); + + long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_072UL, displayName: "CancelApplicant"); + using var clientB = factory.CreateAuthenticatedClient(viewerB); + + // B applies. + await clientB.PostAsync("/guild/join", + JsonContent.Create(new { guild_id = guildId, from_invite = false, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + + // A sees B in the list. + var jrl1 = await clientA.PostAsync("/guild/join_request_list", + JsonContent.Create(new { page = 0, oldest_time = 0, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + using var jrl1Doc = JsonDocument.Parse(await jrl1.Content.ReadAsStringAsync()); + Assert.That(jrl1Doc.RootElement.GetProperty("list").GetArrayLength(), Is.EqualTo(1), + "Should see 1 pending before cancel"); + + // B cancels (no guild_id in request — server infers from viewer state). + var cancelResp = await clientB.PostAsync("/guild/cancel_join_request", + JsonContent.Create(BaseReq())); + var cancelJson = await cancelResp.Content.ReadAsStringAsync(); + Assert.That(cancelResp.IsSuccessStatusCode, Is.True, $"cancel_join_request HTTP failed: {cancelJson}"); + using var cancelDoc = JsonDocument.Parse(cancelJson); + if (cancelDoc.RootElement.TryGetProperty("result_code", out var rc)) + Assert.That(rc.GetInt32(), Is.Not.EqualTo(2), $"cancel_join_request returned error: {cancelJson}"); + + // A no longer sees any pending applicants. + var jrl2 = await clientA.PostAsync("/guild/join_request_list", + JsonContent.Create(new { page = 0, oldest_time = 0, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + using var jrl2Doc = JsonDocument.Parse(await jrl2.Content.ReadAsStringAsync()); + Assert.That(jrl2Doc.RootElement.GetProperty("list").GetArrayLength(), Is.EqualTo(0), + "After cancel, no pending applicants should be visible"); + } + + [Test] + public async Task After_cancel_viewer_can_reapply() + { + using var factory = new SVSimTestFactory(); + + long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_081UL, displayName: "ReApplyLeader"); + using var clientA = factory.CreateAuthenticatedClient(viewerA); + int guildId = await CreateGuildAndGetIdAsync(clientA, "ReApplyGuild", joinCondition: 2); + + long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_082UL, displayName: "ReApplicant"); + using var clientB = factory.CreateAuthenticatedClient(viewerB); + + // Apply → cancel → re-apply. + await clientB.PostAsync("/guild/join", + JsonContent.Create(new { guild_id = guildId, from_invite = false, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + + await clientB.PostAsync("/guild/cancel_join_request", JsonContent.Create(BaseReq())); + + var reApply = await clientB.PostAsync("/guild/join", + JsonContent.Create(new { guild_id = guildId, from_invite = false, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + var reApplyJson = await reApply.Content.ReadAsStringAsync(); + Assert.That(reApply.IsSuccessStatusCode, Is.True, $"Re-apply after cancel HTTP failed: {reApplyJson}"); + using var doc = JsonDocument.Parse(reApplyJson); + if (doc.RootElement.TryGetProperty("result_code", out var rc)) + Assert.That(rc.GetInt32(), Is.Not.EqualTo(2), $"Re-apply after cancel must not error: {reApplyJson}"); + + // A should see 1 pending applicant again. + var jrlResp = await clientA.PostAsync("/guild/join_request_list", + JsonContent.Create(new { page = 0, oldest_time = 0, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + using var jrlDoc = JsonDocument.Parse(await jrlResp.Content.ReadAsStringAsync()); + Assert.That(jrlDoc.RootElement.GetProperty("list").GetArrayLength(), Is.EqualTo(1), + "After re-apply, applicant should appear in list again"); + } + + // ─── join_request_list permission ──────────────────────────────────────────── + + [Test] + public async Task Join_request_list_returns_empty_for_regular_member() + { + using var factory = new SVSimTestFactory(); + + long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_091UL, displayName: "ListLeader"); + using var clientA = factory.CreateAuthenticatedClient(viewerA); + int guildId = await CreateGuildAndGetIdAsync(clientA, "ListGuild", joinCondition: 1); + + // B joins (FREE) — becomes Regular member. + long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_092UL, displayName: "RegularMember"); + using var clientB = factory.CreateAuthenticatedClient(viewerB); + await clientB.PostAsync("/guild/join", + JsonContent.Create(new { guild_id = guildId, from_invite = false, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + + // Regular member B gets an empty list from join_request_list. + var jrlResp = await clientB.PostAsync("/guild/join_request_list", + JsonContent.Create(new { page = 0, oldest_time = 0, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + var jrlJson = await jrlResp.Content.ReadAsStringAsync(); + Assert.That(jrlResp.IsSuccessStatusCode, Is.True, $"join_request_list HTTP failed: {jrlJson}"); + using var jrlDoc = JsonDocument.Parse(jrlJson); + Assert.That(jrlDoc.RootElement.GetProperty("list").GetArrayLength(), Is.EqualTo(0), + "Regular member must see empty list from join_request_list"); + } + + // ─── Wire-shape test for join_request_list ─────────────────────────────────── + + [Test] + public async Task Join_request_list_response_shape_is_correct() + { + using var factory = new SVSimTestFactory(); + + long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_101UL, displayName: "ShapeLeader"); + using var clientA = factory.CreateAuthenticatedClient(viewerA); + int guildId = await CreateGuildAndGetIdAsync(clientA, "ShapeGuild", joinCondition: 2); + + long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_102UL, displayName: "ShapeApplicant"); + using var clientB = factory.CreateAuthenticatedClient(viewerB); + await clientB.PostAsync("/guild/join", + JsonContent.Create(new { guild_id = guildId, from_invite = false, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + + var jrlResp = await clientA.PostAsync("/guild/join_request_list", + JsonContent.Create(new { page = 0, oldest_time = 0, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + var jrlJson = await jrlResp.Content.ReadAsStringAsync(); + Assert.That(jrlResp.IsSuccessStatusCode, Is.True, $"join_request_list failed: {jrlJson}"); + + using var doc = JsonDocument.Parse(jrlJson); + var list = doc.RootElement.GetProperty("list"); + Assert.That(list.GetArrayLength(), Is.EqualTo(1), $"Expected 1 entry, got: {jrlJson}"); + + var entry = list[0]; + + // GuildUserInfo fields (flat siblings per GuildUserInfo constructor) + request-list extras. + string[] requiredFields = [ + "viewer_id", "name", "emblem_id", "country_code", "rank", "degree_id", + "is_official_mark_displayed", "request_time" + ]; + foreach (var field in requiredFields) + { + Assert.That(entry.TryGetProperty(field, out _), Is.True, + $"Field '{field}' must be present in join_request_list entry, got: {jrlJson}"); + } + + // request_time must be a positive integer (unix seconds, NOT milliseconds). + Assert.That(entry.GetProperty("request_time").GetInt64(), Is.GreaterThan(0), + "request_time must be a positive unix seconds timestamp"); + + // viewer_id is stringified — must parse as a valid long. + Assert.That(long.TryParse(entry.GetProperty("viewer_id").GetString(), out var parsedId), Is.True, + $"viewer_id must be stringified long, got: {jrlJson}"); + Assert.That(parsedId, Is.EqualTo(viewerB), "viewer_id must match the applicant"); + } +}