test_merchant.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. import asyncio
  2. import hashlib
  3. import os
  4. from uuid import UUID, uuid4
  5. import pytest
  6. from langchain_core.messages import HumanMessage, message_to_dict
  7. from sqlalchemy import func, select
  8. from tests.test_after_sales import scenario # noqa: F401
  9. from zhifu.modules.agent_runtime.config import AgentSettings
  10. from zhifu.modules.agent_runtime.models import AgentRun, ModelAttempt
  11. from zhifu.modules.agent_runtime.service import queue_run
  12. from zhifu.modules.conversations.api import Message, accept_message
  13. from zhifu.modules.conversations.models import Conversation
  14. from zhifu.modules.identity.access_models import Principal, StoreGrant
  15. from zhifu.modules.identity.auth import authenticate_hash
  16. from zhifu.modules.merchant.models import StaffInvitation
  17. pytestmark = pytest.mark.integration
  18. async def grant_management(s):
  19. async with s["app"].state.sessions.begin() as session:
  20. for permission in ["stores:manage", "staff:manage", "channels:manage", "agent:publish"]:
  21. session.add(StoreGrant(tenant_id=s["tenant"].bytes, store_id=s["store"].bytes,
  22. principal_id=s["accounts"]["agent"].bytes,
  23. permission=permission))
  24. async def test_staff_store_permissions_versions_and_sessions(scenario): # noqa: F811
  25. s = scenario
  26. await grant_management(s)
  27. call = s["call"]
  28. base = f"/admin/v1/stores/{s['store']}"
  29. assert (await call("GET", base, "customer")).status_code == 403
  30. assert (await call("GET", base, "reviewer")).status_code == 404
  31. staff = await call("GET", base + "/staff", "agent")
  32. assert staff.status_code == 200, staff.text
  33. row = next(item for item in staff.json()["items"] if item["account"] == "reviewer")
  34. path = base + f"/staff/{row['id']}/status"
  35. change = {"expected_version": row["version"], "enabled": False}
  36. key = str(uuid4())
  37. disabled = await call("PUT", path, "agent", change, key)
  38. assert disabled.status_code == 200, disabled.text
  39. assert (await call("PUT", path, "agent", change, key)).json() == disabled.json()
  40. assert (await call("GET", "/openapi/v1/auth/me", "reviewer")).status_code == 401
  41. assert (await call("PUT", path, "agent", change)).status_code == 409
  42. assert (await call("PUT", path, "agent", {
  43. "expected_version": row["version"] + 1, "enabled": True
  44. })).status_code == 200
  45. # 启用后原会话保持撤销,员工需要重新验证身份。
  46. assert (await call("GET", "/openapi/v1/auth/me", "reviewer")).status_code == 401
  47. store = (await call("GET", base, "agent")).json()
  48. assert (await call("PUT", base + "/status", "agent", {
  49. "expected_version": store["version"], "enabled": False
  50. })).status_code == 200
  51. assert (await call("GET", base + "/exchange-rules", "agent")).status_code == 404
  52. assert (await call("GET", base, "agent")).status_code == 200
  53. assert (await call("PUT", base + "/status", "agent", {
  54. "expected_version": store["version"] + 1, "enabled": True
  55. })).status_code == 200
  56. async def test_invitation_persistence_and_no_implicit_login(scenario): # noqa: F811
  57. s = scenario
  58. await grant_management(s)
  59. base = f"/admin/v1/stores/{s['store']}"
  60. body = {"account": "invited.staff", "role": "staff"}
  61. key = str(uuid4())
  62. response = await s["call"]("POST", base + "/staff/invitations", "agent", body, key)
  63. assert response.status_code == 201, response.text
  64. assert response.json()["invited"] and not response.json()["enabled"]
  65. repeated = await s["call"]("POST", base + "/staff/invitations", "agent", body, key)
  66. assert repeated.json() == response.json()
  67. assert (await s["call"]("POST", base + "/staff/invitations", "agent", body)).status_code == 409
  68. async with s["app"].state.sessions.begin() as session:
  69. invitation = await session.scalar(select(StaffInvitation).where(
  70. StaffInvitation.tenant_id == s["tenant"].bytes,
  71. StaffInvitation.account == body["account"]))
  72. assert invitation.status == "PENDING"
  73. assert await session.scalar(select(Principal).where(
  74. Principal.tenant_id == s["tenant"].bytes,
  75. Principal.username == body["account"])) is None
  76. async def test_rule_draft_publish_and_stale_action(scenario): # noqa: F811
  77. s = scenario
  78. base = f"/admin/v1/stores/{s['store']}/exchange-rules"
  79. call = s["call"]
  80. state = (await call("GET", base, "agent")).json()
  81. assert state["current"]["version"] == 1 and state["draft"] is None
  82. body = {"expected_version": 0, "base_version": 1, "window_days": 14,
  83. "product_types": ["keyboard"], "citation": "签收十四天内功能故障可申请换货。"}
  84. saved = await call("PUT", base + "/draft", "agent", body)
  85. assert saved.status_code == 200, saved.text
  86. assert (await call("GET", base, "agent")).json()["draft"]["window_days"] == 14
  87. assert (await call("PUT", base + "/draft", "agent", body)).status_code == 409
  88. publish = {"expected_version": saved.json()["version"], "base_version": 1}
  89. key = str(uuid4())
  90. response = await call("POST", base + "/draft/publish", "agent", publish, key)
  91. assert response.status_code == 200, response.text
  92. assert response.json()["version"] == 2
  93. repeated = await call("POST", base + "/draft/publish", "agent", publish, key)
  94. assert repeated.json() == response.json()
  95. public = await call("GET", f"/openapi/v1/stores/{s['store']}/exchange-rules/current")
  96. assert public.json()["window_days"] == 14
  97. saved = await call("PUT", base + "/draft", "agent", {**body, "base_version": 2})
  98. assert saved.status_code == 200
  99. assert (await call("POST", base + "/draft/publish", "agent", publish)).status_code == 409
  100. async def test_channel_validation_and_release_requirements(scenario): # noqa: F811
  101. s = scenario
  102. await grant_management(s)
  103. base = f"/admin/v1/stores/{s['store']}"
  104. body = {"expected_version": 0, "name": "渠道配置验证", "scope_confirmed": True,
  105. "callback_url": "http://127.0.0.1/events"}
  106. assert (await s["call"]("PUT", base + "/channels", "agent", body)).status_code == 422
  107. assert (await s["call"]("PUT", base + "/channels", "agent", {
  108. **body, "callback_url": "https://127.0.0.1/events"
  109. })).status_code == 422
  110. channels = (await s["call"]("GET", base + "/channels", "agent")).json()
  111. assert channels["channel"] is None
  112. assert channels["store"] == (await s["call"]("GET", base, "agent")).json()
  113. candidate = await s["call"]("GET", base + "/agent-releases", "agent")
  114. assert candidate.status_code == 200, candidate.text
  115. assert candidate.json()["current"] is None and candidate.json()["errors"]
  116. release = await s["call"]("POST", base + "/agent-releases", "agent", {
  117. "expected_version": 0, "candidate_hash": candidate.json()["candidate_hash"],
  118. "regression_checked": True, "reason": "核对配置完整性"
  119. })
  120. assert release.status_code == 409 and release.json()["code"] == "RELEASE_NOT_READY"
  121. async def persist_cancelled_snapshot(s, conversation_id=None, key=None):
  122. # 队列记录与取消状态在同一事务提交,工作进程无法看到可执行任务。
  123. token = s["headers"]["customer"]["Authorization"].removeprefix("Bearer ")
  124. async with s["app"].state.sessions.begin() as session:
  125. auth = await authenticate_hash(
  126. session, hashlib.sha256(token.encode()).digest(), str(uuid4())
  127. )
  128. conversation = await session.get(
  129. Conversation, UUID(conversation_id).bytes, with_for_update=True
  130. ) if conversation_id else None
  131. request_key = key or str(uuid4())
  132. if conversation:
  133. event = await accept_message(
  134. session, auth, conversation,
  135. Message(text="查询当前店铺规则",
  136. expected_control_version=conversation.control_version),
  137. request_key,
  138. )
  139. run = await session.get(AgentRun, UUID(event["payload"]["run_id"]).bytes)
  140. conversation.active_run_id = None
  141. else:
  142. run = await queue_run(
  143. session, auth, s["store"].bytes, request_key,
  144. hashlib.sha256(request_key.encode()).digest(),
  145. [message_to_dict(HumanMessage(content="查询当前店铺规则"))],
  146. )
  147. run.status = "CANCELLED"
  148. return run.id
  149. async def test_agent_release_success_and_execution_snapshots(scenario): # noqa: F811
  150. s = scenario
  151. settings = AgentSettings()
  152. if not os.getenv(settings.credential_env):
  153. pytest.skip("发布成功验收需要配置真实模型凭据")
  154. await grant_management(s)
  155. async with s["app"].state.sessions.begin() as session:
  156. session.add(StoreGrant(
  157. tenant_id=s["tenant"].bytes, store_id=s["store"].bytes,
  158. principal_id=s["accounts"]["agent"].bytes, permission="knowledge:write",
  159. ))
  160. call = s["call"]
  161. base = f"/admin/v1/stores/{s['store']}"
  162. document_id = uuid4()
  163. document_path = base + f"/knowledge/{document_id}"
  164. release_path = base + "/agent-releases"
  165. async def create_conversation():
  166. response = await call(
  167. "POST", "/openapi/v1/conversations",
  168. payload={"store_id": str(s["store"])},
  169. )
  170. assert response.status_code == 201, response.text
  171. return response.json()["conversation_id"]
  172. try:
  173. draft = await call("PUT", document_path, "agent", {
  174. "expected_version": 0, "title": "键盘售后服务说明",
  175. "text": "键盘出现功能故障时,请查询订单和当前换货规则,由客户确认后交主管审批。",
  176. })
  177. assert draft.status_code == 200, draft.text
  178. published = await call("POST", document_path + "/publish", "agent", {
  179. "expected_version": draft.json()["version"],
  180. })
  181. assert published.status_code == 200, published.text
  182. assert published.json()["status"] == "PUBLISHED"
  183. first_candidate = (await call("GET", release_path, "agent")).json()
  184. assert first_candidate["errors"] == []
  185. body = {
  186. "expected_version": 0, "candidate_hash": first_candidate["candidate_hash"],
  187. "regression_checked": True, "reason": "核对发布条件、规则与知识版本",
  188. }
  189. key = str(uuid4())
  190. first_response = await call("POST", release_path, "agent", body, key)
  191. assert first_response.status_code == 201, first_response.text
  192. first = first_response.json()
  193. assert first["version"] == 1
  194. assert first["manifest"]["knowledge"][0]["document_id"] == str(document_id)
  195. assert first["manifest"]["knowledge"][0]["version"] == published.json()["version"]
  196. repeated = await call("POST", release_path, "agent", body, key)
  197. assert repeated.status_code == 201 and repeated.json() == first
  198. conflicting = await call(
  199. "POST", release_path, "agent", {**body, "reason": "其他发布说明"}, key
  200. )
  201. assert conflicting.status_code == 409
  202. assert conflicting.json()["code"] == "IDEMPOTENCY_CONFLICT"
  203. stale = await call("POST", release_path, "agent", body)
  204. assert stale.status_code == 409 and stale.json()["code"] == "VERSION_CONFLICT"
  205. old_conversation = await create_conversation()
  206. async with s["app"].state.sessions.begin() as session:
  207. old = await session.get(Conversation, UUID(old_conversation).bytes)
  208. assert old.agent_release_version is None
  209. # 保留升级前会话可能存在的历史列值,验证它不再参与后续运行的版本选择。
  210. old.agent_release_version = 1
  211. first_message_key = str(uuid4())
  212. first_run_id = await persist_cancelled_snapshot(
  213. s, old_conversation, first_message_key
  214. )
  215. revised = await call("PUT", document_path, "agent", {
  216. "expected_version": published.json()["version"], "title": "键盘售后服务说明",
  217. "text": "键盘功能故障需要核对订单及有效换货规则;运输中请保护键盘并保留包装。",
  218. })
  219. assert revised.status_code == 200, revised.text
  220. republished = await call("POST", document_path + "/publish", "agent", {
  221. "expected_version": revised.json()["version"],
  222. })
  223. assert republished.status_code == 200, republished.text
  224. second_candidate = (await call("GET", release_path, "agent")).json()
  225. assert second_candidate["errors"] == []
  226. assert second_candidate["candidate_hash"] != first_candidate["candidate_hash"]
  227. changed = await call("POST", release_path, "agent", {**body, "expected_version": 1})
  228. assert changed.status_code == 409 and changed.json()["code"] == "VERSION_CONFLICT"
  229. second_response = await call("POST", release_path, "agent", {
  230. **body, "expected_version": 1,
  231. "candidate_hash": second_candidate["candidate_hash"],
  232. })
  233. assert second_response.status_code == 201, second_response.text
  234. second = second_response.json()
  235. assert second["version"] == 2
  236. assert second["manifest"]["knowledge"][0]["version"] == republished.json()["version"]
  237. # 同一消息重试继续返回原运行,不因中途发布而重复创建运行或替换快照。
  238. assert await persist_cancelled_snapshot(
  239. s, old_conversation, first_message_key
  240. ) == first_run_id
  241. new_conversation = await create_conversation()
  242. old_run_id = await persist_cancelled_snapshot(s, old_conversation)
  243. new_run_id = await persist_cancelled_snapshot(s, new_conversation)
  244. independent_run_id = await persist_cancelled_snapshot(s)
  245. async with s["app"].state.sessions.begin() as session:
  246. old = await session.get(Conversation, UUID(old_conversation).bytes)
  247. new = await session.get(Conversation, UUID(new_conversation).bytes)
  248. assert old.agent_release_version == 1 and new.agent_release_version is None
  249. for run_id, expected in [
  250. (first_run_id, first), (old_run_id, second),
  251. (new_run_id, second), (independent_run_id, second),
  252. ]:
  253. stored = await session.get(AgentRun, run_id)
  254. assert stored.release_snapshot == expected["manifest"]
  255. assert stored.graph_version == expected["manifest"]["graph_version"]
  256. assert stored.model_name == settings.model_name
  257. assert stored.model_url == settings.model_url
  258. assert stored.model_credential_env == settings.credential_env
  259. assert stored.status == "CANCELLED" and stored.model_calls == 0
  260. continued = await session.get(AgentRun, old_run_id)
  261. assert continued.conversation_id == old.id
  262. assert [item["data"]["content"] for item in continued.messages] == [
  263. "查询当前店铺规则", "查询当前店铺规则",
  264. ]
  265. assert old.sequence == 2 and old.active_run_id is None
  266. attempts = await session.scalar(select(func.count()).select_from(ModelAttempt).where(
  267. ModelAttempt.tenant_id == s["tenant"].bytes,
  268. ))
  269. assert attempts == 0
  270. current = (await call("GET", release_path, "agent")).json()["current"]
  271. assert current == second
  272. finally:
  273. # 只清理本次随机租户与资料标识对应的向量,MySQL 由 scenario 清理。
  274. knowledge = s["app"].state.knowledge
  275. await asyncio.to_thread(
  276. knowledge.milvus.delete, knowledge.settings.collection,
  277. filter=f'tenant == "{s["tenant"].hex}" and document == "{document_id.hex}"',
  278. timeout=knowledge.settings.timeout,
  279. )