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 IDEMPOTENCY = {"Idempotency-Key": "risk-controller-test-0001"} 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 StubApiTransactionService: """幂等透传替身:直接执行 action,不写 `api_request_receipt`。 单测不连库,幂等读写的真实语义由 `tests/integration` 覆盖;这里只保证请求体与 状态码断言不被数据库依赖污染。 """ async def execute_in( self, session: Any, _context: RequestContext, _scope: str, _key: str | None, _body: Any, action: Any, ) -> dict[str, Any]: return await action(session) class StubRiskScanService: def __init__(self, _session: Any) -> None: pass @classmethod def from_settings(cls, session: Any) -> "StubRiskScanService": return cls(session) 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 authorize(self, _context: RequestContext) -> None: """SSE 端点要求构造 `StreamingResponse` 之前先完成鉴权(docs/25 P3 #24)。""" 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: 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 return {"status": "dry_run", "recipient_count": len(recipients)} def authenticated_client(monkeypatch, *, idempotent: bool = True) -> 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, ) if idempotent: monkeypatch.setattr( risk_controller, "ApiTransactionService", StubApiTransactionService, ) 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"][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"] == [{"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", headers=IDEMPOTENCY) 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", headers=IDEMPOTENCY ) investigated = client.post( "/api/v1/risk/alerts/ALERT-001/investigations", headers=IDEMPOTENCY ) excluded = client.post( "/api/v1/risk/alerts/ALERT-001/exclusions", json={"reason": "客户本人确认"}, headers=IDEMPOTENCY, ) resolved = client.post( "/api/v1/risk/alerts/ALERT-001/resolutions", json={"resolution": "已核实并留痕"}, headers=IDEMPOTENCY, ) escalated = client.post( "/api/v1/risk/alerts/ALERT-001/escalations", json={"reason": "需要高级复核"}, headers=IDEMPOTENCY, ) invalid = client.post( "/api/v1/risk/alerts/ALERT-001/exclusions", json={"reason": " "}, headers=IDEMPOTENCY, ) 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_write_routes_require_idempotency_key(monkeypatch) -> None: """docs/05 §5.1:业务写接口必须携带 `Idempotency-Key`,缺失或过短都是 422。""" with authenticated_client(monkeypatch, idempotent=False) as client: missing = client.post("/api/v1/risk/alerts/ALERT-001/acknowledgements") too_short = client.post( "/api/v1/risk/alerts/ALERT-001/acknowledgements", headers={"Idempotency-Key": "too-short"}, ) assert missing.status_code == 422 assert missing.json()["error"]["code"] == "AGENT_INPUT_INVALID" assert too_short.status_code == 422 assert too_short.json()["error"]["code"] == "AGENT_INPUT_INVALID" 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"] == [ {"notification_id": "N-001", "alert_no": "ALERT-001"} ] # 列表资源的分页元数据必须在 `meta` 里(docs/05 §3.3),而不是混进 `data` assert "next_cursor" in response.json()["meta"] assert "has_more" in response.json()["meta"] assert invalid.status_code == 422 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", "total", "page_size"} assert set(body) == {"data", "meta"}, "不得增加其他顶层字段" 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