mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-28 13:26:48 +00:00
216 lines
7.8 KiB
Python
216 lines
7.8 KiB
Python
"""Tests for the WebSocket streaming endpoint."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
|
||
fastapi = pytest.importorskip("fastapi")
|
||
from fastapi import FastAPI # noqa: E402
|
||
from starlette.testclient import TestClient # noqa: E402
|
||
|
||
from openjarvis.server.api_routes import include_all_routes # noqa: E402
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _make_app(engine=None):
|
||
"""Create a minimal FastAPI app with mock engine wired up."""
|
||
app = FastAPI()
|
||
if engine is None:
|
||
engine = _make_streaming_engine()
|
||
app.state.engine = engine
|
||
app.state.model = "test-model"
|
||
include_all_routes(app)
|
||
return app
|
||
|
||
|
||
def _make_streaming_engine(tokens=None):
|
||
"""Return a mock engine whose ``stream()`` yields tokens."""
|
||
if tokens is None:
|
||
tokens = ["Hello", " ", "world"]
|
||
engine = MagicMock()
|
||
engine.engine_id = "mock"
|
||
|
||
async def mock_stream(messages, *, model="test-model", **kwargs):
|
||
for tok in tokens:
|
||
yield tok
|
||
|
||
engine.stream = mock_stream
|
||
engine.generate.return_value = {
|
||
"content": "Hello world",
|
||
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8},
|
||
"model": "test-model",
|
||
"finish_reason": "stop",
|
||
}
|
||
return engine
|
||
|
||
|
||
def _make_generate_only_engine(content="Hello world"):
|
||
"""Return a mock engine that only has ``generate()`` (no ``stream()``)."""
|
||
engine = MagicMock(spec=["generate", "engine_id"])
|
||
engine.engine_id = "mock-nostream"
|
||
engine.generate.return_value = {
|
||
"content": content,
|
||
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8},
|
||
"model": "test-model",
|
||
"finish_reason": "stop",
|
||
}
|
||
return engine
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestWebSocketStreaming:
|
||
"""Tests for WS /v1/chat/stream endpoint."""
|
||
|
||
def test_basic_streaming_exchange(self):
|
||
"""A valid message should produce chunk messages followed by a done."""
|
||
app = _make_app()
|
||
client = TestClient(app)
|
||
with client.websocket_connect("/v1/chat/stream") as ws:
|
||
ws.send_text(json.dumps({"message": "Hi"}))
|
||
chunks = []
|
||
done = None
|
||
# Read all responses until we get 'done'
|
||
while True:
|
||
data = ws.receive_json()
|
||
if data["type"] == "chunk":
|
||
chunks.append(data["content"])
|
||
elif data["type"] == "done":
|
||
done = data
|
||
break
|
||
else:
|
||
break
|
||
assert len(chunks) == 3
|
||
assert chunks == ["Hello", " ", "world"]
|
||
assert done is not None
|
||
assert done["content"] == "Hello world"
|
||
|
||
def test_missing_message_field(self):
|
||
"""Sending JSON without a 'message' field should return an error."""
|
||
app = _make_app()
|
||
client = TestClient(app)
|
||
with client.websocket_connect("/v1/chat/stream") as ws:
|
||
ws.send_text(json.dumps({"text": "Hi"}))
|
||
data = ws.receive_json()
|
||
assert data["type"] == "error"
|
||
assert "Missing" in data["detail"]
|
||
|
||
def test_invalid_json(self):
|
||
"""Sending non-JSON text should return an error."""
|
||
app = _make_app()
|
||
client = TestClient(app)
|
||
with client.websocket_connect("/v1/chat/stream") as ws:
|
||
ws.send_text("not json at all")
|
||
data = ws.receive_json()
|
||
assert data["type"] == "error"
|
||
assert "Invalid JSON" in data["detail"]
|
||
|
||
def test_empty_message_field(self):
|
||
"""An empty string for 'message' should return an error."""
|
||
app = _make_app()
|
||
client = TestClient(app)
|
||
with client.websocket_connect("/v1/chat/stream") as ws:
|
||
ws.send_text(json.dumps({"message": ""}))
|
||
data = ws.receive_json()
|
||
assert data["type"] == "error"
|
||
assert "Missing" in data["detail"]
|
||
|
||
def test_generate_fallback_when_no_stream(self):
|
||
"""When the engine has no stream(), generate() result is sent as one chunk."""
|
||
engine = _make_generate_only_engine("Fallback response")
|
||
app = _make_app(engine=engine)
|
||
client = TestClient(app)
|
||
with client.websocket_connect("/v1/chat/stream") as ws:
|
||
ws.send_text(json.dumps({"message": "Hi"}))
|
||
chunks = []
|
||
done = None
|
||
while True:
|
||
data = ws.receive_json()
|
||
if data["type"] == "chunk":
|
||
chunks.append(data["content"])
|
||
elif data["type"] == "done":
|
||
done = data
|
||
break
|
||
else:
|
||
break
|
||
assert len(chunks) == 1
|
||
assert chunks[0] == "Fallback response"
|
||
assert done is not None
|
||
assert done["content"] == "Fallback response"
|
||
|
||
def test_custom_model_in_request(self):
|
||
"""The model field from the request should be forwarded to the engine."""
|
||
tokens = ["OK"]
|
||
engine = _make_streaming_engine(tokens=tokens)
|
||
app = _make_app(engine=engine)
|
||
client = TestClient(app)
|
||
with client.websocket_connect("/v1/chat/stream") as ws:
|
||
ws.send_text(json.dumps({"message": "Hi", "model": "custom-model"}))
|
||
# Consume until done
|
||
while True:
|
||
data = ws.receive_json()
|
||
if data["type"] == "done":
|
||
break
|
||
# The mock stream function was called — we can't easily inspect
|
||
# async-generator call args, but the exchange completed without error
|
||
assert data["content"] == "OK"
|
||
|
||
def test_engine_error_returns_error_message(self):
|
||
"""If the engine raises, the endpoint should send an error frame."""
|
||
engine = MagicMock()
|
||
|
||
async def bad_stream(messages, *, model="test-model", **kwargs):
|
||
raise RuntimeError("Engine exploded")
|
||
# Make it look like an async generator to the endpoint
|
||
yield # pragma: no cover – unreachable, but needed for async gen syntax
|
||
|
||
engine.stream = bad_stream
|
||
app = _make_app(engine=engine)
|
||
client = TestClient(app)
|
||
with client.websocket_connect("/v1/chat/stream") as ws:
|
||
ws.send_text(json.dumps({"message": "boom"}))
|
||
data = ws.receive_json()
|
||
assert data["type"] == "error"
|
||
assert "Engine exploded" in data["detail"]
|
||
|
||
def test_multiple_messages_on_same_connection(self):
|
||
"""The WebSocket should support multiple request/response cycles."""
|
||
app = _make_app()
|
||
client = TestClient(app)
|
||
with client.websocket_connect("/v1/chat/stream") as ws:
|
||
for _ in range(3):
|
||
ws.send_text(json.dumps({"message": "Hi"}))
|
||
# Drain until done
|
||
while True:
|
||
data = ws.receive_json()
|
||
if data["type"] == "done":
|
||
assert data["content"] == "Hello world"
|
||
break
|
||
|
||
def test_no_engine_configured(self):
|
||
"""If app.state has no engine, an error should be returned."""
|
||
app = FastAPI()
|
||
app.state.model = "test-model"
|
||
# Intentionally do NOT set app.state.engine
|
||
include_all_routes(app)
|
||
client = TestClient(app)
|
||
with client.websocket_connect("/v1/chat/stream") as ws:
|
||
ws.send_text(json.dumps({"message": "Hi"}))
|
||
data = ws.receive_json()
|
||
assert data["type"] == "error"
|
||
assert "engine" in data["detail"].lower()
|
||
|
||
|
||
__all__ = [
|
||
"TestWebSocketStreaming",
|
||
]
|