feat(guild_chat): post text + stamp
Implements PostTextOrStampAsync: validates membership (NotInGuild), validates stamp id against UsableStampList (PermissionDenied), rejects type other than 0 or 1, allocates next per-guild message_id, appends row. Wires /guild_chat/post controller action mapping failures to result_code=2. Response is GuildChatPostResponse (empty body per spec). 7 integration tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -65,10 +65,38 @@ public sealed class GuildChatService : IGuildChatService
|
||||
return new(msgs, wait);
|
||||
}
|
||||
|
||||
// ── Not-yet-implemented stubs ────────────────────────────────────────────
|
||||
// ── Post text / stamp ────────────────────────────────────────────────────
|
||||
|
||||
public Task<ChatPostResult> PostTextOrStampAsync(long viewerId, int type, string message, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
public async Task<ChatPostResult> PostTextOrStampAsync(long viewerId, int type, string message, CancellationToken ct = default)
|
||||
{
|
||||
var m = await _members.GetMembershipAsync(viewerId, ct);
|
||||
if (m is null) return new(false, null);
|
||||
|
||||
var cfg = _cfg.Get<GuildConfig>();
|
||||
if (type == 1)
|
||||
{
|
||||
if (!int.TryParse(message, out var stampId) || !cfg.UsableStampList.Contains(stampId))
|
||||
return new(false, null);
|
||||
}
|
||||
else if (type != 0)
|
||||
{
|
||||
// Only NORMAL (0) and STAMP (1) are valid via this endpoint.
|
||||
return new(false, null);
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var nextId = await _msgs.GetMaxMessageIdAsync(m.GuildId, ct) + 1;
|
||||
var stored = await _msgs.AppendAsync(new GuildChatMessage
|
||||
{
|
||||
GuildId = m.GuildId,
|
||||
MessageId = nextId,
|
||||
AuthorViewerId = viewerId,
|
||||
MessageType = type == 1 ? GuildChatMessageType.Stamp : GuildChatMessageType.Normal,
|
||||
Body = message,
|
||||
CreatedAt = now,
|
||||
}, ct);
|
||||
return new(true, stored.MessageId);
|
||||
}
|
||||
|
||||
public Task<ChatPostResult> PostDeckAsync(long viewerId, string deckPayloadJson, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
@@ -78,8 +78,13 @@ public sealed class GuildChatController : SVSimController
|
||||
}
|
||||
|
||||
[HttpPost("post")]
|
||||
public Task<ActionResult<GuildChatPostResponse>> Post([FromBody] GuildChatPostRequest req, CancellationToken ct)
|
||||
=> Task.FromResult<ActionResult<GuildChatPostResponse>>(new GuildChatPostResponse());
|
||||
public async Task<ActionResult<GuildChatPostResponse>> Post([FromBody] GuildChatPostRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||
var result = await _chat.PostTextOrStampAsync(viewerId, req.Type, req.Message, ct);
|
||||
if (!result.Ok) return Ok(new { result_code = 2 });
|
||||
return new GuildChatPostResponse();
|
||||
}
|
||||
|
||||
[HttpPost("add_deck")]
|
||||
public Task<ActionResult<EmptyResponse>> AddDeck([FromBody] GuildChatAddDeckRequest req, CancellationToken ct)
|
||||
|
||||
300
SVSim.UnitTests/Services/Guild/GuildChatServicePostTests.cs
Normal file
300
SVSim.UnitTests/Services/Guild/GuildChatServicePostTests.cs
Normal file
@@ -0,0 +1,300 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using SVSim.Database.Entities.Guild;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Services.Guild;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for POST /guild_chat/post — NORMAL text and STAMP messages.
|
||||
/// </summary>
|
||||
public class GuildChatServicePostTests
|
||||
{
|
||||
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/post ────────────────────────────────────────
|
||||
|
||||
private static async Task<(bool ok, string body)> PostAsync(
|
||||
HttpClient client,
|
||||
int type,
|
||||
string message)
|
||||
{
|
||||
var resp = await client.PostAsync("/guild_chat/post",
|
||||
JsonContent.Create(new
|
||||
{
|
||||
type,
|
||||
message,
|
||||
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk
|
||||
}));
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
return (resp.IsSuccessStatusCode, body);
|
||||
}
|
||||
|
||||
// ─── Helper: poll messages ────────────────────────────────────────────────
|
||||
|
||||
private static async Task<JsonElement> PollAsync(HttpClient client, int startMessageId = 0, int direction = 1)
|
||||
{
|
||||
var resp = await client.PostAsync("/guild_chat/messages",
|
||||
JsonContent.Create(new
|
||||
{
|
||||
start_message_id = startMessageId,
|
||||
direction = direction,
|
||||
wait_interval = 3,
|
||||
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();
|
||||
return JsonDocument.Parse(json).RootElement.Clone();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 1: Member posts NORMAL — message appears with next message_id
|
||||
// =========================================================================
|
||||
|
||||
[Test]
|
||||
public async Task Post_normal_message_appears_in_chat()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long leaderId = await factory.SeedViewerAsync(76_561_198_600_000_001UL, "PostLeader1");
|
||||
using var leaderClient = factory.CreateAuthenticatedClient(leaderId);
|
||||
|
||||
await CreateGuildAsync(leaderClient, "PostGuild1");
|
||||
|
||||
var (ok, _) = await PostAsync(leaderClient, type: 0, message: "hello world");
|
||||
Assert.That(ok, Is.True, "POST /guild_chat/post should return 200");
|
||||
|
||||
var root = await PollAsync(leaderClient, startMessageId: 0, direction: 1 /* OLD */);
|
||||
var messages = root.GetProperty("chat_message");
|
||||
|
||||
// At least: CreateGuild + our Normal message
|
||||
Assert.That(messages.GetArrayLength(), Is.GreaterThanOrEqualTo(2), "Expected at least CreateGuild + Normal msg");
|
||||
|
||||
bool foundNormal = false;
|
||||
for (int i = 0; i < messages.GetArrayLength(); i++)
|
||||
{
|
||||
int msgType = GetStringifiedInt(messages[i], "message_type");
|
||||
if (msgType == (int)GuildChatMessageType.Normal)
|
||||
{
|
||||
foundNormal = true;
|
||||
Assert.That(messages[i].GetProperty("message").GetString(), Is.EqualTo("hello world"),
|
||||
"Normal message body must match");
|
||||
Assert.That(GetStringifiedLong(messages[i], "message_id"), Is.GreaterThan(0),
|
||||
"message_id must be positive");
|
||||
}
|
||||
}
|
||||
Assert.That(foundNormal, Is.True, "A Normal message must appear in the chat window");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 2: Member posts STAMP with valid id — type=1, body is stringified stamp id
|
||||
// =========================================================================
|
||||
|
||||
[Test]
|
||||
public async Task Post_stamp_with_valid_id_appears_with_type1()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long leaderId = await factory.SeedViewerAsync(76_561_198_600_000_002UL, "PostLeader2");
|
||||
using var leaderClient = factory.CreateAuthenticatedClient(leaderId);
|
||||
|
||||
await CreateGuildAsync(leaderClient, "PostGuild2");
|
||||
|
||||
// ShippedDefaults has stamps 100001..100020; use the first one
|
||||
var (ok, _) = await PostAsync(leaderClient, type: 1, message: "100001");
|
||||
Assert.That(ok, Is.True, "POST stamp should return 200");
|
||||
|
||||
var root = await PollAsync(leaderClient, startMessageId: 0, direction: 1);
|
||||
var messages = root.GetProperty("chat_message");
|
||||
|
||||
bool foundStamp = false;
|
||||
for (int i = 0; i < messages.GetArrayLength(); i++)
|
||||
{
|
||||
int msgType = GetStringifiedInt(messages[i], "message_type");
|
||||
if (msgType == (int)GuildChatMessageType.Stamp)
|
||||
{
|
||||
foundStamp = true;
|
||||
Assert.That(messages[i].GetProperty("message").GetString(), Is.EqualTo("100001"),
|
||||
"Stamp body must be the stringified stamp id");
|
||||
}
|
||||
}
|
||||
Assert.That(foundStamp, Is.True, "A Stamp message (type=1) must appear in the chat window");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 3: STAMP id NOT in UsableStampList → result_code=2
|
||||
// =========================================================================
|
||||
|
||||
[Test]
|
||||
public async Task Post_stamp_invalid_id_returns_error()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long leaderId = await factory.SeedViewerAsync(76_561_198_600_000_003UL, "PostLeader3");
|
||||
using var leaderClient = factory.CreateAuthenticatedClient(leaderId);
|
||||
|
||||
await CreateGuildAsync(leaderClient, "PostGuild3");
|
||||
|
||||
var (ok, body) = await PostAsync(leaderClient, type: 1, message: "999999");
|
||||
Assert.That(ok, Is.True, "HTTP should be 200 even for logic errors");
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.That(doc.RootElement.TryGetProperty("result_code", out var rc), Is.True, "result_code must be present");
|
||||
Assert.That(rc.GetInt32(), Is.EqualTo(2), "result_code must be 2 for invalid stamp");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 4: Non-member tries to post → result_code=2 (NotInGuild)
|
||||
// =========================================================================
|
||||
|
||||
[Test]
|
||||
public async Task Post_non_member_returns_error()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long leaderId = await factory.SeedViewerAsync(76_561_198_600_000_004UL, "PostLeader4");
|
||||
long outsiderId = await factory.SeedViewerAsync(76_561_198_600_000_005UL, "PostOutsider4");
|
||||
|
||||
using var leaderClient = factory.CreateAuthenticatedClient(leaderId);
|
||||
using var outsiderClient = factory.CreateAuthenticatedClient(outsiderId);
|
||||
|
||||
await CreateGuildAsync(leaderClient, "PostGuild4");
|
||||
|
||||
var (ok, body) = await PostAsync(outsiderClient, type: 0, message: "hi from outside");
|
||||
Assert.That(ok, Is.True, "HTTP 200 even for non-member");
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.That(doc.RootElement.TryGetProperty("result_code", out var rc), Is.True, "result_code must be present");
|
||||
Assert.That(rc.GetInt32(), Is.EqualTo(2), "result_code must be 2 for non-member");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 5: Invalid type (e.g., 5 = REPLAY) → result_code=2
|
||||
// =========================================================================
|
||||
|
||||
[Test]
|
||||
public async Task Post_invalid_type_returns_error()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long leaderId = await factory.SeedViewerAsync(76_561_198_600_000_006UL, "PostLeader5");
|
||||
using var leaderClient = factory.CreateAuthenticatedClient(leaderId);
|
||||
|
||||
await CreateGuildAsync(leaderClient, "PostGuild5");
|
||||
|
||||
var (ok, body) = await PostAsync(leaderClient, type: 5, message: "some replay payload");
|
||||
Assert.That(ok, Is.True, "HTTP 200 even for invalid type");
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.That(doc.RootElement.TryGetProperty("result_code", out var rc), Is.True, "result_code must be present");
|
||||
Assert.That(rc.GetInt32(), Is.EqualTo(2), "result_code must be 2 for type=5");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 6: Post-then-poll — direction=NEW, start_message_id=<last known> returns posted msg
|
||||
// =========================================================================
|
||||
|
||||
[Test]
|
||||
public async Task Post_then_poll_NEW_returns_just_posted_message()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long leaderId = await factory.SeedViewerAsync(76_561_198_600_000_007UL, "PostLeader6");
|
||||
using var leaderClient = factory.CreateAuthenticatedClient(leaderId);
|
||||
|
||||
await CreateGuildAsync(leaderClient, "PostGuild6");
|
||||
|
||||
// Get the current max message_id (the CreateGuild system event)
|
||||
var beforePoll = await PollAsync(leaderClient, startMessageId: 0, direction: 1);
|
||||
var beforeMsgs = beforePoll.GetProperty("chat_message");
|
||||
int lastKnownId = 0;
|
||||
for (int i = 0; i < beforeMsgs.GetArrayLength(); i++)
|
||||
{
|
||||
int msgId = (int)GetStringifiedLong(beforeMsgs[i], "message_id");
|
||||
if (msgId > lastKnownId) lastKnownId = msgId;
|
||||
}
|
||||
|
||||
// Post a Normal message
|
||||
var (ok, _) = await PostAsync(leaderClient, type: 0, message: "post-then-poll test");
|
||||
Assert.That(ok, Is.True);
|
||||
|
||||
// Poll NEW from lastKnownId — should return exactly our posted message
|
||||
var newPoll = await PollAsync(leaderClient, startMessageId: lastKnownId, direction: 2 /* NEW */);
|
||||
var newMsgs = newPoll.GetProperty("chat_message");
|
||||
|
||||
Assert.That(newMsgs.GetArrayLength(), Is.GreaterThanOrEqualTo(1),
|
||||
"direction=NEW must return at least the just-posted message");
|
||||
|
||||
bool foundPosted = false;
|
||||
for (int i = 0; i < newMsgs.GetArrayLength(); i++)
|
||||
{
|
||||
int msgType = GetStringifiedInt(newMsgs[i], "message_type");
|
||||
string? body = newMsgs[i].GetProperty("message").GetString();
|
||||
long msgId = GetStringifiedLong(newMsgs[i], "message_id");
|
||||
|
||||
Assert.That(msgId, Is.GreaterThan(lastKnownId),
|
||||
"direction=NEW must return only messages after lastKnownId");
|
||||
if (msgType == (int)GuildChatMessageType.Normal && body == "post-then-poll test")
|
||||
foundPosted = true;
|
||||
}
|
||||
Assert.That(foundPosted, Is.True, "The just-posted Normal message must appear in the NEW poll");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 7: Second post increments message_id
|
||||
// =========================================================================
|
||||
|
||||
[Test]
|
||||
public async Task Post_twice_second_message_has_higher_message_id()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long leaderId = await factory.SeedViewerAsync(76_561_198_600_000_008UL, "PostLeader7");
|
||||
using var leaderClient = factory.CreateAuthenticatedClient(leaderId);
|
||||
|
||||
await CreateGuildAsync(leaderClient, "PostGuild7");
|
||||
|
||||
await PostAsync(leaderClient, type: 0, message: "first");
|
||||
await PostAsync(leaderClient, type: 0, message: "second");
|
||||
|
||||
var root = await PollAsync(leaderClient, startMessageId: 0, direction: 1);
|
||||
var messages = root.GetProperty("chat_message");
|
||||
|
||||
// Collect message_ids for Normal messages only
|
||||
var normalIds = new List<long>();
|
||||
for (int i = 0; i < messages.GetArrayLength(); i++)
|
||||
{
|
||||
if (GetStringifiedInt(messages[i], "message_type") == (int)GuildChatMessageType.Normal)
|
||||
normalIds.Add(GetStringifiedLong(messages[i], "message_id"));
|
||||
}
|
||||
Assert.That(normalIds.Count, Is.EqualTo(2), "Exactly 2 Normal messages expected");
|
||||
Assert.That(normalIds[0], Is.LessThan(normalIds[1]),
|
||||
"Second message_id must be greater than first");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user