diff --git a/SVSim.Database/Services/Guild/GuildService.cs b/SVSim.Database/Services/Guild/GuildService.cs index c76a6ccf..52a7f742 100644 --- a/SVSim.Database/Services/Guild/GuildService.cs +++ b/SVSim.Database/Services/Guild/GuildService.cs @@ -132,8 +132,24 @@ public sealed class GuildService : IGuildService public Task UpdateEmblemAsync(long viewerId, long emblemId, CancellationToken ct = default) => throw new NotImplementedException(); - public Task BreakupAsync(long viewerId, CancellationToken ct = default) - => throw new NotImplementedException(); + public async Task BreakupAsync(long viewerId, CancellationToken ct = default) + { + var membership = await _members.GetMembershipAsync(viewerId, ct); + if (membership is null) return new(GuildOpResultCode.NotInGuild); + if (membership.Role != GuildRole.Leader) return new(GuildOpResultCode.PermissionDenied); + + var now = DateTime.UtcNow; + var members = await _members.ListByGuildAsync(membership.GuildId, ct); + foreach (var m in members) await _viewers.ClearGuildIdAsync(m.ViewerId, ct); + + await _chatMessages.DeleteAllForGuildAsync(membership.GuildId, ct); + await _invites.DeleteAllForGuildAsync(membership.GuildId, ct); + await _joinRequests.DeleteAllForGuildAsync(membership.GuildId, ct); + await _members.DeleteAllForGuildAsync(membership.GuildId, ct); + await _guilds.MarkBrokenUpAsync(membership.GuildId, now, ct); + + return GuildOpResult.Ok; + } public Task InviteAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default) => throw new NotImplementedException(); diff --git a/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs b/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs index b6af3dce..1eea1007 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs @@ -52,10 +52,12 @@ public sealed class GuildController : SVSimController resp.GuildStatus = 2; // JOINING resp.JoinRequestCount = view.JoinRequestCount; resp.InviteCount = view.InviteCount; + var memberDtos = await ToMemberDtoListAsync(view.Members, viewerId, ct); + var leaderName = memberDtos.FirstOrDefault(m => m.ViewerId == view.Guild.LeaderViewerId)?.Name ?? ""; resp.Guild = new GuildBundle { - Detail = ToDetailDto(view.Guild, view.Members.Count), - Members = await ToMemberDtoListAsync(view.Members, viewerId, ct), + Detail = ToDetailDto(view.Guild, view.Members.Count, leaderName), + Members = memberDtos, }; } return resp; @@ -73,7 +75,12 @@ public sealed class GuildController : SVSimController } [HttpPost("breakup")] - public Task> Breakup([FromBody] BaseRequest _, CancellationToken ct) => Stub(); + public async Task> Breakup([FromBody] BaseRequest _, CancellationToken ct) + { + if (!TryGetViewerId(out var v)) return Unauthorized(); + var r = await _guild.BreakupAsync(v, ct); + return r.IsOk ? new EmptyResponse() : MapErrorToWire(r); + } [HttpPost("update")] public Task> Update([FromBody] GuildUpdateRequest req, CancellationToken ct) @@ -91,12 +98,36 @@ public sealed class GuildController : SVSimController => Task.FromResult>(new GuildSearchGuildResponse { List = new() }); [HttpPost("emblem_list")] - public Task> EmblemList([FromBody] BaseRequest _, CancellationToken ct) - => Task.FromResult>(new GuildEmblemListResponse { EmblemList = new() }); + public async Task> EmblemList([FromBody] BaseRequest _, CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + var viewer = await _db.Viewers + .AsNoTracking() + .Include(v => v.Emblems) + .FirstOrDefaultAsync(v => v.Id == viewerId, ct); + var emblems = viewer?.Emblems ?? new(); + return new GuildEmblemListResponse + { + EmblemList = emblems.Select(e => new GuildEmblemEntry { EmblemId = e.Id }).ToList() + }; + } [HttpPost("others_info")] - public Task> OthersInfo([FromBody] GuildOthersInfoRequest req, CancellationToken ct) - => Task.FromResult>(new GuildOthersInfoResponse()); + public async Task> OthersInfo([FromBody] GuildOthersInfoRequest req, CancellationToken ct) + { + if (!TryGetViewerId(out _)) return Unauthorized(); + var guild = await _guild.GetActiveAsync(req.GuildId, ct); + if (guild is null) return new GuildOthersInfoResponse(); + var memberCount = await _db.GuildMembers.CountAsync(m => m.GuildId == guild.GuildId, ct); + var leader = await _db.Viewers.AsNoTracking().FirstOrDefaultAsync(v => v.Id == guild.LeaderViewerId, ct); + return new GuildOthersInfoResponse + { + Guild = new GuildDetailSubTree + { + Detail = ToDetailDto(guild, memberCount, leader?.DisplayName ?? "") + } + }; + } [HttpPost("friend_list")] public Task> FriendList([FromBody] BaseRequest _, CancellationToken ct) @@ -148,7 +179,7 @@ public sealed class GuildController : SVSimController // ===== Private helpers ===== - private static GuildDetailDto ToDetailDto(SVSim.Database.Entities.Guild.Guild guild, int memberCount) => new() + private static GuildDetailDto ToDetailDto(SVSim.Database.Entities.Guild.Guild guild, int memberCount, string leaderName = "") => new() { GuildId = guild.GuildId, GuildName = guild.Name, @@ -158,8 +189,7 @@ public sealed class GuildController : SVSimController Activity = (int)guild.Activity, MemberNum = memberCount, LeaderViewerId = guild.LeaderViewerId, - // LeaderName will be filled via members list join — leave "" here; caller populates it from member data if needed. - LeaderName = "", + LeaderName = leaderName, }; private async Task> ToMemberDtoListAsync( diff --git a/SVSim.UnitTests/Services/Guild/GuildServiceBreakupTests.cs b/SVSim.UnitTests/Services/Guild/GuildServiceBreakupTests.cs new file mode 100644 index 00000000..815f892b --- /dev/null +++ b/SVSim.UnitTests/Services/Guild/GuildServiceBreakupTests.cs @@ -0,0 +1,152 @@ +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Entities.Guild; +using SVSim.Database.Services.Guild; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services.Guild; + +public class GuildServiceBreakupTests +{ + private const string BaseReq = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; + + [Test] + public async Task BreakupAsync_soft_deletes_guild_and_cascade_hard_deletes_dependents() + { + using var factory = new SVSimTestFactory(); + var leaderId = await factory.SeedViewerAsync(76_561_198_100_000_010UL, "BreakupLeader"); + var memberId = await factory.SeedViewerAsync(76_561_198_100_000_011UL, "BreakupMember"); + + int guildId; + + // Create the guild as leader. + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var res = await svc.CreateAsync(leaderId, new("BreakupGuild", (int)GuildActivity.All, (int)GuildJoinCondition.Free)); + Assert.That(res.IsOk, Is.True); + guildId = res.GuildId!.Value; + } + + // Add member2 directly via DbContext (simulates a later join). + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.GuildMembers.Add(new GuildMember + { + GuildId = guildId, + ViewerId = memberId, + Role = GuildRole.Regular, + JoinedAt = DateTime.UtcNow, + }); + var viewer = await db.Viewers.FirstAsync(v => v.Id == memberId); + viewer.GuildId = guildId; + await db.SaveChangesAsync(); + } + + // Breakup as leader. + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var res = await svc.BreakupAsync(leaderId); + Assert.That(res.IsOk, Is.True); + } + + // Assert: guild has BreakupAt set. + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + + var guild = await db.Guilds.FirstOrDefaultAsync(g => g.GuildId == guildId); + Assert.That(guild, Is.Not.Null, "Guild row should still exist (soft-delete)"); + Assert.That(guild!.BreakupAt, Is.Not.Null, "BreakupAt must be set"); + + // 0 members remain. + var memberCount = await db.GuildMembers.CountAsync(m => m.GuildId == guildId); + Assert.That(memberCount, Is.EqualTo(0), "All GuildMember rows must be hard-deleted"); + + // Both viewers' GuildId is null. + var leaderViewer = await db.Viewers.FirstAsync(v => v.Id == leaderId); + var memberViewer = await db.Viewers.FirstAsync(v => v.Id == memberId); + Assert.That(leaderViewer.GuildId, Is.Null, "Leader's GuildId should be cleared"); + Assert.That(memberViewer.GuildId, Is.Null, "Member's GuildId should be cleared"); + } + } + + [Test] + public async Task BreakupAsync_returns_PermissionDenied_for_non_leader() + { + using var factory = new SVSimTestFactory(); + var leaderId = await factory.SeedViewerAsync(76_561_198_100_000_012UL, "NLLeader"); + var memberId = await factory.SeedViewerAsync(76_561_198_100_000_013UL, "NLMember"); + + int guildId; + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var res = await svc.CreateAsync(leaderId, new("NLGuild", (int)GuildActivity.All, (int)GuildJoinCondition.Free)); + Assert.That(res.IsOk, Is.True); + guildId = res.GuildId!.Value; + } + + // Directly add member. + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.GuildMembers.Add(new GuildMember + { + GuildId = guildId, + ViewerId = memberId, + Role = GuildRole.Regular, + JoinedAt = DateTime.UtcNow, + }); + var viewer = await db.Viewers.FirstAsync(v => v.Id == memberId); + viewer.GuildId = guildId; + await db.SaveChangesAsync(); + } + + // Non-leader tries to break up. + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var res = await svc.BreakupAsync(memberId); + Assert.That(res.Code, Is.EqualTo(GuildOpResultCode.PermissionDenied)); + } + } + + [Test] + public async Task GuildInfo_JOINING_response_populates_leader_name() + { + using var factory = new SVSimTestFactory(); + var viewerId = await factory.SeedViewerAsync(76_561_198_100_000_014UL, "LeaderDisplayName"); + + // Create guild. + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var res = await svc.CreateAsync(viewerId, new("LeaderNameGuild", (int)GuildActivity.All, (int)GuildJoinCondition.Free)); + Assert.That(res.IsOk, Is.True); + } + + // Hit /guild/info via HTTP (middleware is no-op in test mode — no UnityPlayer UA). + using var client = factory.CreateAuthenticatedClient(viewerId); + var resp = await client.PostAsync("/guild/info", + new StringContent(BaseReq, Encoding.UTF8, "application/json")); + var body = await resp.Content.ReadAsStringAsync(); + Assert.That(resp.IsSuccessStatusCode, Is.True, $"guild/info failed: {body}"); + + using var doc = JsonDocument.Parse(body); + var leaderName = doc.RootElement + .GetProperty("guild") + .GetProperty("detail") + .GetProperty("leader_name") + .GetString(); + + Assert.That(leaderName, Is.EqualTo("LeaderDisplayName"), + "leader_name in guild/info detail must equal the viewer's display name"); + } +}