mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-30 04:27:40 +08:00
230 lines
7.3 KiB
Python
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",
|
|
},
|
|
),
|
|
]
|