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 <noreply@anthropic.com>
This commit is contained in:
@@ -362,11 +362,46 @@ public sealed class GuildService : IGuildService
|
|||||||
return GuildOpResult.Ok;
|
return GuildOpResult.Ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<GuildOpResult> LeaveAsync(long viewerId, CancellationToken ct = default)
|
public async Task<GuildOpResult> LeaveAsync(long viewerId, CancellationToken ct = default)
|
||||||
=> throw new NotImplementedException();
|
{
|
||||||
|
var m = await _members.GetMembershipAsync(viewerId, ct);
|
||||||
|
if (m is null) return new(GuildOpResultCode.NotInGuild);
|
||||||
|
|
||||||
public Task<GuildOpResult> RemoveAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default)
|
var memberCount = await _members.CountByGuildAsync(m.GuildId, ct);
|
||||||
=> throw new NotImplementedException();
|
|
||||||
|
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<GuildOpResult> 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<GuildOpResult> ChangeRoleAsync(long callerViewerId, long targetViewerId, int newRoleId, CancellationToken ct = default)
|
public Task<GuildOpResult> ChangeRoleAsync(long callerViewerId, long targetViewerId, int newRoleId, CancellationToken ct = default)
|
||||||
=> throw new NotImplementedException();
|
=> throw new NotImplementedException();
|
||||||
|
|||||||
@@ -299,10 +299,20 @@ public sealed class GuildController : SVSimController
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("leave")]
|
[HttpPost("leave")]
|
||||||
public Task<ActionResult<EmptyResponse>> Leave([FromBody] BaseRequest _, CancellationToken ct) => Stub();
|
public async Task<ActionResult<EmptyResponse>> 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")]
|
[HttpPost("remove")]
|
||||||
public Task<ActionResult<EmptyResponse>> Remove([FromBody] GuildRemoveRequest req, CancellationToken ct) => Stub();
|
public async Task<ActionResult<EmptyResponse>> 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")]
|
[HttpPost("change_role")]
|
||||||
public Task<ActionResult<GuildChangeRoleResponse>> ChangeRole([FromBody] GuildChangeRoleRequest req, CancellationToken ct)
|
public Task<ActionResult<GuildChangeRoleResponse>> ChangeRole([FromBody] GuildChangeRoleRequest req, CancellationToken ct)
|
||||||
|
|||||||
338
SVSim.UnitTests/Services/Guild/GuildServiceLeaveTests.cs
Normal file
338
SVSim.UnitTests/Services/Guild/GuildServiceLeaveTests.cs
Normal file
@@ -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<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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// 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<IGuildService>();
|
||||||
|
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<IGuildService>();
|
||||||
|
var r = await svc.LeaveAsync(memberId);
|
||||||
|
Assert.That(r.IsOk, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var scope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
|
||||||
|
// 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<IGuildService>();
|
||||||
|
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<IGuildService>();
|
||||||
|
var r = await svc.LeaveAsync(subLeaderId);
|
||||||
|
Assert.That(r.IsOk, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var scope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
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<IGuildService>();
|
||||||
|
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<IGuildService>();
|
||||||
|
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<IGuildService>();
|
||||||
|
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<IGuildService>();
|
||||||
|
var r = await svc.LeaveAsync(leaderId);
|
||||||
|
Assert.That(r.IsOk, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var scope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
|
||||||
|
// 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<IGuildService>();
|
||||||
|
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<IGuildService>();
|
||||||
|
var r = await svc.RemoveAsync(leaderId, memberId);
|
||||||
|
Assert.That(r.IsOk, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var scope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
|
||||||
|
// 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<IGuildService>();
|
||||||
|
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<IGuildService>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
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<IGuildService>();
|
||||||
|
await svc.CreateAsync(leaderId, new("RemoveGuild3", (int)GuildActivity.All, (int)GuildJoinCondition.Free));
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var scope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var svc = scope.ServiceProvider.GetRequiredService<IGuildService>();
|
||||||
|
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<IGuildService>();
|
||||||
|
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<IGuildService>();
|
||||||
|
var r = await svc.RemoveAsync(leaderId, outsiderId);
|
||||||
|
Assert.That(r.Code, Is.EqualTo(GuildOpResultCode.TargetNotInGuild),
|
||||||
|
"Target not in caller's guild must be rejected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,10 +14,12 @@ namespace SVSim.UnitTests.Services.Guild;
|
|||||||
internal sealed class SpyGuildChatService : IGuildChatService
|
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)> 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)
|
public Task EmitSystemEventAsync(int guildId, long actorViewerId, GuildChatMessageType type, string? body = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Emissions.Add((guildId, actorViewerId, type));
|
Emissions.Add((guildId, actorViewerId, type));
|
||||||
|
EmissionsWithBody.Add((guildId, actorViewerId, type, body));
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user