From 2cb2b57cdbae15006d5e6704ba04df5e9e775303 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 7 Jun 2026 11:23:43 +0200 Subject: [PATCH] Stage 17 round 6 (#10 backend): enforce chat only on your turn PostMessage now rejects a chat sent on a finished game or when it is not the sender's turn (ErrChatNotYourTurn -> 409 chat_not_your_turn), matching the UI where the message field is hidden off-turn and only the nudge shows. Existing chat tests post on the to-move seat and are unaffected; adds an off-turn-rejection integration test + the dto mapping case + the UI error message. --- backend/internal/inttest/social_test.go | 14 ++++++++++++++ backend/internal/server/dto_test.go | 1 + backend/internal/server/handlers.go | 2 ++ backend/internal/social/chat.go | 13 +++++++++++-- backend/internal/social/social.go | 4 ++++ ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + 7 files changed, 34 insertions(+), 2 deletions(-) diff --git a/backend/internal/inttest/social_test.go b/backend/internal/inttest/social_test.go index 0317996..ac44613 100644 --- a/backend/internal/inttest/social_test.go +++ b/backend/internal/inttest/social_test.go @@ -314,6 +314,20 @@ func TestChatRejectsBadContent(t *testing.T) { } } +// TestChatOnlyOnYourTurn checks chat is allowed only on the sender's own turn (Stage 17): +// the player to move can post, the waiting player gets ErrChatNotYourTurn. +func TestChatOnlyOnYourTurn(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + gameID, seats := newGameWithSeats(t, 2) // seat 0 is to move at the opening + if _, err := svc.PostMessage(ctx, gameID, seats[1], "hi", ""); !errors.Is(err, social.ErrChatNotYourTurn) { + t.Fatalf("off-turn chat = %v, want ErrChatNotYourTurn", err) + } + if _, err := svc.PostMessage(ctx, gameID, seats[0], "hi", ""); err != nil { + t.Fatalf("on-turn chat = %v, want nil", err) + } +} + func TestNudgeRulesAndRateLimit(t *testing.T) { ctx := context.Background() svc := newSocialService() diff --git a/backend/internal/server/dto_test.go b/backend/internal/server/dto_test.go index 619a3dc..a5d2c66 100644 --- a/backend/internal/server/dto_test.go +++ b/backend/internal/server/dto_test.go @@ -48,6 +48,7 @@ func TestStatusForError(t *testing.T) { "not your turn": {game.ErrNotYourTurn, http.StatusConflict, "not_your_turn"}, "nudge own turn": {social.ErrNudgeOnOwnTurn, http.StatusConflict, "nudge_own_turn"}, "nudge too soon": {social.ErrNudgeTooSoon, http.StatusConflict, "nudge_too_soon"}, + "chat off turn": {social.ErrChatNotYourTurn, http.StatusConflict, "chat_not_your_turn"}, "illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"}, "email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"}, "code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"}, diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index e4238f3..c4104b5 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -207,6 +207,8 @@ func statusForError(err error) (int, string) { // A too-frequent nudge is a distinct, non-content rejection — the UI must say // "don't rush the player so often", not the chat content-rejection message. return http.StatusConflict, "nudge_too_soon" + case errors.Is(err, social.ErrChatNotYourTurn): + return http.StatusConflict, "chat_not_your_turn" case errors.Is(err, social.ErrSelfRelation): return http.StatusBadRequest, "self_relation" case errors.Is(err, social.ErrRequestExists): diff --git a/backend/internal/social/chat.go b/backend/internal/social/chat.go index 387440e..78e6f24 100644 --- a/backend/internal/social/chat.go +++ b/backend/internal/social/chat.go @@ -49,13 +49,22 @@ type Message struct { // rune limit, and free of links/emails/phone numbers (the content filter). The // gateway-forwarded senderIP is validated and stored for moderation. func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID, body, senderIP string) (Message, error) { - seats, _, _, err := svc.games.Participants(ctx, gameID) + seats, toMove, status, err := svc.games.Participants(ctx, gameID) if err != nil { return Message{}, err } - if !slices.Contains(seats, senderID) { + idx := slices.Index(seats, senderID) + if idx < 0 { return Message{}, ErrNotParticipant } + // Chat is allowed only on the sender's own turn in an active game; the opponent's-turn + // control is the nudge (Stage 17). + if status != statusActive { + return Message{}, ErrGameNotActive + } + if idx != toMove { + return Message{}, ErrChatNotYourTurn + } sender, err := svc.accounts.GetByID(ctx, senderID) if err != nil { return Message{}, err diff --git a/backend/internal/social/social.go b/backend/internal/social/social.go index 43002c2..3ea5c28 100644 --- a/backend/internal/social/social.go +++ b/backend/internal/social/social.go @@ -67,6 +67,10 @@ var ( ErrNudgeTooSoon = errors.New("social: a nudge was already sent in the last hour") // ErrGameNotActive is returned when a nudge is attempted on a finished game. ErrGameNotActive = errors.New("social: game is not active") + // ErrChatNotYourTurn is returned when a chat message is sent while it is not the + // sender's turn — chat is allowed only on your own turn (the opponent's-turn control + // is the nudge, Stage 17). + ErrChatNotYourTurn = errors.New("social: cannot chat while it is not your turn") ) // Service is the social domain. It is the only writer of the friendships, blocks diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 3261d38..03189ce 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -158,6 +158,7 @@ export const en = { 'error.no_hint_available': 'No options with your letters.', 'error.chat_rejected': 'Message rejected (too long or contains contact info).', 'error.nudge_too_soon': "Please don't rush your opponent so often.", + 'error.chat_not_your_turn': 'You can chat only on your turn.', 'error.game_finished': 'This game is finished.', 'error.not_a_player': 'You are not a player in this game.', 'error.already_queued': 'You are already in the queue.', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 10846f5..c7cec61 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -159,6 +159,7 @@ export const ru: Record = { 'error.no_hint_available': 'Нет вариантов с вашим набором.', 'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).', 'error.nudge_too_soon': 'Не стоит торопить соперника так часто.', + 'error.chat_not_your_turn': 'Писать в чат можно только в свой ход.', 'error.game_finished': 'Эта игра уже завершена.', 'error.not_a_player': 'Вы не участник этой игры.', 'error.already_queued': 'Вы уже в очереди.',