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) }