Files
MoviePilot/tests/test_doctor.py
2026-08-25 16:05:45 +08:00

368 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from datetime import datetime, timedelta
import sqlite3
from types import SimpleNamespace
from app.runtime.config import settings
from app.doctor import checks
from app.doctor.formatters import format_json_report, format_text_report
from app.doctor.models import DoctorFinding, DoctorFindingStatus, DoctorSeverity
from app.doctor.runner import DoctorRunner, run_doctor
def _current_log_timestamp() -> str:
"""返回 Doctor 近期日志测试使用的当前时间戳。"""
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def test_doctor_report_has_stable_json_shape(tmp_path, monkeypatch):
"""doctor JSON 报告应包含稳定状态、环境、汇总和发现列表。"""
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
(settings.LOG_PATH).mkdir(parents=True, exist_ok=True)
(settings.ROOT_PATH / "public").mkdir(exist_ok=True)
report = run_doctor()
payload = report.to_dict()
assert payload["schema_version"] == 1
assert payload["status"] in {"healthy", "degraded", "failed"}
assert payload["environment"]["config_path"] == str(tmp_path)
assert isinstance(payload["summary"]["total"], int)
assert isinstance(payload["findings"], list)
assert all("affects_report_status" in item for item in payload["findings"])
assert any(item["id"] == "runtime.paths" for item in payload["findings"])
def test_doctor_reports_valid_backup_when_sqlite_database_is_corrupt(
tmp_path,
monkeypatch,
):
"""主数据库损坏时 Doctor 仍应离线校验备份并给出还原命令。"""
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
(tmp_path / "user.db").write_bytes(b"not a sqlite database")
backup_dir = settings.DATABASE_BACKUP_PATH
backup_dir.mkdir(parents=True)
backup = backup_dir / "moviepilot_v3.0.0_sqlite_20260822_030000.db"
with sqlite3.connect(backup) as connection:
connection.execute("CREATE TABLE entries (value TEXT NOT NULL)")
(backup_dir / "moviepilot_v3.0.0_sqlite_20260822_040000.db").write_bytes(b"invalid newer backup")
runner = DoctorRunner()
checks._check_database(runner)
assert runner.report.find("database.sqlite_open_failed") is not None
finding = runner.report.find("database.backup_recovery")
assert finding is not None
assert finding.status == DoctorFindingStatus.Ok
assert finding.context["backups"][0]["valid"] is False
assert finding.context["backups"][1]["valid"] is True
assert finding.context["restore_command"] == (
"moviepilot database restore moviepilot_v3.0.0_sqlite_20260822_030000.db --confirm"
)
assert finding.context["restore_command"] in finding.recommendation
def test_doctor_distinguishes_missing_and_mismatched_backups(tmp_path, monkeypatch):
"""无备份与仅存在其他数据库类型备份应生成不同诊断结论。"""
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
runner = DoctorRunner()
checks._check_database_backups(runner)
missing = runner.report.find("database.backup_recovery")
assert missing is not None
assert missing.status == DoctorFindingStatus.Skipped
backup_dir = settings.DATABASE_BACKUP_PATH
backup_dir.mkdir(parents=True)
(backup_dir / "moviepilot_v3.0.0_postgresql_20260822_030000.dump").write_bytes(b"PGDMP")
runner = DoctorRunner()
checks._check_database_backups(runner)
mismatched = runner.report.find("database.backup_recovery")
assert mismatched is not None
assert mismatched.status == DoctorFindingStatus.Degraded
assert mismatched.context["mismatched"] == ["moviepilot_v3.0.0_postgresql_20260822_030000.dump"]
def test_doctor_reports_invalid_backup_without_modifying_it(tmp_path, monkeypatch):
"""Doctor --fix 也只校验备份,不覆盖或删除无效文件。"""
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
backup_dir = settings.DATABASE_BACKUP_PATH
backup_dir.mkdir(parents=True)
backup = backup_dir / "moviepilot_v3.0.0_sqlite_20260822_030000.db"
original = b"invalid sqlite backup"
backup.write_bytes(original)
runner = DoctorRunner(fix=True)
checks._check_database_backups(runner)
finding = runner.report.find("database.backup_recovery")
assert finding is not None
assert finding.status == DoctorFindingStatus.Failed
assert finding.affects_report_status is False
assert finding.context["backups"][0]["valid"] is False
assert backup.read_bytes() == original
assert runner.report.status.value == "healthy"
def test_doctor_exposes_missing_pg_restore_in_text_finding(tmp_path, monkeypatch):
"""PostgreSQL 离线校验工具缺失时应直接告诉用户如何补齐。"""
def missing_pg_restore(*_args, **_kwargs):
raise RuntimeError("未找到 pg_restore请安装 PostgreSQL client 并加入 PATH")
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
monkeypatch.setattr(settings, "DB_TYPE", "postgresql")
backup_dir = settings.DATABASE_BACKUP_PATH
backup_dir.mkdir(parents=True)
(backup_dir / "moviepilot_v3.0.0_postgresql_20260822_030000.dump").write_bytes(b"PGDMP")
monkeypatch.setattr(checks, "verify_database_backup", missing_pg_restore)
runner = DoctorRunner()
checks._check_database_backups(runner)
finding = runner.report.find("database.backup_recovery")
assert finding is not None
assert finding.status == DoctorFindingStatus.Failed
assert "未找到 pg_restore" in finding.detail
assert "PostgreSQL client" in format_text_report(runner.report)
def test_doctor_formatters_include_status_and_finding(tmp_path, monkeypatch):
"""doctor 文本和 JSON 格式化应展示状态与诊断项。"""
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
report = run_doctor()
report.add_finding(
DoctorFinding(
id="test.demo",
severity=DoctorSeverity.Warn,
status=DoctorFindingStatus.Degraded,
title="测试诊断项",
detail="测试原因",
recommendation="测试建议",
)
)
text = format_text_report(report)
json_text = format_json_report(report)
assert "MoviePilot Doctor" in text
assert "测试诊断项" in text
assert '"schema_version": 1' in json_text
assert '"test.demo"' in json_text
def test_doctor_fix_removes_stale_runtime(tmp_path, monkeypatch):
"""doctor --fix 应清理指向失效进程的 runtime 文件。"""
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
settings.TEMP_PATH.mkdir(parents=True, exist_ok=True)
runtime_file = settings.TEMP_PATH / "moviepilot.runtime.json"
runtime_file.write_text('{"pid": 999999, "create_time": 1}', encoding="utf-8")
report = run_doctor(fix=True)
assert not runtime_file.exists()
finding = report.find("runtime.backend_stale")
assert finding is not None
assert finding.fixed
def test_doctor_accepts_healthy_unmanaged_backend_port(monkeypatch):
"""doctor 在容器中应把健康的非 CLI 管理后端端口识别为正常。"""
occupant = SimpleNamespace(pid=12345)
monkeypatch.setattr(checks, "_port_occupants", lambda port: [occupant])
monkeypatch.setattr(checks, "_process_description", lambda process: f"PID {process.pid} (python)")
monkeypatch.setattr(checks, "_is_expected_port_process", lambda name, process: False)
monkeypatch.setattr(
checks,
"_backend_health_payload",
lambda port: {"success": True, "data": {"BACKEND_VERSION": "v2-test"}},
)
runner = DoctorRunner()
checks._check_port(runner, name="backend", port=3001, managed_process=None)
finding = runner.report.find("port.backend_listening_unmanaged")
assert finding is not None
assert finding.status == DoctorFindingStatus.Ok
assert finding.severity == DoctorSeverity.Info
assert finding.context["backend_version"] == "v2-test"
assert runner.report.find("port.backend_occupied") is None
def test_doctor_plugin_log_error_does_not_degrade_report(tmp_path, monkeypatch):
"""插件独立日志中的错误应保留告警,但不降低系统整体状态。"""
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
plugin_log = settings.LOG_PATH / "plugins" / "demo.log"
plugin_log.parent.mkdir(parents=True, exist_ok=True)
plugin_log.write_text(
f"【ERROR】{_current_log_timestamp()} - demo.py - 插件任务执行异常\n",
encoding="utf-8",
)
runner = DoctorRunner()
checks._check_logs(runner)
finding = runner.report.find("logs.demo.recent_errors")
assert finding is not None
assert finding.status == DoctorFindingStatus.Degraded
assert finding.severity == DoctorSeverity.Warn
assert finding.affects_report_status is False
assert finding.context["component"] == "plugin"
assert runner.report.status.value == "healthy"
assert "[WARN/ADVISORY]" in format_text_report(runner.report)
def test_doctor_plugin_load_error_in_main_log_does_not_degrade_report(
tmp_path,
monkeypatch,
):
"""主日志中的插件加载异常应归入插件告警,不降低系统整体状态。"""
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
app_log = settings.LOG_PATH / "moviepilot.log"
app_log.parent.mkdir(parents=True, exist_ok=True)
app_log.write_text(
f"【ERROR】{_current_log_timestamp()} - plugin.py - 加载插件 Demo 出错boom - Traceback (most recent call last):\n"
"Exception: boom\n",
encoding="utf-8",
)
runner = DoctorRunner()
checks._check_logs(runner)
finding = runner.report.find("logs.moviepilot.recent_errors")
assert finding is not None
assert finding.affects_report_status is False
assert finding.context["component"] == "plugin"
assert "Exception: boom" in finding.detail
assert runner.report.status.value == "healthy"
def test_doctor_plugin_error_mirrored_to_stdio_does_not_degrade_report(
tmp_path,
monkeypatch,
):
"""插件错误镜像到后端 stdio 日志时仍不应降低系统整体状态。"""
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
plugin_log = settings.LOG_PATH / "plugins" / "DemoPlugin.log"
plugin_log.parent.mkdir(parents=True, exist_ok=True)
plugin_log.write_text(
f"【INFO】{_current_log_timestamp()} - demo.py - 插件已启动\n",
encoding="utf-8",
)
stdio_log = settings.LOG_PATH / "moviepilot.stdout.log"
stdio_log.write_text(
f"ERROR: [demoplugin] {_current_log_timestamp()} demo.py - task exception\n",
encoding="utf-8",
)
runner = DoctorRunner()
checks._check_logs(runner)
finding = runner.report.find("logs.moviepilot.stdout.recent_errors")
assert finding is not None
assert finding.affects_report_status is False
assert finding.context["component"] == "plugin"
assert runner.report.status.value == "healthy"
def test_doctor_core_log_error_still_degrades_report(tmp_path, monkeypatch):
"""核心日志错误仍应参与系统整体状态聚合。"""
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
app_log = settings.LOG_PATH / "moviepilot.log"
app_log.parent.mkdir(parents=True, exist_ok=True)
app_log.write_text(
f"【ERROR】{_current_log_timestamp()} - rss.py - 解析 RSS 失败 - Traceback (most recent call last):\n"
"RuntimeError: boom\n",
encoding="utf-8",
)
runner = DoctorRunner()
checks._check_logs(runner)
finding = runner.report.find("logs.moviepilot.recent_errors")
assert finding is not None
assert finding.affects_report_status is True
assert finding.context["component"] == "core"
assert runner.report.status.value == "degraded"
def test_doctor_mixed_plugin_and_core_log_errors_keep_core_status(
tmp_path,
monkeypatch,
):
"""同一主日志混有插件和核心错误时,仅核心错误应影响整体状态。"""
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
app_log = settings.LOG_PATH / "moviepilot.log"
app_log.parent.mkdir(parents=True, exist_ok=True)
app_log.write_text(
f"【ERROR】{_current_log_timestamp()} - plugin.py - 加载插件 Demo 出错boom - Traceback (most recent call last):\n"
"Exception: plugin boom\n"
f"【ERROR】{_current_log_timestamp()} - rss.py - 解析 RSS 失败 - Traceback (most recent call last):\n"
"Exception: core boom\n",
encoding="utf-8",
)
runner = DoctorRunner()
checks._check_logs(runner)
core_finding = runner.report.find("logs.moviepilot.recent_errors")
plugin_finding = runner.report.find("logs.moviepilot.plugin_errors")
assert core_finding is not None
assert plugin_finding is not None
assert core_finding.affects_report_status is True
assert plugin_finding.affects_report_status is False
assert "core boom" in core_finding.detail
assert "plugin boom" in plugin_finding.detail
assert runner.report.status.value == "degraded"
def test_doctor_deduplicates_mirrored_plugin_errors(tmp_path, monkeypatch):
"""同一插件错误出现在主日志和插件日志时应只生成一条聚合告警。"""
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
app_log = settings.LOG_PATH / "moviepilot.log"
plugin_log = settings.LOG_PATH / "plugins" / "demo.log"
plugin_log.parent.mkdir(parents=True, exist_ok=True)
app_log.write_text(
f"【ERROR】{timestamp} - plugin.py - 插件任务执行异常\n",
encoding="utf-8",
)
plugin_log.write_text(
f"【ERROR】{timestamp} - demo.py - 插件任务执行异常\n",
encoding="utf-8",
)
runner = DoctorRunner()
checks._check_logs(runner)
plugin_findings = [
finding
for finding in runner.report.findings
if finding.title == "最近日志存在插件异常"
]
assert len(plugin_findings) == 1
finding = plugin_findings[0]
assert finding.context["matches"] == 2
assert finding.context["unique_matches"] == 1
assert len(finding.context["log_files"]) == 2
assert runner.report.summary["advisory"] == 1
assert runner.report.find("logs.recent") is not None
def test_doctor_ignores_errors_outside_log_window(tmp_path, monkeypatch):
"""超出日志诊断时间窗的历史错误不应污染当前 Doctor 结果。"""
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
timestamp = (datetime.now() - timedelta(hours=25)).strftime("%Y-%m-%d %H:%M:%S")
app_log = settings.LOG_PATH / "moviepilot.log"
app_log.parent.mkdir(parents=True, exist_ok=True)
app_log.write_text(
f"【ERROR】{timestamp} - rss.py - 历史解析错误\n",
encoding="utf-8",
)
runner = DoctorRunner()
checks._check_logs(runner)
assert runner.report.find("logs.moviepilot.recent_errors") is None
assert runner.report.find("logs.recent") is not None
assert runner.report.status.value == "healthy"