fix(social): robot blocks
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
This commit is contained in:
Ilia Denisov
2026-06-18 13:12:19 +02:00
parent 81b9e1529e
commit 64be0572b3
29 changed files with 700 additions and 68 deletions
+52
View File
@@ -10,7 +10,9 @@ import (
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/social"
)
@@ -249,6 +251,56 @@ func TestAdminSocialLists(t *testing.T) {
}
}
// 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()