Files
group_fqcd_jr/tests/integration/test_handover_outbox_mysql.py
T

187 lines
7.5 KiB
Python
Raw Normal View History

"""C1 回归:转人工申请只能有一条路由,且必须真正写入 Outbox 事件。
历史上 `conversations.py` 与 `public_platform.py` 各注册了一份
`POST /api/v1/conversations/{session_id}/handover-requests`,前者先注册生效且
只写审计,导致转人工的异步链路(Outbox → Worker)永不触发。本文件用真实 HTTP
请求 + 真实 MySQL 断言唯一路由与事件落库。
"""
import asyncio
from collections.abc import Iterator
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete, select, text
from app.api.dependencies.auth import build_request_context
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.conversation import ConversationMessage
from app.model.platform import DomainEventOutbox, HandoverTicket
from app.model.session import ConversationSession
HANDOVER_PATH = "/api/v1/conversations/{session_id}/handover-requests"
TEST_USER_ID = 9001 # 与 tools/seed_test_rbac.py 的测试客户号段一致
def _flatten(routes: Any) -> Iterator[Any]:
"""展开 include_router 的结果。
当前 FastAPI 版本把 include_router 的记录保留为 `_IncludedRouter` 而不摊平
到 `app.routes`,查重必须先递归展开到叶子路由。
"""
for route in routes:
inner = getattr(route, "original_router", None)
if inner is not None:
yield from _flatten(inner.routes)
else:
yield route
def test_handover_route_is_registered_exactly_once_by_writing_controller() -> None:
all_routes = list(_flatten(app.routes))
routes = [
route
for route in all_routes
if getattr(route, "path", None) == HANDOVER_PATH
and "POST" in (getattr(route, "methods", None) or set())
]
assert len(routes) == 1, [getattr(route, "path", None) for route in all_routes]
# 唯一入口必须落在写 Outbox 的那份实现上。
assert routes[0].endpoint.__module__ == "app.api.controllers.public_platform"
seen: list[tuple[str, str]] = [
(method, getattr(route, "path", ""))
for route in all_routes
for method in (getattr(route, "methods", None) or set())
]
duplicates = sorted({item for item in seen if seen.count(item) > 1})
assert duplicates == [], f"存在重复注册的路由: {duplicates}"
@pytest.mark.integration
def test_handover_request_writes_ticket_event_and_audit() -> None:
session_id = f"it-handover-{uuid4().hex}"
key = f"it-handover-key-{uuid4().hex}"
handover_id = ""
async def prepare() -> None:
async with SessionFactory() as db:
db.add(
ConversationSession(
session_id=session_id, user_id=TEST_USER_ID, portal="api",
agent_type="customer_service", status="active",
clarification_round=0, message_count=1,
)
)
await db.flush()
db.add(
ConversationMessage(
session_id=session_id, message_no=1, customer_id=TEST_USER_ID,
portal="api", role="user", content="我要转人工",
created_at=datetime.now(UTC).replace(tzinfo=None),
)
)
await db.commit()
async def context() -> RequestContext:
return RequestContext(
user_id=str(TEST_USER_ID), trace_id=str(uuid4()),
roles=("customer",), permissions=("handover:create",),
)
app.dependency_overrides[build_request_context] = context
asyncio.run(prepare())
try:
with TestClient(app) as client:
response = client.post(
HANDOVER_PATH.format(session_id=session_id),
json={"reason_code": "user_requested", "reason_detail": "集成测试转人工"},
headers={"Idempotency-Key": key},
)
assert response.status_code == 202, response.text
body = response.json()
handover_id = body["data"]["handover_id"]
assert handover_id.startswith("ticket-")
assert body["data"]["session_id"] == session_id
assert body["data"]["status"] == "pending"
async def verify() -> tuple[HandoverTicket | None, DomainEventOutbox | None,
list[InteractionAudit], list[InteractionAudit]]:
async with SessionFactory() as db:
ticket = await db.scalar(
select(HandoverTicket).where(HandoverTicket.ticket_no == handover_id)
)
event = await db.scalar(
select(DomainEventOutbox).where(
DomainEventOutbox.event_type == "conversation.transfer_requested",
DomainEventOutbox.aggregate_id == session_id,
)
)
audits = list(
await db.scalars(
select(InteractionAudit).where(
InteractionAudit.action_type == "platform.handover",
InteractionAudit.session_id == session_id,
)
)
)
legacy = list(
await db.scalars(
select(InteractionAudit).where(
InteractionAudit.action_type == "conversation.transfer_requested",
InteractionAudit.session_id == session_id,
)
)
)
return ticket, event, audits, legacy
ticket, event, audits, legacy = asyncio.run(verify())
assert ticket is not None and ticket.status == "pending"
assert ticket.source_message_id is not None # 取自会话首条消息
assert event is not None, "转人工必须写 Outbox 事件"
assert event.aggregate_type == "conversation"
assert event.status == "pending"
assert event.payload["ticket_no"] == handover_id
assert len(audits) == 1
assert legacy == [], "旧实现(只写审计、不发事件)不得再被调用"
finally:
async def cleanup() -> None:
async with SessionFactory() as db:
await db.execute(
delete(HandoverTicket).where(HandoverTicket.session_id == session_id)
)
await db.execute(
delete(DomainEventOutbox).where(
DomainEventOutbox.aggregate_id == session_id
)
)
await db.execute(
delete(InteractionAudit).where(
InteractionAudit.session_id == session_id
)
)
await db.execute(
delete(ConversationMessage).where(
ConversationMessage.session_id == session_id
)
)
await db.execute(
delete(ConversationSession).where(
ConversationSession.session_id == session_id
)
)
await db.execute(
text("DELETE FROM api_request_receipt WHERE idempotency_key = :key"),
{"key": key},
)
await db.commit()
asyncio.run(cleanup())
app.dependency_overrides.clear()