Maksim Totmin a7722af29e 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
2026-06-25 21:51:37 +07:00

591 lines
20 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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