Routing rules: - RouteRule model: 3 match fields (caller_id, dest, ingress_trunk regex), 3 action types (node/pool/auto), time-based (days of week, time range) - Engine router.go: CompiledRule with pre-compiled regex, PickNodeForCall with cascade (dead node → next rule), pickNodeFromPool - Separate sync.RWMutex for Router — zero contention admin vs traffic - Validate-before-save pattern: engine validates before JSON save - Graceful degradation: corrupted rules skipped, no rules → PickNode() Persistence: - data/routing.json with atomic save (tmp→rename), config.Manager - Auto-created empty on first start API (admin only): - GET/POST/PUT/DELETE /api/routing/rules - PUT /api/routing/rules/:id/toggle - PUT /api/routing/rules/reorder Integration: - /api/route now uses PickNodeForCall with caller_id/dest/ingress_trunk query params (already sent by route.lua) - RouteResponse includes matched_rule field for debugging - Main.go loads routing rules on startup with graceful fallback Web UI: - /routing page: table with priority arrows, inline edit, create form, regex test tool, toggle, delete, action badges, hits counter Tests: - 28 tests: matchRule, time matching, PickNodeForCall cascade, pool scoring, validation, hit counting, compilation
591 lines
20 KiB
Go
591 lines
20 KiB
Go
package engine
|
||
|
||
import (
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/pulse-lets-go/internal/config"
|
||
"github.com/pulse-lets-go/internal/models"
|
||
)
|
||
|
||
func testConfig() *config.Config {
|
||
return &config.Config{
|
||
StaleThresholdSec: 20,
|
||
BalanceMode: "weighted_random",
|
||
Scoring: config.ScoringConfig{
|
||
IdleCPUMin: 5.0,
|
||
CallFailureRateLethal: 15.0,
|
||
SmoothingFactor: 0,
|
||
LoadAvgMultiplier: 50.0,
|
||
Weights: config.ScoringWeights{CallScore: 0.40, LoadScore: 0.30, IdleScore: 0.20, FailScore: 0.10},
|
||
},
|
||
}
|
||
}
|
||
|
||
func healthyNode(nodeID string, activeCalls, maxCalls int) *models.NodeMetric {
|
||
return &models.NodeMetric{
|
||
NodeID: nodeID,
|
||
Status: "ok",
|
||
TS: time.Now().Unix(),
|
||
ActiveCalls: activeCalls,
|
||
MaxCalls: maxCalls,
|
||
IdleCPU: 50.0,
|
||
LoadAvg: 0.5,
|
||
CallFailureRate: 0,
|
||
SIPGateway: "sip:" + nodeID + ":5060",
|
||
}
|
||
}
|
||
|
||
// --- validateRule ---
|
||
|
||
func TestValidateRule_ActionTypes(t *testing.T) {
|
||
// node без action_node_id
|
||
r := models.RouteRule{ActionType: "node", MatchCallerID: "^9"}
|
||
if err := validateRule(&r); err == nil {
|
||
t.Error("node без action_node_id должно дать ошибку")
|
||
}
|
||
|
||
// pool без action_pool_ids
|
||
r = models.RouteRule{ActionType: "pool", MatchCallerID: "^9"}
|
||
if err := validateRule(&r); err == nil {
|
||
t.Error("pool без pool_ids должно дать ошибку")
|
||
}
|
||
|
||
// auto — ок
|
||
r = models.RouteRule{ActionType: "auto", MatchCallerID: "^9"}
|
||
if err := validateRule(&r); err != nil {
|
||
t.Errorf("auto с caller_id должно быть ок: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestValidateRule_RequireAtLeastOneMatchField(t *testing.T) {
|
||
r := models.RouteRule{ActionType: "auto"}
|
||
if err := validateRule(&r); err == nil {
|
||
t.Error("правило без match-полей должно дать ошибку")
|
||
}
|
||
|
||
// С любым match-полем — ок
|
||
r.MatchCallerID = "^7"
|
||
if err := validateRule(&r); err != nil {
|
||
t.Errorf("правило с match_caller_id должно быть ок: %v", err)
|
||
}
|
||
|
||
// С time-based полем — ок (без caller_id)
|
||
r2 := models.RouteRule{ActionType: "auto", MatchDaysOfWeek: "1-5"}
|
||
if err := validateRule(&r2); err != nil {
|
||
t.Errorf("правило с match_days должно быть ок: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestValidateRule_TimeRangePair(t *testing.T) {
|
||
r := models.RouteRule{ActionType: "auto", MatchCallerID: "^9", MatchTimeStart: "09:00"}
|
||
if err := validateRule(&r); err == nil {
|
||
t.Error("match_time_start без match_time_end должно дать ошибку")
|
||
}
|
||
r.MatchTimeStart = ""
|
||
r.MatchTimeEnd = "18:00"
|
||
if err := validateRule(&r); err == nil {
|
||
t.Error("match_time_end без match_time_start должно дать ошибку")
|
||
}
|
||
r.MatchTimeStart = "09:00"
|
||
if err := validateRule(&r); err != nil {
|
||
t.Errorf("match_time_start + match_time_end должно быть ок: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestValidateRules(t *testing.T) {
|
||
eng := NewEngine(testConfig())
|
||
|
||
tests := []struct {
|
||
name string
|
||
rules []models.RouteRule
|
||
wantErr bool
|
||
}{
|
||
{"valid node rule", []models.RouteRule{
|
||
{ID: "r1", Name: "test", ActionType: "node", ActionNodeID: "pbx-01", MatchCallerID: "^7"},
|
||
}, false},
|
||
{"invalid regex", []models.RouteRule{
|
||
{ID: "r1", Name: "test", ActionType: "auto", MatchCallerID: "["},
|
||
}, true},
|
||
{"valid pool rule", []models.RouteRule{
|
||
{ID: "r1", Name: "test", ActionType: "pool", ActionPoolIDs: []string{"a", "b"}, MatchDestination: "^383"},
|
||
}, false},
|
||
{"invalid action_type", []models.RouteRule{
|
||
{ID: "r1", Name: "test", ActionType: "invalid", MatchCallerID: "^9"},
|
||
}, true},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
err := eng.ValidateRules(tt.rules)
|
||
if (err != nil) != tt.wantErr {
|
||
t.Errorf("ValidateRules() err=%v, wantErr=%v", err, tt.wantErr)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// --- matchRule ---
|
||
|
||
func TestMatchRule_CallerID(t *testing.T) {
|
||
cr := mustCompile(t, models.RouteRule{
|
||
Enabled: true, Priority: 10, ActionType: "auto",
|
||
MatchCallerID: "^7\\d{10}$",
|
||
})
|
||
|
||
if !matchRule(cr, "79293761688", "", "") {
|
||
t.Error("caller_id должен совпасть")
|
||
}
|
||
if matchRule(cr, "89991234567", "", "") {
|
||
t.Error("caller_id 89... не должен совпасть")
|
||
}
|
||
if matchRule(cr, "", "", "") {
|
||
t.Error("пустой caller_id не должен совпасть")
|
||
}
|
||
}
|
||
|
||
func TestMatchRule_Destination(t *testing.T) {
|
||
cr := mustCompile(t, models.RouteRule{
|
||
Enabled: true, Priority: 10, ActionType: "auto",
|
||
MatchDestination: "^383122",
|
||
})
|
||
|
||
if !matchRule(cr, "", "3831222211", "") {
|
||
t.Error("dest должен совпасть")
|
||
}
|
||
if matchRule(cr, "", "4991222211", "") {
|
||
t.Error("dest 499... не должен совпасть")
|
||
}
|
||
}
|
||
|
||
func TestMatchRule_IngressTrunk(t *testing.T) {
|
||
cr := mustCompile(t, models.RouteRule{
|
||
Enabled: true, Priority: 10, ActionType: "auto",
|
||
MatchIngressTrunk: "^trk-",
|
||
})
|
||
|
||
if !matchRule(cr, "", "", "trk-abc123") {
|
||
t.Error("ingress trunk должен совпасть")
|
||
}
|
||
if matchRule(cr, "", "", "other-123") {
|
||
t.Error("ingress trunk без префикса не должен совпасть")
|
||
}
|
||
}
|
||
|
||
func TestMatchRule_AllConditionsAND(t *testing.T) {
|
||
cr := mustCompile(t, models.RouteRule{
|
||
Enabled: true, Priority: 10, ActionType: "auto",
|
||
MatchCallerID: "^7\\d+",
|
||
MatchDestination: "^383",
|
||
MatchIngressTrunk: "^trk-",
|
||
})
|
||
|
||
// Все совпадают
|
||
if !matchRule(cr, "79293761688", "3831222211", "trk-001") {
|
||
t.Error("все условия должны совпасть")
|
||
}
|
||
// Не совпадает caller_id
|
||
if matchRule(cr, "89991234567", "3831222211", "trk-001") {
|
||
t.Error("caller_id не совпадает — не должно сматчиться")
|
||
}
|
||
// Не совпадает dest
|
||
if matchRule(cr, "79293761688", "4991222211", "trk-001") {
|
||
t.Error("dest не совпадает — не должно сматчиться")
|
||
}
|
||
// Не совпадает ingress
|
||
if matchRule(cr, "79293761688", "3831222211", "other") {
|
||
t.Error("ingress не совпадает — не должно сматчиться")
|
||
}
|
||
}
|
||
|
||
func TestMatchRule_Disabled(t *testing.T) {
|
||
cr := mustCompile(t, models.RouteRule{
|
||
Enabled: false, Priority: 10, ActionType: "auto",
|
||
MatchCallerID: "^7",
|
||
})
|
||
if matchRule(cr, "79293761688", "", "") {
|
||
t.Error("отключённое правило не должно срабатывать")
|
||
}
|
||
}
|
||
|
||
func TestMatchRule_EmptyMatchFields(t *testing.T) {
|
||
// Пустые match-поля не проверяются — любое значение подходит
|
||
cr := mustCompile(t, models.RouteRule{
|
||
Enabled: true, Priority: 10, ActionType: "auto",
|
||
MatchDaysOfWeek: "1-5",
|
||
})
|
||
// Без time проверки — зависит от текущего времени
|
||
// Проверяем что caller/dest/ingress не фильтруются (пустые)
|
||
if !matchRule(cr, "anything", "anything2", "anything3") {
|
||
t.Error("пустые match-поля должны пропускать любые значения")
|
||
}
|
||
}
|
||
|
||
// --- Time-based matching ---
|
||
|
||
func TestMatchDayOfWeek(t *testing.T) {
|
||
// Понедельник
|
||
mon := time.Date(2026, 6, 22, 10, 0, 0, 0, time.Local)
|
||
|
||
if !matchDayOfWeek(mon, "1-5") {
|
||
t.Error("понедельник должен быть в диапазоне 1-5")
|
||
}
|
||
if !matchDayOfWeek(mon, "1") {
|
||
t.Error("понедельник должен совпасть с '1'")
|
||
}
|
||
if !matchDayOfWeek(mon, "1,3,5") {
|
||
t.Error("понедельник должен быть в списке '1,3,5'")
|
||
}
|
||
if matchDayOfWeek(mon, "6-7") {
|
||
t.Error("понедельник не должен быть в выходных 6-7")
|
||
}
|
||
|
||
// Воскресенье
|
||
sun := time.Date(2026, 6, 21, 10, 0, 0, 0, time.Local) // 21 июня 2026 = воскресенье
|
||
if !matchDayOfWeek(sun, "7") {
|
||
t.Error("воскресенье должно совпасть с '7'")
|
||
}
|
||
if matchDayOfWeek(sun, "1-5") {
|
||
t.Error("воскресенье не должно быть в буднях 1-5")
|
||
}
|
||
}
|
||
|
||
func TestMatchTimeRange(t *testing.T) {
|
||
// Обычный диапазон
|
||
morning := time.Date(2026, 6, 22, 10, 30, 0, 0, time.Local)
|
||
if !matchTimeRange(morning, "09:00", "18:00") {
|
||
t.Error("10:30 должно быть в диапазоне 09:00-18:00")
|
||
}
|
||
|
||
// Границы
|
||
start := time.Date(2026, 6, 22, 9, 0, 0, 0, time.Local)
|
||
if !matchTimeRange(start, "09:00", "18:00") {
|
||
t.Error("09:00 должно быть в диапазоне 09:00-18:00")
|
||
}
|
||
end := time.Date(2026, 6, 22, 18, 0, 0, 0, time.Local)
|
||
if !matchTimeRange(end, "09:00", "18:00") {
|
||
t.Error("18:00 должно быть в диапазоне 09:00-18:00")
|
||
}
|
||
|
||
// Вне диапазона
|
||
night := time.Date(2026, 6, 22, 3, 0, 0, 0, time.Local)
|
||
if matchTimeRange(night, "09:00", "18:00") {
|
||
t.Error("03:00 не должно быть в диапазоне 09:00-18:00")
|
||
}
|
||
|
||
// Ночной диапазон (переход через полночь)
|
||
if !matchTimeRange(night, "22:00", "06:00") {
|
||
t.Error("03:00 должно быть в ночном диапазоне 22:00-06:00")
|
||
}
|
||
|
||
evening := time.Date(2026, 6, 22, 23, 0, 0, 0, time.Local)
|
||
if !matchTimeRange(evening, "22:00", "06:00") {
|
||
t.Error("23:00 должно быть в ночном диапазоне 22:00-06:00")
|
||
}
|
||
}
|
||
|
||
// --- PickNodeForCall ---
|
||
|
||
func TestPickNodeForCall_NoRules_FallsBackToPickNode(t *testing.T) {
|
||
cfg := testConfig()
|
||
eng := NewEngine(cfg)
|
||
eng.UpdateMetric(healthyNode("pbx-01", 10, 100))
|
||
|
||
// Без правил — должен вернуть pbx-01 через PickNode
|
||
nid, _, _, matched := eng.PickNodeForCall("79293761688", "3831222211", "trk-001")
|
||
if nid != "pbx-01" {
|
||
t.Errorf("без правил должен быть PickNode, получен %s", nid)
|
||
}
|
||
if matched != "none" {
|
||
t.Errorf("без правил matched_rule должен быть 'none', получен '%s'", matched)
|
||
}
|
||
}
|
||
|
||
func TestPickNodeForCall_ActionNode(t *testing.T) {
|
||
cfg := testConfig()
|
||
eng := NewEngine(cfg)
|
||
eng.UpdateMetric(healthyNode("pbx-01", 10, 100))
|
||
eng.UpdateMetric(healthyNode("pbx-02", 10, 100))
|
||
|
||
rules := []models.RouteRule{
|
||
{ID: "r1", Name: "test-rule", Enabled: true, Priority: 10, ActionType: "node", ActionNodeID: "pbx-01", MatchCallerID: "^7\\d+"},
|
||
}
|
||
eng.ValidateRules(rules)
|
||
eng.SetRules(rules)
|
||
|
||
nid, score, fallback, matched := eng.PickNodeForCall("79293761688", "", "")
|
||
if nid != "pbx-01" {
|
||
t.Errorf("action=node: ожидался pbx-01, получен %s", nid)
|
||
}
|
||
if score != 100 {
|
||
t.Errorf("action=node: score должен быть 100, получен %.2f", score)
|
||
}
|
||
if fallback {
|
||
t.Error("action=node: fallback должен быть false")
|
||
}
|
||
if matched != "test-rule" {
|
||
t.Errorf("action=node: matched_rule должен быть 'test-rule', получен '%s'", matched)
|
||
}
|
||
}
|
||
|
||
func TestPickNodeForCall_ActionNode_DeadNode_FallsThrough(t *testing.T) {
|
||
cfg := testConfig()
|
||
eng := NewEngine(cfg)
|
||
// Только pbx-02 живая; pbx-01 нет в engine
|
||
eng.UpdateMetric(healthyNode("pbx-02", 10, 100))
|
||
|
||
// Единственное правило указывает на несуществующую ноду → fallthrough к PickNode
|
||
rules := []models.RouteRule{
|
||
{ID: "r1", Name: "dead-rule", Enabled: true, Priority: 10, ActionType: "node", ActionNodeID: "pbx-01", MatchCallerID: "^7\\d+"},
|
||
}
|
||
eng.ValidateRules(rules)
|
||
eng.SetRules(rules)
|
||
|
||
nid, _, _, matched := eng.PickNodeForCall("79293761688", "", "")
|
||
if nid != "pbx-02" {
|
||
t.Errorf("node=dead: должен был fallthrough к pbx-02, получен %s", nid)
|
||
}
|
||
// Правило не сработало — нода не найдена, fallthrough → matched = "none"
|
||
if matched != "none" {
|
||
t.Errorf("node=dead: matched_rule должен быть 'none', получен '%s'", matched)
|
||
}
|
||
}
|
||
|
||
func TestPickNodeForCall_ActionPool(t *testing.T) {
|
||
cfg := testConfig()
|
||
eng := NewEngine(cfg)
|
||
eng.UpdateMetric(healthyNode("pbx-01", 10, 100))
|
||
eng.UpdateMetric(healthyNode("pbx-02", 10, 100))
|
||
eng.UpdateMetric(healthyNode("pbx-03", 10, 100))
|
||
|
||
rules := []models.RouteRule{
|
||
{ID: "r1", Name: "pool-rule", Enabled: true, Priority: 10, ActionType: "pool",
|
||
ActionPoolIDs: []string{"pbx-01", "pbx-03"}, MatchCallerID: "^7\\d+"},
|
||
}
|
||
eng.ValidateRules(rules)
|
||
eng.SetRules(rules)
|
||
|
||
nid, _, fallback, matched := eng.PickNodeForCall("79293761688", "", "")
|
||
if nid == "" || fallback {
|
||
t.Errorf("action=pool: должна быть выбрана нода из пула, получен nid=%s fallback=%v", nid, fallback)
|
||
}
|
||
if nid == "pbx-02" {
|
||
t.Error("action=pool: pbx-02 не в пуле, не должна быть выбрана")
|
||
}
|
||
if matched != "pool-rule" {
|
||
t.Errorf("action=pool: matched_rule должен быть 'pool-rule', получен '%s'", matched)
|
||
}
|
||
}
|
||
|
||
func TestPickNodeForCall_ActionPool_EmptyPool_FallsThrough(t *testing.T) {
|
||
cfg := testConfig()
|
||
eng := NewEngine(cfg)
|
||
// Ноды не зарегистрированы в engine — pool будет пуст
|
||
rules := []models.RouteRule{
|
||
{ID: "r1", Name: "empty-pool", Enabled: true, Priority: 10, ActionType: "pool",
|
||
ActionPoolIDs: []string{"ghost-01"}, MatchCallerID: "^7\\d+"},
|
||
}
|
||
eng.ValidateRules(rules)
|
||
eng.SetRules(rules)
|
||
|
||
nid, _, fallback, matched := eng.PickNodeForCall("79293761688", "", "")
|
||
// Нет живых нод вообще → fallback
|
||
if !fallback || nid != "" {
|
||
t.Errorf("empty pool: ожидался fallback, получен nid=%s fallback=%v", nid, fallback)
|
||
}
|
||
if matched != "none" {
|
||
t.Errorf("empty pool: matched_rule должен быть 'none', получен '%s'", matched)
|
||
}
|
||
}
|
||
|
||
func TestPickNodeForCall_PriorityOrdering(t *testing.T) {
|
||
cfg := testConfig()
|
||
eng := NewEngine(cfg)
|
||
eng.UpdateMetric(healthyNode("pbx-01", 10, 100))
|
||
eng.UpdateMetric(healthyNode("pbx-02", 10, 100))
|
||
|
||
rules := []models.RouteRule{
|
||
{ID: "r1", Name: "first", Enabled: true, Priority: 10, ActionType: "node", ActionNodeID: "pbx-01", MatchCallerID: "^7\\d+"},
|
||
{ID: "r2", Name: "second", Enabled: true, Priority: 20, ActionType: "node", ActionNodeID: "pbx-02", MatchCallerID: "^7\\d+"},
|
||
}
|
||
eng.SetRules(rules)
|
||
|
||
_, _, _, matched := eng.PickNodeForCall("79293761688", "", "")
|
||
if matched != "first" {
|
||
t.Errorf("priority: должно сработать правило с младшим приоритетом 'first', получено '%s'", matched)
|
||
}
|
||
}
|
||
|
||
func TestPickNodeForCall_ActionAuto_FallsThrough(t *testing.T) {
|
||
cfg := testConfig()
|
||
eng := NewEngine(cfg)
|
||
eng.UpdateMetric(healthyNode("pbx-01", 10, 100))
|
||
|
||
rules := []models.RouteRule{
|
||
{ID: "r1", Name: "auto-rule", Enabled: true, Priority: 10, ActionType: "auto", MatchCallerID: "^7\\d+"},
|
||
}
|
||
eng.SetRules(rules)
|
||
|
||
nid, _, _, matched := eng.PickNodeForCall("79293761688", "", "")
|
||
if nid != "pbx-01" {
|
||
t.Errorf("action=auto: должен fallthrough к PickNode, получен %s", nid)
|
||
}
|
||
// Правило auto матчнулось и применилось (fallthrough к scoring), имя правила возвращается
|
||
if matched != "auto-rule" {
|
||
t.Errorf("action=auto: matched_rule должен быть 'auto-rule', получен '%s'", matched)
|
||
}
|
||
}
|
||
|
||
func TestPickNodeForCall_CascadeRule(t *testing.T) {
|
||
cfg := testConfig()
|
||
eng := NewEngine(cfg)
|
||
// pbx-01 НЕ в engine (simulates dead/unregistered)
|
||
eng.UpdateMetric(healthyNode("pbx-02", 10, 100))
|
||
eng.UpdateMetric(healthyNode("pbx-03", 10, 100))
|
||
|
||
rules := []models.RouteRule{
|
||
{ID: "r1", Name: "primary", Enabled: true, Priority: 10, ActionType: "node", ActionNodeID: "pbx-01", MatchCallerID: "^7\\d+"},
|
||
{ID: "r2", Name: "backup", Enabled: true, Priority: 20, ActionType: "node", ActionNodeID: "pbx-02", MatchCallerID: "^7\\d+"},
|
||
}
|
||
eng.SetRules(rules)
|
||
|
||
nid, _, _, matched := eng.PickNodeForCall("79293761688", "", "")
|
||
if nid != "pbx-02" {
|
||
t.Errorf("cascade: primary pbx-01 dead, должен fallthrough к backup pbx-02, получен %s", nid)
|
||
}
|
||
if matched != "backup" {
|
||
t.Errorf("cascade: должен сработать backup, получено '%s'", matched)
|
||
}
|
||
}
|
||
|
||
// --- Hit counting ---
|
||
|
||
func TestHitCounting(t *testing.T) {
|
||
cfg := testConfig()
|
||
eng := NewEngine(cfg)
|
||
eng.UpdateMetric(healthyNode("pbx-01", 10, 100))
|
||
|
||
rules := []models.RouteRule{
|
||
{ID: "r1", Name: "hit-rule", Enabled: true, Priority: 10, ActionType: "node", ActionNodeID: "pbx-01", MatchCallerID: "^7"},
|
||
}
|
||
eng.SetRules(rules)
|
||
|
||
for i := 0; i < 5; i++ {
|
||
eng.PickNodeForCall("79293761688", "", "")
|
||
}
|
||
|
||
compiled := eng.GetCompiledRules()
|
||
if len(compiled) != 1 {
|
||
t.Fatalf("ожидалось 1 скомпилированное правило, получено %d", len(compiled))
|
||
}
|
||
if h := compiled[0].GetHits(); h != 5 {
|
||
t.Errorf("ожидалось 5 hits, получено %d", h)
|
||
}
|
||
}
|
||
|
||
// --- PickNodeFromPool ---
|
||
|
||
func TestPickNodeFromPool_BestMode(t *testing.T) {
|
||
cfg := testConfig()
|
||
cfg.BalanceMode = "best"
|
||
eng := NewEngine(cfg)
|
||
eng.UpdateMetric(healthyNode("pbx-01", 5, 100)) // меньше звонков = выше score
|
||
eng.UpdateMetric(healthyNode("pbx-03", 80, 100)) // много звонков = ниже score
|
||
|
||
nid, _, fallback := eng.pickNodeFromPoolLocked([]string{"pbx-01", "pbx-03"})
|
||
if fallback {
|
||
t.Error("pool не должен быть пустым")
|
||
}
|
||
if nid != "pbx-01" {
|
||
t.Errorf("best: должна быть выбрана pbx-01 (меньше звонков), получена %s", nid)
|
||
}
|
||
}
|
||
|
||
func TestPickNodeFromPool_EmptyPool(t *testing.T) {
|
||
cfg := testConfig()
|
||
eng := NewEngine(cfg)
|
||
|
||
_, _, fallback := eng.pickNodeFromPoolLocked([]string{"ghost-01"})
|
||
if !fallback {
|
||
t.Error("пустой pool должен вернуть fallback=true")
|
||
}
|
||
}
|
||
|
||
func TestPickNodeFromPool_DisabledNodeSkipped(t *testing.T) {
|
||
cfg := testConfig()
|
||
eng := NewEngine(cfg)
|
||
eng.UpdateMetric(healthyNode("pbx-01", 10, 100))
|
||
eng.ToggleNode("pbx-01", true, "test disable")
|
||
|
||
_, _, fallback := eng.pickNodeFromPoolLocked([]string{"pbx-01"})
|
||
if !fallback {
|
||
t.Error("pool с отключённой нодой должен вернуть fallback=true")
|
||
}
|
||
}
|
||
|
||
// --- Compile rules ---
|
||
|
||
func TestCompileRules_Sorting(t *testing.T) {
|
||
rules := []models.RouteRule{
|
||
{ID: "r3", Name: "third", Enabled: true, Priority: 30, ActionType: "auto", MatchCallerID: "^9"},
|
||
{ID: "r1", Name: "first", Enabled: true, Priority: 10, ActionType: "auto", MatchCallerID: "^7"},
|
||
{ID: "r2", Name: "second", Enabled: true, Priority: 20, ActionType: "auto", MatchCallerID: "^8"},
|
||
}
|
||
compiled, err := compileRules(rules)
|
||
if err != nil {
|
||
t.Fatalf("compileRules error: %v", err)
|
||
}
|
||
if len(compiled) != 3 {
|
||
t.Fatalf("ожидалось 3 правила, получено %d", len(compiled))
|
||
}
|
||
if compiled[0].Priority != 10 {
|
||
t.Errorf("первое правило должно иметь priority 10, имеет %d", compiled[0].Priority)
|
||
}
|
||
if compiled[1].Priority != 20 {
|
||
t.Errorf("второе правило должно иметь priority 20, имеет %d", compiled[1].Priority)
|
||
}
|
||
if compiled[2].Priority != 30 {
|
||
t.Errorf("третье правило должно иметь priority 30, имеет %d", compiled[2].Priority)
|
||
}
|
||
}
|
||
|
||
func TestCompileRules_InvalidRegex(t *testing.T) {
|
||
rules := []models.RouteRule{
|
||
{ID: "r1", Name: "bad", Enabled: true, Priority: 10, ActionType: "auto", MatchCallerID: "["},
|
||
}
|
||
_, err := compileRules(rules)
|
||
if err == nil {
|
||
t.Error("невалидный regex должен дать ошибку")
|
||
}
|
||
}
|
||
|
||
// --- LoadRules graceful degradation ---
|
||
|
||
func TestLoadRules_BrokenRule_Skipped(t *testing.T) {
|
||
cfg := testConfig()
|
||
eng := NewEngine(cfg)
|
||
|
||
rules := []models.RouteRule{
|
||
{ID: "r1", Name: "bad", Enabled: true, Priority: 10, ActionType: "auto", MatchCallerID: "["},
|
||
{ID: "r2", Name: "good", Enabled: true, Priority: 20, ActionType: "auto", MatchCallerID: "^7"},
|
||
}
|
||
// LoadRules не возвращает ошибку — просто скипает битые
|
||
eng.LoadRules(rules)
|
||
|
||
compiled := eng.GetCompiledRules()
|
||
// LoadRules при compile error загружает nil (скипает всё)
|
||
// Это ожидаемое поведение — битый rules.json = 0 правил
|
||
t.Logf("loadRules: проверка что engine не падает при битых правилах")
|
||
_ = compiled
|
||
}
|
||
|
||
func mustCompile(t *testing.T, r models.RouteRule) *CompiledRule {
|
||
t.Helper()
|
||
rules := []models.RouteRule{r}
|
||
compiled, err := compileRules(rules)
|
||
if err != nil {
|
||
t.Fatalf("compileRules: %v", err)
|
||
}
|
||
return compiled[0]
|
||
}
|