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;
}
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)
{
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<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);
/// <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.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<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>
/// Mirrors LoadController.BuildArenaInfosAsync. /mypage/index has no Keys.Contains("arena_info")
/// 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);
}
}