- 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
155 lines
5.1 KiB
Go
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)
|
|
}
|
|
}
|