f9acea1d9a
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Nudge badges lingered in the lobby on games that ended by turn-timeout, resignation or forfeit: those paths commit the finish directly, bypassing the move path's per-mover NudgeClearer, so the awaited seat's nudge was never marked read. A finished game's nudges are stale, so clear them all. Add a wired NudgeExpirer (social.ExpireNudges) called from the shared commit finish block — covering every completion path — and from the voidGame recovery path. It clears every seat's nudge bits for the game and leaves chat messages unread; unlike ClearNudges it records no publish-to-read latency, since a completion is an expiry, not a read. An integration test reproduces the timeout case (nudge cleared, chat kept). Docs: ARCHITECTURE §9.1, FUNCTIONAL (+_ru), backend/README.
287 lines
12 KiB
Go
287 lines
12 KiB
Go
//go:build integration
|
|
|
|
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"
|
|
)
|
|
|
|
// TestChatUnreadAndMarkRead checks a text message marks every recipient seat unread (not the
|
|
// sender), surfaces through HasUnread / UnreadGames and the message-level HasUnreadMessage /
|
|
// UnreadMessageGames (it is a real message, not a nudge), 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")
|
|
}
|
|
// A text message also raises the message-level flags (it is not just a nudge).
|
|
if msg, _ := svc.HasUnreadMessage(ctx, gameID, seats[1]); !msg {
|
|
t.Error("recipient should have the message flagged as an unread message")
|
|
}
|
|
if set, err := svc.UnreadMessageGames(ctx, seats[1]); err != nil {
|
|
t.Fatalf("unread message games: %v", err)
|
|
} else if !set[gameID] {
|
|
t.Error("UnreadMessageGames should include the game for a text message")
|
|
}
|
|
|
|
// 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. It also checks a nudge raises
|
|
// HasUnread but not the message-level flags, so the badge can colour it apart from a real message.
|
|
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")
|
|
}
|
|
// A nudge is not a message: it must not raise the message-level flags.
|
|
if msg, _ := svc.HasUnreadMessage(ctx, gameID, seats[0]); msg {
|
|
t.Error("a nudge must not be flagged as an unread message")
|
|
}
|
|
if set, _ := svc.UnreadMessageGames(ctx, seats[0]); set[gameID] {
|
|
t.Error("UnreadMessageGames must not include a game whose only unread entry is a nudge")
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
|
|
// TestGameCompletionExpiresNudgesKeepsChat checks that finishing a game by turn-timeout marks every
|
|
// pending nudge in it read — the lobby's nudge badge is stale once the game is over — while leaving
|
|
// real chat messages unread. It reproduces the reported bug: the timeout path commits the finish
|
|
// directly, bypassing the move path's per-mover nudge clear, so without a completion-driven expiry
|
|
// the awaited seat's nudge lingered as a badge on the finished game.
|
|
func TestGameCompletionExpiresNudgesKeepsChat(t *testing.T) {
|
|
ctx := context.Background()
|
|
gameSvc := newGameService()
|
|
socialSvc := newSocialService()
|
|
gameSvc.SetNudgeExpirer(socialSvc.ExpireNudges)
|
|
|
|
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
|
g, err := gameSvc.Create(ctx, game.CreateParams{
|
|
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: openingSeed(t),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
// Seat 1 nudges the to-move seat 0 (the awaited player), and seat 0 posts a real chat message,
|
|
// which seat 1 then holds unread. So before the timeout each side has exactly one unread entry:
|
|
// seat 0 a nudge, seat 1 a message — letting HasUnread isolate each kind.
|
|
if _, err := socialSvc.Nudge(ctx, g.ID, seats[1]); err != nil {
|
|
t.Fatalf("nudge: %v", err)
|
|
}
|
|
if _, err := socialSvc.PostMessage(ctx, g.ID, seats[0], "good luck", ""); err != nil {
|
|
t.Fatalf("post message: %v", err)
|
|
}
|
|
if unread, _ := socialSvc.HasUnread(ctx, g.ID, seats[0]); !unread {
|
|
t.Fatal("seat 0 should hold the nudge unread before the timeout")
|
|
}
|
|
if unread, _ := socialSvc.HasUnread(ctx, g.ID, seats[1]); !unread {
|
|
t.Fatal("seat 1 should hold the message unread before the timeout")
|
|
}
|
|
|
|
// Age the turn past its deadline and time seat 0 out; an empty away window keeps this
|
|
// deterministic regardless of the wall clock. The sweep finishes the game through the direct
|
|
// commit path, never the move path.
|
|
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
|
|
setAway(t, seats[0], "UTC", "00:00", "00:00")
|
|
if n, err := gameSvc.SweepTimeouts(ctx, time.Now().UTC()); err != nil || n < 1 {
|
|
t.Fatalf("sweep swept %d (err %v), want >= 1", n, err)
|
|
}
|
|
if status, reason := gameStatus(t, gameSvc, g.ID); status != game.StatusFinished || reason != "timeout" {
|
|
t.Fatalf("game not timed out: status %q reason %q", status, reason)
|
|
}
|
|
|
|
// The nudge is stale on a finished game and must be cleared; the chat message must survive.
|
|
if unread, _ := socialSvc.HasUnread(ctx, g.ID, seats[0]); unread {
|
|
t.Error("the nudge should be expired once the game has finished")
|
|
}
|
|
if unread, _ := socialSvc.HasUnread(ctx, g.ID, seats[1]); !unread {
|
|
t.Error("a real chat message must stay unread after the game finishes")
|
|
}
|
|
if msg, _ := socialSvc.HasUnreadMessage(ctx, g.ID, seats[1]); !msg {
|
|
t.Error("the chat message must remain flagged as an unread message after completion")
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
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)
|
|
}
|
|
}
|
|
}
|