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:
@@ -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