Files
MoviePilot/tests/test_observability.py
T

230 lines
7.3 KiB
Python

"""低基数指标合同、no-op 与 HTTP adapter 测试。"""
from dataclasses import dataclass, field
import inspect
from typing import Mapping
import httpx
import pytest
from sqlalchemy import create_engine
from starlette.applications import Starlette
from starlette.responses import PlainTextResponse
from starlette.routing import Route
from app.adapters.observability import otel
from app.adapters.external.market import PluginHelper
from app.adapters.external.server import MoviePilotServerHelper
from app.adapters.web.metrics import HttpMetricsMiddleware
from app.db.engine import _register_database_pool_metrics
from app.runtime.extensions.plugin.lifecycle import observe_plugin_lifecycle
from app.runtime.extensions.plugin_manager import PluginManager
from app.schemas.plugin import PluginRuntimeStatus
from app.runtime.observability import (
METRIC_SPECS,
MetricSpec,
NoopObservationPort,
configure_observation,
observe_duration,
record_metric,
)
@dataclass
class RecordingObservationPort:
"""测试用端口,保存已经通过核心标签校验的写入。"""
records: list[tuple[MetricSpec, float, Mapping[str, str]]] = field(
default_factory=list
)
def record(
self, spec: MetricSpec, value: float, labels: Mapping[str, str]
) -> None:
"""追加一条不可变测试快照。"""
self.records.append((spec, value, dict(labels)))
@pytest.fixture(autouse=True)
def _reset_observation_port():
"""避免进程级观测端口在用例间泄漏。"""
configure_observation(None)
yield
configure_observation(None)
def test_noop_port_accepts_registered_metric_without_exporter() -> None:
"""未安装 exporter 时记录合法指标必须完全可用。"""
configure_observation(NoopObservationPort())
record_metric(
"http.server.duration",
0.1,
route="/health/live",
method="GET",
status="200",
)
def test_metric_catalog_contains_no_high_cardinality_labels() -> None:
"""整个指标目录不得登记用户、插件、媒体、URL 或请求实例标签。"""
forbidden = {
"user_id",
"plugin_id",
"media_id",
"media_title",
"url",
"request_id",
"job_id",
}
assert METRIC_SPECS
assert all(not (spec.labels & forbidden) for spec in METRIC_SPECS.values())
def test_unregistered_label_is_rejected_before_adapter() -> None:
"""调用方不能绕过目录向 exporter 注入高基数标签。"""
with pytest.raises(ValueError, match="未登记标签"):
record_metric(
"scheduler.job.duration",
1,
owner="plugin",
outcome="success",
job_id="dynamic-123",
)
def test_duration_records_success_and_error_outcomes() -> None:
"""统一计时器把正常和异常路径收敛为有限 outcome。"""
port = RecordingObservationPort()
configure_observation(port)
with observe_duration(
"module.provider.duration", method="recognize_media", provider_type="system"
):
pass
with pytest.raises(RuntimeError):
with observe_duration(
"module.provider.duration",
method="recognize_media",
provider_type="plugin",
):
raise RuntimeError("failed")
assert [record[2]["outcome"] for record in port.records] == ["success", "error"]
@pytest.mark.asyncio
async def test_http_metrics_use_route_template_not_request_url() -> None:
"""HTTP 指标使用路由模板,不能把具体资源 ID 变成 label。"""
port = RecordingObservationPort()
configure_observation(port)
async def item(_request):
"""返回固定测试响应。"""
return PlainTextResponse("ok")
app = Starlette(routes=[Route("/items/{item_id}", item)])
app.add_middleware(HttpMetricsMiddleware)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.get("/items/secret-item")
assert response.status_code == 200
_, _, labels = port.records[-1]
assert labels == {"route": "/items/{item_id}", "method": "GET", "status": "200"}
def test_optional_otel_adapter_falls_back_to_noop(monkeypatch) -> None:
"""显式启用但未安装 OTel API 时启动仍返回 no-op。"""
monkeypatch.setenv("MOVIEPILOT_OTEL_METRICS", "1")
def missing(_name: str):
"""模拟可选依赖不存在。"""
raise ImportError("missing")
monkeypatch.setattr(otel.importlib, "import_module", missing)
assert isinstance(otel.build_observation_port(), NoopObservationPort)
def test_database_pool_checkout_updates_gauge() -> None:
"""真实 SQLAlchemy checkout/checkin 应成对维护连接借出量。"""
port = RecordingObservationPort()
configure_observation(port)
engine = create_engine("sqlite://")
_register_database_pool_metrics(engine)
with engine.connect():
pass
engine.dispose()
records = [record for record in port.records if record[0].name == "db.pool.checked_out"]
assert [record[1] for record in records] == [1.0, -1.0]
assert all(record[2] == {"backend": "sqlite"} for record in records)
def test_plugin_lifecycle_failed_status_records_error_outcome() -> None:
"""被插件生命周期内部收敛的加载失败仍应记录 error。"""
port = RecordingObservationPort()
configure_observation(port)
@observe_plugin_lifecycle("start")
def load_plugin() -> dict[str, PluginRuntimeStatus]:
"""返回插件加载失败状态。"""
return {"Example": PluginRuntimeStatus.LOAD_FAILED}
load_plugin()
spec, _, labels = port.records[-1]
assert spec.name == "plugin.lifecycle.duration"
assert labels == {"operation": "start", "outcome": "error"}
def test_legacy_facades_record_public_and_private_hits() -> None:
"""三个正式旧 ABI Facade 的公开/私有调用都应留下可审计命中。"""
port = RecordingObservationPort()
configure_observation(port)
assert PluginHelper.is_local_repo_url("local://example")
assert PluginManager._normalize_plugin_label("example") == "example"
assert MoviePilotServerHelper._has_header({}, "X-Test") is False
assert inspect.iscoroutinefunction(MoviePilotServerHelper.async_subscribe_done)
assert str(inspect.signature(MoviePilotServerHelper.async_subscribe_done)) == (
"(payload: Dict[str, Any])"
)
hits = [
(spec.name, labels)
for spec, _value, labels in port.records
if spec.name == "compat.facade.hit"
]
assert hits == [
(
"compat.facade.hit",
{
"facade": "PluginHelper",
"operation": "is_local_repo_url",
"visibility": "public",
"abi_source": "legacy_facade",
},
),
(
"compat.facade.hit",
{
"facade": "PluginManager",
"operation": "_normalize_plugin_label",
"visibility": "private",
"abi_source": "legacy_facade",
},
),
(
"compat.facade.hit",
{
"facade": "MoviePilotServerHelper",
"operation": "_has_header",
"visibility": "private",
"abi_source": "legacy_facade",
},
),
]