//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) } } }