From 00fb28681b64d48ae73d7696564c03631cb803af Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 27 Jun 2026 14:16:47 -0400 Subject: [PATCH] feat(guild): leave + remove with leader-leaves rule - LeaveAsync: regular/subleader removes self; leader blocked if members remain; sole-member leader auto-routes to BreakupAsync. - RemoveAsync: leader-only kick (SubLeader has no remove authority per GuildManager.cs decompile); self-remove and cross-guild target rejected. - GuildController: wired /guild/leave (BaseRequest) and /guild/remove (GuildRemoveRequest.remove_viewer_id field). - SpyGuildChatService: extended Emissions with EmissionsWithBody tuple to allow body-content assertions in leave/remove tests. - 8 new integration tests; all 1493 pass. Co-Authored-By: Claude Sonnet 4.6 --- SVSim.Database/Services/Guild/GuildService.cs | 43 ++- .../Controllers/GuildController.cs | 14 +- .../Services/Guild/GuildServiceLeaveTests.cs | 338 ++++++++++++++++++ .../Services/Guild/GuildServiceUpdateTests.cs | 2 + 4 files changed, 391 insertions(+), 6 deletions(-) create mode 100644 SVSim.UnitTests/Services/Guild/GuildServiceLeaveTests.cs diff --git a/SVSim.Database/Services/Guild/GuildService.cs b/SVSim.Database/Services/Guild/GuildService.cs index 4b284941..1df4f18d 100644 --- a/SVSim.Database/Services/Guild/GuildService.cs +++ b/SVSim.Database/Services/Guild/GuildService.cs @@ -362,11 +362,46 @@ public sealed class GuildService : IGuildService return GuildOpResult.Ok; } - public Task LeaveAsync(long viewerId, CancellationToken ct = default) - => throw new NotImplementedException(); + public async Task LeaveAsync(long viewerId, CancellationToken ct = default) + { + var m = await _members.GetMembershipAsync(viewerId, ct); + if (m is null) return new(GuildOpResultCode.NotInGuild); - public Task RemoveAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default) - => throw new NotImplementedException(); + var memberCount = await _members.CountByGuildAsync(m.GuildId, ct); + + if (m.Role == GuildRole.Leader) + { + // Sole-member leader: auto-breakup. + if (memberCount == 1) return await BreakupAsync(viewerId, ct); + // Leader with remaining members: blocked. + return new(GuildOpResultCode.LeaderLeaveBlocked); + } + + // Regular or SubLeader: remove self. + await _members.RemoveAsync(m.GuildId, viewerId, ct); + await _viewers.ClearGuildIdAsync(viewerId, ct); + await _chat.EmitSystemEventAsync(m.GuildId, viewerId, GuildChatMessageType.Leave, body: null, ct); + return GuildOpResult.Ok; + } + + public async Task RemoveAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default) + { + var caller = await _members.GetMembershipAsync(callerViewerId, ct); + if (caller is null) return new(GuildOpResultCode.NotInGuild); + // Only Leader may kick (GuildManager.cs has no HasAuthorityRemove — client has no SubLeader kick UI). + if (caller.Role != GuildRole.Leader) return new(GuildOpResultCode.PermissionDenied); + // Cannot kick yourself. + if (callerViewerId == targetViewerId) return new(GuildOpResultCode.PermissionDenied); + + var target = await _members.GetMembershipAsync(targetViewerId, ct); + if (target is null || target.GuildId != caller.GuildId) return new(GuildOpResultCode.TargetNotInGuild); + + await _members.RemoveAsync(caller.GuildId, targetViewerId, ct); + await _viewers.ClearGuildIdAsync(targetViewerId, ct); + await _chat.EmitSystemEventAsync(caller.GuildId, callerViewerId, GuildChatMessageType.Remove, + body: targetViewerId.ToString(), ct); + return GuildOpResult.Ok; + } public Task ChangeRoleAsync(long callerViewerId, long targetViewerId, int newRoleId, CancellationToken ct = default) => throw new NotImplementedException(); diff --git a/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs b/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs index 6e494c3f..2f99184b 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs @@ -299,10 +299,20 @@ public sealed class GuildController : SVSimController } [HttpPost("leave")] - public Task> Leave([FromBody] BaseRequest _, CancellationToken ct) => Stub(); + public async Task> Leave([FromBody] BaseRequest _, CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + var r = await _guild.LeaveAsync(viewerId, ct); + return r.IsOk ? new EmptyResponse() : MapErrorToWire(r); + } [HttpPost("remove")] - public Task> Remove([FromBody] GuildRemoveRequest req, CancellationToken ct) => Stub(); + public async Task> Remove([FromBody] GuildRemoveRequest req, CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + var r = await _guild.RemoveAsync(viewerId, req.RemoveViewerId, ct); + return r.IsOk ? new EmptyResponse() : MapErrorToWire(r); + } [HttpPost("change_role")] public Task> ChangeRole([FromBody] GuildChangeRoleRequest req, CancellationToken ct) diff --git a/SVSim.UnitTests/Services/Guild/GuildServiceLeaveTests.cs b/SVSim.UnitTests/Services/Guild/GuildServiceLeaveTests.cs new file mode 100644 index 00000000..e65ad3f4 --- /dev/null +++ b/SVSim.UnitTests/Services/Guild/GuildServiceLeaveTests.cs @@ -0,0 +1,338 @@ +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; +using GuildEntity = SVSim.Database.Entities.Guild.Guild; + +namespace SVSim.UnitTests.Services.Guild; + +public class GuildServiceLeaveTests +{ + // ────────────────────────────────────────────────────────────────────────── + // Helper: add a member row + set Viewer.GuildId in one scope + // ────────────────────────────────────────────────────────────────────────── + 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(); + 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(); + } + + // ────────────────────────────────────────────────────────────────────────── + // LeaveAsync tests + // ────────────────────────────────────────────────────────────────────────── + + [Test] + public async Task LeaveAsync_regular_member_leaves_guild_persists_and_chat_event_fires() + { + using var factory = new SpyGuildChatFactory(); + var leaderId = await factory.SeedViewerAsync(76_561_198_200_000_001UL, "LvLeader"); + var memberId = await factory.SeedViewerAsync(76_561_198_200_000_002UL, "LvMember"); + + int guildId; + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.CreateAsync(leaderId, new("LeaveGuild1", (int)GuildActivity.All, (int)GuildJoinCondition.Free)); + Assert.That(r.IsOk, Is.True); + guildId = r.GuildId!.Value; + } + + await AddMemberDirectlyAsync(factory, guildId, memberId, GuildRole.Regular); + + // Regular member leaves. + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.LeaveAsync(memberId); + Assert.That(r.IsOk, Is.True); + } + + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + + // Guild must still exist. + var guild = await db.Guilds.FirstOrDefaultAsync(g => g.GuildId == guildId); + Assert.That(guild, Is.Not.Null, "Guild should persist after regular member leaves"); + Assert.That(guild!.BreakupAt, Is.Null, "Guild must not be soft-deleted"); + + // Member row must be gone. + var memberRow = await db.GuildMembers.FirstOrDefaultAsync(m => m.GuildId == guildId && m.ViewerId == memberId); + Assert.That(memberRow, Is.Null, "GuildMember row must be deleted"); + + // Viewer.GuildId must be cleared. + var viewer = await db.Viewers.FirstAsync(v => v.Id == memberId); + Assert.That(viewer.GuildId, Is.Null, "Viewer.GuildId must be null after leave"); + } + + // Chat event: Leave emitted via spy. + var leaveEmissions = factory.ChatSpy.Emissions + .Where(e => e.Type == GuildChatMessageType.Leave && e.GuildId == guildId && e.ActorId == memberId) + .ToList(); + Assert.That(leaveEmissions, Has.Count.GreaterThanOrEqualTo(1), "Leave chat event must be emitted"); + } + + [Test] + public async Task LeaveAsync_subleader_leaves_same_as_regular_member() + { + using var factory = new SpyGuildChatFactory(); + var leaderId = await factory.SeedViewerAsync(76_561_198_200_000_003UL, "LvLeader2"); + var subLeaderId = await factory.SeedViewerAsync(76_561_198_200_000_004UL, "LvSubLeader"); + + int guildId; + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.CreateAsync(leaderId, new("LeaveGuild2", (int)GuildActivity.All, (int)GuildJoinCondition.Free)); + Assert.That(r.IsOk, Is.True); + guildId = r.GuildId!.Value; + } + + await AddMemberDirectlyAsync(factory, guildId, subLeaderId, GuildRole.SubLeader); + + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.LeaveAsync(subLeaderId); + Assert.That(r.IsOk, Is.True); + } + + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var memberRow = await db.GuildMembers.FirstOrDefaultAsync(m => m.GuildId == guildId && m.ViewerId == subLeaderId); + Assert.That(memberRow, Is.Null, "SubLeader member row must be deleted after leave"); + var viewer = await db.Viewers.FirstAsync(v => v.Id == subLeaderId); + Assert.That(viewer.GuildId, Is.Null, "SubLeader.GuildId must be null after leave"); + } + + // Chat spy: Leave event emitted. + var leaveEmissions = factory.ChatSpy.Emissions + .Where(e => e.Type == GuildChatMessageType.Leave && e.GuildId == guildId && e.ActorId == subLeaderId) + .ToList(); + Assert.That(leaveEmissions, Has.Count.GreaterThanOrEqualTo(1), "SubLeader leave must emit Leave chat event"); + } + + [Test] + public async Task LeaveAsync_leader_with_remaining_members_is_blocked() + { + using var factory = new SVSimTestFactory(); + var leaderId = await factory.SeedViewerAsync(76_561_198_200_000_005UL, "LvLeader3"); + var memberId = await factory.SeedViewerAsync(76_561_198_200_000_006UL, "LvMember3"); + + int guildId; + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.CreateAsync(leaderId, new("LeaveGuild3", (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(); + var r = await svc.LeaveAsync(leaderId); + Assert.That(r.Code, Is.EqualTo(GuildOpResultCode.LeaderLeaveBlocked), + "Leader with remaining members must be blocked from leaving"); + } + } + + [Test] + public async Task LeaveAsync_sole_member_leader_auto_routes_to_breakup() + { + using var factory = new SVSimTestFactory(); + var leaderId = await factory.SeedViewerAsync(76_561_198_200_000_007UL, "LvLeader4"); + + int guildId; + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.CreateAsync(leaderId, new("LeaveGuild4", (int)GuildActivity.All, (int)GuildJoinCondition.Free)); + Assert.That(r.IsOk, Is.True); + guildId = r.GuildId!.Value; + } + + // Sole-member leader leaves. + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.LeaveAsync(leaderId); + Assert.That(r.IsOk, Is.True); + } + + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + + // Guild should be soft-deleted (BreakupAt set). + var guild = await db.Guilds.FirstOrDefaultAsync(g => g.GuildId == guildId); + Assert.That(guild, Is.Not.Null); + Assert.That(guild!.BreakupAt, Is.Not.Null, "Sole-member leader leave must trigger breakup (BreakupAt set)"); + + // No member rows should remain. + var count = await db.GuildMembers.CountAsync(m => m.GuildId == guildId); + Assert.That(count, Is.EqualTo(0), "All member rows should be deleted via breakup"); + + // Viewer.GuildId cleared. + var viewer = await db.Viewers.FirstAsync(v => v.Id == leaderId); + Assert.That(viewer.GuildId, Is.Null, "Leader.GuildId must be null after sole-member breakup"); + } + } + + // ────────────────────────────────────────────────────────────────────────── + // RemoveAsync tests + // ────────────────────────────────────────────────────────────────────────── + + [Test] + public async Task RemoveAsync_leader_removes_regular_member_row_deleted_chat_event_fires() + { + using var factory = new SpyGuildChatFactory(); + var leaderId = await factory.SeedViewerAsync(76_561_198_200_000_010UL, "RmLeader1"); + var memberId = await factory.SeedViewerAsync(76_561_198_200_000_011UL, "RmMember1"); + + int guildId; + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.CreateAsync(leaderId, new("RemoveGuild1", (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(); + var r = await svc.RemoveAsync(leaderId, memberId); + Assert.That(r.IsOk, Is.True); + } + + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + + // Target member row gone. + var memberRow = await db.GuildMembers.FirstOrDefaultAsync(m => m.GuildId == guildId && m.ViewerId == memberId); + Assert.That(memberRow, Is.Null, "Removed member row must be deleted"); + + // Target Viewer.GuildId cleared. + var viewer = await db.Viewers.FirstAsync(v => v.Id == memberId); + Assert.That(viewer.GuildId, Is.Null, "Removed member's GuildId must be null"); + + // Leader row still present. + var leaderRow = await db.GuildMembers.FirstOrDefaultAsync(m => m.GuildId == guildId && m.ViewerId == leaderId); + Assert.That(leaderRow, Is.Not.Null, "Leader must still be in the guild"); + } + + // Remove chat event: type=Remove, actor=leader, body contains target viewer id. + var removeEmissions = factory.ChatSpy.EmissionsWithBody + .Where(e => e.Type == GuildChatMessageType.Remove && e.GuildId == guildId && e.ActorId == leaderId) + .ToList(); + Assert.That(removeEmissions, Has.Count.GreaterThanOrEqualTo(1), "Remove chat event must be emitted"); + Assert.That(removeEmissions[0].Body, Does.Contain(memberId.ToString()), + "Remove event body must contain the target's viewer_id"); + } + + [Test] + public async Task RemoveAsync_non_leader_is_rejected() + { + using var factory = new SVSimTestFactory(); + var leaderId = await factory.SeedViewerAsync(76_561_198_200_000_012UL, "RmLeader2"); + var subLeaderId = await factory.SeedViewerAsync(76_561_198_200_000_013UL, "RmSubLdr2"); + var targetId = await factory.SeedViewerAsync(76_561_198_200_000_014UL, "RmTarget2"); + + int guildId; + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.CreateAsync(leaderId, new("RemoveGuild2", (int)GuildActivity.All, (int)GuildJoinCondition.Free)); + Assert.That(r.IsOk, Is.True); + guildId = r.GuildId!.Value; + } + + await AddMemberDirectlyAsync(factory, guildId, subLeaderId, GuildRole.SubLeader); + await AddMemberDirectlyAsync(factory, guildId, targetId, GuildRole.Regular); + + // SubLeader attempts to kick — must be rejected (only Leader can kick). + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.RemoveAsync(subLeaderId, targetId); + Assert.That(r.Code, Is.EqualTo(GuildOpResultCode.PermissionDenied), + "SubLeader must not be allowed to remove members"); + } + + // Target must still be in the guild. + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var memberRow = await db.GuildMembers.FirstOrDefaultAsync(m => m.GuildId == guildId && m.ViewerId == targetId); + Assert.That(memberRow, Is.Not.Null, "Target must still be in the guild after failed remove"); + } + } + + [Test] + public async Task RemoveAsync_leader_cannot_remove_themselves() + { + using var factory = new SVSimTestFactory(); + var leaderId = await factory.SeedViewerAsync(76_561_198_200_000_015UL, "RmLeader3"); + + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + await svc.CreateAsync(leaderId, new("RemoveGuild3", (int)GuildActivity.All, (int)GuildJoinCondition.Free)); + } + + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.RemoveAsync(leaderId, leaderId); + Assert.That(r.Code, Is.EqualTo(GuildOpResultCode.PermissionDenied), + "Leader must not be able to remove themselves"); + } + } + + [Test] + public async Task RemoveAsync_target_not_in_callers_guild_is_rejected() + { + using var factory = new SVSimTestFactory(); + var leaderId = await factory.SeedViewerAsync(76_561_198_200_000_016UL, "RmLeader4"); + var outsiderId = await factory.SeedViewerAsync(76_561_198_200_000_017UL, "Outsider4"); + + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + await svc.CreateAsync(leaderId, new("RemoveGuild4", (int)GuildActivity.All, (int)GuildJoinCondition.Free)); + } + + // outsiderId is not in any guild. + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var r = await svc.RemoveAsync(leaderId, outsiderId); + Assert.That(r.Code, Is.EqualTo(GuildOpResultCode.TargetNotInGuild), + "Target not in caller's guild must be rejected"); + } + } +} diff --git a/SVSim.UnitTests/Services/Guild/GuildServiceUpdateTests.cs b/SVSim.UnitTests/Services/Guild/GuildServiceUpdateTests.cs index 6b6b48ea..15da6f81 100644 --- a/SVSim.UnitTests/Services/Guild/GuildServiceUpdateTests.cs +++ b/SVSim.UnitTests/Services/Guild/GuildServiceUpdateTests.cs @@ -14,10 +14,12 @@ namespace SVSim.UnitTests.Services.Guild; internal sealed class SpyGuildChatService : IGuildChatService { public readonly List<(int GuildId, long ActorId, GuildChatMessageType Type)> Emissions = new(); + public readonly List<(int GuildId, long ActorId, GuildChatMessageType Type, string? Body)> EmissionsWithBody = new(); public Task EmitSystemEventAsync(int guildId, long actorViewerId, GuildChatMessageType type, string? body = null, CancellationToken ct = default) { Emissions.Add((guildId, actorViewerId, type)); + EmissionsWithBody.Add((guildId, actorViewerId, type, body)); return Task.CompletedTask; }