Files
galaxy-game/game/internal/router/router_test.go
T
Ilia Denisov 601970b028
Tests · Go / test (push) Successful in 2m27s
Tests · UI / test (push) Waiting to run
Tests · Integration / integration (pull_request) Successful in 1m45s
Tests · Go / test (pull_request) Successful in 3m13s
Tests · UI / test (pull_request) Successful in 3m8s
refactor(game): lock-free storage, remove /command, flatten engine wrapper
Three-stage refactor of the game-engine plumbing (game logic untouched):

Stage 1 — lock-free persistence + admin serialisation. Remove the file
lock from repo/fs (the .lock file, the Read/Write-vs-*Safe duality and the
dead ReadSafe polling) and replace the two-step rename with a single atomic
rename so concurrent reads are torn-free without a lock. Serialise the
state-mutating admin writers (init/turn/banish) with one shared router
LimitMiddleware, rewritten to block on the request context instead of a
racy shared 100ms timer.

Stage 2 — remove the obsolete immediate-command path end to end. Players
submit through PUT /api/v1/order; the legacy PUT /api/v1/command path is
deleted across game (route, handler, 24 command factories, Ctrl), backend
(Commands handler/route, engineclient.ExecuteCommands), gateway (dispatch +
executeUserGamesCommand + routing entry), the FlatBuffers/model contract
(UserGamesCommand[Response]) and transcoder, plus every affected
OpenAPI/README/FUNCTIONAL/ARCHITECTURE doc. The integration proxy test is
converted to the order path.

Stage 3 — flatten the REST->engine wrapper. Replace the executor adapter,
the controller package functions and RepoController with one concrete
controller.Service; drop the single-implementation Repo and Storage
interfaces (repo.Repo / fs.FS are now concrete). Handlers depend on a thin
handler.Engine seam and own the domain->REST projection; storage is
resolved once at startup instead of per request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:37:07 +02:00

174 lines
4.1 KiB
Go

package router_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"galaxy/model/rest"
"galaxy/game/internal/router"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func TestLimitConnections(t *testing.T) {
r := limitTestingRouter()
wg := sync.WaitGroup{}
lock := sync.WaitGroup{}
lock.Add(1)
for range 1000 {
wg.Go(func() {
w := httptest.NewRecorder()
lock.Wait()
req, _ := http.NewRequest("GET", "/limited", nil)
r.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code, w.Body)
})
}
lock.Done()
wg.Wait()
}
// TestLimitSharedInstanceSerialisesRoutes pins the property the engine relies
// on to serialise state mutations: a single LimitMiddleware(1) instance shared
// across several routes admits at most one request across all of them at a
// time. The handler tracks the high-water concurrency and asserts it never
// exceeds one.
func TestLimitSharedInstanceSerialisesRoutes(t *testing.T) {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
shared := router.LimitMiddleware(1)
var inFlight, maxSeen atomic.Int32
handler := func(c *gin.Context) {
n := inFlight.Add(1)
for {
cur := maxSeen.Load()
if n <= cur || maxSeen.CompareAndSwap(cur, n) {
break
}
}
time.Sleep(time.Millisecond) // widen the overlap window
inFlight.Add(-1)
c.Status(http.StatusOK)
}
r.GET("/a", shared, handler)
r.PUT("/b", shared, handler)
wg := sync.WaitGroup{}
for i := range 200 {
method, path := http.MethodGet, "/a"
if i%2 == 1 {
method, path = http.MethodPut, "/b"
}
wg.Go(func() {
w := httptest.NewRecorder()
req, _ := http.NewRequest(method, path, nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, w.Body)
})
}
wg.Wait()
assert.Equal(t, int32(1), maxSeen.Load(), "a shared limiter must serialise across every route it guards")
}
// TestLimitReleasesOnContextCancel verifies the wait path: while one request
// holds the only slot, a second request blocked on the limiter answers 503
// once its request context is cancelled, instead of hanging.
func TestLimitReleasesOnContextCancel(t *testing.T) {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
shared := router.LimitMiddleware(1)
entered := make(chan struct{})
release := make(chan struct{})
r.GET("/hold", shared, func(c *gin.Context) {
close(entered)
<-release
c.Status(http.StatusOK)
})
// First request grabs and holds the only slot.
go func() {
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/hold", nil)
r.ServeHTTP(w, req)
}()
<-entered
// Second request blocks on the limiter, then loses its context.
ctx, cancel := context.WithCancel(context.Background())
w := httptest.NewRecorder()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "/hold", nil)
done := make(chan struct{})
go func() {
r.ServeHTTP(w, req)
close(done)
}()
cancel()
<-done
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
close(release)
}
func asBody(body any) *strings.Reader {
commandJson, _ := json.Marshal(body)
return strings.NewReader(string(commandJson))
}
func limitTestingRouter() *gin.Engine {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
counter := atomic.Int32{}
r.GET("/limited",
// limiting all ingoing connections
router.LimitMiddleware(1),
// storing counter value and testing increment after executing Next handlers
func(c *gin.Context) {
expected := counter.Load() + 1
c.Next()
current := counter.Load()
if current != expected {
c.String(http.StatusConflict, "expected: %d, got: %d", expected, current)
}
},
// increment counter
func(c *gin.Context) {
counter.Add(1)
c.Status(http.StatusOK)
})
return r
}
func generateInitRequest(races int) rest.InitRequest {
request := rest.InitRequest{
GameID: uuid.New(),
Races: make([]rest.InitRace, races),
}
for i := range request.Races {
request.Races[i] = rest.InitRace{RaceName: raceName(i)}
}
return request
}
func raceName(i int) string {
return fmt.Sprintf("Race_%02d", i)
}