diff --git a/tests/test_cli.py b/tests/test_cli.py index 20af28c..9a89356 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -161,3 +161,121 @@ async def test_aclose_really_closes_real_client(): assert not client.is_closed await transport.aclose() assert client.is_closed + + +# --------------------------------------------------------------------------- # +# Human (rich) renderers + the providers command. These cover the panel/table +# rendering paths that the JSON exit-code tests bypass via model_dump. +# --------------------------------------------------------------------------- # + + +def _all_keys(monkeypatch) -> None: + for var in ("XAI_API_KEY", "GEMINI_API_KEY", "ANTHROPIC_API_KEY", "PERPLEXITY_API_KEY"): + monkeypatch.setenv(var, "dummy-key") + + +def test_synthesize_human_render_prints_synthesis(monkeypatch, patch_cli_config, patch_call_model): + """The default synthesize mode renders member panels and a SYNTHESIS panel.""" + _all_keys(monkeypatch) + + def handler(model, messages, **kwargs): + from tests.conftest import make_response + + # Every call (members and the synthesizer) returns a usable answer, so the + # synthesis runs and its panel is rendered. + return make_response(f"ans-{model}") + + patch_call_model(handler) + result = runner.invoke(cli.app, ["ask", "hello", "--council", "grok,gemini"]) + assert result.exit_code == 0 + # Member panel titles and the synthesis header are present in the rendered output. + assert "grok" in result.output + assert "SYNTHESIS" in result.output + + +def test_raw_human_render_prints_member_panels(monkeypatch, patch_cli_config, patch_call_model): + """Raw mode renders each member's answer panel and no synthesis header.""" + _all_keys(monkeypatch) + + def handler(model, messages, **kwargs): + from tests.conftest import make_response + + return make_response(f"raw-{model}") + + patch_call_model(handler) + result = runner.invoke(cli.app, ["ask", "hello", "--council", "grok,gemini", "--mode", "raw"]) + assert result.exit_code == 0 + assert "grok" in result.output + assert "gemini" in result.output + + +def test_debate_human_render_prints_rounds(monkeypatch, patch_cli_config, patch_call_model): + """Debate mode renders a Round rule per round plus the FINAL SYNTHESIS panel.""" + _all_keys(monkeypatch) + + def handler(model, messages, **kwargs): + from tests.conftest import make_response + + return make_response(f"debate-{model}") + + patch_call_model(handler) + result = runner.invoke( + cli.app, + ["ask", "hello", "--council", "grok,gemini", "--mode", "debate", "--rounds", "2"], + ) + assert result.exit_code == 0 + assert "Round" in result.output + assert "SYNTHESIS" in result.output + + +def test_adversarial_human_render_prints_proposal_and_verdict( + monkeypatch, patch_cli_config, patch_call_model +): + """Adversarial mode renders the proposal, critiques, and a VERDICT panel.""" + _all_keys(monkeypatch) + + def handler(model, messages, **kwargs): + from tests.conftest import make_response + + return make_response(f"adv-{model}") + + patch_call_model(handler) + result = runner.invoke( + cli.app, + ["ask", "hello", "--council", "grok,gemini", "--mode", "adversarial"], + ) + assert result.exit_code == 0 + assert "Proposal" in result.output + assert "VERDICT" in result.output + + +def test_skipped_members_warning_is_printed(monkeypatch, patch_cli_config, patch_call_model): + """A member with no key is skipped and surfaced via the yellow warning line.""" + # Only grok has a key; gemini will be skipped for lack of GEMINI_API_KEY. + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + monkeypatch.setenv("XAI_API_KEY", "dummy-key") + + def handler(model, messages, **kwargs): + from tests.conftest import make_response + + return make_response(f"ans-{model}") + + patch_call_model(handler) + result = runner.invoke(cli.app, ["ask", "hello", "--council", "grok,gemini", "--mode", "raw"]) + assert result.exit_code == 0 + assert "Skipped (no key)" in result.output + assert "gemini" in result.output + + +def test_providers_command_lists_keys_without_values(monkeypatch, patch_cli_config): + """`conclave providers` prints a table marking present/absent keys, no values.""" + monkeypatch.setenv("XAI_API_KEY", "super-secret-value") + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + + result = runner.invoke(cli.app, ["providers"]) + assert result.exit_code == 0 + assert "conclave providers" in result.output + # Provider names and the env-var column appear; the secret VALUE never does. + assert "grok" in result.output + assert "XAI_API_KEY" in result.output + assert "super-secret-value" not in result.output diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..bc413d9 --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,107 @@ +"""Tests for the centralized logger factory and CONCLAVE_LOG_LEVEL resolution. + +``get_logger`` configures the root ``conclave`` logger exactly once (guarded by a +module ``_CONFIGURED`` flag) and returns either that root or a named child. These +tests prove the level is read from ``CONCLAVE_LOG_LEVEL`` (default ``WARNING``), +that an unrecognized value falls back to ``WARNING``, and that the factory's +root-vs-child contract holds. Each test resets the one-shot configuration state so +the env-var branch actually runs instead of short-circuiting on the import-time +configuration done by ``conclave.transport``. +""" + +from __future__ import annotations + +import logging + +import pytest + +import conclave.logging as logging_mod +from conclave.logging import get_logger + + +@pytest.fixture +def fresh_logging(monkeypatch): + """Reset the one-shot logger config so a fresh get_logger() reconfigures. + + Saves and restores ``_CONFIGURED`` plus the root ``conclave`` logger's + handlers/level/propagate so the global logging state is left exactly as found. + """ + root = logging.getLogger("conclave") + saved_configured = logging_mod._CONFIGURED + saved_handlers = root.handlers[:] + saved_level = root.level + saved_propagate = root.propagate + + # Force reconfiguration on the next get_logger call. + monkeypatch.setattr(logging_mod, "_CONFIGURED", False) + root.handlers = [] + + yield root + + # Restore prior state. + root.handlers = saved_handlers + root.setLevel(saved_level) + root.propagate = saved_propagate + logging_mod._CONFIGURED = saved_configured + + +def test_default_level_is_warning_when_env_unset(fresh_logging, monkeypatch): + """With CONCLAVE_LOG_LEVEL unset the root logger is configured at WARNING.""" + monkeypatch.delenv("CONCLAVE_LOG_LEVEL", raising=False) + + logger = get_logger() + + assert logger.name == "conclave" + assert logger.level == logging.WARNING + assert logger.propagate is False + assert len(logger.handlers) == 1 + assert isinstance(logger.handlers[0], logging.StreamHandler) + + +def test_env_var_sets_level_case_insensitively(fresh_logging, monkeypatch): + """A lowercase CONCLAVE_LOG_LEVEL is upper-cased and applied (DEBUG).""" + monkeypatch.setenv("CONCLAVE_LOG_LEVEL", "debug") + + logger = get_logger() + + assert logger.level == logging.DEBUG + + +def test_unknown_level_falls_back_to_warning(fresh_logging, monkeypatch): + """An unrecognized level name falls back to WARNING rather than crashing.""" + monkeypatch.setenv("CONCLAVE_LOG_LEVEL", "NOPE") + + logger = get_logger() + + assert logger.level == logging.WARNING + + +def test_named_logger_is_child_of_root(fresh_logging, monkeypatch): + """A non-default name returns a child logger that inherits root config.""" + monkeypatch.setenv("CONCLAVE_LOG_LEVEL", "INFO") + + child = get_logger("transport") + + assert child.name == "conclave.transport" + assert child.parent is logging.getLogger("conclave") + # Child has no handler of its own; it propagates to the configured root. + assert child.handlers == [] + # Effective level is inherited from the root we just configured at INFO. + assert child.getEffectiveLevel() == logging.INFO + + +def test_configuration_happens_once(fresh_logging, monkeypatch): + """Repeated calls do not stack handlers -- configuration is one-shot.""" + monkeypatch.setenv("CONCLAVE_LOG_LEVEL", "ERROR") + + first = get_logger() + assert len(first.handlers) == 1 + assert logging_mod._CONFIGURED is True + + # Changing the env now must have no effect -- the guard short-circuits. + monkeypatch.setenv("CONCLAVE_LOG_LEVEL", "DEBUG") + second = get_logger() + + assert second is first + assert len(second.handlers) == 1 # not duplicated + assert second.level == logging.ERROR # unchanged from first config diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..df17104 --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,206 @@ +"""Direct tests for the shared async HTTP transport (the single network boundary). + +The rest of the suite patches ``conclave.transport.post_json`` to stay offline, +which means the real ``post_json`` body -- status return, JSON-vs-text decoding, +and the timeout / ``HTTPError`` -> :class:`TransportError` normalization -- never +runs under test. These tests close that gap by driving the *real* ``post_json`` +through an :class:`httpx.MockTransport` mounted on the pooled client, so every +byte path is exercised with no network and no API key. + +Design: +* ``mock_client`` installs an ``httpx.AsyncClient`` backed by a caller-supplied + ``handler`` into ``transport._client`` and restores the module global on + teardown. Because that injected client is open, ``post_json`` reuses it via + ``_get_client`` (covering the "client already live" branch) instead of building + a default networked client. +* Handlers either return an :class:`httpx.Response` (success / non-JSON / error + status) or ``raise`` an httpx exception (timeout / connect error) so the + transport's own ``except`` arms run. +""" + +from __future__ import annotations + +import httpx +import pytest + +from conclave import transport +from conclave.transport import TransportError, post_json + + +@pytest.fixture +async def mock_client(): + """Install a MockTransport-backed pooled client; restore the global after. + + Returns an installer ``use(handler)`` where ``handler(request) -> Response`` + (or raises an httpx exception). The created client is open, so ``post_json`` + reuses it through ``_get_client`` -- exercising the real transport end to end. + """ + saved = transport._client + created: list[httpx.AsyncClient] = [] + + def use(handler): + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + created.append(client) + transport._client = client + return client + + yield use + + # Close anything we created, then put the module global back as we found it. + for client in created: + if not client.is_closed: + await client.aclose() + transport._client = saved + + +async def test_post_json_success_returns_status_and_decoded_json(mock_client): + """A 200 with a JSON body comes back as ``(200, dict)`` decoded by httpx.""" + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + seen["auth"] = request.headers.get("authorization") + seen["body"] = request.read() + return httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]}) + + mock_client(handler) + status, body = await post_json( + "https://api.example.test/v1/chat", + {"Authorization": "Bearer FAKE-NOT-A-REAL-KEY"}, + {"model": "x", "messages": []}, + timeout=30.0, + ) + + assert status == 200 + assert body == {"choices": [{"message": {"content": "ok"}}]} + # The real request the adapter would have built actually reached the transport. + assert seen["url"] == "https://api.example.test/v1/chat" + assert seen["auth"] == "Bearer FAKE-NOT-A-REAL-KEY" + assert b'"model":"x"' in seen["body"].replace(b" ", b"") + + +async def test_post_json_error_status_with_json_body_is_returned_not_raised(mock_client): + """A 4xx/5xx JSON body is returned verbatim -- the transport never raises on status.""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(401, json={"error": {"message": "invalid api key"}}) + + mock_client(handler) + status, body = await post_json( + "https://api.example.test/v1/chat", + {"Authorization": "Bearer FAKE"}, + {"model": "x"}, + timeout=30.0, + ) + + assert status == 401 + assert body == {"error": {"message": "invalid api key"}} + + +async def test_post_json_non_json_body_falls_back_to_text(mock_client): + """A non-JSON body (e.g. an HTML 502 page) decodes to the raw text fallback.""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(502, text="Bad Gateway") + + mock_client(handler) + status, body = await post_json( + "https://api.example.test/v1/chat", + {}, + {"model": "x"}, + timeout=30.0, + ) + + assert status == 502 + assert body == "Bad Gateway" + assert isinstance(body, str) + + +async def test_post_json_timeout_becomes_transport_error(mock_client): + """An httpx timeout maps to TransportError naming the timeout, not the headers.""" + + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.TimeoutException("timed out", request=request) + + mock_client(handler) + with pytest.raises(TransportError) as excinfo: + await post_json( + "https://api.example.test/v1/chat", + {"Authorization": "Bearer FAKE-SECRET-VALUE"}, + {"model": "x"}, + timeout=5.0, + ) + + msg = str(excinfo.value) + assert "timed out" in msg + assert "5s" in msg # the timeout value, formatted as in transport.py + # The header value must never leak into the normalized error message. + assert "FAKE-SECRET-VALUE" not in msg + + +async def test_post_json_connect_error_becomes_transport_error(mock_client): + """A generic httpx.HTTPError (connect failure) maps to a leak-free TransportError.""" + + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused", request=request) + + mock_client(handler) + with pytest.raises(TransportError) as excinfo: + await post_json( + "https://api.example.test/v1/chat", + {"Authorization": "Bearer FAKE-SECRET-VALUE"}, + {"model": "x"}, + timeout=30.0, + ) + + msg = str(excinfo.value) + # Message names the failure *kind* (class name) only -- never str(exc), never the key. + assert "network error" in msg + assert "ConnectError" in msg + assert "FAKE-SECRET-VALUE" not in msg + assert "connection refused" not in msg + + +async def test_post_json_reuses_open_pooled_client(mock_client): + """Two calls in a row reuse the same open client (pooling) -- no rebuild.""" + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + return httpx.Response(200, json={"n": calls["n"]}) + + client = mock_client(handler) + s1, b1 = await post_json("https://api.example.test/a", {}, {}, timeout=10.0) + # The client the second call resolves must be the very same object. + assert transport._get_client() is client + s2, b2 = await post_json("https://api.example.test/b", {}, {}, timeout=10.0) + + assert (s1, s2) == (200, 200) + assert b1 == {"n": 1} + assert b2 == {"n": 2} + assert calls["n"] == 2 + + +async def test_get_client_rebuilds_after_close(): + """_get_client creates a fresh client when the cached one was closed.""" + # Force a clean slate, then create -> close -> ensure a new instance is built. + await transport.aclose() + first = transport._get_client() + assert not first.is_closed + await first.aclose() + assert first.is_closed + + second = transport._get_client() + assert second is not first + assert not second.is_closed + await transport.aclose() + + +async def test_aclose_is_idempotent(): + """Calling aclose twice (and on a fresh None client) is safe and resets state.""" + transport._get_client() # ensure a live client exists + await transport.aclose() + assert transport._client is None + # Second close with the global already None must not raise. + await transport.aclose() + assert transport._client is None