test_main.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. import json
  2. import httpx
  3. import pytest
  4. from fastapi.testclient import TestClient
  5. from main import app
  6. @pytest.fixture
  7. def client():
  8. app.state.products.clear()
  9. app.state.next_id = 1
  10. with TestClient(app) as test_client:
  11. yield test_client
  12. def test_health_and_docs(client):
  13. assert client.get("/health").json() == {"status": "ok"}
  14. assert client.get("/docs").status_code == 200
  15. paths = client.get("/openapi.json").json()["paths"]
  16. assert "/products" in paths
  17. assert paths["/products"]["post"]["responses"]["422"]["content"]["application/json"]["schema"] == {
  18. "$ref": "#/components/schemas/ErrorResponse"
  19. }
  20. def test_crud_lifecycle(client):
  21. created = client.post("/products", json={"name": "陶瓷杯"})
  22. assert created.status_code == 201
  23. assert created.json() == {"id": 1, "name": "陶瓷杯", "description": "", "is_active": False, "price_cents": 0}
  24. assert created.headers["location"] == "/products/1"
  25. assert client.get(created.headers["location"]).json() == created.json()
  26. assert client.get("/products/1").json() == created.json()
  27. replacement = {"name": "蓝色陶瓷杯", "description": "350ml", "is_active": True, "price_cents": 1990}
  28. updated = client.put("/products/1", json=replacement)
  29. assert updated.status_code == 200
  30. assert updated.json() == {"id": 1, **replacement}
  31. removed = client.delete("/products/1")
  32. assert removed.status_code == 204
  33. assert removed.content == b""
  34. assert client.get("/products").json() == []
  35. assert client.get("/products/1").status_code == 404
  36. assert client.post("/products", json={"name": "新商品"}).json()["id"] == 2
  37. def test_list_filter_and_limit(client):
  38. client.post("/products", json={"name": "Ceramic mug", "description": "350ml"})
  39. client.post("/products", json={"name": "马克杯", "description": "white ceramic"})
  40. client.post("/products", json={"name": "帆布袋"})
  41. assert len(client.get("/products", params={"q": "CERAMIC"}).json()) == 2
  42. assert len(client.get("/products", params={"q": "ceramic", "limit": 1}).json()) == 1
  43. assert client.get("/products", params={"q": "unknown"}).json() == []
  44. def test_default_limit_and_combined_is_active_filter(client):
  45. for index in range(12):
  46. client.post("/products", json={
  47. "name": f"product {index}",
  48. "description": "match" if index in (1, 2, 3) else "",
  49. "is_active": index % 2 == 0,
  50. })
  51. assert len(client.get("/products").json()) == 10
  52. is_active = client.get("/products", params={"is_active": "true"}).json()
  53. inactive = client.get("/products", params={"is_active": "false"}).json()
  54. assert len(is_active) == len(inactive) == 6
  55. assert all(product["is_active"] is True for product in is_active)
  56. assert all(product["is_active"] is False for product in inactive)
  57. combined = client.get("/products", params={"q": "match", "is_active": "false", "limit": 1}).json()
  58. assert [product["id"] for product in combined] == [2]
  59. @pytest.mark.parametrize("name", ["", " ", "x" * 121])
  60. def test_invalid_name_reports_safe_validation_errors(client, name):
  61. response = client.post("/products", json={"name": name})
  62. assert response.status_code == 422
  63. error = response.json()["error"]
  64. assert error["code"] == "validation_error"
  65. assert error["details"][0]["loc"] == ["body", "name"]
  66. assert set(error["details"][0]) == {"loc", "msg", "type"}
  67. assert client.get("/products").json() == []
  68. @pytest.mark.parametrize("missing", ["name", "description", "is_active", "price_cents"])
  69. def test_put_requires_every_mutable_field(client, missing):
  70. client.post("/products", json={"name": "保留的商品"})
  71. body = {"name": "替换", "description": "内容", "is_active": False, "price_cents": 0}
  72. del body[missing]
  73. response = client.put("/products/1", json=body)
  74. assert response.status_code == 422
  75. assert client.get("/products/1").json()["name"] == "保留的商品"
  76. @pytest.mark.parametrize("price", [-1, 1.5, "1990", True, None, 2147483648])
  77. def test_price_rejects_invalid_create_and_replace_without_mutation(client, price):
  78. original = client.post("/products", json={"name": "陶瓷杯", "price_cents": 1990}).json()
  79. rejected = client.post("/products", json={"name": "无效价格", "price_cents": price})
  80. assert rejected.status_code == 422
  81. assert rejected.json()["error"]["details"][0]["loc"] == ["body", "price_cents"]
  82. replacement = {"name": "不应保存", "description": "", "is_active": True, "price_cents": price}
  83. assert client.put("/products/1", json=replacement).status_code == 422
  84. assert client.get("/products/1").json() == original
  85. assert client.get("/products").json() == [original]
  86. @pytest.mark.parametrize("price", [0, 1990, 2147483647])
  87. def test_price_integer_boundaries_are_preserved(client, price):
  88. response = client.post("/products", json={"name": "陶瓷杯", "price_cents": price})
  89. assert response.status_code == 201
  90. assert response.json()["price_cents"] == price
  91. assert type(response.json()["price_cents"]) is int
  92. def test_description_size_matches_database_text_budget(client):
  93. original = client.post("/products", json={"name": "陶瓷杯", "description": "杯" * 16000})
  94. assert original.status_code == 201
  95. assert client.post("/products", json={"name": "过长描述", "description": "杯" * 16001}).status_code == 422
  96. replacement = {"name": "过长描述", "description": "杯" * 16001, "is_active": False, "price_cents": 0}
  97. assert client.put("/products/1", json=replacement).status_code == 422
  98. assert client.get("/products/1").json() == original.json()
  99. @pytest.mark.parametrize("method,kwargs", [
  100. ("get", {}),
  101. ("put", {"json": {"name": "missing", "description": "", "is_active": False, "price_cents": 0}}),
  102. ("delete", {}),
  103. ])
  104. def test_missing_product(client, method, kwargs):
  105. response = getattr(client, method)("/products/999", **kwargs)
  106. assert response.status_code == 404
  107. assert response.json() == {"error": {"code": "http_error", "message": "Product not found", "details": []}}
  108. @pytest.mark.parametrize("url", ["/products?limit=0", "/products?limit=101", "/products?is_active=invalid", "/products/no-number", "/slow?delay=-1", "/slow?delay=6"])
  109. def test_query_and_path_validation(client, url):
  110. response = client.get(url)
  111. assert response.status_code == 422
  112. assert response.json()["error"]["code"] == "validation_error"
  113. def test_private_key_is_required_and_environment_is_read(monkeypatch, client):
  114. monkeypatch.delenv("DEMO_API_KEY", raising=False)
  115. assert client.get("/private", headers={"X-API-Key": "classroom-key"}).status_code == 503
  116. monkeypatch.setenv("DEMO_API_KEY", "classroom-key")
  117. missing = client.get("/private")
  118. assert missing.status_code == 401
  119. assert missing.headers["www-authenticate"] == "APIKey"
  120. assert client.get("/private", headers={"X-API-Key": "wrong"}).status_code == 401
  121. assert client.get("/private", headers={"X-API-Key": "classroom-key"}).status_code == 200
  122. assert client.get("/health").status_code == 200
  123. def test_generate_and_sse_protocol(client):
  124. prompt = '你好 "world"\n下一行'
  125. response = client.post("/generate", json={"prompt": prompt})
  126. assert response.status_code == 200
  127. expected = response.json()["text"]
  128. stream = client.get("/stream", params={"prompt": prompt})
  129. assert stream.status_code == 200
  130. assert stream.headers["content-type"].startswith("text/event-stream")
  131. assert stream.text.endswith("\n\n")
  132. frames = stream.text.rstrip("\n").split("\n\n")
  133. chunks = []
  134. for frame in frames[:-1]:
  135. event, data = frame.split("\n")
  136. assert event == "event: token"
  137. assert data.startswith("data: ")
  138. chunks.append(json.loads(data.removeprefix("data: "))["text"])
  139. assert "".join(chunks) == expected
  140. assert frames[-1] == "event: done\ndata: {}"
  141. def test_empty_generate_prompt_is_rejected(client):
  142. assert client.post("/generate", json={"prompt": " "}).status_code == 422
  143. assert client.get("/stream", params={"prompt": ""}).status_code == 422
  144. def test_websocket_echo_ping_and_reconnect(client):
  145. with client.websocket_connect("/ws") as websocket:
  146. websocket.send_text("你好")
  147. assert websocket.receive_json() == {"type": "echo", "text": "你好"}
  148. websocket.send_text("ping")
  149. assert websocket.receive_json() == {"type": "pong"}
  150. with client.websocket_connect("/ws") as websocket:
  151. websocket.send_text("再次连接")
  152. assert websocket.receive_json()["text"] == "再次连接"
  153. def test_cors_preflight_permits_local_client_only(client):
  154. headers = {
  155. "Origin": "http://localhost:5500",
  156. "Access-Control-Request-Method": "POST",
  157. "Access-Control-Request-Headers": "content-type",
  158. }
  159. accepted = client.options("/products", headers=headers)
  160. assert accepted.status_code == 200
  161. assert accepted.headers["access-control-allow-origin"] == "http://localhost:5500"
  162. headers["Origin"] = "https://example.org"
  163. rejected = client.options("/products", headers=headers)
  164. assert rejected.status_code == 400
  165. assert "access-control-allow-origin" not in rejected.headers
  166. def test_slow_completes(client):
  167. assert client.get("/slow?delay=0").json() == {"delay": 0, "status": "done"}
  168. def test_lifespan_opens_and_closes_http_client():
  169. with TestClient(app):
  170. http_client = app.state.http
  171. assert not http_client.is_closed
  172. assert http_client.is_closed
  173. @pytest.mark.parametrize("scenario,status", [
  174. ("success", 200), ("timeout", 504), ("http_failure", 502), ("connection_failure", 502),
  175. ])
  176. def test_upstream_result_and_error_mapping(client, monkeypatch, scenario, status):
  177. def handler(request):
  178. assert request.url.path == "/slow"
  179. assert request.url.params["delay"] == "1.0"
  180. if scenario == "timeout":
  181. raise httpx.ReadTimeout("simulated timeout", request=request)
  182. if scenario == "connection_failure":
  183. raise httpx.ConnectError("simulated connection failure", request=request)
  184. if scenario == "http_failure":
  185. return httpx.Response(503, json={"message": "unavailable"})
  186. return httpx.Response(200, json={"delay": 1, "status": "done"})
  187. async def mocked_get(path, *, params):
  188. async with httpx.AsyncClient(
  189. transport=httpx.MockTransport(handler), base_url="http://upstream.test",
  190. ) as upstream:
  191. return await upstream.get(path, params=params)
  192. monkeypatch.setattr(app.state.http, "get", mocked_get)
  193. response = client.get("/upstream-demo?delay=1")
  194. assert response.status_code == status
  195. if status == 200:
  196. assert response.json() == {"upstream": {"delay": 1, "status": "done"}}
  197. else:
  198. error = response.json()["error"]
  199. assert error["code"] == "http_error"
  200. expected_messages = {
  201. "timeout": "上游响应超时",
  202. "http_failure": "上游返回非成功状态",
  203. "connection_failure": "无法连接上游服务",
  204. }
  205. assert error["message"] == expected_messages[scenario]