test_fulfillment.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. import asyncio
  2. import hashlib
  3. import os
  4. import secrets
  5. from argparse import Namespace
  6. from datetime import timedelta
  7. from uuid import UUID, uuid4
  8. import httpx
  9. import pytest
  10. import pytest_asyncio
  11. from sqlalchemy import delete, func, select
  12. from sqlalchemy.ext.asyncio import async_sessionmaker
  13. from test_after_sales import confirm_proposal, make_proposal, scenario # noqa: F401
  14. from zhifu.core.config import Settings
  15. from zhifu.modules.after_sales.models import BusinessEvent, Ticket
  16. from zhifu.modules.identity.access_models import StoreGrant
  17. from zhifu.modules.identity.auth import now
  18. from zhifu.modules.integrations.client import OmsClient
  19. from zhifu.modules.integrations.fulfillment import execute_due, prepare_event, record_result
  20. from zhifu.modules.integrations.manage import main as configure_connector
  21. from zhifu.modules.integrations.models import (
  22. ConsumerReceipt,
  23. ExternalOperation,
  24. Reservation,
  25. )
  26. from zhifu.modules.integrations.worker import Broker, consume_one, publish_one
  27. from zhifu.oms.database import oms_engine
  28. from zhifu.oms.manage import main as register_credential
  29. from zhifu.oms.models import Credential, Exchange, Order
  30. pytestmark = pytest.mark.integration
  31. @pytest_asyncio.fixture
  32. async def connected(scenario): # noqa: F811
  33. engine = oms_engine()
  34. sessions = async_sessionmaker(engine, expire_on_commit=False)
  35. token = secrets.token_urlsafe(32)
  36. warehouse_token = secrets.token_urlsafe(32)
  37. variable = "ZHIFU_OMS_" + uuid4().hex.upper()
  38. os.environ[variable] = token
  39. await register_credential(
  40. Namespace(
  41. tenant=scenario["tenant"],
  42. store=scenario["store"],
  43. credential_env=variable,
  44. fulfillment=False,
  45. )
  46. )
  47. async with sessions.begin() as session:
  48. session.add(
  49. Credential(
  50. token_hash=hashlib.sha256(warehouse_token.encode()).digest(),
  51. tenant_id=scenario["tenant"].bytes,
  52. store_id=scenario["store"].bytes,
  53. can_fulfill=True,
  54. )
  55. )
  56. await configure_connector(
  57. Namespace(
  58. tenant=scenario["tenant"],
  59. store=scenario["store"],
  60. source_system="oms",
  61. base_url="http://127.0.0.1:8100",
  62. credential_env=variable,
  63. )
  64. )
  65. try:
  66. async with httpx.AsyncClient(
  67. base_url="http://127.0.0.1:8100",
  68. headers={"Authorization": "Bearer " + warehouse_token},
  69. timeout=5,
  70. ) as warehouse:
  71. body = {
  72. name: value
  73. for name, value in scenario["source"].items()
  74. if name not in {"store_id", "source_system"}
  75. }
  76. response = await warehouse.put(f"/orders/{scenario['order_id']}", json=body)
  77. assert response.status_code == 200, response.text
  78. yield {
  79. **scenario,
  80. "warehouse": warehouse,
  81. "oms_sessions": sessions,
  82. "variable": variable,
  83. }
  84. finally:
  85. async with sessions.begin() as session:
  86. for model in (Exchange, Order, Credential):
  87. await session.execute(
  88. delete(model).where(model.tenant_id == scenario["tenant"].bytes)
  89. )
  90. await engine.dispose()
  91. del os.environ[variable]
  92. async def approve(connected):
  93. ticket = await confirm_proposal(connected, await make_proposal(connected))
  94. response = await connected["call"](
  95. "POST",
  96. f"/openapi/v1/approvals/{ticket['approval_id']}/decisions",
  97. "reviewer",
  98. {
  99. "proposal_version": 1,
  100. "expected_version": 2,
  101. "decision": "APPROVED",
  102. "reason": "审核通过",
  103. },
  104. )
  105. assert response.status_code == 200, response.text
  106. async with connected["app"].state.sessions() as session:
  107. event = await session.scalar(
  108. select(BusinessEvent).where(
  109. BusinessEvent.aggregate_id == UUID(ticket["ticket_id"]).bytes,
  110. BusinessEvent.event_type == "after_sale.approved",
  111. )
  112. )
  113. return ticket, event.id
  114. async def operation_for(connected, ticket):
  115. async with connected["app"].state.sessions() as session:
  116. return await session.scalar(
  117. select(ExternalOperation).where(
  118. ExternalOperation.ticket_id == UUID(ticket["ticket_id"]).bytes
  119. )
  120. )
  121. async def due(connected, operation_id):
  122. async with connected["app"].state.sessions.begin() as session:
  123. operation = await session.get(ExternalOperation, operation_id, with_for_update=True)
  124. operation.next_attempt_at = now() - timedelta(seconds=1)
  125. async def test_broker_delivery_and_fulfillment(connected):
  126. ticket, event_id = await approve(connected)
  127. sessions = connected["app"].state.sessions
  128. queue_name = "verify-" + uuid4().hex
  129. async with Broker(Settings().broker_url.get_secret_value(), queue_name, queue_name) as broker:
  130. try:
  131. async with sessions() as session:
  132. event = await session.get(BusinessEvent, event_id)
  133. await broker.publish_event(event)
  134. assert await publish_one(sessions, broker, event_id)
  135. assert await consume_one(sessions, broker, 5)
  136. assert await consume_one(sessions, broker, 5)
  137. async with sessions() as session:
  138. assert (
  139. await session.scalar(
  140. select(func.count())
  141. .select_from(ConsumerReceipt)
  142. .where(ConsumerReceipt.event_id == event_id)
  143. )
  144. == 1
  145. )
  146. assert (await session.get(BusinessEvent, event_id)).delivery_status == "SENT"
  147. operation = await operation_for(connected, ticket)
  148. assert await execute_due(sessions, 5, operation.id)
  149. response = await connected["warehouse"].post(
  150. f"/exchanges/{UUID(bytes=operation.id)}/complete"
  151. )
  152. assert response.status_code == 200
  153. await due(connected, operation.id)
  154. assert await execute_due(sessions, 5, operation.id)
  155. result = await connected["call"]("GET", f"/openapi/v1/tickets/{ticket['ticket_id']}")
  156. assert result.json()["status"] == "COMPLETED"
  157. finally:
  158. await broker.queue.delete(if_unused=False, if_empty=False)
  159. await broker.exchange.delete(if_unused=False)
  160. async def test_two_proposals_cannot_reserve_same_quantity(connected):
  161. one, event_one = await approve(connected)
  162. two, event_two = await approve(connected)
  163. sessions = connected["app"].state.sessions
  164. await asyncio.gather(
  165. prepare_event(sessions, event_one, 5), prepare_event(sessions, event_two, 5)
  166. )
  167. async with sessions() as session:
  168. reservations = (
  169. await session.scalars(
  170. select(Reservation).where(Reservation.tenant_id == connected["tenant"].bytes)
  171. )
  172. ).all()
  173. assert sum(r.quantity for r in reservations) == 1
  174. tickets = (
  175. await session.scalars(
  176. select(Ticket).where(
  177. Ticket.id.in_([UUID(one["ticket_id"]).bytes, UUID(two["ticket_id"]).bytes])
  178. )
  179. )
  180. ).all()
  181. assert sorted(t.status for t in tickets) == ["FAILED", "PROCESSING"]
  182. async def test_external_condition_failure_releases_reservation(connected):
  183. ticket, event_id = await approve(connected)
  184. sessions = connected["app"].state.sessions
  185. await prepare_event(sessions, event_id, 5)
  186. operation = await operation_for(connected, ticket)
  187. async with connected["oms_sessions"].begin() as session:
  188. order = await session.get(Order, connected["order_id"].bytes, with_for_update=True)
  189. order.source_version += 1
  190. assert await execute_due(sessions, 5, operation.id)
  191. async with sessions() as session:
  192. reservation = await session.get(Reservation, operation.id)
  193. assert reservation.status == "RELEASED"
  194. assert (await session.get(Ticket, operation.ticket_id)).status == "FAILED"
  195. async def test_expired_execution_cannot_overwrite_recovered_result(connected):
  196. ticket, event_id = await approve(connected)
  197. sessions = connected["app"].state.sessions
  198. await prepare_event(sessions, event_id, 5)
  199. operation = await operation_for(connected, ticket)
  200. async with sessions.begin() as session:
  201. current = await session.get(ExternalOperation, operation.id, with_for_update=True)
  202. current.lease_token = 1
  203. current.lease_until = now() - timedelta(seconds=1)
  204. client = OmsClient(operation.base_url, operation.credential_env, 5)
  205. try:
  206. result = await client.process(operation.id, operation.request, query_first=False)
  207. finally:
  208. await client.close()
  209. await record_result(sessions, operation.id, 1, result)
  210. async with sessions() as session:
  211. assert (await session.get(ExternalOperation, operation.id)).status == "PREPARED"
  212. assert await execute_due(sessions, 5, operation.id)
  213. async with connected["oms_sessions"]() as session:
  214. assert (
  215. await session.scalar(
  216. select(func.count())
  217. .select_from(Exchange)
  218. .where(Exchange.tenant_id == connected["tenant"].bytes)
  219. )
  220. == 1
  221. )
  222. async def test_prepared_operation_blocks_cancellation(connected):
  223. ticket, event_id = await approve(connected)
  224. await prepare_event(connected["app"].state.sessions, event_id, 5)
  225. response = await connected["call"](
  226. "POST",
  227. f"/openapi/v1/tickets/{ticket['ticket_id']}/cancel",
  228. payload={"expected_version": 4, "reason": "取消"},
  229. )
  230. assert response.status_code == 409
  231. assert response.json()["code"] == "OPERATION_IN_PROGRESS"
  232. async def test_lost_response_keeps_reservation_and_recovers(connected):
  233. ticket, event_id = await approve(connected)
  234. sessions = connected["app"].state.sessions
  235. await prepare_event(sessions, event_id, 5)
  236. operation = await operation_for(connected, ticket)
  237. async with httpx.AsyncClient(base_url="http://127.0.0.1:18474") as proxy:
  238. name = "oms-" + uuid4().hex
  239. response = await proxy.post(
  240. "/proxies",
  241. json={
  242. "name": name,
  243. "listen": "0.0.0.0:18100",
  244. "upstream": "host.docker.internal:8100",
  245. },
  246. )
  247. assert response.status_code == 201, response.text
  248. try:
  249. response = await proxy.post(
  250. f"/proxies/{name}/toxics",
  251. json={
  252. "name": "response-delay",
  253. "type": "latency",
  254. "stream": "downstream",
  255. "attributes": {"latency": 2000, "jitter": 0},
  256. },
  257. )
  258. assert response.status_code == 200, response.text
  259. async with sessions.begin() as session:
  260. current = await session.get(ExternalOperation, operation.id)
  261. current.base_url = "http://127.0.0.1:18100"
  262. assert await execute_due(sessions, 0.3, operation.id)
  263. async with sessions() as session:
  264. assert (await session.get(ExternalOperation, operation.id)).status == "UNKNOWN"
  265. assert (await session.get(Reservation, operation.id)).status == "HELD"
  266. async with connected["oms_sessions"]() as session:
  267. assert (await session.get(Exchange, operation.id)).status == "ACCEPTED"
  268. response = await proxy.delete(f"/proxies/{name}/toxics/response-delay")
  269. assert response.status_code == 204
  270. await due(connected, operation.id)
  271. assert await execute_due(sessions, 5, operation.id)
  272. async with sessions() as session:
  273. assert (await session.get(ExternalOperation, operation.id)).status == "ACCEPTED"
  274. assert (await session.get(Reservation, operation.id)).status == "CONSUMED"
  275. finally:
  276. await proxy.delete(f"/proxies/{name}")
  277. async def test_revoked_approval_permission_prevents_external_operation(connected):
  278. ticket, event_id = await approve(connected)
  279. sessions = connected["app"].state.sessions
  280. async with sessions.begin() as session:
  281. grant = await session.get(
  282. StoreGrant,
  283. (
  284. connected["tenant"].bytes,
  285. connected["accounts"]["reviewer"].bytes,
  286. connected["store"].bytes,
  287. "approvals:decide",
  288. ),
  289. with_for_update=True,
  290. )
  291. await session.delete(grant)
  292. await prepare_event(sessions, event_id, 5)
  293. assert await operation_for(connected, ticket) is None
  294. response = await connected["call"]("GET", f"/openapi/v1/tickets/{ticket['ticket_id']}")
  295. assert response.json()["status"] == "NEEDS_REVISION"
  296. async def test_broker_redelivers_unacknowledged_message(connected):
  297. ticket, event_id = await approve(connected)
  298. sessions = connected["app"].state.sessions
  299. queue_name = "verify-" + uuid4().hex
  300. url = Settings().broker_url.get_secret_value()
  301. async with Broker(url, queue_name, queue_name) as broker:
  302. assert await publish_one(sessions, broker, event_id)
  303. message = await broker.queue.get()
  304. assert message is not None
  305. async with Broker(url, queue_name, queue_name) as broker:
  306. try:
  307. assert await consume_one(sessions, broker, 5)
  308. assert await operation_for(connected, ticket) is not None
  309. finally:
  310. await broker.queue.delete(if_unused=False, if_empty=False)
  311. await broker.exchange.delete(if_unused=False)
  312. async def test_platform_oms_credential_cannot_complete_exchange(connected):
  313. ticket, event_id = await approve(connected)
  314. sessions = connected["app"].state.sessions
  315. await prepare_event(sessions, event_id, 5)
  316. operation = await operation_for(connected, ticket)
  317. assert await execute_due(sessions, 5, operation.id)
  318. async with httpx.AsyncClient(
  319. base_url="http://127.0.0.1:8100",
  320. headers={"Authorization": "Bearer " + os.environ[connected["variable"]]},
  321. ) as client:
  322. response = await client.post(f"/exchanges/{UUID(bytes=operation.id)}/complete")
  323. assert response.status_code == 403