Stage 4: lobby & social (matchmaking, friends, blocks, chat+nudge, invitations, profile, email, multi-player drop-out)
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 9s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 9s

Engine: multi-player drop-out-and-continue with a per-game tile disposition (remove default / return), resigned seats skipped and excluded from the win, leaver rack never revealed; 2-player behaviour unchanged.

New domains (service/store, no HTTP yet): internal/social (friend request/accept graph, per-user blocks, per-game chat with nudge as a message kind, content filter via mvdan.cc/xurls/v2 + leet/separator normaliser + phone heuristic) and internal/lobby (in-memory variant-keyed matchmaking pool, friend-game invitations invite->accept with lazy 7-day expiry). account gains profile editing and the email confirm-code flow (Mailer seam: SMTP or log mailer).

Migration 00003_social.sql + regenerated jet. main wires the new services into the server (accessors for the Stage 6 handlers); robot substitution stays in Stage 5, REST/stream/push in Stage 6/8. Docs (PLAN, ARCHITECTURE, FUNCTIONAL+ru, TESTING, README) updated.
This commit is contained in:
Ilia Denisov
2026-06-02 19:29:30 +02:00
parent 571bc8c9f2
commit bfa8797f8c
54 changed files with 4270 additions and 81 deletions
+159
View File
@@ -0,0 +1,159 @@
//go:build integration
package inttest
import (
"context"
"errors"
"regexp"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/account"
)
// capturingMailer records the last message instead of sending it, so tests can
// recover the confirm-code from the body.
type capturingMailer struct{ lastBody string }
func (m *capturingMailer) Send(_ context.Context, _, _, body string) error {
m.lastBody = body
return nil
}
var sixDigit = regexp.MustCompile(`\d{6}`)
// TestEmailConfirmFlow covers the happy path: request a code, confirm it, and the
// email becomes a confirmed identity of the account.
func TestEmailConfirmFlow(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer)
acc := provisionAccount(t)
email := "user-" + uuid.NewString() + "@example.com"
if err := svc.RequestCode(ctx, acc, email); err != nil {
t.Fatalf("request code: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if code == "" {
t.Fatalf("no code in mail body %q", mailer.lastBody)
}
// A wrong code is rejected without confirming.
if _, err := svc.ConfirmCode(ctx, acc, email, "000000"); !errors.Is(err, account.ErrCodeMismatch) && !errors.Is(err, account.ErrTooManyAttempts) {
t.Fatalf("wrong code = %v, want mismatch", err)
}
got, err := svc.ConfirmCode(ctx, acc, email, code)
if err != nil {
t.Fatalf("confirm code: %v", err)
}
if got.ID != acc {
t.Errorf("confirmed account = %s, want %s", got.ID, acc)
}
if !identityConfirmed(t, account.KindEmail, email) {
t.Error("email identity must be confirmed after a correct code")
}
}
// TestEmailAlreadyTakenByAnotherAccount refuses to bind an email confirmed by a
// different account (merge is a later stage).
func TestEmailAlreadyTakenByAnotherAccount(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer)
owner := provisionAccount(t)
email := "taken-" + uuid.NewString() + "@example.com"
if err := svc.RequestCode(ctx, owner, email); err != nil {
t.Fatalf("owner request: %v", err)
}
if _, err := svc.ConfirmCode(ctx, owner, email, sixDigit.FindString(mailer.lastBody)); err != nil {
t.Fatalf("owner confirm: %v", err)
}
other := provisionAccount(t)
if err := svc.RequestCode(ctx, other, email); !errors.Is(err, account.ErrEmailTaken) {
t.Fatalf("other request = %v, want ErrEmailTaken", err)
}
}
// TestEmailCodeExpires rejects a code past its TTL (backdated directly).
func TestEmailCodeExpires(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer)
acc := provisionAccount(t)
email := "expire-" + uuid.NewString() + "@example.com"
if err := svc.RequestCode(ctx, acc, email); err != nil {
t.Fatalf("request: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.email_confirmations SET expires_at = now() - interval '1 minute' WHERE account_id = $1`, acc); err != nil {
t.Fatalf("backdate expiry: %v", err)
}
if _, err := svc.ConfirmCode(ctx, acc, email, code); !errors.Is(err, account.ErrCodeExpired) {
t.Fatalf("confirm expired = %v, want ErrCodeExpired", err)
}
}
// TestEmailTooManyAttempts locks a code after the attempt cap.
func TestEmailTooManyAttempts(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer)
acc := provisionAccount(t)
email := "lock-" + uuid.NewString() + "@example.com"
if err := svc.RequestCode(ctx, acc, email); err != nil {
t.Fatalf("request: %v", err)
}
// Five wrong tries are mismatches; the sixth is locked out.
for i := 0; i < 5; i++ {
if _, err := svc.ConfirmCode(ctx, acc, email, "999999"); !errors.Is(err, account.ErrCodeMismatch) {
t.Fatalf("attempt %d = %v, want ErrCodeMismatch", i+1, err)
}
}
if _, err := svc.ConfirmCode(ctx, acc, email, "999999"); !errors.Is(err, account.ErrTooManyAttempts) {
t.Fatalf("after cap = %v, want ErrTooManyAttempts", err)
}
}
// TestUpdateProfilePersists writes a full profile and reads it back.
func TestUpdateProfilePersists(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
acc := provisionAccount(t)
updated, err := store.UpdateProfile(ctx, acc, account.ProfileUpdate{
DisplayName: "Kaya",
PreferredLanguage: "ru",
TimeZone: "Europe/Moscow",
BlockChat: true,
BlockFriendRequests: true,
})
if err != nil {
t.Fatalf("update profile: %v", err)
}
if updated.DisplayName != "Kaya" || updated.PreferredLanguage != "ru" || updated.TimeZone != "Europe/Moscow" {
t.Errorf("profile not applied: %+v", updated)
}
if !updated.BlockChat || !updated.BlockFriendRequests {
t.Errorf("block toggles not applied: %+v", updated)
}
reloaded, err := store.GetByID(ctx, acc)
if err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.TimeZone != "Europe/Moscow" || !reloaded.BlockChat {
t.Errorf("profile did not persist: %+v", reloaded)
}
}
+167
View File
@@ -0,0 +1,167 @@
//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/lobby"
)
// newInvitationService builds an invitation service over the shared pool, starting
// games through a real game service and reading blocks through a social service.
func newInvitationService() *lobby.InvitationService {
return lobby.NewInvitationService(lobby.NewStore(testDB), newGameService(), account.NewStore(testDB), newSocialService())
}
func englishInvite() lobby.InvitationSettings {
return lobby.InvitationSettings{
Variant: engine.VariantEnglish,
TurnTimeout: 24 * time.Hour,
HintsAllowed: true,
HintsPerPlayer: 1,
}
}
func TestMatchmakingPairsAndStartsGame(t *testing.T) {
ctx := context.Background()
mm := lobby.NewMatchmaker(newGameService())
a, b := provisionAccount(t), provisionAccount(t)
r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish)
if err != nil {
t.Fatalf("enqueue a: %v", err)
}
if r1.Matched {
t.Fatal("first enqueue must wait")
}
r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish)
if err != nil {
t.Fatalf("enqueue b: %v", err)
}
if !r2.Matched {
t.Fatal("second enqueue must match")
}
seats, _, status, err := newGameService().Participants(ctx, r2.Game.ID)
if err != nil {
t.Fatalf("participants: %v", err)
}
if status != "active" || len(seats) != 2 {
t.Fatalf("matched game state: status %q seats %v", status, seats)
}
}
func TestInvitationAllAcceptStartsGame(t *testing.T) {
ctx := context.Background()
svc := newInvitationService()
inviter := provisionAccount(t)
invitees := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
if err != nil {
t.Fatalf("create: %v", err)
}
if inv.Status != "pending" || len(inv.Invitees) != 2 {
t.Fatalf("unexpected invitation: %+v", inv)
}
if got, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], true); err != nil || got.Status != "pending" {
t.Fatalf("first accept: status %q err %v", got.Status, err)
}
final, err := svc.RespondInvitation(ctx, inv.ID, invitees[1], true)
if err != nil {
t.Fatalf("second accept: %v", err)
}
if final.Status != "started" || final.GameID == nil {
t.Fatalf("invitation not started: %+v", final)
}
seats, _, status, err := newGameService().Participants(ctx, *final.GameID)
if err != nil {
t.Fatalf("participants: %v", err)
}
if status != "active" || len(seats) != 3 || seats[0] != inviter {
t.Fatalf("started game: status %q seats %v (inviter %s)", status, seats, inviter)
}
}
func TestInvitationDeclineCancels(t *testing.T) {
ctx := context.Background()
svc := newInvitationService()
inviter := provisionAccount(t)
invitees := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
if err != nil {
t.Fatalf("create: %v", err)
}
got, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], false)
if err != nil {
t.Fatalf("decline: %v", err)
}
if got.Status != "declined" || got.GameID != nil {
t.Fatalf("after decline: %+v", got)
}
// A further response is refused.
if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[1], true); !errors.Is(err, lobby.ErrInvitationNotPending) {
t.Fatalf("respond after decline = %v, want ErrInvitationNotPending", err)
}
}
func TestInvitationLazyExpiry(t *testing.T) {
ctx := context.Background()
svc := newInvitationService()
inviter := provisionAccount(t)
invitees := []uuid.UUID{provisionAccount(t)}
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.game_invitations SET expires_at = now() - interval '1 minute' WHERE invitation_id = $1`, inv.ID); err != nil {
t.Fatalf("backdate expiry: %v", err)
}
if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], true); !errors.Is(err, lobby.ErrInvitationExpired) {
t.Fatalf("respond expired = %v, want ErrInvitationExpired", err)
}
}
func TestInvitationBlockedInvitee(t *testing.T) {
ctx := context.Background()
svc := newInvitationService()
social := newSocialService()
inviter := provisionAccount(t)
invitee := provisionAccount(t)
if err := social.Block(ctx, invitee, inviter); 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)
}
}
func TestInvitationCancelByInviter(t *testing.T) {
ctx := context.Background()
svc := newInvitationService()
inviter := provisionAccount(t)
invitees := []uuid.UUID{provisionAccount(t)}
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
if err != nil {
t.Fatalf("create: %v", err)
}
// A non-inviter cannot cancel.
if err := svc.CancelInvitation(ctx, inv.ID, invitees[0]); !errors.Is(err, lobby.ErrNotInviter) {
t.Fatalf("stranger cancel = %v, want ErrNotInviter", err)
}
if err := svc.CancelInvitation(ctx, inv.ID, inviter); err != nil {
t.Fatalf("inviter cancel: %v", err)
}
if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], true); !errors.Is(err, lobby.ErrInvitationNotPending) {
t.Fatalf("respond after cancel = %v, want ErrInvitationNotPending", err)
}
}
@@ -0,0 +1,75 @@
//go:build integration
package inttest
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// TestMultiplayerTimeoutContinues drives a three-player game through the domain:
// the first timeout drops a seat but the game plays on, and the second leaves a
// sole survivor who wins. Empty away windows make the timeouts deterministic.
func TestMultiplayerTimeoutContinues(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t), provisionAccount(t)}
for _, s := range seats {
setAway(t, s, "UTC", "00:00", "00:00") // empty window → no away grace
}
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: 42,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if g.Players != 3 {
t.Fatalf("players = %d, want 3", g.Players)
}
// Seat 0 (to move) goes overdue. It times out, but two seats remain, so the
// game continues and the turn advances off the dropped seat.
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
if _, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil {
t.Fatalf("first sweep: %v", err)
}
h, err := svc.History(ctx, g.ID)
if err != nil {
t.Fatalf("history: %v", err)
}
if h.Game.Status != game.StatusActive {
t.Fatalf("a three-player game must continue after one timeout, status %q", h.Game.Status)
}
if h.Game.ToMove == 0 {
t.Errorf("to-move should advance off the timed-out seat 0, got %d", h.Game.ToMove)
}
// The next seat to move also times out, leaving a single active seat: the game
// finishes and the sole survivor wins.
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
if _, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil {
t.Fatalf("second sweep: %v", err)
}
h2, err := svc.History(ctx, g.ID)
if err != nil {
t.Fatalf("history 2: %v", err)
}
if h2.Game.Status != game.StatusFinished || h2.Game.EndReason != "timeout" {
t.Fatalf("game should finish on the second timeout: status %q reason %q", h2.Game.Status, h2.Game.EndReason)
}
winners := 0
for _, s := range h2.Game.Seats {
if s.IsWinner {
winners++
}
}
if winners != 1 {
t.Errorf("want exactly one surviving winner, got %d", winners)
}
}
+199
View File
@@ -0,0 +1,199 @@
//go:build integration
package inttest
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/social"
)
// newSocialService builds a social service over the shared pool, reading game
// state through a real game service.
func newSocialService() *social.Service {
return social.NewService(social.NewStore(testDB), account.NewStore(testDB), newGameService())
}
// newGameWithSeats creates a started game seating n fresh accounts and returns the
// game id and the seated account ids in seat order.
func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) {
t.Helper()
seats := make([]uuid.UUID, n)
for i := range seats {
seats[i] = provisionAccount(t)
}
g, err := newGameService().Create(context.Background(), game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
})
if err != nil {
t.Fatalf("create game: %v", err)
}
return g.ID, seats
}
func TestFriendRequestLifecycle(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
a, b := provisionAccount(t), provisionAccount(t)
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
t.Fatalf("send: %v", err)
}
// A duplicate request in either direction is refused.
if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrRequestExists) {
t.Fatalf("duplicate = %v, want ErrRequestExists", err)
}
if got, _ := svc.ListIncomingRequests(ctx, b); len(got) != 1 || got[0] != a {
t.Fatalf("incoming for b = %v, want [a]", got)
}
if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil {
t.Fatalf("accept: %v", err)
}
for _, who := range []uuid.UUID{a, b} {
friends, err := svc.ListFriends(ctx, who)
if err != nil {
t.Fatalf("list friends: %v", err)
}
if len(friends) != 1 {
t.Fatalf("friends of %s = %v, want one", who, friends)
}
}
if err := svc.Unfriend(ctx, a, b); err != nil {
t.Fatalf("unfriend: %v", err)
}
if friends, _ := svc.ListFriends(ctx, a); len(friends) != 0 {
t.Errorf("friends after unfriend = %v, want none", friends)
}
}
func TestFriendRequestRefusedByToggleAndBlock(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
store := account.NewStore(testDB)
// Toggle: the addressee does not accept friend requests.
a, b := provisionAccount(t), provisionAccount(t)
if _, err := store.UpdateProfile(ctx, b, account.ProfileUpdate{PreferredLanguage: "en", TimeZone: "UTC", BlockFriendRequests: true}); err != nil {
t.Fatalf("set toggle: %v", err)
}
if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrRequestBlocked) {
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 {
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)
}
}
func TestBlockSeversFriendship(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
a, b := provisionAccount(t), provisionAccount(t)
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
t.Fatalf("send: %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)
}
if friends, _ := svc.ListFriends(ctx, a); len(friends) != 0 {
t.Errorf("friendship must be severed by a block, got %v", friends)
}
}
func TestChatPostListAndBlocks(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
store := account.NewStore(testDB)
gameID, seats := newGameWithSeats(t, 2)
if _, err := svc.PostMessage(ctx, gameID, seats[0], "good luck", "203.0.113.7"); err != nil {
t.Fatalf("post: %v", err)
}
msgs, err := svc.Messages(ctx, gameID, seats[1])
if err != nil {
t.Fatalf("messages: %v", err)
}
if len(msgs) != 1 || msgs[0].Body != "good luck" || msgs[0].SenderIP != "203.0.113.7" {
t.Fatalf("unexpected messages: %+v", msgs)
}
// A per-user block hides the blocked sender's messages from the viewer.
if err := svc.Block(ctx, seats[1], seats[0]); err != nil {
t.Fatalf("block: %v", err)
}
if msgs, _ := svc.Messages(ctx, gameID, seats[1]); len(msgs) != 0 {
t.Errorf("blocked sender's messages still visible: %+v", msgs)
}
// A viewer who disabled chat sees no messages.
other, seats2 := newGameWithSeats(t, 2)
if _, err := svc.PostMessage(ctx, other, seats2[0], "hi", ""); err != nil {
t.Fatalf("post 2: %v", err)
}
if _, err := store.UpdateProfile(ctx, seats2[1], account.ProfileUpdate{PreferredLanguage: "en", TimeZone: "UTC", BlockChat: true}); err != nil {
t.Fatalf("set block_chat: %v", err)
}
if msgs, _ := svc.Messages(ctx, other, seats2[1]); len(msgs) != 0 {
t.Errorf("block_chat viewer should see no messages, got %+v", msgs)
}
}
func TestChatRejectsBadContent(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2)
if _, err := svc.PostMessage(ctx, gameID, seats[0], "join evil.example.com now", ""); !errors.Is(err, social.ErrForbiddenContent) {
t.Fatalf("link post = %v, want ErrForbiddenContent", err)
}
if _, err := svc.PostMessage(ctx, gameID, seats[0], strings.Repeat("a", 61), ""); !errors.Is(err, social.ErrMessageTooLong) {
t.Fatalf("long post = %v, want ErrMessageTooLong", err)
}
// A non-participant cannot post.
if _, err := svc.PostMessage(ctx, gameID, provisionAccount(t), "hi", ""); !errors.Is(err, social.ErrNotParticipant) {
t.Fatalf("stranger post = %v, want ErrNotParticipant", err)
}
}
func TestNudgeRulesAndRateLimit(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2) // seat 0 is to move at the start
// The player to move cannot nudge; the waiting opponent can.
if _, err := svc.Nudge(ctx, gameID, seats[0]); !errors.Is(err, social.ErrNudgeOnOwnTurn) {
t.Fatalf("to-move nudge = %v, want ErrNudgeOnOwnTurn", err)
}
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil {
t.Fatalf("opponent nudge: %v", err)
}
// A second nudge within the hour is refused.
if _, err := svc.Nudge(ctx, gameID, seats[1]); !errors.Is(err, social.ErrNudgeTooSoon) {
t.Fatalf("rapid nudge = %v, want ErrNudgeTooSoon", err)
}
// Backdating the last nudge past the window allows another.
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.chat_messages SET created_at = now() - interval '2 hours' WHERE game_id = $1 AND kind = 'nudge'`, gameID); err != nil {
t.Fatalf("backdate nudge: %v", err)
}
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil {
t.Fatalf("nudge after window: %v", err)
}
}