test_conversations.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. import asyncio
  2. import hashlib
  3. import json
  4. import os
  5. from contextlib import AsyncExitStack
  6. from datetime import UTC, datetime, timedelta
  7. from uuid import UUID
  8. import httpx
  9. import pytest
  10. from httpx_sse import aconnect_sse
  11. from sqlalchemy import select
  12. from test_after_sales import scenario # noqa: F401
  13. from zhifu.core.errors import BusinessError
  14. from zhifu.modules.agent_runtime.models import AgentRun
  15. from zhifu.modules.agent_runtime.runtime import locked_run
  16. from zhifu.modules.agent_runtime.worker import claim
  17. from zhifu.modules.conversations.models import Conversation, ConversationEvent
  18. from zhifu.modules.conversations.stream import read_events
  19. from zhifu.modules.identity.access_models import StoreGrant
  20. from zhifu.modules.identity.auth import now
  21. from zhifu.operations.conversation_retention import advance_window
  22. pytestmark = pytest.mark.integration
  23. async def setup(context):
  24. async with context["app"].state.sessions.begin() as session:
  25. for name in ("agent", "reviewer"):
  26. for permission in ("conversations:read", "conversations:manage", "conversations:reply"):
  27. session.add(
  28. StoreGrant(
  29. tenant_id=context["tenant"].bytes,
  30. principal_id=context["accounts"][name].bytes,
  31. store_id=context["store"].bytes,
  32. permission=permission,
  33. )
  34. )
  35. response = await context["call"](
  36. "POST", "/openapi/v1/conversations", payload={"store_id": str(context["store"])}, key="new"
  37. )
  38. assert response.status_code == 201, response.text
  39. return "/openapi/v1/conversations/" + response.json()["conversation_id"]
  40. async def test_takeover_invalidates_agent_and_release_restores_control(scenario): # noqa: F811
  41. path = await setup(scenario)
  42. response = await scenario["call"](
  43. "POST", path + "/messages", payload={"text": "订单查询", "expected_control_version": 1}
  44. )
  45. assert response.status_code == 201
  46. sessions = scenario["app"].state.sessions
  47. run_id, token = await claim(sessions, 2)
  48. response = await scenario["call"](
  49. "POST", path + "/takeover", "agent", {"expected_control_version": 1}
  50. )
  51. assert response.status_code == 200, response.text
  52. async with sessions.begin() as session:
  53. with pytest.raises(BusinessError, match="控制权已变更"):
  54. await locked_run(session, run_id, token)
  55. response = await scenario["call"](
  56. "POST", path + "/messages", payload={"text": "客服你好", "expected_control_version": 2}
  57. )
  58. assert response.json()["payload"]["run_id"] is None
  59. response = await scenario["call"](
  60. "POST", path + "/messages", "reviewer", {"text": "回复", "expected_control_version": 2}
  61. )
  62. assert response.status_code == 403
  63. response = await scenario["call"](
  64. "POST", path + "/messages", "agent", {"text": "正在处理", "expected_control_version": 2}
  65. )
  66. assert response.status_code == 201
  67. response = await scenario["call"](
  68. "POST", path + "/release", "agent", {"expected_control_version": 2}
  69. )
  70. assert response.json()["control_version"] == 3
  71. response = await scenario["call"](
  72. "POST", path + "/messages", payload={"text": "继续查询", "expected_control_version": 3}
  73. )
  74. assert response.json()["payload"]["run_id"] is not None
  75. async def test_competing_takeovers_and_message_idempotency(scenario): # noqa: F811
  76. path = await setup(scenario)
  77. original = (await scenario["call"]("GET", path)).json()
  78. assert original["channel"] == "h5"
  79. created_at = datetime.fromisoformat(original["created_at"])
  80. assert created_at.utcoffset() == timedelta(0)
  81. responses = await asyncio.gather(
  82. *(
  83. scenario["call"]("POST", path + "/takeover", actor, {"expected_control_version": 1})
  84. for actor in ("agent", "reviewer")
  85. )
  86. )
  87. assert sorted(response.status_code for response in responses) == [200, 409]
  88. body = {"text": "同一消息", "expected_control_version": 2}
  89. first = await scenario["call"]("POST", path + "/messages", payload=body, key="message")
  90. again = await scenario["call"]("POST", path + "/messages", payload=body, key="message")
  91. assert first.json() == again.json()
  92. history = (await scenario["call"]("GET", path + "/messages")).json()["items"]
  93. assert history[-1] == first.json()
  94. # 通过实际数据库记录验证展示时间来源,接管和重复发送保留原会话与事件时间。
  95. async with scenario["app"].state.sessions.begin() as session:
  96. conversation_id = UUID(original["conversation_id"]).bytes
  97. conversation = await session.get(Conversation, conversation_id)
  98. assert created_at == conversation.created_at.replace(tzinfo=UTC)
  99. persisted = (await session.scalars(select(ConversationEvent).where(
  100. ConversationEvent.tenant_id == scenario["tenant"].bytes,
  101. ConversationEvent.conversation_id == conversation_id,
  102. ).order_by(ConversationEvent.sequence))).all()
  103. assert [datetime.fromisoformat(item["created_at"]) for item in history] == [
  104. item.created_at.replace(tzinfo=UTC) for item in persisted
  105. ]
  106. response = await scenario["call"]("GET", path, "other")
  107. assert response.status_code == 404
  108. response = await scenario["call"]("GET", path + "/messages", "foreign")
  109. assert response.status_code == 404
  110. listing = f"/openapi/v1/conversations?store_id={scenario['store']}"
  111. response = await scenario["call"]("GET", listing, "other")
  112. assert response.json()["items"] == []
  113. response = await scenario["call"]("GET", listing, "agent")
  114. assert len(response.json()["items"]) == 1
  115. listed = response.json()["items"][0]
  116. assert listed["channel"] == "h5" and listed["created_at"] == original["created_at"]
  117. assert (await scenario["call"]("GET", path)).json()["created_at"] == original["created_at"]
  118. async def test_sse_http_reconnect_and_revocation(scenario): # noqa: F811
  119. path = await setup(scenario)
  120. await scenario["call"]("POST", path + "/takeover", "agent", {"expected_control_version": 1})
  121. async with httpx.AsyncClient(
  122. base_url=os.environ.get("ACCEPTANCE_API_URL", "http://127.0.0.1:18000"), timeout=10
  123. ) as client:
  124. async with aconnect_sse(
  125. client, "GET", path + "/events", headers=scenario["headers"]["customer"]
  126. ) as stream:
  127. iterator = stream.aiter_sse()
  128. event = await anext(iterator)
  129. assert event.event == "control.changed" and event.id == "1"
  130. history = (await scenario["call"]("GET", path + "/messages")).json()["items"]
  131. assert json.loads(event.data)["created_at"] == history[0]["created_at"]
  132. await scenario["call"](
  133. "POST", path + "/messages", "agent", {"text": "处理进度", "expected_control_version": 2}
  134. )
  135. headers = {**scenario["headers"]["customer"], "Last-Event-ID": "1"}
  136. async with aconnect_sse(client, "GET", path + "/events", headers=headers) as stream:
  137. iterator = stream.aiter_sse()
  138. event = await anext(iterator)
  139. assert event.id == "2" and event.event == "message.staff"
  140. history = (await scenario["call"]("GET", path + "/messages")).json()["items"]
  141. assert json.loads(event.data)["created_at"] == history[-1]["created_at"]
  142. await scenario["call"]("DELETE", "/openapi/v1/auth/sessions/current")
  143. event = await anext(iterator)
  144. assert event.event == "access.revoked"
  145. async def test_sse_connection_limit_and_cleanup(scenario): # noqa: F811
  146. path = await setup(scenario)
  147. await scenario["call"]("POST", path + "/takeover", "agent", {"expected_control_version": 1})
  148. async with httpx.AsyncClient(
  149. base_url=os.environ.get("ACCEPTANCE_API_URL", "http://127.0.0.1:18000"), timeout=10
  150. ) as client:
  151. async with AsyncExitStack() as connections:
  152. iterators = []
  153. for _ in range(3):
  154. stream = await connections.enter_async_context(
  155. aconnect_sse(
  156. client, "GET", path + "/events", headers=scenario["headers"]["customer"]
  157. )
  158. )
  159. iterators.append(stream.aiter_sse())
  160. assert (await anext(iterators[-1])).event == "control.changed"
  161. async with client.stream(
  162. "GET", path + "/events", headers=scenario["headers"]["customer"]
  163. ) as response:
  164. assert response.status_code == 429
  165. key = f"sse:actor:{scenario['tenant'].hex}:{scenario['accounts']['customer'].hex}"
  166. async with asyncio.timeout(5):
  167. while True:
  168. if await scenario["app"].state.state.zcard(key) == 0:
  169. break
  170. await asyncio.sleep(0.05)
  171. async def test_expired_and_cancelled_runs_release_conversation(scenario): # noqa: F811
  172. path = await setup(scenario)
  173. sessions = scenario["app"].state.sessions
  174. body = {"text": "查询订单", "expected_control_version": 1}
  175. response = await scenario["call"]("POST", path + "/messages", payload=body)
  176. run_id = UUID(response.json()["payload"]["run_id"])
  177. async with sessions.begin() as session:
  178. run = await session.get(AgentRun, run_id.bytes)
  179. run.deadline_at = now() - timedelta(seconds=1)
  180. assert await claim(sessions, 2) is None
  181. response = await scenario["call"]("GET", path)
  182. assert response.json()["active_run_id"] is None
  183. response = await scenario["call"]("POST", path + "/messages", payload=body)
  184. run_id = response.json()["payload"]["run_id"]
  185. response = await scenario["call"]("POST", f"/openapi/v1/agent/runs/{run_id}/cancel")
  186. assert response.status_code == 200
  187. response = await scenario["call"]("GET", path)
  188. assert response.json()["active_run_id"] is None
  189. async def test_stream_window_requires_state_reload_and_keeps_history(scenario): # noqa: F811
  190. path = await setup(scenario)
  191. await scenario["call"]("POST", path + "/takeover", "agent", {"expected_control_version": 1})
  192. conversation_id = UUID(path.rsplit("/", 1)[1])
  193. sessions = scenario["app"].state.sessions
  194. cursor = await advance_window(sessions, scenario["tenant"].bytes,
  195. conversation_id.bytes, now())
  196. assert cursor == 1
  197. response = await scenario["call"]("GET", path + "/events")
  198. assert response.status_code == 410 and response.json()["code"] == "EVENT_CURSOR_EXPIRED"
  199. assert (await scenario["call"]("GET", path)).json()["stream_cursor"] == 1
  200. assert len((await scenario["call"]("GET", path + "/messages")).json()["items"]) == 1
  201. token = scenario["headers"]["customer"]["Authorization"].removeprefix("Bearer ")
  202. with pytest.raises(BusinessError, match="事件游标已过期"):
  203. await read_events(sessions, hashlib.sha256(token.encode()).digest(), "window-test",
  204. conversation_id.bytes, 0, asyncio.Semaphore(1))