feat(chat): a message to a disguised robot opponent is born read
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s

A pooled robot substituted into an ordinary (non-AI) game never opens the
chat, so a text message to it would linger unread forever — skewing the unread
count and the publish-to-read metric. Clear its recipient bit at PostMessage
time (robotRecipients via account.IsRobot), so the message is born read. The
human sender never had their own message unread, so this is invisible to them;
a nudge to a robot already self-clears when the robot answers by moving.
This commit is contained in:
Ilia Denisov
2026-06-17 11:46:59 +02:00
parent a7f3df9346
commit 20f2a5a011
3 changed files with 80 additions and 6 deletions
+41
View File
@@ -5,9 +5,13 @@ package inttest
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/social"
)
@@ -116,6 +120,43 @@ func TestNudgeClearedByMove(t *testing.T) {
}
}
// TestChatToRobotIsBornRead checks a text message to a disguised robot opponent (a pooled
// robot substituted into an ordinary, non-AI game) is born read: the robot never opens the
// chat, so the message must not linger unread (skewing the count and the read metric).
func TestChatToRobotIsBornRead(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
human := provisionAccount(t)
robot, err := account.NewStore(testDB).ProvisionRobot(ctx, "robot-chat-"+uuid.NewString(), "Robbie")
if err != nil {
t.Fatalf("provision robot: %v", err)
}
g, err := newGameService().Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robot.ID},
TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
})
if err != nil {
t.Fatalf("create game: %v", err)
}
// The human (seat 0, to move) chats the disguised robot; the message is born read.
msg, err := svc.PostMessage(ctx, g.ID, human, "good luck", "")
if err != nil {
t.Fatalf("post: %v", err)
}
if items, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: g.ID, UnreadOnly: true}, 50, 0); len(items) != 0 {
t.Errorf("a message to a robot should be born read, but the unread filter listed %d", len(items))
}
card, err := svc.AdminMessageDetail(ctx, msg.ID)
if err != nil {
t.Fatalf("detail: %v", err)
}
for _, s := range card.Seats {
if s.AccountID == robot.ID && s.Role != "read" {
t.Errorf("robot seat role = %q, want read", s.Role)
}
}
}
// TestAdminChatUnreadFilterAndDetail checks the admin unread-only filter and the per-seat read
// breakdown of the message card.
func TestAdminChatUnreadFilterAndDetail(t *testing.T) {