pulse-lets-go/internal/api/route_test.go
Maksim Totmin b0697da8e4 feat: weighted random routing + mode switching + EWMA smoothing + stale eviction
- Weighted random routing (math/rand/v2 per-goroutine ChaCha8)
- PUT /api/route/mode for runtime mode switch (best / weighted_random)
- EWMA score smoothing (configurable smoothing_factor, default 0.3)
- Periodic stale node eviction (5 min threshold, 60s interval)
- Non-blocking WS broadcast (per-client buffered channel + writePump)
- Background rate limiter cleanup goroutine
- In-memory trunk gateway cache (zero disk I/O in hot path)
- Configurable load_avg multiplier (default 50.0, backward compat)
- UI mode indicator + admin Switch button in dashboard
- 42 tests: weighted random, mode switching, EWMA, eviction, API integration
2026-06-25 20:42:46 +07:00

155 lines
5.1 KiB
Go

package api
import (
"bytes"
"encoding/json"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/pulse-lets-go/internal/config"
"github.com/pulse-lets-go/internal/engine"
)
func testCfg() *config.Config {
return &config.Config{
StaleThresholdSec: 20,
Log: config.LogConfig{Level: "info", Format: "text"},
RateLimit: config.RateLimitConfig{Enabled: false},
BalanceMode: "weighted_random",
Scoring: config.ScoringConfig{
IdleCPUMin: 5.0,
CallFailureRateLethal: 15.0,
Weights: config.ScoringWeights{CallScore: 0.40, LoadScore: 0.30, IdleScore: 0.20, FailScore: 0.10},
},
}
}
func createTestJWT(secret, username, role string) string {
claims := jwt.MapClaims{
"user_id": "user-test",
"username": username,
"role": role,
"exp": time.Now().Add(1 * time.Hour).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
s, _ := token.SignedString([]byte(secret))
return s
}
func TestRouteModeEndpoint_NoAuth(t *testing.T) {
cfg := testCfg()
eng := engine.NewEngine(cfg)
a := NewAPI(eng, nil, "test-secret", "test-api-key", func() bool { return true }, cfg)
handler := a.Handler()
body, _ := json.Marshal(map[string]string{"mode": "best"})
req := httptest.NewRequest("PUT", "/api/route/mode", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code == 404 {
t.Fatalf("PUT /api/route/mode вернул 404 — маршрут не найден! body=%s", rr.Body.String())
}
if rr.Code == 401 {
t.Log("OK: без токена получаем 401 (роутинг работает)")
} else {
t.Logf("Код ответа: %d, body: %s", rr.Code, rr.Body.String())
}
}
func TestRouteModeEndpoint_ViewerForbidden(t *testing.T) {
cfg := testCfg()
eng := engine.NewEngine(cfg)
a := NewAPI(eng, nil, "test-secret", "test-api-key", func() bool { return true }, cfg)
handler := a.Handler()
token := createTestJWT("test-secret", "pbx-viewer", "viewer")
body, _ := json.Marshal(map[string]string{"mode": "weighted_random"})
req := httptest.NewRequest("PUT", "/api/route/mode", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code == 404 {
t.Fatalf("PUT /api/route/mode вернул 404 (viewer) — маршрут не найден!")
}
if rr.Code != 403 {
t.Errorf("viewer должен получить 403, получен %d", rr.Code)
}
t.Logf("viewer: код=%d, body=%s", rr.Code, rr.Body.String())
}
func TestRouteModeEndpoint_AdminSuccess(t *testing.T) {
cfg := testCfg()
eng := engine.NewEngine(cfg)
a := NewAPI(eng, nil, "test-secret", "test-api-key", func() bool { return true }, cfg)
handler := a.Handler()
token := createTestJWT("test-secret", "admin", "admin")
body, _ := json.Marshal(map[string]string{"mode": "best"})
req := httptest.NewRequest("PUT", "/api/route/mode", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code == 404 {
t.Fatalf("PUT /api/route/mode вернул 404 (admin) — маршрут не найден!")
}
if rr.Code != 200 {
t.Fatalf("admin должен получить 200, получен %d body=%s", rr.Code, rr.Body.String())
}
var resp map[string]string
json.Unmarshal(rr.Body.Bytes(), &resp)
if resp["mode"] != "best" {
t.Errorf("ожидался mode=best, получен %s", resp["mode"])
}
if eng.GetBalancingMode() != "best" {
t.Errorf("engine mode должен быть 'best', получен %s", eng.GetBalancingMode())
}
t.Logf("admin: код=%d, body=%s, engine_mode=%s", rr.Code, rr.Body.String(), eng.GetBalancingMode())
}
func TestRouteModeEndpoint_InvalidMode(t *testing.T) {
cfg := testCfg()
eng := engine.NewEngine(cfg)
a := NewAPI(eng, nil, "test-secret", "test-api-key", func() bool { return true }, cfg)
handler := a.Handler()
token := createTestJWT("test-secret", "admin", "admin")
body, _ := json.Marshal(map[string]string{"mode": "invalid"})
req := httptest.NewRequest("PUT", "/api/route/mode", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != 400 {
t.Errorf("невалидный mode должен дать 400, получен %d", rr.Code)
}
}
func TestRouteEndpoint_UsesPickNode(t *testing.T) {
cfg := testCfg()
eng := engine.NewEngine(cfg)
a := NewAPI(eng, nil, "test-secret", "test-api-key", func() bool { return true }, cfg)
a.balanceGateways["test-node"] = "sip:test:5060"
a.fallbackGateway = "sip:fallback:5060"
handler := a.Handler()
req := httptest.NewRequest("GET", "/api/route", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != 503 {
t.Errorf("GET /api/route без нод должен дать 503, получен %d", rr.Code)
}
}