feat: 重构接口日志页面与接口监控看板,支持HTTP方法过滤和实时轮询

This commit is contained in:
liumangmang
2026-07-01 15:04:32 +08:00
parent 397a14c978
commit 8abfa4c5ef
19 changed files with 2463 additions and 37 deletions
+677
View File
@@ -0,0 +1,677 @@
"""Tests for external API log persistence: logging, query, summary, and cleanup."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.database import Base
from app.models.external_api_log import ExternalApiLog
@pytest.fixture
def engine():
e = create_engine("sqlite://", connect_args={"check_same_thread": False})
# Import all models so they register with Base.metadata
import app.models.admin_user # noqa: F401
import app.models.upstream # noqa: F401
import app.models.website # noqa: F401
import app.models.snapshot # noqa: F401
import app.models.webhook_config # noqa: F401
import app.models.notification_log # noqa: F401
import app.models.custom_page # noqa: F401
import app.models.revoked_token # noqa: F401
import app.models.upstream_key # noqa: F401
import app.models.external_api_log # noqa: F401
Base.metadata.create_all(bind=e)
yield e
Base.metadata.drop_all(bind=e)
@pytest.fixture
def session(engine):
TestingSession = sessionmaker(bind=engine)
db = TestingSession()
try:
yield db
finally:
db.close()
# ─────────────────────────────────────────────
# 1. Model: basic insert and read
# ─────────────────────────────────────────────
def test_insert_and_read(session):
now = datetime.now(timezone.utc)
log = ExternalApiLog(
direction="upstream",
target_type="upstream",
target_id=1,
target_name="test-upstream",
method="GET",
path="/api/v1/models",
url_host="api.test.local",
status_code=200,
success=True,
duration_ms=150,
created_at=now,
)
session.add(log)
session.commit()
rows = session.query(ExternalApiLog).all()
assert len(rows) == 1
assert rows[0].direction == "upstream"
assert rows[0].duration_ms == 150
assert rows[0].status_code == 200
assert rows[0].success is True
# ─────────────────────────────────────────────
# 2. Filters (direction, target_type, target_id, success, status_code, path)
# ─────────────────────────────────────────────
def _seed(session, count: int = 1, **overrides) -> list[ExternalApiLog]:
rows = []
for i in range(count):
defaults = dict(
direction="upstream",
target_type="upstream",
target_id=10 + i,
target_name=f"upstream-{i}",
method="GET",
path=f"/api/v1/endpoint/{i}",
url_host="api.test.local",
status_code=200,
success=True,
duration_ms=100 + i * 10,
created_at=datetime.now(timezone.utc),
)
defaults.update(overrides)
r = ExternalApiLog(**defaults)
session.add(r)
rows.append(r)
session.commit()
return rows
def test_filter_by_direction(session):
_seed(session, 1, direction="upstream")
_seed(session, 1, direction="website")
rows = session.query(ExternalApiLog).filter(ExternalApiLog.direction == "upstream").all()
assert len(rows) == 1
assert rows[0].direction == "upstream"
def test_filter_by_success(session):
_seed(session, 1, success=True)
_seed(session, 1, success=False, status_code=500, error_type="ServerError")
rows = session.query(ExternalApiLog).filter(ExternalApiLog.success == False).all() # noqa: E712
assert len(rows) == 1
assert rows[0].success is False
def test_filter_by_status_code(session):
_seed(session, 1, status_code=200)
_seed(session, 1, status_code=404, success=False, error_type="NotFound")
rows = session.query(ExternalApiLog).filter(ExternalApiLog.status_code == 404).all()
assert len(rows) == 1
assert rows[0].status_code == 404
def test_filter_by_target_id(session):
_seed(session, 1, target_id=42)
_seed(session, 1, target_id=99)
rows = session.query(ExternalApiLog).filter(ExternalApiLog.target_id == 42).all()
assert len(rows) == 1
assert rows[0].target_id == 42
def test_filter_by_path_like(session):
_seed(session, 1, path="/api/keys/list")
_seed(session, 1, path="/api/groups/list")
rows = session.query(ExternalApiLog).filter(ExternalApiLog.path.like("%keys%")).all()
assert len(rows) == 1
assert "keys" in rows[0].path
def test_pagination(session):
_seed(session, 5)
total = session.query(ExternalApiLog).count()
assert total == 5
page = session.query(ExternalApiLog).order_by(ExternalApiLog.id).offset(0).limit(2).all()
assert len(page) == 2
page2 = session.query(ExternalApiLog).order_by(ExternalApiLog.id).offset(2).limit(2).all()
assert len(page2) == 2
# ─────────────────────────────────────────────
# 3. Summary aggregation
# ─────────────────────────────────────────────
def test_summary_aggregation(session):
now = datetime.now(timezone.utc)
# 3 calls to same path: 2 success, 1 fail, various durations
for i in range(3):
session.add(ExternalApiLog(
direction="upstream",
target_type="upstream",
target_id=1,
target_name="agg-test",
method="GET",
path="/api/v1/status",
url_host="api.test.local",
status_code=200 if i < 2 else 500,
success=i < 2,
duration_ms=[100, 200, 300][i],
created_at=now,
))
# A different path
session.add(ExternalApiLog(
direction="website",
target_type="website",
target_id=2,
target_name="site-2",
method="POST",
path="/api/v1/accounts",
url_host="site.test.local",
status_code=201,
success=True,
duration_ms=500,
created_at=now,
))
session.commit()
from sqlalchemy import cast, case, func, Integer
slow_threshold = 250
cols = [
ExternalApiLog.direction,
ExternalApiLog.target_type,
ExternalApiLog.target_id,
ExternalApiLog.target_name,
ExternalApiLog.method,
ExternalApiLog.path,
func.count(ExternalApiLog.id).label("call_count"),
func.sum(cast(ExternalApiLog.success, Integer)).label("success_count"),
func.sum(cast(1 - cast(ExternalApiLog.success, Integer), Integer)).label("fail_count"),
func.avg(ExternalApiLog.duration_ms).label("avg_duration_ms"),
func.max(ExternalApiLog.duration_ms).label("max_duration_ms"),
func.sum(case((ExternalApiLog.duration_ms >= slow_threshold, 1), else_=0)).label("slow_count"),
]
rows = (
session.query(*cols)
.group_by(
ExternalApiLog.direction, ExternalApiLog.target_type,
ExternalApiLog.target_id, ExternalApiLog.target_name,
ExternalApiLog.method, ExternalApiLog.path,
)
.order_by(func.sum(ExternalApiLog.duration_ms).desc())
.all()
)
assert len(rows) == 2
# First row: upstream, 3 calls
r1 = rows[0]
assert r1.direction == "upstream"
assert r1.call_count == 3
assert r1.success_count == 2
assert r1.fail_count == 1
assert r1.slow_count == 1 # only 300ms >= 250
assert r1.max_duration_ms == 300
assert 190 <= r1.avg_duration_ms <= 210 # ~200
# Second row: website, 1 call
r2 = rows[1]
assert r2.direction == "website"
assert r2.call_count == 1
assert r2.success_count == 1
assert r2.fail_count == 0
# ─────────────────────────────────────────────
# 4. Cleanup (retention)
# ─────────────────────────────────────────────
def test_clean_expired_logs(session):
now = datetime.now(timezone.utc)
# Old record (40 days ago)
session.add(ExternalApiLog(
direction="upstream", target_type="upstream", method="GET",
path="/old", url_host="old.local",
status_code=200, success=True, duration_ms=10,
created_at=now - timedelta(days=40),
))
# Recent record (5 days ago)
session.add(ExternalApiLog(
direction="upstream", target_type="upstream", method="GET",
path="/recent", url_host="recent.local",
status_code=200, success=True, duration_ms=10,
created_at=now - timedelta(days=5),
))
session.commit()
assert session.query(ExternalApiLog).count() == 2
# Delete records older than 30 days
cutoff = now - timedelta(days=30)
deleted = session.query(ExternalApiLog).filter(
ExternalApiLog.created_at < cutoff
).delete(synchronize_session=False)
session.commit()
assert deleted == 1
assert session.query(ExternalApiLog).count() == 1
remaining = session.query(ExternalApiLog).first()
assert "/recent" in remaining.path
# ─────────────────────────────────────────────
# 5. log_external_api_call function
# ─────────────────────────────────────────────
def test_log_external_api_call_success(session):
"""Verify the logging helper writes the expected row."""
from app.services.external_api_logger import log_external_api_call
with patch("app.services.external_api_logger.SessionLocal", return_value=session):
log_external_api_call(
direction="upstream",
target_type="upstream",
method="POST",
path="/api/v1/test",
url_host="test.local",
duration_ms=250,
status_code=201,
success=True,
target_id=7,
target_name="gold-upstream",
)
rows = session.query(ExternalApiLog).all()
assert len(rows) == 1
r = rows[0]
assert r.direction == "upstream"
assert r.method == "POST"
assert r.path == "/api/v1/test"
assert r.url_host == "test.local"
assert r.duration_ms == 250
assert r.status_code == 201
assert r.success is True
assert r.target_id == 7
assert r.target_name == "gold-upstream"
assert r.error_type is None
assert r.error_message is None
def test_log_external_api_call_failure(session):
"""Failure path: error fields populated, success=False."""
from app.services.external_api_logger import log_external_api_call
with patch("app.services.external_api_logger.SessionLocal", return_value=session):
log_external_api_call(
direction="website",
target_type="website",
method="GET",
path="/api/v1/groups",
url_host="site.local",
duration_ms=5000,
status_code=502,
success=False,
target_id=3,
target_name="bad-site",
error_type="BadGateway",
error_message="upstream connection refused",
)
rows = session.query(ExternalApiLog).all()
assert len(rows) == 1
r = rows[0]
assert r.direction == "website"
assert r.success is False
assert r.status_code == 502
assert r.duration_ms == 5000
assert r.error_type == "BadGateway"
assert r.error_message == "upstream connection refused"
def test_log_no_sensitive_data(session):
"""Verify no request/response body or credentials are logged."""
from app.services.external_api_logger import log_external_api_call
with patch("app.services.external_api_logger.SessionLocal", return_value=session):
log_external_api_call(
direction="upstream",
target_type="upstream",
method="GET",
path="/api/v1/keys",
url_host="secure.local",
duration_ms=100,
status_code=200,
success=True,
)
rows = session.query(ExternalApiLog).all()
assert len(rows) == 1
r = rows[0]
# These fields MUST be empty — we never log bodies or credentials
assert not hasattr(r, "request_body")
assert not hasattr(r, "response_body")
assert not hasattr(r, "authorization")
assert not hasattr(r, "cookie")
assert not hasattr(r, "api_key")
# ─────────────────────────────────────────────
# 6. Time window on summary
# ─────────────────────────────────────────────
def test_summary_time_window(session):
now = datetime.now(timezone.utc)
# Recent (1 hour ago)
session.add(ExternalApiLog(
direction="upstream", target_type="upstream", method="GET",
path="/recent", url_host="h", status_code=200, success=True,
duration_ms=50, created_at=now - timedelta(hours=1),
))
# Old (48 hours ago)
session.add(ExternalApiLog(
direction="upstream", target_type="upstream", method="GET",
path="/old", url_host="h", status_code=200, success=True,
duration_ms=50, created_at=now - timedelta(hours=48),
))
session.commit()
# 24-hour window should only include the recent record
cutoff = now - timedelta(hours=24)
rows = session.query(ExternalApiLog).filter(ExternalApiLog.created_at >= cutoff).all()
assert len(rows) == 1
assert "/recent" in rows[0].path
# 72-hour window includes both
cutoff = now - timedelta(hours=72)
rows = session.query(ExternalApiLog).filter(ExternalApiLog.created_at >= cutoff).all()
assert len(rows) == 2
# ─────────────────────────────────────────────
# 7. UpstreamClient integration: 4xx/5xx logged as failure
# ─────────────────────────────────────────────
def _make_httpx_mock(status_code: int, body: dict | None = None):
"""Return a mock for httpx.Client.request that returns a fixed status.
Used via patch.object(client._client, 'request', ...) which replaces the
bound method, so the signature is (method, url, **kwargs) — no self.
"""
import httpx
def mock_request(method, url, **kwargs):
req = httpx.Request(method, url)
return httpx.Response(status_code, json=body or {}, request=req)
return mock_request
def test_upstream_client_500_logged_as_failure():
"""UpstreamClient._request() → HTTP 500 → log with success=False, status_code=500."""
from app.services.upstream_client import UpstreamClient
captured = {}
def capture(**kw):
captured.update(kw)
with patch("app.services.upstream_client.log_external_api_call", side_effect=capture):
client = UpstreamClient(
base_url="http://test.local",
api_prefix="",
auth_type="api_key",
auth_config={"key": "test", "header": "Authorization"},
target_id=1,
target_name="test-upstream",
)
with patch.object(client._client, "request", _make_httpx_mock(500)):
try:
client._request("GET", "/api/v1/models")
except Exception:
pass
assert captured.get("status_code") == 500
assert captured.get("success") is False
assert captured.get("error_type") == "HTTPStatus"
assert "500" in (captured.get("error_message") or "")
def test_upstream_client_429_logged_as_failure():
"""UpstreamClient._request() → HTTP 429 → log with success=False, status_code=429."""
from app.services.upstream_client import UpstreamClient
captured = {}
def capture(**kw):
captured.update(kw)
with patch("app.services.upstream_client.log_external_api_call", side_effect=capture):
client = UpstreamClient(
base_url="http://test.local",
api_prefix="",
auth_type="api_key",
auth_config={"key": "test", "header": "Authorization"},
target_id=1,
target_name="test-upstream",
)
with patch.object(client._client, "request", _make_httpx_mock(429)):
try:
client._request("GET", "/api/v1/rate-limited")
except Exception:
pass
assert captured.get("status_code") == 429
assert captured.get("success") is False
assert captured.get("error_type") == "HTTPStatus"
def test_upstream_client_200_logged_as_success():
"""UpstreamClient._request() → HTTP 200 → log with success=True, status_code=200."""
from app.services.upstream_client import UpstreamClient
captured = {}
def capture(**kw):
captured.update(kw)
with patch("app.services.upstream_client.log_external_api_call", side_effect=capture):
client = UpstreamClient(
base_url="http://test.local",
api_prefix="",
auth_type="api_key",
auth_config={"key": "test", "header": "Authorization"},
target_id=1,
target_name="test-upstream",
)
with patch.object(client._client, "request", _make_httpx_mock(200, {"data": []})):
client._request("GET", "/api/v1/groups")
assert captured.get("status_code") == 200
assert captured.get("success") is True
assert captured.get("error_type") is None
# ─────────────────────────────────────────────
# 8. Route-level tests — call handlers directly
# ─────────────────────────────────────────────
def test_route_list_empty(session):
"""list_external_api_logs with no data returns []."""
from app.routers.external_api_logs import list_external_api_logs
result = list_external_api_logs(
direction=None, target_type=None, target_id=None,
success=None, status_code=None, path=None,
limit=100, offset=0, db=session, _=None,
)
assert result == []
def test_route_list_with_filters(session):
"""list_external_api_logs with filters returns filtered results."""
from app.routers.external_api_logs import list_external_api_logs
now = datetime.now(timezone.utc)
for i in range(3):
session.add(ExternalApiLog(
direction="upstream" if i < 2 else "website",
target_type="upstream" if i < 2 else "website",
target_id=i + 1, target_name=f"tgt-{i}",
method="GET", path=f"/path/{i}", url_host="h.local",
status_code=200 if i < 2 else 500, success=i < 2,
duration_ms=100 + i, created_at=now,
))
session.commit()
_empty = None # sentinel for "no filter"
# Filter by direction
result = list_external_api_logs(
direction="website", target_type=_empty, target_id=_empty,
success=_empty, status_code=_empty, path=_empty,
limit=100, offset=0, db=session, _=None,
)
assert len(result) == 1 and result[0].direction == "website"
# Filter by success
result = list_external_api_logs(
direction=_empty, target_type=_empty, target_id=_empty,
success=False, status_code=_empty, path=_empty,
limit=100, offset=0, db=session, _=None,
)
assert len(result) == 1 and result[0].success is False
# Filter by status_code
result = list_external_api_logs(
direction=_empty, target_type=_empty, target_id=_empty,
success=_empty, status_code=500, path=_empty,
limit=100, offset=0, db=session, _=None,
)
assert len(result) == 1 and result[0].status_code == 500
# Filter by path
result = list_external_api_logs(
direction=_empty, target_type=_empty, target_id=_empty,
success=_empty, status_code=_empty, path="/path/0",
limit=100, offset=0, db=session, _=None,
)
assert len(result) == 1 and "/path/0" in result[0].path
# Filter by target_id
result = list_external_api_logs(
direction=_empty, target_type=_empty, target_id=2,
success=_empty, status_code=_empty, path=_empty,
limit=100, offset=0, db=session, _=None,
)
assert len(result) == 1 and result[0].target_id == 2
def test_route_list_pagination(session):
"""list_external_api_logs respects limit/offset."""
from app.routers.external_api_logs import list_external_api_logs
now = datetime.now(timezone.utc)
for i in range(10):
session.add(ExternalApiLog(
direction="upstream", target_type="upstream",
method="GET", path=f"/p/{i}", url_host="h",
status_code=200, success=True, duration_ms=i, created_at=now,
))
session.commit()
_e = None
assert len(list_external_api_logs(
direction=_e, target_type=_e, target_id=_e,
success=_e, status_code=_e, path=_e,
limit=3, offset=0, db=session, _=None,
)) == 3
assert len(list_external_api_logs(
direction=_e, target_type=_e, target_id=_e,
success=_e, status_code=_e, path=_e,
limit=3, offset=3, db=session, _=None,
)) == 3
assert len(list_external_api_logs(
direction=_e, target_type=_e, target_id=_e,
success=_e, status_code=_e, path=_e,
limit=3, offset=20, db=session, _=None,
)) == 0
def test_route_summary_hours_window(session):
"""get_external_api_log_summary respects hours parameter."""
from app.routers.external_api_logs import get_external_api_log_summary
now = datetime.now(timezone.utc)
# Recent (30 minutes ago — well within 1-hour window)
session.add(ExternalApiLog(direction="upstream", target_type="upstream",
method="GET", path="/recent", url_host="h",
status_code=200, success=True, duration_ms=50,
created_at=now - timedelta(minutes=30)))
# Old (48 hours ago — outside 1-hour window)
session.add(ExternalApiLog(direction="upstream", target_type="upstream",
method="GET", path="/old", url_host="h",
status_code=200, success=True, duration_ms=50,
created_at=now - timedelta(hours=48)))
session.commit()
# hours=1 → only recent
result = get_external_api_log_summary(
slow_ms=None, hours=1, limit=50, db=session, _=None,
)
assert len(result) == 1 and result[0].path == "/recent"
# hours=72 → both
result = get_external_api_log_summary(
slow_ms=None, hours=72, limit=50, db=session, _=None,
)
assert len(result) == 2
def test_route_summary_limit(session):
"""get_external_api_log_summary respects limit parameter."""
from app.routers.external_api_logs import get_external_api_log_summary
now = datetime.now(timezone.utc)
for i in range(10):
session.add(ExternalApiLog(direction="upstream", target_type="upstream",
method="GET", path=f"/path-{i}", url_host="h",
status_code=200, success=True, duration_ms=50 + i, created_at=now))
session.commit()
result = get_external_api_log_summary(
slow_ms=None, hours=24, limit=3, db=session, _=None,
)
assert len(result) == 3
def test_route_summary_slow_threshold(session):
"""get_external_api_log_summary respects slow_ms parameter."""
from app.routers.external_api_logs import get_external_api_log_summary
now = datetime.now(timezone.utc)
session.add(ExternalApiLog(direction="upstream", target_type="upstream",
method="GET", path="/fast", url_host="h",
status_code=200, success=True, duration_ms=100, created_at=now))
session.add(ExternalApiLog(direction="upstream", target_type="upstream",
method="GET", path="/fast2", url_host="h",
status_code=200, success=True, duration_ms=200, created_at=now))
session.add(ExternalApiLog(direction="upstream", target_type="upstream",
method="GET", path="/slow", url_host="h",
status_code=200, success=True, duration_ms=5000, created_at=now))
session.commit()
result = get_external_api_log_summary(
slow_ms=1000, hours=24, limit=50, db=session, _=None,
)
slow_paths = [r for r in result if r.slow_count > 0]
assert len(slow_paths) == 1
assert slow_paths[0].path == "/slow"