2207ac6132
Add an in-memory SendLimiter enforcing a one-per-minute cooldown and a five-per-rolling-hour cap per recipient address, checked before provisioning or sending in RequestCode, RequestLoginCode and RequestLinkCode. It guards against email bombing and protects the relay quota. The limiter is injected in main (nil in tests, so the domain suite is unaffected); ErrTooManyRequests maps to HTTP 429.
149 lines
5.7 KiB
Go
149 lines
5.7 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"scrabble/backend/internal/account"
|
|
"scrabble/backend/internal/engine"
|
|
"scrabble/backend/internal/game"
|
|
"scrabble/backend/internal/session"
|
|
"scrabble/backend/internal/social"
|
|
)
|
|
|
|
func TestStatusForError(t *testing.T) {
|
|
cases := map[string]struct {
|
|
err error
|
|
wantStatus int
|
|
wantCode string
|
|
}{
|
|
"not a player": {game.ErrNotAPlayer, http.StatusForbidden, "not_a_player"},
|
|
"not your turn": {game.ErrNotYourTurn, http.StatusConflict, "not_your_turn"},
|
|
"nudge own turn": {social.ErrNudgeOnOwnTurn, http.StatusConflict, "nudge_own_turn"},
|
|
"nudge too soon": {social.ErrNudgeTooSoon, http.StatusConflict, "nudge_too_soon"},
|
|
"chat off turn": {social.ErrChatNotYourTurn, http.StatusConflict, "chat_not_your_turn"},
|
|
"chat one/turn": {social.ErrChatAlreadySentThisTurn, http.StatusConflict, "chat_already_sent"},
|
|
"illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"},
|
|
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
|
|
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
|
|
"too many codes": {account.ErrTooManyRequests, http.StatusTooManyRequests, "too_many_requests"},
|
|
"session gone": {session.ErrNotFound, http.StatusUnauthorized, "session_invalid"},
|
|
"chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"},
|
|
"no hint move": {game.ErrNoHintAvailable, http.StatusConflict, "no_hint_available"},
|
|
"no hints left": {game.ErrNoHintsLeft, http.StatusConflict, "hint_unavailable"},
|
|
"game limit": {game.ErrGameLimitReached, http.StatusConflict, "game_limit_reached"},
|
|
"unknown -> 500": {context_deadline, http.StatusInternalServerError, "internal"},
|
|
}
|
|
for name, tc := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
status, code := statusForError(tc.err)
|
|
if status != tc.wantStatus || code != tc.wantCode {
|
|
t.Fatalf("statusForError(%v) = (%d, %q), want (%d, %q)", tc.err, status, code, tc.wantStatus, tc.wantCode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// context_deadline is an arbitrary unmapped error standing in for "anything
|
|
// unrecognised", which must fall through to 500/internal.
|
|
var context_deadline = errNew("boom")
|
|
|
|
type simpleErr string
|
|
|
|
func (e simpleErr) Error() string { return string(e) }
|
|
func errNew(s string) error { return simpleErr(s) }
|
|
|
|
func TestGameDTOFromGame(t *testing.T) {
|
|
gid, aid := uuid.New(), uuid.New()
|
|
g := game.Game{
|
|
ID: gid,
|
|
Variant: engine.VariantEnglish,
|
|
DictVersion: "v1",
|
|
Status: game.StatusActive,
|
|
Players: 2,
|
|
ToMove: 1,
|
|
TurnTimeout: 24 * time.Hour,
|
|
MoveCount: 3,
|
|
Seats: []game.Seat{{Seat: 0, AccountID: aid, Score: 12}},
|
|
}
|
|
dto := gameDTOFromGame(g)
|
|
if dto.ID != gid.String() || dto.Variant != "scrabble_en" || dto.ToMove != 1 || dto.TurnTimeoutSecs != 86400 {
|
|
t.Fatalf("game dto mismatch: %+v", dto)
|
|
}
|
|
if len(dto.Seats) != 1 || dto.Seats[0].AccountID != aid.String() || dto.Seats[0].Score != 12 {
|
|
t.Fatalf("seat dto mismatch: %+v", dto.Seats)
|
|
}
|
|
}
|
|
|
|
func TestProfileResponseForAwayWindow(t *testing.T) {
|
|
acc := account.Account{
|
|
ID: uuid.New(),
|
|
DisplayName: "Kaya",
|
|
PreferredLanguage: "ru",
|
|
TimeZone: "Europe/Moscow",
|
|
AwayStart: time.Date(0, 1, 1, 0, 0, 0, 0, time.UTC),
|
|
AwayEnd: time.Date(0, 1, 1, 7, 30, 0, 0, time.UTC),
|
|
}
|
|
dto := profileResponseFor(acc)
|
|
if dto.AwayStart != "00:00" || dto.AwayEnd != "07:30" {
|
|
t.Fatalf("away window = (%q, %q), want (00:00, 07:30)", dto.AwayStart, dto.AwayEnd)
|
|
}
|
|
if dto.PreferredLanguage != "ru" || dto.TimeZone != "Europe/Moscow" {
|
|
t.Fatalf("profile dto mismatch: %+v", dto)
|
|
}
|
|
}
|
|
|
|
func TestMoveRecordDTOFrom(t *testing.T) {
|
|
rec := engine.MoveRecord{
|
|
Player: 1,
|
|
Action: engine.ActionPlay,
|
|
Dir: engine.Vertical,
|
|
MainRow: 7,
|
|
MainCol: 7,
|
|
Tiles: []engine.TileRecord{{Row: 7, Col: 7, Letter: "A", Blank: false}},
|
|
Words: []string{"AB"},
|
|
Score: 10,
|
|
Total: 10,
|
|
}
|
|
dto := moveRecordDTOFrom(rec)
|
|
if dto.Action != "play" || dto.Dir != "V" || dto.Score != 10 || len(dto.Tiles) != 1 || dto.Tiles[0].Letter != "A" {
|
|
t.Fatalf("move dto mismatch: %+v", dto)
|
|
}
|
|
}
|
|
|
|
// TestGameDTOCarriesSeatSnapshot checks the DTO carries each seat's display-name
|
|
// snapshot (so a disguised robot's per-game name reaches the REST views, not just live
|
|
// events) rather than leaving it for an account lookup.
|
|
func TestGameDTOCarriesSeatSnapshot(t *testing.T) {
|
|
g := game.Game{
|
|
Variant: engine.VariantEnglish, Players: 2, TurnTimeout: time.Hour,
|
|
Seats: []game.Seat{
|
|
{Seat: 0, AccountID: uuid.New(), DisplayName: "Звёздный_Барс2"},
|
|
{Seat: 1, AccountID: uuid.New(), DisplayName: "Тест"},
|
|
},
|
|
}
|
|
dto := gameDTOFromGame(g)
|
|
if dto.Seats[0].DisplayName != "Звёздный_Барс2" || dto.Seats[1].DisplayName != "Тест" {
|
|
t.Fatalf("seat snapshot not carried into DTO: %+v", dto.Seats)
|
|
}
|
|
}
|
|
|
|
// TestFillSeatNamesKeepsSnapshot checks fillSeatNames leaves a seat whose snapshot is
|
|
// already set untouched (no account lookup), so the per-game name is not overwritten by
|
|
// the robot's seeded account name.
|
|
func TestFillSeatNamesKeepsSnapshot(t *testing.T) {
|
|
s := &Server{} // accounts is unused: a present snapshot must skip the lookup
|
|
dto := &gameDTO{Seats: []seatDTO{
|
|
{Seat: 0, AccountID: uuid.New().String(), DisplayName: "Звёздный_Барс2"},
|
|
{Seat: 1, AccountID: uuid.New().String(), DisplayName: "Тест"},
|
|
}}
|
|
s.fillSeatNames(context.Background(), dto, map[string]string{})
|
|
if dto.Seats[0].DisplayName != "Звёздный_Барс2" || dto.Seats[1].DisplayName != "Тест" {
|
|
t.Fatalf("fillSeatNames overwrote a seat snapshot: %+v", dto.Seats)
|
|
}
|
|
}
|