feat(guild): breakup + emblem_list + others_info
Implements GuildService.BreakupAsync (soft-delete guild, hard-delete dependents, clear viewer GuildId pointers). Wires /guild/breakup, /guild/emblem_list, /guild/others_info in GuildController. Fixes ToDetailDto to accept and populate leader_name from the member list. Adds GuildServiceBreakupTests (3 tests: cascade delete, permission guard, leader_name HTTP regression). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -132,8 +132,24 @@ public sealed class GuildService : IGuildService
|
|||||||
public Task<GuildOpResult> UpdateEmblemAsync(long viewerId, long emblemId, CancellationToken ct = default)
|
public Task<GuildOpResult> UpdateEmblemAsync(long viewerId, long emblemId, CancellationToken ct = default)
|
||||||
=> throw new NotImplementedException();
|
=> throw new NotImplementedException();
|
||||||
|
|
||||||
public Task<GuildOpResult> BreakupAsync(long viewerId, CancellationToken ct = default)
|
public async Task<GuildOpResult> BreakupAsync(long viewerId, CancellationToken ct = default)
|
||||||
=> throw new NotImplementedException();
|
{
|
||||||
|
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<GuildOpResult> InviteAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default)
|
public Task<GuildOpResult> InviteAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default)
|
||||||
=> throw new NotImplementedException();
|
=> throw new NotImplementedException();
|
||||||
|
|||||||
@@ -52,10 +52,12 @@ public sealed class GuildController : SVSimController
|
|||||||
resp.GuildStatus = 2; // JOINING
|
resp.GuildStatus = 2; // JOINING
|
||||||
resp.JoinRequestCount = view.JoinRequestCount;
|
resp.JoinRequestCount = view.JoinRequestCount;
|
||||||
resp.InviteCount = view.InviteCount;
|
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
|
resp.Guild = new GuildBundle
|
||||||
{
|
{
|
||||||
Detail = ToDetailDto(view.Guild, view.Members.Count),
|
Detail = ToDetailDto(view.Guild, view.Members.Count, leaderName),
|
||||||
Members = await ToMemberDtoListAsync(view.Members, viewerId, ct),
|
Members = memberDtos,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return resp;
|
return resp;
|
||||||
@@ -73,7 +75,12 @@ public sealed class GuildController : SVSimController
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("breakup")]
|
[HttpPost("breakup")]
|
||||||
public Task<ActionResult<EmptyResponse>> Breakup([FromBody] BaseRequest _, CancellationToken ct) => Stub();
|
public async Task<ActionResult<EmptyResponse>> 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")]
|
[HttpPost("update")]
|
||||||
public Task<ActionResult<GuildUpdateResponse>> Update([FromBody] GuildUpdateRequest req, CancellationToken ct)
|
public Task<ActionResult<GuildUpdateResponse>> Update([FromBody] GuildUpdateRequest req, CancellationToken ct)
|
||||||
@@ -91,12 +98,36 @@ public sealed class GuildController : SVSimController
|
|||||||
=> Task.FromResult<ActionResult<GuildSearchGuildResponse>>(new GuildSearchGuildResponse { List = new() });
|
=> Task.FromResult<ActionResult<GuildSearchGuildResponse>>(new GuildSearchGuildResponse { List = new() });
|
||||||
|
|
||||||
[HttpPost("emblem_list")]
|
[HttpPost("emblem_list")]
|
||||||
public Task<ActionResult<GuildEmblemListResponse>> EmblemList([FromBody] BaseRequest _, CancellationToken ct)
|
public async Task<ActionResult<GuildEmblemListResponse>> EmblemList([FromBody] BaseRequest _, CancellationToken ct)
|
||||||
=> Task.FromResult<ActionResult<GuildEmblemListResponse>>(new GuildEmblemListResponse { EmblemList = new() });
|
{
|
||||||
|
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")]
|
[HttpPost("others_info")]
|
||||||
public Task<ActionResult<GuildOthersInfoResponse>> OthersInfo([FromBody] GuildOthersInfoRequest req, CancellationToken ct)
|
public async Task<ActionResult<GuildOthersInfoResponse>> OthersInfo([FromBody] GuildOthersInfoRequest req, CancellationToken ct)
|
||||||
=> Task.FromResult<ActionResult<GuildOthersInfoResponse>>(new GuildOthersInfoResponse());
|
{
|
||||||
|
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")]
|
[HttpPost("friend_list")]
|
||||||
public Task<ActionResult<GuildFriendListResponse>> FriendList([FromBody] BaseRequest _, CancellationToken ct)
|
public Task<ActionResult<GuildFriendListResponse>> FriendList([FromBody] BaseRequest _, CancellationToken ct)
|
||||||
@@ -148,7 +179,7 @@ public sealed class GuildController : SVSimController
|
|||||||
|
|
||||||
// ===== Private helpers =====
|
// ===== 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,
|
GuildId = guild.GuildId,
|
||||||
GuildName = guild.Name,
|
GuildName = guild.Name,
|
||||||
@@ -158,8 +189,7 @@ public sealed class GuildController : SVSimController
|
|||||||
Activity = (int)guild.Activity,
|
Activity = (int)guild.Activity,
|
||||||
MemberNum = memberCount,
|
MemberNum = memberCount,
|
||||||
LeaderViewerId = guild.LeaderViewerId,
|
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<List<GuildMemberInfoDto>> ToMemberDtoListAsync(
|
private async Task<List<GuildMemberInfoDto>> ToMemberDtoListAsync(
|
||||||
|
|||||||
152
SVSim.UnitTests/Services/Guild/GuildServiceBreakupTests.cs
Normal file
152
SVSim.UnitTests/Services/Guild/GuildServiceBreakupTests.cs
Normal file
@@ -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<IGuildService>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
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<IGuildService>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
|
||||||
|
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<IGuildService>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
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<IGuildService>();
|
||||||
|
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<IGuildService>();
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user