Files
lfnovo--open-notebook/tests/test_chat_routers_characterization.py
2026-07-13 12:10:23 +08:00

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": [],
}