64be0572b3
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
Blocking an auto-match opponent who is secretly a pooled robot is recorded instead in a separate `robot_blocks` table. Now blocking behaves the same in that game (struck name, hidden composer) and lists the blocked opponent under the name you saw, but is recorded only against that game — the disguise holds, the shared robot is never globally blocked, and the matchmaker keeps pairing you with robots (so you can never block yourself out of opponents). - the shared robot account is never put in `blocks` - the matchmaker keeps it free and it is not blocked under its other per-game names - the blocked list and the in-game card still show it by joining that table; an unblock deletes the row
323 lines
12 KiB
Go
323 lines
12 KiB
Go
//go:build integration
|
|
|
|
package inttest
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"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"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// TestRobotBlockIsPerGameAndMatchmakerImmune checks that blocking a disguised-robot opponent
|
|
// is recorded per-game in robot_blocks — never in the blocks table or against the shared robot
|
|
// account — so the matchmaker keeps the robot free, and that unblocking by the row id removes it.
|
|
func TestRobotBlockIsPerGameAndMatchmakerImmune(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newSocialService()
|
|
accs := account.NewStore(testDB)
|
|
human := provisionAccount(t)
|
|
robot, err := accs.ProvisionRobot(ctx, "robot-block-"+uuid.NewString(), "Robbie")
|
|
if err != nil {
|
|
t.Fatalf("provision robot: %v", err)
|
|
}
|
|
g, err := newGameService().Create(ctx, game.CreateParams{
|
|
Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robot.ID},
|
|
TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create game: %v", err)
|
|
}
|
|
|
|
if err := svc.BlockInGame(ctx, human, robot.ID, g.ID); err != nil {
|
|
t.Fatalf("block robot: %v", err)
|
|
}
|
|
rbs, err := svc.ListRobotBlocks(ctx, human)
|
|
if err != nil {
|
|
t.Fatalf("list robot blocks: %v", err)
|
|
}
|
|
if len(rbs) != 1 || rbs[0].GameID != g.ID {
|
|
t.Fatalf("robot blocks = %v, want one for game %s", rbs, g.ID)
|
|
}
|
|
if bl, _ := svc.ListBlocks(ctx, human); len(bl) != 0 {
|
|
t.Errorf("blocks table must stay empty for a robot block, got %v", bl)
|
|
}
|
|
if yes, _ := svc.IsBlocked(ctx, human, robot.ID); yes {
|
|
t.Error("the shared robot account must never be blocked")
|
|
}
|
|
// Matchmaking immunity: the robot is never in the caller's exclusion set, so the matchmaker
|
|
// keeps giving robots and the player can never be starved by blocking them.
|
|
if with, _ := svc.BlockedWith(ctx, human); contains(with, robot.ID) {
|
|
t.Error("a robot block must not put the robot in BlockedWith")
|
|
}
|
|
// Unblock by the robot_blocks row id removes it.
|
|
if err := svc.Unblock(ctx, human, rbs[0].ID); err != nil {
|
|
t.Fatalf("unblock robot: %v", err)
|
|
}
|
|
if rbs, _ := svc.ListRobotBlocks(ctx, human); len(rbs) != 0 {
|
|
t.Errorf("robot block not removed by unblock: %v", rbs)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|