2026-09-10 21:03:44 +08:00
|
|
|
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
|
|
|
|
|
|
2026-09-11 14:08:55 +08:00
|
|
|
async def authorize(self, _context: RequestContext) -> None:
|
|
|
|
|
"""SSE 端点要求构造 `StreamingResponse` 之前先完成鉴权(docs/25 P3 #24)。"""
|
|
|
|
|
|
2026-09-10 21:03:44 +08:00
|
|
|
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:
|
2026-09-11 13:39:50 +08:00
|
|
|
async def send(
|
|
|
|
|
self,
|
|
|
|
|
recipients: list[str],
|
|
|
|
|
_subject: str,
|
|
|
|
|
_content: str,
|
|
|
|
|
*,
|
|
|
|
|
context: RequestContext,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
# 真实实现会先 `require("risk:report:mail")`;这里只复现调用形状,
|
|
|
|
|
# 权限本身由 test_risk_daily_report_service 里那组用例覆盖。
|
|
|
|
|
del context
|
2026-09-10 21:03:44 +08:00
|
|
|
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
|
2026-09-11 14:01:16 +08:00
|
|
|
assert alerts.json()["data"][0]["alert_no"] == "ALERT-001"
|
2026-09-10 21:03:44 +08:00
|
|
|
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
|
2026-09-11 14:01:16 +08:00
|
|
|
assert valid.json()["data"] == [{"source": "customers"}]
|
2026-09-10 21:03:44 +08:00
|
|
|
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
|
2026-09-11 14:01:16 +08:00
|
|
|
assert response.json()["data"] == [
|
2026-09-10 21:03:44 +08:00
|
|
|
{"notification_id": "N-001", "alert_no": "ALERT-001"}
|
|
|
|
|
]
|
2026-09-11 14:01:16 +08:00
|
|
|
# 列表资源的分页元数据必须在 `meta` 里(docs/05 §3.3),而不是混进 `data`
|
|
|
|
|
assert "next_cursor" in response.json()["meta"]
|
|
|
|
|
assert "has_more" in response.json()["meta"]
|
2026-09-10 21:03:44 +08:00
|
|
|
assert invalid.status_code == 422
|
|
|
|
|
|
|
|
|
|
|
2026-09-11 14:01:16 +08:00
|
|
|
def test_list_endpoints_follow_the_documented_envelope(monkeypatch) -> None:
|
|
|
|
|
"""`data` 是纯数组、游标与 has_more 在 `meta` —— docs/05 §3.3 的列表样例。
|
|
|
|
|
|
|
|
|
|
原先 `_page` 的 `{items, next_cursor, has_more}` 被整体塞进 `data`,游标因此出现在
|
|
|
|
|
**业务数据**里,而 §3.3 明确「业务接口不得增加其他顶层字段」。
|
|
|
|
|
"""
|
|
|
|
|
with authenticated_client(monkeypatch) as client:
|
|
|
|
|
body = client.get("/api/v1/risk/alerts?limit=5").json()
|
|
|
|
|
|
|
|
|
|
assert isinstance(body["data"], list), "data 必须是纯数组"
|
|
|
|
|
assert "items" not in body["data"] if isinstance(body["data"], dict) else True
|
|
|
|
|
assert set(body["meta"]) == {"trace_id", "next_cursor", "has_more"}
|
|
|
|
|
assert set(body) == {"data", "meta"}, "不得增加其他顶层字段"
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 21:03:44 +08:00
|
|
|
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
|