Files
tiennm99 f4acc12668 refactor(wsapi): split wsapi_test.go by topic, delete hub_test.go
wsapi_test.go was 2688 lines and 82 tests spanning every topic in the
package. Pure moves: wsapi_test.go keeps the harness (the hand-built
dictionary, the server-over-a-real-socket helpers, and the client-side
driving methods every topic file below uses); game_test.go,
presence_test.go, resume_test.go, lobby_test.go, protocol_test.go,
ratelimit_test.go, bot_board_test.go, chat_test.go, deadend_test.go
and report_test.go each hold one topic's tests. Three tests about
sanitizing and distinguishing nicknames moved into the existing
nickname_test.go alongside its fuzz target.

hub_test.go was three lines of comment pointing at hub.go; the note
now lives there instead, next to roomCount.

Verified by counting: the wsapi package's declaration count is
unchanged, `go test -list` finds the same tests it did before the
split, and go build/vet/test -race and golangci-lint stay clean.
2026-09-21 16:32:57 +07:00

105 lines
2.6 KiB
Go

package wsapi
import (
"testing"
"time"
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
)
// The rate limiters themselves, and the actions that spend them.
// TestSubmitRateLimit checks the token bucket refuses a burst well past what a
// person types, since each submit costs a dictionary lookup.
func TestSubmitRateLimit(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
c := dial(t, url)
c.hello("Người thử")
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{
StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY},
}})
started := c.await("game_started").GetGameStarted()
for range submitBurst + 5 {
c.submit("khong co", started.GetTurnSeq())
}
for range 40 {
m := c.recv()
if payloadCase(m) == "error" && m.GetError().GetCode() == "too_fast" {
return
}
}
t.Error("never rate-limited despite submitting past the burst")
}
func TestBucketRefills(t *testing.T) {
now := time.Now()
b := newBucket(5, 2, now)
for i := range 2 {
if !b.allow(now) {
t.Fatalf("burst should cover the first two, call %d refused", i+1)
}
}
if b.allow(now) {
t.Fatal("third call should exhaust the bucket")
}
// 5/sec means one token back after 200ms.
if !b.allow(now.Add(220 * time.Millisecond)) {
t.Error("bucket did not refill")
}
}
func TestKeyedLimiterIsPerKey(t *testing.T) {
now := time.Now()
l := newKeyedLimiter(1, 1, time.Minute)
if !l.allow("a", now) {
t.Fatal("first call for a key should pass")
}
if l.allow("a", now) {
t.Fatal("second call for the same key should be refused")
}
if !l.allow("b", now) {
t.Error("a different key must have its own bucket")
}
}
func TestKeyedLimiterSweepsIdleBuckets(t *testing.T) {
now := time.Now()
l := newKeyedLimiter(10, 5, time.Minute)
l.allow("gone", now)
l.sweep(now.Add(2 * time.Minute))
l.mu.Lock()
n := len(l.buckets)
l.mu.Unlock()
if n != 0 {
t.Errorf("%d buckets survived the sweep, want 0", n)
}
}
// TestLobbyActionsAreRateLimited: every accepted action is broadcast to both
// seats, so an unbounded one lets a player fill the opponent's outbox until
// the server closes their session for falling behind.
func TestLobbyActionsAreRateLimited(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
_, guest, _ := pvpLobby(t, url)
for i := range submitBurst + 5 {
guest.setReady(i%2 == 0)
}
// The limiter answers before the room does, so a refusal has to appear in
// the stream rather than an unbroken run of room states.
for range 30 {
if payloadCase(guest.recv()) == "error" {
return
}
}
t.Error("a burst of lobby actions was never refused")
}