袁聪的第一次提交,包含nl2sql,行情数据,场外申购
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
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))
|
||||
@@ -0,0 +1,275 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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, 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
|
||||
|
||||
TRACE_ID = ""
|
||||
|
||||
|
||||
async def override_context() -> RequestContext:
|
||||
return RequestContext(
|
||||
user_id="1",
|
||||
trace_id=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_successful_notification_send_is_idempotent(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
global TRACE_ID
|
||||
TRACE_ID = f"trace-send-success-{uuid4()}"
|
||||
payload = _subscription_payload()
|
||||
attachment_path = tmp_path / "申购申请单.pdf"
|
||||
attachment_path.write_bytes(b"original-pdf")
|
||||
payload["attachments"][0]["original_file_path"] = str(attachment_path)
|
||||
sent_requests: list[object] = []
|
||||
|
||||
class FakeSuccessSender:
|
||||
def __init__(self, _settings: object) -> None:
|
||||
pass
|
||||
|
||||
def send_reply(self, request: object) -> SmtpSendResult:
|
||||
sent_requests.append(request)
|
||||
return SmtpSendResult(
|
||||
status="发送成功",
|
||||
dry_run=False,
|
||||
provider_message_id="provider-test-001",
|
||||
failure_reason=None,
|
||||
retry_count=0,
|
||||
request_summary={"provider": "test"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.service.offsite_fund_service.OffsiteSmtpSender", FakeSuccessSender)
|
||||
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)
|
||||
data = created.json()["data"]
|
||||
mail_id = data["mail_id"]
|
||||
task_id = data["documents"][0]["task_id"]
|
||||
_confirm_and_create_notice(client, task_id)
|
||||
notice = client.post(
|
||||
f"/api/v1/offsite-fund/documents/{task_id}/notifications",
|
||||
json={"notification_type": "mail_return", "operator_id": "operator-001"},
|
||||
)
|
||||
notification_id = int(notice.json()["data"]["notification_id"])
|
||||
|
||||
first = client.post(
|
||||
f"/api/v1/offsite-fund/notifications/{notification_id}/send",
|
||||
json={"operator_id": "operator-001", "operator_confirmed": True},
|
||||
)
|
||||
second = client.post(
|
||||
f"/api/v1/offsite-fund/notifications/{notification_id}/send",
|
||||
json={"operator_id": "operator-001", "operator_confirmed": True},
|
||||
)
|
||||
|
||||
assert first.json()["data"]["status"] == "发送成功"
|
||||
assert first.json()["data"]["provider_message_id"] == "provider-test-001"
|
||||
assert second.json()["data"]["status"] == "发送成功"
|
||||
assert len(sent_requests) == 1
|
||||
asyncio.run(_assert_notification(notification_id, "发送成功", "provider-test-001"))
|
||||
asyncio.run(_assert_mail_status(mail_id, "normal_return_sent"))
|
||||
finally:
|
||||
asyncio.run(_cleanup(mail_id, task_id, notification_id, TRACE_ID))
|
||||
app.dependency_overrides.clear()
|
||||
TRACE_ID = ""
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_failed_notification_send_persists_failure_and_retry_count(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
global TRACE_ID
|
||||
TRACE_ID = f"trace-send-failure-{uuid4()}"
|
||||
payload = _subscription_payload()
|
||||
attachment_path = tmp_path / "申购申请单.pdf"
|
||||
attachment_path.write_bytes(b"original-pdf")
|
||||
payload["attachments"][0]["original_file_path"] = str(attachment_path)
|
||||
|
||||
class FakeFailureSender:
|
||||
def __init__(self, _settings: object) -> None:
|
||||
pass
|
||||
|
||||
def send_reply(self, request: object) -> SmtpSendResult:
|
||||
del request
|
||||
return SmtpSendResult(
|
||||
status="发送失败",
|
||||
dry_run=False,
|
||||
provider_message_id=None,
|
||||
failure_reason="SMTPException",
|
||||
retry_count=1,
|
||||
request_summary={"provider": "test"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.service.offsite_fund_service.OffsiteSmtpSender", FakeFailureSender)
|
||||
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)
|
||||
data = created.json()["data"]
|
||||
mail_id = data["mail_id"]
|
||||
task_id = data["documents"][0]["task_id"]
|
||||
_confirm_and_create_notice(client, task_id)
|
||||
notice = client.post(
|
||||
f"/api/v1/offsite-fund/documents/{task_id}/notifications",
|
||||
json={"notification_type": "mail_return", "operator_id": "operator-001"},
|
||||
)
|
||||
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},
|
||||
)
|
||||
|
||||
assert sent.json()["data"]["status"] == "发送失败"
|
||||
assert sent.json()["data"]["failure_reason"] == "SMTPException"
|
||||
assert sent.json()["data"]["retry_count"] == 1
|
||||
asyncio.run(_assert_notification(notification_id, "发送失败", None))
|
||||
finally:
|
||||
asyncio.run(_cleanup(mail_id, task_id, notification_id, TRACE_ID))
|
||||
app.dependency_overrides.clear()
|
||||
TRACE_ID = ""
|
||||
|
||||
|
||||
def _confirm_and_create_notice(client: TestClient, task_id: str) -> None:
|
||||
confirmed = client.post(
|
||||
f"/api/v1/offsite-fund/documents/{task_id}/confirmations",
|
||||
json={"decision": "确认正常", "operator_id": "operator-001"},
|
||||
)
|
||||
assert confirmed.status_code == 200
|
||||
|
||||
|
||||
def _subscription_payload() -> dict[str, object]:
|
||||
suffix = uuid4().hex[:8]
|
||||
return {
|
||||
"imap_uid": f"offsite-{suffix}",
|
||||
"message_id": f"<{suffix}@integration.local>",
|
||||
"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-{suffix}",
|
||||
"申请日期": "2026-09-10",
|
||||
"代销机构": "测试代销",
|
||||
"申购金额": "10000",
|
||||
"金额单位": "元",
|
||||
"最新净值": "1.0000",
|
||||
"基金最新总份额": "1000000",
|
||||
"申请前持有份额": "1000",
|
||||
},
|
||||
"field_confidence": {"基金代码": "0.99", "申购金额": "0.98"},
|
||||
"page_evidence": {"基金代码": [1], "申购金额": [1]},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def _assert_notification(
|
||||
notification_id: int, status: str, provider_message_id: str | None
|
||||
) -> 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 == status
|
||||
assert notice.provider_message_id == provider_message_id
|
||||
if status == "发送成功":
|
||||
assert notice.sent_at is not None
|
||||
else:
|
||||
assert notice.sent_at is None
|
||||
|
||||
|
||||
async def _assert_mail_status(mail_id: str, status: str) -> None:
|
||||
async with SessionFactory() as session:
|
||||
mail = await session.scalar(select(OffsiteFundMail).where(
|
||||
OffsiteFundMail.mail_id == mail_id
|
||||
))
|
||||
assert mail is not None
|
||||
assert mail.status == status
|
||||
|
||||
|
||||
async def _cleanup(mail_id: str, task_id: str, notification_id: int, trace_id: str) -> None:
|
||||
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 notification_id:
|
||||
await session.execute(delete(OffsiteNotification).where(
|
||||
OffsiteNotification.id == notification_id
|
||||
))
|
||||
if 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
|
||||
))
|
||||
Reference in New Issue
Block a user