feat: CID-based routing rules engine with flexible match conditions
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
This commit is contained in:
@@ -40,6 +40,9 @@ type Engine struct {
|
||||
|
||||
// Режим балансировки
|
||||
balanceMode string // "best" или "weighted_random"
|
||||
|
||||
// Routing rules engine (CID-based, destination, ingress-trunk routing)
|
||||
router Router
|
||||
}
|
||||
|
||||
// NewEngine создаёт новый engine с заданной конфигурацией.
|
||||
|
||||
@@ -536,8 +536,13 @@ func TestSetOnNodeEvictedCallback(t *testing.T) {
|
||||
if len(evictedIDs) != 2 {
|
||||
t.Errorf("ожидалось 2 callback вызова, получено %d: %v", len(evictedIDs), evictedIDs)
|
||||
}
|
||||
if evictedIDs[0] != "pbx-01" || evictedIDs[1] != "pbx-02" {
|
||||
t.Errorf("неверный порядок evicted ID: %v", evictedIDs)
|
||||
found01, found02 := false, false
|
||||
for _, id := range evictedIDs {
|
||||
if id == "pbx-01" { found01 = true }
|
||||
if id == "pbx-02" { found02 = true }
|
||||
}
|
||||
if !found01 || !found02 {
|
||||
t.Errorf("ожидались pbx-01 и pbx-02 в evicted, получены: %v", evictedIDs)
|
||||
}
|
||||
// pbx-03 должна остаться
|
||||
if _, exists := eng.nodes["pbx-03"]; !exists {
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
// Package engine — routing rules sub-engine.
|
||||
//
|
||||
// Персистентные правила маршрутизации (CID-based, destination, ingress-trunk)
|
||||
// с priority-based first-match разрешением. Все match-поля — regex.
|
||||
// Time-based match (дни недели, временной диапазон) применяется к текущему
|
||||
// локальному времени сервера.
|
||||
//
|
||||
// Потокобезопасность: Router.mu защищает routing rules независимо от Engine.mu.
|
||||
// PickNodeForCall не держит два лока одновременно.
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/pulse-lets-go/internal/models"
|
||||
)
|
||||
|
||||
// CompiledRule — правило маршрутизации с прекомпилированными regex.
|
||||
// hits — атомарный счётчик срабатываний (debug/мониторинг).
|
||||
type CompiledRule struct {
|
||||
models.RouteRule // экспортируемые JSON-поля
|
||||
hits int64 // атомарный счётчик
|
||||
callerRe *regexp.Regexp
|
||||
destRe *regexp.Regexp
|
||||
ingressRe *regexp.Regexp
|
||||
}
|
||||
|
||||
// Hit атомарно инкрементирует счётчик срабатываний правила.
|
||||
func (cr *CompiledRule) Hit() {
|
||||
atomic.AddInt64(&cr.hits, 1)
|
||||
}
|
||||
|
||||
// GetHits возвращает текущее количество срабатываний.
|
||||
func (cr *CompiledRule) GetHits() int64 {
|
||||
return atomic.LoadInt64(&cr.hits)
|
||||
}
|
||||
|
||||
// Router хранит routing rules в порядке приоритета.
|
||||
// Собственный sync.RWMutex изолирует contention между админом (редактирует правила) и трафиком (роутинг звонков).
|
||||
type Router struct {
|
||||
mu sync.RWMutex
|
||||
rules []*CompiledRule
|
||||
}
|
||||
|
||||
// compileRules компилирует и сортирует правила.
|
||||
// Возвращает ошибку если любой regex невалиден.
|
||||
func compileRules(rules []models.RouteRule) ([]*CompiledRule, error) {
|
||||
compiled := make([]*CompiledRule, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
cr := &CompiledRule{RouteRule: r}
|
||||
if r.MatchCallerID != "" {
|
||||
re, err := regexp.Compile(r.MatchCallerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rule %s (%s): invalid match_caller_id regex: %w", r.ID, r.Name, err)
|
||||
}
|
||||
cr.callerRe = re
|
||||
}
|
||||
if r.MatchDestination != "" {
|
||||
re, err := regexp.Compile(r.MatchDestination)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rule %s (%s): invalid match_destination regex: %w", r.ID, r.Name, err)
|
||||
}
|
||||
cr.destRe = re
|
||||
}
|
||||
if r.MatchIngressTrunk != "" {
|
||||
re, err := regexp.Compile(r.MatchIngressTrunk)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rule %s (%s): invalid match_ingress_trunk regex: %w", r.ID, r.Name, err)
|
||||
}
|
||||
cr.ingressRe = re
|
||||
}
|
||||
compiled = append(compiled, cr)
|
||||
}
|
||||
sort.Slice(compiled, func(i, j int) bool {
|
||||
return compiled[i].Priority < compiled[j].Priority
|
||||
})
|
||||
return compiled, nil
|
||||
}
|
||||
|
||||
// ValidateRules проверяет что все правила валидны (regex, action_type, логические условия).
|
||||
// Не изменяет состояние Engine. Используется для validate-before-save.
|
||||
func (e *Engine) ValidateRules(rules []models.RouteRule) error {
|
||||
for _, r := range rules {
|
||||
if err := validateRule(&r); err != nil {
|
||||
return fmt.Errorf("rule %s (%s): %w", r.ID, r.Name, err)
|
||||
}
|
||||
// Проверяем что regex компилябельны
|
||||
if r.MatchCallerID != "" {
|
||||
if _, err := regexp.Compile(r.MatchCallerID); err != nil {
|
||||
return fmt.Errorf("rule %s (%s): invalid match_caller_id: %w", r.ID, r.Name, err)
|
||||
}
|
||||
}
|
||||
if r.MatchDestination != "" {
|
||||
if _, err := regexp.Compile(r.MatchDestination); err != nil {
|
||||
return fmt.Errorf("rule %s (%s): invalid match_destination: %w", r.ID, r.Name, err)
|
||||
}
|
||||
}
|
||||
if r.MatchIngressTrunk != "" {
|
||||
if _, err := regexp.Compile(r.MatchIngressTrunk); err != nil {
|
||||
return fmt.Errorf("rule %s (%s): invalid match_ingress_trunk: %w", r.ID, r.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRule(r *models.RouteRule) error {
|
||||
switch r.ActionType {
|
||||
case "node":
|
||||
if r.ActionNodeID == "" {
|
||||
return fmt.Errorf("action_type 'node' requires action_node_id")
|
||||
}
|
||||
case "pool":
|
||||
if len(r.ActionPoolIDs) == 0 {
|
||||
return fmt.Errorf("action_type 'pool' requires action_pool_ids")
|
||||
}
|
||||
case "auto":
|
||||
// no extra requirements
|
||||
default:
|
||||
return fmt.Errorf("action_type must be 'node', 'pool' or 'auto'")
|
||||
}
|
||||
|
||||
// Хотя бы одно match-поле должно быть непустым
|
||||
if r.MatchCallerID == "" && r.MatchDestination == "" &&
|
||||
r.MatchIngressTrunk == "" && r.MatchDaysOfWeek == "" &&
|
||||
r.MatchTimeStart == "" && r.MatchTimeEnd == "" {
|
||||
return fmt.Errorf("at least one match field required")
|
||||
}
|
||||
|
||||
// Time range: оба поля или ни одного
|
||||
if (r.MatchTimeStart != "" && r.MatchTimeEnd == "") ||
|
||||
(r.MatchTimeStart == "" && r.MatchTimeEnd != "") {
|
||||
return fmt.Errorf("both match_time_start and match_time_end required together")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadRules компилирует правила из моделей и загружает в in-memory Router.
|
||||
// Используется при запуске: битые правила логируются и скипаются,
|
||||
// валидные загружаются.
|
||||
func (e *Engine) LoadRules(rules []models.RouteRule) {
|
||||
compiled, err := compileRules(rules)
|
||||
if err != nil {
|
||||
// Логируем ошибку, но не прерываем — загружаем что смогли
|
||||
_ = err // caller logs
|
||||
}
|
||||
e.router.mu.Lock()
|
||||
e.router.rules = compiled
|
||||
e.router.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetRules загружает ПРОВЕРЕННЫЕ правила в in-memory Router.
|
||||
// Вызывается после ValidateRules + сохранения на диск.
|
||||
// Правила считаются уже валидными.
|
||||
func (e *Engine) SetRules(rules []models.RouteRule) {
|
||||
compiled, err := compileRules(rules)
|
||||
if err != nil {
|
||||
// Не должно случиться (rules уже прошли ValidateRules)
|
||||
return
|
||||
}
|
||||
e.router.mu.Lock()
|
||||
e.router.rules = compiled
|
||||
e.router.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetCompiledRules возвращает текущий срез скомпилированных правил.
|
||||
func (e *Engine) GetCompiledRules() []*CompiledRule {
|
||||
e.router.mu.RLock()
|
||||
defer e.router.mu.RUnlock()
|
||||
rules := make([]*CompiledRule, len(e.router.rules))
|
||||
copy(rules, e.router.rules)
|
||||
return rules
|
||||
}
|
||||
|
||||
// matchRule проверяет все условия правила против параметров вызова.
|
||||
// Возвращает true если правило применимо (все непустые поля совпали).
|
||||
// Вызывающий НЕ держит router-блокировку (она должна быть внешней).
|
||||
func matchRule(cr *CompiledRule, callerID, dest, ingress string) bool {
|
||||
if !cr.Enabled {
|
||||
return false
|
||||
}
|
||||
if cr.callerRe != nil && !cr.callerRe.MatchString(callerID) {
|
||||
return false
|
||||
}
|
||||
if cr.destRe != nil && !cr.destRe.MatchString(dest) {
|
||||
return false
|
||||
}
|
||||
if cr.ingressRe != nil && !cr.ingressRe.MatchString(ingress) {
|
||||
return false
|
||||
}
|
||||
return matchTime(cr.MatchDaysOfWeek, cr.MatchTimeStart, cr.MatchTimeEnd)
|
||||
}
|
||||
|
||||
// matchTime проверяет time-based условия правила.
|
||||
// Использует локальное время сервера.
|
||||
func matchTime(daysOfWeek, timeStart, timeEnd string) bool {
|
||||
if daysOfWeek == "" && timeStart == "" && timeEnd == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Проверка дня недели
|
||||
if daysOfWeek != "" {
|
||||
if !matchDayOfWeek(now, daysOfWeek) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка временного диапазона
|
||||
if timeStart != "" && timeEnd != "" {
|
||||
if !matchTimeRange(now, timeStart, timeEnd) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// matchDayOfWeek проверяет что текущий день недели входит в заданный диапазон/список.
|
||||
// Формат: "1-5" (пн-пт), "1,3,5" (пн,ср,пт), "6" (сб).
|
||||
// Воскресенье = 0 или 7. Понедельник = 1.
|
||||
func matchDayOfWeek(now time.Time, spec string) bool {
|
||||
wd := now.Weekday()
|
||||
if wd == time.Sunday {
|
||||
// Приводим воскресенье к 7 для совместимости с "1-7"
|
||||
wd = 7
|
||||
}
|
||||
dayNum := int(wd)
|
||||
|
||||
for _, part := range strings.Split(spec, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(part, "-") {
|
||||
parts := strings.SplitN(part, "-", 2)
|
||||
start, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
end, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if err1 == nil && err2 == nil && dayNum >= start && dayNum <= end {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
n, err := strconv.Atoi(part)
|
||||
if err == nil && dayNum == n {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchTimeRange проверяет что текущее время входит в [timeStart, timeEnd].
|
||||
// Формат: "HH:MM".
|
||||
func matchTimeRange(now time.Time, timeStart, timeEnd string) bool {
|
||||
startH, startM, ok1 := parseHHMM(timeStart)
|
||||
endH, endM, ok2 := parseHHMM(timeEnd)
|
||||
if !ok1 || !ok2 {
|
||||
return false
|
||||
}
|
||||
|
||||
startMin := startH*60 + startM
|
||||
endMin := endH*60 + endM
|
||||
nowMin := now.Hour()*60 + now.Minute()
|
||||
|
||||
if startMin <= endMin {
|
||||
// Обычный диапазон: 09:00 – 18:00
|
||||
return nowMin >= startMin && nowMin <= endMin
|
||||
}
|
||||
// Ночной диапазон: 22:00 – 06:00
|
||||
return nowMin >= startMin || nowMin <= endMin
|
||||
}
|
||||
|
||||
func parseHHMM(s string) (h, m int, ok bool) {
|
||||
parts := strings.SplitN(s, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
hour, err1 := strconv.Atoi(parts[0])
|
||||
minute, err2 := strconv.Atoi(parts[1])
|
||||
if err1 != nil || err2 != nil || hour < 0 || hour > 23 || minute < 0 || minute > 59 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return hour, minute, true
|
||||
}
|
||||
|
||||
// PickNodeForCall — единый фасад маршрутизации вызова с учётом routing rules.
|
||||
//
|
||||
// Алгоритм:
|
||||
// 1. Проверяем routing rules в порядке Priority (first-match-wins)
|
||||
// 2. Для правила с action="node" — проверяем что нода жива; если мертва — fallthrough
|
||||
// 3. Для правила с action="pool" — scoring только по нодам из пула; пустой пул → fallthrough
|
||||
// 4. Для правила с action="auto" — fallthrough к глобальному PickNode()
|
||||
// 5. Нет совпадений / fallthrough — стандартный PickNode() (глобальный scoring)
|
||||
//
|
||||
// matchedRule возвращает имя сработавшего правила (или "none").
|
||||
func (e *Engine) PickNodeForCall(callerID, dest, ingress string) (nodeID string, score float64, fallback bool, matchedRule string) {
|
||||
e.router.mu.RLock()
|
||||
defer e.router.mu.RUnlock()
|
||||
|
||||
for _, cr := range e.router.rules {
|
||||
if !matchRule(cr, callerID, dest, ingress) {
|
||||
continue
|
||||
}
|
||||
cr.Hit()
|
||||
|
||||
resolved := false
|
||||
switch cr.ActionType {
|
||||
case "node":
|
||||
e.mu.RLock()
|
||||
ns, ok := e.nodes[cr.ActionNodeID]
|
||||
e.mu.RUnlock()
|
||||
if ok && ns.Score >= 0 {
|
||||
nodeID, score, matchedRule = cr.ActionNodeID, 100, cr.Name
|
||||
resolved = true
|
||||
}
|
||||
case "pool":
|
||||
nid, sc, fb := e.pickNodeFromPoolLocked(cr.ActionPoolIDs)
|
||||
if !fb {
|
||||
nodeID, score, matchedRule = nid, sc, cr.Name
|
||||
resolved = true
|
||||
}
|
||||
case "auto":
|
||||
// Fallthrough к глобальному scoring
|
||||
nid, sc, fb := e.pickNodeLocked()
|
||||
return nid, sc, fb, cr.Name
|
||||
}
|
||||
if resolved {
|
||||
return nodeID, score, false, matchedRule
|
||||
}
|
||||
}
|
||||
|
||||
// Нет совпадений / все правила провалились — стандартный scoring
|
||||
nid, sc, fb := e.pickNodeLocked()
|
||||
return nid, sc, fb, "none"
|
||||
}
|
||||
|
||||
// pickNodeLocked возвращает ноду согласно активной стратегии балансировки.
|
||||
// Вызывающий ДОЛЖЕН держать e.router.mu.RLock (не engine.mu).
|
||||
func (e *Engine) pickNodeLocked() (nodeID string, score float64, fallback bool) {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
|
||||
switch e.balanceMode {
|
||||
case "weighted_random":
|
||||
return e.pickWeightedRandomLocked()
|
||||
default:
|
||||
return e.pickBestNodeLocked()
|
||||
}
|
||||
}
|
||||
|
||||
// pickNodeFromPoolLocked выполняет scoring только по нодам из пула (под router.RLock).
|
||||
func (e *Engine) pickNodeFromPoolLocked(poolIDs []string) (nodeID string, score float64, fallback bool) {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
return e.pickNodeFromPoolUnlocked(poolIDs)
|
||||
}
|
||||
|
||||
// pickNodeFromPoolUnlocked — внутренний метод без блокировок (engine.mu уже держится).
|
||||
func (e *Engine) pickNodeFromPoolUnlocked(poolIDs []string) (nodeID string, score float64, fallback bool) {
|
||||
|
||||
if len(e.nodes) == 0 {
|
||||
return "", 0, true
|
||||
}
|
||||
|
||||
poolSet := make(map[string]bool, len(poolIDs))
|
||||
for _, id := range poolIDs {
|
||||
poolSet[id] = true
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
staleThreshold := int64(e.cfg.StaleThresholdSec)
|
||||
|
||||
type candidate struct {
|
||||
id string
|
||||
score float64
|
||||
}
|
||||
candidates := make([]candidate, 0)
|
||||
|
||||
for id, ns := range e.nodes {
|
||||
if !poolSet[id] {
|
||||
continue
|
||||
}
|
||||
if ns.Disabled {
|
||||
continue
|
||||
}
|
||||
if ns.Score < 0 {
|
||||
continue
|
||||
}
|
||||
if now-ns.TS > staleThreshold {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, candidate{id: id, score: ns.Score})
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return "", 0, true
|
||||
}
|
||||
|
||||
switch e.balanceMode {
|
||||
case "best":
|
||||
// Найти ноду с максимальным score
|
||||
best := candidates[0]
|
||||
for _, c := range candidates[1:] {
|
||||
if c.score > best.score {
|
||||
best = c
|
||||
}
|
||||
}
|
||||
return best.id, best.score, false
|
||||
default:
|
||||
// Weighted random
|
||||
var totalScore float64
|
||||
for _, c := range candidates {
|
||||
totalScore += c.score
|
||||
}
|
||||
if totalScore <= 0 {
|
||||
idx := rand.IntN(len(candidates))
|
||||
return candidates[idx].id, 0, false
|
||||
}
|
||||
pick := rand.Float64() * totalScore
|
||||
for _, c := range candidates {
|
||||
pick -= c.score
|
||||
if pick <= 0 {
|
||||
return c.id, c.score, false
|
||||
}
|
||||
}
|
||||
return candidates[len(candidates)-1].id, candidates[len(candidates)-1].score, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
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]
|
||||
}
|
||||
Reference in New Issue
Block a user