Files
MoviePilot/tests/test_log_exception.py
MoviePilot Contributor e550ec6dd8 fix(log): 文件日志保留异常堆栈 (#7286)
未捕获异常经 localized_unhandled_exception_handler 写入文件日志时只保留正文,异常三元组未进入文件写入链路,导致 moviepilot.log 丢失异常类型与堆栈。现将 exc_info 在调用方线程规范化为三元组并随 LogEntry 携带到文件格式化器。
2026-11-04 02:23:07 +08:00

154 lines
6.1 KiB
Python

"""
异常日志入口应保留错误等级、调用位置和备份接口的安全失败响应。
"""
import logging
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from fastapi import HTTPException
from app.api.endpoints import system as system_endpoint
from app.runtime import log as log_module
from app.runtime.log import LoggerManager, logger
@pytest.fixture
def captured_logs(monkeypatch, caplog):
"""
捕获真实标准库日志记录与文件消息,避免写入运行目录。
"""
console = logging.Logger("exception-contract", level=logging.DEBUG)
console.addHandler(caplog.handler)
writer = Mock()
monkeypatch.setattr(LoggerManager, "_write_file_log", writer)
monkeypatch.setattr(LoggerManager, "_get_console_logger", classmethod(lambda _cls, _path: console))
monkeypatch.setattr(log_module, "_effective_context_log_level", lambda: logging.ERROR)
return caplog, writer
@pytest.mark.parametrize("include_exception", [None, False, True])
def test_exception_logs_error_with_current_exception(captured_logs, include_exception):
"""
默认记录当前异常,也支持调用方显式关闭或启用堆栈。
"""
caplog, writer = captured_logs
original = ValueError("original failure")
try:
raise original
except ValueError:
options = {} if include_exception is None else {"exc_info": include_exception}
logger.exception("operation %s failed", "backup", **options)
record = caplog.records[-1]
assert record.levelno == logging.ERROR
assert record.getMessage() == "test_log_exception.py - operation backup failed"
if include_exception is False:
assert record.exc_info is False
else:
assert record.exc_info[1] is original
assert "ValueError: original failure" in caplog.text
assert writer.call_args.args == ("ERROR", record.getMessage(), LoggerManager._default_log_file)
def test_exception_accepts_explicit_exception(captured_logs):
"""
离开异常处理分支后仍能显式指定需要记录的原异常。
"""
caplog, _writer = captured_logs
failure = RuntimeError("explicit failure")
logger.exception("saved exception", exc_info=failure)
assert caplog.records[-1].exc_info[1] is failure
def test_exception_file_log_carries_exc_info(captured_logs):
"""
文件日志写入链路应携带规范化异常三元组,供格式化器输出堆栈。
"""
caplog, writer = captured_logs
failure = RuntimeError("file traceback failure")
try:
raise failure
except RuntimeError:
logger.error("request failed", exc_info=True)
assert caplog.records[-1].exc_info[1] is failure
assert writer.call_args.kwargs["exc_info"][1] is failure
def test_file_record_formatter_includes_traceback():
"""
文件格式化器应把日志条目携带的异常三元组输出为可读堆栈。
"""
failure = ValueError("formatted file failure")
entry = log_module.LogEntry(
"ERROR",
"API 请求发生未捕获异常: formatted file failure",
LoggerManager._default_log_file,
exc_info=(type(failure), failure, failure.__traceback__),
)
record = log_module.NonBlockingFileHandler._to_record(entry)
formatter = logging.Formatter(log_module.LogSettings().LOG_FILE_FORMAT)
formatted = formatter.format(record)
assert "ValueError: formatted file failure" in formatted
def test_exception_respects_current_log_threshold(captured_logs, monkeypatch):
"""
高于 ERROR 的日志阈值继续拦截异常消息。
"""
caplog, writer = captured_logs
monkeypatch.setattr(log_module, "_effective_context_log_level", lambda: logging.CRITICAL)
logger.exception("filtered failure")
assert not caplog.records
writer.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("handler,operation,args", [
(system_endpoint.list_database_backups, "list_backups", ()),
(system_endpoint.create_database_backup, "create_backup", ()),
(system_endpoint.verify_database_backup, "verify_backup", ("backup.db",)),
(system_endpoint.delete_database_backup, "delete_backup", ("backup.db",)),
])
async def test_database_failure_preserves_safe_http_error(captured_logs, monkeypatch, handler, operation, args):
"""
备份服务失败时记录原异常,返回预定 500 且不泄露内部路径。
"""
caplog, _writer = captured_logs
failure = RuntimeError("private database path /sensitive/database.db")
governance = SimpleNamespace(**{operation: Mock(side_effect=failure)})
monkeypatch.setattr(system_endpoint, "get_database_governance", lambda: governance)
with pytest.raises(HTTPException) as caught:
await handler(*args, _=object())
assert caught.value.status_code == 500
assert caught.value.detail.endswith(",请查看日志")
assert "/sensitive/" not in caught.value.detail
assert caught.value.__cause__ is failure
assert caplog.records[-1].levelno == logging.ERROR
assert caplog.records[-1].exc_info[1] is failure
def test_critical_logs_keep_level_and_file_route(captured_logs):
"""
严重错误入口应将相同内容以 CRITICAL 等级交给控制台和文件。
"""
caplog, writer = captured_logs
logger.critical("critical failure")
record = caplog.records[-1]
assert record.levelno == logging.CRITICAL
assert record.getMessage().endswith("critical failure")
assert writer.call_args.args == ("CRITICAL", record.getMessage(), LoggerManager._default_log_file)
@pytest.mark.parametrize("category,resource_id", [("../invalid", 1), ("plugin", 0)])
def test_invalid_resource_log_scope_preserves_context(category, resource_id):
"""
资源标识非法时拒绝绑定,避免污染原有日志上下文。
"""
with log_module.bind_log_scope("plugin", 1, "operation", "test") as scope:
with pytest.raises(ValueError, match="日志资源标识无效"):
with log_module.bind_log_scope(category, resource_id, "bad", "test"):
pytest.fail("invalid resource scope must not be entered")
with log_module.bind_log_scope("plugin", 1, "nested", "test") as nested:
assert nested is scope