Stage 4: lobby & social (matchmaking, friends, blocks, chat+nudge, invitations, profile, email, multi-player drop-out)
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user