mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
123 lines
5.0 KiB
Python
123 lines
5.0 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_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
|