Files
VodokanalBot/tests/test_run_safety.py
T
serega404 c5c4a63668 fix: ограничить время запросов и исключить параллельные запуски
Добавлены таймауты запросов к сайту и Telegram, общий предел выполнения 9 минут и файловая блокировка запусков. База сообщений сохраняется атомарно. Регрессионные тесты запускаются перед публикацией Docker-образа.
2026-10-28 18:11:59 +03:00

148 lines
6.3 KiB
Python

import fcntl
import signal
import tempfile
import threading
import time
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from unittest.mock import patch
import requests
import parser
import start_telegram
class RunSafetyTests(unittest.TestCase):
def test_next_run_delivers_new_posts_after_skipped_check(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "bot.lock"
db_path = str(Path(directory) / "db.json")
old = parser.Post("01.01", "old")
new = parser.Post("01.01", "new")
parser.save_database([old], db_path)
sent = []
with patch.object(parser, "fetch_posts", return_value=[old, new]), patch.object(
start_telegram, "main", side_effect=lambda: parser.publish_new_posts(
sent.append, None, "http://source", db_path=db_path
)
):
with path.open("a") as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
start_telegram.run_once(path)
self.assertEqual([], sent)
self.assertEqual([old.key], parser.load_database(db_path))
start_telegram.run_once(path)
self.assertEqual([new.text], sent)
self.assertEqual([old.key, new.key], parser.load_database(db_path))
def test_unresponsive_telegram_times_out(self):
class SlowHandler(BaseHTTPRequestHandler):
def do_GET(self):
time.sleep(0.2)
self.send_response(200)
self.end_headers()
try:
self.wfile.write(b'{"result": {"message_id": 42}}')
except BrokenPipeError:
pass
def log_message(self, *args):
pass
with ThreadingHTTPServer(("127.0.0.1", 0), SlowHandler) as server:
class LocalTelegramSession(requests.Session):
def get(self, url, **kwargs):
return super().get(f"http://127.0.0.1:{server.server_port}", **kwargs)
worker = threading.Thread(target=server.serve_forever, daemon=True)
worker.start()
try:
with LocalTelegramSession() as session, patch.object(
start_telegram, "TELEGRAM_TIMEOUT", (0.05, 0.05), create=True
):
session.trust_env = False
with self.assertRaises(requests.exceptions.Timeout):
start_telegram.send_telegram_message(session, "test")
finally:
server.shutdown()
worker.join(timeout=2)
def test_interrupted_database_save_preserves_previous_data(self):
def interrupt_save(value, file, **kwargs):
file.write("[")
raise SystemExit("time limit")
with tempfile.TemporaryDirectory() as directory:
path = str(Path(directory) / "db.json")
parser.save_database([parser.Post("01.01", "previous")], path)
with patch.object(parser.json, "dump", side_effect=interrupt_save):
with self.assertRaisesRegex(SystemExit, "time limit"):
parser.save_database([parser.Post("01.01", "new")], path)
self.assertEqual(["01.01$previous"], parser.load_database(path))
def test_unresponsive_source_times_out(self):
class SlowHandler(BaseHTTPRequestHandler):
def do_GET(self):
time.sleep(0.2)
self.send_response(200)
self.end_headers()
def log_message(self, *args):
pass
with ThreadingHTTPServer(("127.0.0.1", 0), SlowHandler) as server:
worker = threading.Thread(target=server.serve_forever, daemon=True)
worker.start()
try:
with requests.Session() as session, patch.object(
parser, "SOURCE_TIMEOUT", (0.05, 0.05), create=True
):
session.trust_env = False
with self.assertRaises(requests.exceptions.Timeout):
parser.fetch_posts(session, f"http://127.0.0.1:{server.server_port}")
finally:
server.shutdown()
worker.join(timeout=2)
def test_overlapping_run_is_skipped(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "bot.lock"
with path.open("a") as lock, patch.object(start_telegram, "main") as main:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
start_telegram.run_once(path)
main.assert_not_called()
def test_completed_run_releases_lock(self):
with tempfile.TemporaryDirectory() as directory, patch.object(start_telegram, "main") as main:
path = Path(directory) / "bot.lock"
start_telegram.run_once(path)
start_telegram.run_once(path)
self.assertEqual(2, main.call_count)
def test_failed_run_releases_lock(self):
with tempfile.TemporaryDirectory() as directory, patch.object(
start_telegram, "main", side_effect=[RuntimeError("source failed"), None]
) as main:
path = Path(directory) / "bot.lock"
with self.assertRaisesRegex(RuntimeError, "source failed"):
start_telegram.run_once(path)
start_telegram.run_once(path)
self.assertEqual(2, main.call_count)
def test_overdue_run_stops_and_allows_next_run(self):
previous_handler = signal.getsignal(signal.SIGALRM)
with tempfile.TemporaryDirectory() as directory, patch.object(
start_telegram, "RUN_TIMEOUT_SECONDS", 1, create=True
):
path = Path(directory) / "bot.lock"
with patch.object(start_telegram, "main", side_effect=lambda: time.sleep(2)):
with self.assertRaisesRegex(SystemExit, "time limit"):
start_telegram.run_once(path)
self.assertEqual(previous_handler, signal.getsignal(signal.SIGALRM))
self.assertEqual(0, signal.alarm(0))
with patch.object(start_telegram, "main") as main:
start_telegram.run_once(path)
main.assert_called_once_with()