Files
group_fqcd_jr/tests/integration/test_offsite_fund_api.py
T

425 lines
17 KiB
Python
Raw Normal View History

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))