Files
gaokao-volunteer-system/admin/tests/test_logging.py
Hermes Agent 3f46811e49
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
release: cut v2.1
2026-06-13 14:49:58 +08:00

281 lines
9.0 KiB
Python

"""T9.3 结构化日志测试.
覆盖:
- JsonLogFormatter: 时间 / 级别 / logger / ctx / exc
- log_event / log_event_exc: 结构化字段注入、异常捕获
- request context: bind / clear / middleware 隔离
- FastAPI 端到端: BusinessError 触发 JSON 日志, 含 code/path/method/request_id
"""
from __future__ import annotations
import io
import json
import logging
import sys
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from admin.errors import (
AUTH_INVALID_CREDENTIALS,
BusinessError,
register_exception_handler,
)
from admin.logging_utils import (
JsonLogFormatter,
bind_request_context,
clear_request_context,
configure_logging,
current_context,
log_event,
log_event_exc,
reset_logging_for_tests,
)
class TestJsonLogFormatter:
def test_formats_basic_record_with_ctx(self):
formatter = JsonLogFormatter()
token = bind_request_context(request_id="req_test", path="/api/x", method="GET")
try:
record = logging.LogRecord(
name="admin.errors",
level=logging.WARNING,
pathname=__file__,
lineno=10,
msg="hello %s",
args=("world",),
exc_info=None,
)
record.ctx = {"code": "E01101", "event": "business_error"}
payload = json.loads(formatter.format(record))
finally:
clear_request_context(token)
assert payload["level"] == "warning"
assert payload["logger"] == "admin.errors"
assert payload["msg"] == "hello world"
assert payload["ts"].endswith("Z")
assert payload["ctx"]["code"] == "E01101"
assert payload["ctx"]["event"] == "business_error"
assert payload["ctx"]["request_id"] == "req_test"
assert payload["ctx"]["path"] == "/api/x"
assert payload["ctx"]["method"] == "GET"
def test_formats_exception(self):
formatter = JsonLogFormatter()
try:
raise RuntimeError("boom")
except RuntimeError:
record = logging.LogRecord(
name="admin.errors",
level=logging.ERROR,
pathname=__file__,
lineno=20,
msg="failed",
args=(),
exc_info=sys.exc_info(),
)
payload = json.loads(formatter.format(record))
assert payload["level"] == "error"
assert payload["exc"]["type"] == "RuntimeError"
assert payload["exc"]["message"] == "boom"
assert "RuntimeError: boom" in payload["exc"]["traceback"]
class TestContextBinding:
def test_bind_and_clear(self):
assert current_context() == {}
token = bind_request_context(request_id="req_1", path="/a", method="POST")
try:
assert current_context() == {
"request_id": "req_1",
"path": "/a",
"method": "POST",
}
finally:
clear_request_context(token)
assert current_context() == {}
class TestLogEvent:
def test_log_event_emits_json_with_ctx(self):
stream = io.StringIO()
reset_logging_for_tests()
configure_logging(level="INFO", fmt="json", stream=stream)
logger = logging.getLogger("admin.errors")
log_event(
logger,
logging.WARNING,
"business_error",
msg="business error mapped",
code="E01101",
path="/api/auth/login",
method="POST",
status=401,
)
line = stream.getvalue().strip()
payload = json.loads(line)
assert payload["level"] == "warning"
assert payload["msg"] == "business error mapped"
assert payload["ctx"]["event"] == "business_error"
assert payload["ctx"]["code"] == "E01101"
assert payload["ctx"]["path"] == "/api/auth/login"
assert payload["ctx"]["method"] == "POST"
assert payload["ctx"]["status"] == 401
def test_log_event_rejects_reserved_field(self):
logger = logging.getLogger("admin.errors")
with pytest.raises(ValueError, match="collides with LogRecord builtin"):
log_event(logger, logging.INFO, "bad", name="oops")
def test_log_event_exc_includes_traceback(self):
stream = io.StringIO()
reset_logging_for_tests()
configure_logging(level="INFO", fmt="json", stream=stream)
logger = logging.getLogger("admin.errors")
try:
raise ValueError("x")
except ValueError:
log_event_exc(
logger,
logging.ERROR,
"unhandled_exception",
exc_info=sys.exc_info(),
msg="unhandled",
path="/boom",
method="GET",
)
payload = json.loads(stream.getvalue().strip())
assert payload["level"] == "error"
assert payload["ctx"]["event"] == "unhandled_exception"
assert payload["exc"]["type"] == "ValueError"
assert payload["exc"]["message"] == "x"
def test_large_log_stays_valid_json(self):
stream = io.StringIO()
reset_logging_for_tests()
configure_logging(level="INFO", fmt="json", stream=stream)
logger = logging.getLogger("admin.errors")
log_event(
logger,
logging.WARNING,
"oversized_payload",
msg="payload too large",
fields=["x" * 12000],
)
encoded = stream.getvalue().strip()
payload = json.loads(encoded)
assert payload["msg"] == "payload too large"
assert payload["ctx"]["event"] == "oversized_payload"
assert payload["ctx"].get("truncated") is True
assert len(encoded.encode("utf-8")) <= 8 * 1024
def test_huge_message_is_also_capped(self):
stream = io.StringIO()
reset_logging_for_tests()
configure_logging(level="INFO", fmt="json", stream=stream)
logger = logging.getLogger("admin.errors")
logger.error("m" * 20000)
encoded = stream.getvalue().strip()
payload = json.loads(encoded)
assert payload["ctx"]["truncated"] is True
assert len(encoded.encode("utf-8")) <= 8 * 1024
class TestFastAPIStructuredIntegration:
def test_business_error_log_contains_request_context(self):
stream = io.StringIO()
reset_logging_for_tests()
configure_logging(level="INFO", fmt="json", stream=stream)
app = FastAPI()
from admin.app import request_context_middleware
app.middleware("http")(request_context_middleware)
@app.get("/boom")
async def _boom():
raise BusinessError(AUTH_INVALID_CREDENTIALS)
register_exception_handler(app)
with TestClient(app) as client:
resp = client.get("/boom")
assert resp.status_code == 401
body = resp.json()
assert body["code"] == "E01101"
lines = [
json.loads(line) for line in stream.getvalue().splitlines() if line.strip()
]
business_logs = [
line
for line in lines
if line.get("ctx", {}).get("event") == "business_error"
]
assert business_logs, stream.getvalue()
payload = business_logs[-1]
assert payload["logger"] == "admin.errors"
assert payload["ctx"]["code"] == "E01101"
assert payload["ctx"]["path"] == "/boom"
assert payload["ctx"]["method"] == "GET"
assert payload["ctx"]["status"] == 401
assert payload["ctx"]["request_id"].startswith("req_")
def test_validation_error_log_contains_request_context(self):
stream = io.StringIO()
reset_logging_for_tests()
configure_logging(level="INFO", fmt="json", stream=stream)
app = FastAPI()
from pydantic import BaseModel
from admin.app import request_context_middleware
app.middleware("http")(request_context_middleware)
class Payload(BaseModel):
username: str
@app.post("/validate")
async def _validate(payload: Payload):
return {"username": payload.username}
register_exception_handler(app)
with TestClient(app) as client:
resp = client.post("/validate", json={})
assert resp.status_code == 422
body = resp.json()
assert body["code"] == "E03001"
lines = [
json.loads(line) for line in stream.getvalue().splitlines() if line.strip()
]
validation_logs = [
line
for line in lines
if line.get("ctx", {}).get("event") == "validation_error"
]
assert validation_logs, stream.getvalue()
payload = validation_logs[-1]
assert payload["logger"] == "admin.errors"
assert payload["ctx"]["path"] == "/validate"
assert payload["ctx"]["method"] == "POST"
assert payload["ctx"]["request_id"].startswith("req_")
assert payload["ctx"]["code_count"] >= 1