feat(guild): MyPage guild_notification + load_index audit

- Replace GuildNotification TODO stub in MyPageController.Index with
  BuildGuildNotificationAsync: populates guild_id, guild_room_message_id,
  is_join_request, is_invited from real DB state.
- Inject IGuildService, IGuildInviteRepository, IGuildJoinRequestRepository,
  IGuildChatMessageRepository into MyPageController.
- Add GetMaxMessageIdSafelyAsync to IGuildChatMessageRepository (returns null
  when no messages exist, vs GetMaxMessageIdAsync which returns 0).
- LoadDetail.cs audit: grep returned no guild fields — no LoadController changes.
- Add GuildCrossSurfaceTests (4 tests): guild member, pending invite, pending
  join request, no-guild viewer all verified green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-27 15:24:46 -04:00
parent eb65c2081c
commit d62f1b7ba7
4 changed files with 244 additions and 2 deletions

View File

@@ -142,6 +142,12 @@ public sealed class GuildChatMessageRepository : IGuildChatMessageRepository
return max ?? 0; return max ?? 0;
} }
public async Task<long?> 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) public async Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default)
{ {
var messages = await _db.GuildChatMessages.Where(m => m.GuildId == guildId).ToListAsync(ct); var messages = await _db.GuildChatMessages.Where(m => m.GuildId == guildId).ToListAsync(ct);

View File

@@ -12,6 +12,14 @@ public interface IGuildChatMessageRepository
Task<IReadOnlyList<GuildChatMessage>> GetWindowAsync(int guildId, int start, int direction, int limit, CancellationToken ct = default); Task<IReadOnlyList<GuildChatMessage>> GetWindowAsync(int guildId, int start, int direction, int limit, CancellationToken ct = default);
Task<int> GetMaxMessageIdAsync(int guildId, CancellationToken ct = default); Task<int> GetMaxMessageIdAsync(int guildId, CancellationToken ct = default);
/// <summary>
/// Returns the highest message_id for the guild, or null if no messages exist.
/// Adapts <see cref="GetMaxMessageIdAsync"/> (which returns 0 when empty) for callers
/// that need null-vs-present semantics (e.g. GuildNotification.guild_room_message_id).
/// </summary>
Task<long?> GetMaxMessageIdSafelyAsync(int guildId, CancellationToken ct = default);
Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default); Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default);
/// <summary>Fetch a single message by (guildId, messageId). Returns null if not found.</summary> /// <summary>Fetch a single message by (guildId, messageId). Returns null if not found.</summary>

View File

@@ -4,8 +4,10 @@ using Microsoft.AspNetCore.Mvc;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Models.Config; using SVSim.Database.Models.Config;
using SVSim.Database.Repositories.Globals; using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Guild;
using SVSim.Database.Repositories.Viewer; using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services; using SVSim.Database.Services;
using SVSim.Database.Services.Guild;
using SVSim.EmulatedEntrypoint.Constants; using SVSim.EmulatedEntrypoint.Constants;
using SVSim.EmulatedEntrypoint.Infrastructure; using SVSim.EmulatedEntrypoint.Infrastructure;
using SVSim.EmulatedEntrypoint.Models.Dtos; using SVSim.EmulatedEntrypoint.Models.Dtos;
@@ -31,10 +33,16 @@ public class MyPageController : SVSimController
private readonly IArenaTwoPickRunRepository _arenaTwoPickRuns; private readonly IArenaTwoPickRunRepository _arenaTwoPickRuns;
private readonly IHomeDialogSessionTracker _homeDialogTracker; private readonly IHomeDialogSessionTracker _homeDialogTracker;
private readonly ILoginBonusService _loginBonus; 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, public MyPageController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository,
IGameConfigService config, IArenaTwoPickRunRepository arenaTwoPickRuns, IGameConfigService config, IArenaTwoPickRunRepository arenaTwoPickRuns,
IHomeDialogSessionTracker homeDialogTracker, ILoginBonusService loginBonus) IHomeDialogSessionTracker homeDialogTracker, ILoginBonusService loginBonus,
IGuildService guild, IGuildInviteRepository invites,
IGuildJoinRequestRepository joinRequests, IGuildChatMessageRepository chat)
{ {
_viewerRepository = viewerRepository; _viewerRepository = viewerRepository;
_globalsRepository = globalsRepository; _globalsRepository = globalsRepository;
@@ -42,6 +50,10 @@ public class MyPageController : SVSimController
_arenaTwoPickRuns = arenaTwoPickRuns; _arenaTwoPickRuns = arenaTwoPickRuns;
_homeDialogTracker = homeDialogTracker; _homeDialogTracker = homeDialogTracker;
_loginBonus = loginBonus; _loginBonus = loginBonus;
_guild = guild;
_invites = invites;
_joinRequests = joinRequests;
_chat = chat;
} }
[HttpPost("index")] [HttpPost("index")]
@@ -90,7 +102,7 @@ public class MyPageController : SVSimController
ReceiveFriendApplyCount = 0, // TODO(mypage-stub): viewer friend-request inbox ReceiveFriendApplyCount = 0, // TODO(mypage-stub): viewer friend-request inbox
UnreadPresentCount = 0, // TODO(mypage-stub): viewer presents/mail UnreadPresentCount = 0, // TODO(mypage-stub): viewer presents/mail
FriendBattleInviteCount = 0, // TODO(mypage-stub): viewer room-invite count 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 LastAnnounceId = 0, // TODO(mypage-stub): globals announcement metadata
LastAnnounceUpdateTime = string.Empty, // TODO(mypage-stub): globals announcement metadata LastAnnounceUpdateTime = string.Empty, // TODO(mypage-stub): globals announcement metadata
FeatureMaintenanceList = new(), // TODO(mypage-stub): FeatureMaintenanceEntry rows FeatureMaintenanceList = new(), // TODO(mypage-stub): FeatureMaintenanceEntry rows
@@ -188,6 +200,20 @@ public class MyPageController : SVSimController
}; };
} }
private async Task<GuildNotification> 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,
};
}
/// <summary> /// <summary>
/// Mirrors LoadController.BuildArenaInfosAsync. /mypage/index has no Keys.Contains("arena_info") /// Mirrors LoadController.BuildArenaInfosAsync. /mypage/index has no Keys.Contains("arena_info")
/// guard (ArenaData(jsonData["arena_info"]) at MyPageTask.cs:55 indexes [0] unconditionally), and /// guard (ArenaData(jsonData["arena_info"]) at MyPageTask.cs:55 indexes [0] unconditionally), and

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
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");
/// <summary>Seeds a guild + makes the viewer a Leader member. Returns the guild's GuildId.</summary>
private static async Task<int> SeedGuildWithMemberAsync(SVSimTestFactory factory, long viewerId)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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<SVSimDbContext>();
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<SVSimDbContext>();
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);
}
}