import asyncio from collections.abc import AsyncIterator from pathlib import Path from uuid import uuid4 import pytest from fastapi.testclient import TestClient from sqlalchemy import delete, func, select from sqlalchemy.ext.asyncio import AsyncSession 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.infrastructure.db import SessionFactory from app.main import app from app.model.audit import InteractionAudit from app.model.offsite_fund import ( OffsiteExecutionPlanTask, OffsiteFundAttachment, OffsiteFundDocument, OffsiteFundMail, OffsiteNotification, OffsiteQueryRecord, OffsiteRuleResult, ) from app.service.offsite_smtp_adapter import SmtpSendResult TEST_TRACE_ID = "" async def override_context() -> RequestContext: return RequestContext( user_id="1", trace_id=TEST_TRACE_ID or str(uuid4()), roles=("operator",), permissions=("offsite:write",), data_scope="all", ) async def override_session() -> AsyncIterator[AsyncSession]: async with SessionFactory() as session: yield session @pytest.mark.integration def test_offsite_recognized_mail_persists_workflow_and_notification() -> None: global TEST_TRACE_ID TEST_TRACE_ID = f"trace-offsite-{uuid4()}" uid = f"offsite-{uuid4()}" message_id = f"<{uuid4()}@integration.local>" payload = { "imap_uid": uid, "message_id": message_id, "sender": "15008108550@163.com", "return_path": "15008108550@163.com", "auth_result": {"spf": "pass", "dkim": "pass"}, "eml_path": "mock/offsite.eml", "attachments": [ { "filename": "申购申请单.pdf", "file_hash": f"hash-{uuid4().hex}", "media_type": "application/pdf", "size_bytes": 2048, "document_type": "subscription", "ocr_text": "基金代码 000001 申购金额 10000", "extracted_fields": { "基金代码": "000001", "基金名称": "测试基金", "账户标识": "ACCT-001", "投资者名称": "测试客户", "申请编号": f"SUB-{uuid4().hex[:8]}", "申请日期": "2026-09-10", "代销机构": "测试代销", "申购金额": "10000", "金额单位": "元", "最新净值": "1.0000", "基金最新总份额": "1000000", "申请前持有份额": "1000", }, "field_confidence": {"基金代码": "0.99", "申购金额": "0.98"}, "page_evidence": {"基金代码": [1], "申购金额": [1]}, } ], } app.dependency_overrides[build_request_context] = override_context app.dependency_overrides[get_session] = override_session mail_id = "" task_id = "" try: with TestClient(app) as client: response = client.post("/api/v1/offsite-fund/recognized-mails", json=payload) assert response.status_code == 200 body = response.json() assert body["code"] == 0 mail_id = body["data"]["mail_id"] task_id = body["data"]["documents"][0]["task_id"] assert body["data"]["documents"][0]["rule_results"] == { "subscription_minimum_amount": "正常", "subscription_holding_ratio": "正常", "subscription_single_share_limit": "正常", } duplicate = client.post("/api/v1/offsite-fund/recognized-mails", json=payload) assert duplicate.status_code == 200 assert duplicate.json()["data"] == {"mail_id": mail_id} confirm = client.post( f"/api/v1/offsite-fund/documents/{task_id}/confirmations", json={"decision": "确认正常", "operator_id": "operator-001"}, ) assert confirm.status_code == 200 assert confirm.json()["code"] == 0 recalc = client.post( "/api/v1/offsite-fund/settlement-statistics/recalculate", json={"fund_code": "000001", "application_date": "2026-09-10"}, ) assert recalc.status_code == 200 assert recalc.json()["data"]["subscription_amount_yuan"] == "10000.0000" notice = client.post( f"/api/v1/offsite-fund/documents/{task_id}/notifications", json={"notification_type": "settlement", "operator_id": "operator-001"}, ) assert notice.status_code == 200 assert notice.json()["data"]["notification_id"] asyncio.run(_assert_offsite_rows(mail_id, task_id, TEST_TRACE_ID)) finally: asyncio.run(_cleanup_offsite_rows(mail_id, task_id, TEST_TRACE_ID)) app.dependency_overrides.clear() TEST_TRACE_ID = "" @pytest.mark.integration def test_offsite_trigger_nl2sql_uses_query_dict_adapter() -> None: global TEST_TRACE_ID TEST_TRACE_ID = f"trace-offsite-nl2sql-{uuid4()}" uid = f"offsite-{uuid4()}" message_id = f"<{uuid4()}@integration.local>" payload = _subscription_payload(uid, message_id) app.dependency_overrides[build_request_context] = override_context app.dependency_overrides[get_session] = override_session mail_id = "" task_id = "" try: with TestClient(app) as client: created = client.post("/api/v1/offsite-fund/recognized-mails", json=payload) assert created.status_code == 200 body = created.json() mail_id = body["data"]["mail_id"] task_id = body["data"]["documents"][0]["task_id"] response = client.post( f"/api/tasks/{task_id}/trigger-agent-nl2sql", json={"operator_id": "1", "manual_confirmed": True}, ) assert response.status_code == 200 data = response.json()["data"] assert data["task_id"] == task_id assert len(data["queries"]) == 2 assert {item["status"] for item in data["queries"]} <= { "ready", "success", "need_confirmation", "rejected", "error", } asyncio.run(_assert_nl2sql_rows(task_id)) finally: asyncio.run(_cleanup_offsite_rows(mail_id, task_id, TEST_TRACE_ID)) app.dependency_overrides.clear() TEST_TRACE_ID = "" @pytest.mark.integration def test_offsite_low_confidence_document_goes_to_recognition_exception() -> None: global TEST_TRACE_ID TEST_TRACE_ID = f"trace-offsite-low-confidence-{uuid4()}" uid = f"offsite-{uuid4()}" message_id = f"<{uuid4()}@integration.local>" payload = _subscription_payload(uid, message_id) payload["attachments"][0]["field_confidence"] = {"基金代码": "0.79", "申购金额": "0.99"} app.dependency_overrides[build_request_context] = override_context app.dependency_overrides[get_session] = override_session mail_id = "" task_id = "" try: with TestClient(app) as client: response = client.post("/api/v1/offsite-fund/recognized-mails", json=payload) assert response.status_code == 200 body = response.json() mail_id = body["data"]["mail_id"] document = body["data"]["documents"][0] task_id = document["task_id"] assert document["status"] == "recognition_exception" finally: asyncio.run(_cleanup_offsite_rows(mail_id, task_id, TEST_TRACE_ID)) app.dependency_overrides.clear() TEST_TRACE_ID = "" @pytest.mark.integration def test_offsite_service_rejects_missing_permission() -> None: async def no_permission_context() -> RequestContext: return RequestContext( user_id="1", trace_id=f"trace-offsite-denied-{uuid4()}", roles=("operator",), permissions=(), ) payload = _subscription_payload(f"offsite-{uuid4()}", f"<{uuid4()}@integration.local>") app.dependency_overrides[build_request_context] = no_permission_context app.dependency_overrides[get_session] = override_session try: with TestClient(app) as client: response = client.post("/api/v1/offsite-fund/recognized-mails", json=payload) assert response.status_code == 200 assert response.json()["code"] == 403 assert "缺少场外基金操作权限" in response.json()["message"] finally: app.dependency_overrides.clear() @pytest.mark.integration def test_offsite_mail_return_send_updates_notification_without_external_call( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: global TEST_TRACE_ID TEST_TRACE_ID = f"trace-offsite-send-{uuid4()}" uid = f"offsite-{uuid4()}" message_id = f"<{uuid4()}@integration.local>" payload = _subscription_payload(uid, message_id) attachment_path = tmp_path / "申购申请单.pdf" attachment_path.write_bytes(b"pdf-bytes") payload["attachments"][0]["original_file_path"] = str(attachment_path) class FakeDryRunSender: def __init__(self, _settings: object) -> None: pass def send_reply(self, request: object) -> SmtpSendResult: del request return SmtpSendResult( status="待发送", dry_run=True, provider_message_id=None, failure_reason=None, retry_count=0, request_summary={"provider": "test"}, ) monkeypatch.setattr("app.service.offsite_fund_service.OffsiteSmtpSender", FakeDryRunSender) app.dependency_overrides[build_request_context] = override_context app.dependency_overrides[get_session] = override_session mail_id = "" task_id = "" notification_id = 0 try: with TestClient(app) as client: created = client.post("/api/v1/offsite-fund/recognized-mails", json=payload) assert created.status_code == 200 data = created.json()["data"] mail_id = data["mail_id"] task_id = data["documents"][0]["task_id"] confirmed = client.post( f"/api/v1/offsite-fund/documents/{task_id}/confirmations", json={"decision": "确认正常", "operator_id": "operator-001"}, ) assert confirmed.status_code == 200 notice = client.post( f"/api/v1/offsite-fund/documents/{task_id}/notifications", json={"notification_type": "mail_return", "operator_id": "operator-001"}, ) assert notice.status_code == 200 notification_id = int(notice.json()["data"]["notification_id"]) sent = client.post( f"/api/v1/offsite-fund/notifications/{notification_id}/send", json={ "operator_id": "operator-001", "operator_confirmed": True, "final_content": "运营确认后的回复正文", }, ) assert sent.status_code == 200 assert sent.json()["data"] == { "notification_id": str(notification_id), "status": "待发送", "dry_run": True, "provider_message_id": None, "failure_reason": None, "retry_count": 0, } asyncio.run(_assert_notification_pending(notification_id)) finally: asyncio.run(_cleanup_offsite_rows(mail_id, task_id, TEST_TRACE_ID)) app.dependency_overrides.clear() TEST_TRACE_ID = "" def _subscription_payload(uid: str, message_id: str) -> dict[str, object]: return { "imap_uid": uid, "message_id": message_id, "sender": "15008108550@163.com", "return_path": "15008108550@163.com", "auth_result": {"spf": "pass", "dkim": "pass"}, "eml_path": "mock/offsite.eml", "attachments": [ { "filename": "申购申请单.pdf", "file_hash": f"hash-{uuid4().hex}", "original_file_path": "mock/申购申请单.pdf", "media_type": "application/pdf", "size_bytes": 2048, "document_type": "subscription", "ocr_text": "基金代码 000001 申购金额 10000", "extracted_fields": { "基金代码": "000001", "基金名称": "测试基金", "账户标识": "ACCT-001", "投资者名称": "测试客户", "申请编号": f"SUB-{uuid4().hex[:8]}", "申请日期": "2026-09-10", "代销机构": "测试代销", "申购金额": "10000", "金额单位": "元", "最新净值": "1.0000", "基金最新总份额": "1000000", "申请前持有份额": "1000", }, "field_confidence": {"基金代码": "0.99", "申购金额": "0.98"}, "page_evidence": {"基金代码": [1], "申购金额": [1]}, } ], } async def _assert_offsite_rows(mail_id: str, task_id: str, trace_id: str) -> None: async with SessionFactory() as session: assert await _count(session, OffsiteFundMail, OffsiteFundMail.mail_id == mail_id) == 1 assert await _count( session, OffsiteFundAttachment, OffsiteFundAttachment.mail_id == mail_id ) == 1 assert await _count( session, OffsiteFundDocument, OffsiteFundDocument.task_id == task_id ) == 1 assert await _count(session, OffsiteRuleResult, OffsiteRuleResult.task_id == task_id) == 3 assert await _count( session, OffsiteExecutionPlanTask, OffsiteExecutionPlanTask.task_id == task_id ) == 9 assert await _count( session, OffsiteNotification, OffsiteNotification.business_key == task_id ) == 1 assert await _count( session, InteractionAudit, InteractionAudit.action_type.like("offsite.%") ) >= 1 async def _assert_nl2sql_rows(task_id: str) -> None: async with SessionFactory() as session: assert await _count(session, OffsiteQueryRecord, OffsiteQueryRecord.task_id == task_id) == 2 tasks = (await session.execute(select(OffsiteExecutionPlanTask).where( OffsiteExecutionPlanTask.task_id == task_id, OffsiteExecutionPlanTask.stage == "查询", OffsiteExecutionPlanTask.rule_code != "subscription_minimum_amount", ))).scalars().all() assert {task.status for task in tasks} <= {"已完成", "查询失败", "无法判断"} assert all(task.output_json is not None for task in tasks) async def _assert_notification_pending(notification_id: int) -> None: async with SessionFactory() as session: notice = await session.scalar(select(OffsiteNotification).where( OffsiteNotification.id == notification_id )) assert notice is not None assert notice.status == "待发送" assert notice.provider_message_id is None assert notice.failure_reason is None assert notice.sent_at is None async def _count(session: AsyncSession, model: type, criterion: object) -> int: return int(await session.scalar(select(func.count()).select_from(model).where(criterion)) or 0) async def _cleanup_offsite_rows(mail_id: str, task_id: str, trace_id: str) -> None: if not mail_id and not task_id: return async with SessionFactory() as session, session.begin(): if trace_id: await session.execute( delete(InteractionAudit).where( InteractionAudit.detail["trace_id"].as_string() == trace_id ) ) if task_id: await session.execute( delete(OffsiteNotification).where(OffsiteNotification.business_key == task_id) ) await session.execute( delete(OffsiteQueryRecord).where(OffsiteQueryRecord.task_id == task_id) ) await session.execute( delete(OffsiteRuleResult).where(OffsiteRuleResult.task_id == task_id) ) await session.execute( delete(OffsiteExecutionPlanTask).where( OffsiteExecutionPlanTask.task_id == task_id ) ) await session.execute( delete(OffsiteFundDocument).where(OffsiteFundDocument.task_id == task_id) ) if mail_id: await session.execute( delete(OffsiteFundAttachment).where(OffsiteFundAttachment.mail_id == mail_id) ) await session.execute(delete(OffsiteFundMail).where(OffsiteFundMail.mail_id == mail_id))