//go:build integration package inttest import ( "context" "errors" "strings" "testing" "time" "github.com/google/uuid" "scrabble/backend/internal/account" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" "scrabble/backend/internal/social" ) // 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 } 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) } } 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) } }