Stage 17 round 6 (#10 backend): enforce chat only on your turn
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s
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.
This commit is contained in:
@@ -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) {
|
func TestNudgeRulesAndRateLimit(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
svc := newSocialService()
|
svc := newSocialService()
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ func TestStatusForError(t *testing.T) {
|
|||||||
"not your turn": {game.ErrNotYourTurn, http.StatusConflict, "not_your_turn"},
|
"not your turn": {game.ErrNotYourTurn, http.StatusConflict, "not_your_turn"},
|
||||||
"nudge own turn": {social.ErrNudgeOnOwnTurn, http.StatusConflict, "nudge_own_turn"},
|
"nudge own turn": {social.ErrNudgeOnOwnTurn, http.StatusConflict, "nudge_own_turn"},
|
||||||
"nudge too soon": {social.ErrNudgeTooSoon, http.StatusConflict, "nudge_too_soon"},
|
"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"},
|
"illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"},
|
||||||
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
|
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
|
||||||
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
|
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
|
||||||
|
|||||||
@@ -207,6 +207,8 @@ func statusForError(err error) (int, string) {
|
|||||||
// A too-frequent nudge is a distinct, non-content rejection — the UI must say
|
// 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.
|
// "don't rush the player so often", not the chat content-rejection message.
|
||||||
return http.StatusConflict, "nudge_too_soon"
|
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):
|
case errors.Is(err, social.ErrSelfRelation):
|
||||||
return http.StatusBadRequest, "self_relation"
|
return http.StatusBadRequest, "self_relation"
|
||||||
case errors.Is(err, social.ErrRequestExists):
|
case errors.Is(err, social.ErrRequestExists):
|
||||||
|
|||||||
@@ -49,13 +49,22 @@ type Message struct {
|
|||||||
// rune limit, and free of links/emails/phone numbers (the content filter). The
|
// rune limit, and free of links/emails/phone numbers (the content filter). The
|
||||||
// gateway-forwarded senderIP is validated and stored for moderation.
|
// 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) {
|
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 {
|
if err != nil {
|
||||||
return Message{}, err
|
return Message{}, err
|
||||||
}
|
}
|
||||||
if !slices.Contains(seats, senderID) {
|
idx := slices.Index(seats, senderID)
|
||||||
|
if idx < 0 {
|
||||||
return Message{}, ErrNotParticipant
|
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)
|
sender, err := svc.accounts.GetByID(ctx, senderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Message{}, err
|
return Message{}, err
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ var (
|
|||||||
ErrNudgeTooSoon = errors.New("social: a nudge was already sent in the last hour")
|
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 is returned when a nudge is attempted on a finished game.
|
||||||
ErrGameNotActive = errors.New("social: game is not active")
|
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
|
// Service is the social domain. It is the only writer of the friendships, blocks
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export const en = {
|
|||||||
'error.no_hint_available': 'No options with your letters.',
|
'error.no_hint_available': 'No options with your letters.',
|
||||||
'error.chat_rejected': 'Message rejected (too long or contains contact info).',
|
'error.chat_rejected': 'Message rejected (too long or contains contact info).',
|
||||||
'error.nudge_too_soon': "Please don't rush your opponent so often.",
|
'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.game_finished': 'This game is finished.',
|
||||||
'error.not_a_player': 'You are not a player in this game.',
|
'error.not_a_player': 'You are not a player in this game.',
|
||||||
'error.already_queued': 'You are already in the queue.',
|
'error.already_queued': 'You are already in the queue.',
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'error.no_hint_available': 'Нет вариантов с вашим набором.',
|
'error.no_hint_available': 'Нет вариантов с вашим набором.',
|
||||||
'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).',
|
'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).',
|
||||||
'error.nudge_too_soon': 'Не стоит торопить соперника так часто.',
|
'error.nudge_too_soon': 'Не стоит торопить соперника так часто.',
|
||||||
|
'error.chat_not_your_turn': 'Писать в чат можно только в свой ход.',
|
||||||
'error.game_finished': 'Эта игра уже завершена.',
|
'error.game_finished': 'Эта игра уже завершена.',
|
||||||
'error.not_a_player': 'Вы не участник этой игры.',
|
'error.not_a_player': 'Вы не участник этой игры.',
|
||||||
'error.already_queued': 'Вы уже в очереди.',
|
'error.already_queued': 'Вы уже в очереди.',
|
||||||
|
|||||||
Reference in New Issue
Block a user