test_storefront.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. import asyncio
  2. from datetime import timedelta
  3. from uuid import UUID
  4. import pytest
  5. from sqlalchemy import func, select
  6. from test_after_sales import scenario # noqa: F401
  7. from test_conversations import setup
  8. from zhifu.modules.identity.access_models import StoreGrant
  9. from zhifu.modules.identity.auth import now
  10. from zhifu.modules.merchant.models import ChannelApplication
  11. from zhifu.modules.notifications.models import Notification
  12. from zhifu.modules.storefront.models import CatalogCategory, CatalogProduct, IdentityChallenge
  13. pytestmark = pytest.mark.integration
  14. async def prepare_channel(context):
  15. # 独立测试租户只验证身份应用服务;回调传输由平台 TLS 接收器测试覆盖。
  16. async with context["app"].state.sessions.begin() as session:
  17. session.add(ChannelApplication(
  18. tenant_id=context["tenant"].bytes, store_id=context["store"].bytes,
  19. name="独立身份组件验证渠道", callback_url="https://127.0.0.1/channel-component",
  20. enabled=True, version=1, scopes=["tickets:results"], updated_at=now(),
  21. ))
  22. for permission in ["identities:verify", "tickets:read"]:
  23. session.add(StoreGrant(
  24. tenant_id=context["tenant"].bytes, store_id=context["store"].bytes,
  25. principal_id=context["accounts"]["connector"].bytes, permission=permission,
  26. ))
  27. async def challenge(context, actor="customer", key=None):
  28. response = await context["call"]("POST", "/openapi/v1/me/identity-challenges", actor,
  29. {"store_id": str(context["store"]), "channel": "webhook"}, key)
  30. assert response.status_code == 200, response.text
  31. return response.json()
  32. def proof(context, external="verified-channel-account"):
  33. return {"store_id": str(context["store"]), "channel": "webhook",
  34. "external_subject": external, "display_name": "经过渠道认证的客户"}
  35. async def test_catalog_order_metadata_and_visibility(scenario): # noqa: F811
  36. sessions = scenario["app"].state.sessions
  37. tenant, store = scenario["tenant"].bytes, scenario["store"].bytes
  38. async with sessions.begin() as session:
  39. session.add(CatalogCategory(tenant_id=tenant, store_id=store,
  40. code="keyboard", name="键盘", position=0))
  41. await session.flush()
  42. for code, status in [("keyboard-1", "ACTIVE"), ("keyboard-2", "ACTIVE"),
  43. ("keyboard-3", "HIDDEN")]:
  44. session.add(CatalogProduct(
  45. tenant_id=tenant, store_id=store, id=code, category_code="keyboard",
  46. name="办公键盘", description="日常文字输入", specifications={"款式": "标准款"},
  47. delivery="店铺配送", image_url="/products/keyboard-01.jpg",
  48. image_source="https://www.pexels.com/photo/17479949/", status=status,
  49. ))
  50. client = scenario["client"]
  51. path = f"/openapi/v1/stores/{scenario['store']}"
  52. first = await client.get(path + "/catalog?limit=1")
  53. assert first.status_code == 200
  54. assert first.json()["next_cursor"] == "keyboard-1"
  55. second = await client.get(path + "/catalog?limit=1&cursor=keyboard-1")
  56. assert second.json()["next_cursor"] is None
  57. assert second.json()["items"][0]["id"] == "keyboard-2"
  58. assert (await client.get(path + "/products/keyboard-3")).status_code == 404
  59. assert (await client.get(path + "/catalog?category=missing")).json()["items"] == []
  60. order = await scenario["call"]("GET", f"/openapi/v1/orders/{scenario['order_id']}")
  61. assert order.json()["product_name"] == "办公键盘"
  62. assert order.json()["specifications"] == {"款式": "标准款"}
  63. assert order.json()["fulfillment_status"] == "SIGNED"
  64. assert order.json()["available_quantity"] == 1
  65. orders_path = f"/openapi/v1/orders?store_id={scenario['store']}"
  66. listed = await scenario["call"]("GET", orders_path)
  67. assert listed.status_code == 200, listed.text
  68. assert listed.json()["items"][0]["order_id"] == str(scenario["order_id"])
  69. assert listed.json()["items"][0]["available_quantity"] == 1
  70. foreign = await scenario["call"]("GET", f"/openapi/v1/orders/{scenario['order_id']}", "foreign")
  71. assert foreign.status_code == 404
  72. # 来源更新到零时,列表与详情都应显示最新可售后数量。
  73. synchronized = await scenario["call"](
  74. "PUT", f"/internal/v1/orders/{scenario['order_id']}", "connector",
  75. {**scenario["source"], "source_version": 2, "available_quantity": 0},
  76. )
  77. assert synchronized.status_code == 200, synchronized.text
  78. listed = await scenario["call"]("GET", orders_path)
  79. order = await scenario["call"]("GET", f"/openapi/v1/orders/{scenario['order_id']}")
  80. assert listed.json()["items"][0]["available_quantity"] == 0
  81. assert order.json()["available_quantity"] == 0
  82. async def test_handoff_transaction_retry_and_takeover(scenario): # noqa: F811
  83. path = await setup(scenario)
  84. call = scenario["call"]
  85. body = {"expected_control_version": 1}
  86. requests = await asyncio.gather(*[
  87. call("POST", path + "/human-assistance", payload=body, key="handoff-same")
  88. for _ in range(3)
  89. ])
  90. assert all(response.status_code == 200 for response in requests)
  91. assert requests[0].json() == requests[1].json() == requests[2].json()
  92. assert requests[0].json()["requested"]
  93. assert (await call("GET", path + "/human-assistance")).json()["requested"]
  94. assert (await call("GET", path + "/human-assistance", "other")).status_code == 404
  95. conflict = await call("POST", path + "/human-assistance",
  96. payload={"expected_control_version": 9}, key="handoff-same")
  97. assert conflict.json()["code"] == "IDEMPOTENCY_CONFLICT"
  98. async with scenario["app"].state.sessions() as session:
  99. count = await session.scalar(select(func.count()).select_from(Notification).where(
  100. Notification.tenant_id == scenario["tenant"].bytes,
  101. ))
  102. assert count == 2
  103. assert (await call("POST", path + "/takeover", "agent", body)).status_code == 200
  104. assert not (await call("GET", path + "/human-assistance")).json()["requested"]
  105. assert (await call("POST", path + "/release", "agent",
  106. {"expected_control_version": 2})).status_code == 200
  107. assert not (await call("GET", path + "/human-assistance")).json()["requested"]
  108. fresh = await call("POST", path + "/human-assistance",
  109. payload={"expected_control_version": 3})
  110. assert fresh.status_code == 200 and fresh.json()["requested"]
  111. async def test_identity_proof_confirmation_isolation_and_conflict(scenario): # noqa: F811
  112. await prepare_channel(scenario)
  113. call = scenario["call"]
  114. first = await challenge(scenario, key="challenge-same")
  115. assert await challenge(scenario, key="challenge-same") == first
  116. identifier = first["challenge_id"]
  117. path = f"/openapi/v1/me/identity-challenges/{identifier}"
  118. verify = f"/openapi/v1/channel/identity-challenges/{identifier}/verify"
  119. assert (await call("POST", path + "/confirm")).json()["code"] == "IDENTITY_NOT_VERIFIED"
  120. for actor in ["other", "foreign"]:
  121. assert (await call("GET", path, actor)).status_code == 404
  122. assert (await call("POST", path + "/confirm", actor)).status_code == 404
  123. assert (await call("POST", verify, payload=proof(scenario))).status_code == 403
  124. response = await call("POST", verify, "connector", proof(scenario))
  125. assert response.status_code == 200 and response.json()["status"] == "VERIFIED"
  126. assert (await call("POST", verify, "connector", proof(scenario))).json() == response.json()
  127. conflict = await call("POST", verify, "connector", proof(scenario, "other-account"))
  128. assert conflict.status_code == 409 and conflict.json()["code"] == "IDENTITY_CONFLICT"
  129. confirmed = await asyncio.gather(*[call("POST", path + "/confirm") for _ in range(3)])
  130. assert all(item.status_code == 200 and item.json()["status"] == "LINKED"
  131. for item in confirmed)
  132. identities = await call(
  133. "GET", f"/openapi/v1/me/channel-identities?store_id={scenario['store']}"
  134. )
  135. assert len(identities.json()["items"]) == 1
  136. other = await challenge(scenario, "other")
  137. other_id = other["challenge_id"]
  138. assert (await call("POST", f"/openapi/v1/channel/identity-challenges/{other_id}/verify",
  139. "connector", proof(scenario))).status_code == 200
  140. collision = await call(
  141. "POST", f"/openapi/v1/me/identity-challenges/{other_id}/confirm", "other"
  142. )
  143. assert collision.status_code == 409 and collision.json()["code"] == "IDENTITY_CONFLICT"
  144. tickets = f"/openapi/v1/channel/identities/tickets?store_id={scenario['store']}"
  145. assert (await call("GET", tickets + "&external_subject=verified-channel-account",
  146. "connector")).status_code == 200
  147. missing = await call("GET", tickets + "&external_subject=unknown", "connector")
  148. assert missing.status_code == 404
  149. async def test_identity_expiration_and_channel_disabled(scenario): # noqa: F811
  150. await prepare_channel(scenario)
  151. call = scenario["call"]
  152. pending = await challenge(scenario)
  153. identifier = pending["challenge_id"]
  154. path = f"/openapi/v1/me/identity-challenges/{identifier}"
  155. verify = f"/openapi/v1/channel/identity-challenges/{identifier}/verify"
  156. assert (await call("POST", verify, "connector", proof(scenario))).status_code == 200
  157. async with scenario["app"].state.sessions.begin() as session:
  158. row = await session.get(IdentityChallenge, UUID(identifier).bytes)
  159. row.expires_at = now() - timedelta(seconds=1)
  160. assert (await call("POST", path + "/confirm")).json()["code"] == "CHALLENGE_EXPIRED"
  161. assert (await call("POST", verify, "connector",
  162. proof(scenario))).json()["code"] == "CHALLENGE_EXPIRED"
  163. async with scenario["app"].state.sessions.begin() as session:
  164. channel = await session.get(ChannelApplication,
  165. (scenario["tenant"].bytes, scenario["store"].bytes))
  166. channel.enabled = False
  167. body = {"store_id": str(scenario["store"]), "channel": "webhook"}
  168. disabled = await call("POST", "/openapi/v1/me/identity-challenges", payload=body)
  169. assert disabled.status_code == 409 and disabled.json()["code"] == "CHANNEL_UNAVAILABLE"