81b9e1529e
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.
298 lines
9.9 KiB
Go
298 lines
9.9 KiB
Go
package lobby
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"slices"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
|
|
"scrabble/backend/internal/engine"
|
|
"scrabble/backend/internal/game"
|
|
"scrabble/backend/internal/notify"
|
|
)
|
|
|
|
// stubMatcher is a fake GameMatcher: it returns canned games and records the calls the
|
|
// matchmaker makes, so the unit tests cover delegation, the opponent_joined emit and
|
|
// the wait-window math without a database. The DB-backed open/join/substitute logic is
|
|
// covered by the integration suite.
|
|
type stubMatcher struct {
|
|
openGame game.Game
|
|
openJoined bool
|
|
openErr error
|
|
openCalls int
|
|
lastDeadline time.Time
|
|
lastExclude []uuid.UUID
|
|
|
|
expired []game.OpenGame
|
|
|
|
attachGame game.Game
|
|
attached bool
|
|
attachErr error
|
|
attachedGames []uuid.UUID
|
|
|
|
createGame game.Game
|
|
createErr error
|
|
createCalls int
|
|
createParams game.CreateParams
|
|
}
|
|
|
|
func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time, exclude []uuid.UUID) (game.Game, bool, error) {
|
|
s.openCalls++
|
|
s.lastDeadline = deadline
|
|
s.lastExclude = exclude
|
|
return s.openGame, s.openJoined, s.openErr
|
|
}
|
|
|
|
func (s *stubMatcher) AttachRobot(_ context.Context, gameID, _ uuid.UUID, _ string) (game.Game, bool, error) {
|
|
if s.attachErr != nil {
|
|
return game.Game{}, false, s.attachErr
|
|
}
|
|
if s.attached {
|
|
s.attachedGames = append(s.attachedGames, gameID)
|
|
}
|
|
return s.attachGame, s.attached, nil
|
|
}
|
|
|
|
func (s *stubMatcher) ExpiredOpen(_ context.Context, _ time.Time) ([]game.OpenGame, error) {
|
|
return s.expired, nil
|
|
}
|
|
|
|
func (s *stubMatcher) InitialState(_ context.Context, _, _ uuid.UUID) (notify.PlayerState, error) {
|
|
return notify.PlayerState{}, nil
|
|
}
|
|
|
|
func (s *stubMatcher) Create(_ context.Context, params game.CreateParams) (game.Game, error) {
|
|
s.createCalls++
|
|
s.createParams = params
|
|
return s.createGame, s.createErr
|
|
}
|
|
|
|
// fakeRobots is a RobotProvider returning a fixed robot id, or an error to model an
|
|
// empty pool. It records the variant of the last substitution request.
|
|
type fakeRobots struct {
|
|
id uuid.UUID
|
|
err error
|
|
lastVariant engine.Variant
|
|
}
|
|
|
|
func (f *fakeRobots) Pick(variant engine.Variant) (uuid.UUID, error) {
|
|
f.lastVariant = variant
|
|
if f.err != nil {
|
|
return uuid.Nil, f.err
|
|
}
|
|
return f.id, nil
|
|
}
|
|
|
|
func (f *fakeRobots) PickNamed(variant engine.Variant) (uuid.UUID, string, error) {
|
|
id, err := f.Pick(variant)
|
|
return id, "Robot", err
|
|
}
|
|
|
|
// capturePub records every published intent.
|
|
type capturePub struct{ intents []notify.Intent }
|
|
|
|
func (c *capturePub) Publish(intents ...notify.Intent) { c.intents = append(c.intents, intents...) }
|
|
|
|
// fakeBlocker is a Blocker returning a canned both-direction block set.
|
|
type fakeBlocker struct {
|
|
with []uuid.UUID
|
|
err error
|
|
}
|
|
|
|
func (f *fakeBlocker) Blocks(context.Context, uuid.UUID, uuid.UUID) (bool, error) { return false, nil }
|
|
func (f *fakeBlocker) BlockedWith(context.Context, uuid.UUID) ([]uuid.UUID, error) {
|
|
return f.with, f.err
|
|
}
|
|
|
|
// TestEnqueueExcludesBlockedStarters checks the matchmaker passes the caller's block set
|
|
// (both directions) to OpenOrJoin as the exclusion list, so auto-match never joins a game
|
|
// whose waiting player has a block with the caller.
|
|
func TestEnqueueExcludesBlockedStarters(t *testing.T) {
|
|
caller, blocked := uuid.New(), uuid.New()
|
|
m := &stubMatcher{openGame: twoSeatGame(caller, uuid.Nil)}
|
|
mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop())
|
|
mm.SetBlocker(&fakeBlocker{with: []uuid.UUID{blocked}})
|
|
if _, err := mm.Enqueue(context.Background(), caller, engine.VariantEnglish, true); err != nil {
|
|
t.Fatalf("enqueue: %v", err)
|
|
}
|
|
if !slices.Contains(m.lastExclude, blocked) {
|
|
t.Fatalf("OpenOrJoin exclude = %v, want it to contain the blocked id %s", m.lastExclude, blocked)
|
|
}
|
|
}
|
|
|
|
// twoSeatGame is a two-player game seating starter at seat 0 and opponent at seat 1
|
|
// (uuid.Nil for a still-empty opponent seat).
|
|
func twoSeatGame(starter, opponent uuid.UUID) game.Game {
|
|
return game.Game{
|
|
ID: uuid.New(),
|
|
Variant: engine.VariantEnglish,
|
|
Seats: []game.Seat{
|
|
{Seat: 0, AccountID: starter},
|
|
{Seat: 1, AccountID: opponent},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestEnqueueOpensGameWithoutOpponent(t *testing.T) {
|
|
starter := uuid.New()
|
|
m := &stubMatcher{openGame: twoSeatGame(starter, uuid.Nil)}
|
|
pub := &capturePub{}
|
|
mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop())
|
|
mm.SetNotifier(pub)
|
|
|
|
res, err := mm.Enqueue(context.Background(), starter, engine.VariantEnglish, true)
|
|
if err != nil {
|
|
t.Fatalf("enqueue: %v", err)
|
|
}
|
|
if res.Matched {
|
|
t.Error("opening a game must report Matched=false")
|
|
}
|
|
if m.openCalls != 1 {
|
|
t.Errorf("OpenOrJoin calls = %d, want 1", m.openCalls)
|
|
}
|
|
if len(pub.intents) != 0 {
|
|
t.Errorf("opening a game must not emit opponent_joined; got %d intents", len(pub.intents))
|
|
}
|
|
}
|
|
|
|
func TestEnqueueJoinEmitsOpponentJoinedToStarter(t *testing.T) {
|
|
starter, joiner := uuid.New(), uuid.New()
|
|
m := &stubMatcher{openGame: twoSeatGame(starter, joiner), openJoined: true}
|
|
pub := &capturePub{}
|
|
mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop())
|
|
mm.SetNotifier(pub)
|
|
|
|
res, err := mm.Enqueue(context.Background(), joiner, engine.VariantEnglish, true)
|
|
if err != nil {
|
|
t.Fatalf("enqueue: %v", err)
|
|
}
|
|
if !res.Matched {
|
|
t.Error("joining a waiting game must report Matched=true")
|
|
}
|
|
if len(pub.intents) != 1 {
|
|
t.Fatalf("joining must emit one opponent_joined; got %d", len(pub.intents))
|
|
}
|
|
if got := pub.intents[0]; got.Kind != notify.KindOpponentJoined || got.UserID != starter {
|
|
t.Errorf("opponent_joined = (kind %q, user %s), want (%q, starter %s)", got.Kind, got.UserID, notify.KindOpponentJoined, starter)
|
|
}
|
|
}
|
|
|
|
func TestStartVsAISeatsRobotAndFlagsGame(t *testing.T) {
|
|
human, robotID := uuid.New(), uuid.New()
|
|
m := &stubMatcher{createGame: twoSeatGame(human, robotID)}
|
|
robots := &fakeRobots{id: robotID}
|
|
mm := NewMatchmaker(m, robots, time.Minute, time.Minute, zap.NewNop())
|
|
|
|
res, err := mm.StartVsAI(context.Background(), human, engine.VariantRussianScrabble, false)
|
|
if err != nil {
|
|
t.Fatalf("start vs AI: %v", err)
|
|
}
|
|
if !res.Matched {
|
|
t.Error("an AI game starts already matched (Matched=true)")
|
|
}
|
|
// The AI path creates a seated active game and never enters the open pool.
|
|
if m.createCalls != 1 || m.openCalls != 0 {
|
|
t.Errorf("calls: create=%d open=%d, want create=1 open=0", m.createCalls, m.openCalls)
|
|
}
|
|
if robots.lastVariant != engine.VariantRussianScrabble {
|
|
t.Errorf("robot picked for variant %v, want RussianScrabble", robots.lastVariant)
|
|
}
|
|
if !m.createParams.VsAI {
|
|
t.Error("AI game create params must set VsAI")
|
|
}
|
|
if len(m.createParams.Seats) != 2 ||
|
|
!slices.Contains(m.createParams.Seats, human) || !slices.Contains(m.createParams.Seats, robotID) {
|
|
t.Errorf("AI game seats %v, want exactly the human %s and the robot %s", m.createParams.Seats, human, robotID)
|
|
}
|
|
}
|
|
|
|
func TestStartVsAINoRobotLeavesNoGame(t *testing.T) {
|
|
poolErr := errors.New("pool empty")
|
|
m := &stubMatcher{}
|
|
mm := NewMatchmaker(m, &fakeRobots{err: poolErr}, time.Minute, time.Minute, zap.NewNop())
|
|
|
|
if _, err := mm.StartVsAI(context.Background(), uuid.New(), engine.VariantEnglish, true); !errors.Is(err, poolErr) {
|
|
t.Fatalf("start vs AI with empty pool = %v, want poolErr", err)
|
|
}
|
|
if m.createCalls != 0 {
|
|
t.Errorf("no robot available must not create a game; create calls = %d", m.createCalls)
|
|
}
|
|
}
|
|
|
|
func TestEnqueueDeadlineWithinWindow(t *testing.T) {
|
|
base := time.Now()
|
|
m := &stubMatcher{openGame: twoSeatGame(uuid.New(), uuid.Nil)}
|
|
mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, 90*time.Second, 90*time.Second, zap.NewNop())
|
|
mm.clock = func() time.Time { return base }
|
|
|
|
if _, err := mm.Enqueue(context.Background(), uuid.New(), engine.VariantEnglish, true); err != nil {
|
|
t.Fatalf("enqueue: %v", err)
|
|
}
|
|
lo, hi := base.Add(90*time.Second), base.Add(180*time.Second)
|
|
if m.lastDeadline.Before(lo) || !m.lastDeadline.Before(hi) {
|
|
t.Errorf("deadline %s not in [%s, %s)", m.lastDeadline, lo, hi)
|
|
}
|
|
}
|
|
|
|
func TestReapSubstitutesRobotAndEmits(t *testing.T) {
|
|
human, robotID := uuid.New(), uuid.New()
|
|
og := game.OpenGame{ID: uuid.New(), Variant: engine.VariantRussianScrabble}
|
|
m := &stubMatcher{
|
|
expired: []game.OpenGame{og},
|
|
attachGame: twoSeatGame(human, robotID),
|
|
attached: true,
|
|
}
|
|
robots := &fakeRobots{id: robotID}
|
|
pub := &capturePub{}
|
|
mm := NewMatchmaker(m, robots, time.Minute, time.Minute, zap.NewNop())
|
|
mm.SetNotifier(pub)
|
|
|
|
mm.Reap(context.Background(), time.Now())
|
|
|
|
if robots.lastVariant != engine.VariantRussianScrabble {
|
|
t.Errorf("robot picked for %v, want the open game's variant", robots.lastVariant)
|
|
}
|
|
if len(m.attachedGames) != 1 || m.attachedGames[0] != og.ID {
|
|
t.Errorf("attached games = %v, want [%s]", m.attachedGames, og.ID)
|
|
}
|
|
if len(pub.intents) != 1 || pub.intents[0].Kind != notify.KindOpponentJoined || pub.intents[0].UserID != human {
|
|
t.Errorf("reap must emit opponent_joined to the human starter; got %+v", pub.intents)
|
|
}
|
|
}
|
|
|
|
func TestReapDefersWithoutRobot(t *testing.T) {
|
|
m := &stubMatcher{expired: []game.OpenGame{{ID: uuid.New(), Variant: engine.VariantEnglish}}}
|
|
pub := &capturePub{}
|
|
mm := NewMatchmaker(m, &fakeRobots{err: errors.New("empty pool")}, time.Minute, time.Minute, zap.NewNop())
|
|
mm.SetNotifier(pub)
|
|
|
|
mm.Reap(context.Background(), time.Now())
|
|
|
|
if len(m.attachedGames) != 0 {
|
|
t.Errorf("no robot available: must not attach; attached %v", m.attachedGames)
|
|
}
|
|
if len(pub.intents) != 0 {
|
|
t.Errorf("no robot available: must not emit; got %d intents", len(pub.intents))
|
|
}
|
|
}
|
|
|
|
func TestReapSkipsWhenHumanJoinedFirst(t *testing.T) {
|
|
m := &stubMatcher{
|
|
expired: []game.OpenGame{{ID: uuid.New(), Variant: engine.VariantEnglish}},
|
|
attached: false, // AttachRobot reports the game already filled by a human
|
|
}
|
|
pub := &capturePub{}
|
|
mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop())
|
|
mm.SetNotifier(pub)
|
|
|
|
mm.Reap(context.Background(), time.Now())
|
|
|
|
if len(pub.intents) != 0 {
|
|
t.Errorf("a human-filled game must not emit opponent_joined; got %d", len(pub.intents))
|
|
}
|
|
}
|