"""Contract tests for the UI-independent API client and token store.""" from __future__ import annotations import json from concurrent.futures import ThreadPoolExecutor from pathlib import Path from threading import Barrier, get_ident from typing import Any import httpx import pytest from doctor_workstation.core.errors import ( ApiBusinessError, ApiError, ApiProtocolError, ApiTimeoutError, AuthenticationExpiredError, OpenPageRequiredError, WorkWechatBindingRequiredError, ) from doctor_workstation.services import api_client as api_client_module from doctor_workstation.services.api_client import ApiClient from doctor_workstation.services.token_store import TokenStore def test_get_normalises_adminapi_and_sends_contract_headers() -> None: """The site base and already-prefixed base resolve to the same API URL.""" requests: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: requests.append(request) return httpx.Response(200, json={"code": 1, "data": {"ok": True}}) with ApiClient( "https://example.test/root/", token="secret-token", transport=httpx.MockTransport(handler), ) as client: assert client.get("/doctor.appointment/lists", {"page_no": 2}) == {"ok": True} request = requests[0] assert str(request.url) == ( "https://example.test/root/adminapi/doctor.appointment/lists?page_no=2" ) assert request.headers["token"] == "secret-token" assert request.headers["version"] == "1.9.4" assert ApiClient.normalise_base_url("https://example.test/adminapi") == ( "https://example.test/adminapi/" ) def test_default_client_allows_parallel_requests_with_independent_transports( monkeypatch: pytest.MonkeyPatch, ) -> None: """Production workers must not queue behind one process-wide HTTP lock.""" rendezvous = Barrier(2, timeout=2) created: list[Any] = [] request_threads: set[int] = set() class PooledClient: def __init__(self, **_options: Any) -> None: self.closed = False created.append(self) def request(self, _method: str, url: str, **_options: Any) -> httpx.Response: request_threads.add(get_ident()) rendezvous.wait() return httpx.Response(200, json={"code": 1, "data": url.rsplit("/", 1)[-1]}) def close(self) -> None: self.closed = True monkeypatch.setattr(api_client_module.httpx, "Client", PooledClient) client = ApiClient("https://example.test", max_retries=0) with ThreadPoolExecutor(max_workers=2) as executor: first = executor.submit(client.get, "patient/first") second = executor.submit(client.get, "patient/second") assert {first.result(timeout=3), second.result(timeout=3)} == {"first", "second"} client.close() assert len(created) == 2 assert len(request_threads) == 2 assert all(item.closed for item in created) def test_post_uses_json_and_never_retries_timeout() -> None: """Writes use JSON and a timeout never causes an automatic duplicate POST.""" attempts = 0 bodies: list[dict[str, object]] = [] def handler(request: httpx.Request) -> httpx.Response: nonlocal attempts attempts += 1 bodies.append(json.loads(request.content)) raise httpx.ReadTimeout("slow write", request=request) client = ApiClient( "https://example.test", max_retries=5, transport=httpx.MockTransport(handler), ) with pytest.raises(ApiTimeoutError) as caught: client.post("doctor.appointment/complete", {"id": 42}) client.close() assert attempts == 1 assert bodies == [{"id": 42}] assert caught.value.data["attempts"] == 1 def test_post_event_stream_sends_exact_contract_and_preserves_event_order() -> None: requests: list[httpx.Request] = [] content = ( 'event: start\ndata: {"model_key":"qwen"}\n\n' 'event: delta\ndata: {"content":"辨"}\n\n' 'event: delta\ndata: {"content":"证"}\n\n' 'event: done\ndata: {"model_label":"千问"}\n\n' ) def handler(request: httpx.Request) -> httpx.Response: requests.append(request) return httpx.Response( 200, headers={"content-type": "text/event-stream; charset=utf-8"}, text=content, ) client = ApiClient( "https://example.test", token="stream-token", transport=httpx.MockTransport(handler), ) events = list( client.post_event_stream( "tcm.diagnosis/aiAssistantStream", {"id": 501, "task": "custom", "prompt": "如何辨证?"}, ) ) client.close() assert [event["event"] for event in events] == ["start", "delta", "delta", "done"] assert [event["data"] for event in events[1:3]] == [ {"content": "辨"}, {"content": "证"}, ] request = requests[0] assert request.headers["accept"] == "text/event-stream" assert request.headers["token"] == "stream-token" assert str(request.url).endswith("/adminapi/tcm.diagnosis/aiAssistantStream") assert json.loads(request.content) == { "id": 501, "task": "custom", "prompt": "如何辨证?", } def test_multipart_post_lets_httpx_set_boundary_and_sends_form_fields( tmp_path: Path, ) -> None: """Uploads use real multipart encoding without the JSON content type.""" requests: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: requests.append(request) return httpx.Response( 200, json={"code": 1, "data": {"uri": "/uploads/demo.jpg"}}, ) source = tmp_path / "demo.jpg" source.write_bytes(b"jpeg-demo-bytes") with ( ApiClient( "https://example.test", transport=httpx.MockTransport(handler), ) as client, source.open("rb") as stream, ): result = client.post_multipart( "upload/image", files={"file": (source.name, stream, "image/jpeg")}, data={"cid": "0"}, ) assert result == {"uri": "/uploads/demo.jpg"} request = requests[0] content_type = request.headers["content-type"] assert content_type.startswith("multipart/form-data; boundary=") assert "application/json" not in content_type assert b'name="file"; filename="demo.jpg"' in request.content assert b'name="cid"' in request.content assert b"jpeg-demo-bytes" in request.content def test_get_retries_only_timeouts_then_returns_data() -> None: """A GET may recover from a bounded number of timeout failures.""" attempts = 0 def handler(request: httpx.Request) -> httpx.Response: nonlocal attempts attempts += 1 if attempts < 3: raise httpx.ReadTimeout("temporary", request=request) return httpx.Response(200, json={"code": "1", "data": ["ready"]}) with ApiClient( "https://example.test/adminapi/", max_retries=2, transport=httpx.MockTransport(handler), ) as client: assert client.get("health") == ["ready"] assert attempts == 3 def test_get_bytes_downloads_relative_public_image_without_api_token() -> None: """Generated QR images bypass the JSON envelope and never leak the API token.""" requests: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: requests.append(request) return httpx.Response(200, headers={"content-type": "image/png"}, content=b"png-data") with ApiClient( "https://example.test/root", token="private-token", transport=httpx.MockTransport(handler), ) as client: assert client.get_bytes("/uploads/qrcode.png") == b"png-data" request = requests[0] assert str(request.url) == "https://example.test/uploads/qrcode.png" assert "token" not in request.headers @pytest.mark.parametrize( ("code", "exception_type"), [ (0, ApiBusinessError), (-1, AuthenticationExpiredError), (10, WorkWechatBindingRequiredError), ], ) def test_envelope_error_codes_are_structured(code: int, exception_type: type[Exception]) -> None: """Known control-flow codes become typed exceptions with response data.""" transport = httpx.MockTransport( lambda request: httpx.Response( 200, headers={"x-request-id": "req-123"}, json={"code": code, "msg": "action needed", "data": {"reason": "demo"}}, ) ) with ( ApiClient("https://example.test", transport=transport) as client, pytest.raises(exception_type) as caught, ): client.get("auth.admin/mySelf") error = caught.value assert isinstance(error, ApiError) assert error.code == code assert error.data == {"reason": "demo"} assert error.request_id == "req-123" def test_open_page_signal_does_not_open_a_browser() -> None: """Code 2 is surfaced to the UI as data, not executed by the service layer.""" transport = httpx.MockTransport( lambda request: httpx.Response( 200, json={"code": 2, "data": {"url": "https://example.test/continue"}}, ) ) with ( ApiClient("https://example.test", transport=transport) as client, pytest.raises(OpenPageRequiredError) as caught, ): client.get("continue") assert caught.value.url == "https://example.test/continue" def test_invalid_envelope_raises_protocol_error() -> None: """Successful HTTP is not mistaken for API success without a valid envelope.""" transport = httpx.MockTransport( lambda request: httpx.Response(200, json={"data": "missing code"}) ) with ( ApiClient("https://example.test", transport=transport) as client, pytest.raises(ApiProtocolError), ): client.get("broken") def test_token_store_file_fallback_never_persists_plaintext_password(tmp_path: Path) -> None: """The fallback may contain a Windows DPAPI blob, but never plaintext.""" path = tmp_path / "credentials.json" store = TokenStore(path, keyring_backend=None) store.save_token("token-value", account="doctor") password_saved = store.save_password( "must-not-reach-disk", account="doctor", scope="https://example.test/adminapi", ) assert store.load_token() == "token-value" assert store.load_account() == "doctor" restored = store.load_password(account="doctor", scope="https://example.test/adminapi") if password_saved: assert restored == "must-not-reach-disk" else: assert restored is None payload = json.loads(path.read_text(encoding="utf-8")) assert payload["token"] == "token-value" assert payload["account"] == "doctor" assert "password" not in path.read_text(encoding="utf-8").lower() assert "must-not-reach-disk" not in path.read_text(encoding="utf-8") store.clear_token() assert store.load_token() is None assert store.load_account() == "doctor" after_logout = store.load_password( account="doctor", scope="https://example.test/adminapi", ) if password_saved: assert after_logout == "must-not-reach-disk" else: assert after_logout is None store.clear_account() assert store.load_password(account="doctor", scope="https://example.test/adminapi") is None class _MemoryKeyring: """Minimal deterministic keyring double.""" def __init__(self) -> None: self.values: dict[tuple[str, str], str] = {} def get_password(self, service: str, username: str) -> str | None: """Return an in-memory secret.""" return self.values.get((service, username)) def set_password(self, service: str, username: str, password: str) -> None: """Store an in-memory secret.""" self.values[(service, username)] = password def delete_password(self, service: str, username: str) -> None: """Delete an in-memory secret.""" self.values.pop((service, username), None) def test_token_store_prefers_available_keyring(tmp_path: Path) -> None: """A working keyring keeps the token out of the fallback JSON file.""" backend = _MemoryKeyring() path = tmp_path / "credentials.json" store = TokenStore(path, keyring_backend=backend) store.save_token("keyring-token", account="doctor") assert store.uses_keyring assert store.load_token() == "keyring-token" assert json.loads(path.read_text(encoding="utf-8")) == {"account": "doctor"} def test_token_store_keeps_login_password_in_scoped_keyring_only(tmp_path: Path) -> None: backend = _MemoryKeyring() path = tmp_path / "credentials.json" store = TokenStore(path, keyring_backend=backend) scope = "https://example.test/adminapi/" assert store.save_password("secret-value", account="doctor", scope=scope) assert ( store.load_password(account="doctor", scope="https://example.test/adminapi") == "secret-value" ) assert store.load_password(account="doctor", scope="https://other.test/adminapi") is None assert "secret-value" not in path.read_text(encoding="utf-8") assert json.loads(path.read_text(encoding="utf-8")) == { "account": "doctor", "scope": "https://example.test/adminapi", } next_scope = "https://next.test/adminapi" assert store.save_password("next-secret", account="doctor", scope=next_scope) assert store.load_password(account="doctor", scope=scope) is None assert store.load_password(account="doctor", scope=next_scope) == "next-secret" store.clear_password(account="doctor", scope=next_scope) assert store.load_password(account="doctor", scope=next_scope) is None def test_token_store_scopes_automatic_restore_and_forgets_account(tmp_path: Path) -> None: """Automatic restore never returns a token issued for another API base.""" path = tmp_path / "credentials.json" store = TokenStore(path, keyring_backend=None) api_scope = "https://example.test/adminapi/" store.save_token( "scoped-token", account="doctor", scope=api_scope, ) assert store.load_token(scope="https://example.test/adminapi") == "scoped-token" assert store.load_token(scope="https://other.test/adminapi/") is None assert store.load_token() == "scoped-token" assert json.loads(path.read_text(encoding="utf-8")) == { "token": "scoped-token", "account": "doctor", "scope": "https://example.test/adminapi", } store.save_token("next-token", account="", scope=api_scope) assert store.load_account() is None assert "account" not in json.loads(path.read_text(encoding="utf-8"))