diff --git a/SVSim.Database/Services/Guild/GuildService.cs b/SVSim.Database/Services/Guild/GuildService.cs index 547faf34..9e8cf542 100644 --- a/SVSim.Database/Services/Guild/GuildService.cs +++ b/SVSim.Database/Services/Guild/GuildService.cs @@ -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 GetActiveAsync(int guildId, CancellationToken ct = default) => _guilds.GetActiveByIdAsync(guildId, ct); - public Task> SearchAsync(string name, int activity, int joinCondition, int memberBucket, CancellationToken ct = default) - => throw new NotImplementedException(); + public async Task> SearchAsync(string name, int activity, int joinCondition, int memberBucket, CancellationToken ct = default) + { + var cfg = _config.Get(); + 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 CreateAsync(long viewerId, CreateGuildRequest req, CancellationToken ct = default) { diff --git a/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs b/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs index 9183b705..0dcb1454 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs @@ -131,8 +131,12 @@ public sealed class GuildController : SVSimController } [HttpPost("search_guild")] - public Task> SearchGuild([FromBody] GuildSearchGuildRequest req, CancellationToken ct) - => Task.FromResult>(new GuildSearchGuildResponse { List = new() }); + public async Task> 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> EmblemList([FromBody] BaseRequest _, CancellationToken ct) diff --git a/SVSim.UnitTests/Integration/Guild/GuildSearchFlowTests.cs b/SVSim.UnitTests/Integration/Guild/GuildSearchFlowTests.cs new file mode 100644 index 00000000..0abf0d01 --- /dev/null +++ b/SVSim.UnitTests/Integration/Guild/GuildSearchFlowTests.cs @@ -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 = ""; + + /// + /// Seeds three guilds with different activity / join_condition / member counts directly + /// via DbContext (bypassing the service to keep the test focused on /search_guild). + /// + 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(); + 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 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 GetListNames(JsonElement root) + { + var names = new List(); + 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")); + } +} diff --git a/SVSim.UnitTests/Wire/GuildWireShape.cs b/SVSim.UnitTests/Wire/GuildWireShape.cs index cb6876b8..146e726f 100644 --- a/SVSim.UnitTests/Wire/GuildWireShape.cs +++ b/SVSim.UnitTests/Wire/GuildWireShape.cs @@ -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() {