//go:build integration package inttest import ( "context" "errors" "strings" "sync" "testing" "time" "github.com/google/uuid" "scrabble/backend/internal/account" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" "scrabble/backend/internal/notify" "scrabble/backend/internal/social" fb "scrabble/pkg/fbs/scrabblefb" ) // capturePublisher records every published intent for assertions on live events. type capturePublisher struct { mu sync.Mutex intents []notify.Intent } func (c *capturePublisher) Publish(in ...notify.Intent) { c.mu.Lock() defer c.mu.Unlock() c.intents = append(c.intents, in...) } // notified reports whether a Notification with the given sub-kind was published to user. func (c *capturePublisher) notified(user uuid.UUID, sub string) bool { c.mu.Lock() defer c.mu.Unlock() for _, in := range c.intents { if in.UserID == user && in.Kind == notify.KindNotification && string(fb.GetRootAsNotificationEvent(in.Payload, 0).Kind()) == sub { return true } } return false } // newSocialService builds a social service over the shared pool, reading game // state through a real game service. func newSocialService() *social.Service { return social.NewService(social.NewStore(testDB), account.NewStore(testDB), newGameService()) } // newGameWithSeats creates a started game seating n fresh accounts and returns the // game id and the seated account ids in seat order. func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) { t.Helper() seats := make([]uuid.UUID, n) for i := range seats { seats[i] = provisionAccount(t) } g, err := newGameService().Create(context.Background(), game.CreateParams{ Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t), }) if err != nil { t.Fatalf("create game: %v", err) } return g.ID, seats } // TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as // pending rather than blocked: robots no longer block friend requests, so the request // just sits unanswered and later expires — mirroring a human who ignores it (Stage 17). func TestFriendRequestToRobotStaysPending(t *testing.T) { ctx := context.Background() svc := newSocialService() accs := account.NewStore(testDB) human := provisionAccount(t) robot, err := accs.ProvisionRobot(ctx, "robot-friend-"+uuid.NewString(), "Robbie") if err != nil { t.Fatalf("provision robot: %v", err) } if robot.BlockFriendRequests { t.Fatal("robot must not block friend requests") } // A request is only allowed between players who share a game. if _, err := newGameService().Create(ctx, game.CreateParams{ Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robot.ID}, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t), }); err != nil { t.Fatalf("create game: %v", err) } if err := svc.SendFriendRequest(ctx, human, robot.ID); err != nil { t.Fatalf("request to robot = %v, want nil (accepted as pending)", err) } if got, _ := svc.ListIncomingRequests(ctx, robot.ID); len(got) != 1 || got[0] != human { t.Fatalf("robot incoming = %v, want [human]", got) } } func TestFriendRequestLifecycle(t *testing.T) { ctx := context.Background() svc := newSocialService() // A request is only allowed between players who share a game. _, seats := newGameWithSeats(t, 2) a, b := seats[0], seats[1] if err := svc.SendFriendRequest(ctx, a, b); err != nil { t.Fatalf("send: %v", err) } // A duplicate request in either direction is refused. if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrRequestExists) { t.Fatalf("duplicate = %v, want ErrRequestExists", err) } if got, _ := svc.ListIncomingRequests(ctx, b); len(got) != 1 || got[0] != a { t.Fatalf("incoming for b = %v, want [a]", got) } if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil { t.Fatalf("accept: %v", err) } for _, who := range []uuid.UUID{a, b} { friends, err := svc.ListFriends(ctx, who) if err != nil { t.Fatalf("list friends: %v", err) } if len(friends) != 1 { t.Fatalf("friends of %s = %v, want one", who, friends) } } if err := svc.Unfriend(ctx, a, b); err != nil { t.Fatalf("unfriend: %v", err) } if friends, _ := svc.ListFriends(ctx, a); len(friends) != 0 { t.Errorf("friends after unfriend = %v, want none", friends) } } func TestFriendRequestRefusedByToggleAndBlock(t *testing.T) { ctx := context.Background() svc := newSocialService() store := account.NewStore(testDB) // Toggle: the addressee does not accept friend requests. a, b := provisionAccount(t), provisionAccount(t) if _, err := store.UpdateProfile(ctx, b, account.ProfileUpdate{DisplayName: "Player", PreferredLanguage: "en", TimeZone: "UTC", BlockFriendRequests: true}); err != nil { t.Fatalf("set toggle: %v", err) } if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrRequestBlocked) { t.Fatalf("toggle send = %v, want ErrRequestBlocked", err) } // Block: the addressee has blocked the requester. c, d := provisionAccount(t), provisionAccount(t) if err := svc.Block(ctx, d, c); err != nil { t.Fatalf("block: %v", err) } if err := svc.SendFriendRequest(ctx, c, d); !errors.Is(err, social.ErrRequestBlocked) { t.Fatalf("blocked send = %v, want ErrRequestBlocked", err) } } func TestBlockSeversFriendship(t *testing.T) { ctx := context.Background() svc := newSocialService() _, seats := newGameWithSeats(t, 2) a, b := seats[0], seats[1] if err := svc.SendFriendRequest(ctx, a, b); err != nil { t.Fatalf("send: %v", err) } if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil { t.Fatalf("accept: %v", err) } if err := svc.Block(ctx, a, b); err != nil { t.Fatalf("block: %v", err) } if friends, _ := svc.ListFriends(ctx, a); len(friends) != 0 { t.Errorf("friendship must be severed by a block, got %v", friends) } } func TestFriendRequestRequiresSharedGame(t *testing.T) { ctx := context.Background() svc := newSocialService() a, b := provisionAccount(t), provisionAccount(t) // never played together if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrNoSharedGame) { t.Fatalf("send without shared game = %v, want ErrNoSharedGame", err) } } func TestFriendDeclineIsPermanentUntilCode(t *testing.T) { ctx := context.Background() svc := newSocialService() _, seats := newGameWithSeats(t, 2) a, b := seats[0], seats[1] if err := svc.SendFriendRequest(ctx, a, b); err != nil { t.Fatalf("send: %v", err) } if err := svc.RespondFriendRequest(ctx, b, a, false); err != nil { // b declines a t.Fatalf("decline: %v", err) } // An explicit decline is remembered: a cannot re-send. if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrRequestDeclined) { t.Fatalf("resend after decline = %v, want ErrRequestDeclined", err) } // But a one-time code from b bypasses the decline. code, err := svc.IssueFriendCode(ctx, b) if err != nil { t.Fatalf("issue code: %v", err) } issuer, err := svc.RedeemFriendCode(ctx, a, code.Code) if err != nil { t.Fatalf("redeem: %v", err) } if issuer != b { t.Fatalf("redeem issuer = %s, want b", issuer) } if friends, _ := svc.ListFriends(ctx, a); len(friends) != 1 || friends[0] != b { t.Fatalf("friends of a after code = %v, want [b]", friends) } } func TestFriendRequestResendAfterExpiry(t *testing.T) { ctx := context.Background() svc := newSocialService() _, seats := newGameWithSeats(t, 2) a, b := seats[0], seats[1] if err := svc.SendFriendRequest(ctx, a, b); err != nil { t.Fatalf("send: %v", err) } // A request older than the 30-day window lazily expires: it leaves the incoming // list and may be re-sent. if _, err := testDB.ExecContext(ctx, `UPDATE backend.friendships SET created_at = now() - interval '31 days' WHERE requester_id = $1 AND addressee_id = $2`, a, b); err != nil { t.Fatalf("backdate: %v", err) } if got, _ := svc.ListIncomingRequests(ctx, b); len(got) != 0 { t.Fatalf("expired request still incoming: %v", got) } if err := svc.SendFriendRequest(ctx, a, b); err != nil { t.Fatalf("resend after expiry: %v", err) } if got, _ := svc.ListIncomingRequests(ctx, b); len(got) != 1 || got[0] != a { t.Fatalf("re-sent request not incoming: %v", got) } } func TestFriendCodeSelfAndSingleUse(t *testing.T) { ctx := context.Background() svc := newSocialService() a := provisionAccount(t) code, err := svc.IssueFriendCode(ctx, a) if err != nil { t.Fatalf("issue: %v", err) } if _, err := svc.RedeemFriendCode(ctx, a, code.Code); !errors.Is(err, social.ErrSelfRelation) { t.Fatalf("self redeem = %v, want ErrSelfRelation", err) } b := provisionAccount(t) if _, err := svc.RedeemFriendCode(ctx, b, code.Code); err != nil { t.Fatalf("redeem: %v", err) } // Single-use: redeeming the same code again fails. if _, err := svc.RedeemFriendCode(ctx, provisionAccount(t), code.Code); !errors.Is(err, social.ErrFriendCodeInvalid) { t.Fatalf("reused code = %v, want ErrFriendCodeInvalid", err) } if friends, _ := svc.ListFriends(ctx, b); len(friends) != 1 || friends[0] != a { t.Fatalf("friends of b = %v, want [a]", friends) } } func TestListBlocks(t *testing.T) { ctx := context.Background() svc := newSocialService() a, b := provisionAccount(t), provisionAccount(t) if err := svc.Block(ctx, a, b); err != nil { t.Fatalf("block: %v", err) } blocked, err := svc.ListBlocks(ctx, a) if err != nil { t.Fatalf("list blocks: %v", err) } if len(blocked) != 1 || blocked[0] != b { t.Fatalf("blocks = %v, want [b]", blocked) } } func TestChatPostListAndBlocks(t *testing.T) { ctx := context.Background() svc := newSocialService() store := account.NewStore(testDB) gameID, seats := newGameWithSeats(t, 2) if _, err := svc.PostMessage(ctx, gameID, seats[0], "good luck", "203.0.113.7"); err != nil { t.Fatalf("post: %v", err) } msgs, err := svc.Messages(ctx, gameID, seats[1]) if err != nil { t.Fatalf("messages: %v", err) } if len(msgs) != 1 || msgs[0].Body != "good luck" || msgs[0].SenderIP != "203.0.113.7" { t.Fatalf("unexpected messages: %+v", msgs) } // A per-user block hides the blocked sender's messages from the viewer. if err := svc.Block(ctx, seats[1], seats[0]); err != nil { t.Fatalf("block: %v", err) } if msgs, _ := svc.Messages(ctx, gameID, seats[1]); len(msgs) != 0 { t.Errorf("blocked sender's messages still visible: %+v", msgs) } // A viewer who disabled chat sees no messages. other, seats2 := newGameWithSeats(t, 2) if _, err := svc.PostMessage(ctx, other, seats2[0], "hi", ""); err != nil { t.Fatalf("post 2: %v", err) } if _, err := store.UpdateProfile(ctx, seats2[1], account.ProfileUpdate{DisplayName: "Player", PreferredLanguage: "en", TimeZone: "UTC", BlockChat: true}); err != nil { t.Fatalf("set block_chat: %v", err) } if msgs, _ := svc.Messages(ctx, other, seats2[1]); len(msgs) != 0 { t.Errorf("block_chat viewer should see no messages, got %+v", msgs) } } func TestChatRejectsBadContent(t *testing.T) { ctx := context.Background() svc := newSocialService() gameID, seats := newGameWithSeats(t, 2) if _, err := svc.PostMessage(ctx, gameID, seats[0], "join evil.example.com now", ""); !errors.Is(err, social.ErrForbiddenContent) { t.Fatalf("link post = %v, want ErrForbiddenContent", err) } if _, err := svc.PostMessage(ctx, gameID, seats[0], strings.Repeat("a", 61), ""); !errors.Is(err, social.ErrMessageTooLong) { t.Fatalf("long post = %v, want ErrMessageTooLong", err) } // A non-participant cannot post. if _, err := svc.PostMessage(ctx, gameID, provisionAccount(t), "hi", ""); !errors.Is(err, social.ErrNotParticipant) { t.Fatalf("stranger post = %v, want ErrNotParticipant", err) } } // 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) { ctx := context.Background() svc := newSocialService() gameID, seats := newGameWithSeats(t, 2) // seat 0 is to move at the start // The player to move cannot nudge; the waiting opponent can. if _, err := svc.Nudge(ctx, gameID, seats[0]); !errors.Is(err, social.ErrNudgeOnOwnTurn) { t.Fatalf("to-move nudge = %v, want ErrNudgeOnOwnTurn", err) } if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil { t.Fatalf("opponent nudge: %v", err) } // A second nudge within the hour is refused. if _, err := svc.Nudge(ctx, gameID, seats[1]); !errors.Is(err, social.ErrNudgeTooSoon) { t.Fatalf("rapid nudge = %v, want ErrNudgeTooSoon", err) } // Backdating the last nudge past the window allows another. if _, err := testDB.ExecContext(ctx, `UPDATE backend.chat_messages SET created_at = now() - interval '2 hours' WHERE game_id = $1 AND kind = 'nudge'`, gameID); err != nil { t.Fatalf("backdate nudge: %v", err) } if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil { t.Fatalf("nudge after window: %v", err) } } // TestNudgeCooldownResetsOnAction checks the nudge cooldown clears once the player has // acted (moved or chatted) since their last nudge, even within the hour (Stage 17). func TestNudgeCooldownResetsOnAction(t *testing.T) { ctx := context.Background() svc := newSocialService() gsvc := newGameService() gameID, seats := newGameWithSeats(t, 2) // seat 0 to move if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil { // the waiting player nudges t.Fatalf("nudge: %v", err) } if _, err := svc.Nudge(ctx, gameID, seats[1]); !errors.Is(err, social.ErrNudgeTooSoon) { t.Fatalf("rapid nudge = %v, want ErrNudgeTooSoon", err) } // Seat 1 takes a turn: seat 0 passes (-> seat 1's turn), seat 1 chats then passes. if _, err := gsvc.Pass(ctx, gameID, seats[0]); err != nil { t.Fatalf("seat0 pass: %v", err) } if _, err := svc.PostMessage(ctx, gameID, seats[1], "thinking", ""); err != nil { t.Fatalf("seat1 chat: %v", err) } if _, err := gsvc.Pass(ctx, gameID, seats[1]); err != nil { t.Fatalf("seat1 pass: %v", err) } // Back on the opponent's turn, the cooldown is reset by the action since the nudge. if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil { t.Fatalf("nudge after acting = %v, want allowed (cooldown reset)", err) } } // TestListOutgoingRequests checks the requester-side list that backs the in-game "add to // friends" item (Stage 17): a pending request shows for the requester only; an accepted one // clears (it is a friendship now); a declined one stays (cannot be re-sent, so it reads as // still "sent"); a lazily expired pending one drops (it may be re-sent). func TestListOutgoingRequests(t *testing.T) { ctx := context.Background() svc := newSocialService() // Pending: outgoing for the requester, not the addressee. _, s1 := newGameWithSeats(t, 2) a, b := s1[0], s1[1] if err := svc.SendFriendRequest(ctx, a, b); err != nil { t.Fatalf("send: %v", err) } if got, _ := svc.ListOutgoingRequests(ctx, a); len(got) != 1 || got[0] != b { t.Fatalf("outgoing pending = %v, want [b]", got) } if got, _ := svc.ListOutgoingRequests(ctx, b); len(got) != 0 { t.Fatalf("addressee outgoing = %v, want none", got) } // Accepted: a friendship, no longer an outgoing request. if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil { t.Fatalf("accept: %v", err) } if got, _ := svc.ListOutgoingRequests(ctx, a); len(got) != 0 { t.Fatalf("outgoing after accept = %v, want none", got) } // Declined: stays outgoing (reads as sent; cannot re-send). _, s2 := newGameWithSeats(t, 2) c, d := s2[0], s2[1] if err := svc.SendFriendRequest(ctx, c, d); err != nil { t.Fatalf("send2: %v", err) } if err := svc.RespondFriendRequest(ctx, d, c, false); err != nil { t.Fatalf("decline: %v", err) } if got, _ := svc.ListOutgoingRequests(ctx, c); len(got) != 1 || got[0] != d { t.Fatalf("outgoing after decline = %v, want [d]", got) } // Lazily expired pending: omitted (may be re-sent). _, s3 := newGameWithSeats(t, 2) e, f := s3[0], s3[1] if err := svc.SendFriendRequest(ctx, e, f); err != nil { t.Fatalf("send3: %v", err) } if _, err := testDB.ExecContext(ctx, `UPDATE backend.friendships SET created_at = now() - interval '31 days' WHERE requester_id = $1 AND addressee_id = $2`, e, f); err != nil { t.Fatalf("backdate: %v", err) } if got, _ := svc.ListOutgoingRequests(ctx, e); len(got) != 0 { t.Fatalf("expired outgoing = %v, want none", got) } } // TestRespondPublishesToRequester checks that answering a request notifies the original // requester over the live channel (Stage 17): accept -> friend_added, decline -> // friend_declined, so a game screen watching that opponent re-derives its friend state. func TestRespondPublishesToRequester(t *testing.T) { ctx := context.Background() svc := newSocialService() pub := &capturePublisher{} svc.SetNotifier(pub) _, s1 := newGameWithSeats(t, 2) a, b := s1[0], s1[1] if err := svc.SendFriendRequest(ctx, a, b); err != nil { t.Fatalf("send: %v", err) } if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil { t.Fatalf("accept: %v", err) } if !pub.notified(a, notify.NotifyFriendAdded) { t.Errorf("accept did not notify requester with %q", notify.NotifyFriendAdded) } _, s2 := newGameWithSeats(t, 2) c, d := s2[0], s2[1] if err := svc.SendFriendRequest(ctx, c, d); err != nil { t.Fatalf("send2: %v", err) } if err := svc.RespondFriendRequest(ctx, d, c, false); err != nil { t.Fatalf("decline: %v", err) } if !pub.notified(c, notify.NotifyFriendDeclined) { t.Errorf("decline did not notify requester with %q", notify.NotifyFriendDeclined) } } // TestNudgeRoutedByGameLanguage checks a nudge's out-of-app push carries the game's language, so // it is delivered by the game's bot rather than the recipient's last-login bot (Stage 17). func TestNudgeRoutedByGameLanguage(t *testing.T) { ctx := context.Background() svc := newSocialService() pub := &capturePublisher{} svc.SetNotifier(pub) gameID, seats := newGameWithSeats(t, 2) // an English game; seat 0 is to move if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil { t.Fatalf("nudge: %v", err) } found := false for _, in := range pub.intents { if in.Kind == notify.KindNudge { found = true if in.Language != "en" { t.Errorf("nudge language = %q, want en (the game's language)", in.Language) } } } if !found { t.Fatal("no nudge intent published") } } // TestAdminListMessages checks the admin moderation list (Stage 17): real messages only // (nudges excluded), the game / sender pins, the sender glob masks, and the source label. func TestAdminListMessages(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", "203.0.113.9"); err != nil { t.Fatalf("post: %v", err) } if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil { // the waiting player nudges t.Fatalf("nudge: %v", err) } // Pinned to the game: the message is listed; the nudge (kind=nudge) is excluded. msgs, err := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID}, 50, 0) if err != nil { t.Fatalf("admin list: %v", err) } if len(msgs) != 1 { t.Fatalf("game messages = %d, want 1 (nudge excluded)", len(msgs)) } if m := msgs[0]; m.Body != "good luck" || m.SenderID != seats[0] || m.SenderIP != "203.0.113.9" { t.Fatalf("message = %+v, want body=good luck sender=seat0 ip=203.0.113.9", m) } if msgs[0].Source != "telegram" { // provisionAccount provisions a telegram identity t.Errorf("source = %q, want telegram", msgs[0].Source) } if n, _ := svc.AdminCountMessages(ctx, social.AdminMessageFilter{GameID: gameID}); n != 1 { t.Errorf("count = %d, want 1", n) } // Sender pin: seat 0 has the message; seat 1 has only a nudge. if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{SenderID: seats[0]}, 50, 0); len(got) == 0 { t.Error("sender=seat0 returned nothing") } if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, SenderID: seats[1]}, 50, 0); len(got) != 0 { t.Errorf("sender=seat1 has only a nudge, got %d messages", len(got)) } // Sender glob masks: the telegram external id matches "tg-*"; bogus masks exclude. if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, ExtMask: "tg-*"}, 50, 0); len(got) != 1 { t.Errorf("ext mask tg-* = %d, want 1", len(got)) } if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, ExtMask: "zzz-*"}, 50, 0); len(got) != 0 { t.Errorf("ext mask zzz-* = %d, want 0", len(got)) } if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, NameMask: "zzz-no-such-*"}, 50, 0); len(got) != 0 { t.Errorf("name mask miss = %d, want 0", len(got)) } }