"""接口指标可观测(observe)单测:配置门槛 / 队列 / 中间件 / worker。 沿用仓库约定:TestClient + monkeypatch,绝不打真网络。observe 默认关(conftest 未设 OBSERVE_*),需要开启的用例用 monkeypatch 改 settings 单例属性。 """ from __future__ import annotations import asyncio import httpx import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from app.core import observe, observe_worker from app.core.config import settings def test_observe_configured_requires_switch_and_creds(monkeypatch): # 开关开 + endpoint(默认 localhost)+ user + password 齐全 → True monkeypatch.setattr(settings, "OBSERVE_ENABLED", True) monkeypatch.setattr(settings, "OBSERVE_USER", "u") monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "p") assert settings.observe_configured is True # 缺密码 → False monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "") assert settings.observe_configured is False # 缺用户名 → False monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "p") monkeypatch.setattr(settings, "OBSERVE_USER", "") assert settings.observe_configured is False # 开关关 → False(即便凭证齐全) monkeypatch.setattr(settings, "OBSERVE_USER", "u") monkeypatch.setattr(settings, "OBSERVE_ENABLED", False) assert settings.observe_configured is False def test_record_event_enqueues(monkeypatch): q = asyncio.Queue(maxsize=10) monkeypatch.setattr(observe, "_queue", q) observe.record_event({"route": "/x"}) assert q.get_nowait() == {"route": "/x"} def test_record_event_drops_when_full(monkeypatch): q = asyncio.Queue(maxsize=1) monkeypatch.setattr(observe, "_queue", q) monkeypatch.setattr(observe, "_dropped", 0) observe.record_event({"n": 1}) # 占满 observe.record_event({"n": 2}) # 满 → 丢弃当前,不抛异常 assert observe.take_dropped() == 1 assert observe.take_dropped() == 0 # 取出后清零 assert q.get_nowait() == {"n": 1} # 保留的是先到的 def _make_probe_app() -> FastAPI: """独立最小 app:只挂中间件 + 两个无鉴权路由,不碰真业务 DB/auth。""" app = FastAPI() app.add_middleware(observe.RequestMetricsMiddleware) @app.get("/things/{tid}") def get_thing(tid: str): return {"tid": tid} @app.get("/health") def health(): return {"ok": True} return app @pytest.fixture def observe_on(monkeypatch): """开启观测 + 换一个干净小队列,返回该队列供断言。""" monkeypatch.setattr(settings, "OBSERVE_ENABLED", True) monkeypatch.setattr(settings, "OBSERVE_USER", "u") monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "p") q = asyncio.Queue(maxsize=100) monkeypatch.setattr(observe, "_queue", q) return q def test_middleware_records_route_template(observe_on): client = TestClient(_make_probe_app()) r = client.get("/things/42") assert r.status_code == 200 evt = observe_on.get_nowait() assert evt["route"] == "/things/{tid}" # 模板,不是 /things/42 assert evt["method"] == "GET" assert evt["status"] == 200 assert evt["duration_ms"] >= 0 assert evt["service"] and "env" in evt and isinstance(evt["_timestamp"], int) def test_middleware_skips_health(observe_on): client = TestClient(_make_probe_app()) client.get("/health") assert observe_on.empty() def test_middleware_unmatched_route_is_normalized(observe_on): client = TestClient(_make_probe_app()) r = client.get("/definitely-not-a-route") assert r.status_code == 404 evt = observe_on.get_nowait() assert evt["route"] == "__unmatched__" assert evt["status"] == 404 def test_middleware_noop_when_disabled(monkeypatch): monkeypatch.setattr(settings, "OBSERVE_ENABLED", False) q = asyncio.Queue(maxsize=100) monkeypatch.setattr(observe, "_queue", q) client = TestClient(_make_probe_app()) client.get("/things/1") assert q.empty() # 未配置观测 → 零入队 async def test_collect_batch_drains_up_to_batch_max(monkeypatch): q = asyncio.Queue(maxsize=100) monkeypatch.setattr(observe, "_queue", q) monkeypatch.setattr(settings, "OBSERVE_FLUSH_INTERVAL_SEC", 0.1) monkeypatch.setattr(settings, "OBSERVE_BATCH_MAX", 200) for i in range(3): q.put_nowait({"n": i}) batch = await observe_worker._collect_batch() assert [e["n"] for e in batch] == [0, 1, 2] async def test_collect_batch_timeout_returns_empty(monkeypatch): q = asyncio.Queue(maxsize=100) monkeypatch.setattr(observe, "_queue", q) monkeypatch.setattr(settings, "OBSERVE_FLUSH_INTERVAL_SEC", 0.05) batch = await observe_worker._collect_batch() assert batch == [] async def test_post_batch_hits_json_ingest_url(monkeypatch): monkeypatch.setattr(settings, "OBSERVE_ORG", "default") monkeypatch.setattr(settings, "OBSERVE_STREAM", "app_requests") captured = {} def handler(request: httpx.Request) -> httpx.Response: captured["url"] = str(request.url) captured["json"] = request.content return httpx.Response(200, json={"code": 200}) client = httpx.AsyncClient( base_url="http://oo", transport=httpx.MockTransport(handler) ) await observe_worker._post_batch(client, [{"route": "/x", "status": 200}]) await client.aclose() assert captured["url"] == "http://oo/api/default/app_requests/_json" assert b"/x" in captured["json"] def test_start_observe_worker_noop_when_not_configured(monkeypatch): monkeypatch.setattr(settings, "OBSERVE_ENABLED", False) assert observe_worker.start_observe_worker() is None async def test_run_loop_survives_post_failure(monkeypatch): """_post_batch 抛异常时,loop 不崩溃、继续处理后续批次(best-effort 契约)。""" q = asyncio.Queue(maxsize=100) monkeypatch.setattr(observe, "_queue", q) monkeypatch.setattr(settings, "OBSERVE_FLUSH_INTERVAL_SEC", 0.02) monkeypatch.setattr(settings, "OBSERVE_BATCH_MAX", 200) seen: list[list[int]] = [] async def boom(client, batch): seen.append([e["n"] for e in batch]) raise RuntimeError("boom") monkeypatch.setattr(observe_worker, "_post_batch", boom) q.put_nowait({"n": 1}) task = asyncio.create_task(observe_worker._run_loop(None)) try: for _ in range(50): # 轮询直到第 1 批被处理(失败),最多等 0.5s await asyncio.sleep(0.01) if seen: break q.put_nowait({"n": 2}) for _ in range(50): # 第 2 批被处理 → 证明失败后 loop 仍存活 await asyncio.sleep(0.01) if len(seen) >= 2: break finally: task.cancel() try: await task except asyncio.CancelledError: pass assert seen == [[1], [2]] async def test_stop_flushes_remaining_and_closes_client(monkeypatch): """stop:cancel 后把剩余事件 best-effort 发出最后一批,并关闭 + 置空 client。""" q = asyncio.Queue(maxsize=100) monkeypatch.setattr(observe, "_queue", q) monkeypatch.setattr(settings, "OBSERVE_ORG", "default") monkeypatch.setattr(settings, "OBSERVE_STREAM", "app_requests") monkeypatch.setattr(settings, "OBSERVE_BATCH_MAX", 200) q.put_nowait({"n": 1}) q.put_nowait({"n": 2}) posted: dict = {} def handler(request: httpx.Request) -> httpx.Response: posted["body"] = request.content return httpx.Response(200, json={"code": 200}) client = httpx.AsyncClient( base_url="http://oo", transport=httpx.MockTransport(handler) ) monkeypatch.setattr(observe_worker, "_client", client) async def _noop() -> None: return None task = asyncio.create_task(_noop()) await observe_worker.stop_observe_worker(task) assert b'"n"' in posted["body"] # 关停时把剩余事件发了出去 assert observe_worker._client is None # client 已关闭并置空 def test_app_has_metrics_middleware(): from app.main import app names = [m.cls.__name__ for m in app.user_middleware] assert "RequestMetricsMiddleware" in names