From ab1e23b7cb1a5877e7fa1471cd66374d63fa4558 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 27 Jun 2026 14:58:18 -0400 Subject: [PATCH] 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 --- .../Services/Guild/GuildChatService.cs | 34 +- .../Controllers/GuildChatController.cs | 9 +- .../Guild/GuildChatServicePostTests.cs | 300 ++++++++++++++++++ 3 files changed, 338 insertions(+), 5 deletions(-) create mode 100644 SVSim.UnitTests/Services/Guild/GuildChatServicePostTests.cs diff --git a/SVSim.Database/Services/Guild/GuildChatService.cs b/SVSim.Database/Services/Guild/GuildChatService.cs index 38ca5861..18ec630e 100644 --- a/SVSim.Database/Services/Guild/GuildChatService.cs +++ b/SVSim.Database/Services/Guild/GuildChatService.cs @@ -65,10 +65,38 @@ public sealed class GuildChatService : IGuildChatService return new(msgs, wait); } - // ── Not-yet-implemented stubs ──────────────────────────────────────────── + // ── Post text / stamp ──────────────────────────────────────────────────── - public Task PostTextOrStampAsync(long viewerId, int type, string message, CancellationToken ct = default) - => throw new NotImplementedException(); + public async Task 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(); + 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 PostDeckAsync(long viewerId, string deckPayloadJson, CancellationToken ct = default) => throw new NotImplementedException(); diff --git a/SVSim.EmulatedEntrypoint/Controllers/GuildChatController.cs b/SVSim.EmulatedEntrypoint/Controllers/GuildChatController.cs index 794e7ee7..17a2347e 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/GuildChatController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/GuildChatController.cs @@ -78,8 +78,13 @@ public sealed class GuildChatController : SVSimController } [HttpPost("post")] - public Task> Post([FromBody] GuildChatPostRequest req, CancellationToken ct) - => Task.FromResult>(new GuildChatPostResponse()); + public async Task> 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> AddDeck([FromBody] GuildChatAddDeckRequest req, CancellationToken ct) diff --git a/SVSim.UnitTests/Services/Guild/GuildChatServicePostTests.cs b/SVSim.UnitTests/Services/Guild/GuildChatServicePostTests.cs new file mode 100644 index 00000000..9f0f398a --- /dev/null +++ b/SVSim.UnitTests/Services/Guild/GuildChatServicePostTests.cs @@ -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; + +/// +/// Integration tests for POST /guild_chat/post — NORMAL text and STAMP messages. +/// +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 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 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= 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(); + 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"); + } +}