Initial commit: SES Monitor

Watchdog-демон для непрерывного мониторинга SES.
- Liveness-проверки API, лицензии, списка роботов
- Диалоговые сценарии из YAML с агрегацией в 6 фиксированных Zabbix-метрик
- Доставка: zabbix_sender (push) или UserParameter (poll)
- Hot-reload сценариев без перезапуска
This commit is contained in:
Maksim Totmin
2026-06-19 21:04:05 +07:00
commit 8dca7eb46b
25 changed files with 4267 additions and 0 deletions
View File
+694
View File
@@ -0,0 +1,694 @@
"""
Тесты для основных компонентов системы мониторинга SES.
Проверяют:
- Загрузку конфигурации из YAML
- Извлечение JSON-значений по пути
- Проверку условий ожидания (expect)
- Парсинг ответов SES API
- Выполнение шагов сценария (с мок-клиентом)
"""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
import yaml
from ses_monitor.config import (
ScenarioConfig,
ScenarioStep,
load_scenarios,
)
from ses_monitor.utils import (
check_all_expectations,
check_expectation,
get_json_value,
setup_logging,
timer,
)
from ses_monitor.session_client import AskResponse, HostnameResponse, LicenseResponse
# =============================================================================
# Тесты: config.py
# =============================================================================
class TestConfigLoading:
"""Тесты загрузки конфигурации."""
def test_load_scenarios(self):
"""Проверяет загрузку YAML-сценариев из каталога."""
with tempfile.TemporaryDirectory() as tmpdir:
# Создаём тестовый сценарий
scenario_yaml = {
"name": "Тестовый сценарий",
"description": "Проверка загрузки",
"enabled": True,
"interval": 120,
"steps": [
{
"send": "привет",
"expect": [
{"path": "error", "equals": 0},
{"path": "answer[0].messages[0]", "contains": "при"},
],
}
],
}
with open(Path(tmpdir) / "test_scenario.yaml", "w") as f:
yaml.dump(scenario_yaml, f)
scenarios = load_scenarios(tmpdir)
assert len(scenarios) == 1
s = scenarios[0]
assert s.name == "Тестовый сценарий"
assert s.interval == 120
assert len(s.steps) == 1
assert s.steps[0].send == "привет"
assert len(s.steps[0].expect) == 2
def test_load_scenarios_disabled(self):
"""Проверяет что отключённые сценарии не загружаются."""
with tempfile.TemporaryDirectory() as tmpdir:
scenario_yaml = {
"name": "Отключённый",
"enabled": False,
"steps": [],
}
with open(Path(tmpdir) / "disabled.yaml", "w") as f:
yaml.dump(scenario_yaml, f)
scenarios = load_scenarios(tmpdir)
assert len(scenarios) == 0
def test_load_scenarios_empty_dir(self):
"""Проверяет загрузку из пустого каталога."""
with tempfile.TemporaryDirectory() as tmpdir:
scenarios = load_scenarios(tmpdir)
assert scenarios == []
# =============================================================================
# Тесты: utils.py — get_json_value
# =============================================================================
class TestGetJsonValue:
"""Тесты извлечения значений из JSON по пути."""
def test_simple_key(self):
data = {"error": 0, "message": "ok"}
assert get_json_value(data, "error") == 0
assert get_json_value(data, "message") == "ok"
def test_nested_key(self):
data = {"answer": [{"messages": ["здравствуйте"]}]}
assert get_json_value(data, "answer[0].messages[0]") == "здравствуйте"
def test_missing_key_returns_none(self):
data = {"error": 0}
assert get_json_value(data, "nonexistent") is None
assert get_json_value(data, "answer[0].text") is None
def test_array_index_out_of_bounds(self):
data = {"items": [1, 2]}
assert get_json_value(data, "items[5]") is None
def test_deeply_nested(self):
data = {
"request": {
"data": [
{
"reply": {
"confidence": 0.95,
}
}
]
}
}
assert get_json_value(data, "request.data[0].reply.confidence") == 0.95
def test_non_dict_data(self):
assert get_json_value([1, 2, 3], "items[0]") is None
# =============================================================================
# Тесты: utils.py — check_expectation
# =============================================================================
class TestCheckExpectation:
"""Тесты проверки условий ожидания."""
def test_equals_int(self):
ok, msg = check_expectation({"error": 0}, "error", "equals", 0)
assert ok
assert msg == ""
def test_equals_fail(self):
ok, msg = check_expectation({"error": 1}, "error", "equals", 0)
assert not ok
assert "не равно" in msg
def test_contains_str(self):
data = {"answer": [{"messages": ["введите фамилию"]}]}
ok, msg = check_expectation(
data, "answer[0].messages[0]", "contains", "фамилию"
)
assert ok
def test_contains_case_insensitive(self):
data = {"answer": [{"messages": ["Введите ФАМИЛИЮ"]}]}
ok, msg = check_expectation(
data, "answer[0].messages[0]", "contains", "фамилию"
)
assert ok
def test_not_contains(self):
data = {"answer": [{"messages": ["всё хорошо"]}]}
ok, msg = check_expectation(
data, "answer[0].messages[0]", "not_contains", "ошибка"
)
assert ok
def test_not_contains_fail(self):
data = {"answer": [{"messages": ["произошла ошибка"]}]}
ok, msg = check_expectation(
data, "answer[0].messages[0]", "not_contains", "ошибка"
)
assert not ok
def test_not_empty(self):
ok, msg = check_expectation(
{"text": "привет"}, "text", "not_empty", True
)
assert ok
def test_not_empty_fail(self):
ok, msg = check_expectation({}, "text", "not_empty", True)
assert not ok
def test_not_empty_empty_string(self):
ok, msg = check_expectation({"text": ""}, "text", "not_empty", True)
assert not ok
def test_regex_match(self):
data = {"phone": "+7-913-123-45-67"}
ok, msg = check_expectation(data, "phone", "regex", r"\+7[\d\-]+")
assert ok
def test_regex_no_match(self):
data = {"phone": "abc"}
ok, msg = check_expectation(data, "phone", "regex", r"^\+\d+$")
assert not ok
def test_exists(self):
ok, msg = check_expectation({"name": "test"}, "name", "exists")
assert ok
def test_exists_fail(self):
ok, msg = check_expectation({}, "name", "exists")
assert not ok
def test_missing_path(self):
ok, msg = check_expectation({}, "nonexistent[0].field", "equals", 0)
assert not ok
assert "не найдено" in msg or "не найд" in msg
# =============================================================================
# Тесты: utils.py — check_all_expectations
# =============================================================================
class TestCheckAllExpectations:
"""Тесты групповой проверки ожиданий."""
def test_all_pass(self):
data = {"error": 0, "answer": [{"messages": ["здравствуйте"]}]}
expectations = [
{"path": "error", "equals": 0},
{"path": "answer[0].messages[0]", "contains": "здрав"},
]
passed, errors = check_all_expectations(data, expectations)
assert passed
assert errors == []
def test_one_fails(self):
data = {"error": 1}
expectations = [
{"path": "error", "equals": 0},
{"path": "answer", "not_empty": True},
]
passed, errors = check_all_expectations(data, expectations)
assert not passed
assert len(errors) >= 1 # Первое или оба могут упасть
def test_no_expectations(self):
passed, errors = check_all_expectations({}, [])
assert passed
assert errors == []
def test_missing_path_in_expectation(self):
passed, errors = check_all_expectations({}, [{"contains": "test"}])
assert not passed
assert any("path" in e for e in errors)
# =============================================================================
# Тесты: utils.py — timer
# =============================================================================
class TestTimer:
"""Тесты контекстного менеджера timer."""
def test_measures_time(self):
import time
with timer() as t:
time.sleep(0.05)
assert "start" in t
assert "elapsed" in t
assert t["elapsed"] >= 0.04 # Допускаем небольшую погрешность
# =============================================================================
# Тесты: session_client.py — парсинг ответов
# =============================================================================
class TestAskResponse:
"""Тесты парсинга ответа POST /ses/ask."""
def test_from_valid_json(self):
data = {
"error": 0,
"question": "привет",
"answer": [{"messages": ["здравствуйте"], "voice": "Коля"}],
"session": "monitoring_test_123",
"uuid": "abc-def-123",
}
resp = AskResponse.from_json(data)
assert resp.error == 0
assert resp.question == "привет"
assert len(resp.answer) == 1
assert resp.session == "monitoring_test_123"
assert resp.uuid == "abc-def-123"
assert resp.raw == data
def test_from_error_json(self):
data = {"error": 1, "message": "robot not found"}
resp = AskResponse.from_json(data)
assert resp.error == 1
assert resp.question == ""
assert resp.answer == []
class TestHostnameResponse:
"""Тесты парсинга ответа GET /ses/hostname."""
def test_from_valid_json(self):
data = {
"error": 0,
"message": "Success",
"hostname": "ses-host.example.com",
"version": "5.046 release",
}
resp = HostnameResponse.from_json(data)
assert resp.error == 0
assert resp.hostname == "ses-host.example.com"
assert "5.046" in resp.version
class TestLicenseResponse:
"""Тесты парсинга ответа GET /license/check."""
def test_from_valid_json(self):
data = {
"error": 0,
"uuid": "398e8f18a83a6535d2bade81e9ee52a3",
"remaining_licenses": "infinity",
"threshold": 0,
}
resp = LicenseResponse.from_json(data)
assert resp.error == 0
assert resp.remaining_licenses == "infinity"
# =============================================================================
# Тесты: scenario_runner.py — с мок-клиентом
# =============================================================================
class TestScenarioRunner:
"""Тесты выполнения сценариев с мок-клиентом."""
@pytest.mark.asyncio
async def test_run_simple_scenario(self):
"""Проверяет выполнение простого сценария с успешным ответом."""
from ses_monitor.config import SESConfig
from ses_monitor.scenario_runner import ScenarioRunner
scenario = ScenarioConfig(
name="test",
interval=10,
steps=[
ScenarioStep(
send="привет",
expect=[{"path": "error", "equals": 0}],
),
],
)
runner = ScenarioRunner(SESConfig(), [scenario])
# Мок HTTP-клиента
mock_client = AsyncMock()
mock_client.ask.return_value = AskResponse.from_json({
"error": 0,
"question": "привет",
"answer": [{"messages": ["здравствуйте"], "voice": "Коля"}],
"session": "monitoring_test_1",
"uuid": "test-uuid",
})
results = await runner.run_all(mock_client)
assert len(results) == 1
assert results[0].passed
assert len(results[0].step_results) == 1
assert results[0].step_results[0].passed
@pytest.mark.asyncio
async def test_run_scenario_api_error(self):
"""Проверяет что сценарий фейлится при error != 0."""
from ses_monitor.config import SESConfig
from ses_monitor.scenario_runner import ScenarioRunner
scenario = ScenarioConfig(
name="test",
interval=10,
steps=[
ScenarioStep(
send="привет",
expect=[{"path": "error", "equals": 0}],
),
],
)
runner = ScenarioRunner(SESConfig(), [scenario])
mock_client = AsyncMock()
mock_client.ask.return_value = AskResponse.from_json({
"error": 1,
"question": "привет",
"answer": [],
"session": "",
"uuid": "",
})
results = await runner.run_all(mock_client)
assert len(results) == 1
assert not results[0].passed
@pytest.mark.asyncio
async def test_run_scenario_expectation_fail(self):
"""Проверяет что сценарий фейлится при несовпадении ожиданий."""
from ses_monitor.config import SESConfig
from ses_monitor.scenario_runner import ScenarioRunner
scenario = ScenarioConfig(
name="test",
interval=10,
steps=[
ScenarioStep(
send="привет",
expect=[{"path": "answer[0].messages[0]", "contains": "ОЖИДАЕМЫЙ_ТЕКСТ"}],
),
],
)
runner = ScenarioRunner(SESConfig(), [scenario])
mock_client = AsyncMock()
mock_client.ask.return_value = AskResponse.from_json({
"error": 0,
"question": "привет",
"answer": [{"messages": ["другой текст"], "voice": "Коля"}],
"session": "test_1",
"uuid": "test-uuid",
})
results = await runner.run_all(mock_client)
assert len(results) == 1
assert not results[0].passed
# =============================================================================
# Тесты: aggregate_scenario_results
# =============================================================================
class TestAggregateScenarioResults:
"""Тесты агрегации результатов сценариев."""
def _make_passed(self, name: str = "test", time_ms: float = 100.0):
"""Создаёт успешный результат."""
from ses_monitor.scenario_runner import ScenarioResult
return ScenarioResult(
scenario_name=name,
passed=True,
total_time_ms=time_ms,
)
def _make_failed(self, name: str = "test", time_ms: float = 100.0):
"""Создаёт упавший результат."""
from ses_monitor.scenario_runner import ScenarioResult
return ScenarioResult(
scenario_name=name,
passed=False,
total_time_ms=time_ms,
error_message="тестовая ошибка",
)
def test_empty_list_returns_zeroes(self):
"""Пустой список — ok=1, всё нули."""
from ses_monitor.scenario_runner import aggregate_scenario_results
agg = aggregate_scenario_results([])
assert agg["ok"] == 1
assert agg["total"] == 0
assert agg["failed"] == 0
assert agg["avg_time"] == 0
assert agg["max_time"] == 0
assert agg["error"] == ""
def test_all_pass(self):
"""Все сценарии прошли — ok=1, failed=0, error пуст."""
from ses_monitor.scenario_runner import aggregate_scenario_results
results = [
self._make_passed("a", 100.0),
self._make_passed("b", 200.0),
self._make_passed("c", 300.0),
]
agg = aggregate_scenario_results(results)
assert agg["ok"] == 1
assert agg["total"] == 3
assert agg["failed"] == 0
assert agg["error"] == ""
assert agg["avg_time"] == 200 # (100+200+300)/3
assert agg["max_time"] == 300
def test_some_fail(self):
"""Часть сценариев упала — ok=0, failed=N, error содержит счётчик."""
from ses_monitor.scenario_runner import aggregate_scenario_results
results = [
self._make_passed("a", 100.0),
self._make_failed("b", 200.0),
self._make_failed("c", 300.0),
]
agg = aggregate_scenario_results(results)
assert agg["ok"] == 0
assert agg["total"] == 3
assert agg["failed"] == 2
assert agg["error"] == "2 сценариев упали"
assert agg["avg_time"] == 200
assert agg["max_time"] == 300
def test_all_fail(self):
"""Все сценарии упали — ok=0, failed=total."""
from ses_monitor.scenario_runner import aggregate_scenario_results
results = [
self._make_failed("a", 50.0),
self._make_failed("b", 150.0),
]
agg = aggregate_scenario_results(results)
assert agg["ok"] == 0
assert agg["total"] == 2
assert agg["failed"] == 2
assert agg["error"] == "2 сценариев упали"
def test_avg_time_calculation(self):
"""Среднее время считается корректно (включая упавшие)."""
from ses_monitor.scenario_runner import aggregate_scenario_results
results = [
self._make_passed("fast", 50.0),
self._make_failed("slow", 450.0),
]
agg = aggregate_scenario_results(results)
assert agg["avg_time"] == 250 # (50+450)/2
assert agg["max_time"] == 450
def test_max_time_with_single_result(self):
"""Один сценарий — avg_time == max_time."""
from ses_monitor.scenario_runner import aggregate_scenario_results
results = [self._make_passed("x", 123.0)]
agg = aggregate_scenario_results(results)
assert agg["ok"] == 1
assert agg["total"] == 1
assert agg["failed"] == 0
assert agg["avg_time"] == 123
assert agg["max_time"] == 123
def test_rounding_behaviour(self):
"""Время округляется до целых миллисекунд."""
from ses_monitor.scenario_runner import aggregate_scenario_results
results = [
self._make_passed("a", 10.6),
self._make_passed("b", 10.3),
]
agg = aggregate_scenario_results(results)
assert agg["avg_time"] == 10 # round(20.9/2) = round(10.45) = 10
assert agg["max_time"] == 11 # round(10.6) = 11
@pytest.mark.asyncio
class TestUserParameterWriter:
"""Тесты для UserParameterWriter."""
async def test_write_json_file(self, tmp_path):
"""Проверяет что JSON-файл пишется атомарно и читается."""
from ses_monitor.zabbix import UserParameterWriter
from ses_monitor.config import ZabbixConfig, ZabbixMode
metrics_file = str(tmp_path / "metrics.json")
config = ZabbixConfig(
enabled=True,
mode=ZabbixMode.USERPARAMETER,
metrics_file=metrics_file,
)
writer = UserParameterWriter(config)
writer.add("ses.api.liveness", 1)
writer.add("ses.api.response_time", 42)
writer.add("ses.license.ok", True)
ok = await writer.flush()
assert ok, "flush должен вернуть True"
import json
with open(metrics_file, "r") as f:
data = json.load(f)
assert data["ses.api.liveness"] == "1"
assert data["ses.api.response_time"] == "42"
assert data["ses.license.ok"] == "1"
async def test_disabled_no_write(self, tmp_path):
"""Проверяет что при отключённом Zabbix файл не пишется."""
from ses_monitor.zabbix import UserParameterWriter
from ses_monitor.config import ZabbixConfig, ZabbixMode
metrics_file = str(tmp_path / "metrics.json")
config = ZabbixConfig(
enabled=False,
mode=ZabbixMode.USERPARAMETER,
metrics_file=metrics_file,
)
writer = UserParameterWriter(config)
writer.add("ses.test", 1)
ok = await writer.flush()
assert ok
import os
assert not os.path.exists(metrics_file), "файл не должен создаваться"
async def test_empty_no_write(self, tmp_path):
"""Проверяет что при пустом буфере файл не пишется."""
from ses_monitor.zabbix import UserParameterWriter
from ses_monitor.config import ZabbixConfig, ZabbixMode
metrics_file = str(tmp_path / "metrics.json")
config = ZabbixConfig(
enabled=True,
mode=ZabbixMode.USERPARAMETER,
metrics_file=metrics_file,
)
writer = UserParameterWriter(config)
ok = await writer.flush()
assert ok
import os
assert not os.path.exists(metrics_file), "файл не должен создаваться"
async def test_accumulates_in_single_cycle(self, tmp_path):
"""Проверяет что все метрики одного цикла попадают в файл."""
from ses_monitor.zabbix import UserParameterWriter
from ses_monitor.config import ZabbixConfig, ZabbixMode
metrics_file = str(tmp_path / "metrics.json")
config = ZabbixConfig(
enabled=True,
mode=ZabbixMode.USERPARAMETER,
metrics_file=metrics_file,
)
writer = UserParameterWriter(config)
writer.add("ses.metric.one", 1)
writer.add("ses.metric.two", 2)
await writer.flush()
import json
with open(metrics_file, "r") as f:
data = json.load(f)
assert data.get("ses.metric.one") == "1"
assert data.get("ses.metric.two") == "2"
async def test_generate_agentd_config(self):
"""Проверяет генерацию конфига Zabbix агента."""
from ses_monitor.zabbix import UserParameterWriter
from ses_monitor.config import ZabbixConfig, ZabbixMode
config = ZabbixConfig(mode=ZabbixMode.USERPARAMETER)
writer = UserParameterWriter(config)
cfg = writer.generate_agentd_config()
assert "UserParameter=ses.metrics," in cfg
assert "zbx_get.py" in cfg
async def test_atomic_write(self, tmp_path):
"""Проверяет что запись атомарна (файл не остаётся в повреждённом состоянии)."""
from ses_monitor.zabbix import UserParameterWriter
from ses_monitor.config import ZabbixConfig, ZabbixMode
metrics_file = str(tmp_path / "metrics.json")
config = ZabbixConfig(
enabled=True,
mode=ZabbixMode.USERPARAMETER,
metrics_file=metrics_file,
)
writer = UserParameterWriter(config)
for i in range(100):
writer.add(f"ses.test.{i}", i)
ok = await writer.flush()
assert ok
import json
with open(metrics_file, "r") as f:
data = json.load(f)
assert len(data) == 100
assert data["ses.test.99"] == "99"