refactor(guild_chat): extract chat-user lookup to viewer repo; fix NEW-direction exclusivity
Add ChatUserProfile record and IViewerRepository.LoadChatProfilesAsync (AsNoTracking, projects Name/EmblemId/CountryCode/Rank/DegreeId from Viewer+Info nav refs). GuildChatController.Messages now injects IViewerRepository instead of SVSimDbContext; calls LoadChatProfilesAsync to populate users[] (removes direct db access from controller). GuildChatMessageRepository direction=2 (NEW): change >= start to > start (exclusive). GuildChatPollTests updated to assert the start message is NOT returned by direction=NEW. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -102,9 +102,9 @@ public sealed class GuildChatMessageRepository : IGuildChatMessageRepository
|
|||||||
.OrderBy(m => m.MessageId)
|
.OrderBy(m => m.MessageId)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
case 2: // NEW — messages newer than (and including) start
|
case 2: // NEW: strictly newer than start (exclusive)
|
||||||
return await baseQ
|
return await baseQ
|
||||||
.Where(m => m.MessageId >= start)
|
.Where(m => m.MessageId > start)
|
||||||
.OrderBy(m => m.MessageId)
|
.OrderBy(m => m.MessageId)
|
||||||
.Take(limit)
|
.Take(limit)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|||||||
@@ -2,6 +2,17 @@ using SVSim.Database.Enums;
|
|||||||
|
|
||||||
namespace SVSim.Database.Repositories.Viewer;
|
namespace SVSim.Database.Repositories.Viewer;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lightweight profile shape used by chat surfaces (guild_chat, gathering_chat).
|
||||||
|
/// Fields match the ChatUserDto wire shape.
|
||||||
|
/// </summary>
|
||||||
|
public record ChatUserProfile(
|
||||||
|
string Name,
|
||||||
|
long EmblemId,
|
||||||
|
string CountryCode,
|
||||||
|
int Rank,
|
||||||
|
int DegreeId);
|
||||||
|
|
||||||
public interface IViewerRepository
|
public interface IViewerRepository
|
||||||
{
|
{
|
||||||
Task<Models.Viewer?> GetViewerBySocialConnection(SocialAccountType accountType, ulong socialId);
|
Task<Models.Viewer?> GetViewerBySocialConnection(SocialAccountType accountType, ulong socialId);
|
||||||
@@ -39,4 +50,11 @@ public interface IViewerRepository
|
|||||||
/// ids with no matching row are absent from the result. Read-only (AsNoTracking).
|
/// ids with no matching row are absent from the result. Read-only (AsNoTracking).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Dictionary<long, string>> LoadDisplayNamesAsync(IReadOnlyCollection<long> viewerIds, CancellationToken ct = default);
|
Task<Dictionary<long, string>> LoadDisplayNamesAsync(IReadOnlyCollection<long> viewerIds, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Batch-loads the <see cref="ChatUserProfile"/> fields for a set of viewer ids. Used by chat
|
||||||
|
/// surfaces (guild_chat, gathering_chat) to populate <c>users[]</c> without a direct DB context
|
||||||
|
/// in the controller. Ids with no matching row are absent from the result. Read-only (AsNoTracking).
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyDictionary<long, ChatUserProfile>> LoadChatProfilesAsync(IReadOnlyCollection<long> viewerIds, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -296,6 +296,33 @@ public class ViewerRepository : IViewerRepository
|
|||||||
.ToDictionaryAsync(v => v.Id, v => v.DisplayName, ct);
|
.ToDictionaryAsync(v => v.Id, v => v.DisplayName, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyDictionary<long, ChatUserProfile>> LoadChatProfilesAsync(IReadOnlyCollection<long> viewerIds, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (viewerIds.Count == 0) return new Dictionary<long, ChatUserProfile>();
|
||||||
|
var rows = await _dbContext.Set<Models.Viewer>()
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(v => v.Info.SelectedEmblem)
|
||||||
|
.Include(v => v.Info.SelectedDegree)
|
||||||
|
.Where(v => viewerIds.Contains(v.Id))
|
||||||
|
.Select(v => new
|
||||||
|
{
|
||||||
|
v.Id,
|
||||||
|
v.DisplayName,
|
||||||
|
EmblemId = v.Info.SelectedEmblem != null ? v.Info.SelectedEmblem.Id : 100_000_000L,
|
||||||
|
CountryCode = v.Info.CountryCode ?? "",
|
||||||
|
DegreeId = v.Info.SelectedDegree != null ? (int)v.Info.SelectedDegree.Id : 0,
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return rows.ToDictionary(
|
||||||
|
r => r.Id,
|
||||||
|
r => new ChatUserProfile(
|
||||||
|
Name: r.DisplayName,
|
||||||
|
EmblemId: r.EmblemId,
|
||||||
|
CountryCode: r.CountryCode,
|
||||||
|
Rank: 1, // TODO: real rank when rank tracking lands
|
||||||
|
DegreeId: r.DegreeId));
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<Models.Viewer> BuildDefaultViewer(string displayName, int initialTutorialState = 1)
|
private async Task<Models.Viewer> BuildDefaultViewer(string displayName, int initialTutorialState = 1)
|
||||||
{
|
{
|
||||||
Models.Viewer viewer = new Models.Viewer
|
Models.Viewer viewer = new Models.Viewer
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using SVSim.Database.Repositories.Viewer;
|
||||||
using SVSim.Database;
|
|
||||||
using SVSim.Database.Services.Guild;
|
using SVSim.Database.Services.Guild;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Guild;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Guild;
|
||||||
@@ -14,12 +13,12 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
|
|||||||
public sealed class GuildChatController : SVSimController
|
public sealed class GuildChatController : SVSimController
|
||||||
{
|
{
|
||||||
private readonly IGuildChatService _chat;
|
private readonly IGuildChatService _chat;
|
||||||
private readonly SVSimDbContext _db;
|
private readonly IViewerRepository _viewers;
|
||||||
|
|
||||||
public GuildChatController(IGuildChatService chat, SVSimDbContext db)
|
public GuildChatController(IGuildChatService chat, IViewerRepository viewers)
|
||||||
{
|
{
|
||||||
_chat = chat;
|
_chat = chat;
|
||||||
_db = db;
|
_viewers = viewers;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("messages")]
|
[HttpPost("messages")]
|
||||||
@@ -53,26 +52,19 @@ public sealed class GuildChatController : SVSimController
|
|||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var viewers = authorIds.Count == 0
|
var profiles = await _viewers.LoadChatProfilesAsync(authorIds, ct);
|
||||||
? new Dictionary<long, SVSim.Database.Models.Viewer>()
|
|
||||||
: await _db.Viewers
|
|
||||||
.AsNoTracking()
|
|
||||||
.Include(v => v.Info.SelectedEmblem)
|
|
||||||
.Include(v => v.Info.SelectedDegree)
|
|
||||||
.Where(v => authorIds.Contains(v.Id))
|
|
||||||
.ToDictionaryAsync(v => v.Id, ct);
|
|
||||||
|
|
||||||
var users = authorIds.Select(vid =>
|
var users = authorIds.Select(vid =>
|
||||||
{
|
{
|
||||||
viewers.TryGetValue(vid, out var v);
|
profiles.TryGetValue(vid, out var p);
|
||||||
return new ChatUserDto
|
return new ChatUserDto
|
||||||
{
|
{
|
||||||
ViewerId = vid,
|
ViewerId = vid,
|
||||||
Name = v?.DisplayName ?? "",
|
Name = p?.Name ?? "",
|
||||||
EmblemId = v?.Info?.SelectedEmblem?.Id is > 0 ? v.Info.SelectedEmblem.Id : 100_000_000L,
|
EmblemId = p?.EmblemId ?? 100_000_000L,
|
||||||
CountryCode = v?.Info?.CountryCode ?? "",
|
CountryCode = p?.CountryCode ?? "",
|
||||||
Rank = 1, // TODO: real rank when rank tracking lands
|
Rank = p?.Rank ?? 1,
|
||||||
DegreeId = v?.Info?.SelectedDegree?.Id ?? 0,
|
DegreeId = p?.DegreeId ?? 0,
|
||||||
};
|
};
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
|
|||||||
@@ -193,24 +193,32 @@ public class GuildChatPollTests
|
|||||||
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk
|
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Poll NEW from createGuildMsgId — should return only the Join message
|
// Poll NEW from createGuildMsgId — should return only the Join message (exclusive: > start)
|
||||||
var newPoll = await PollAsync(leaderClient, startMessageId: createGuildMsgId, direction: 2 /* NEW */);
|
var newPoll = await PollAsync(leaderClient, startMessageId: createGuildMsgId, direction: 2 /* NEW */);
|
||||||
var newMsgs = newPoll.GetProperty("chat_message");
|
var newMsgs = newPoll.GetProperty("chat_message");
|
||||||
|
|
||||||
// direction=NEW returns messages with message_id >= start, but since we want > start
|
// direction=NEW uses strict > (exclusive): the CreateGuild message at message_id=createGuildMsgId
|
||||||
// the repository uses ">= start" for NEW. In practice the first NEW msg returned includes
|
// must NOT appear; only messages with message_id strictly greater than start are returned.
|
||||||
// the start message itself if it exists, plus newer ones. Let's verify Join is present.
|
bool hasCreateGuild = false;
|
||||||
bool hasJoin = false;
|
bool hasJoin = false;
|
||||||
for (int i = 0; i < newMsgs.GetArrayLength(); i++)
|
for (int i = 0; i < newMsgs.GetArrayLength(); i++)
|
||||||
{
|
{
|
||||||
if (GetStringifiedInt(newMsgs[i], "message_type") == (int)GuildChatMessageType.Join)
|
int msgType = GetStringifiedInt(newMsgs[i], "message_type");
|
||||||
|
long msgId = GetStringifiedLong(newMsgs[i], "message_id");
|
||||||
|
if (msgType == (int)GuildChatMessageType.CreateGuild)
|
||||||
|
hasCreateGuild = true;
|
||||||
|
if (msgType == (int)GuildChatMessageType.Join)
|
||||||
{
|
{
|
||||||
hasJoin = true;
|
hasJoin = true;
|
||||||
// Its message_id must be > createGuildMsgId
|
Assert.That(msgId, Is.GreaterThan(createGuildMsgId),
|
||||||
Assert.That(GetStringifiedLong(newMsgs[i], "message_id"), Is.GreaterThan(createGuildMsgId),
|
|
||||||
"Join message_id must be greater than the CreateGuild id");
|
"Join message_id must be greater than the CreateGuild id");
|
||||||
}
|
}
|
||||||
|
// Every returned message must have message_id strictly > start (exclusive semantics)
|
||||||
|
Assert.That(msgId, Is.GreaterThan(createGuildMsgId),
|
||||||
|
$"direction=NEW must be exclusive: message_id {msgId} must be > start {createGuildMsgId}");
|
||||||
}
|
}
|
||||||
|
Assert.That(hasCreateGuild, Is.False,
|
||||||
|
"direction=NEW (exclusive) must NOT include the message at start itself");
|
||||||
Assert.That(hasJoin, Is.True, "direction=NEW poll must include the Join message");
|
Assert.That(hasJoin, Is.True, "direction=NEW poll must include the Join message");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user