Backend stability and security improvements: * internal/util/ — common RandomHex helper, removed 3 duplicates * ESL: deduplicated readMessage (locked/unlocked), net.JoinHostPort for IPv6 * AMI: synchronous reconnect() in readEventsLoop, net.JoinHostPort for IPv6 * Auth: /api/auth/refresh accepts Authorization header only (no ?token=) * decodeJSON: http.MaxBytesReader(1<<20) body limit * Trunks: gatewayParams() uses configured ESL.GatewayPrefix * Config: jwt_secret_env env-var fallback * FSCollector: time.After → time.NewTimer with defer Stop * Monitoring: Prometheus counters (route_requests, nodes_total/healthy, uptime) * go fmt pass across all internal/ packages
341 lines
8.6 KiB
Go
341 lines
8.6 KiB
Go
package engine
|
||
|
||
import (
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/pulse-lets-go/internal/config"
|
||
"github.com/pulse-lets-go/internal/models"
|
||
)
|
||
|
||
func defaultCfg() *config.Config {
|
||
return &config.Config{
|
||
StaleThresholdSec: 20,
|
||
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 freshMetric(nodeID string) *models.NodeMetric {
|
||
return &models.NodeMetric{
|
||
NodeID: nodeID,
|
||
TS: time.Now().Unix(),
|
||
Status: "ok",
|
||
ActiveCalls: 42,
|
||
MaxCalls: 250,
|
||
IdleCPU: 35,
|
||
LoadAvg: 0.85,
|
||
CallFailureRate: 0.5,
|
||
}
|
||
}
|
||
|
||
func TestNewEngine(t *testing.T) {
|
||
eng := NewEngine(defaultCfg())
|
||
if eng == nil {
|
||
t.Fatal("ожидался engine, получен nil")
|
||
}
|
||
}
|
||
|
||
func TestUpdateMetric_CreatesNode(t *testing.T) {
|
||
eng := NewEngine(defaultCfg())
|
||
m := freshMetric("pbx-01")
|
||
ns := eng.UpdateMetric(m)
|
||
|
||
if ns.NodeID != "pbx-01" {
|
||
t.Errorf("ожидался node_id=pbx-01, получен %s", ns.NodeID)
|
||
}
|
||
if len(eng.nodes) != 1 {
|
||
t.Errorf("ожидалась 1 нода, получено %d", len(eng.nodes))
|
||
}
|
||
}
|
||
|
||
func TestUpdateMetric_PreservesDisabled(t *testing.T) {
|
||
eng := NewEngine(defaultCfg())
|
||
m := freshMetric("pbx-01")
|
||
eng.UpdateMetric(m)
|
||
|
||
// Отключаем ноду вручную
|
||
eng.ToggleNode("pbx-01", true, "test")
|
||
eng.UpdateMetric(m)
|
||
|
||
ns, _ := eng.GetNodeInfo("pbx-01")
|
||
if !ns.Disabled {
|
||
t.Error("ожидалось disabled=true")
|
||
}
|
||
if ns.DisabledReason != "test" {
|
||
t.Errorf("ожидалась причина 'test', получена '%s'", ns.DisabledReason)
|
||
}
|
||
|
||
// Включаем обратно
|
||
eng.ToggleNode("pbx-01", false, "")
|
||
eng.UpdateMetric(m)
|
||
|
||
ns, _ = eng.GetNodeInfo("pbx-01")
|
||
if ns.Disabled {
|
||
t.Error("ожидалось disabled=false")
|
||
}
|
||
}
|
||
|
||
func TestScore_LethalDisabled(t *testing.T) {
|
||
eng := NewEngine(defaultCfg())
|
||
m := freshMetric("pbx-01")
|
||
eng.UpdateMetric(m)
|
||
eng.ToggleNode("pbx-01", true, "maintenance")
|
||
|
||
ns, _ := eng.GetNodeInfo("pbx-01")
|
||
if ns.Score != -100 {
|
||
t.Errorf("disabled нода должна иметь score=-100, получен %.0f", ns.Score)
|
||
}
|
||
}
|
||
|
||
func TestScore_LethalStatusNotOk(t *testing.T) {
|
||
eng := NewEngine(defaultCfg())
|
||
m := freshMetric("pbx-01")
|
||
m.Status = "down"
|
||
eng.UpdateMetric(m)
|
||
|
||
ns, _ := eng.GetNodeInfo("pbx-01")
|
||
if ns.Score != -100 {
|
||
t.Errorf("status==down нода должна иметь score=-100, получен %.0f", ns.Score)
|
||
}
|
||
}
|
||
|
||
func TestScore_LethalStale(t *testing.T) {
|
||
eng := NewEngine(defaultCfg())
|
||
m := freshMetric("pbx-01")
|
||
// Устанавливаем ts на 30 секунд назад (stale_threshold=20)
|
||
m.TS = time.Now().Unix() - 30
|
||
eng.UpdateMetric(m)
|
||
|
||
ns, _ := eng.GetNodeInfo("pbx-01")
|
||
if ns.Score != -100 {
|
||
t.Errorf("stale нода должна иметь score=-100, получен %.0f", ns.Score)
|
||
}
|
||
}
|
||
|
||
func TestScore_LethalMaxCalls(t *testing.T) {
|
||
eng := NewEngine(defaultCfg())
|
||
m := freshMetric("pbx-01")
|
||
m.ActiveCalls = 250
|
||
m.MaxCalls = 250
|
||
eng.UpdateMetric(m)
|
||
|
||
ns, _ := eng.GetNodeInfo("pbx-01")
|
||
if ns.Score != -100 {
|
||
t.Errorf("нода с исчерпанной ёмкостью должна иметь score=-100, получен %.0f", ns.Score)
|
||
}
|
||
}
|
||
|
||
func TestScore_LethalIdleCPU(t *testing.T) {
|
||
eng := NewEngine(defaultCfg())
|
||
m := freshMetric("pbx-01")
|
||
m.IdleCPU = 2.0
|
||
eng.UpdateMetric(m)
|
||
|
||
ns, _ := eng.GetNodeInfo("pbx-01")
|
||
if ns.Score != -100 {
|
||
t.Errorf("нода с idle_cpu < 5 должна иметь score=-100, получен %.0f", ns.Score)
|
||
}
|
||
}
|
||
|
||
func TestScore_LethalFailureRate(t *testing.T) {
|
||
eng := NewEngine(defaultCfg())
|
||
m := freshMetric("pbx-01")
|
||
m.CallFailureRate = 20.0
|
||
eng.UpdateMetric(m)
|
||
|
||
ns, _ := eng.GetNodeInfo("pbx-01")
|
||
if ns.Score != -100 {
|
||
t.Errorf("нода с call_failure_rate > 15 должна иметь score=-100, получен %.0f", ns.Score)
|
||
}
|
||
}
|
||
|
||
func TestScore_Healthy(t *testing.T) {
|
||
eng := NewEngine(defaultCfg())
|
||
m := freshMetric("pbx-01")
|
||
ns := eng.UpdateMetric(m)
|
||
|
||
if ns.Score <= 0 {
|
||
t.Errorf("здоровая нода должна иметь положительный score, получен %.0f", ns.Score)
|
||
}
|
||
}
|
||
|
||
func TestScore_HealthyFormula(t *testing.T) {
|
||
eng := NewEngine(defaultCfg())
|
||
m := freshMetric("pbx-01")
|
||
m.ActiveCalls = 0
|
||
m.MaxCalls = 100
|
||
m.IdleCPU = 100
|
||
m.LoadAvg = 0
|
||
m.CallFailureRate = 0
|
||
|
||
ns := eng.UpdateMetric(m)
|
||
|
||
// Все компоненты = 100
|
||
// score = 100*0.4 + 100*0.3 + 100*0.2 + 100*0.1 = 100
|
||
if ns.Score != 100.0 {
|
||
t.Errorf("идеальная нода должна иметь score=100, получен %.1f", ns.Score)
|
||
}
|
||
}
|
||
|
||
func TestScore_WeightedFormula(t *testing.T) {
|
||
eng := NewEngine(defaultCfg())
|
||
m := freshMetric("pbx-01")
|
||
m.ActiveCalls = 50
|
||
m.MaxCalls = 100
|
||
m.IdleCPU = 50
|
||
m.LoadAvg = 1.0
|
||
m.CallFailureRate = 5.0
|
||
|
||
ns := eng.UpdateMetric(m)
|
||
|
||
// call_score = 100 - (50/100 * 100) = 50
|
||
// load_score = 100 - (1.0 * 50) = 50
|
||
// idle_score = 50
|
||
// fail_score = 100 - 5 = 95
|
||
// score = 50*0.4 + 50*0.3 + 50*0.2 + 95*0.1 = 20+15+10+9.5 = 54.5
|
||
expected := 54.5
|
||
|
||
if ns.Score < expected-0.1 || ns.Score > expected+0.1 {
|
||
t.Errorf("ожидался score=%.1f, получен %.1f", expected, ns.Score)
|
||
}
|
||
}
|
||
|
||
func TestGetBestNode_NoNodes(t *testing.T) {
|
||
eng := NewEngine(defaultCfg())
|
||
_, _, fallback := eng.GetBestNode()
|
||
if !fallback {
|
||
t.Error("без нод должен быть fallback")
|
||
}
|
||
}
|
||
|
||
func TestGetBestNode_OneNode(t *testing.T) {
|
||
eng := NewEngine(defaultCfg())
|
||
eng.UpdateMetric(freshMetric("pbx-01"))
|
||
|
||
id, score, fallback := eng.GetBestNode()
|
||
if fallback {
|
||
t.Error("не должно быть fallback с одной здоровой нодой")
|
||
}
|
||
if id != "pbx-01" {
|
||
t.Errorf("ожидалась pbx-01, получена %s", id)
|
||
}
|
||
if score <= 0 {
|
||
t.Errorf("ожидался положительный score, получен %.0f", score)
|
||
}
|
||
}
|
||
|
||
func TestGetBestNode_AllUnhealthy(t *testing.T) {
|
||
eng := NewEngine(defaultCfg())
|
||
m1 := freshMetric("pbx-01")
|
||
m1.Status = "down"
|
||
eng.UpdateMetric(m1)
|
||
|
||
m2 := freshMetric("pbx-02")
|
||
m2.ActiveCalls = 500
|
||
m2.MaxCalls = 500
|
||
eng.UpdateMetric(m2)
|
||
|
||
_, _, fallback := eng.GetBestNode()
|
||
if !fallback {
|
||
t.Error("все ноды unhealthy — должен быть fallback")
|
||
}
|
||
}
|
||
|
||
func TestGetBestNode_MultipleNodes(t *testing.T) {
|
||
eng := NewEngine(defaultCfg())
|
||
|
||
// pbx-01: 80% загружена (меньше score)
|
||
m1 := freshMetric("pbx-01")
|
||
m1.ActiveCalls = 200
|
||
m1.MaxCalls = 250
|
||
eng.UpdateMetric(m1)
|
||
|
||
// pbx-02: 20% загружена (выше score)
|
||
m2 := freshMetric("pbx-02")
|
||
m2.ActiveCalls = 20
|
||
m2.MaxCalls = 250
|
||
eng.UpdateMetric(m2)
|
||
|
||
id, _, _ := eng.GetBestNode()
|
||
if id != "pbx-02" {
|
||
t.Errorf("ожидалась pbx-02 (меньше загрузки), получена %s", id)
|
||
}
|
||
}
|
||
|
||
func TestGetNodeHistory(t *testing.T) {
|
||
eng := NewEngine(defaultCfg())
|
||
eng.UpdateMetric(freshMetric("pbx-01"))
|
||
|
||
// Повторяем обновление для накопления истории
|
||
for i := 0; i < 5; i++ {
|
||
m := freshMetric("pbx-01")
|
||
m.ActiveCalls = i * 10
|
||
eng.UpdateMetric(m)
|
||
}
|
||
|
||
history := eng.GetNodeHistory("pbx-01")
|
||
if len(history) < 3 {
|
||
t.Errorf("ожидалось минимум 3 снапшота, получено %d", len(history))
|
||
}
|
||
}
|
||
|
||
func TestGetNodeHistory_NoNode(t *testing.T) {
|
||
eng := NewEngine(defaultCfg())
|
||
history := eng.GetNodeHistory("nonexistent")
|
||
if history != nil {
|
||
t.Error("для несуществующей ноды ожидался nil")
|
||
}
|
||
}
|
||
|
||
func TestToggleNode_NotFound(t *testing.T) {
|
||
eng := NewEngine(defaultCfg())
|
||
err := eng.ToggleNode("nonexistent", true, "test")
|
||
if err == nil {
|
||
t.Error("ожидалась ошибка для несуществующей ноды")
|
||
}
|
||
}
|
||
|
||
func TestUpdateConfig(t *testing.T) {
|
||
eng := NewEngine(defaultCfg())
|
||
m := freshMetric("pbx-01")
|
||
eng.UpdateMetric(m)
|
||
|
||
// Меняем конфиг на более строгий
|
||
newCfg := defaultCfg()
|
||
newCfg.Scoring.CallFailureRateLethal = 0.1
|
||
newCfg.Scoring.IdleCPUMin = 50
|
||
eng.UpdateConfig(newCfg)
|
||
|
||
ns, _ := eng.GetNodeInfo("pbx-01")
|
||
if ns.Score != -100 {
|
||
t.Errorf("после ужесточения конфига нода должна стать unhealthy, получен score=%.0f", ns.Score)
|
||
}
|
||
}
|
||
|
||
func TestClamp(t *testing.T) {
|
||
tests := []struct {
|
||
v, min, max, expected float64
|
||
}{
|
||
{50, 0, 100, 50},
|
||
{-10, 0, 100, 0},
|
||
{150, 0, 100, 100},
|
||
{0, 0, 0, 0},
|
||
}
|
||
for _, tt := range tests {
|
||
got := clamp(tt.v, tt.min, tt.max)
|
||
if got != tt.expected {
|
||
t.Errorf("clamp(%.0f, %.0f, %.0f) = %.0f, ожидалось %.0f",
|
||
tt.v, tt.min, tt.max, got, tt.expected)
|
||
}
|
||
}
|
||
}
|