kinchat / tests /test_client.py
Bhargav
Initial KinChat env: models, personas, scenarios, rubrics, grader, env loop, FastAPI app, client, dashboard, baseline inference (377 tests passing)
2e8387b
Raw
History Blame Contribute Delete
14.2 kB
"""Tests for ``kinchat.client.KinChatEnv``.
The HTTP layer is mocked end-to-end β€” these tests must NOT spin up the
real FastAPI app, because that would defeat the point of asserting
client/server separation.
Mocking strategy: patch ``requests.Session.get`` / ``requests.Session.post``
on the bound session attached to each ``KinChatEnv`` instance. We construct
a tiny ``_FakeResponse`` to mimic the parts of ``requests.Response`` we use.
"""
from __future__ import annotations
import inspect
import sys
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from kinchat import client as client_module
from kinchat.client import KinChatEnv, KinChatHTTPError
from kinchat.models import (
ChatMsg,
KinChatAction,
KinChatObservation,
KinChatState,
PersonaState,
)
# --------------------------------------------------------------------------- #
# Fake response helper #
# --------------------------------------------------------------------------- #
class _FakeResponse:
"""Minimal stand-in for ``requests.Response`` covering what _check uses."""
def __init__(
self,
json_body: Any = None,
status_code: int = 200,
url: str = "http://fake/",
text: str = "",
) -> None:
self._json = json_body
self.status_code = status_code
self.url = url
self.text = text or (str(json_body) if json_body is not None else "")
self.content = b"" if json_body is None else b"{}"
self.reason = "OK" if status_code < 400 else "ERROR"
def json(self) -> Any:
if self._json is None:
raise ValueError("no json body")
return self._json
def _obs_payload(**overrides: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"chat_history": [],
"user_draft": "",
"active_recipients": [],
"turn_index": 0,
"scenario_brief": "test brief",
"reward": 0.5,
"reward_breakdown": {},
"feedback": "",
"done": False,
"episode_id": "ep-1",
}
base.update(overrides)
return base
def _state_payload(**overrides: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"session_id": "sess-1",
"episode_index": 0,
"scenario_id": "secret_grandma_surgery_01",
"family": {
"mom": PersonaState(persona_id="mom").model_dump(),
},
"secrets": [],
"chat_log": [],
"turn_index": 0,
"scores_history": [],
}
base.update(overrides)
return base
# --------------------------------------------------------------------------- #
# Construction & URL normalization #
# --------------------------------------------------------------------------- #
def test_construction_does_not_raise():
KinChatEnv("http://localhost:7860")
def test_construction_does_not_perform_network_call():
"""No HTTP traffic during __init__."""
with patch("requests.Session.get") as g, patch("requests.Session.post") as p:
KinChatEnv("http://localhost:7860")
assert g.call_count == 0
assert p.call_count == 0
def test_empty_base_url_raises():
with pytest.raises(ValueError):
KinChatEnv("")
def test_trailing_slash_normalized_for_get():
e_slash = KinChatEnv("http://x:7860/")
e_plain = KinChatEnv("http://x:7860")
assert e_slash.base_url == e_plain.base_url == "http://x:7860"
captured: list[str] = []
def fake_get(self, url, *args, **kwargs): # noqa: ARG001
captured.append(url)
return _FakeResponse({"status": "ok", "version": "0.1.0"})
with patch("requests.Session.get", new=fake_get):
e_slash.health()
e_plain.health()
assert captured == ["http://x:7860/health", "http://x:7860/health"]
def test_multiple_trailing_slashes_normalized():
e = KinChatEnv("http://x:7860///")
assert e.base_url == "http://x:7860"
# --------------------------------------------------------------------------- #
# health / list_scenarios / dashboard_data #
# --------------------------------------------------------------------------- #
def test_health_calls_get_health():
e = KinChatEnv("http://localhost:7860")
fake = _FakeResponse({"status": "ok", "version": "0.1.0"})
with patch.object(e._session, "get", return_value=fake) as m:
out = e.health()
assert out == {"status": "ok", "version": "0.1.0"}
assert m.call_count == 1
args, kwargs = m.call_args
assert args[0] == "http://localhost:7860/health"
assert "timeout" in kwargs
def test_list_scenarios_calls_get_scenarios():
e = KinChatEnv("http://localhost:7860")
body = {
"archetypes": ["secret_keeping"],
"scenarios": [
{
"id": "secret_grandma_surgery_01",
"archetype": "secret_keeping",
"brief": "Grandma's surgery",
}
],
}
fake = _FakeResponse(body)
with patch.object(e._session, "get", return_value=fake) as m:
out = e.list_scenarios()
assert out == body
assert m.call_args.args[0] == "http://localhost:7860/scenarios"
def test_dashboard_data_calls_get_dashboard_data():
e = KinChatEnv("http://localhost:7860")
body = {"session_id": None, "episode_index": 0, "leak_events": []}
fake = _FakeResponse(body)
with patch.object(e._session, "get", return_value=fake) as m:
out = e.dashboard_data()
assert out == body
assert m.call_args.args[0] == "http://localhost:7860/dashboard/data"
# --------------------------------------------------------------------------- #
# reset #
# --------------------------------------------------------------------------- #
def test_reset_posts_scenario_id_and_returns_observation():
e = KinChatEnv("http://localhost:7860")
fake = _FakeResponse(_obs_payload(scenario_brief="grandma's surgery"))
with patch.object(e._session, "post", return_value=fake) as m:
obs = e.reset(scenario_id="secret_grandma_surgery_01")
assert isinstance(obs, KinChatObservation)
assert obs.scenario_brief == "grandma's surgery"
assert m.call_args.args[0] == "http://localhost:7860/reset"
body = m.call_args.kwargs["json"]
assert body["scenario_id"] == "secret_grandma_surgery_01"
# Optional fields omitted when None
assert "session_id" not in body
assert "seed" not in body
assert "episode_id" not in body
def test_reset_threads_session_id_and_seed_into_body():
e = KinChatEnv("http://localhost:7860")
fake = _FakeResponse(_obs_payload())
with patch.object(e._session, "post", return_value=fake) as m:
e.reset(
scenario_id="secret_grandma_surgery_01",
session_id="sess-42",
seed=123,
episode_id="ep-9",
)
body = m.call_args.kwargs["json"]
assert body == {
"scenario_id": "secret_grandma_surgery_01",
"session_id": "sess-42",
"seed": 123,
"episode_id": "ep-9",
}
# --------------------------------------------------------------------------- #
# step #
# --------------------------------------------------------------------------- #
def test_step_posts_action_body_and_returns_observation():
e = KinChatEnv("http://localhost:7860")
fake = _FakeResponse(
_obs_payload(
turn_index=1,
reward=0.42,
chat_history=[
ChatMsg(sender="mom", text="hi", recipients=["user"], turn=1).model_dump()
],
)
)
action = KinChatAction(action_type="stay_silent")
with patch.object(e._session, "post", return_value=fake) as m:
obs = e.step(action)
assert isinstance(obs, KinChatObservation)
assert obs.turn_index == 1
assert obs.reward == pytest.approx(0.42)
assert m.call_args.args[0] == "http://localhost:7860/step"
body = m.call_args.kwargs["json"]
assert body["action_type"] == "stay_silent"
assert body["message"] == ""
def test_step_with_send_action_carries_message_and_recipients():
e = KinChatEnv("http://localhost:7860")
fake = _FakeResponse(_obs_payload(turn_index=2))
action = KinChatAction(
action_type="send",
message="hey mom",
recipients=["mom"],
reasoning="be nice",
)
with patch.object(e._session, "post", return_value=fake) as m:
e.step(action)
body = m.call_args.kwargs["json"]
assert body["action_type"] == "send"
assert body["message"] == "hey mom"
assert body["recipients"] == ["mom"]
assert body["reasoning"] == "be nice"
def test_step_rejects_non_action_arguments():
e = KinChatEnv("http://localhost:7860")
with pytest.raises(TypeError):
e.step({"action_type": "stay_silent"}) # type: ignore[arg-type]
# --------------------------------------------------------------------------- #
# state #
# --------------------------------------------------------------------------- #
def test_state_returns_kinchat_state():
e = KinChatEnv("http://localhost:7860")
fake = _FakeResponse(_state_payload())
with patch.object(e._session, "get", return_value=fake) as m:
s = e.state()
assert isinstance(s, KinChatState)
assert s.session_id == "sess-1"
assert "mom" in s.family
assert m.call_args.args[0] == "http://localhost:7860/state"
# --------------------------------------------------------------------------- #
# Error handling #
# --------------------------------------------------------------------------- #
def test_4xx_response_raises_kinchat_http_error():
e = KinChatEnv("http://localhost:7860")
fake = _FakeResponse(
json_body={"detail": "scenario 'nope' not found"},
status_code=404,
url="http://localhost:7860/reset",
)
fake.content = b'{"detail": "..."}'
with patch.object(e._session, "post", return_value=fake):
with pytest.raises(KinChatHTTPError) as exc_info:
e.reset(scenario_id="nope")
assert exc_info.value.status_code == 404
assert "not found" in exc_info.value.detail
def test_500_response_raises_kinchat_http_error():
e = KinChatEnv("http://localhost:7860")
fake = _FakeResponse(
json_body={"detail": "boom"},
status_code=500,
url="http://localhost:7860/state",
)
fake.content = b'{"detail": "boom"}'
with patch.object(e._session, "get", return_value=fake):
with pytest.raises(KinChatHTTPError) as exc_info:
e.state()
assert exc_info.value.status_code == 500
def test_4xx_with_non_json_body_still_raises_cleanly():
e = KinChatEnv("http://localhost:7860")
fake = _FakeResponse(
json_body=None,
status_code=502,
url="http://localhost:7860/health",
text="Bad Gateway",
)
fake.content = b"Bad Gateway"
with patch.object(e._session, "get", return_value=fake):
with pytest.raises(KinChatHTTPError) as exc_info:
e.health()
assert exc_info.value.status_code == 502
# --------------------------------------------------------------------------- #
# Lifecycle #
# --------------------------------------------------------------------------- #
def test_close_is_idempotent():
e = KinChatEnv("http://localhost:7860")
e.close()
e.close() # second call must not raise
def test_context_manager_closes_session():
with KinChatEnv("http://localhost:7860") as e:
assert isinstance(e, KinChatEnv)
sess = e._session
sess.close = MagicMock()
sess.close.assert_called_once()
# --------------------------------------------------------------------------- #
# Negative test: client must NOT import server internals #
# --------------------------------------------------------------------------- #
def test_client_module_does_not_reference_server():
"""The hackathon judges check this. Source-level grep is the strict
test β€” module-level ``sys.modules`` snooping is unreliable because
other tests in the suite legitimately import ``kinchat.server``.
"""
src = inspect.getsource(client_module)
assert "kinchat.server" not in src, (
"kinchat.client must not reference kinchat.server "
"(client/server separation is hackathon-required)"
)
assert "from kinchat.server" not in src
assert "import kinchat.server" not in src
def test_client_imports_only_allowed_modules():
"""Walk the ``kinchat.client`` module's globals and ensure no
attribute originates from ``kinchat.server``."""
for name, obj in vars(client_module).items():
mod = getattr(obj, "__module__", None)
if isinstance(mod, str):
assert not mod.startswith("kinchat.server"), (
f"kinchat.client exposes {name!r} from {mod!r} β€” "
"must not reach into kinchat.server"
)
def test_fresh_import_does_not_load_server():
"""Importing ``kinchat.client`` by itself must not pull in
``kinchat.server.*`` as a side effect.
We do this by spawning a subprocess so the test is robust against
other tests in the suite having already imported server modules.
"""
import subprocess
code = (
"import sys; "
"import kinchat.client; "
"leaked = [m for m in sys.modules if m.startswith('kinchat.server')]; "
"assert not leaked, leaked; "
"print('OK')"
)
result = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, (
f"stdout={result.stdout!r} stderr={result.stderr!r}"
)
assert "OK" in result.stdout