diff --git a/SVSim.Database/Repositories/Guild/GuildChatMessageRepository.cs b/SVSim.Database/Repositories/Guild/GuildChatMessageRepository.cs index c20bf1f8..e82a9c8f 100644 --- a/SVSim.Database/Repositories/Guild/GuildChatMessageRepository.cs +++ b/SVSim.Database/Repositories/Guild/GuildChatMessageRepository.cs @@ -142,6 +142,12 @@ public sealed class GuildChatMessageRepository : IGuildChatMessageRepository return max ?? 0; } + public async Task GetMaxMessageIdSafelyAsync(int guildId, CancellationToken ct = default) + { + var v = await GetMaxMessageIdAsync(guildId, ct); + return v == 0 ? null : (long?)v; + } + public async Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default) { var messages = await _db.GuildChatMessages.Where(m => m.GuildId == guildId).ToListAsync(ct); diff --git a/SVSim.Database/Repositories/Guild/IGuildChatMessageRepository.cs b/SVSim.Database/Repositories/Guild/IGuildChatMessageRepository.cs index 8e018105..6f99bb2c 100644 --- a/SVSim.Database/Repositories/Guild/IGuildChatMessageRepository.cs +++ b/SVSim.Database/Repositories/Guild/IGuildChatMessageRepository.cs @@ -12,6 +12,14 @@ public interface IGuildChatMessageRepository Task> GetWindowAsync(int guildId, int start, int direction, int limit, CancellationToken ct = default); Task GetMaxMessageIdAsync(int guildId, CancellationToken ct = default); + + /// + /// Returns the highest message_id for the guild, or null if no messages exist. + /// Adapts (which returns 0 when empty) for callers + /// that need null-vs-present semantics (e.g. GuildNotification.guild_room_message_id). + /// + Task GetMaxMessageIdSafelyAsync(int guildId, CancellationToken ct = default); + Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default); /// Fetch a single message by (guildId, messageId). Returns null if not found. diff --git a/SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs b/SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs index 8c61c683..9f284a74 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs @@ -4,8 +4,10 @@ using Microsoft.AspNetCore.Mvc; using SVSim.Database.Models; using SVSim.Database.Models.Config; using SVSim.Database.Repositories.Globals; +using SVSim.Database.Repositories.Guild; using SVSim.Database.Repositories.Viewer; using SVSim.Database.Services; +using SVSim.Database.Services.Guild; using SVSim.EmulatedEntrypoint.Constants; using SVSim.EmulatedEntrypoint.Infrastructure; using SVSim.EmulatedEntrypoint.Models.Dtos; @@ -31,10 +33,16 @@ public class MyPageController : SVSimController private readonly IArenaTwoPickRunRepository _arenaTwoPickRuns; private readonly IHomeDialogSessionTracker _homeDialogTracker; private readonly ILoginBonusService _loginBonus; + private readonly IGuildService _guild; + private readonly IGuildInviteRepository _invites; + private readonly IGuildJoinRequestRepository _joinRequests; + private readonly IGuildChatMessageRepository _chat; public MyPageController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository, IGameConfigService config, IArenaTwoPickRunRepository arenaTwoPickRuns, - IHomeDialogSessionTracker homeDialogTracker, ILoginBonusService loginBonus) + IHomeDialogSessionTracker homeDialogTracker, ILoginBonusService loginBonus, + IGuildService guild, IGuildInviteRepository invites, + IGuildJoinRequestRepository joinRequests, IGuildChatMessageRepository chat) { _viewerRepository = viewerRepository; _globalsRepository = globalsRepository; @@ -42,6 +50,10 @@ public class MyPageController : SVSimController _arenaTwoPickRuns = arenaTwoPickRuns; _homeDialogTracker = homeDialogTracker; _loginBonus = loginBonus; + _guild = guild; + _invites = invites; + _joinRequests = joinRequests; + _chat = chat; } [HttpPost("index")] @@ -90,7 +102,7 @@ public class MyPageController : SVSimController ReceiveFriendApplyCount = 0, // TODO(mypage-stub): viewer friend-request inbox UnreadPresentCount = 0, // TODO(mypage-stub): viewer presents/mail FriendBattleInviteCount = 0, // TODO(mypage-stub): viewer room-invite count - GuildNotification = new GuildNotification(), // TODO(mypage-stub): viewer guild state + GuildNotification = await BuildGuildNotificationAsync(viewer.Id, HttpContext.RequestAborted), LastAnnounceId = 0, // TODO(mypage-stub): globals announcement metadata LastAnnounceUpdateTime = string.Empty, // TODO(mypage-stub): globals announcement metadata FeatureMaintenanceList = new(), // TODO(mypage-stub): FeatureMaintenanceEntry rows @@ -188,6 +200,20 @@ public class MyPageController : SVSimController }; } + private async Task BuildGuildNotificationAsync(long viewerId, CancellationToken ct) + { + var view = await _guild.GetMyGuildAsync(viewerId, ct); + var inviteCount = await _invites.CountPendingForInviteeAsync(viewerId, ct); + var joinReqHasPending = (await _joinRequests.ListPendingForViewerAsync(viewerId, ct)).Count > 0; + return new GuildNotification + { + GuildId = view?.Guild.GuildId, + GuildRoomMessageId = view is null ? null : await _chat.GetMaxMessageIdSafelyAsync(view.Guild.GuildId, ct), + IsJoinRequest = joinReqHasPending, + IsInvited = inviteCount > 0, + }; + } + /// /// Mirrors LoadController.BuildArenaInfosAsync. /mypage/index has no Keys.Contains("arena_info") /// guard (ArenaData(jsonData["arena_info"]) at MyPageTask.cs:55 indexes [0] unconditionally), and diff --git a/SVSim.UnitTests/Integration/Guild/GuildCrossSurfaceTests.cs b/SVSim.UnitTests/Integration/Guild/GuildCrossSurfaceTests.cs new file mode 100644 index 00000000..eb1ac503 --- /dev/null +++ b/SVSim.UnitTests/Integration/Guild/GuildCrossSurfaceTests.cs @@ -0,0 +1,202 @@ +using System.Net; +using System.Text; +using System.Text.Json; +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; + +/// +/// Cross-surface integration tests for guild state flowing into /mypage/index +/// guild_notification. Verifies that the four GuildNotification fields are populated +/// correctly (or left null/false) based on real DB state. +/// +public class GuildCrossSurfaceTests +{ + private const string MyPageRequestJson = + """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","carrier":"steam"}"""; + + private static StringContent JsonBody(string json) + => new(json, Encoding.UTF8, "application/json"); + + /// Seeds a guild + makes the viewer a Leader member. Returns the guild's GuildId. + private static async Task SeedGuildWithMemberAsync(SVSimTestFactory factory, long viewerId) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var guild = new GuildEntity + { + GuildId = 100_000_001, + Name = "TestGuild", + Description = "Test guild for cross-surface tests", + LeaderViewerId = viewerId, + EmblemId = 1, + Activity = GuildActivity.All, + JoinCondition = GuildJoinCondition.Free, + CreatedAt = DateTime.UtcNow, + }; + guild.Members.Add(new GuildMember + { + GuildId = guild.GuildId, + ViewerId = viewerId, + Role = GuildRole.Leader, + JoinedAt = DateTime.UtcNow, + }); + db.Guilds.Add(guild); + await db.SaveChangesAsync(); + return guild.GuildId; + } + + [Test] + public async Task Viewer_with_guild_gets_guild_id_populated() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + int guildId = await SeedGuildWithMemberAsync(factory, viewerId); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var resp = await client.PostAsync("/mypage/index", JsonBody(MyPageRequestJson)); + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK), await resp.Content.ReadAsStringAsync()); + + var root = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement; + var notification = root.GetProperty("guild_notification"); + + Assert.That(notification.GetProperty("guild_id").GetInt32(), Is.EqualTo(guildId), + "guild_id should be the viewer's guild id"); + Assert.That(notification.GetProperty("is_invited").GetBoolean(), Is.False); + Assert.That(notification.GetProperty("is_join_request").GetBoolean(), Is.False); + } + + [Test] + public async Task Viewer_with_pending_invite_gets_is_invited_true() + { + using var factory = new SVSimTestFactory(); + long inviterViewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_002UL, displayName: "Inviter"); + long inviteeViewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_003UL, displayName: "Invitee"); + + // Seed a guild for the inviter + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var guild = new GuildEntity + { + GuildId = 100_000_002, + Name = "InviterGuild", + Description = "", + LeaderViewerId = inviterViewerId, + EmblemId = 1, + Activity = GuildActivity.All, + JoinCondition = GuildJoinCondition.Free, + CreatedAt = DateTime.UtcNow, + }; + guild.Members.Add(new GuildMember + { + GuildId = guild.GuildId, + ViewerId = inviterViewerId, + Role = GuildRole.Leader, + JoinedAt = DateTime.UtcNow, + }); + db.Guilds.Add(guild); + // Seed a pending invite for the invitee + db.GuildInvites.Add(new GuildInvite + { + GuildId = guild.GuildId, + InviterViewerId = inviterViewerId, + InviteeViewerId = inviteeViewerId, + Status = GuildInviteStatus.Pending, + CreatedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(inviteeViewerId); + var resp = await client.PostAsync("/mypage/index", JsonBody(MyPageRequestJson)); + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK), await resp.Content.ReadAsStringAsync()); + + var root = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement; + var notification = root.GetProperty("guild_notification"); + + Assert.That(notification.GetProperty("is_invited").GetBoolean(), Is.True, + "is_invited should be true when viewer has a pending invite"); + Assert.That(notification.GetProperty("guild_id").ValueKind, Is.EqualTo(JsonValueKind.Null), + "guild_id should be null — invitee has no guild yet"); + } + + [Test] + public async Task Viewer_with_pending_join_request_gets_is_join_request_true() + { + using var factory = new SVSimTestFactory(); + long applicantViewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_004UL, displayName: "Applicant"); + long leaderViewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_005UL, displayName: "Leader"); + + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var guild = new GuildEntity + { + GuildId = 100_000_003, + Name = "RequestTargetGuild", + Description = "", + LeaderViewerId = leaderViewerId, + EmblemId = 1, + Activity = GuildActivity.All, + JoinCondition = GuildJoinCondition.Approval, + CreatedAt = DateTime.UtcNow, + }; + guild.Members.Add(new GuildMember + { + GuildId = guild.GuildId, + ViewerId = leaderViewerId, + Role = GuildRole.Leader, + JoinedAt = DateTime.UtcNow, + }); + db.Guilds.Add(guild); + db.GuildJoinRequests.Add(new GuildJoinRequest + { + GuildId = guild.GuildId, + ViewerId = applicantViewerId, + Status = GuildJoinRequestStatus.Pending, + CreatedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(applicantViewerId); + var resp = await client.PostAsync("/mypage/index", JsonBody(MyPageRequestJson)); + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK), await resp.Content.ReadAsStringAsync()); + + var root = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement; + var notification = root.GetProperty("guild_notification"); + + Assert.That(notification.GetProperty("is_join_request").GetBoolean(), Is.True, + "is_join_request should be true when viewer has a pending join request"); + Assert.That(notification.GetProperty("guild_id").ValueKind, Is.EqualTo(JsonValueKind.Null), + "guild_id should be null — applicant is not yet a member"); + } + + [Test] + public async Task Viewer_with_no_guild_and_no_pending_gets_all_null_shape() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_006UL, displayName: "NoGuild"); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var resp = await client.PostAsync("/mypage/index", JsonBody(MyPageRequestJson)); + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK), await resp.Content.ReadAsStringAsync()); + + var root = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement; + var notification = root.GetProperty("guild_notification"); + + // Fields with [JsonIgnore(Condition = Never)] serialize as explicit null/false. + Assert.That(notification.GetProperty("guild_id").ValueKind, Is.EqualTo(JsonValueKind.Null), + "guild_id must be explicit null (not absent) for viewers with no guild"); + Assert.That(notification.GetProperty("guild_room_message_id").ValueKind, Is.EqualTo(JsonValueKind.Null), + "guild_room_message_id must be explicit null for viewers with no guild"); + Assert.That(notification.GetProperty("is_invited").GetBoolean(), Is.False); + Assert.That(notification.GetProperty("is_join_request").GetBoolean(), Is.False); + } +}