test_after_sales.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  1. import asyncio
  2. from argparse import Namespace
  3. from datetime import UTC, datetime, timedelta
  4. from ipaddress import IPv6Address
  5. from uuid import uuid4
  6. import pytest
  7. import pytest_asyncio
  8. from httpx import ASGITransport, AsyncClient
  9. from sqlalchemy import delete, func, select
  10. from zhifu.app import create_app
  11. from zhifu.manage import run as manage
  12. from zhifu.modules.after_sales.models import (
  13. Approval,
  14. BusinessEvent,
  15. CommandReceipt,
  16. Confirmation,
  17. ExchangeRule,
  18. OrderProjection,
  19. ProposalVersion,
  20. Ticket,
  21. )
  22. from zhifu.modules.agent_runtime.models import AgentRun, ModelAttempt, RunInputEvent
  23. from zhifu.modules.channels.models import ChannelInbox, ChannelKey, ChannelNonce
  24. from zhifu.modules.conversations.models import Conversation, ConversationEvent
  25. from zhifu.modules.identity.access_models import AccessSession, Principal, StoreGrant
  26. from zhifu.modules.identity.auth import password_hasher
  27. from zhifu.modules.identity.models import Store, Tenant
  28. from zhifu.modules.integrations.models import (
  29. Connector,
  30. ConsumerReceipt,
  31. ExternalOperation,
  32. Reservation,
  33. )
  34. from zhifu.modules.knowledge.models import KnowledgeDocument
  35. from zhifu.modules.memory.models import MemoryJob, MemoryProfile
  36. from zhifu.modules.memory.service import erase, snapshot
  37. from zhifu.modules.merchant.models import (
  38. AgentRelease,
  39. ChannelApplication,
  40. RuleDraft,
  41. StaffInvitation,
  42. )
  43. from zhifu.modules.notifications.models import Notification
  44. from zhifu.modules.platform.models import ChannelDelivery
  45. from zhifu.modules.storefront.models import (
  46. CatalogCategory,
  47. CatalogProduct,
  48. ChannelIdentity,
  49. IdentityChallenge,
  50. )
  51. pytestmark = pytest.mark.integration
  52. @pytest_asyncio.fixture
  53. async def scenario():
  54. app = create_app()
  55. tenant_ids = [uuid4(), uuid4()]
  56. stores = [uuid4(), uuid4()]
  57. accounts = {
  58. name: uuid4() for name in ("customer", "other", "agent", "reviewer", "connector", "foreign")
  59. }
  60. password = "integration-" + str(uuid4())
  61. encoded = password_hasher.hash(password)
  62. permissions = {
  63. "customer": [
  64. "orders:read",
  65. "tickets:read",
  66. "after_sales:create",
  67. "after_sales:confirm",
  68. "after_sales:cancel",
  69. ],
  70. "other": ["orders:read", "tickets:read", "after_sales:confirm"],
  71. "agent": [
  72. "orders:read",
  73. "tickets:read",
  74. "after_sales:create",
  75. "after_sales:cancel",
  76. "approvals:decide",
  77. "rules:write",
  78. ],
  79. "reviewer": ["tickets:read", "approvals:decide"],
  80. "connector": ["orders:sync"],
  81. "foreign": ["tickets:read", "orders:read", "after_sales:create"],
  82. }
  83. async with app.router.lifespan_context(app):
  84. async with app.state.sessions.begin() as session:
  85. for tenant_id, store_id in zip(tenant_ids, stores, strict=True):
  86. session.add(Tenant(id=tenant_id.bytes, name="集成验收"))
  87. await session.flush()
  88. session.add(Store(id=store_id.bytes, tenant_id=tenant_id.bytes, name="集成店铺"))
  89. await session.flush()
  90. for name, actor_id in accounts.items():
  91. index = 1 if name == "foreign" else 0
  92. kind = "SERVICE" if name == "connector" else "STAFF"
  93. if name in {"customer", "other", "foreign"}:
  94. kind = "CUSTOMER"
  95. session.add(
  96. Principal(
  97. id=actor_id.bytes,
  98. tenant_id=tenant_ids[index].bytes,
  99. username=name,
  100. password_hash=encoded,
  101. kind=kind,
  102. )
  103. )
  104. await session.flush()
  105. for permission in permissions[name]:
  106. session.add(
  107. StoreGrant(
  108. tenant_id=tenant_ids[index].bytes,
  109. principal_id=actor_id.bytes,
  110. store_id=stores[index].bytes,
  111. permission=permission,
  112. )
  113. )
  114. try:
  115. transport = ASGITransport(app=app, client=(str(IPv6Address(tenant_ids[0].int)), 12345))
  116. async with AsyncClient(transport=transport, base_url="http://test") as client:
  117. headers = {}
  118. for name in accounts:
  119. index = 1 if name == "foreign" else 0
  120. response = await client.post(
  121. "/openapi/v1/auth/sessions",
  122. json={
  123. "tenant_id": str(tenant_ids[index]),
  124. "username": name,
  125. "password": password,
  126. },
  127. )
  128. assert response.status_code == 200, response.text
  129. headers[name] = {"Authorization": "Bearer " + response.json()["access_token"]}
  130. order_id = uuid4()
  131. async def call(method, path, actor="customer", payload=None, key=None):
  132. return await client.request(
  133. method,
  134. path,
  135. headers={**headers[actor], "Idempotency-Key": key or str(uuid4())},
  136. json=payload,
  137. )
  138. source = {
  139. "store_id": str(stores[0]),
  140. "subject_id": str(accounts["customer"]),
  141. "source_system": "oms",
  142. "source_version": 1,
  143. "item_id": "keyboard-1",
  144. "product_type": "keyboard",
  145. "available_quantity": 1,
  146. "signed_at": datetime.now(UTC).isoformat(),
  147. }
  148. response = await call("PUT", f"/internal/v1/orders/{order_id}", "connector", source)
  149. assert response.status_code == 200, response.text
  150. response = await call(
  151. "POST",
  152. f"/admin/v1/stores/{stores[0]}/exchange-rules",
  153. "agent",
  154. {
  155. "expected_version": 0,
  156. "window_days": 7,
  157. "product_types": ["keyboard"],
  158. "citation": "商家换货规则第 1 条:签收后七天内受理功能故障换货。",
  159. },
  160. )
  161. assert response.status_code == 201, response.text
  162. yield {
  163. "app": app,
  164. "call": call,
  165. "client": client,
  166. "headers": headers,
  167. "tenant": tenant_ids[0],
  168. "store": stores[0],
  169. "accounts": accounts,
  170. "order_id": order_id,
  171. "source": source,
  172. "proposal": {
  173. "order_id": str(order_id),
  174. "item_id": "keyboard-1",
  175. "quantity": 1,
  176. "reason": "按键失灵",
  177. },
  178. }
  179. finally:
  180. async with app.state.sessions() as session:
  181. profiles = (
  182. await session.scalars(
  183. select(MemoryProfile).where(
  184. MemoryProfile.tenant_id.in_([value.bytes for value in tenant_ids])
  185. )
  186. )
  187. ).all()
  188. for profile in profiles:
  189. await erase(profile.tenant_id, profile.subject_id, snapshot(profile))
  190. async with app.state.sessions.begin() as session:
  191. for model in (
  192. ChannelDelivery,
  193. ChannelInbox,
  194. ChannelNonce,
  195. ChannelKey,
  196. ChannelIdentity,
  197. IdentityChallenge,
  198. CatalogProduct,
  199. CatalogCategory,
  200. AgentRelease,
  201. RuleDraft,
  202. StaffInvitation,
  203. ChannelApplication,
  204. Notification,
  205. MemoryJob,
  206. MemoryProfile,
  207. KnowledgeDocument,
  208. ModelAttempt,
  209. RunInputEvent,
  210. AgentRun,
  211. ConversationEvent,
  212. Conversation,
  213. ConsumerReceipt,
  214. Reservation,
  215. ExternalOperation,
  216. Connector,
  217. BusinessEvent,
  218. CommandReceipt,
  219. Approval,
  220. Confirmation,
  221. ProposalVersion,
  222. Ticket,
  223. ExchangeRule,
  224. OrderProjection,
  225. StoreGrant,
  226. AccessSession,
  227. Principal,
  228. Store,
  229. ):
  230. await session.execute(
  231. delete(model).where(
  232. model.tenant_id.in_([value.bytes for value in tenant_ids])
  233. )
  234. )
  235. await session.execute(
  236. delete(Tenant).where(Tenant.id.in_([value.bytes for value in tenant_ids]))
  237. )
  238. async def takeover_customer_conversation(scenario):
  239. # 通过真实授权与会话接口建立客服接管条件,调用方明确选择此业务准备步骤。
  240. async with scenario["app"].state.sessions.begin() as session:
  241. for permission in ("conversations:read", "conversations:manage", "conversations:reply"):
  242. identity = (
  243. scenario["tenant"].bytes, scenario["accounts"]["agent"].bytes,
  244. scenario["store"].bytes, permission,
  245. )
  246. if await session.get(StoreGrant, identity) is None:
  247. session.add(StoreGrant(
  248. tenant_id=identity[0], principal_id=identity[1],
  249. store_id=identity[2], permission=permission,
  250. ))
  251. response = await scenario["call"](
  252. "POST", "/openapi/v1/conversations", payload={"store_id": str(scenario["store"])}
  253. )
  254. assert response.status_code == 201, response.text
  255. conversation_id = response.json()["conversation_id"]
  256. response = await scenario["call"](
  257. "POST", f"/openapi/v1/conversations/{conversation_id}/takeover", "agent",
  258. {"expected_control_version": 1},
  259. )
  260. assert response.status_code == 200, response.text
  261. return {
  262. "conversation_id": conversation_id,
  263. "expected_control_version": response.json()["payload"]["control_version"],
  264. }
  265. async def make_proposal(scenario, actor="customer", control=None):
  266. response = await scenario["call"](
  267. "POST", "/openapi/v1/after-sales/proposals", actor,
  268. {**scenario["proposal"], **(control or {})},
  269. )
  270. assert response.status_code == 201, response.text
  271. return response.json()
  272. async def confirm_proposal(scenario, proposal):
  273. response = await scenario["call"](
  274. "POST",
  275. f"/openapi/v1/after-sales/proposals/{proposal['proposal_id']}/confirm",
  276. payload={
  277. "proposal_version": proposal["proposal_version"],
  278. "expected_ticket_version": proposal["version"],
  279. },
  280. )
  281. assert response.status_code == 200, response.text
  282. return response.json()
  283. async def test_confirmation_approval_and_isolation(scenario):
  284. call = scenario["call"]
  285. control = await takeover_customer_conversation(scenario)
  286. proposal = await make_proposal(scenario, "agent", control)
  287. path = f"/openapi/v1/after-sales/proposals/{proposal['proposal_id']}/confirm"
  288. confirm = {"proposal_version": 1, "expected_ticket_version": 1}
  289. assert (await call("POST", path, "agent", confirm)).status_code == 403
  290. assert (await call("POST", path, "other", confirm)).status_code == 404
  291. assert (
  292. await call("GET", f"/openapi/v1/tickets/{proposal['ticket_id']}", "foreign")
  293. ).status_code == 404
  294. proposal = await confirm_proposal(scenario, proposal)
  295. path = f"/openapi/v1/approvals/{proposal['approval_id']}/decisions"
  296. decision = {
  297. "expected_version": 2,
  298. "proposal_version": 1,
  299. "decision": "APPROVED",
  300. "reason": "审核通过",
  301. }
  302. assert (await call("POST", path, "agent", decision)).status_code == 403
  303. response = await call("POST", path, "reviewer", decision)
  304. assert response.status_code == 200, response.text
  305. assert response.json()["status"] == "APPROVED"
  306. async with scenario["app"].state.sessions() as session:
  307. count = await session.scalar(
  308. select(func.count())
  309. .select_from(BusinessEvent)
  310. .where(
  311. BusinessEvent.tenant_id == scenario["tenant"].bytes,
  312. BusinessEvent.event_type == "after_sale.approved",
  313. )
  314. )
  315. assert count == 1
  316. async def test_concurrent_idempotency_and_conflict(scenario):
  317. call = scenario["call"]
  318. key = str(uuid4())
  319. responses = await asyncio.gather(
  320. *[
  321. call("POST", "/openapi/v1/after-sales/proposals", payload=scenario["proposal"], key=key)
  322. for _ in range(4)
  323. ]
  324. )
  325. assert all(response.status_code == 201 for response in responses)
  326. assert len({r.json()["ticket_id"] for r in responses}) == 1
  327. response = await call(
  328. "POST",
  329. "/openapi/v1/after-sales/proposals",
  330. payload={**scenario["proposal"], "reason": "新的故障描述"},
  331. key=key,
  332. )
  333. assert response.status_code == 409
  334. assert response.json()["code"] == "IDEMPOTENCY_CONFLICT"
  335. ticket = responses[0].json()
  336. confirm_key = str(uuid4())
  337. replies = await asyncio.gather(
  338. *[
  339. call(
  340. "POST",
  341. f"/openapi/v1/after-sales/proposals/{ticket['proposal_id']}/confirm",
  342. payload={"proposal_version": 1, "expected_ticket_version": 1},
  343. key=confirm_key,
  344. )
  345. for _ in range(3)
  346. ]
  347. )
  348. assert all(r.status_code == 200 for r in replies)
  349. assert len({r.json()["approval_id"] for r in replies}) == 1
  350. async def test_revision_invalidates_previous_approval(scenario):
  351. call = scenario["call"]
  352. proposal = await confirm_proposal(scenario, await make_proposal(scenario))
  353. response = await call(
  354. "POST",
  355. f"/openapi/v1/after-sales/proposals/{proposal['proposal_id']}/revisions",
  356. payload={"expected_version": 2, "quantity": 1, "reason": "补充故障情况"},
  357. )
  358. assert response.status_code == 200, response.text
  359. revised = response.json()
  360. assert revised["proposal_version"] == 2
  361. response = await call(
  362. "POST",
  363. f"/openapi/v1/approvals/{proposal['approval_id']}/decisions",
  364. "reviewer",
  365. {"expected_version": 3, "proposal_version": 1, "decision": "APPROVED", "reason": "审核"},
  366. )
  367. assert response.status_code == 409
  368. assert response.json()["code"] == "PROPOSAL_VERSION_CONFLICT"
  369. current = await confirm_proposal(scenario, revised)
  370. assert current["approval_id"] != proposal["approval_id"]
  371. async def test_permission_revocation_and_logout(scenario):
  372. proposal = await make_proposal(scenario)
  373. async with scenario["app"].state.sessions.begin() as session:
  374. actor = await session.get(
  375. Principal, scenario["accounts"]["customer"].bytes, with_for_update=True
  376. )
  377. await session.execute(
  378. delete(StoreGrant).where(
  379. StoreGrant.principal_id == actor.id, StoreGrant.permission == "tickets:read"
  380. )
  381. )
  382. response = await scenario["call"]("GET", f"/openapi/v1/tickets/{proposal['ticket_id']}")
  383. assert response.status_code == 404
  384. assert (
  385. await scenario["call"]("DELETE", "/openapi/v1/auth/sessions/current")
  386. ).status_code == 204
  387. assert (await scenario["call"]("GET", "/openapi/v1/auth/me")).status_code == 401
  388. async def test_rule_revocation(scenario):
  389. proposal = await make_proposal(scenario)
  390. response = await scenario["call"](
  391. "POST",
  392. f"/admin/v1/stores/{scenario['store']}/exchange-rules/revoke",
  393. "agent",
  394. {"expected_version": 1, "reason": "规则撤销"},
  395. )
  396. assert response.status_code == 200
  397. response = await scenario["call"](
  398. "POST",
  399. f"/openapi/v1/after-sales/proposals/{proposal['proposal_id']}/confirm",
  400. payload={"expected_ticket_version": 1, "proposal_version": 1},
  401. )
  402. assert response.status_code == 422
  403. assert response.json()["code"] == "RULE_NOT_SATISFIED"
  404. async def test_tenant_injection_and_cancellation(scenario):
  405. response = await scenario["call"](
  406. "POST",
  407. "/openapi/v1/after-sales/proposals",
  408. payload={**scenario["proposal"], "tenant_id": str(uuid4())},
  409. )
  410. assert response.status_code == 422
  411. proposal = await make_proposal(scenario)
  412. response = await scenario["call"](
  413. "POST",
  414. f"/openapi/v1/tickets/{proposal['ticket_id']}/cancel",
  415. payload={"expected_version": 1, "reason": "客户取消"},
  416. )
  417. assert response.status_code == 200
  418. assert response.json()["status"] == "CANCELLED"
  419. response = await scenario["call"](
  420. "POST",
  421. f"/openapi/v1/after-sales/proposals/{proposal['proposal_id']}/confirm",
  422. payload={"proposal_version": 1, "expected_ticket_version": 2},
  423. )
  424. assert response.status_code == 409
  425. async def test_order_version_requires_reconfirmation(scenario):
  426. proposal = await make_proposal(scenario)
  427. response = await scenario["call"](
  428. "PUT",
  429. f"/internal/v1/orders/{scenario['order_id']}",
  430. "connector",
  431. {**scenario["source"], "source_version": 2},
  432. )
  433. assert response.status_code == 200
  434. response = await scenario["call"](
  435. "POST",
  436. f"/openapi/v1/after-sales/proposals/{proposal['proposal_id']}/confirm",
  437. payload={"proposal_version": 1, "expected_ticket_version": 1},
  438. )
  439. assert response.status_code == 409
  440. assert response.json()["code"] == "ORDER_VERSION_CONFLICT"
  441. async def test_expired_token_and_login_throttling(scenario):
  442. async with scenario["app"].state.sessions.begin() as session:
  443. access = await session.scalar(
  444. select(AccessSession).where(
  445. AccessSession.principal_id == scenario["accounts"]["customer"].bytes
  446. )
  447. )
  448. access.expires_at = datetime.now(UTC).replace(tzinfo=None) - timedelta(seconds=1)
  449. assert (await scenario["call"]("GET", "/openapi/v1/auth/me")).status_code == 401
  450. invalid_password = "invalid-password-" + str(uuid4())
  451. for _ in range(10):
  452. response = await scenario["client"].post(
  453. "/openapi/v1/auth/sessions",
  454. json={
  455. "tenant_id": str(scenario["tenant"]),
  456. "username": "missing",
  457. "password": invalid_password,
  458. },
  459. )
  460. assert response.status_code == 401
  461. assert invalid_password not in response.text
  462. response = await scenario["client"].post(
  463. "/openapi/v1/auth/sessions",
  464. json={
  465. "tenant_id": str(scenario["tenant"]),
  466. "username": "missing",
  467. "password": invalid_password,
  468. },
  469. )
  470. assert response.status_code == 429
  471. assert response.headers["Retry-After"] == "60"
  472. async def test_approval_cancellation_race(scenario):
  473. ticket = await confirm_proposal(scenario, await make_proposal(scenario))
  474. responses = await asyncio.gather(
  475. scenario["call"](
  476. "POST",
  477. f"/openapi/v1/approvals/{ticket['approval_id']}/decisions",
  478. "reviewer",
  479. {
  480. "expected_version": 2,
  481. "proposal_version": 1,
  482. "decision": "APPROVED",
  483. "reason": "审核",
  484. },
  485. ),
  486. scenario["call"](
  487. "POST",
  488. f"/openapi/v1/tickets/{ticket['ticket_id']}/cancel",
  489. payload={"expected_version": 2, "reason": "取消"},
  490. ),
  491. )
  492. assert sorted(response.status_code for response in responses) == [200, 409]
  493. async def test_revision_submitter_cannot_approve(scenario):
  494. ticket = await make_proposal(scenario)
  495. control = await takeover_customer_conversation(scenario)
  496. response = await scenario["call"](
  497. "POST",
  498. f"/openapi/v1/after-sales/proposals/{ticket['proposal_id']}/revisions",
  499. "agent",
  500. {"expected_version": 1, "quantity": 1, "reason": "客服补充申请", **control},
  501. )
  502. assert response.status_code == 200
  503. ticket = await confirm_proposal(scenario, response.json())
  504. response = await scenario["call"](
  505. "POST",
  506. f"/openapi/v1/approvals/{ticket['approval_id']}/decisions",
  507. "agent",
  508. {
  509. "expected_version": ticket["version"],
  510. "proposal_version": ticket["proposal_version"],
  511. "decision": "APPROVED",
  512. "reason": "审批",
  513. },
  514. )
  515. assert response.status_code == 403
  516. async def test_store_and_list_isolation(scenario):
  517. ticket = await confirm_proposal(scenario, await make_proposal(scenario))
  518. path = f"/openapi/v1/orders?store_id={scenario['store']}"
  519. assert len((await scenario["call"]("GET", path)).json()["items"]) == 1
  520. assert (await scenario["call"]("GET", path, "other")).json()["items"] == []
  521. assert (await scenario["call"]("GET", path, "foreign")).status_code == 404
  522. response = await scenario["call"](
  523. "GET", f"/openapi/v1/approvals?store_id={scenario['store']}", "reviewer"
  524. )
  525. assert response.json()["items"][0]["approval_id"] == ticket["approval_id"]
  526. async with scenario["app"].state.sessions.begin() as session:
  527. store_id = uuid4()
  528. session.add(Store(id=store_id.bytes, tenant_id=scenario["tenant"].bytes, name="未授权店铺"))
  529. response = await scenario["call"]("GET", f"/openapi/v1/orders?store_id={store_id}", "agent")
  530. assert response.status_code == 404
  531. async def test_order_sync_requires_service_and_immutable_version(scenario):
  532. path = f"/internal/v1/orders/{scenario['order_id']}"
  533. assert (await scenario["call"]("PUT", path, "customer", scenario["source"])).status_code == 403
  534. assert (await scenario["call"]("PUT", path, "connector", scenario["source"])).status_code == 200
  535. response = await scenario["call"](
  536. "PUT", path, "connector", {**scenario["source"], "available_quantity": 0}
  537. )
  538. assert response.status_code == 409
  539. async def test_management_revocation_is_audited(scenario):
  540. ticket = await make_proposal(scenario)
  541. await manage(
  542. Namespace(
  543. action="revoke",
  544. tenant=scenario["tenant"],
  545. actor=scenario["accounts"]["agent"],
  546. store=scenario["store"],
  547. permission="tickets:read",
  548. )
  549. )
  550. response = await scenario["call"]("GET", f"/openapi/v1/tickets/{ticket['ticket_id']}", "agent")
  551. assert response.status_code == 404
  552. async with scenario["app"].state.sessions() as session:
  553. event = await session.scalar(
  554. select(BusinessEvent).where(
  555. BusinessEvent.tenant_id == scenario["tenant"].bytes,
  556. BusinessEvent.event_type == "identity.revoke",
  557. )
  558. )
  559. assert event.payload["permission"] == "tickets:read"