"""低基数指标合同、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", }, ), ]