feat(social): asymmetric per-user block, in-game block control, admin lists
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 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
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 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Make a per-user block one-directional and non-destructive: the blocker stops
receiving everything from the blocked user (chat, nudge, friend requests,
invitations) and the matchmaker never pairs them, while the blocked user
notices nothing — their sends still persist by the normal rules but are never
delivered or surfaced (born-read). A block no longer deletes the friendship
(an unblock cleanly restores it) and instant-reads any unread the blocked user
had left for the blocker.
- backend: a directional blockExists guard across chat/nudge/friends/invitations
(store-but-hide for the blocked->blocker direction, refuse blocker->blocked);
the matchmaker excludes a block-related pair (both directions) from auto-match;
user_blocked/user_unblocked notifications to the blocker only (in-app only).
- ui: the opponent score card gains a block ✖️ control mirroring add-friend
(red "Block?" confirm, mutual-hide while confirming, struck name, hidden chat
composer when blocked); optimistic apply + event confirm + rollback for both.
- admin: the user card gains cross-linked blocks / blocked-by / friends lists.
- docs: FUNCTIONAL(+ru), ARCHITECTURE §10 + decision record, UI_DESIGN, PRERELEASE.
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/social"
|
||||
)
|
||||
|
||||
// TestBlockInstantReadsExistingUnread checks that blocking marks read any chat the blocked
|
||||
// user had left unread for the blocker in their shared games, so no stale unread badge
|
||||
// lingers for someone the blocker no longer sees.
|
||||
func TestBlockInstantReadsExistingUnread(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
gameID, seats := newGameWithSeats(t, 2)
|
||||
sender, viewer := seats[0], seats[1] // seat 0 moves first, so it may chat
|
||||
|
||||
if _, err := svc.PostMessage(ctx, gameID, sender, "good luck", ""); err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
if unread, _ := svc.HasUnread(ctx, gameID, viewer); !unread {
|
||||
t.Fatal("viewer should have the message unread before blocking")
|
||||
}
|
||||
if err := svc.Block(ctx, viewer, sender); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if unread, _ := svc.HasUnread(ctx, gameID, viewer); unread {
|
||||
t.Error("blocking should instant-read the blocked sender's prior unread message")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatFromBlockedIsBornReadAndNotDelivered checks the store-but-hide path for chat: a
|
||||
// message the blocked user sends is stored and visible to themselves, but is born read for
|
||||
// the blocker, never delivered to them, and hidden from their view.
|
||||
func TestChatFromBlockedIsBornReadAndNotDelivered(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
pub := &capturePublisher{}
|
||||
svc.SetNotifier(pub)
|
||||
gameID, seats := newGameWithSeats(t, 2)
|
||||
blocked, blocker := seats[0], seats[1] // seat 0 moves first, so the blocked user may chat
|
||||
|
||||
if err := svc.Block(ctx, blocker, blocked); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if _, err := svc.PostMessage(ctx, gameID, blocked, "hello there", ""); err != nil {
|
||||
t.Fatalf("blocked user's post = %v, want nil (it must look ordinary to them)", err)
|
||||
}
|
||||
if unread, _ := svc.HasUnread(ctx, gameID, blocker); unread {
|
||||
t.Error("the blocked sender's message must be born read for the blocker")
|
||||
}
|
||||
if pub.delivered(blocker, notify.KindChatMessage) {
|
||||
t.Error("the blocked sender's message must not be delivered to the blocker")
|
||||
}
|
||||
if msgs, _ := svc.Messages(ctx, gameID, blocker); len(msgs) != 0 {
|
||||
t.Errorf("blocker still sees the blocked sender's message: %+v", msgs)
|
||||
}
|
||||
if msgs, _ := svc.Messages(ctx, gameID, blocked); len(msgs) != 1 {
|
||||
t.Errorf("the blocked sender should see their own message, got %+v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatToBlockedIsRejected checks the blocker-side guard: a player cannot post when their
|
||||
// only opponent is someone they have blocked (the composer is hidden client-side too).
|
||||
func TestChatToBlockedIsRejected(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
gameID, seats := newGameWithSeats(t, 2)
|
||||
blocker, blocked := seats[0], seats[1] // blocker moves first, so it is the one trying to chat
|
||||
|
||||
if err := svc.Block(ctx, blocker, blocked); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if _, err := svc.PostMessage(ctx, gameID, blocker, "hi", ""); !errors.Is(err, social.ErrRecipientBlocked) {
|
||||
t.Fatalf("chat to blocked opponent = %v, want ErrRecipientBlocked", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNudgeFromBlockedIsBornReadAndNotDelivered checks the store-but-hide path for nudge: a
|
||||
// nudge the blocked user sends is recorded (so their once-per-hour cooldown applies, looking
|
||||
// ordinary to them) but is born read and never delivered to the blocker.
|
||||
func TestNudgeFromBlockedIsBornReadAndNotDelivered(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
pub := &capturePublisher{}
|
||||
svc.SetNotifier(pub)
|
||||
gameID, seats := newGameWithSeats(t, 2)
|
||||
blocker, blocked := seats[0], seats[1] // seat 0 awaited; the blocked seat-1 player nudges it
|
||||
|
||||
if err := svc.Block(ctx, blocker, blocked); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if _, err := svc.Nudge(ctx, gameID, blocked); err != nil {
|
||||
t.Fatalf("blocked user's nudge = %v, want nil (it must look ordinary to them)", err)
|
||||
}
|
||||
if unread, _ := svc.HasUnread(ctx, gameID, blocker); unread {
|
||||
t.Error("the blocked sender's nudge must be born read for the blocker")
|
||||
}
|
||||
if pub.delivered(blocker, notify.KindNudge) {
|
||||
t.Error("the blocked sender's nudge must not be delivered to the blocker")
|
||||
}
|
||||
// The nudge is still recorded, so the cooldown applies (the blocked user notices nothing).
|
||||
if _, ok, _ := svc.LastNudgeAt(ctx, gameID, blocked); !ok {
|
||||
t.Error("the suppressed nudge should still be recorded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNudgeToBlockedIsRejected checks the blocker-side guard: a player cannot nudge the
|
||||
// awaited opponent when they have blocked them.
|
||||
func TestNudgeToBlockedIsRejected(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
gameID, seats := newGameWithSeats(t, 2)
|
||||
blocked, blocker := seats[0], seats[1] // seat 0 awaited; the blocker (seat 1) tries to nudge it
|
||||
|
||||
if err := svc.Block(ctx, blocker, blocked); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if _, err := svc.Nudge(ctx, gameID, blocker); !errors.Is(err, social.ErrRecipientBlocked) {
|
||||
t.Fatalf("nudge to blocked opponent = %v, want ErrRecipientBlocked", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlockPublishesToBlockerOnly checks that block and unblock confirm to the blocker (so
|
||||
// their other sessions update in place) and never reach the blocked user.
|
||||
func TestBlockPublishesToBlockerOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
pub := &capturePublisher{}
|
||||
svc.SetNotifier(pub)
|
||||
blocker, blocked := provisionAccount(t), provisionAccount(t)
|
||||
|
||||
if err := svc.Block(ctx, blocker, blocked); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if !pub.notified(blocker, notify.NotifyUserBlocked) {
|
||||
t.Error("the blocker should receive a user_blocked event")
|
||||
}
|
||||
if pub.notified(blocked, notify.NotifyUserBlocked) {
|
||||
t.Error("the blocked user must never receive a user_blocked event")
|
||||
}
|
||||
if err := svc.Unblock(ctx, blocker, blocked); err != nil {
|
||||
t.Fatalf("unblock: %v", err)
|
||||
}
|
||||
if !pub.notified(blocker, notify.NotifyUserUnblocked) {
|
||||
t.Error("the blocker should receive a user_unblocked event")
|
||||
}
|
||||
if pub.notified(blocked, notify.NotifyUserUnblocked) {
|
||||
t.Error("the blocked user must never receive a user_unblocked event")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchmakingExcludesBlockedPlayers checks auto-match never pairs two players with a
|
||||
// block between them (either direction), while an unblocked third player still joins.
|
||||
func TestMatchmakingExcludesBlockedPlayers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
mm := newMatchmaker(t, newRobotService(t, newGameService()), 90*time.Second, 90*time.Second)
|
||||
soc := newSocialService()
|
||||
mm.SetBlocker(soc)
|
||||
a, b, c := provisionAccount(t), provisionAccount(t), provisionAccount(t)
|
||||
|
||||
if err := soc.Block(ctx, a, b); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
// The block is seen from both sides — the exclusion set unions both directions.
|
||||
if !contains(blockedWith(t, soc, a), b) || !contains(blockedWith(t, soc, b), a) {
|
||||
t.Fatal("a block must appear in BlockedWith for both the blocker and the blocked")
|
||||
}
|
||||
|
||||
// b opens a game awaiting an opponent.
|
||||
r1, err := mm.Enqueue(ctx, b, engine.VariantEnglish, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue b: %v", err)
|
||||
}
|
||||
if r1.Matched {
|
||||
t.Fatal("first enqueue must open a game, not match")
|
||||
}
|
||||
// a must not join b's game (a blocked b): it opens its own instead.
|
||||
r2, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue a: %v", err)
|
||||
}
|
||||
if r2.Matched || r2.Game.ID == r1.Game.ID {
|
||||
t.Fatalf("a joined the blocked player's game %s (matched %v), want a fresh game", r1.Game.ID, r2.Matched)
|
||||
}
|
||||
// A third, unblocked player joins b's still-open game (the oldest), proving the exclusion
|
||||
// is specific to the blocked pair, not a blanket refusal.
|
||||
r3, err := mm.Enqueue(ctx, c, engine.VariantEnglish, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue c: %v", err)
|
||||
}
|
||||
if !r3.Matched || r3.Game.ID != r1.Game.ID {
|
||||
t.Fatalf("unblocked c = (game %s, matched %v), want it to join b's open game %s", r3.Game.ID, r3.Matched, r1.Game.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminSocialLists checks the admin user card's blocks / blocked-by / friends queries
|
||||
// return the full truth in both directions, including a friendship that a block overrides but
|
||||
// does not delete.
|
||||
func TestAdminSocialLists(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("request: %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)
|
||||
}
|
||||
|
||||
blocks, err := svc.AdminBlocksBy(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("admin blocks by: %v", err)
|
||||
}
|
||||
if len(blocks) != 1 || blocks[0].AccountID != b || blocks[0].At.IsZero() {
|
||||
t.Errorf("a's blocks = %v, want [b] with a date", blocks)
|
||||
}
|
||||
blockedBy, err := svc.AdminBlockedBy(ctx, b)
|
||||
if err != nil {
|
||||
t.Fatalf("admin blocked by: %v", err)
|
||||
}
|
||||
if len(blockedBy) != 1 || blockedBy[0].AccountID != a {
|
||||
t.Errorf("b's blocked-by = %v, want [a]", blockedBy)
|
||||
}
|
||||
// The friendship survives the block, so it still shows on both admin friend lists.
|
||||
friendsA, err := svc.AdminFriends(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("admin friends: %v", err)
|
||||
}
|
||||
if len(friendsA) != 1 || friendsA[0].AccountID != b || friendsA[0].At.IsZero() {
|
||||
t.Errorf("a's admin friends = %v, want [b] with a date", friendsA)
|
||||
}
|
||||
if friendsB, err := svc.AdminFriends(ctx, b); err != nil || len(friendsB) != 1 || friendsB[0].AccountID != a {
|
||||
t.Errorf("b's admin friends = %v (err %v), want [a]", friendsB, err)
|
||||
}
|
||||
}
|
||||
|
||||
// blockedWith reads the both-direction block set for id.
|
||||
func blockedWith(t *testing.T, soc *social.Service, id uuid.UUID) []uuid.UUID {
|
||||
t.Helper()
|
||||
ids, err := soc.BlockedWith(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("blocked with: %v", err)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// contains reports whether ids includes want.
|
||||
func contains(ids []uuid.UUID, want uuid.UUID) bool {
|
||||
for _, id := range ids {
|
||||
if id == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func TestCountActiveQuickGames(t *testing.T) {
|
||||
// An open (awaiting-opponent) quick game counts.
|
||||
if _, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour,
|
||||
}, time.Now().Add(time.Minute)); err != nil {
|
||||
}, time.Now().Add(time.Minute), nil); err != nil {
|
||||
t.Fatalf("open quick game: %v", err)
|
||||
}
|
||||
// An active quick game and an honest-AI quick game both count (neither has an invitation row).
|
||||
|
||||
@@ -198,14 +198,36 @@ func TestInvitationLazyExpiry(t *testing.T) {
|
||||
func TestInvitationBlockedInvitee(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newInvitationService()
|
||||
social := newSocialService()
|
||||
pub := &capturePublisher{}
|
||||
svc.SetNotifier(pub)
|
||||
soc := newSocialService()
|
||||
|
||||
// The inviter has blocked an invitee: the invitation is refused outright (the inviter is aware).
|
||||
inviter := provisionAccount(t)
|
||||
invitee := provisionAccount(t)
|
||||
if err := social.Block(ctx, invitee, inviter); err != nil {
|
||||
blockedInvitee := provisionAccount(t)
|
||||
if err := soc.Block(ctx, inviter, blockedInvitee); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) {
|
||||
t.Fatalf("create blocked = %v, want ErrInvitationBlocked", err)
|
||||
if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{blockedInvitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) {
|
||||
t.Fatalf("create with a blocked invitee = %v, want ErrInvitationBlocked", err)
|
||||
}
|
||||
|
||||
// An invitee who has blocked the inviter is instead suppressed: the invitation is created
|
||||
// (no error — the inviter notices nothing), but that invitee is never notified and never
|
||||
// sees it in their list.
|
||||
inviter2 := provisionAccount(t)
|
||||
suppressed := provisionAccount(t)
|
||||
if err := soc.Block(ctx, suppressed, inviter2); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateInvitation(ctx, inviter2, []uuid.UUID{suppressed}, englishInvite()); err != nil {
|
||||
t.Fatalf("create with a suppressing invitee = %v, want nil", err)
|
||||
}
|
||||
if pub.notified(suppressed, notify.NotifyInvitation) {
|
||||
t.Error("an invitee who blocked the inviter must not be notified of the invitation")
|
||||
}
|
||||
if list, _ := svc.ListInvitations(ctx, suppressed); len(list) != 0 {
|
||||
t.Errorf("suppressed invitee's invitation list = %v, want empty", list)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ func openParams(seed int64) game.CreateParams {
|
||||
|
||||
func openGame(t *testing.T, svc *game.Service, starter uuid.UUID, seed int64) game.Game {
|
||||
t.Helper()
|
||||
g, joined, err := svc.OpenOrJoin(context.Background(), starter, openParams(seed), time.Now().Add(time.Minute))
|
||||
g, joined, err := svc.OpenOrJoin(context.Background(), starter, openParams(seed), time.Now().Add(time.Minute), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open game: %v", err)
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func TestOpenGameStarterWaitsWhenOpponentMovesFirst(t *testing.T) {
|
||||
clearOpenGames(t)
|
||||
svc := newGameService()
|
||||
starter := provisionAccount(t)
|
||||
g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute))
|
||||
g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
@@ -128,7 +128,7 @@ func TestOpenGameJoinAfterStarterMoved(t *testing.T) {
|
||||
}
|
||||
|
||||
joiner := provisionAccount(t)
|
||||
g2, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute))
|
||||
g2, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("join: %v", err)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,20 @@ func (c *capturePublisher) notified(user uuid.UUID, sub string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// delivered reports whether an intent of the given top-level kind (e.g. chat_message,
|
||||
// nudge) was published to user — used to assert that a blocked sender's message or nudge
|
||||
// never reaches the blocker.
|
||||
func (c *capturePublisher) delivered(user uuid.UUID, kind string) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, in := range c.intents {
|
||||
if in.UserID == user && in.Kind == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -128,17 +142,53 @@ func TestFriendRequestRefusedByToggleAndBlock(t *testing.T) {
|
||||
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 {
|
||||
// Block from the requester's own side: a requester who has blocked the addressee is
|
||||
// refused outright (they are the blocker and aware of it). The reverse direction — the
|
||||
// addressee having blocked the requester — is instead silently suppressed, covered by
|
||||
// TestFriendRequestFromBlockedIsSuppressed.
|
||||
_, seats := newGameWithSeats(t, 2)
|
||||
c, d := seats[0], seats[1]
|
||||
if err := svc.Block(ctx, c, d); 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)
|
||||
t.Fatalf("blocker's own request = %v, want ErrRequestBlocked", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockSeversFriendship(t *testing.T) {
|
||||
// TestFriendRequestFromBlockedIsSuppressed checks the store-but-hide path: when the
|
||||
// addressee has blocked the requester, the requester's friend request succeeds silently
|
||||
// (no error — they must not notice the block), is stored, but is never delivered to nor
|
||||
// surfaced for the blocker.
|
||||
func TestFriendRequestFromBlockedIsSuppressed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
pub := &capturePublisher{}
|
||||
svc.SetNotifier(pub)
|
||||
_, seats := newGameWithSeats(t, 2)
|
||||
blocker, blocked := seats[0], seats[1]
|
||||
if err := svc.Block(ctx, blocker, blocked); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if err := svc.SendFriendRequest(ctx, blocked, blocker); err != nil {
|
||||
t.Fatalf("suppressed request = %v, want nil", err)
|
||||
}
|
||||
if pub.notified(blocker, notify.NotifyFriendRequest) {
|
||||
t.Error("blocker must not be notified of a suppressed request")
|
||||
}
|
||||
if got, _ := svc.ListIncomingRequests(ctx, blocker); len(got) != 0 {
|
||||
t.Errorf("blocker incoming = %v, want none (suppressed)", got)
|
||||
}
|
||||
// The blocked user sees an ordinary outgoing request — no leak of the block.
|
||||
if got, _ := svc.ListOutgoingRequests(ctx, blocked); len(got) != 1 || got[0] != blocker {
|
||||
t.Errorf("blocked outgoing = %v, want [blocker]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlockKeepsFriendshipHiddenFromBlocker checks a block overrides but does not delete a
|
||||
// friendship: the blocker stops seeing the blocked friend, the blocked user's own list is
|
||||
// unchanged (so they never notice), and an unblock cleanly restores the friendship.
|
||||
func TestBlockKeepsFriendshipHiddenFromBlocker(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
_, seats := newGameWithSeats(t, 2)
|
||||
@@ -153,7 +203,16 @@ func TestBlockSeversFriendship(t *testing.T) {
|
||||
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)
|
||||
t.Errorf("blocker's friend list = %v, want the blocked friend hidden", friends)
|
||||
}
|
||||
if friends, _ := svc.ListFriends(ctx, b); len(friends) != 1 || friends[0] != a {
|
||||
t.Errorf("blocked user's friend list = %v, want [blocker] unchanged", friends)
|
||||
}
|
||||
if err := svc.Unblock(ctx, a, b); err != nil {
|
||||
t.Fatalf("unblock: %v", err)
|
||||
}
|
||||
if friends, _ := svc.ListFriends(ctx, a); len(friends) != 1 || friends[0] != b {
|
||||
t.Errorf("after unblock, blocker's friend list = %v, want [b] restored", friends)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user