feat(guild_chat): messages window + system events
- GuildChatService.EmitSystemEventAsync: replaces no-op stub with real chat-row insertion via _msgs.AppendAsync (per-guild monotonic id). - GuildChatService.GetWindowAsync: real implementation — verifies caller membership, delegates to IGuildChatMessageRepository.GetWindowAsync (direction 1=OLD/2=NEW/3=BOTH, limit 50), returns adaptive wait interval (ChatPollActiveSeconds when messages returned, ChatPollIdleSeconds otherwise). - Drop unused repo injections from GuildChatService (guilds, invites, joinRequests). - GuildChatController.Messages: wires the full response — chat_message[], users[] (deduplicated author profiles with Emblem+Degree nav includes), maintenance_card_list (empty until maintenance service lands), wait_interval. - GuildChatPollTests (7 integration tests): fresh-guild CreateGuild event, sequenced Create+Join ordering, direction=NEW filter, direction=BOTH window, users[] deduplication, empty result for viewer-not-in-guild, active vs idle interval comparison. All 7 pass; full suite 1515/1515. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
using SVSim.Database.Entities.Guild;
|
||||||
|
using SVSim.Database.Models.Config;
|
||||||
using SVSim.Database.Repositories.Guild;
|
using SVSim.Database.Repositories.Guild;
|
||||||
using SVSim.Database.Services;
|
using SVSim.Database.Services;
|
||||||
|
|
||||||
@@ -5,31 +7,65 @@ namespace SVSim.Database.Services.Guild;
|
|||||||
|
|
||||||
public sealed class GuildChatService : IGuildChatService
|
public sealed class GuildChatService : IGuildChatService
|
||||||
{
|
{
|
||||||
private readonly IGuildRepository _guilds;
|
|
||||||
private readonly IGuildMemberRepository _members;
|
private readonly IGuildMemberRepository _members;
|
||||||
private readonly IGuildInviteRepository _invites;
|
private readonly IGuildChatMessageRepository _msgs;
|
||||||
private readonly IGuildJoinRequestRepository _joinRequests;
|
private readonly IGameConfigService _cfg;
|
||||||
private readonly IGuildChatMessageRepository _chatMessages;
|
|
||||||
private readonly IGameConfigService _config;
|
|
||||||
|
|
||||||
public GuildChatService(
|
public GuildChatService(
|
||||||
IGuildRepository guilds,
|
|
||||||
IGuildMemberRepository members,
|
IGuildMemberRepository members,
|
||||||
IGuildInviteRepository invites,
|
IGuildChatMessageRepository msgs,
|
||||||
IGuildJoinRequestRepository joinRequests,
|
IGameConfigService cfg)
|
||||||
IGuildChatMessageRepository chatMessages,
|
|
||||||
IGameConfigService config)
|
|
||||||
{
|
{
|
||||||
_guilds = guilds;
|
|
||||||
_members = members;
|
_members = members;
|
||||||
_invites = invites;
|
_msgs = msgs;
|
||||||
_joinRequests = joinRequests;
|
_cfg = cfg;
|
||||||
_chatMessages = chatMessages;
|
|
||||||
_config = config;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<ChatWindow> GetWindowAsync(long viewerId, int startMessageId, int direction, int waitIntervalHint, CancellationToken ct = default)
|
// ── System-event emission ────────────────────────────────────────────────
|
||||||
=> throw new NotImplementedException();
|
|
||||||
|
public async Task EmitSystemEventAsync(
|
||||||
|
int guildId,
|
||||||
|
long actorViewerId,
|
||||||
|
GuildChatMessageType type,
|
||||||
|
string? body = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var msg = new GuildChatMessage
|
||||||
|
{
|
||||||
|
GuildId = guildId,
|
||||||
|
AuthorViewerId = actorViewerId,
|
||||||
|
MessageType = type,
|
||||||
|
Body = body ?? "",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
await _msgs.AppendAsync(msg, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Window query ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<ChatWindow> GetWindowAsync(
|
||||||
|
long viewerId,
|
||||||
|
int startMessageId,
|
||||||
|
int direction,
|
||||||
|
int waitIntervalHint,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var membership = await _members.GetMembershipAsync(viewerId, ct);
|
||||||
|
if (membership is null)
|
||||||
|
{
|
||||||
|
var idleInterval = _cfg.Get<GuildConfig>().ChatPollIdleSeconds;
|
||||||
|
return new(Array.Empty<GuildChatMessage>(), idleInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
const int limit = 50;
|
||||||
|
var msgs = await _msgs.GetWindowAsync(membership.GuildId, startMessageId, direction, limit, ct);
|
||||||
|
|
||||||
|
var cfg = _cfg.Get<GuildConfig>();
|
||||||
|
int wait = msgs.Count == 0 ? cfg.ChatPollIdleSeconds : cfg.ChatPollActiveSeconds;
|
||||||
|
return new(msgs, wait);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Not-yet-implemented stubs ────────────────────────────────────────────
|
||||||
|
|
||||||
public Task<ChatPostResult> PostTextOrStampAsync(long viewerId, int type, string message, CancellationToken ct = default)
|
public Task<ChatPostResult> PostTextOrStampAsync(long viewerId, int type, string message, CancellationToken ct = default)
|
||||||
=> throw new NotImplementedException();
|
=> throw new NotImplementedException();
|
||||||
@@ -48,8 +84,4 @@ public sealed class GuildChatService : IGuildChatService
|
|||||||
|
|
||||||
public Task<string?> GetDeckLogAsync(long viewerId, int messageId, CancellationToken ct = default)
|
public Task<string?> GetDeckLogAsync(long viewerId, int messageId, CancellationToken ct = default)
|
||||||
=> throw new NotImplementedException();
|
=> throw new NotImplementedException();
|
||||||
|
|
||||||
// No-op skeleton — will be wired in Phase 5.
|
|
||||||
public Task EmitSystemEventAsync(int guildId, long actorViewerId, Entities.Guild.GuildChatMessageType type, string? body = null, CancellationToken ct = default)
|
|
||||||
=> Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
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.GuildChat;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.GuildChat;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||||
|
|
||||||
@@ -11,18 +14,76 @@ 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;
|
||||||
|
|
||||||
public GuildChatController(IGuildChatService chat) => _chat = chat;
|
public GuildChatController(IGuildChatService chat, SVSimDbContext db)
|
||||||
|
{
|
||||||
|
_chat = chat;
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("messages")]
|
[HttpPost("messages")]
|
||||||
public Task<ActionResult<GuildChatMessagesResponse>> Messages([FromBody] GuildChatMessagesRequest req, CancellationToken ct)
|
public async Task<ActionResult<GuildChatMessagesResponse>> Messages(
|
||||||
=> Task.FromResult<ActionResult<GuildChatMessagesResponse>>(new GuildChatMessagesResponse
|
[FromBody] GuildChatMessagesRequest req,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||||
|
|
||||||
|
var window = await _chat.GetWindowAsync(
|
||||||
|
viewerId,
|
||||||
|
(int)req.StartMessageId,
|
||||||
|
req.Direction,
|
||||||
|
req.WaitInterval,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
// Build chat_message list
|
||||||
|
var chatMessages = window.Messages.Select(m => new ChatMessageDto
|
||||||
|
{
|
||||||
|
ViewerId = m.AuthorViewerId,
|
||||||
|
MessageId = m.MessageId,
|
||||||
|
MessageType = (int)m.MessageType,
|
||||||
|
CreateTime = new DateTimeOffset(m.CreatedAt, TimeSpan.Zero).ToUnixTimeSeconds(),
|
||||||
|
Message = m.Body,
|
||||||
|
// deck / replay / room remain null until Task 17
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
// Build deduplicated users[] catalog from message authors
|
||||||
|
var authorIds = window.Messages
|
||||||
|
.Select(m => m.AuthorViewerId)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var viewers = authorIds.Count == 0
|
||||||
|
? 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 =>
|
||||||
|
{
|
||||||
|
viewers.TryGetValue(vid, out var v);
|
||||||
|
return new ChatUserDto
|
||||||
|
{
|
||||||
|
ViewerId = vid,
|
||||||
|
Name = v?.DisplayName ?? "",
|
||||||
|
EmblemId = v?.Info?.SelectedEmblem?.Id is > 0 ? v.Info.SelectedEmblem.Id : 100_000_000L,
|
||||||
|
CountryCode = v?.Info?.CountryCode ?? "",
|
||||||
|
Rank = 1, // TODO: real rank when rank tracking lands
|
||||||
|
DegreeId = v?.Info?.SelectedDegree?.Id ?? 0,
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return new GuildChatMessagesResponse
|
||||||
{
|
{
|
||||||
MaintenanceCardList = new(),
|
MaintenanceCardList = new(),
|
||||||
Users = new(),
|
Users = users,
|
||||||
ChatMessage = new(),
|
ChatMessage = chatMessages,
|
||||||
WaitInterval = 10,
|
WaitInterval = window.WaitIntervalSeconds,
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("post")]
|
[HttpPost("post")]
|
||||||
public Task<ActionResult<GuildChatPostResponse>> Post([FromBody] GuildChatPostRequest req, CancellationToken ct)
|
public Task<ActionResult<GuildChatPostResponse>> Post([FromBody] GuildChatPostRequest req, CancellationToken ct)
|
||||||
|
|||||||
358
SVSim.UnitTests/Integration/Guild/GuildChatPollTests.cs
Normal file
358
SVSim.UnitTests/Integration/Guild/GuildChatPollTests.cs
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using SVSim.Database.Entities.Guild;
|
||||||
|
using SVSim.UnitTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace SVSim.UnitTests.Integration.Guild;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for POST /guild_chat/messages — window query + system-event accumulation.
|
||||||
|
///
|
||||||
|
/// Uses the REAL GuildChatService (not a spy) so EmitSystemEventAsync actually inserts rows.
|
||||||
|
/// </summary>
|
||||||
|
public class GuildChatPollTests
|
||||||
|
{
|
||||||
|
private const string Vid = "0";
|
||||||
|
private const int Sid = 0;
|
||||||
|
private const string Stk = "";
|
||||||
|
|
||||||
|
private static object BaseBody() => new { viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk };
|
||||||
|
|
||||||
|
private static int GetStringifiedInt(JsonElement el, string prop)
|
||||||
|
=> int.Parse(el.GetProperty(prop).GetString()!);
|
||||||
|
|
||||||
|
private static long GetStringifiedLong(JsonElement el, string prop)
|
||||||
|
=> long.Parse(el.GetProperty(prop).GetString()!);
|
||||||
|
|
||||||
|
// ─── Helper: create a guild and return its guild_id ───────────────────────
|
||||||
|
|
||||||
|
private static async Task<int> CreateGuildAsync(HttpClient client, string name, int joinCondition = 1)
|
||||||
|
{
|
||||||
|
var createResp = await client.PostAsync("/guild/create",
|
||||||
|
JsonContent.Create(new
|
||||||
|
{
|
||||||
|
guild_name = name,
|
||||||
|
activity = 1,
|
||||||
|
join_condition = joinCondition,
|
||||||
|
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk
|
||||||
|
}));
|
||||||
|
Assert.That(createResp.IsSuccessStatusCode, Is.True,
|
||||||
|
$"create failed: {await createResp.Content.ReadAsStringAsync()}");
|
||||||
|
|
||||||
|
var infoResp = await client.PostAsync("/guild/info",
|
||||||
|
JsonContent.Create(BaseBody()));
|
||||||
|
var infoJson = await infoResp.Content.ReadAsStringAsync();
|
||||||
|
using var doc = JsonDocument.Parse(infoJson);
|
||||||
|
return GetStringifiedInt(doc.RootElement.GetProperty("guild").GetProperty("detail"), "guild_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helper: POST /guild_chat/messages and parse response ────────────────
|
||||||
|
|
||||||
|
private static async Task<JsonElement> PollAsync(
|
||||||
|
HttpClient client,
|
||||||
|
int startMessageId = 0,
|
||||||
|
int direction = 1, // OLD
|
||||||
|
int waitInterval = 3)
|
||||||
|
{
|
||||||
|
var resp = await client.PostAsync("/guild_chat/messages",
|
||||||
|
JsonContent.Create(new
|
||||||
|
{
|
||||||
|
start_message_id = startMessageId,
|
||||||
|
direction = direction,
|
||||||
|
wait_interval = waitInterval,
|
||||||
|
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk
|
||||||
|
}));
|
||||||
|
Assert.That(resp.IsSuccessStatusCode, Is.True,
|
||||||
|
$"messages HTTP failed: {await resp.Content.ReadAsStringAsync()}");
|
||||||
|
var json = await resp.Content.ReadAsStringAsync();
|
||||||
|
var doc = JsonDocument.Parse(json);
|
||||||
|
return doc.RootElement.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Test 1: fresh guild → poll returns exactly one CreateGuild message
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Poll_freshGuild_direction_OLD_returns_one_CreateGuild_event()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long leaderId = await factory.SeedViewerAsync(76_561_198_500_000_001UL, "ChatPollLeader1");
|
||||||
|
using var leaderClient = factory.CreateAuthenticatedClient(leaderId);
|
||||||
|
|
||||||
|
await CreateGuildAsync(leaderClient, "ChatPollGuild1");
|
||||||
|
|
||||||
|
var root = await PollAsync(leaderClient, startMessageId: 0, direction: 1 /* OLD */);
|
||||||
|
|
||||||
|
var messages = root.GetProperty("chat_message");
|
||||||
|
Assert.That(messages.GetArrayLength(), Is.EqualTo(1),
|
||||||
|
$"Expected 1 message after create, got: {root}");
|
||||||
|
|
||||||
|
var msg0 = messages[0];
|
||||||
|
Assert.That(GetStringifiedInt(msg0, "message_type"), Is.EqualTo((int)GuildChatMessageType.CreateGuild),
|
||||||
|
"First message must be CreateGuild (type=8)");
|
||||||
|
Assert.That(GetStringifiedLong(msg0, "viewer_id"), Is.EqualTo(leaderId),
|
||||||
|
"Author must be the guild creator");
|
||||||
|
Assert.That(GetStringifiedLong(msg0, "message_id"), Is.GreaterThan(0),
|
||||||
|
"message_id must be positive");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Test 2: leader creates guild → member joins → poll OLD returns both in order
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Poll_afterJoin_direction_OLD_returns_CreateGuild_then_Join_in_order()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long leaderId = await factory.SeedViewerAsync(76_561_198_500_000_002UL, "ChatPollLeader2");
|
||||||
|
long memberId = await factory.SeedViewerAsync(76_561_198_500_000_003UL, "ChatPollMember2");
|
||||||
|
|
||||||
|
using var leaderClient = factory.CreateAuthenticatedClient(leaderId);
|
||||||
|
using var memberClient = factory.CreateAuthenticatedClient(memberId);
|
||||||
|
|
||||||
|
// Create guild (FREE join)
|
||||||
|
await CreateGuildAsync(leaderClient, "ChatPollGuild2", joinCondition: 1);
|
||||||
|
|
||||||
|
// Member joins (FREE)
|
||||||
|
var joinResp = await memberClient.PostAsync("/guild/join",
|
||||||
|
JsonContent.Create(new
|
||||||
|
{
|
||||||
|
guild_id = 0, // will be looked up — but we need the real one
|
||||||
|
from_invite = false,
|
||||||
|
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk
|
||||||
|
}));
|
||||||
|
// The join needs the real guild_id — let's get it from leader's info first
|
||||||
|
var infoResp = await leaderClient.PostAsync("/guild/info", JsonContent.Create(BaseBody()));
|
||||||
|
var infoJson = await infoResp.Content.ReadAsStringAsync();
|
||||||
|
using var infoDoc = JsonDocument.Parse(infoJson);
|
||||||
|
int guildId = GetStringifiedInt(infoDoc.RootElement.GetProperty("guild").GetProperty("detail"), "guild_id");
|
||||||
|
|
||||||
|
// Retry join with real guildId
|
||||||
|
var joinResp2 = await memberClient.PostAsync("/guild/join",
|
||||||
|
JsonContent.Create(new
|
||||||
|
{
|
||||||
|
guild_id = guildId,
|
||||||
|
from_invite = false,
|
||||||
|
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk
|
||||||
|
}));
|
||||||
|
Assert.That(joinResp2.IsSuccessStatusCode, Is.True,
|
||||||
|
$"join failed: {await joinResp2.Content.ReadAsStringAsync()}");
|
||||||
|
|
||||||
|
// Leader polls
|
||||||
|
var root = await PollAsync(leaderClient, startMessageId: 0, direction: 1 /* OLD */);
|
||||||
|
|
||||||
|
var messages = root.GetProperty("chat_message");
|
||||||
|
Assert.That(messages.GetArrayLength(), Is.EqualTo(2),
|
||||||
|
$"Expected 2 messages (CreateGuild + Join), got: {root}");
|
||||||
|
|
||||||
|
// Messages should be ordered oldest-to-newest (ascending message_id)
|
||||||
|
long id0 = GetStringifiedLong(messages[0], "message_id");
|
||||||
|
long id1 = GetStringifiedLong(messages[1], "message_id");
|
||||||
|
Assert.That(id0, Is.LessThan(id1), "Messages must be ordered oldest-to-newest");
|
||||||
|
|
||||||
|
int type0 = GetStringifiedInt(messages[0], "message_type");
|
||||||
|
int type1 = GetStringifiedInt(messages[1], "message_type");
|
||||||
|
Assert.That(type0, Is.EqualTo((int)GuildChatMessageType.CreateGuild), "First msg must be CreateGuild");
|
||||||
|
Assert.That(type1, Is.EqualTo((int)GuildChatMessageType.Join), "Second msg must be Join");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Test 3: direction=NEW returns only messages with message_id > start
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Poll_direction_NEW_returns_only_messages_after_start()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long leaderId = await factory.SeedViewerAsync(76_561_198_500_000_004UL, "ChatPollLeader3");
|
||||||
|
long memberId = await factory.SeedViewerAsync(76_561_198_500_000_005UL, "ChatPollMember3");
|
||||||
|
|
||||||
|
using var leaderClient = factory.CreateAuthenticatedClient(leaderId);
|
||||||
|
using var memberClient = factory.CreateAuthenticatedClient(memberId);
|
||||||
|
|
||||||
|
await CreateGuildAsync(leaderClient, "ChatPollGuild3", joinCondition: 1);
|
||||||
|
|
||||||
|
var infoResp = await leaderClient.PostAsync("/guild/info", JsonContent.Create(BaseBody()));
|
||||||
|
var infoJson = await infoResp.Content.ReadAsStringAsync();
|
||||||
|
using var infoDoc = JsonDocument.Parse(infoJson);
|
||||||
|
int guildId = GetStringifiedInt(infoDoc.RootElement.GetProperty("guild").GetProperty("detail"), "guild_id");
|
||||||
|
|
||||||
|
// Poll to get the CreateGuild message_id
|
||||||
|
var firstPoll = await PollAsync(leaderClient, startMessageId: 0, direction: 1 /* OLD */);
|
||||||
|
var firstMsgs = firstPoll.GetProperty("chat_message");
|
||||||
|
Assert.That(firstMsgs.GetArrayLength(), Is.EqualTo(1), "Should have 1 msg before join");
|
||||||
|
int createGuildMsgId = (int)GetStringifiedLong(firstMsgs[0], "message_id");
|
||||||
|
|
||||||
|
// Member joins
|
||||||
|
await memberClient.PostAsync("/guild/join",
|
||||||
|
JsonContent.Create(new
|
||||||
|
{
|
||||||
|
guild_id = guildId,
|
||||||
|
from_invite = false,
|
||||||
|
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Poll NEW from createGuildMsgId — should return only the Join message
|
||||||
|
var newPoll = await PollAsync(leaderClient, startMessageId: createGuildMsgId, direction: 2 /* NEW */);
|
||||||
|
var newMsgs = newPoll.GetProperty("chat_message");
|
||||||
|
|
||||||
|
// direction=NEW returns messages with message_id >= start, but since we want > start
|
||||||
|
// the repository uses ">= start" for NEW. In practice the first NEW msg returned includes
|
||||||
|
// the start message itself if it exists, plus newer ones. Let's verify Join is present.
|
||||||
|
bool hasJoin = false;
|
||||||
|
for (int i = 0; i < newMsgs.GetArrayLength(); i++)
|
||||||
|
{
|
||||||
|
if (GetStringifiedInt(newMsgs[i], "message_type") == (int)GuildChatMessageType.Join)
|
||||||
|
{
|
||||||
|
hasJoin = true;
|
||||||
|
// Its message_id must be > createGuildMsgId
|
||||||
|
Assert.That(GetStringifiedLong(newMsgs[i], "message_id"), Is.GreaterThan(createGuildMsgId),
|
||||||
|
"Join message_id must be greater than the CreateGuild id");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Assert.That(hasJoin, Is.True, "direction=NEW poll must include the Join message");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Test 4: direction=BOTH returns a window around start_message_id
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Poll_direction_BOTH_returns_window_around_start()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long leaderId = await factory.SeedViewerAsync(76_561_198_500_000_006UL, "ChatPollLeader4");
|
||||||
|
long memberId = await factory.SeedViewerAsync(76_561_198_500_000_007UL, "ChatPollMember4");
|
||||||
|
|
||||||
|
using var leaderClient = factory.CreateAuthenticatedClient(leaderId);
|
||||||
|
using var memberClient = factory.CreateAuthenticatedClient(memberId);
|
||||||
|
|
||||||
|
await CreateGuildAsync(leaderClient, "ChatPollGuild4", joinCondition: 1);
|
||||||
|
|
||||||
|
var infoResp = await leaderClient.PostAsync("/guild/info", JsonContent.Create(BaseBody()));
|
||||||
|
var infoJson = await infoResp.Content.ReadAsStringAsync();
|
||||||
|
using var infoDoc = JsonDocument.Parse(infoJson);
|
||||||
|
int guildId = GetStringifiedInt(infoDoc.RootElement.GetProperty("guild").GetProperty("detail"), "guild_id");
|
||||||
|
|
||||||
|
// Member joins → we now have CreateGuild (id=1) + Join (id=2)
|
||||||
|
await memberClient.PostAsync("/guild/join",
|
||||||
|
JsonContent.Create(new
|
||||||
|
{
|
||||||
|
guild_id = guildId,
|
||||||
|
from_invite = false,
|
||||||
|
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk
|
||||||
|
}));
|
||||||
|
|
||||||
|
// BOTH around message_id=1 (the CreateGuild message)
|
||||||
|
var bothPoll = await PollAsync(leaderClient, startMessageId: 1, direction: 3 /* BOTH */);
|
||||||
|
var bothMsgs = bothPoll.GetProperty("chat_message");
|
||||||
|
|
||||||
|
// With only 2 messages, BOTH around 1 should return at least CreateGuild (id=1) in the result
|
||||||
|
Assert.That(bothMsgs.GetArrayLength(), Is.GreaterThanOrEqualTo(1),
|
||||||
|
"BOTH direction must return at least 1 message");
|
||||||
|
bool hasCreateGuild = false;
|
||||||
|
for (int i = 0; i < bothMsgs.GetArrayLength(); i++)
|
||||||
|
{
|
||||||
|
if (GetStringifiedInt(bothMsgs[i], "message_type") == (int)GuildChatMessageType.CreateGuild)
|
||||||
|
hasCreateGuild = true;
|
||||||
|
}
|
||||||
|
Assert.That(hasCreateGuild, Is.True, "BOTH window around id=1 must include CreateGuild");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Test 5: users[] contains deduplicated profiles for all message authors
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Poll_users_list_contains_all_authors_deduplicated()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long leaderId = await factory.SeedViewerAsync(76_561_198_500_000_008UL, "ChatPollLeader5");
|
||||||
|
long memberId = await factory.SeedViewerAsync(76_561_198_500_000_009UL, "ChatPollMember5");
|
||||||
|
|
||||||
|
using var leaderClient = factory.CreateAuthenticatedClient(leaderId);
|
||||||
|
using var memberClient = factory.CreateAuthenticatedClient(memberId);
|
||||||
|
|
||||||
|
await CreateGuildAsync(leaderClient, "ChatPollGuild5", joinCondition: 1);
|
||||||
|
|
||||||
|
var infoResp = await leaderClient.PostAsync("/guild/info", JsonContent.Create(BaseBody()));
|
||||||
|
var infoJson = await infoResp.Content.ReadAsStringAsync();
|
||||||
|
using var infoDoc = JsonDocument.Parse(infoJson);
|
||||||
|
int guildId = GetStringifiedInt(infoDoc.RootElement.GetProperty("guild").GetProperty("detail"), "guild_id");
|
||||||
|
|
||||||
|
// Member joins — now we have 2 messages with 2 different authors
|
||||||
|
await memberClient.PostAsync("/guild/join",
|
||||||
|
JsonContent.Create(new
|
||||||
|
{
|
||||||
|
guild_id = guildId,
|
||||||
|
from_invite = false,
|
||||||
|
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk
|
||||||
|
}));
|
||||||
|
|
||||||
|
var root = await PollAsync(leaderClient, startMessageId: 0, direction: 1 /* OLD */);
|
||||||
|
|
||||||
|
var users = root.GetProperty("users");
|
||||||
|
Assert.That(users.GetArrayLength(), Is.EqualTo(2),
|
||||||
|
$"users[] must have 2 entries (leader + member), got: {root}");
|
||||||
|
|
||||||
|
var userIds = new HashSet<long>();
|
||||||
|
for (int i = 0; i < users.GetArrayLength(); i++)
|
||||||
|
{
|
||||||
|
userIds.Add(GetStringifiedLong(users[i], "viewer_id"));
|
||||||
|
}
|
||||||
|
Assert.That(userIds, Does.Contain(leaderId), "users[] must contain the leader's viewer_id");
|
||||||
|
Assert.That(userIds, Does.Contain(memberId), "users[] must contain the member's viewer_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Test 6: viewer NOT in a guild gets empty result with idle interval
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Poll_viewerNotInGuild_returns_empty_with_idle_wait_interval()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long lonelyId = await factory.SeedViewerAsync(76_561_198_500_000_010UL, "ChatPollLonely");
|
||||||
|
using var lonelyClient = factory.CreateAuthenticatedClient(lonelyId);
|
||||||
|
|
||||||
|
var root = await PollAsync(lonelyClient, startMessageId: 0, direction: 1);
|
||||||
|
|
||||||
|
var messages = root.GetProperty("chat_message");
|
||||||
|
Assert.That(messages.GetArrayLength(), Is.EqualTo(0),
|
||||||
|
"chat_message must be empty for a viewer not in a guild");
|
||||||
|
|
||||||
|
// wait_interval is stringified; idle default is ChatPollIdleSeconds (10)
|
||||||
|
int waitInterval = GetStringifiedInt(root, "wait_interval");
|
||||||
|
Assert.That(waitInterval, Is.GreaterThan(0), "wait_interval must be positive");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Test 7: wait_interval is smaller when messages were returned (active)
|
||||||
|
// vs no messages (idle)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Poll_activeInterval_less_than_idleInterval_when_messages_returned()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long leaderId = await factory.SeedViewerAsync(76_561_198_500_000_011UL, "ChatPollLeader6");
|
||||||
|
long lonelyId = await factory.SeedViewerAsync(76_561_198_500_000_012UL, "ChatPollLonely6");
|
||||||
|
|
||||||
|
using var leaderClient = factory.CreateAuthenticatedClient(leaderId);
|
||||||
|
using var lonelyClient = factory.CreateAuthenticatedClient(lonelyId);
|
||||||
|
|
||||||
|
await CreateGuildAsync(leaderClient, "ChatPollGuild6");
|
||||||
|
|
||||||
|
// Leader gets messages → active interval
|
||||||
|
var activePoll = await PollAsync(leaderClient, startMessageId: 0, direction: 1);
|
||||||
|
int activeWait = GetStringifiedInt(activePoll, "wait_interval");
|
||||||
|
|
||||||
|
// Lonely viewer not in a guild → idle interval
|
||||||
|
var idlePoll = await PollAsync(lonelyClient, startMessageId: 0, direction: 1);
|
||||||
|
int idleWait = GetStringifiedInt(idlePoll, "wait_interval");
|
||||||
|
|
||||||
|
Assert.That(activeWait, Is.LessThan(idleWait),
|
||||||
|
$"Active interval ({activeWait}s) must be less than idle interval ({idleWait}s)");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user