feat(guild): /guild/search_guild
Implement SearchAsync in GuildService (wraps IGuildRepository.SearchAsync + CountBatchByGuildIdsAsync), wire the controller action, add 7 integration tests (GuildSearchFlowTests) covering no-filter / activity / join_condition / bucket-1 / bucket-3 / name-prefix / flat-entry shape, and a wire-shape test in GuildWireShape confirming list entries are flat (no detail wrapper). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Entities.Guild;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.Database.Repositories.Guild;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.Database.Services;
|
||||
@@ -60,8 +61,13 @@ public sealed class GuildService : IGuildService
|
||||
public Task<Entities.Guild.Guild?> GetActiveAsync(int guildId, CancellationToken ct = default)
|
||||
=> _guilds.GetActiveByIdAsync(guildId, ct);
|
||||
|
||||
public Task<IReadOnlyList<GuildSearchEntry>> SearchAsync(string name, int activity, int joinCondition, int memberBucket, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
public async Task<IReadOnlyList<GuildSearchEntry>> SearchAsync(string name, int activity, int joinCondition, int memberBucket, CancellationToken ct = default)
|
||||
{
|
||||
var cfg = _config.Get<GuildConfig>();
|
||||
var rows = await _guilds.SearchAsync(name ?? "", activity, joinCondition, memberBucket, cfg.MaxMemberNum, cfg.SearchResultCap, ct);
|
||||
var memberCounts = await _members.CountBatchByGuildIdsAsync(rows.Select(r => r.GuildId).ToList(), ct);
|
||||
return rows.Select(r => new GuildSearchEntry(r, memberCounts.GetValueOrDefault(r.GuildId, 0))).ToList();
|
||||
}
|
||||
|
||||
public async Task<GuildOpResult> CreateAsync(long viewerId, CreateGuildRequest req, CancellationToken ct = default)
|
||||
{
|
||||
|
||||
@@ -131,8 +131,12 @@ public sealed class GuildController : SVSimController
|
||||
}
|
||||
|
||||
[HttpPost("search_guild")]
|
||||
public Task<ActionResult<GuildSearchGuildResponse>> SearchGuild([FromBody] GuildSearchGuildRequest req, CancellationToken ct)
|
||||
=> Task.FromResult<ActionResult<GuildSearchGuildResponse>>(new GuildSearchGuildResponse { List = new() });
|
||||
public async Task<ActionResult<GuildSearchGuildResponse>> SearchGuild([FromBody] GuildSearchGuildRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var v)) return Unauthorized();
|
||||
var entries = await _guild.SearchAsync(req.GuildName ?? "", req.Activity, req.JoinCondition, req.MemberConditionRange, ct);
|
||||
return new GuildSearchGuildResponse { List = entries.Select(e => ToDetailDto(e.Guild, e.MemberNum)).ToList() };
|
||||
}
|
||||
|
||||
[HttpPost("emblem_list")]
|
||||
public async Task<ActionResult<GuildEmblemListResponse>> EmblemList([FromBody] BaseRequest _, CancellationToken ct)
|
||||
|
||||
235
SVSim.UnitTests/Integration/Guild/GuildSearchFlowTests.cs
Normal file
235
SVSim.UnitTests/Integration/Guild/GuildSearchFlowTests.cs
Normal file
@@ -0,0 +1,235 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Entities.Guild;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
using GuildEntity = SVSim.Database.Entities.Guild.Guild;
|
||||
|
||||
namespace SVSim.UnitTests.Integration.Guild;
|
||||
|
||||
public class GuildSearchFlowTests
|
||||
{
|
||||
private const string Vid = "0";
|
||||
private const int Sid = 0;
|
||||
private const string Stk = "";
|
||||
|
||||
/// <summary>
|
||||
/// Seeds three guilds with different activity / join_condition / member counts directly
|
||||
/// via DbContext (bypassing the service to keep the test focused on /search_guild).
|
||||
/// </summary>
|
||||
private static async Task<(int SmallFree, int MedApproval, int LargeInvite)> SeedThreeGuildsAsync(
|
||||
SVSimTestFactory factory, long leaderId)
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Small guild (bucket=1: 1..10 members), activity=1, join_condition=1 (Free)
|
||||
var gSmall = new GuildEntity
|
||||
{
|
||||
GuildId = 100_000_901,
|
||||
Name = "SmallFreeGuild",
|
||||
Description = "",
|
||||
LeaderViewerId = leaderId,
|
||||
EmblemId = 100_000_000L,
|
||||
Activity = GuildActivity.All,
|
||||
JoinCondition = GuildJoinCondition.Free,
|
||||
CreatedAt = now,
|
||||
};
|
||||
db.Guilds.Add(gSmall);
|
||||
// 5 members (leader + 4 extras)
|
||||
db.GuildMembers.Add(new GuildMember { GuildId = 100_000_901, ViewerId = leaderId, Role = GuildRole.Leader, JoinedAt = now });
|
||||
for (int i = 1; i <= 4; i++)
|
||||
db.GuildMembers.Add(new GuildMember { GuildId = 100_000_901, ViewerId = 900_000_000 + i, Role = GuildRole.Regular, JoinedAt = now });
|
||||
|
||||
// Medium guild (bucket=2: 11..25 members), activity=2, join_condition=2 (Approval)
|
||||
var gMed = new GuildEntity
|
||||
{
|
||||
GuildId = 100_000_902,
|
||||
Name = "MedApprovalGuild",
|
||||
Description = "",
|
||||
LeaderViewerId = leaderId,
|
||||
EmblemId = 100_000_000L,
|
||||
Activity = (GuildActivity)2,
|
||||
JoinCondition = GuildJoinCondition.Approval,
|
||||
CreatedAt = now,
|
||||
};
|
||||
db.Guilds.Add(gMed);
|
||||
// 12 members
|
||||
for (int i = 1; i <= 12; i++)
|
||||
db.GuildMembers.Add(new GuildMember { GuildId = 100_000_902, ViewerId = 900_001_000 + i, Role = GuildRole.Regular, JoinedAt = now });
|
||||
|
||||
// Large guild (bucket=3: 26..MaxMemberNum members), activity=3, join_condition=3 (Invite)
|
||||
var gLarge = new GuildEntity
|
||||
{
|
||||
GuildId = 100_000_903,
|
||||
Name = "LargeInviteGuild",
|
||||
Description = "",
|
||||
LeaderViewerId = leaderId,
|
||||
EmblemId = 100_000_000L,
|
||||
Activity = (GuildActivity)3,
|
||||
JoinCondition = GuildJoinCondition.OnlyInvite,
|
||||
CreatedAt = now,
|
||||
};
|
||||
db.Guilds.Add(gLarge);
|
||||
// 27 members
|
||||
for (int i = 1; i <= 27; i++)
|
||||
db.GuildMembers.Add(new GuildMember { GuildId = 100_000_903, ViewerId = 900_002_000 + i, Role = GuildRole.Regular, JoinedAt = now });
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return (100_000_901, 100_000_902, 100_000_903);
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> PostSearchAsync(HttpClient client, string guildName, int activity, int joinCondition, int memberConditionRange)
|
||||
{
|
||||
var resp = await client.PostAsync("/guild/search_guild",
|
||||
JsonContent.Create(new
|
||||
{
|
||||
guild_name = guildName,
|
||||
activity,
|
||||
join_condition = joinCondition,
|
||||
member_condition_range = memberConditionRange,
|
||||
viewer_id = Vid,
|
||||
steam_id = Sid,
|
||||
steam_session_ticket = Stk,
|
||||
}));
|
||||
Assert.That(resp.IsSuccessStatusCode, Is.True, $"search_guild HTTP {resp.StatusCode}: {await resp.Content.ReadAsStringAsync()}");
|
||||
var json = await resp.Content.ReadAsStringAsync();
|
||||
return JsonDocument.Parse(json).RootElement.Clone();
|
||||
}
|
||||
|
||||
private static List<string> GetListNames(JsonElement root)
|
||||
{
|
||||
var names = new List<string>();
|
||||
var arr = root.GetProperty("list");
|
||||
foreach (var entry in arr.EnumerateArray())
|
||||
names.Add(entry.GetProperty("guild_name").GetString()!);
|
||||
return names;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SearchGuild_no_filter_returns_all_guilds()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var leaderId = await factory.SeedViewerAsync(76_561_198_300_000_001UL, "SearchLeader1");
|
||||
await SeedThreeGuildsAsync(factory, leaderId);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(leaderId);
|
||||
var root = await PostSearchAsync(client, "", 0, 0, 0);
|
||||
|
||||
var names = GetListNames(root);
|
||||
Assert.That(names, Does.Contain("SmallFreeGuild"), "All-filter should include SmallFreeGuild");
|
||||
Assert.That(names, Does.Contain("MedApprovalGuild"), "All-filter should include MedApprovalGuild");
|
||||
Assert.That(names, Does.Contain("LargeInviteGuild"), "All-filter should include LargeInviteGuild");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SearchGuild_activity_filter_returns_matching_guilds_only()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var leaderId = await factory.SeedViewerAsync(76_561_198_300_000_002UL, "SearchLeader2");
|
||||
await SeedThreeGuildsAsync(factory, leaderId);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(leaderId);
|
||||
// activity=2 should return only MedApprovalGuild
|
||||
var root = await PostSearchAsync(client, "", 2, 0, 0);
|
||||
var names = GetListNames(root);
|
||||
|
||||
Assert.That(names, Does.Contain("MedApprovalGuild"), "activity=2 should match MedApprovalGuild");
|
||||
Assert.That(names, Does.Not.Contain("SmallFreeGuild"), "activity=2 should not match SmallFreeGuild (activity=1)");
|
||||
Assert.That(names, Does.Not.Contain("LargeInviteGuild"), "activity=2 should not match LargeInviteGuild (activity=3)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SearchGuild_join_condition_filter_returns_matching_guilds_only()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var leaderId = await factory.SeedViewerAsync(76_561_198_300_000_003UL, "SearchLeader3");
|
||||
await SeedThreeGuildsAsync(factory, leaderId);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(leaderId);
|
||||
// join_condition=1 (Free) should return only SmallFreeGuild
|
||||
var root = await PostSearchAsync(client, "", 0, 1, 0);
|
||||
var names = GetListNames(root);
|
||||
|
||||
Assert.That(names, Does.Contain("SmallFreeGuild"), "join_condition=1 should match SmallFreeGuild");
|
||||
Assert.That(names, Does.Not.Contain("MedApprovalGuild"), "join_condition=1 should not match MedApprovalGuild (approval)");
|
||||
Assert.That(names, Does.Not.Contain("LargeInviteGuild"), "join_condition=1 should not match LargeInviteGuild (invite)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SearchGuild_bucket1_returns_small_guilds()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var leaderId = await factory.SeedViewerAsync(76_561_198_300_000_004UL, "SearchLeader4");
|
||||
await SeedThreeGuildsAsync(factory, leaderId);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(leaderId);
|
||||
// bucket=1: 1..10 members
|
||||
var root = await PostSearchAsync(client, "", 0, 0, 1);
|
||||
var names = GetListNames(root);
|
||||
|
||||
Assert.That(names, Does.Contain("SmallFreeGuild"), "bucket=1 should include SmallFreeGuild (5 members)");
|
||||
Assert.That(names, Does.Not.Contain("MedApprovalGuild"), "bucket=1 should not include MedApprovalGuild (12 members)");
|
||||
Assert.That(names, Does.Not.Contain("LargeInviteGuild"), "bucket=1 should not include LargeInviteGuild (27 members)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SearchGuild_bucket3_returns_large_guilds()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var leaderId = await factory.SeedViewerAsync(76_561_198_300_000_005UL, "SearchLeader5");
|
||||
await SeedThreeGuildsAsync(factory, leaderId);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(leaderId);
|
||||
// bucket=3: 26..MaxMemberNum
|
||||
var root = await PostSearchAsync(client, "", 0, 0, 3);
|
||||
var names = GetListNames(root);
|
||||
|
||||
Assert.That(names, Does.Contain("LargeInviteGuild"), "bucket=3 should include LargeInviteGuild (27 members)");
|
||||
Assert.That(names, Does.Not.Contain("SmallFreeGuild"), "bucket=3 should not include SmallFreeGuild (5 members)");
|
||||
Assert.That(names, Does.Not.Contain("MedApprovalGuild"), "bucket=3 should not include MedApprovalGuild (12 members)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SearchGuild_name_prefix_returns_matching_guilds_only()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var leaderId = await factory.SeedViewerAsync(76_561_198_300_000_006UL, "SearchLeader6");
|
||||
await SeedThreeGuildsAsync(factory, leaderId);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(leaderId);
|
||||
// prefix "Small" should match only SmallFreeGuild
|
||||
var root = await PostSearchAsync(client, "Small", 0, 0, 0);
|
||||
var names = GetListNames(root);
|
||||
|
||||
Assert.That(names, Does.Contain("SmallFreeGuild"), "name 'Small' should match SmallFreeGuild");
|
||||
Assert.That(names, Does.Not.Contain("MedApprovalGuild"), "name 'Small' should not match MedApprovalGuild");
|
||||
Assert.That(names, Does.Not.Contain("LargeInviteGuild"), "name 'Small' should not match LargeInviteGuild");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SearchGuild_response_list_entries_are_flat_not_wrapped()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var leaderId = await factory.SeedViewerAsync(76_561_198_300_000_007UL, "SearchLeader7");
|
||||
await SeedThreeGuildsAsync(factory, leaderId);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(leaderId);
|
||||
var root = await PostSearchAsync(client, "SmallFreeGuild", 0, 0, 0);
|
||||
|
||||
var arr = root.GetProperty("list");
|
||||
Assert.That(arr.ValueKind, Is.EqualTo(JsonValueKind.Array));
|
||||
Assert.That(arr.GetArrayLength(), Is.GreaterThanOrEqualTo(1));
|
||||
|
||||
var entry = arr[0];
|
||||
// guild_id is directly on the entry, not under a "detail" wrapper
|
||||
Assert.That(entry.TryGetProperty("guild_id", out _), Is.True, "guild_id should be flat on each list entry");
|
||||
Assert.That(entry.TryGetProperty("detail", out _), Is.False, "no 'detail' wrapper on search list entries");
|
||||
Assert.That(entry.TryGetProperty("guild_name", out var gn), Is.True);
|
||||
Assert.That(gn.GetString(), Is.EqualTo("SmallFreeGuild"));
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,20 @@ public class GuildWireShape
|
||||
Assert.That(guildNode.TryGetProperty("guild_id", out _), Is.False, "guild_id must be inside detail, not at guild level");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GuildSearchGuild_response_serializes_with_flat_list_entries()
|
||||
{
|
||||
var resp = new GuildSearchGuildResponse { List = new() {
|
||||
new GuildDetailDto { GuildId = 100_000_001, GuildName = "Alpha", LeaderName = "Lead", LeaderViewerId = 7, Activity = 1, JoinCondition = 1, MemberNum = 5, GuildEmblemId = 100000000, Description = "" }
|
||||
} };
|
||||
var json = JsonSerializer.Serialize(resp, Opts);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var arr = doc.RootElement.GetProperty("list");
|
||||
Assert.That(arr.ValueKind, Is.EqualTo(JsonValueKind.Array));
|
||||
Assert.That(arr[0].GetProperty("guild_id").GetString(), Is.EqualTo("100000001"));
|
||||
Assert.That(arr[0].TryGetProperty("detail", out _), Is.False, "search list entries are flat, no detail wrapper");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GuildInfo_non_joined_serializes_to_prod_shape()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user