699 lines
31 KiB
Python
699 lines
31 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
test_leader_election.py — Unit tests для leader_election.py
|
||
|
||
Montana Protocol
|
||
Тестирование 3-Mirror Leader Election системы
|
||
"""
|
||
|
||
import sys
|
||
import os
|
||
import unittest
|
||
import time
|
||
from unittest.mock import Mock, patch, MagicMock
|
||
from collections import deque
|
||
|
||
# Добавляем путь к модулю
|
||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../бот'))
|
||
from leader_election import (
|
||
BOT_CHAIN,
|
||
CHECK_INTERVAL,
|
||
PING_TIMEOUT,
|
||
STARTUP_DELAY,
|
||
AttackDetector,
|
||
LeaderElection,
|
||
get_leader_election
|
||
)
|
||
|
||
|
||
class TestConstants(unittest.TestCase):
|
||
"""Тесты констант Montana Protocol"""
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# КОНСТАНТЫ
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
def test_bot_chain_structure(self):
|
||
"""Цепочка BOT_CHAIN имеет правильную структуру"""
|
||
self.assertIsInstance(BOT_CHAIN, list)
|
||
self.assertEqual(len(BOT_CHAIN), 5) # 5 узлов
|
||
|
||
for node in BOT_CHAIN:
|
||
self.assertIsInstance(node, tuple)
|
||
self.assertEqual(len(node), 2) # (name, ip)
|
||
name, ip = node
|
||
self.assertIsInstance(name, str)
|
||
self.assertIsInstance(ip, str)
|
||
|
||
def test_bot_chain_order(self):
|
||
"""Проверка правильного порядка узлов в цепочке"""
|
||
expected_order = [
|
||
"amsterdam",
|
||
"moscow",
|
||
"almaty",
|
||
"spb",
|
||
"novosibirsk"
|
||
]
|
||
|
||
for i, (name, ip) in enumerate(BOT_CHAIN):
|
||
self.assertEqual(name, expected_order[i])
|
||
|
||
def test_bot_chain_primary(self):
|
||
"""PRIMARY узел — Amsterdam (первый в цепочке)"""
|
||
primary = BOT_CHAIN[0]
|
||
self.assertEqual(primary[0], "amsterdam")
|
||
self.assertEqual(primary[1], "72.56.102.240")
|
||
|
||
def test_constants_values(self):
|
||
"""Проверка значений констант мониторинга"""
|
||
self.assertEqual(CHECK_INTERVAL, 5) # 5 секунд
|
||
self.assertEqual(PING_TIMEOUT, 2) # 2 секунды
|
||
self.assertEqual(STARTUP_DELAY, 3) # 3 секунды
|
||
|
||
|
||
class TestAttackDetector(unittest.TestCase):
|
||
"""Тесты AttackDetector"""
|
||
|
||
def setUp(self):
|
||
"""Создаём новый detector перед каждым тестом"""
|
||
self.detector = AttackDetector()
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# INITIALIZATION
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
def test_init_default_values(self):
|
||
"""Инициализация с правильными дефолтными значениями"""
|
||
self.assertEqual(self.detector.failure_count, 0)
|
||
self.assertEqual(self.detector.max_failures, 10)
|
||
self.assertEqual(self.detector.cpu_threshold, 80.0)
|
||
self.assertEqual(self.detector.response_time_threshold, 5.0)
|
||
self.assertFalse(self.detector.under_attack)
|
||
self.assertIsInstance(self.detector.response_times, deque)
|
||
self.assertEqual(self.detector.response_times.maxlen, 10)
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# FAILURE RECORDING
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
def test_record_failure_increments(self):
|
||
"""record_failure() увеличивает счётчик"""
|
||
self.assertEqual(self.detector.failure_count, 0)
|
||
|
||
self.detector.record_failure()
|
||
self.assertEqual(self.detector.failure_count, 1)
|
||
|
||
self.detector.record_failure()
|
||
self.assertEqual(self.detector.failure_count, 2)
|
||
|
||
def test_record_failure_triggers_attack(self):
|
||
"""10 failures → атака обнаружена"""
|
||
for i in range(9):
|
||
self.detector.record_failure()
|
||
self.assertFalse(self.detector.under_attack)
|
||
|
||
# 10-я ошибка триггерит атаку
|
||
self.detector.record_failure()
|
||
self.assertTrue(self.detector.under_attack)
|
||
self.assertEqual(self.detector.failure_count, 10)
|
||
|
||
def test_record_success_decreases(self):
|
||
"""record_success() уменьшает счётчик failures"""
|
||
self.detector.failure_count = 5
|
||
|
||
self.detector.record_success()
|
||
self.assertEqual(self.detector.failure_count, 4)
|
||
|
||
self.detector.record_success()
|
||
self.assertEqual(self.detector.failure_count, 3)
|
||
|
||
def test_record_success_not_below_zero(self):
|
||
"""record_success() не уходит в отрицательные"""
|
||
self.detector.failure_count = 0
|
||
|
||
self.detector.record_success()
|
||
self.assertEqual(self.detector.failure_count, 0)
|
||
|
||
self.detector.record_success()
|
||
self.assertEqual(self.detector.failure_count, 0)
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# RESPONSE TIME TRACKING
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
def test_record_response_time(self):
|
||
"""Запись времени отклика"""
|
||
self.detector.record_response_time(1.0)
|
||
self.assertEqual(len(self.detector.response_times), 1)
|
||
self.assertEqual(self.detector.response_times[0], 1.0)
|
||
|
||
self.detector.record_response_time(2.0)
|
||
self.assertEqual(len(self.detector.response_times), 2)
|
||
|
||
def test_response_time_maxlen(self):
|
||
"""Очередь response_times ограничена 10 элементами"""
|
||
for i in range(15):
|
||
self.detector.record_response_time(float(i))
|
||
|
||
# Должно быть только последние 10
|
||
self.assertEqual(len(self.detector.response_times), 10)
|
||
self.assertEqual(list(self.detector.response_times), list(range(5, 15, 1.0)))
|
||
|
||
def test_slow_response_triggers_attack(self):
|
||
"""Медленный отклик (> 5 сек в среднем) → атака"""
|
||
# 5 медленных ответов
|
||
for _ in range(5):
|
||
self.detector.record_response_time(6.0) # > 5 сек порог
|
||
|
||
# Должна быть обнаружена атака
|
||
self.assertTrue(self.detector.under_attack)
|
||
|
||
def test_fast_response_no_attack(self):
|
||
"""Быстрый отклик не триггерит атаку"""
|
||
for _ in range(10):
|
||
self.detector.record_response_time(0.5) # < 5 сек
|
||
|
||
self.assertFalse(self.detector.under_attack)
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# CPU MONITORING
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
@patch('leader_election.psutil.cpu_percent')
|
||
def test_check_cpu_normal(self, mock_cpu):
|
||
"""Нормальный CPU usage (< 80%)"""
|
||
mock_cpu.return_value = 50.0
|
||
|
||
result = self.detector.check_cpu_usage()
|
||
|
||
self.assertFalse(result)
|
||
self.assertFalse(self.detector.under_attack)
|
||
|
||
@patch('leader_election.psutil.cpu_percent')
|
||
def test_check_cpu_high(self, mock_cpu):
|
||
"""Высокий CPU usage (> 80%) → атака"""
|
||
mock_cpu.return_value = 90.0
|
||
|
||
result = self.detector.check_cpu_usage()
|
||
|
||
self.assertTrue(result)
|
||
self.assertTrue(self.detector.under_attack)
|
||
|
||
@patch('leader_election.psutil.cpu_percent')
|
||
def test_check_cpu_exactly_threshold(self, mock_cpu):
|
||
"""CPU ровно на пороге (80%)"""
|
||
mock_cpu.return_value = 80.0
|
||
|
||
result = self.detector.check_cpu_usage()
|
||
|
||
self.assertFalse(result) # 80% не > 80%
|
||
|
||
@patch('leader_election.psutil.cpu_percent')
|
||
def test_check_cpu_error_handling(self, mock_cpu):
|
||
"""Ошибка при проверке CPU не ломает систему"""
|
||
mock_cpu.side_effect = Exception("CPU check failed")
|
||
|
||
result = self.detector.check_cpu_usage()
|
||
|
||
self.assertFalse(result)
|
||
self.assertFalse(self.detector.under_attack)
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# NETWORK MONITORING
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
@patch('leader_election.psutil.net_io_counters')
|
||
@patch('leader_election.time.time')
|
||
def test_check_network_normal(self, mock_time, mock_net_io):
|
||
"""Нормальный network traffic"""
|
||
mock_time.return_value = 100.0
|
||
self.detector.last_check_time = 99.0 # 1 секунда назад
|
||
|
||
# 10 MB за секунду (нормально)
|
||
mock_net_io.return_value = Mock(bytes_recv=10 * 1024 * 1024)
|
||
|
||
result = self.detector.check_network_traffic()
|
||
|
||
self.assertFalse(result)
|
||
self.assertFalse(self.detector.under_attack)
|
||
|
||
@patch('leader_election.psutil.net_io_counters')
|
||
@patch('leader_election.time.time')
|
||
def test_check_network_high(self, mock_time, mock_net_io):
|
||
"""Высокий network traffic (> 100 MB/s) → атака"""
|
||
mock_time.return_value = 100.0
|
||
self.detector.last_check_time = 99.0 # 1 секунда назад
|
||
|
||
# 150 MB за секунду (атака)
|
||
mock_net_io.return_value = Mock(bytes_recv=150 * 1024 * 1024)
|
||
|
||
result = self.detector.check_network_traffic()
|
||
|
||
self.assertTrue(result)
|
||
self.assertTrue(self.detector.under_attack)
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# IS UNDER ATTACK
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
@patch('leader_election.psutil.cpu_percent')
|
||
@patch('leader_election.psutil.net_io_counters')
|
||
def test_is_under_attack_false(self, mock_net, mock_cpu):
|
||
"""Нормальные метрики → не под атакой"""
|
||
mock_cpu.return_value = 50.0
|
||
mock_net.return_value = Mock(bytes_recv=10 * 1024 * 1024)
|
||
|
||
result = self.detector.is_under_attack()
|
||
|
||
self.assertFalse(result)
|
||
|
||
@patch('leader_election.psutil.cpu_percent')
|
||
@patch('leader_election.psutil.net_io_counters')
|
||
def test_is_under_attack_cpu_high(self, mock_net, mock_cpu):
|
||
"""Высокий CPU → под атакой"""
|
||
mock_cpu.return_value = 95.0
|
||
mock_net.return_value = Mock(bytes_recv=10 * 1024 * 1024)
|
||
|
||
result = self.detector.is_under_attack()
|
||
|
||
self.assertTrue(result)
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# RESET
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
def test_reset_clears_attack(self):
|
||
"""reset() сбрасывает флаг атаки"""
|
||
# Симулируем атаку
|
||
self.detector.under_attack = True
|
||
self.detector.failure_count = 10
|
||
self.detector.response_times.extend([6.0, 7.0, 8.0])
|
||
|
||
self.detector.reset()
|
||
|
||
self.assertFalse(self.detector.under_attack)
|
||
self.assertEqual(self.detector.failure_count, 0)
|
||
self.assertEqual(len(self.detector.response_times), 0)
|
||
|
||
def test_reset_idempotent(self):
|
||
"""Множественный reset() безопасен"""
|
||
self.detector.reset()
|
||
self.detector.reset()
|
||
self.detector.reset()
|
||
|
||
self.assertFalse(self.detector.under_attack)
|
||
self.assertEqual(self.detector.failure_count, 0)
|
||
|
||
|
||
class TestLeaderElectionBasic(unittest.TestCase):
|
||
"""Базовые тесты LeaderElection"""
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# INITIALIZATION
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
def test_init_default_chain(self):
|
||
"""Инициализация с дефолтной цепочкой"""
|
||
le = LeaderElection()
|
||
|
||
self.assertEqual(le.chain, BOT_CHAIN)
|
||
self.assertEqual(len(le.chain), 5)
|
||
|
||
def test_init_custom_chain(self):
|
||
"""Инициализация с кастомной цепочкой"""
|
||
custom_chain = [
|
||
("node1", "1.1.1.1"),
|
||
("node2", "2.2.2.2")
|
||
]
|
||
|
||
le = LeaderElection(chain=custom_chain)
|
||
|
||
self.assertEqual(le.chain, custom_chain)
|
||
self.assertEqual(len(le.chain), 2)
|
||
|
||
def test_init_creates_attack_detector(self):
|
||
"""Инициализация создаёт AttackDetector"""
|
||
le = LeaderElection()
|
||
|
||
self.assertIsNotNone(le.attack_detector)
|
||
self.assertIsInstance(le.attack_detector, AttackDetector)
|
||
|
||
def test_init_saves_original_chain(self):
|
||
"""Инициализация сохраняет оригинальную цепочку"""
|
||
custom_chain = [("node1", "1.1.1.1"), ("node2", "2.2.2.2")]
|
||
le = LeaderElection(chain=custom_chain)
|
||
|
||
self.assertEqual(le.original_chain, custom_chain)
|
||
self.assertIsNot(le.original_chain, le.chain) # Копия
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# SELF DETECTION
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
@patch.dict(os.environ, {'MONTANA_NODE_NAME': 'moscow'})
|
||
def test_detect_self_by_node_name(self):
|
||
"""Определение себя по NODE_NAME"""
|
||
le = LeaderElection()
|
||
|
||
self.assertEqual(le.my_name, "moscow")
|
||
self.assertEqual(le.my_ip, "176.124.208.93")
|
||
self.assertEqual(le.my_position, 1)
|
||
|
||
@patch.dict(os.environ, {'MONTANA_NODE_NAME': 'amsterdam'})
|
||
def test_detect_self_primary_node(self):
|
||
"""Определение PRIMARY узла"""
|
||
le = LeaderElection()
|
||
|
||
self.assertEqual(le.my_name, "amsterdam")
|
||
self.assertEqual(le.my_position, 0)
|
||
|
||
@patch.dict(os.environ, {'MONTANA_NODE_NAME': 'novosibirsk'})
|
||
def test_detect_self_last_node(self):
|
||
"""Определение последнего узла в цепочке"""
|
||
le = LeaderElection()
|
||
|
||
self.assertEqual(le.my_name, "novosibirsk")
|
||
self.assertEqual(le.my_position, 4) # Последний в 5-узловой цепочке
|
||
|
||
@patch.dict(os.environ, {}, clear=True)
|
||
def test_detect_self_fallback(self):
|
||
"""Fallback на первый узел если не определён"""
|
||
le = LeaderElection()
|
||
|
||
# Должен выбрать первый узел как fallback
|
||
self.assertEqual(le.my_name, "amsterdam")
|
||
self.assertEqual(le.my_position, 0)
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# CHAIN STATUS
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
@patch('leader_election.check_node_health')
|
||
def test_get_chain_status_all_alive(self, mock_health):
|
||
"""Статус цепочки: все живы"""
|
||
mock_health.return_value = True
|
||
|
||
le = LeaderElection()
|
||
status = le.get_chain_status()
|
||
|
||
# Должны быть зелёные кружки для всех узлов
|
||
self.assertIn("🟢", status)
|
||
self.assertNotIn("🔴", status)
|
||
|
||
@patch('leader_election.check_node_health')
|
||
def test_get_chain_status_all_dead(self, mock_health):
|
||
"""Статус цепочки: все мертвы"""
|
||
mock_health.return_value = False
|
||
|
||
le = LeaderElection()
|
||
status = le.get_chain_status()
|
||
|
||
# Должны быть красные кружки для всех узлов
|
||
self.assertIn("🔴", status)
|
||
self.assertNotIn("🟢", status)
|
||
|
||
@patch('leader_election.check_node_health')
|
||
@patch.dict(os.environ, {'MONTANA_NODE_NAME': 'moscow'})
|
||
def test_get_chain_status_marks_self(self, mock_health):
|
||
"""Статус цепочки: отмечает свой узел"""
|
||
mock_health.return_value = True
|
||
|
||
le = LeaderElection()
|
||
status = le.get_chain_status()
|
||
|
||
# Должна быть метка "← я" для своего узла
|
||
self.assertIn("moscow ← я", status)
|
||
|
||
|
||
class TestLeaderElectionMasterLogic(unittest.TestCase):
|
||
"""Тесты логики определения мастера"""
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# AM I THE MASTER
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
@patch('leader_election.check_node_health')
|
||
@patch.dict(os.environ, {'MONTANA_NODE_NAME': 'amsterdam'})
|
||
def test_am_i_master_primary_always(self, mock_health):
|
||
"""PRIMARY (amsterdam) всегда мастер"""
|
||
mock_health.return_value = True
|
||
|
||
le = LeaderElection()
|
||
result = le.am_i_the_master()
|
||
|
||
# Amsterdam — первый в цепочке, всегда мастер
|
||
self.assertTrue(result)
|
||
|
||
@patch('leader_election.check_node_health')
|
||
@patch.dict(os.environ, {'MONTANA_NODE_NAME': 'moscow'})
|
||
def test_am_i_master_second_when_first_dead(self, mock_health):
|
||
"""MIRROR 1 (moscow) — мастер если amsterdam мертв"""
|
||
|
||
def health_check(ip):
|
||
# Amsterdam мертв, остальные живы
|
||
return ip != "72.56.102.240"
|
||
|
||
mock_health.side_effect = health_check
|
||
|
||
le = LeaderElection()
|
||
result = le.am_i_the_master()
|
||
|
||
# Moscow становится мастером
|
||
self.assertTrue(result)
|
||
|
||
@patch('leader_election.check_node_health')
|
||
@patch.dict(os.environ, {'MONTANA_NODE_NAME': 'moscow'})
|
||
def test_am_i_master_second_when_first_alive(self, mock_health):
|
||
"""MIRROR 1 (moscow) НЕ мастер если amsterdam жив"""
|
||
mock_health.return_value = True # Все живы
|
||
|
||
le = LeaderElection()
|
||
result = le.am_i_the_master()
|
||
|
||
# Amsterdam жив → Moscow не мастер
|
||
self.assertFalse(result)
|
||
|
||
@patch('leader_election.check_node_health')
|
||
@patch.dict(os.environ, {'MONTANA_NODE_NAME': 'novosibirsk'})
|
||
def test_am_i_master_last_when_all_dead(self, mock_health):
|
||
"""Последний узел (novosibirsk) — мастер если все выше мертвы"""
|
||
mock_health.return_value = False # Все мертвы
|
||
|
||
le = LeaderElection()
|
||
result = le.am_i_the_master()
|
||
|
||
# Все выше мертвы → Novosibirsk становится мастером
|
||
self.assertTrue(result)
|
||
|
||
@patch('leader_election.check_node_health')
|
||
@patch.dict(os.environ, {'MONTANA_NODE_NAME': 'spb'})
|
||
def test_am_i_master_middle_selective(self, mock_health):
|
||
"""Средний узел (spb) — мастер только если все выше мертвы"""
|
||
|
||
def health_check(ip):
|
||
# Amsterdam и Moscow мертвы, Almaty жив
|
||
dead_nodes = ["72.56.102.240", "176.124.208.93"]
|
||
return ip not in dead_nodes
|
||
|
||
mock_health.side_effect = health_check
|
||
|
||
le = LeaderElection()
|
||
result = le.am_i_the_master()
|
||
|
||
# Almaty (выше) жив → SPB не мастер
|
||
self.assertFalse(result)
|
||
|
||
|
||
class TestLeaderElectionAttackHandling(unittest.TestCase):
|
||
"""Тесты обработки атак"""
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# CHAIN SHUFFLING
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
@patch('leader_election.check_node_health')
|
||
@patch.dict(os.environ, {'MONTANA_NODE_NAME': 'moscow'})
|
||
def test_shuffle_chain_on_attack(self, mock_health):
|
||
"""При атаке цепочка перемешивается"""
|
||
mock_health.return_value = True # Все узлы живы
|
||
|
||
le = LeaderElection()
|
||
original_order = [name for name, _ in le.chain]
|
||
|
||
# Симулируем атаку
|
||
le.attack_detector.under_attack = True
|
||
|
||
le.shuffle_chain_on_attack()
|
||
|
||
# Цепочка должна быть перемешана
|
||
new_order = [name for name, _ in le.chain]
|
||
self.assertNotEqual(original_order, new_order)
|
||
self.assertTrue(le.chain_shuffled)
|
||
|
||
@patch('leader_election.check_node_health')
|
||
def test_shuffle_filters_dead_nodes(self, mock_health):
|
||
"""shuffle_chain_on_attack() фильтрует мертвые узлы"""
|
||
|
||
def health_check(ip):
|
||
# Только Amsterdam и Moscow живы
|
||
alive_nodes = ["72.56.102.240", "176.124.208.93"]
|
||
return ip in alive_nodes
|
||
|
||
mock_health.side_effect = health_check
|
||
|
||
le = LeaderElection()
|
||
le.attack_detector.under_attack = True
|
||
|
||
le.shuffle_chain_on_attack()
|
||
|
||
# В новой цепочке должны быть только 2 живых узла
|
||
self.assertEqual(len(le.chain), 2)
|
||
|
||
chain_ips = [ip for _, ip in le.chain]
|
||
self.assertIn("72.56.102.240", chain_ips)
|
||
self.assertIn("176.124.208.93", chain_ips)
|
||
|
||
@patch('leader_election.check_node_health')
|
||
def test_shuffle_no_attack_no_shuffle(self, mock_health):
|
||
"""Без атаки цепочка не перемешивается"""
|
||
mock_health.return_value = True
|
||
|
||
le = LeaderElection()
|
||
original_chain = list(le.chain)
|
||
|
||
# НЕТ атаки
|
||
le.attack_detector.under_attack = False
|
||
|
||
le.shuffle_chain_on_attack()
|
||
|
||
# Цепочка НЕ изменилась
|
||
self.assertEqual(le.chain, original_chain)
|
||
self.assertFalse(le.chain_shuffled)
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# CHAIN RESTORATION
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
@patch('leader_election.check_node_health')
|
||
def test_restore_original_chain(self, mock_health):
|
||
"""Восстановление оригинальной цепочки"""
|
||
mock_health.return_value = True
|
||
|
||
le = LeaderElection()
|
||
original = list(le.original_chain)
|
||
|
||
# Перемешиваем
|
||
le.attack_detector.under_attack = True
|
||
le.shuffle_chain_on_attack()
|
||
|
||
# Восстанавливаем
|
||
le.restore_original_chain()
|
||
|
||
# Цепочка восстановлена
|
||
self.assertEqual(le.chain, original)
|
||
self.assertFalse(le.chain_shuffled)
|
||
|
||
def test_restore_when_not_shuffled(self):
|
||
"""Восстановление когда цепочка не была перемешана"""
|
||
le = LeaderElection()
|
||
original = list(le.chain)
|
||
|
||
le.restore_original_chain()
|
||
|
||
# Ничего не изменилось
|
||
self.assertEqual(le.chain, original)
|
||
self.assertFalse(le.chain_shuffled)
|
||
|
||
|
||
class TestLeaderElectionSingleton(unittest.TestCase):
|
||
"""Тесты singleton паттерна"""
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# SINGLETON
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
def test_get_leader_election_returns_instance(self):
|
||
"""get_leader_election() возвращает экземпляр"""
|
||
le = get_leader_election()
|
||
|
||
self.assertIsNotNone(le)
|
||
self.assertIsInstance(le, LeaderElection)
|
||
|
||
def test_get_leader_election_singleton(self):
|
||
"""get_leader_election() возвращает один и тот же экземпляр"""
|
||
le1 = get_leader_election()
|
||
le2 = get_leader_election()
|
||
|
||
self.assertIs(le1, le2)
|
||
|
||
def test_singleton_shared_state(self):
|
||
"""Singleton делит состояние между вызовами"""
|
||
le1 = get_leader_election()
|
||
le1.is_master = True
|
||
|
||
le2 = get_leader_election()
|
||
|
||
# Должен быть тот же объект с тем же состоянием
|
||
self.assertTrue(le2.is_master)
|
||
|
||
|
||
class TestLeaderElectionEdgeCases(unittest.TestCase):
|
||
"""Тесты граничных случаев"""
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# EDGE CASES
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
def test_empty_chain(self):
|
||
"""Пустая цепочка"""
|
||
le = LeaderElection(chain=[])
|
||
|
||
self.assertEqual(len(le.chain), 0)
|
||
self.assertIsNone(le.my_name)
|
||
self.assertIsNone(le.my_ip)
|
||
self.assertEqual(le.my_position, -1)
|
||
|
||
@patch('leader_election.check_node_health')
|
||
def test_single_node_chain(self, mock_health):
|
||
"""Цепочка из одного узла"""
|
||
mock_health.return_value = True
|
||
|
||
single_chain = [("solo", "1.1.1.1")]
|
||
le = LeaderElection(chain=single_chain)
|
||
|
||
# Единственный узел всегда мастер
|
||
self.assertTrue(le.am_i_the_master())
|
||
|
||
@patch('leader_election.check_node_health')
|
||
@patch.dict(os.environ, {'MONTANA_NODE_NAME': 'unknown'})
|
||
def test_node_not_in_chain(self, mock_health):
|
||
"""Узел не найден в цепочке"""
|
||
mock_health.return_value = True
|
||
|
||
le = LeaderElection()
|
||
|
||
# Должен fallback на первый узел
|
||
self.assertIn(le.my_name, [name for name, _ in BOT_CHAIN])
|
||
|
||
@patch('leader_election.check_node_health')
|
||
def test_all_nodes_dead_last_becomes_master(self, mock_health):
|
||
"""Все узлы мертвы → последний становится мастером"""
|
||
mock_health.return_value = False # Все мертвы
|
||
|
||
# Создаём узел как последний в цепочке
|
||
with patch.dict(os.environ, {'MONTANA_NODE_NAME': 'novosibirsk'}):
|
||
le = LeaderElection()
|
||
result = le.am_i_the_master()
|
||
|
||
# Все мертвы → последний мастер
|
||
self.assertTrue(result)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# RUN TESTS
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
if __name__ == '__main__':
|
||
# Запускаем с verbose output
|
||
unittest.main(verbosity=2)
|