333 lines
11 KiB
Python
333 lines
11 KiB
Python
"""Characterization tests for the chat and source-chat routers.
|
|
|
|
These pin down the behaviors shared (via copy-paste) between
|
|
`api/routers/chat.py` and `api/routers/source_chat.py` before/after extracting
|
|
them into `api/routers/_chat_shared.py`:
|
|
|
|
- record-ID normalization (bare id vs already-prefixed id)
|
|
- session/source verification (missing record -> 404, missing `refers_to`
|
|
relation -> 404 on every method now that the routers re-raise HTTPException
|
|
instead of swallowing it into a 500)
|
|
- LangGraph state -> `ChatMessage` extraction shapes (type/content fallbacks)
|
|
|
|
DB access and LangGraph state are mocked following the style of
|
|
tests/test_crud_404.py.
|
|
"""
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from open_notebook.exceptions import NotFoundError
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
from api.main import app
|
|
|
|
return TestClient(app)
|
|
|
|
|
|
def _nf(*_args, **_kwargs):
|
|
raise NotFoundError("not found")
|
|
|
|
|
|
def _session(**overrides):
|
|
"""A ChatSession-like object with the attributes the routers read."""
|
|
defaults = dict(
|
|
id="chat_session:abc",
|
|
title="My Session",
|
|
created="2026-01-01T00:00:00",
|
|
updated="2026-01-02T00:00:00",
|
|
model_override=None,
|
|
)
|
|
defaults.update(overrides)
|
|
return SimpleNamespace(**defaults)
|
|
|
|
|
|
def _source(**overrides):
|
|
defaults = dict(id="source:xyz", title="My Source")
|
|
defaults.update(overrides)
|
|
return SimpleNamespace(**defaults)
|
|
|
|
|
|
def _graph_state(values):
|
|
state = MagicMock()
|
|
state.values = values
|
|
return state
|
|
|
|
|
|
class _Msg:
|
|
"""A LangChain-like message with id/type/content."""
|
|
|
|
def __init__(self, id, type, content):
|
|
self.id = id
|
|
self.type = type
|
|
self.content = content
|
|
|
|
|
|
class _Bare:
|
|
"""An object with no type/content attributes (exercises the fallbacks)."""
|
|
|
|
def __str__(self):
|
|
return "bare-repr"
|
|
|
|
|
|
# --- chat.py: ID normalization ------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("api.routers.chat.repo_query", new_callable=AsyncMock)
|
|
@patch("api.routers.chat.chat_graph")
|
|
@patch("api.routers.chat.ChatSession.get", new_callable=AsyncMock)
|
|
async def test_get_chat_session_bare_id_gets_prefixed(
|
|
mock_get, mock_graph, mock_repo, client
|
|
):
|
|
mock_get.return_value = _session()
|
|
mock_graph.get_state.return_value = _graph_state({"messages": []})
|
|
mock_repo.return_value = [{"out": "notebook:1"}]
|
|
|
|
resp = client.get("/api/chat/sessions/abc")
|
|
|
|
assert resp.status_code == 200
|
|
mock_get.assert_awaited_once_with("chat_session:abc")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("api.routers.chat.repo_query", new_callable=AsyncMock)
|
|
@patch("api.routers.chat.chat_graph")
|
|
@patch("api.routers.chat.ChatSession.get", new_callable=AsyncMock)
|
|
async def test_get_chat_session_prefixed_id_kept_as_is(
|
|
mock_get, mock_graph, mock_repo, client
|
|
):
|
|
mock_get.return_value = _session()
|
|
mock_graph.get_state.return_value = _graph_state({"messages": []})
|
|
mock_repo.return_value = [{"out": "notebook:1"}]
|
|
|
|
resp = client.get("/api/chat/sessions/chat_session:abc")
|
|
|
|
assert resp.status_code == 200
|
|
mock_get.assert_awaited_once_with("chat_session:abc")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("api.routers.chat.ChatSession.get", new_callable=AsyncMock)
|
|
async def test_delete_chat_session_missing_returns_404(mock_get, client):
|
|
mock_get.side_effect = _nf
|
|
resp = client.delete("/api/chat/sessions/gone")
|
|
assert resp.status_code == 404
|
|
assert resp.json()["detail"] == "Session not found"
|
|
mock_get.assert_awaited_once_with("chat_session:gone")
|
|
|
|
|
|
# --- chat.py: message extraction shape ----------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("api.routers.chat.repo_query", new_callable=AsyncMock)
|
|
@patch("api.routers.chat.chat_graph")
|
|
@patch("api.routers.chat.ChatSession.get", new_callable=AsyncMock)
|
|
async def test_get_chat_session_message_shapes(mock_get, mock_graph, mock_repo, client):
|
|
mock_get.return_value = _session()
|
|
mock_graph.get_state.return_value = _graph_state(
|
|
{"messages": [_Msg("m1", "human", "hello"), _Msg("m2", "ai", "hi"), _Bare()]}
|
|
)
|
|
mock_repo.return_value = [{"out": "notebook:1"}]
|
|
|
|
resp = client.get("/api/chat/sessions/abc")
|
|
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["message_count"] == 3
|
|
assert body["messages"][0] == {
|
|
"id": "m1",
|
|
"type": "human",
|
|
"content": "hello",
|
|
"timestamp": None,
|
|
}
|
|
assert body["messages"][1]["type"] == "ai"
|
|
# Object without type/content falls back to "unknown" / str(msg); the id
|
|
# fallback is positional (msg_<index>).
|
|
assert body["messages"][2] == {
|
|
"id": "msg_2",
|
|
"type": "unknown",
|
|
"content": "bare-repr",
|
|
"timestamp": None,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("api.routers.chat.repo_query", new_callable=AsyncMock)
|
|
@patch("api.routers.chat.chat_graph")
|
|
@patch("api.routers.chat.ChatSession.get", new_callable=AsyncMock)
|
|
async def test_get_chat_session_no_state_yields_empty_messages(
|
|
mock_get, mock_graph, mock_repo, client
|
|
):
|
|
mock_get.return_value = _session()
|
|
mock_graph.get_state.return_value = None
|
|
mock_repo.return_value = []
|
|
|
|
resp = client.get("/api/chat/sessions/abc")
|
|
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["messages"] == []
|
|
assert body["message_count"] == 0
|
|
assert body["notebook_id"] is None
|
|
|
|
|
|
# --- source_chat.py: source verification --------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("api.routers._chat_shared.Source.get", new_callable=AsyncMock)
|
|
async def test_create_source_chat_session_missing_source_returns_404(mock_get, client):
|
|
mock_get.side_effect = _nf
|
|
resp = client.post(
|
|
"/api/sources/gone/chat/sessions", json={"source_id": "gone"}
|
|
)
|
|
assert resp.status_code == 404
|
|
assert resp.json()["detail"] == "Source not found"
|
|
mock_get.assert_awaited_once_with("source:gone")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("api.routers._chat_shared.Source.get", new_callable=AsyncMock)
|
|
async def test_list_source_chat_sessions_missing_source_returns_404(mock_get, client):
|
|
mock_get.side_effect = _nf
|
|
resp = client.get("/api/sources/source:gone/chat/sessions")
|
|
assert resp.status_code == 404
|
|
assert resp.json()["detail"] == "Source not found"
|
|
mock_get.assert_awaited_once_with("source:gone")
|
|
|
|
|
|
# --- source_chat.py: session + relation verification ---------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("api.routers._chat_shared.repo_query", new_callable=AsyncMock)
|
|
@patch("api.routers._chat_shared.ChatSession.get", new_callable=AsyncMock)
|
|
@patch("api.routers._chat_shared.Source.get", new_callable=AsyncMock)
|
|
async def test_get_source_chat_session_missing_session_returns_404(
|
|
mock_source_get, mock_session_get, mock_repo, client
|
|
):
|
|
mock_source_get.return_value = _source()
|
|
mock_session_get.side_effect = _nf
|
|
|
|
resp = client.get("/api/sources/xyz/chat/sessions/gone")
|
|
|
|
assert resp.status_code == 404
|
|
assert resp.json()["detail"] == "Source or session not found"
|
|
mock_source_get.assert_awaited_once_with("source:xyz")
|
|
mock_session_get.assert_awaited_once_with("chat_session:gone")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("api.routers._chat_shared.repo_query", new_callable=AsyncMock)
|
|
@patch("api.routers._chat_shared.ChatSession.get", new_callable=AsyncMock)
|
|
@patch("api.routers._chat_shared.Source.get", new_callable=AsyncMock)
|
|
async def test_get_source_chat_session_missing_relation_behavior(
|
|
mock_source_get, mock_session_get, mock_repo, client
|
|
):
|
|
"""Session exists but is not related to the source.
|
|
|
|
Intentional behavior change: the router now re-raises HTTPException before
|
|
its broad `except Exception`, so the inner 404 surfaces as a real 404
|
|
instead of being swallowed and re-raised as a 500 embedding the original
|
|
404 message.
|
|
"""
|
|
mock_source_get.return_value = _source()
|
|
mock_session_get.return_value = _session()
|
|
mock_repo.return_value = [] # no refers_to relation
|
|
|
|
resp = client.get("/api/sources/xyz/chat/sessions/abc")
|
|
|
|
assert resp.status_code == 404
|
|
assert resp.json()["detail"] == "Session not found for this source"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("api.routers._chat_shared.repo_query", new_callable=AsyncMock)
|
|
@patch("api.routers._chat_shared.ChatSession.get", new_callable=AsyncMock)
|
|
@patch("api.routers._chat_shared.Source.get", new_callable=AsyncMock)
|
|
async def test_delete_source_chat_session_missing_relation_behavior(
|
|
mock_source_get, mock_session_get, mock_repo, client
|
|
):
|
|
# Intentional behavior change: the inner 404 is no longer swallowed into a
|
|
# 500 by the broad `except Exception` (see the get test above).
|
|
mock_source_get.return_value = _source()
|
|
mock_session_get.return_value = _session()
|
|
mock_repo.return_value = []
|
|
|
|
resp = client.delete("/api/sources/xyz/chat/sessions/abc")
|
|
|
|
assert resp.status_code == 404
|
|
assert resp.json()["detail"] == "Session not found for this source"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("api.routers._chat_shared.repo_query", new_callable=AsyncMock)
|
|
@patch("api.routers._chat_shared.ChatSession.get", new_callable=AsyncMock)
|
|
@patch("api.routers._chat_shared.Source.get", new_callable=AsyncMock)
|
|
async def test_send_message_missing_relation_returns_404(
|
|
mock_source_get, mock_session_get, mock_repo, client
|
|
):
|
|
"""send_message re-raises HTTPException before its broad handler, so the
|
|
missing-relation case surfaces as a real 404 here (unlike get/put/delete)."""
|
|
mock_source_get.return_value = _source()
|
|
mock_session_get.return_value = _session()
|
|
mock_repo.return_value = []
|
|
|
|
resp = client.post(
|
|
"/api/sources/xyz/chat/sessions/abc/messages", json={"message": "hi"}
|
|
)
|
|
|
|
assert resp.status_code == 404
|
|
assert resp.json()["detail"] == "Session not found for this source"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("api.routers.source_chat.source_chat_graph")
|
|
@patch("api.routers._chat_shared.repo_query", new_callable=AsyncMock)
|
|
@patch("api.routers._chat_shared.ChatSession.get", new_callable=AsyncMock)
|
|
@patch("api.routers._chat_shared.Source.get", new_callable=AsyncMock)
|
|
async def test_get_source_chat_session_happy_path_shapes(
|
|
mock_source_get, mock_session_get, mock_repo, mock_graph, client
|
|
):
|
|
mock_source_get.return_value = _source()
|
|
mock_session_get.return_value = _session()
|
|
mock_repo.return_value = [{"in": "chat_session:abc", "out": "source:xyz"}]
|
|
mock_graph.get_state.return_value = _graph_state(
|
|
{
|
|
"messages": [_Msg("m1", "human", "hello"), _Bare()],
|
|
"context_indicators": {"sources": ["source:xyz"], "insights": []},
|
|
}
|
|
)
|
|
|
|
resp = client.get("/api/sources/xyz/chat/sessions/abc")
|
|
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["id"] == "chat_session:abc"
|
|
assert body["source_id"] == "xyz"
|
|
assert body["message_count"] == 2
|
|
assert body["messages"][0] == {
|
|
"id": "m1",
|
|
"type": "human",
|
|
"content": "hello",
|
|
"timestamp": None,
|
|
}
|
|
assert body["messages"][1] == {
|
|
"id": "msg_1",
|
|
"type": "unknown",
|
|
"content": "bare-repr",
|
|
"timestamp": None,
|
|
}
|
|
assert body["context_indicators"] == {
|
|
"sources": ["source:xyz"],
|
|
"insights": [],
|
|
"notes": [],
|
|
}
|