Files
group_fqcd_jr/tests/unit/api/test_risk_controller.py
T

313 lines
11 KiB
Python

from typing import Any
from fastapi.testclient import TestClient
from app.api.controllers import risk as risk_controller
from app.api.dependencies.auth import build_request_context
from app.api.dependencies.database import get_session
from app.core.contracts import RequestContext
from app.main import create_app
class StubRiskQueryService:
def __init__(self, _session: Any) -> None:
pass
async def overview(self, _context: RequestContext) -> dict[str, Any]:
return {"total": 2, "levels": {"高风险": 1}, "pending": 1, "overdue": 0}
async def list_alerts(self, _context: RequestContext, _query: Any) -> dict[str, Any]:
return {"items": [{"alert_no": "ALERT-001"}], "next_cursor": None, "has_more": False}
async def get_alert_detail(self, _context: RequestContext, alert_no: str) -> dict[str, Any]:
return {"alert": {"alert_no": alert_no}}
async def list_evidence(
self,
_context: RequestContext,
source: str,
_query: Any,
) -> dict[str, Any]:
return {"items": [{"source": source}], "next_cursor": None, "has_more": False}
class StubRiskScanService:
def __init__(self, _session: Any) -> None:
pass
async def scan(self, _context: RequestContext) -> dict[str, Any]:
return {"message": "规则扫描完成", "created_count": 2, "high_risk_count": 1}
class StubRiskActionService:
def __init__(self, _session: Any) -> None:
pass
async def acknowledge(self, alert_no: str, _context: RequestContext) -> dict[str, Any]:
return {"alert_no": alert_no, "status": "待处理", "ack_status": "已确认"}
async def investigate(self, alert_no: str, _context: RequestContext) -> dict[str, Any]:
return {"alert_no": alert_no, "status": "调查中"}
async def exclude(
self, alert_no: str, reason: str, _context: RequestContext
) -> dict[str, Any]:
return {"alert_no": alert_no, "status": "已排除", "handle_result": reason}
async def resolve(
self, alert_no: str, resolution: str, _context: RequestContext
) -> dict[str, Any]:
return {"alert_no": alert_no, "status": "已结案", "handle_result": resolution}
async def escalate(
self, alert_no: str, reason: str, _context: RequestContext
) -> dict[str, Any]:
return {"alert_no": alert_no, "is_escalated": True, "escalation_reason": reason}
class StubRiskEvidenceArchiveService:
def __init__(self, _session: Any) -> None:
pass
async def archive(self, alert_no: str, upload: Any, _context: RequestContext) -> dict[str, Any]:
return {
"alert_no": alert_no,
"evidence_archived": True,
"stored_name": upload.filename,
"file_size": 8,
}
class StubRiskNotificationService:
def __init__(self, _session: Any) -> None:
pass
async def list_notifications(self, _context: RequestContext, _query: Any) -> dict[str, Any]:
return {
"items": [{"notification_id": "N-001", "alert_no": "ALERT-001"}],
"next_cursor": None,
"has_more": False,
}
class StubRiskDailyReportService:
def __init__(self, _session: Any) -> None:
pass
async def generate(self, _context: RequestContext, _report_time: Any) -> dict[str, Any]:
return {"report_date": "2026-09-10", "content": "日报正文"}
async def stream(self, _context: RequestContext, _report_time: Any):
yield {"type": "start", "generated_at": "2026-09-10T00:00:00"}
yield {"type": "done", "report": {"report_date": "2026-09-10"}}
class StubRiskDailyReportMailService:
def send(self, recipients: list[str], _subject: str, _content: str) -> dict[str, Any]:
return {"status": "dry_run", "recipient_count": len(recipients)}
def authenticated_client(monkeypatch) -> TestClient:
async def context() -> RequestContext:
return RequestContext(
user_id="990000002",
trace_id="risk-trace",
permissions=("risk:alert:read",),
data_scope="all",
)
async def session():
yield None
monkeypatch.setattr(risk_controller, "RiskQueryService", StubRiskQueryService)
monkeypatch.setattr(risk_controller, "RiskScanService", StubRiskScanService)
monkeypatch.setattr(risk_controller, "RiskActionService", StubRiskActionService)
monkeypatch.setattr(
risk_controller,
"RiskEvidenceArchiveService",
StubRiskEvidenceArchiveService,
)
monkeypatch.setattr(
risk_controller,
"RiskNotificationService",
StubRiskNotificationService,
)
monkeypatch.setattr(
risk_controller,
"RiskDailyReportService",
StubRiskDailyReportService,
)
monkeypatch.setattr(
risk_controller,
"RiskDailyReportMailService",
StubRiskDailyReportMailService,
)
application = create_app()
application.dependency_overrides[build_request_context] = context
application.dependency_overrides[get_session] = session
return TestClient(application)
def test_risk_routes_require_authentication() -> None:
with TestClient(create_app()) as client:
response = client.get("/api/v1/risk/overview")
scan = client.post("/api/v1/risk/alerts/scan")
assert response.status_code == 401
assert response.json()["error"]["code"] == "AUTHENTICATION_REQUIRED"
assert scan.status_code == 401
def test_overview_uses_success_envelope(monkeypatch) -> None:
with authenticated_client(monkeypatch) as client:
response = client.get("/api/v1/risk/overview")
assert response.status_code == 200
assert response.json() == {
"data": {
"total": 2,
"levels": {"高风险": 1},
"pending": 1,
"overdue": 0,
},
"meta": {"trace_id": "risk-trace"},
}
def test_alert_and_detail_routes_bind_parameters(monkeypatch) -> None:
with authenticated_client(monkeypatch) as client:
alerts = client.get("/api/v1/risk/alerts?limit=5&risk_level=高")
detail = client.get("/api/v1/risk/alerts/ALERT-001")
assert alerts.status_code == 200
assert alerts.json()["data"]["items"][0]["alert_no"] == "ALERT-001"
assert detail.status_code == 200
assert detail.json()["data"]["alert"]["alert_no"] == "ALERT-001"
def test_evidence_route_and_page_limit_are_enforced(monkeypatch) -> None:
with authenticated_client(monkeypatch) as client:
valid = client.get("/api/v1/risk/evidence/customers?limit=10")
invalid = client.get("/api/v1/risk/evidence/customers?limit=11")
assert valid.status_code == 200
assert valid.json()["data"]["items"] == [{"source": "customers"}]
assert invalid.status_code == 422
def test_scan_route_returns_success_envelope(monkeypatch) -> None:
with authenticated_client(monkeypatch) as client:
response = client.post("/api/v1/risk/alerts/scan")
assert response.status_code == 200
assert response.json() == {
"data": {
"message": "规则扫描完成",
"created_count": 2,
"high_risk_count": 1,
},
"meta": {"trace_id": "risk-trace"},
}
def test_action_routes_require_authentication() -> None:
with TestClient(create_app()) as client:
response = client.post("/api/v1/risk/alerts/ALERT-001/acknowledgements")
assert response.status_code == 401
def test_action_routes_use_success_envelope_and_validate_body(monkeypatch) -> None:
with authenticated_client(monkeypatch) as client:
acknowledged = client.post("/api/v1/risk/alerts/ALERT-001/acknowledgements")
investigated = client.post("/api/v1/risk/alerts/ALERT-001/investigations")
excluded = client.post(
"/api/v1/risk/alerts/ALERT-001/exclusions",
json={"reason": "客户本人确认"},
)
resolved = client.post(
"/api/v1/risk/alerts/ALERT-001/resolutions",
json={"resolution": "已核实并留痕"},
)
escalated = client.post(
"/api/v1/risk/alerts/ALERT-001/escalations",
json={"reason": "需要高级复核"},
)
invalid = client.post(
"/api/v1/risk/alerts/ALERT-001/exclusions",
json={"reason": " "},
)
assert acknowledged.status_code == 200
assert investigated.status_code == 200
assert excluded.json()["data"]["handle_result"] == "客户本人确认"
assert resolved.json()["data"]["handle_result"] == "已核实并留痕"
assert escalated.json()["data"]["is_escalated"] is True
assert invalid.status_code == 422
def test_evidence_upload_uses_success_envelope(monkeypatch) -> None:
with authenticated_client(monkeypatch) as client:
response = client.post(
"/api/v1/risk/alerts/ALERT-001/evidence",
files={"evidence_file": ("ALERT-001.png", b"png-data", "image/png")},
)
assert response.status_code == 200
assert response.json()["data"] == {
"alert_no": "ALERT-001",
"evidence_archived": True,
"stored_name": "ALERT-001.png",
"file_size": 8,
}
def test_notification_query_uses_notification_schema(monkeypatch) -> None:
with authenticated_client(monkeypatch) as client:
response = client.get("/api/v1/risk/notifications?limit=10&send_status=已发送")
invalid = client.get("/api/v1/risk/notifications?limit=11")
assert response.status_code == 200
assert response.json()["data"]["items"] == [
{"notification_id": "N-001", "alert_no": "ALERT-001"}
]
assert invalid.status_code == 422
def test_daily_report_generate_stream_and_mail(monkeypatch) -> None:
with authenticated_client(monkeypatch) as client:
generated = client.post(
"/api/v1/risk/daily-report",
json={"report_date": "2026-09-10"},
)
streamed = client.post(
"/api/v1/risk/daily-report/stream",
json={"report_date": "2026-09-10"},
)
mailed = client.post(
"/api/v1/risk/daily-report/mail",
json={
"recipients": ["risk@example.com", "RISK@example.com"],
"subject": "风控日报",
"content": "日报正文",
},
)
invalid = client.post(
"/api/v1/risk/daily-report/mail",
json={
"recipients": ["not-an-email"],
"subject": "风控日报",
"content": "日报正文",
},
)
assert generated.status_code == 200
assert generated.json()["data"]["content"] == "日报正文"
assert streamed.status_code == 200
assert streamed.headers["content-type"].startswith("text/event-stream")
assert "event: start" in streamed.text
assert "event: done" in streamed.text
assert mailed.status_code == 200
assert mailed.json()["data"] == {"status": "dry_run", "recipient_count": 1}
assert invalid.status_code == 422