mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
refactor(runtime): tighten resource cleanup and test isolation (#6116)
This commit is contained in:
+44
-11
@@ -18,8 +18,10 @@ from app.log import logger
|
|||||||
from app.utils.mixins import ConfigReloadMixin
|
from app.utils.mixins import ConfigReloadMixin
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
|
|
||||||
# 定义一个全局线程池执行器
|
# DoH 关闭时需要释放线程池;保持惰性创建可避免未启用 DoH 时占用进程级资源
|
||||||
_executor = concurrent.futures.ThreadPoolExecutor()
|
_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None
|
||||||
|
_executor_lock = Lock()
|
||||||
|
_doh_enabled = False
|
||||||
|
|
||||||
# 定义默认的DoH配置
|
# 定义默认的DoH配置
|
||||||
_doh_timeout = 5
|
_doh_timeout = 5
|
||||||
@@ -29,11 +31,21 @@ _doh_lock = Lock()
|
|||||||
_orig_getaddrinfo = socket.getaddrinfo
|
_orig_getaddrinfo = socket.getaddrinfo
|
||||||
|
|
||||||
|
|
||||||
|
def _get_executor_locked() -> concurrent.futures.ThreadPoolExecutor:
|
||||||
|
"""在持有执行器锁时按需获取 DoH 查询线程池"""
|
||||||
|
global _executor
|
||||||
|
if _executor is None:
|
||||||
|
_executor = concurrent.futures.ThreadPoolExecutor()
|
||||||
|
return _executor
|
||||||
|
|
||||||
|
|
||||||
def enable_doh(enable: bool) -> None:
|
def enable_doh(enable: bool) -> None:
|
||||||
"""
|
"""
|
||||||
对 socket.getaddrinfo 进行补丁
|
对 socket.getaddrinfo 进行补丁
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
global _doh_enabled
|
||||||
|
|
||||||
def _patched_getaddrinfo(host: str, *args, **kwargs):
|
def _patched_getaddrinfo(host: str, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
socket.getaddrinfo的补丁版本。
|
socket.getaddrinfo的补丁版本。
|
||||||
@@ -47,9 +59,15 @@ def enable_doh(enable: bool) -> None:
|
|||||||
logger.info(f"已解析 [{host}] 为 [{ip}] (缓存)")
|
logger.info(f"已解析 [{host}] 为 [{ip}] (缓存)")
|
||||||
return _orig_getaddrinfo(ip, *args, **kwargs)
|
return _orig_getaddrinfo(ip, *args, **kwargs)
|
||||||
# 使用DoH解析主机
|
# 使用DoH解析主机
|
||||||
futures = []
|
with _executor_lock:
|
||||||
for resolver in settings.DOH_RESOLVERS.split(","):
|
if not _doh_enabled:
|
||||||
futures.append(_executor.submit(_doh_query, resolver, host))
|
return _orig_getaddrinfo(host, *args, **kwargs)
|
||||||
|
executor = _get_executor_locked()
|
||||||
|
# 一次解析的任务必须在同一临界区提交完,避免关闭过程中部分任务落入新线程池
|
||||||
|
futures = [
|
||||||
|
executor.submit(_doh_query, resolver, host)
|
||||||
|
for resolver in settings.DOH_RESOLVERS.split(",")
|
||||||
|
]
|
||||||
for future in concurrent.futures.as_completed(futures):
|
for future in concurrent.futures.as_completed(futures):
|
||||||
ip = future.result()
|
ip = future.result()
|
||||||
if ip is not None:
|
if ip is not None:
|
||||||
@@ -60,11 +78,9 @@ def enable_doh(enable: bool) -> None:
|
|||||||
break
|
break
|
||||||
return _orig_getaddrinfo(host, *args, **kwargs)
|
return _orig_getaddrinfo(host, *args, **kwargs)
|
||||||
|
|
||||||
if enable:
|
with _executor_lock:
|
||||||
# 替换 socket.getaddrinfo 方法
|
_doh_enabled = enable
|
||||||
socket.getaddrinfo = _patched_getaddrinfo
|
socket.getaddrinfo = _patched_getaddrinfo if enable else _orig_getaddrinfo
|
||||||
else:
|
|
||||||
socket.getaddrinfo = _orig_getaddrinfo
|
|
||||||
|
|
||||||
|
|
||||||
class DohHelper(ConfigReloadMixin, metaclass=Singleton):
|
class DohHelper(ConfigReloadMixin, metaclass=Singleton):
|
||||||
@@ -77,14 +93,31 @@ class DohHelper(ConfigReloadMixin, metaclass=Singleton):
|
|||||||
enable_doh(settings.DOH_ENABLE)
|
enable_doh(settings.DOH_ENABLE)
|
||||||
|
|
||||||
def on_config_changed(self) -> None:
|
def on_config_changed(self) -> None:
|
||||||
|
if not settings.DOH_ENABLE:
|
||||||
|
self.shutdown()
|
||||||
|
return
|
||||||
with _doh_lock:
|
with _doh_lock:
|
||||||
# DOH配置有变动的情况下,清空缓存
|
# DOH配置有变动的情况下,清空缓存
|
||||||
_doh_cache.clear()
|
_doh_cache.clear()
|
||||||
enable_doh(settings.DOH_ENABLE)
|
enable_doh(True)
|
||||||
|
|
||||||
def get_reload_name(self) -> str:
|
def get_reload_name(self) -> str:
|
||||||
return 'DoH'
|
return 'DoH'
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""恢复系统 DNS 并释放 DoH 查询线程池"""
|
||||||
|
global _executor, _doh_enabled
|
||||||
|
with _executor_lock:
|
||||||
|
_doh_enabled = False
|
||||||
|
socket.getaddrinfo = _orig_getaddrinfo
|
||||||
|
executor = _executor
|
||||||
|
_executor = None
|
||||||
|
with _doh_lock:
|
||||||
|
_doh_cache.clear()
|
||||||
|
if executor:
|
||||||
|
executor.shutdown(wait=True)
|
||||||
|
|
||||||
|
|
||||||
def _doh_query(resolver: str, host: str) -> Optional[str]:
|
def _doh_query(resolver: str, host: str) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
使用给定的DoH解析器查询给定主机的IP地址。
|
使用给定的DoH解析器查询给定主机的IP地址。
|
||||||
|
|||||||
@@ -605,6 +605,7 @@ class MessageQueueManager(metaclass=SingletonClass):
|
|||||||
self.check_interval = check_interval
|
self.check_interval = check_interval
|
||||||
|
|
||||||
self._running = True
|
self._running = True
|
||||||
|
self._stop_event = threading.Event()
|
||||||
self.thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
self.thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
||||||
self.thread.start()
|
self.thread.start()
|
||||||
|
|
||||||
@@ -752,13 +753,15 @@ class MessageQueueManager(metaclass=SingletonClass):
|
|||||||
logger.info(f"队列剩余消息:{self.queue.qsize()}")
|
logger.info(f"队列剩余消息:{self.queue.qsize()}")
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
break
|
break
|
||||||
time.sleep(self.check_interval)
|
if self._stop_event.wait(self.check_interval):
|
||||||
|
break
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""
|
"""
|
||||||
停止队列管理器
|
停止队列管理器
|
||||||
"""
|
"""
|
||||||
self._running = False
|
self._running = False
|
||||||
|
self._stop_event.set()
|
||||||
logger.info("正在停止消息队列...")
|
logger.info("正在停止消息队列...")
|
||||||
self.thread.join()
|
self.thread.join()
|
||||||
logger.info("消息队列已停止")
|
logger.info("消息队列已停止")
|
||||||
@@ -841,7 +844,8 @@ def stop_message():
|
|||||||
"""
|
"""
|
||||||
停止消息服务
|
停止消息服务
|
||||||
"""
|
"""
|
||||||
# 停止消息队列
|
# 只关闭已启动的服务,避免清理路径反向创建后台线程和缓存
|
||||||
MessageQueueManager().stop()
|
if queue_manager := MessageQueueManager.get_existing_instance():
|
||||||
# 关闭消息演染器
|
queue_manager.stop()
|
||||||
TemplateHelper().close()
|
if template_helper := TemplateHelper.get_existing_instance():
|
||||||
|
template_helper.close()
|
||||||
|
|||||||
+58
-35
@@ -124,7 +124,7 @@ class NonBlockingFileHandler:
|
|||||||
"""
|
"""
|
||||||
_instance = None
|
_instance = None
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
_rotating_handlers = {}
|
_stop_sentinel = object()
|
||||||
|
|
||||||
def __new__(cls):
|
def __new__(cls):
|
||||||
if cls._instance is None:
|
if cls._instance is None:
|
||||||
@@ -138,6 +138,9 @@ class NonBlockingFileHandler:
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._initialized = True
|
self._initialized = True
|
||||||
|
self._state_lock = threading.RLock()
|
||||||
|
self._handlers_lock = threading.Lock()
|
||||||
|
self._rotating_handlers = {}
|
||||||
self._write_queue = queue.Queue(maxsize=log_settings.ASYNC_FILE_QUEUE_SIZE)
|
self._write_queue = queue.Queue(maxsize=log_settings.ASYNC_FILE_QUEUE_SIZE)
|
||||||
self._executor = ThreadPoolExecutor(max_workers=log_settings.ASYNC_FILE_WORKERS,
|
self._executor = ThreadPoolExecutor(max_workers=log_settings.ASYNC_FILE_WORKERS,
|
||||||
thread_name_prefix="LogWriter")
|
thread_name_prefix="LogWriter")
|
||||||
@@ -151,27 +154,28 @@ class NonBlockingFileHandler:
|
|||||||
"""
|
"""
|
||||||
获取或创建RotatingFileHandler实例
|
获取或创建RotatingFileHandler实例
|
||||||
"""
|
"""
|
||||||
if file_path not in self._rotating_handlers:
|
with self._handlers_lock:
|
||||||
# 确保目录存在
|
if file_path not in self._rotating_handlers:
|
||||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
# 确保目录存在
|
||||||
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# 创建RotatingFileHandler
|
# 创建RotatingFileHandler
|
||||||
handler = RotatingFileHandler(
|
handler = RotatingFileHandler(
|
||||||
filename=str(file_path),
|
filename=str(file_path),
|
||||||
maxBytes=log_settings.LOG_MAX_FILE_SIZE_BYTES,
|
maxBytes=log_settings.LOG_MAX_FILE_SIZE_BYTES,
|
||||||
backupCount=log_settings.LOG_BACKUP_COUNT,
|
backupCount=log_settings.LOG_BACKUP_COUNT,
|
||||||
encoding='utf-8'
|
encoding='utf-8'
|
||||||
)
|
)
|
||||||
|
|
||||||
# 设置格式化器
|
# 设置格式化器
|
||||||
formatter = logging.Formatter(log_settings.LOG_FILE_FORMAT)
|
formatter = logging.Formatter(log_settings.LOG_FILE_FORMAT)
|
||||||
handler.setFormatter(formatter)
|
handler.setFormatter(formatter)
|
||||||
|
|
||||||
self._rotating_handlers[file_path] = handler
|
self._rotating_handlers[file_path] = handler
|
||||||
|
|
||||||
return self._rotating_handlers[file_path]
|
return self._rotating_handlers[file_path]
|
||||||
|
|
||||||
def write_log(self, level: str, message: str, file_path: Path):
|
def write_log(self, level: str, message: str, file_path: Path) -> None:
|
||||||
"""
|
"""
|
||||||
写入日志 - 自动检测协程环境并使用合适的方式
|
写入日志 - 自动检测协程环境并使用合适的方式
|
||||||
"""
|
"""
|
||||||
@@ -181,8 +185,11 @@ class NonBlockingFileHandler:
|
|||||||
if self._is_in_event_loop():
|
if self._is_in_event_loop():
|
||||||
# 在协程环境中,使用非阻塞方式
|
# 在协程环境中,使用非阻塞方式
|
||||||
self._write_non_blocking(entry)
|
self._write_non_blocking(entry)
|
||||||
else:
|
return
|
||||||
# 不在协程环境中,直接同步写入
|
with self._state_lock:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
# 不在协程环境中,持锁同步写入,避免关闭文件处理器时仍有写操作进行
|
||||||
self._write_sync(entry)
|
self._write_sync(entry)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -196,15 +203,19 @@ class NonBlockingFileHandler:
|
|||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _write_non_blocking(self, entry: LogEntry):
|
def _write_non_blocking(self, entry: LogEntry) -> bool:
|
||||||
"""
|
"""
|
||||||
非阻塞写入(用于协程环境)
|
非阻塞写入(用于协程环境)
|
||||||
"""
|
"""
|
||||||
try:
|
with self._state_lock:
|
||||||
self._write_queue.put_nowait(entry)
|
if not self._running:
|
||||||
except queue.Full:
|
return False
|
||||||
# 队列满时,使用线程池处理
|
try:
|
||||||
self._executor.submit(self._write_sync, entry)
|
self._write_queue.put_nowait(entry)
|
||||||
|
except queue.Full:
|
||||||
|
# 队列满时,使用线程池处理
|
||||||
|
self._executor.submit(self._write_sync, entry)
|
||||||
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _write_sync(entry: LogEntry):
|
def _write_sync(entry: LogEntry):
|
||||||
@@ -215,8 +226,7 @@ class NonBlockingFileHandler:
|
|||||||
# 获取RotatingFileHandler实例
|
# 获取RotatingFileHandler实例
|
||||||
handler = NonBlockingFileHandler()._get_rotating_handler(entry.file_path)
|
handler = NonBlockingFileHandler()._get_rotating_handler(entry.file_path)
|
||||||
|
|
||||||
# 使用RotatingFileHandler的emit方法,只传递原始消息
|
handler.handle(logging.LogRecord(
|
||||||
handler.emit(logging.LogRecord(
|
|
||||||
name='',
|
name='',
|
||||||
level=getattr(logging, entry.level.upper(), logging.INFO),
|
level=getattr(logging, entry.level.upper(), logging.INFO),
|
||||||
pathname='',
|
pathname='',
|
||||||
@@ -235,22 +245,28 @@ class NonBlockingFileHandler:
|
|||||||
"""
|
"""
|
||||||
后台批量写入线程
|
后台批量写入线程
|
||||||
"""
|
"""
|
||||||
while self._running:
|
while True:
|
||||||
try:
|
try:
|
||||||
# 收集一批日志条目
|
# 收集一批日志条目
|
||||||
batch = []
|
batch = []
|
||||||
|
should_stop = False
|
||||||
end_time = time.time() + log_settings.WRITE_TIMEOUT
|
end_time = time.time() + log_settings.WRITE_TIMEOUT
|
||||||
|
|
||||||
while len(batch) < log_settings.BATCH_WRITE_SIZE and time.time() < end_time:
|
while len(batch) < log_settings.BATCH_WRITE_SIZE and time.time() < end_time:
|
||||||
try:
|
try:
|
||||||
remaining_time = max(0, end_time - time.time())
|
remaining_time = max(0, end_time - time.time())
|
||||||
entry = self._write_queue.get(timeout=remaining_time)
|
entry = self._write_queue.get(timeout=remaining_time)
|
||||||
|
if entry is self._stop_sentinel:
|
||||||
|
should_stop = True
|
||||||
|
break
|
||||||
batch.append(entry)
|
batch.append(entry)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
break
|
break
|
||||||
|
|
||||||
if batch:
|
if batch:
|
||||||
self._write_batch(batch)
|
self._write_batch(batch)
|
||||||
|
if should_stop:
|
||||||
|
break
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"批量写入线程错误: {e}")
|
print(f"批量写入线程错误: {e}")
|
||||||
@@ -275,8 +291,7 @@ class NonBlockingFileHandler:
|
|||||||
|
|
||||||
# 批量写入
|
# 批量写入
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
# 使用RotatingFileHandler的emit方法,只传递原始消息
|
handler.handle(logging.LogRecord(
|
||||||
handler.emit(logging.LogRecord(
|
|
||||||
name='',
|
name='',
|
||||||
level=getattr(logging, entry.level.upper(), logging.INFO),
|
level=getattr(logging, entry.level.upper(), logging.INFO),
|
||||||
pathname='',
|
pathname='',
|
||||||
@@ -294,15 +309,23 @@ class NonBlockingFileHandler:
|
|||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
"""
|
"""
|
||||||
关闭文件处理器
|
排空异步日志并关闭文件处理器
|
||||||
"""
|
"""
|
||||||
self._running = False
|
with self._state_lock:
|
||||||
if hasattr(self, '_write_thread'):
|
if not self._running:
|
||||||
self._write_thread.join(timeout=5)
|
return
|
||||||
|
self._running = False
|
||||||
|
if hasattr(self, '_write_thread') and self._write_thread.is_alive():
|
||||||
|
# 状态锁保证停止标记之后不会再有生产者入队
|
||||||
|
self._write_queue.put(self._stop_sentinel)
|
||||||
|
if hasattr(self, '_write_thread') and self._write_thread.is_alive():
|
||||||
|
self._write_thread.join()
|
||||||
if self._executor:
|
if self._executor:
|
||||||
self._executor.shutdown(wait=True)
|
self._executor.shutdown(wait=True)
|
||||||
|
|
||||||
# 清理缓存
|
for handler in self._rotating_handlers.values():
|
||||||
|
handler.flush()
|
||||||
|
handler.close()
|
||||||
self._rotating_handlers.clear()
|
self._rotating_handlers.clear()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -279,8 +279,6 @@ class Telegram:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _telegramify_item_text(item: Text) -> str:
|
def _telegramify_item_text(item: Text) -> str:
|
||||||
"""将 telegramify 文本片段转换为 Telegram MarkdownV2 字符串。"""
|
"""将 telegramify 文本片段转换为 Telegram MarkdownV2 字符串。"""
|
||||||
if hasattr(item, "content"):
|
|
||||||
return item.content
|
|
||||||
if entities_to_markdownv2:
|
if entities_to_markdownv2:
|
||||||
return entities_to_markdownv2(item.text, item.entities)
|
return entities_to_markdownv2(item.text, item.entities)
|
||||||
return standardize(item.text)
|
return standardize(item.text)
|
||||||
@@ -290,8 +288,6 @@ class Telegram:
|
|||||||
"""将 telegramify 文本或媒体片段转换为 Telegram MarkdownV2 caption。"""
|
"""将 telegramify 文本或媒体片段转换为 Telegram MarkdownV2 caption。"""
|
||||||
if isinstance(item, Text):
|
if isinstance(item, Text):
|
||||||
return Telegram._telegramify_item_text(item)
|
return Telegram._telegramify_item_text(item)
|
||||||
if hasattr(item, "caption"):
|
|
||||||
return item.caption
|
|
||||||
if entities_to_markdownv2:
|
if entities_to_markdownv2:
|
||||||
return entities_to_markdownv2(item.caption_text, item.caption_entities)
|
return entities_to_markdownv2(item.caption_text, item.caption_entities)
|
||||||
return standardize(item.caption_text)
|
return standardize(item.caption_text)
|
||||||
|
|||||||
+22
-17
@@ -20,6 +20,7 @@ from app.chain.system import SystemChain
|
|||||||
from app.core.config import global_vars, settings
|
from app.core.config import global_vars, settings
|
||||||
from app.helper.server import MoviePilotServerHelper
|
from app.helper.server import MoviePilotServerHelper
|
||||||
from app.helper.system import SystemHelper
|
from app.helper.system import SystemHelper
|
||||||
|
from app.log import LoggerManager
|
||||||
from app.startup.command_initializer import init_command, stop_command, restart_command
|
from app.startup.command_initializer import init_command, stop_command, restart_command
|
||||||
from app.startup.modules_initializer import init_modules, stop_modules
|
from app.startup.modules_initializer import init_modules, stop_modules
|
||||||
from app.startup.monitor_initializer import stop_monitor, init_monitor
|
from app.startup.monitor_initializer import stop_monitor, init_monitor
|
||||||
@@ -97,20 +98,24 @@ async def lifespan(app: FastAPI):
|
|||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(str(e))
|
print(str(e))
|
||||||
if not settings.MOVIEPILOT_SAFE_MODE:
|
try:
|
||||||
# 备份插件
|
if not settings.MOVIEPILOT_SAFE_MODE:
|
||||||
SystemChain().backup_plugins()
|
# 备份插件
|
||||||
# 停止工作流
|
SystemChain().backup_plugins()
|
||||||
stop_workflow()
|
# 停止工作流
|
||||||
# 停止命令
|
stop_workflow()
|
||||||
stop_command()
|
# 停止命令
|
||||||
# 停止监控器
|
stop_command()
|
||||||
stop_monitor()
|
# 停止监控器
|
||||||
# 停止定时器
|
stop_monitor()
|
||||||
stop_scheduler()
|
# 停止定时器
|
||||||
# 停止插件
|
stop_scheduler()
|
||||||
stop_plugins()
|
# 停止插件
|
||||||
# 停止模块
|
stop_plugins()
|
||||||
await stop_modules()
|
# 停止模块
|
||||||
# 关闭共享的异步 HTTP 连接池,释放底层连接资源
|
await stop_modules()
|
||||||
await aclose_shared_async_transports()
|
# 关闭共享的异步 HTTP 连接池,释放底层连接资源
|
||||||
|
await aclose_shared_async_transports()
|
||||||
|
finally:
|
||||||
|
# 日志最后关闭,确保其他组件的收尾信息已写入文件
|
||||||
|
LoggerManager.shutdown()
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ async def stop_modules():
|
|||||||
EventManager().stop()
|
EventManager().stop()
|
||||||
# 停止虚拟显示
|
# 停止虚拟显示
|
||||||
DisplayHelper().stop()
|
DisplayHelper().stop()
|
||||||
|
# 停止 DoH 服务
|
||||||
|
DohHelper().shutdown()
|
||||||
# 停止线程池
|
# 停止线程池
|
||||||
ThreadHelper().shutdown()
|
ThreadHelper().shutdown()
|
||||||
# 停止消息服务
|
# 停止消息服务
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ fixture 一并识别,autouse 自动作用于每个用例,无需逐用例改
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
# 本地回环/通配地址放行,其余主机一律视为真实出站;getaddrinfo 的 host 可能为 str 或 bytes
|
# 本地回环/通配地址放行,其余主机一律视为真实出站;getaddrinfo 的 host 可能为 str 或 bytes
|
||||||
@@ -20,21 +22,45 @@ def block_real_network(monkeypatch):
|
|||||||
"""防御纵深:拦截对非本地主机的真实出站,强制测试零真实网络。
|
"""防御纵深:拦截对非本地主机的真实出站,强制测试零真实网络。
|
||||||
|
|
||||||
补在各用例自身 mock 之上:某用例万一漏 mock 外部依赖(TMDB / LLM 目录 / 下载器 /
|
补在各用例自身 mock 之上:某用例万一漏 mock 外部依赖(TMDB / LLM 目录 / 下载器 /
|
||||||
媒体服务器 / 任意外链),其真实 DNS 解析会在此被拦并报错,而非静默发请求。本地回环放行
|
媒体服务器 / 任意外链),其 DNS 解析或 socket 连接会被拦截。本地回环放行(sqlite 等)。
|
||||||
(sqlite 等)。asyncio 默认解析器经线程池调用 ``socket.getaddrinfo``,故拦此一处即覆盖
|
所有拦截记录会在用例收尾再次断言,避免业务代码捕获网络异常后让漏 mock 的用例静默通过。
|
||||||
同步与异步出站。``monkeypatch`` 在用例结束后自动还原,不影响其他用例与进程退出。
|
``monkeypatch`` 在用例结束后自动还原,不影响其他用例与进程退出。
|
||||||
"""
|
"""
|
||||||
import socket
|
import socket
|
||||||
|
|
||||||
_real_getaddrinfo = socket.getaddrinfo
|
_real_getaddrinfo = socket.getaddrinfo
|
||||||
|
_real_connect = socket.socket.connect
|
||||||
|
attempts = []
|
||||||
|
|
||||||
|
def _is_allowed_host(host) -> bool:
|
||||||
|
normalized = host.decode() if isinstance(host, (bytes, bytearray)) else host
|
||||||
|
if normalized is None or normalized in _ALLOWED_NETWORK_HOSTS:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
address = ipaddress.ip_address(str(normalized).split("%", 1)[0])
|
||||||
|
return address.is_loopback or address.is_unspecified
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _blocked(operation: str, host):
|
||||||
|
attempts.append((operation, host))
|
||||||
|
raise RuntimeError(
|
||||||
|
f"测试禁止真实出站网络:尝试通过 {operation} 访问 {host!r};请 mock 对应外部依赖"
|
||||||
|
)
|
||||||
|
|
||||||
def _guarded_getaddrinfo(host, *args, **kwargs):
|
def _guarded_getaddrinfo(host, *args, **kwargs):
|
||||||
normalized = host.decode() if isinstance(host, (bytes, bytearray)) else host
|
if not _is_allowed_host(host):
|
||||||
if normalized is not None and normalized not in _ALLOWED_NETWORK_HOSTS:
|
_blocked("DNS", host)
|
||||||
raise RuntimeError(
|
|
||||||
f"测试禁止真实出站网络:尝试解析 {normalized!r};请 mock 对应外部依赖"
|
|
||||||
)
|
|
||||||
return _real_getaddrinfo(host, *args, **kwargs)
|
return _real_getaddrinfo(host, *args, **kwargs)
|
||||||
|
|
||||||
|
def _guarded_connect(sock, address):
|
||||||
|
if isinstance(address, tuple) and address and not _is_allowed_host(address[0]):
|
||||||
|
_blocked("socket", address[0])
|
||||||
|
return _real_connect(sock, address)
|
||||||
|
|
||||||
monkeypatch.setattr(socket, "getaddrinfo", _guarded_getaddrinfo)
|
monkeypatch.setattr(socket, "getaddrinfo", _guarded_getaddrinfo)
|
||||||
|
monkeypatch.setattr(socket.socket, "connect", _guarded_connect)
|
||||||
yield
|
yield
|
||||||
|
if attempts:
|
||||||
|
details = ", ".join(f"{operation}:{host}" for operation, host in attempts)
|
||||||
|
pytest.fail(f"测试期间发生真实出站网络尝试:{details}")
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ class Singleton(abc.ABCMeta, type):
|
|||||||
|
|
||||||
_instances: dict = {}
|
_instances: dict = {}
|
||||||
|
|
||||||
|
def get_existing_instance(cls, *args, **kwargs):
|
||||||
|
"""按相同参数返回已创建实例,不触发初始化"""
|
||||||
|
key = (cls, args, frozenset(kwargs.items()))
|
||||||
|
return cls._instances.get(key)
|
||||||
|
|
||||||
def __call__(cls, *args, **kwargs):
|
def __call__(cls, *args, **kwargs):
|
||||||
key = (cls, args, frozenset(kwargs.items()))
|
key = (cls, args, frozenset(kwargs.items()))
|
||||||
if key not in cls._instances:
|
if key not in cls._instances:
|
||||||
@@ -31,6 +36,10 @@ class SingletonClass(abc.ABCMeta, type):
|
|||||||
|
|
||||||
_instances: dict = {}
|
_instances: dict = {}
|
||||||
|
|
||||||
|
def get_existing_instance(cls):
|
||||||
|
"""返回已创建实例,不触发初始化"""
|
||||||
|
return cls._instances.get(cls)
|
||||||
|
|
||||||
def __call__(cls, *args, **kwargs):
|
def __call__(cls, *args, **kwargs):
|
||||||
if cls not in cls._instances:
|
if cls not in cls._instances:
|
||||||
cls._instances[cls] = super().__call__(*args, **kwargs)
|
cls._instances[cls] = super().__call__(*args, **kwargs)
|
||||||
|
|||||||
@@ -7,9 +7,5 @@ timeout_method = thread
|
|||||||
# 让本仓自身的新告警更醒目。本仓代码引发的告警一律不在此忽略,应在源码/用例处修复。
|
# 让本仓自身的新告警更醒目。本仓代码引发的告警一律不在此忽略,应在源码/用例处修复。
|
||||||
filterwarnings =
|
filterwarnings =
|
||||||
ignore:datetime.datetime.utcfromtimestamp\(\) is deprecated:DeprecationWarning
|
ignore:datetime.datetime.utcfromtimestamp\(\) is deprecated:DeprecationWarning
|
||||||
ignore:websockets.legacy is deprecated:DeprecationWarning
|
|
||||||
ignore:websockets.InvalidStatusCode is deprecated:DeprecationWarning
|
|
||||||
ignore:pkg_resources is deprecated as an API:DeprecationWarning
|
|
||||||
ignore:Deprecated call to .pkg_resources.declare_namespace:DeprecationWarning
|
|
||||||
ignore:'crypt' is deprecated:DeprecationWarning
|
ignore:'crypt' is deprecated:DeprecationWarning
|
||||||
ignore:'audioop' is deprecated:DeprecationWarning
|
ignore:'audioop' is deprecated:DeprecationWarning
|
||||||
|
|||||||
+15
-7
@@ -16,9 +16,11 @@ prepare_backend()
|
|||||||
from app.testing.network_guard import block_real_network # noqa: E402,F401
|
from app.testing.network_guard import block_real_network # noqa: E402,F401
|
||||||
|
|
||||||
|
|
||||||
def _report_session_cleanup_error(name: str, err: Exception) -> None:
|
def _report_session_cleanup_error(session, name: str, err: Exception) -> None:
|
||||||
"""测试收尾清理失败只记录诊断,不覆盖原始 pytest 退出状态。"""
|
"""记录收尾错误;原测试绿色时将会话标记为失败。"""
|
||||||
sys.stderr.write(f"\npytest session cleanup failed: {name}: {err!r}\n")
|
sys.stderr.write(f"\npytest session cleanup failed: {name}: {err!r}\n")
|
||||||
|
if session.exitstatus == 0:
|
||||||
|
session.exitstatus = 1
|
||||||
|
|
||||||
|
|
||||||
def pytest_sessionfinish(session, exitstatus):
|
def pytest_sessionfinish(session, exitstatus):
|
||||||
@@ -28,21 +30,27 @@ def pytest_sessionfinish(session, exitstatus):
|
|||||||
|
|
||||||
shutdown_blocking_executors(cancel_futures=True)
|
shutdown_blocking_executors(cancel_futures=True)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_report_session_cleanup_error("agent blocking executors", err)
|
_report_session_cleanup_error(session, "agent blocking executors", err)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from app.helper.thread import ThreadHelper
|
from app.helper.thread import ThreadHelper
|
||||||
from app.utils.singleton import Singleton
|
|
||||||
|
|
||||||
helper = Singleton._instances.get((ThreadHelper, (), frozenset()))
|
helper = ThreadHelper.get_existing_instance()
|
||||||
if helper:
|
if helper:
|
||||||
helper.shutdown()
|
helper.shutdown()
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_report_session_cleanup_error("thread helper", err)
|
_report_session_cleanup_error(session, "thread helper", err)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.helper.message import stop_message
|
||||||
|
|
||||||
|
stop_message()
|
||||||
|
except Exception as err:
|
||||||
|
_report_session_cleanup_error(session, "message service", err)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from app.log import LoggerManager
|
from app.log import LoggerManager
|
||||||
|
|
||||||
LoggerManager.shutdown()
|
LoggerManager.shutdown()
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_report_session_cleanup_error("logger manager", err)
|
_report_session_cleanup_error(session, "logger manager", err)
|
||||||
|
|||||||
@@ -3,6 +3,61 @@ import socket
|
|||||||
from app.helper import doh
|
from app.helper import doh
|
||||||
|
|
||||||
|
|
||||||
|
def test_doh_executor_is_lazy_and_shutdown_restores_socket(monkeypatch):
|
||||||
|
"""DoH 线程池按需创建,并在模块关闭时恢复系统 DNS"""
|
||||||
|
original_getaddrinfo = socket.getaddrinfo
|
||||||
|
helper = object.__new__(doh.DohHelper)
|
||||||
|
monkeypatch.setattr(doh.settings, "DOH_DOMAINS", "example.com")
|
||||||
|
monkeypatch.setattr(doh.settings, "DOH_RESOLVERS", "resolver.test")
|
||||||
|
monkeypatch.setattr(doh, "_doh_query", lambda resolver, host: "203.0.113.7")
|
||||||
|
monkeypatch.setattr(doh, "_orig_getaddrinfo", lambda host, *args, **kwargs: [])
|
||||||
|
|
||||||
|
try:
|
||||||
|
helper.shutdown()
|
||||||
|
assert doh._executor is None
|
||||||
|
|
||||||
|
doh.enable_doh(True)
|
||||||
|
socket.getaddrinfo("example.com", None)
|
||||||
|
executor = doh._executor
|
||||||
|
assert executor is not None
|
||||||
|
|
||||||
|
helper.shutdown()
|
||||||
|
|
||||||
|
assert doh._executor is None
|
||||||
|
assert socket.getaddrinfo is doh._orig_getaddrinfo
|
||||||
|
assert getattr(executor, "_shutdown", False)
|
||||||
|
finally:
|
||||||
|
helper.shutdown()
|
||||||
|
socket.getaddrinfo = original_getaddrinfo
|
||||||
|
|
||||||
|
|
||||||
|
def test_doh_config_reload_disables_and_closes_executor(monkeypatch):
|
||||||
|
"""热更新关闭 DoH 时恢复系统 DNS 并释放已创建的线程池"""
|
||||||
|
original_getaddrinfo = socket.getaddrinfo
|
||||||
|
helper = object.__new__(doh.DohHelper)
|
||||||
|
monkeypatch.setattr(doh.settings, "DOH_DOMAINS", "example.com")
|
||||||
|
monkeypatch.setattr(doh.settings, "DOH_RESOLVERS", "resolver.test")
|
||||||
|
monkeypatch.setattr(doh, "_doh_query", lambda resolver, host: "203.0.113.7")
|
||||||
|
monkeypatch.setattr(doh, "_orig_getaddrinfo", lambda host, *args, **kwargs: [])
|
||||||
|
|
||||||
|
try:
|
||||||
|
helper.shutdown()
|
||||||
|
doh.enable_doh(True)
|
||||||
|
socket.getaddrinfo("example.com", None)
|
||||||
|
executor = doh._executor
|
||||||
|
assert executor is not None
|
||||||
|
monkeypatch.setattr(doh.settings, "DOH_ENABLE", False)
|
||||||
|
|
||||||
|
helper.on_config_changed()
|
||||||
|
|
||||||
|
assert doh._executor is None
|
||||||
|
assert getattr(executor, "_shutdown", False)
|
||||||
|
assert socket.getaddrinfo is doh._orig_getaddrinfo
|
||||||
|
finally:
|
||||||
|
helper.shutdown()
|
||||||
|
socket.getaddrinfo = original_getaddrinfo
|
||||||
|
|
||||||
|
|
||||||
def test_enable_doh_reuses_cached_host_resolution(monkeypatch):
|
def test_enable_doh_reuses_cached_host_resolution(monkeypatch):
|
||||||
"""
|
"""
|
||||||
同一 DoH 域名第二次解析应命中缓存,避免重复请求远端解析器。
|
同一 DoH 域名第二次解析应命中缓存,避免重复请求远端解析器。
|
||||||
@@ -33,6 +88,7 @@ def test_enable_doh_reuses_cached_host_resolution(monkeypatch):
|
|||||||
socket.getaddrinfo("example.com", None)
|
socket.getaddrinfo("example.com", None)
|
||||||
socket.getaddrinfo("example.com", None)
|
socket.getaddrinfo("example.com", None)
|
||||||
finally:
|
finally:
|
||||||
|
object.__new__(doh.DohHelper).shutdown()
|
||||||
socket.getaddrinfo = original_getaddrinfo
|
socket.getaddrinfo = original_getaddrinfo
|
||||||
with doh._doh_lock:
|
with doh._doh_lock:
|
||||||
doh._doh_cache.clear()
|
doh._doh_cache.clear()
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ class EmbyDashboardLinksTest(unittest.TestCase):
|
|||||||
with (
|
with (
|
||||||
patch.object(client, "_Emby__get_emby_librarys") as librarys,
|
patch.object(client, "_Emby__get_emby_librarys") as librarys,
|
||||||
patch.object(client, "_Emby__get_local_image_by_id") as image_by_id,
|
patch.object(client, "_Emby__get_local_image_by_id") as image_by_id,
|
||||||
|
patch.object(client, "get_items_count", return_value=0),
|
||||||
):
|
):
|
||||||
librarys.return_value = [
|
librarys.return_value = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from app.startup import lifecycle
|
||||||
|
|
||||||
|
|
||||||
|
def test_lifespan_closes_logger_when_early_shutdown_step_fails(monkeypatch):
|
||||||
|
"""前置关闭步骤失败时仍应关闭 Logger"""
|
||||||
|
monkeypatch.setattr(lifecycle.settings, "MOVIEPILOT_SAFE_MODE", False)
|
||||||
|
monkeypatch.setattr(lifecycle.global_vars, "set_loop", MagicMock())
|
||||||
|
for name in (
|
||||||
|
"init_routers",
|
||||||
|
"init_modules",
|
||||||
|
"init_plugins",
|
||||||
|
"init_scheduler",
|
||||||
|
"init_monitor",
|
||||||
|
"init_command",
|
||||||
|
"init_workflow",
|
||||||
|
"stop_workflow",
|
||||||
|
"stop_command",
|
||||||
|
"stop_monitor",
|
||||||
|
"stop_scheduler",
|
||||||
|
"stop_plugins",
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(lifecycle, name, MagicMock())
|
||||||
|
|
||||||
|
system_chain = MagicMock()
|
||||||
|
system_chain.backup_plugins.side_effect = RuntimeError("backup failed")
|
||||||
|
monkeypatch.setattr(lifecycle, "SystemChain", MagicMock(return_value=system_chain))
|
||||||
|
monkeypatch.setattr(lifecycle, "init_extra", AsyncMock())
|
||||||
|
monkeypatch.setattr(lifecycle, "stop_modules", AsyncMock())
|
||||||
|
monkeypatch.setattr(lifecycle, "aclose_shared_async_transports", AsyncMock())
|
||||||
|
logger_shutdown = MagicMock()
|
||||||
|
monkeypatch.setattr(lifecycle.LoggerManager, "shutdown", logger_shutdown)
|
||||||
|
|
||||||
|
async def run_lifespan():
|
||||||
|
with pytest.raises(RuntimeError, match="backup failed"):
|
||||||
|
async with lifecycle.lifespan(FastAPI()):
|
||||||
|
pass
|
||||||
|
|
||||||
|
asyncio.run(run_lifespan())
|
||||||
|
|
||||||
|
logger_shutdown.assert_called_once_with()
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from app.log import LogEntry, NonBlockingFileHandler, log_settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_blocking_file_handler_shutdown_wakes_writer_and_closes_handlers(tmp_path):
|
||||||
|
"""日志关闭应立即唤醒空闲写线程,并关闭所有已打开的文件处理器"""
|
||||||
|
original_instance = NonBlockingFileHandler._instance
|
||||||
|
NonBlockingFileHandler._instance = None
|
||||||
|
handler = NonBlockingFileHandler()
|
||||||
|
handler._rotating_handlers = {}
|
||||||
|
log_handler = handler._get_rotating_handler(tmp_path / "shutdown.log")
|
||||||
|
|
||||||
|
try:
|
||||||
|
started_at = time.monotonic()
|
||||||
|
handler.shutdown()
|
||||||
|
elapsed = time.monotonic() - started_at
|
||||||
|
|
||||||
|
assert elapsed < 1
|
||||||
|
assert not handler._write_thread.is_alive()
|
||||||
|
assert log_handler.stream is None
|
||||||
|
assert handler._write_non_blocking(
|
||||||
|
LogEntry("info", "late-message", tmp_path / "shutdown.log")
|
||||||
|
) is False
|
||||||
|
assert handler._write_queue.empty()
|
||||||
|
finally:
|
||||||
|
if handler._write_thread.is_alive():
|
||||||
|
handler._running = False
|
||||||
|
handler._write_thread.join(timeout=5)
|
||||||
|
if log_handler.stream is not None:
|
||||||
|
log_handler.close()
|
||||||
|
NonBlockingFileHandler._instance = original_instance
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_blocking_file_handler_shutdown_drains_queued_batches(monkeypatch, tmp_path):
|
||||||
|
"""停止标记之前已进入队列的日志应跨批次全部写完"""
|
||||||
|
original_instance = NonBlockingFileHandler._instance
|
||||||
|
NonBlockingFileHandler._instance = None
|
||||||
|
monkeypatch.setattr(log_settings, "BATCH_WRITE_SIZE", 2)
|
||||||
|
handler = NonBlockingFileHandler()
|
||||||
|
handler._rotating_handlers = {}
|
||||||
|
written = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
handler,
|
||||||
|
"_write_batch",
|
||||||
|
lambda batch: written.extend(entry.message for entry in batch),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for index in range(5):
|
||||||
|
handler._write_non_blocking(
|
||||||
|
LogEntry("info", f"message-{index}", tmp_path / "drain.log")
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.shutdown()
|
||||||
|
|
||||||
|
assert written == [f"message-{index}" for index in range(5)]
|
||||||
|
assert not handler._write_thread.is_alive()
|
||||||
|
finally:
|
||||||
|
if handler._write_thread.is_alive():
|
||||||
|
handler._running = False
|
||||||
|
handler._write_queue.put(handler._stop_sentinel)
|
||||||
|
handler._write_thread.join(timeout=5)
|
||||||
|
NonBlockingFileHandler._instance = original_instance
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_blocking_file_handler_creates_one_handler_for_concurrent_first_write(monkeypatch, tmp_path):
|
||||||
|
"""同一路径首次并发写入时只创建并关闭一个文件处理器"""
|
||||||
|
original_instance = NonBlockingFileHandler._instance
|
||||||
|
NonBlockingFileHandler._instance = None
|
||||||
|
handler = NonBlockingFileHandler()
|
||||||
|
handler._rotating_handlers = {}
|
||||||
|
first_created = threading.Event()
|
||||||
|
second_started = threading.Event()
|
||||||
|
release_first = threading.Event()
|
||||||
|
created_handlers = []
|
||||||
|
results = []
|
||||||
|
|
||||||
|
class ProbeHandler:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.closed = False
|
||||||
|
created_handlers.append(self)
|
||||||
|
if len(created_handlers) == 1:
|
||||||
|
first_created.set()
|
||||||
|
release_first.wait(timeout=2)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def setFormatter(formatter):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def flush():
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.log.RotatingFileHandler", ProbeHandler)
|
||||||
|
file_path = tmp_path / "concurrent.log"
|
||||||
|
|
||||||
|
def get_handler(started=None):
|
||||||
|
if started:
|
||||||
|
started.set()
|
||||||
|
results.append(handler._get_rotating_handler(file_path))
|
||||||
|
|
||||||
|
first = threading.Thread(target=get_handler)
|
||||||
|
second = threading.Thread(target=get_handler, args=(second_started,))
|
||||||
|
try:
|
||||||
|
first.start()
|
||||||
|
assert first_created.wait(timeout=1)
|
||||||
|
second.start()
|
||||||
|
assert second_started.wait(timeout=1)
|
||||||
|
time.sleep(0.05)
|
||||||
|
release_first.set()
|
||||||
|
first.join(timeout=2)
|
||||||
|
second.join(timeout=2)
|
||||||
|
|
||||||
|
assert len(created_handlers) == 1
|
||||||
|
assert results[0] is results[1]
|
||||||
|
|
||||||
|
handler.shutdown()
|
||||||
|
assert created_handlers[0].closed is True
|
||||||
|
finally:
|
||||||
|
release_first.set()
|
||||||
|
first.join(timeout=2)
|
||||||
|
second.join(timeout=2)
|
||||||
|
handler.shutdown()
|
||||||
|
NonBlockingFileHandler._instance = original_instance
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_blocking_file_handler_uses_handler_lock(monkeypatch, tmp_path):
|
||||||
|
"""日志写入通过 Handler 入口串行化 emit 与 rollover"""
|
||||||
|
original_instance = NonBlockingFileHandler._instance
|
||||||
|
NonBlockingFileHandler._instance = None
|
||||||
|
handler = NonBlockingFileHandler()
|
||||||
|
handler._rotating_handlers = {}
|
||||||
|
log_handler = MagicMock()
|
||||||
|
monkeypatch.setattr(handler, "_get_rotating_handler", MagicMock(return_value=log_handler))
|
||||||
|
|
||||||
|
try:
|
||||||
|
handler._write_sync(LogEntry("info", "message", tmp_path / "locked.log"))
|
||||||
|
|
||||||
|
log_handler.handle.assert_called_once()
|
||||||
|
log_handler.emit.assert_not_called()
|
||||||
|
finally:
|
||||||
|
handler.shutdown()
|
||||||
|
NonBlockingFileHandler._instance = original_instance
|
||||||
@@ -20,6 +20,16 @@ def clear_media_interactions():
|
|||||||
plugin_input_interaction_manager.clear()
|
plugin_input_interaction_manager.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_default_media_search():
|
||||||
|
"""未显式验证搜索结果的消息路由用例不访问真实媒体元数据服务"""
|
||||||
|
with patch(
|
||||||
|
"app.chain.media.MediaChain.search",
|
||||||
|
side_effect=lambda title: (_build_meta(title), []),
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
def _build_meta(name: str) -> MetaBase:
|
def _build_meta(name: str) -> MetaBase:
|
||||||
"""构造媒体识别元数据。"""
|
"""构造媒体识别元数据。"""
|
||||||
meta = MetaBase(name)
|
meta = MetaBase(name)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
from app.helper.message import MessageQueueManager, TemplateHelper, stop_message
|
||||||
|
from app.utils.singleton import SingletonClass
|
||||||
|
|
||||||
|
|
||||||
|
def test_message_queue_stop_wakes_idle_monitor(monkeypatch):
|
||||||
|
"""消息队列停止时应唤醒空闲监控线程,不等待完整检查周期"""
|
||||||
|
monkeypatch.setattr(MessageQueueManager, "init_config", lambda self: None)
|
||||||
|
manager = object.__new__(MessageQueueManager)
|
||||||
|
manager.__init__(check_interval=10)
|
||||||
|
|
||||||
|
started_at = time.monotonic()
|
||||||
|
manager.stop()
|
||||||
|
elapsed = time.monotonic() - started_at
|
||||||
|
|
||||||
|
assert elapsed < 1
|
||||||
|
assert not manager.thread.is_alive()
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_message_does_not_initialize_absent_services(monkeypatch):
|
||||||
|
"""消息服务未初始化时,关闭入口不应为了清理而创建后台资源"""
|
||||||
|
monkeypatch.setattr(SingletonClass, "_instances", {})
|
||||||
|
|
||||||
|
assert MessageQueueManager.get_existing_instance() is None
|
||||||
|
assert TemplateHelper.get_existing_instance() is None
|
||||||
|
stop_message()
|
||||||
|
|
||||||
|
assert MessageQueueManager not in SingletonClass._instances
|
||||||
|
assert TemplateHelper not in SingletonClass._instances
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import socket
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.testing.network_guard import block_real_network
|
||||||
|
|
||||||
|
|
||||||
|
def test_network_guard_fails_when_blocked_attempt_is_swallowed(monkeypatch):
|
||||||
|
"""业务代码即使捕获网络异常,网络守卫仍应在用例收尾报告失败"""
|
||||||
|
fixture = block_real_network.__wrapped__(monkeypatch)
|
||||||
|
next(fixture)
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
socket.getaddrinfo("external.example", 443)
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
with pytest.raises(pytest.fail.Exception, match="external.example"):
|
||||||
|
next(fixture)
|
||||||
|
finally:
|
||||||
|
monkeypatch.undo()
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from app.utils.singleton import Singleton, SingletonClass
|
||||||
|
|
||||||
|
|
||||||
|
def test_singleton_class_can_read_existing_instance_without_creating(monkeypatch):
|
||||||
|
"""按类单例可以只读取已存在实例"""
|
||||||
|
|
||||||
|
class Example(metaclass=SingletonClass):
|
||||||
|
pass
|
||||||
|
|
||||||
|
monkeypatch.setattr(SingletonClass, "_instances", {})
|
||||||
|
|
||||||
|
assert Example.get_existing_instance() is None
|
||||||
|
instance = Example()
|
||||||
|
assert Example.get_existing_instance() is instance
|
||||||
|
|
||||||
|
|
||||||
|
def test_parameterized_singleton_can_read_matching_instance_without_creating(monkeypatch):
|
||||||
|
"""参数化单例按相同参数读取已存在实例"""
|
||||||
|
|
||||||
|
class Example(metaclass=Singleton):
|
||||||
|
def __init__(self, name):
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
monkeypatch.setattr(Singleton, "_instances", {})
|
||||||
|
|
||||||
|
assert Example.get_existing_instance("first") is None
|
||||||
|
instance = Example("first")
|
||||||
|
assert Example.get_existing_instance("first") is instance
|
||||||
|
assert Example.get_existing_instance("second") is None
|
||||||
@@ -362,7 +362,7 @@ class SubscribeEndpointTest(TestCase):
|
|||||||
with patch(
|
with patch(
|
||||||
"app.api.endpoints.subscribe.Subscribe.async_list_by_username",
|
"app.api.endpoints.subscribe.Subscribe.async_list_by_username",
|
||||||
new=AsyncMock(return_value=owned),
|
new=AsyncMock(return_value=owned),
|
||||||
):
|
), patch("app.api.endpoints.subscribe.Scheduler") as scheduler_cls:
|
||||||
response = asyncio.run(
|
response = asyncio.run(
|
||||||
search_subscribes(
|
search_subscribes(
|
||||||
background_tasks=background_tasks,
|
background_tasks=background_tasks,
|
||||||
@@ -376,6 +376,7 @@ class SubscribeEndpointTest(TestCase):
|
|||||||
[task["kwargs"]["sid"] for task in background_tasks.tasks],
|
[task["kwargs"]["sid"] for task in background_tasks.tasks],
|
||||||
[17, 18],
|
[17, 18],
|
||||||
)
|
)
|
||||||
|
self.assertEqual(scheduler_cls.return_value.start.call_count, 0)
|
||||||
|
|
||||||
def test_subscribe_files_hides_other_user_row(self):
|
def test_subscribe_files_hides_other_user_row(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
+23
-6
@@ -3,6 +3,7 @@
|
|||||||
Telegram 模块单元测试(pytest 原生)。
|
Telegram 模块单元测试(pytest 原生)。
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import warnings
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock, Mock, patch
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
@@ -286,13 +287,29 @@ def test_send_msg_markdown_escaping(telegram):
|
|||||||
assert send_kwargs["text"].startswith("*测试标题*\n")
|
assert send_kwargs["text"].startswith("*测试标题*\n")
|
||||||
|
|
||||||
|
|
||||||
def test_telegramify_new_content_fields_are_used_directly():
|
def test_telegramify_current_fields_are_used_directly():
|
||||||
"""新版telegramify对象应直接使用已渲染的MarkdownV2字段"""
|
"""telegramify 对象直接使用当前 MarkdownV2 字段"""
|
||||||
text_item = SimpleNamespace(content="已转义\\_文本")
|
from telegramify_markdown.content import ContentTrace, File, Text
|
||||||
file_item = SimpleNamespace(caption="已转义\\_说明")
|
|
||||||
|
|
||||||
assert Telegram._telegramify_item_text(text_item) == "已转义\\_文本"
|
text_item = Text(
|
||||||
assert Telegram._telegramify_item_caption(file_item) == "已转义\\_说明"
|
text="已转义_文本",
|
||||||
|
entities=[],
|
||||||
|
content_trace=ContentTrace(source_type="test"),
|
||||||
|
)
|
||||||
|
file_item = File(
|
||||||
|
file_name="test.txt",
|
||||||
|
file_data=b"test",
|
||||||
|
caption_text="已转义_说明",
|
||||||
|
caption_entities=[],
|
||||||
|
content_trace=ContentTrace(source_type="test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with warnings.catch_warnings(record=True) as warning_records:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
assert Telegram._telegramify_item_text(text_item) == "已转义\\_文本"
|
||||||
|
assert Telegram._telegramify_item_caption(file_item) == "已转义\\_说明"
|
||||||
|
|
||||||
|
assert not warning_records
|
||||||
|
|
||||||
|
|
||||||
def test_send_msg_with_html_parse_mode_keeps_html(telegram):
|
def test_send_msg_with_html_parse_mode_keeps_html(telegram):
|
||||||
|
|||||||
@@ -183,7 +183,8 @@ def test_build_web_agent_command_items_returns_slash_commands():
|
|||||||
|
|
||||||
def test_build_web_agent_command_items_includes_sites_command():
|
def test_build_web_agent_command_items_includes_sites_command():
|
||||||
"""WebAgent 命令建议应包含内建站点管理命令。"""
|
"""WebAgent 命令建议应包含内建站点管理命令。"""
|
||||||
commands = _build_web_agent_command_items()
|
with patch("app.command.Scheduler"), patch("app.command.ThreadHelper"):
|
||||||
|
commands = _build_web_agent_command_items()
|
||||||
|
|
||||||
assert any(command["command"] == "/sites" for command in commands)
|
assert any(command["command"] == "/sites" for command in commands)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user