feat(chat): unread read-receipts with lobby/game dot and history-open ack
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s

Persist per-message read state as a chat_messages.unread_seats bitmask
(migration 00008): a text message seeds every recipient seat's bit, a nudge
only the awaited seat's. A seat's bit clears when the player opens the move
history or chat (POST /games/:id/chat/read, sent only when something is
unread), and a nudge additionally clears when its recipient answers by moving
(a wired game NudgeClearer, dependency-inverted so game keeps off social).

UI shows a per-viewer unread dot in the lobby (next to the opponent) and the
game score bar — the unread_chat game-view flag seeds it from authoritative
REST views, live chat/nudge events raise it. Opening the move history counts
as reading (even without entering chat): the 💬 fade-blinks twice and the
client acks. Admin Messages gains an unread-only filter, a read/unread column,
and a per-message card with the per-seat read breakdown. Observability:
chat_read_duration histogram + chat_unread_messages gauge + social tracing.
This commit is contained in:
Ilia Denisov
2026-06-17 11:12:38 +02:00
parent d53ff18a67
commit aaac816dc2
51 changed files with 1000 additions and 111 deletions
+168
View File
@@ -0,0 +1,168 @@
//go:build integration
package inttest
import (
"context"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/social"
)
// TestChatUnreadAndMarkRead checks a text message marks every recipient seat unread (not the
// sender), surfaces through HasUnread / UnreadGames, and that MarkRead clears the reader's bit.
func TestChatUnreadAndMarkRead(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2) // seat 0 is to move
if _, err := svc.PostMessage(ctx, gameID, seats[0], "good luck", ""); err != nil {
t.Fatalf("post: %v", err)
}
// The opponent has it unread; the sender does not.
if unread, _ := svc.HasUnread(ctx, gameID, seats[1]); !unread {
t.Error("recipient should have the message unread")
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[0]); unread {
t.Error("sender should not have their own message unread")
}
if set, err := svc.UnreadGames(ctx, seats[1]); err != nil {
t.Fatalf("unread games: %v", err)
} else if !set[gameID] {
t.Error("UnreadGames should include the game for the recipient")
}
// Reading it clears the recipient's bit and reports one entry marked.
if n, err := svc.MarkRead(ctx, gameID, seats[1]); err != nil {
t.Fatalf("mark read: %v", err)
} else if n != 1 {
t.Errorf("marked %d, want 1", n)
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[1]); unread {
t.Error("message should be read after MarkRead")
}
// A second MarkRead is a no-op — nothing is left unread.
if n, _ := svc.MarkRead(ctx, gameID, seats[1]); n != 0 {
t.Errorf("second mark read marked %d, want 0", n)
}
}
// TestNudgeUnreadTargetsOnlyToMove checks, in a three-player game, that a nudge marks ONLY the
// awaited (to-move) seat unread — never the other waiting players. This is the owner's nudge
// targeting invariant, now observable through the unread bitmask.
func TestNudgeUnreadTargetsOnlyToMove(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 3) // seat 0 is to move
// A waiting player nudges; only the awaited seat 0 is targeted.
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil {
t.Fatalf("nudge: %v", err)
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[0]); !unread {
t.Error("the awaited seat should have the nudge unread")
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[2]); unread {
t.Error("a non-awaited waiting seat must not receive the nudge")
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[1]); unread {
t.Error("the nudging sender must not have it unread")
}
}
// TestTextMessageUnreadReachesAllOpponents checks a text message in a three-player game marks both
// opponents (every seated player but the sender) unread.
func TestTextMessageUnreadReachesAllOpponents(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 3) // seat 0 is to move and may chat
if _, err := svc.PostMessage(ctx, gameID, seats[0], "hello all", ""); err != nil {
t.Fatalf("post: %v", err)
}
for _, s := range []int{1, 2} {
if unread, _ := svc.HasUnread(ctx, gameID, seats[s]); !unread {
t.Errorf("seat %d should have the text message unread", s)
}
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[0]); unread {
t.Error("the sender should not have their own message unread")
}
}
// TestNudgeClearedByMove checks the move path clears a nudge the actor answered by moving: the
// game service's wired NudgeClearer flips the awaited seat's nudge to read on commit.
func TestNudgeClearedByMove(t *testing.T) {
ctx := context.Background()
gameSvc, gameID, seats, play := newDraftGame(t)
socialSvc := newSocialService()
gameSvc.SetNudgeClearer(socialSvc.ClearNudges)
// The waiting seat 1 nudges the to-move seat 0.
if _, err := socialSvc.Nudge(ctx, gameID, seats[1]); err != nil {
t.Fatalf("nudge: %v", err)
}
if unread, _ := socialSvc.HasUnread(ctx, gameID, seats[0]); !unread {
t.Fatal("seat 0 should have the nudge unread before moving")
}
// Seat 0 answers by moving — the commit clears its pending nudge.
if _, err := gameSvc.SubmitPlay(ctx, gameID, seats[0], play.Tiles); err != nil {
t.Fatalf("submit play: %v", err)
}
if unread, _ := socialSvc.HasUnread(ctx, gameID, seats[0]); unread {
t.Error("the nudge should be cleared once the awaited player has moved")
}
}
// TestAdminChatUnreadFilterAndDetail checks the admin unread-only filter and the per-seat read
// breakdown of the message card.
func TestAdminChatUnreadFilterAndDetail(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2)
msg, err := svc.PostMessage(ctx, gameID, seats[0], "good luck", "")
if err != nil {
t.Fatalf("post: %v", err)
}
// The unread-only filter (scoped to the game) lists the still-unread message.
if items, err := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, UnreadOnly: true}, 50, 0); err != nil {
t.Fatalf("admin list: %v", err)
} else if len(items) != 1 || items[0].ID != msg.ID {
t.Fatalf("unread filter = %+v, want the one message", items)
}
// The detail card marks the sender 'sender' and the recipient 'unread'.
card, err := svc.AdminMessageDetail(ctx, msg.ID)
if err != nil {
t.Fatalf("detail: %v", err)
}
roles := map[uuid.UUID]string{}
for _, s := range card.Seats {
roles[s.AccountID] = s.Role
}
if roles[seats[0]] != "sender" {
t.Errorf("sender role = %q, want sender", roles[seats[0]])
}
if roles[seats[1]] != "unread" {
t.Errorf("recipient role = %q, want unread", roles[seats[1]])
}
// After the recipient reads, the filter drops it and the seat flips to 'read'.
if _, err := svc.MarkRead(ctx, gameID, seats[1]); err != nil {
t.Fatalf("mark read: %v", err)
}
if items, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, UnreadOnly: true}, 50, 0); len(items) != 0 {
t.Errorf("unread filter should be empty after read, got %d", len(items))
}
card2, err := svc.AdminMessageDetail(ctx, msg.ID)
if err != nil {
t.Fatalf("detail after read: %v", err)
}
for _, s := range card2.Seats {
if s.AccountID == seats[1] && s.Role != "read" {
t.Errorf("recipient role after read = %q, want read", s.Role)
}
}
}