test_api_documentation.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. import json
  2. from pathlib import Path
  3. import httpx
  4. import pytest
  5. from jsonschema import Draft202012Validator, FormatChecker
  6. from test_after_sales import scenario # noqa: F401
  7. from test_conversations import setup as conversation_setup
  8. from test_fulfillment import connected # noqa: F401
  9. from test_knowledge_memory import cleanup_vectors, document
  10. from zhifu.app import create_app
  11. from zhifu.modules.knowledge.inference import app as inference_app
  12. from zhifu.monitoring.api import app as operations_app
  13. from zhifu.oms.api import create_app as create_oms
  14. @pytest.mark.parametrize(
  15. "name,app",
  16. [
  17. ("openapi.json", create_app()),
  18. ("oms-openapi.json", create_oms()),
  19. ("inference-openapi.json", inference_app),
  20. ("operations-openapi.json", operations_app),
  21. ],
  22. )
  23. def test_published_definitions_match_routes_and_describe_responses(name, app):
  24. schema = app.openapi()
  25. assert json.loads(Path("docs/api", name).read_text(encoding="utf-8")) == schema
  26. identifiers = []
  27. for path, methods in schema["paths"].items():
  28. for method, operation in methods.items():
  29. assert operation["summary"] and operation.get("description"), (name, path, method)
  30. identifiers.append(operation["operationId"])
  31. public = path in {
  32. "/openapi/v1/auth/sessions",
  33. "/openapi/v1/platform/auth/sessions",
  34. "/openapi/v1/stores/{store_id}/catalog",
  35. "/openapi/v1/stores/{store_id}/products/{product_id}",
  36. "/health",
  37. "/health/ready",
  38. "/embeddings",
  39. "/metrics",
  40. }
  41. assert bool(operation.get("security")) != public, (name, path, method)
  42. for status, response in operation["responses"].items():
  43. if status == "204":
  44. assert "content" not in response
  45. elif status.startswith("2"):
  46. assert all(value.get("schema") for value in response["content"].values())
  47. if name == "openapi.json" and "422" in operation["responses"]:
  48. assert operation["responses"]["422"]["content"]["application/json"]["schema"] == {
  49. "$ref": "#/components/schemas/ApiError"
  50. }
  51. assert len(identifiers) == len(set(identifiers))
  52. for model in schema.get("components", {}).get("schemas", {}).values():
  53. if model.get("title") in {"HTTPValidationError", "ValidationError"}:
  54. continue
  55. for field, value in model.get("properties", {}).items():
  56. assert value.get("description"), (name, model["title"], field)
  57. def validate_response(schema, path, method, response, expected_status):
  58. assert response.status_code == expected_status, response.text
  59. body_schema = schema["paths"][path][method]["responses"][str(expected_status)]["content"][
  60. "application/json"
  61. ]["schema"]
  62. Draft202012Validator(
  63. {"components": schema["components"], **body_schema}, format_checker=FormatChecker()
  64. ).validate(response.json())
  65. @pytest.mark.integration
  66. async def test_real_platform_responses_match_documented_schema(scenario): # noqa: F811
  67. schema = scenario["app"].openapi()
  68. call = scenario["call"]
  69. path = "/openapi/v1/auth/me"
  70. validate_response(schema, path, "get", await call("GET", path), 200)
  71. path = "/openapi/v1/orders/{order_id}"
  72. validate_response(
  73. schema, path, "get", await call("GET", path.format(order_id=scenario["order_id"])), 200
  74. )
  75. path = "/openapi/v1/after-sales/proposals"
  76. validate_response(schema, path, "post", await call("POST", path, payload={}), 422)
  77. response = await call("POST", path, payload=scenario["proposal"])
  78. validate_response(schema, path, "post", response, 201)
  79. path = "/openapi/v1/tickets/{ticket_id}"
  80. validate_response(
  81. schema,
  82. path,
  83. "get",
  84. await call("GET", path.format(ticket_id=response.json()["ticket_id"])),
  85. 200,
  86. )
  87. path = "/openapi/v1/agent/runs"
  88. validate_response(
  89. schema,
  90. path,
  91. "post",
  92. await call(
  93. "POST",
  94. path,
  95. payload={"store_id": str(scenario["store"]), "message": "查询订单进度"},
  96. ),
  97. 202,
  98. )
  99. conversation = await conversation_setup(scenario)
  100. validate_response(
  101. schema,
  102. "/openapi/v1/conversations/{conversation_id}",
  103. "get",
  104. await call("GET", conversation),
  105. 200,
  106. )
  107. response = await call(
  108. "POST",
  109. conversation + "/messages",
  110. payload={"expected_control_version": 1, "text": "请查询订单"},
  111. )
  112. validate_response(
  113. schema, "/openapi/v1/conversations/{conversation_id}/messages", "post", response, 201
  114. )
  115. path = "/openapi/v1/me/memory"
  116. validate_response(schema, path, "get", await call("GET", path), 200)
  117. await call("PUT", path + "/consent", payload={"expected_revision": 0, "enabled": True})
  118. response = await call(
  119. "PUT",
  120. path,
  121. payload={"expected_revision": 1, "communication": {"response_style": "concise"}},
  122. )
  123. validate_response(schema, path, "put", response, 200)
  124. knowledge = await document(scenario)
  125. try:
  126. response = await call("POST", knowledge + "/publish", "agent", {"expected_version": 1})
  127. validate_response(
  128. schema,
  129. "/admin/v1/stores/{store_id}/knowledge/{document_id}/{action}",
  130. "post",
  131. response,
  132. 200,
  133. )
  134. path = "/openapi/v1/stores/{store_id}/knowledge/search"
  135. response = await call(
  136. "POST", path.format(store_id=scenario["store"]), payload={"query": "键盘清洁"}
  137. )
  138. validate_response(schema, path, "post", response, 200)
  139. assert response.json()["items"]
  140. finally:
  141. await cleanup_vectors(scenario["tenant"])
  142. @pytest.mark.integration
  143. async def test_real_oms_and_inference_response_formats(connected): # noqa: F811
  144. app = create_oms()
  145. async with app.router.lifespan_context(app):
  146. async with httpx.AsyncClient(
  147. transport=httpx.ASGITransport(app=app), base_url="http://oms"
  148. ) as client:
  149. path = "/orders/{order_id}"
  150. url = path.format(order_id=connected["order_id"])
  151. response = await client.get(url, headers=connected["warehouse"].headers)
  152. validate_response(app.openapi(), path, "get", response, 200)
  153. response = await client.get(url)
  154. validate_response(app.openapi(), path, "get", response, 401)
  155. async with httpx.AsyncClient(base_url="http://127.0.0.1:8200", timeout=30) as client:
  156. response = await client.post("/embeddings", json={"input": ["售后服务说明"]})
  157. validate_response(inference_app.openapi(), "/embeddings", "post", response, 200)
  158. assert len(response.json()["vectors"][0]) == 1024
  159. for payload in ({"input": [""]}, {"input": []}):
  160. response = await client.post("/embeddings", json=payload)
  161. validate_response(inference_app.openapi(), "/embeddings", "post", response, 422)