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:
gamer147
2026-06-27 13:01:58 -04:00
parent 09104f4e73
commit 3f8f04eb1a
4 changed files with 263 additions and 4 deletions

View File

@@ -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)
{

View File

@@ -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)

View 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"));
}
}

View File

@@ -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()
{