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