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