test(sofiia-console): cover idempotency and cursor pagination contracts
Add focused API contract tests for chat idempotency, cursor pagination, and node routing behavior using isolated local fixtures and mocked upstream inference. Made-with: Cursor
This commit is contained in:
65
tests/conftest.py
Normal file
65
tests/conftest.py
Normal file
@@ -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)
|
||||||
|
|
||||||
95
tests/test_sofiia_chat_idempotency.py
Normal file
95
tests/test_sofiia_chat_idempotency.py
Normal file
@@ -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"]
|
||||||
|
|
||||||
77
tests/test_sofiia_chat_pagination.py
Normal file
77
tests/test_sofiia_chat_pagination.py
Normal file
@@ -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)
|
||||||
|
|
||||||
60
tests/test_sofiia_chat_routing.py
Normal file
60
tests/test_sofiia_chat_routing.py
Normal file
@@ -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"
|
||||||
|
|
||||||
Reference in New Issue
Block a user