mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-16 05:10:53 +08:00
未捕获异常经 localized_unhandled_exception_handler 写入文件日志时只保留正文,异常三元组未进入文件写入链路,导致 moviepilot.log 丢失异常类型与堆栈。现将 exc_info 在调用方线程规范化为三元组并随 LogEntry 携带到文件格式化器。
154 lines
6.1 KiB
Python
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
|