from __future__ import annotations import asyncio import hmac import hashlib import logging import time import pytest import httpx from sync_state_machine.datasource.api.client import ApiClient from sync_state_machine.datasource.api.datasource import ApiDataSource from sync_state_machine.logging import clear_scope_display_names, set_scope_display_name class _FakeResponse: status_code = 200 def raise_for_status(self) -> None: return None def json(self) -> dict: return {"code": 200, "data": []} class _FakeAsyncClient: def __init__(self) -> None: self.call_times: list[float] = [] self.closed = False self.last_kwargs: dict | None = None async def request(self, **kwargs): self.call_times.append(time.monotonic()) self.last_kwargs = kwargs return _FakeResponse() async def aclose(self) -> None: self.closed = True class _FailingAsyncClient: def __init__(self) -> None: self.closed = False async def request(self, **kwargs): request = httpx.Request(kwargs["method"], kwargs["url"]) response = httpx.Response(500, request=request, text="boom") raise httpx.HTTPStatusError("server error", request=request, response=response) async def aclose(self) -> None: self.closed = True class _FakeCollection: def __init__(self, scope: str) -> None: self.scope = scope def set_state_machine_runtime(self, runtime) -> None: del runtime class _FakeProjectNode: node_type = "project" def __init__(self, data_id: str, name: str) -> None: self.data_id = data_id self._data = {"name": name} def get_data(self) -> dict: return dict(self._data) @pytest.mark.asyncio async def test_api_client_global_rate_limit_applies_to_concurrent_requests() -> None: client = ApiClient( base_url="http://example.com", uid="uid", secret="secret", rate_limit_max_requests=2, rate_limit_window_seconds=0.2, ) fake_client = _FakeAsyncClient() client._client = fake_client await asyncio.gather( client.request("GET", "/ping"), client.request("GET", "/ping"), client.request("GET", "/ping"), ) assert len(fake_client.call_times) == 3 third_delay = fake_client.call_times[2] - fake_client.call_times[0] assert third_delay >= 0.15 @pytest.mark.asyncio async def test_api_client_request_stats_track_success_and_failure(caplog: pytest.LogCaptureFixture) -> None: client = ApiClient( base_url="http://example.com", uid="uid", secret="secret", ) logger_name = "sync_state_machine.datasource.api.client" success_client = _FakeAsyncClient() client._client = success_client with caplog.at_level(logging.INFO, logger=logger_name): await client.request("GET", "/ping") failing_client = _FailingAsyncClient() client._client = failing_client with pytest.raises(httpx.HTTPStatusError): await client.request("GET", "/ping") stats = client.get_request_stats() assert stats == {"total": 2, "success": 1, "failure": 1} assert client.format_request_stats().endswith("total=2, success=1, failure=1") await client.close() assert failing_client.closed is True assert "📊 ApiClient request stats: total=2, success=1, failure=1" in caplog.text @pytest.mark.asyncio async def test_api_client_logs_one_line_request_summary_in_debug_mode(caplog: pytest.LogCaptureFixture) -> None: client = ApiClient( base_url="http://example.com", uid="uid", secret="secret", debug=True, ) logger_name = "sync_state_machine.datasource.api.client" fake_client = _FakeAsyncClient() client._client = fake_client with caplog.at_level(logging.INFO, logger=logger_name): await client.request("GET", "/ping") assert "🔎 ApiClient request: method=GET endpoint=/ping elapsed=" in caplog.text @pytest.mark.asyncio async def test_api_client_request_summary_includes_session_label_with_scope_fallback( caplog: pytest.LogCaptureFixture, ) -> None: clear_scope_display_names() client = ApiClient( base_url="http://example.com", uid="uid", secret="secret", debug=True, ) logger_name = "sync_state_machine.datasource.api.client" fake_client = _FakeAsyncClient() client._client = fake_client client.set_scope("project_id:project-1") with caplog.at_level(logging.INFO, logger=logger_name): await client.request("GET", "/ping") assert "session=project_id:project-1 method=GET endpoint=/ping" in caplog.text caplog.clear() client.set_session_label("project:Alpha") with caplog.at_level(logging.INFO, logger=logger_name): await client.request("GET", "/ping") assert "session=project:Alpha method=GET endpoint=/ping" in caplog.text clear_scope_display_names() @pytest.mark.asyncio async def test_api_client_request_summary_uses_registered_project_name_for_project_id_requests( caplog: pytest.LogCaptureFixture, ) -> None: clear_scope_display_names() set_scope_display_name("project_id:project-1", "project:Alpha") client = ApiClient( base_url="http://example.com", uid="uid", secret="secret", debug=True, ) logger_name = "sync_state_machine.datasource.api.client" fake_client = _FakeAsyncClient() client._client = fake_client client.set_scope("global") with caplog.at_level(logging.INFO, logger=logger_name): await client.request("GET", "/supplier/list", params={"project_id": "project-1"}) clear_scope_display_names() assert "session=project:Alpha method=GET endpoint=/supplier/list" in caplog.text def test_api_datasource_updates_client_session_label_from_project_node() -> None: clear_scope_display_names() datasource = ApiDataSource( base_url="http://example.com", uid="uid", secret="secret", debug=True, ) datasource.set_collection(_FakeCollection("project_id:project-1")) assert datasource.client.get_log_session_label() == "project_id:project-1" datasource._on_node_loaded(None, _FakeProjectNode(data_id="project-1", name="Alpha")) assert datasource.client.get_log_session_label() == "project:Alpha" clear_scope_display_names() @pytest.mark.asyncio async def test_api_client_get_signature_keeps_list_params_in_signature_source() -> None: client = ApiClient( base_url="http://example.com", uid="uid", secret="secret", ) fake_client = _FakeAsyncClient() client._client = fake_client await client.request( "GET", "/ping", params={"domains": ["a", "b", "c"], "project_id": "project-1"}, ) assert fake_client.last_kwargs is not None sent_params = fake_client.last_kwargs["params"] assert sent_params["domains"] == ["a", "b", "c"] sign = sent_params["sign"] sign_source = {k: v for k, v in sent_params.items() if k != "sign"} items = [] for key, value in sorted(sign_source.items()): if value is None: value_str = "null" elif isinstance(value, bool): value_str = "true" if value else "false" elif isinstance(value, str): value_str = value elif isinstance(value, (int, float)): value_str = str(value) elif isinstance(value, (list, dict)): value_str = __import__("json").dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) else: value_str = str(value) items.append(f"{key}={value_str}") expected_sign = hmac.new( b"secret", "&".join(items).encode("utf-8"), hashlib.sha256, ).hexdigest() assert sign == expected_sign