diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..4d114be9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import importlib +import sys +from pathlib import Path + +import asyncio +import httpx +import pytest + + +_ROOT = Path(__file__).resolve().parent.parent +_SOFIIA_PATH = _ROOT / "services" / "sofiia-console" +if str(_SOFIIA_PATH) not in sys.path: + sys.path.insert(0, str(_SOFIIA_PATH)) + + +@pytest.fixture +def sofiia_module(tmp_path, monkeypatch): + """Reload sofiia-console app with isolated env and DB path.""" + monkeypatch.setenv("SOFIIA_DATA_DIR", str(tmp_path / "sofiia-data")) + monkeypatch.setenv("ENV", "dev") + monkeypatch.delenv("SOFIIA_CONSOLE_API_KEY", raising=False) + monkeypatch.setenv("ROUTER_URL", "http://router.local:8000") + monkeypatch.delenv("NODE_ID", raising=False) + # Python 3.9 + pytest-asyncio strict mode may not have a default loop. + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + import app.db as db_mod # type: ignore + import app.main as main_mod # type: ignore + + importlib.reload(db_mod) + importlib.reload(main_mod) + main_mod._rate_buckets.clear() + main_mod._idempotency_cache.clear() + return main_mod + + +@pytest.fixture +def sofiia_client(sofiia_module): + class _LocalClient: + def __init__(self, app): + self.app = app + + def request(self, method: str, path: str, **kwargs): + async def _do(): + transport = httpx.ASGITransport(app=self.app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + follow_redirects=True, + ) as client: + return await client.request(method, path, **kwargs) + + return asyncio.run(_do()) + + def get(self, path: str, **kwargs): + return self.request("GET", path, **kwargs) + + def post(self, path: str, **kwargs): + return self.request("POST", path, **kwargs) + + return _LocalClient(sofiia_module.app) + diff --git a/tests/test_sofiia_chat_idempotency.py b/tests/test_sofiia_chat_idempotency.py new file mode 100644 index 00000000..878403ec --- /dev/null +++ b/tests/test_sofiia_chat_idempotency.py @@ -0,0 +1,95 @@ +from __future__ import annotations + + +def _create_chat(client, agent_id: str, node_id: str, ref: str) -> str: + r = client.post( + "/api/chats", + json={ + "agent_id": agent_id, + "node_id": node_id, + "source": "web", + "external_chat_ref": ref, + }, + ) + assert r.status_code == 200, r.text + return r.json()["chat"]["chat_id"] + + +def test_send_idempotency_header_replays_same_message_id(sofiia_client, sofiia_module, monkeypatch): + async def _fake_infer(base_url, agent_id, text, **kwargs): + return {"response": f"ok:{agent_id}:{text}", "backend": "fake", "model": "fake-model"} + + monkeypatch.setattr(sofiia_module, "infer", _fake_infer) + chat_id = _create_chat(sofiia_client, "sofiia", "NODA2", "idem-header") + headers = {"Idempotency-Key": "idem-header-1"} + + r1 = sofiia_client.post(f"/api/chats/{chat_id}/send", json={"text": "ping"}, headers=headers) + r2 = sofiia_client.post(f"/api/chats/{chat_id}/send", json={"text": "ping"}, headers=headers) + assert r1.status_code == 200 and r2.status_code == 200 + + j1, j2 = r1.json(), r2.json() + assert j1["message"]["message_id"] == j2["message"]["message_id"] + assert j1["idempotency"]["replayed"] is False + assert j2["idempotency"]["replayed"] is True + + +def test_send_idempotency_body_replays_same_message_id(sofiia_client, sofiia_module, monkeypatch): + async def _fake_infer(base_url, agent_id, text, **kwargs): + return {"response": f"ok:{agent_id}:{text}", "backend": "fake", "model": "fake-model"} + + monkeypatch.setattr(sofiia_module, "infer", _fake_infer) + chat_id = _create_chat(sofiia_client, "sofiia", "NODA2", "idem-body") + payload = {"text": "ping", "idempotency_key": "idem-body-1"} + + r1 = sofiia_client.post(f"/api/chats/{chat_id}/send", json=payload) + r2 = sofiia_client.post(f"/api/chats/{chat_id}/send", json=payload) + assert r1.status_code == 200 and r2.status_code == 200 + + j1, j2 = r1.json(), r2.json() + assert j1["message"]["message_id"] == j2["message"]["message_id"] + assert j1["idempotency"]["replayed"] is False + assert j2["idempotency"]["replayed"] is True + + +def test_send_idempotency_header_overrides_body(sofiia_client, sofiia_module, monkeypatch): + async def _fake_infer(base_url, agent_id, text, **kwargs): + return {"response": f"ok:{agent_id}:{text}", "backend": "fake", "model": "fake-model"} + + monkeypatch.setattr(sofiia_module, "infer", _fake_infer) + chat_id = _create_chat(sofiia_client, "sofiia", "NODA2", "idem-override") + + r1 = sofiia_client.post( + f"/api/chats/{chat_id}/send", + json={"text": "ping-a", "idempotency_key": "body-a"}, + headers={"Idempotency-Key": "header-wins"}, + ) + r2 = sofiia_client.post( + f"/api/chats/{chat_id}/send", + json={"text": "ping-b", "idempotency_key": "body-b"}, + headers={"Idempotency-Key": "header-wins"}, + ) + assert r1.status_code == 200 and r2.status_code == 200 + j1, j2 = r1.json(), r2.json() + assert j1["message"]["message_id"] == j2["message"]["message_id"] + assert j2["idempotency"]["replayed"] is True + assert j2["idempotency"]["key"] == "header-wins" + + +def test_send_idempotency_different_keys_create_different_message_ids(sofiia_client, sofiia_module, monkeypatch): + async def _fake_infer(base_url, agent_id, text, **kwargs): + return {"response": f"ok:{agent_id}:{text}", "backend": "fake", "model": "fake-model"} + + monkeypatch.setattr(sofiia_module, "infer", _fake_infer) + chat_id = _create_chat(sofiia_client, "sofiia", "NODA2", "idem-different") + + r1 = sofiia_client.post( + f"/api/chats/{chat_id}/send", + json={"text": "ping", "idempotency_key": "k1"}, + ) + r2 = sofiia_client.post( + f"/api/chats/{chat_id}/send", + json={"text": "ping", "idempotency_key": "k2"}, + ) + assert r1.status_code == 200 and r2.status_code == 200 + assert r1.json()["message"]["message_id"] != r2.json()["message"]["message_id"] + diff --git a/tests/test_sofiia_chat_pagination.py b/tests/test_sofiia_chat_pagination.py new file mode 100644 index 00000000..24c0677c --- /dev/null +++ b/tests/test_sofiia_chat_pagination.py @@ -0,0 +1,77 @@ +from __future__ import annotations + + +def _create_chat(client, agent_id: str, node_id: str, ref: str) -> str: + r = client.post( + "/api/chats", + json={ + "agent_id": agent_id, + "node_id": node_id, + "source": "web", + "external_chat_ref": ref, + }, + ) + assert r.status_code == 200, r.text + return r.json()["chat"]["chat_id"] + + +def test_list_chats_cursor_paginates_without_duplicates(sofiia_client, sofiia_module, monkeypatch): + async def _fake_infer(base_url, agent_id, text, **kwargs): + return {"response": f"ok:{agent_id}:{text}", "backend": "fake", "model": "fake-model"} + + monkeypatch.setattr(sofiia_module, "infer", _fake_infer) + + chat_ids = [] + for idx in range(5): + cid = _create_chat(sofiia_client, "sofiia", "NODA2", f"pag-chats-{idx}") + chat_ids.append(cid) + r = sofiia_client.post( + f"/api/chats/{cid}/send", + json={"text": f"touch-{idx}", "idempotency_key": f"touch-{idx}"}, + ) + assert r.status_code == 200, r.text + + p1 = sofiia_client.get("/api/chats?nodes=NODA2&limit=2") + assert p1.status_code == 200, p1.text + j1 = p1.json() + assert j1["count"] == 2 + assert j1["has_more"] is True + assert j1["next_cursor"] + + p2 = sofiia_client.get(f"/api/chats?nodes=NODA2&limit=2&cursor={j1['next_cursor']}") + assert p2.status_code == 200, p2.text + j2 = p2.json() + assert j2["count"] >= 1 + ids1 = {x["chat_id"] for x in j1["items"]} + ids2 = {x["chat_id"] for x in j2["items"]} + assert ids1.isdisjoint(ids2) + + +def test_list_messages_cursor_paginates_without_duplicates(sofiia_client, sofiia_module, monkeypatch): + async def _fake_infer(base_url, agent_id, text, **kwargs): + return {"response": f"ok:{agent_id}:{text}", "backend": "fake", "model": "fake-model"} + + monkeypatch.setattr(sofiia_module, "infer", _fake_infer) + cid = _create_chat(sofiia_client, "sofiia", "NODA2", "pag-messages") + for idx in range(4): + r = sofiia_client.post( + f"/api/chats/{cid}/send", + json={"text": f"msg-{idx}", "idempotency_key": f"msg-{idx}"}, + ) + assert r.status_code == 200, r.text + + p1 = sofiia_client.get(f"/api/chats/{cid}/messages?limit=2") + assert p1.status_code == 200, p1.text + j1 = p1.json() + assert j1["count"] == 2 + assert j1["has_more"] is True + assert j1["next_cursor"] + + p2 = sofiia_client.get(f"/api/chats/{cid}/messages?limit=2&cursor={j1['next_cursor']}") + assert p2.status_code == 200, p2.text + j2 = p2.json() + assert j2["count"] >= 1 + ids1 = {x["message_id"] for x in j1["items"]} + ids2 = {x["message_id"] for x in j2["items"]} + assert ids1.isdisjoint(ids2) + diff --git a/tests/test_sofiia_chat_routing.py b/tests/test_sofiia_chat_routing.py new file mode 100644 index 00000000..b0136471 --- /dev/null +++ b/tests/test_sofiia_chat_routing.py @@ -0,0 +1,60 @@ +from __future__ import annotations + + +def _create_chat(client, agent_id: str, node_id: str, ref: str) -> str: + r = client.post( + "/api/chats", + json={ + "agent_id": agent_id, + "node_id": node_id, + "source": "web", + "external_chat_ref": ref, + }, + ) + assert r.status_code == 200, r.text + return r.json()["chat"]["chat_id"] + + +def test_send_routes_to_noda1(sofiia_client, sofiia_module, monkeypatch): + calls = [] + + def _router_url(node_id: str) -> str: + return {"NODA1": "http://noda1-router.test", "NODA2": "http://noda2-router.test"}.get(node_id, "") + + async def _fake_infer(base_url, agent_id, text, **kwargs): + calls.append({"base_url": base_url, "agent_id": agent_id, "text": text}) + return {"response": "ok-n1", "backend": "fake", "model": "fake-model"} + + monkeypatch.setattr(sofiia_module, "get_router_url", _router_url) + monkeypatch.setattr(sofiia_module, "infer", _fake_infer) + + cid = _create_chat(sofiia_client, "monitor", "NODA1", "route-n1") + r = sofiia_client.post(f"/api/chats/{cid}/send", json={"text": "ping-n1"}) + assert r.status_code == 200, r.text + body = r.json() + assert body["accepted"] is True + assert body["node_id"] == "NODA1" + assert calls and calls[-1]["base_url"] == "http://noda1-router.test" + + +def test_send_routes_to_noda2(sofiia_client, sofiia_module, monkeypatch): + calls = [] + + def _router_url(node_id: str) -> str: + return {"NODA1": "http://noda1-router.test", "NODA2": "http://noda2-router.test"}.get(node_id, "") + + async def _fake_infer(base_url, agent_id, text, **kwargs): + calls.append({"base_url": base_url, "agent_id": agent_id, "text": text}) + return {"response": "ok-n2", "backend": "fake", "model": "fake-model"} + + monkeypatch.setattr(sofiia_module, "get_router_url", _router_url) + monkeypatch.setattr(sofiia_module, "infer", _fake_infer) + + cid = _create_chat(sofiia_client, "sofiia", "NODA2", "route-n2") + r = sofiia_client.post(f"/api/chats/{cid}/send", json={"text": "ping-n2"}) + assert r.status_code == 200, r.text + body = r.json() + assert body["accepted"] is True + assert body["node_id"] == "NODA2" + assert calls and calls[-1]["base_url"] == "http://noda2-router.test" +