test_main.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  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.notes.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 "/notes" in paths
  17. assert paths["/notes"]["post"]["responses"]["422"]["content"]["application/json"]["schema"] == {
  18. "$ref": "#/components/schemas/ErrorResponse"
  19. }
  20. def test_crud_lifecycle(client):
  21. created = client.post("/notes", json={"title": "REST 笔记"})
  22. assert created.status_code == 201
  23. assert created.json() == {"id": 1, "title": "REST 笔记", "content": "", "pinned": False}
  24. assert created.headers["location"] == "/notes/1"
  25. assert client.get(created.headers["location"]).json() == created.json()
  26. assert client.get("/notes/1").json() == created.json()
  27. replacement = {"title": "修改后的标题", "content": "PUT 完整替换", "pinned": True}
  28. updated = client.put("/notes/1", json=replacement)
  29. assert updated.status_code == 200
  30. assert updated.json() == {"id": 1, **replacement}
  31. removed = client.delete("/notes/1")
  32. assert removed.status_code == 204
  33. assert removed.content == b""
  34. assert client.get("/notes").json() == []
  35. assert client.get("/notes/1").status_code == 404
  36. assert client.post("/notes", json={"title": "新笔记"}).json()["id"] == 2
  37. def test_list_filter_and_limit(client):
  38. client.post("/notes", json={"title": "FastAPI", "content": "request"})
  39. client.post("/notes", json={"title": "第二篇", "content": "fastapi response"})
  40. client.post("/notes", json={"title": "Docker"})
  41. assert len(client.get("/notes", params={"q": "FASTAPI"}).json()) == 2
  42. assert len(client.get("/notes", params={"q": "fastapi", "limit": 1}).json()) == 1
  43. assert client.get("/notes", params={"q": "unknown"}).json() == []
  44. def test_default_limit_and_combined_pinned_filter(client):
  45. for index in range(12):
  46. client.post("/notes", json={
  47. "title": f"note {index}",
  48. "content": "match" if index in (1, 2, 3) else "",
  49. "pinned": index % 2 == 0,
  50. })
  51. assert len(client.get("/notes").json()) == 10
  52. pinned = client.get("/notes", params={"pinned": "true"}).json()
  53. unpinned = client.get("/notes", params={"pinned": "false"}).json()
  54. assert len(pinned) == len(unpinned) == 6
  55. assert all(note["pinned"] is True for note in pinned)
  56. assert all(note["pinned"] is False for note in unpinned)
  57. combined = client.get("/notes", params={"q": "match", "pinned": "false", "limit": 1}).json()
  58. assert [note["id"] for note in combined] == [2]
  59. @pytest.mark.parametrize("title", ["", " ", "x" * 121])
  60. def test_invalid_title_reports_safe_validation_errors(client, title):
  61. response = client.post("/notes", json={"title": title})
  62. assert response.status_code == 422
  63. error = response.json()["error"]
  64. assert error["code"] == "validation_error"
  65. assert error["details"][0]["loc"] == ["body", "title"]
  66. assert set(error["details"][0]) == {"loc", "msg", "type"}
  67. assert client.get("/notes").json() == []
  68. @pytest.mark.parametrize("missing", ["title", "content", "pinned"])
  69. def test_put_requires_every_mutable_field(client, missing):
  70. client.post("/notes", json={"title": "保留的笔记"})
  71. body = {"title": "替换", "content": "内容", "pinned": False}
  72. del body[missing]
  73. response = client.put("/notes/1", json=body)
  74. assert response.status_code == 422
  75. assert client.get("/notes/1").json()["title"] == "保留的笔记"
  76. @pytest.mark.parametrize("method,kwargs", [
  77. ("get", {}),
  78. ("put", {"json": {"title": "missing", "content": "", "pinned": False}}),
  79. ("delete", {}),
  80. ])
  81. def test_missing_note(client, method, kwargs):
  82. response = getattr(client, method)("/notes/999", **kwargs)
  83. assert response.status_code == 404
  84. assert response.json() == {"error": {"code": "http_error", "message": "Note not found", "details": []}}
  85. @pytest.mark.parametrize("url", ["/notes?limit=0", "/notes?limit=101", "/notes?pinned=invalid", "/notes/no-number", "/slow?delay=-1", "/slow?delay=6"])
  86. def test_query_and_path_validation(client, url):
  87. response = client.get(url)
  88. assert response.status_code == 422
  89. assert response.json()["error"]["code"] == "validation_error"
  90. def test_private_key_is_required_and_environment_is_read(monkeypatch, client):
  91. monkeypatch.delenv("DEMO_API_KEY", raising=False)
  92. assert client.get("/private", headers={"X-API-Key": "classroom-key"}).status_code == 503
  93. monkeypatch.setenv("DEMO_API_KEY", "classroom-key")
  94. missing = client.get("/private")
  95. assert missing.status_code == 401
  96. assert missing.headers["www-authenticate"] == "APIKey"
  97. assert client.get("/private", headers={"X-API-Key": "wrong"}).status_code == 401
  98. assert client.get("/private", headers={"X-API-Key": "classroom-key"}).status_code == 200
  99. assert client.get("/health").status_code == 200
  100. def test_generate_and_sse_protocol(client):
  101. prompt = '你好 "world"\n下一行'
  102. response = client.post("/generate", json={"prompt": prompt})
  103. assert response.status_code == 200
  104. expected = response.json()["text"]
  105. stream = client.get("/stream", params={"prompt": prompt})
  106. assert stream.status_code == 200
  107. assert stream.headers["content-type"].startswith("text/event-stream")
  108. assert stream.text.endswith("\n\n")
  109. frames = stream.text.rstrip("\n").split("\n\n")
  110. chunks = []
  111. for frame in frames[:-1]:
  112. event, data = frame.split("\n")
  113. assert event == "event: token"
  114. assert data.startswith("data: ")
  115. chunks.append(json.loads(data.removeprefix("data: "))["text"])
  116. assert "".join(chunks) == expected
  117. assert frames[-1] == "event: done\ndata: {}"
  118. def test_empty_generate_prompt_is_rejected(client):
  119. assert client.post("/generate", json={"prompt": " "}).status_code == 422
  120. assert client.get("/stream", params={"prompt": ""}).status_code == 422
  121. def test_websocket_echo_ping_and_reconnect(client):
  122. with client.websocket_connect("/ws") as websocket:
  123. websocket.send_text("你好")
  124. assert websocket.receive_json() == {"type": "echo", "text": "你好"}
  125. websocket.send_text("ping")
  126. assert websocket.receive_json() == {"type": "pong"}
  127. with client.websocket_connect("/ws") as websocket:
  128. websocket.send_text("再次连接")
  129. assert websocket.receive_json()["text"] == "再次连接"
  130. def test_cors_preflight_permits_local_client_only(client):
  131. headers = {
  132. "Origin": "http://localhost:5500",
  133. "Access-Control-Request-Method": "POST",
  134. "Access-Control-Request-Headers": "content-type",
  135. }
  136. accepted = client.options("/notes", headers=headers)
  137. assert accepted.status_code == 200
  138. assert accepted.headers["access-control-allow-origin"] == "http://localhost:5500"
  139. headers["Origin"] = "https://example.org"
  140. rejected = client.options("/notes", headers=headers)
  141. assert rejected.status_code == 400
  142. assert "access-control-allow-origin" not in rejected.headers
  143. def test_slow_completes(client):
  144. assert client.get("/slow?delay=0").json() == {"delay": 0, "status": "done"}
  145. def test_lifespan_opens_and_closes_http_client():
  146. with TestClient(app):
  147. http_client = app.state.http
  148. assert not http_client.is_closed
  149. assert http_client.is_closed
  150. @pytest.mark.parametrize("scenario,status", [
  151. ("success", 200), ("timeout", 504), ("http_failure", 502), ("connection_failure", 502),
  152. ])
  153. def test_upstream_result_and_error_mapping(client, monkeypatch, scenario, status):
  154. def handler(request):
  155. assert request.url.path == "/slow"
  156. assert request.url.params["delay"] == "1.0"
  157. if scenario == "timeout":
  158. raise httpx.ReadTimeout("simulated timeout", request=request)
  159. if scenario == "connection_failure":
  160. raise httpx.ConnectError("simulated connection failure", request=request)
  161. if scenario == "http_failure":
  162. return httpx.Response(503, json={"message": "unavailable"})
  163. return httpx.Response(200, json={"delay": 1, "status": "done"})
  164. async def mocked_get(path, *, params):
  165. async with httpx.AsyncClient(
  166. transport=httpx.MockTransport(handler), base_url="http://upstream.test",
  167. ) as upstream:
  168. return await upstream.get(path, params=params)
  169. monkeypatch.setattr(app.state.http, "get", mocked_get)
  170. response = client.get("/upstream-demo?delay=1")
  171. assert response.status_code == status
  172. if status == 200:
  173. assert response.json() == {"upstream": {"delay": 1, "status": "done"}}
  174. else:
  175. error = response.json()["error"]
  176. assert error["code"] == "http_error"
  177. expected_messages = {
  178. "timeout": "上游响应超时",
  179. "http_failure": "上游返回非成功状态",
  180. "connection_failure": "无法连接上游服务",
  181. }
  182. assert error["message"] == expected_messages[scenario]