7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
967 lines
36 KiB
Python
967 lines
36 KiB
Python
"""Unit tests for ChatService."""
|
|
|
|
import uuid
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from freezegun import freeze_time
|
|
|
|
from tests.chat.conftest import setup_query_mock_with_session
|
|
|
|
|
|
class TestChatServiceSessionCreation:
|
|
"""Tests for ChatService session creation."""
|
|
|
|
def test_create_session_returns_uuid(self, mock_user_db_session):
|
|
"""Test that create_session returns a valid UUID."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
mock_user_db_session.add = MagicMock()
|
|
mock_user_db_session.commit = MagicMock()
|
|
|
|
service = ChatService(username="testuser")
|
|
session_id = service.create_session()
|
|
|
|
# Verify it's a valid UUID format
|
|
assert session_id is not None
|
|
try:
|
|
uuid.UUID(session_id)
|
|
except ValueError:
|
|
pytest.fail("create_session did not return a valid UUID")
|
|
|
|
def test_create_session_with_initial_query(self, mock_user_db_session):
|
|
"""Test creating a session with an initial query."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
mock_user_db_session.add = MagicMock()
|
|
mock_user_db_session.commit = MagicMock()
|
|
|
|
service = ChatService(username="testuser")
|
|
initial_query = "What is quantum computing?"
|
|
session_id = service.create_session(initial_query=initial_query)
|
|
|
|
assert session_id is not None
|
|
# Verify that session was added
|
|
mock_user_db_session.add.assert_called()
|
|
|
|
def test_create_session_with_custom_title(self, mock_user_db_session):
|
|
"""Test creating a session with a custom title."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
mock_user_db_session.add = MagicMock()
|
|
mock_user_db_session.commit = MagicMock()
|
|
|
|
service = ChatService(username="testuser")
|
|
custom_title = "My Research Session"
|
|
session_id = service.create_session(title=custom_title)
|
|
|
|
assert session_id is not None
|
|
# The session should be added to the database
|
|
mock_user_db_session.add.assert_called()
|
|
|
|
def test_create_session_generates_title_from_query(
|
|
self, mock_user_db_session, long_query_text
|
|
):
|
|
"""Test that title is generated from query and truncated to 100 chars + '...'."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
mock_user_db_session.add = MagicMock()
|
|
mock_user_db_session.commit = MagicMock()
|
|
|
|
service = ChatService(username="testuser")
|
|
session_id = service.create_session(initial_query=long_query_text)
|
|
|
|
assert session_id is not None
|
|
# The session was added (title truncation happens in service)
|
|
mock_user_db_session.add.assert_called()
|
|
|
|
# Get the added session and verify title length.
|
|
# ChatSession always has a `title` column — drop the hasattr guard
|
|
# so a regression that stops populating it surfaces here instead
|
|
# of silently passing.
|
|
added_session = mock_user_db_session.add.call_args[0][0]
|
|
assert len(long_query_text) > 100, (
|
|
"test fixture must exceed the 100-char truncation threshold"
|
|
)
|
|
assert len(added_session.title) <= 103 # 100 chars + "..."
|
|
|
|
@freeze_time("2024-01-15 14:30:00")
|
|
def test_create_session_generates_title_without_query(
|
|
self, mock_user_db_session
|
|
):
|
|
"""Test that title is generated in format 'Chat YYYY-MM-DD HH:MM' when no query."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
mock_user_db_session.add = MagicMock()
|
|
mock_user_db_session.commit = MagicMock()
|
|
|
|
service = ChatService(username="testuser")
|
|
session_id = service.create_session()
|
|
|
|
assert session_id is not None
|
|
mock_user_db_session.add.assert_called()
|
|
|
|
# Get the added session and verify title format
|
|
added_session = mock_user_db_session.add.call_args[0][0]
|
|
expected_title = "Chat 2024-01-15 14:30"
|
|
assert added_session.title == expected_title
|
|
|
|
def test_create_session_initializes_accumulated_context(
|
|
self, mock_user_db_session
|
|
):
|
|
"""Test that accumulated_context is initialized with proper structure."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
mock_user_db_session.add = MagicMock()
|
|
mock_user_db_session.commit = MagicMock()
|
|
|
|
service = ChatService(username="testuser")
|
|
session_id = service.create_session()
|
|
|
|
assert session_id is not None
|
|
# Verify the session was added with accumulated_context
|
|
added_session = mock_user_db_session.add.call_args[0][0]
|
|
# Actual implementation initializes with this structure
|
|
expected_context = {
|
|
"key_entities": [],
|
|
"topics": [],
|
|
"summary": "",
|
|
}
|
|
assert added_session.accumulated_context == expected_context
|
|
|
|
|
|
class TestChatServiceAddMessage:
|
|
"""Tests for ChatService add_message method."""
|
|
|
|
def test_add_message_returns_uuid(
|
|
self, mock_user_db_session, mock_chat_session
|
|
):
|
|
"""Test that add_message returns a valid UUID."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
# Set up query mock to return session (handles with_for_update)
|
|
setup_query_mock_with_session(mock_user_db_session, mock_chat_session)
|
|
mock_user_db_session.add = MagicMock()
|
|
mock_user_db_session.commit = MagicMock()
|
|
|
|
service = ChatService(username="testuser")
|
|
message_id = service.add_message(
|
|
session_id=mock_chat_session.id,
|
|
role="user",
|
|
content="Test message",
|
|
message_type="query",
|
|
)
|
|
|
|
assert message_id is not None
|
|
try:
|
|
uuid.UUID(message_id)
|
|
except ValueError:
|
|
pytest.fail("add_message did not return a valid UUID")
|
|
|
|
def test_add_message_increments_sequence_number(
|
|
self, mock_user_db_session, mock_chat_session
|
|
):
|
|
"""Test that sequence number increments with each message."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
mock_chat_session.message_count = 5
|
|
setup_query_mock_with_session(mock_user_db_session, mock_chat_session)
|
|
mock_user_db_session.add = MagicMock()
|
|
mock_user_db_session.commit = MagicMock()
|
|
|
|
service = ChatService(username="testuser")
|
|
message_id = service.add_message(
|
|
session_id=mock_chat_session.id,
|
|
role="user",
|
|
content="Test message",
|
|
message_type="query",
|
|
)
|
|
|
|
assert message_id is not None
|
|
# Verify the added message has correct sequence number
|
|
added_message = mock_user_db_session.add.call_args[0][0]
|
|
assert added_message.sequence_number == 6
|
|
|
|
def test_add_message_updates_session_message_count(
|
|
self, mock_user_db_session, mock_chat_session
|
|
):
|
|
"""Test that session message_count is updated."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
mock_chat_session.message_count = 5
|
|
setup_query_mock_with_session(mock_user_db_session, mock_chat_session)
|
|
mock_user_db_session.add = MagicMock()
|
|
mock_user_db_session.commit = MagicMock()
|
|
|
|
service = ChatService(username="testuser")
|
|
service.add_message(
|
|
session_id=mock_chat_session.id,
|
|
role="user",
|
|
content="Test message",
|
|
message_type="query",
|
|
)
|
|
|
|
# Session message_count should be incremented
|
|
assert mock_chat_session.message_count == 6
|
|
|
|
def test_add_message_raises_for_invalid_session(self, mock_user_db_session):
|
|
"""Test that add_message raises ValueError for non-existent session."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
# Set up query mock to return None (session not found)
|
|
setup_query_mock_with_session(mock_user_db_session, None)
|
|
|
|
service = ChatService(username="testuser")
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
service.add_message(
|
|
session_id="nonexistent-session",
|
|
role="user",
|
|
content="Test message",
|
|
message_type="query",
|
|
)
|
|
|
|
assert "not found" in str(exc_info.value)
|
|
|
|
|
|
class TestChatServiceContextAccumulation:
|
|
"""Tests for ChatService context accumulation methods."""
|
|
|
|
def test_update_accumulated_context_adds_entities(
|
|
self, mock_user_db_session, mock_chat_session
|
|
):
|
|
"""Test that new entities are added to accumulated context."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
mock_chat_session.accumulated_context = {
|
|
"key_entities": ["entity1"],
|
|
"topics": [],
|
|
"summary": "",
|
|
}
|
|
setup_query_mock_with_session(mock_user_db_session, mock_chat_session)
|
|
mock_user_db_session.commit = MagicMock()
|
|
|
|
service = ChatService(username="testuser")
|
|
# Use the actual method signature with individual params
|
|
result = service.update_accumulated_context(
|
|
session_id=mock_chat_session.id,
|
|
new_entities=["entity2", "entity3"],
|
|
new_topics=["topic1"],
|
|
summary_addition="New summary",
|
|
)
|
|
|
|
assert result is True
|
|
# Verify entities were added
|
|
assert (
|
|
"entity1" in mock_chat_session.accumulated_context["key_entities"]
|
|
)
|
|
assert (
|
|
"entity2" in mock_chat_session.accumulated_context["key_entities"]
|
|
)
|
|
assert (
|
|
"entity3" in mock_chat_session.accumulated_context["key_entities"]
|
|
)
|
|
|
|
def test_update_accumulated_context_limits_entities_to_50(
|
|
self, mock_user_db_session, mock_chat_session
|
|
):
|
|
"""Test that entities are limited to 50."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
mock_chat_session.accumulated_context = {
|
|
"key_entities": [],
|
|
"topics": [],
|
|
"summary": "",
|
|
}
|
|
setup_query_mock_with_session(mock_user_db_session, mock_chat_session)
|
|
mock_user_db_session.commit = MagicMock()
|
|
|
|
service = ChatService(username="testuser")
|
|
# Add 60 entities
|
|
many_entities = [f"entity_{i}" for i in range(60)]
|
|
service.update_accumulated_context(
|
|
session_id=mock_chat_session.id,
|
|
new_entities=many_entities,
|
|
)
|
|
|
|
# Entities should be capped at 50
|
|
assert len(mock_chat_session.accumulated_context["key_entities"]) <= 50
|
|
|
|
def test_update_accumulated_context_limits_topics_to_20(
|
|
self, mock_user_db_session, mock_chat_session
|
|
):
|
|
"""Test that topics are limited to 20."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
mock_chat_session.accumulated_context = {
|
|
"key_entities": [],
|
|
"topics": [],
|
|
"summary": "",
|
|
}
|
|
setup_query_mock_with_session(mock_user_db_session, mock_chat_session)
|
|
mock_user_db_session.commit = MagicMock()
|
|
|
|
service = ChatService(username="testuser")
|
|
# Add 25 topics
|
|
many_topics = [f"topic_{i}" for i in range(25)]
|
|
service.update_accumulated_context(
|
|
session_id=mock_chat_session.id,
|
|
new_topics=many_topics,
|
|
)
|
|
|
|
# Topics should be capped at 20
|
|
assert len(mock_chat_session.accumulated_context["topics"]) <= 20
|
|
|
|
def test_update_accumulated_context_truncates_summary_to_8000_chars(
|
|
self, mock_user_db_session, mock_chat_session
|
|
):
|
|
"""Test that summary is truncated to 8000 characters."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
mock_chat_session.accumulated_context = {
|
|
"key_entities": [],
|
|
"topics": [],
|
|
"summary": "",
|
|
}
|
|
setup_query_mock_with_session(mock_user_db_session, mock_chat_session)
|
|
mock_user_db_session.commit = MagicMock()
|
|
|
|
service = ChatService(username="testuser")
|
|
# Add a 9000 character summary
|
|
long_summary = "A" * 9000
|
|
service.update_accumulated_context(
|
|
session_id=mock_chat_session.id,
|
|
summary_addition=long_summary,
|
|
)
|
|
|
|
# Summary should be capped at 8000 characters
|
|
assert len(mock_chat_session.accumulated_context["summary"]) <= 8000
|
|
|
|
|
|
class TestChatServiceGetSession:
|
|
"""Tests for ChatService get_session method."""
|
|
|
|
def test_get_session_returns_session_dict(
|
|
self, mock_user_db_session, mock_chat_session
|
|
):
|
|
"""Test that get_session returns the correct session as a dict."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
query_mock = MagicMock()
|
|
query_mock.filter_by.return_value.first.return_value = mock_chat_session
|
|
mock_user_db_session.query.return_value = query_mock
|
|
|
|
service = ChatService(username="testuser")
|
|
result = service.get_session(mock_chat_session.id)
|
|
|
|
assert result is not None
|
|
# get_session returns a dict, not an object
|
|
assert isinstance(result, dict)
|
|
assert result["id"] == mock_chat_session.id
|
|
|
|
def test_get_session_raises_not_found_for_nonexistent(
|
|
self, mock_user_db_session
|
|
):
|
|
"""Test that get_session raises ChatSessionNotFound for missing rows."""
|
|
import pytest
|
|
from src.local_deep_research.chat.service import (
|
|
ChatService,
|
|
ChatSessionNotFound,
|
|
)
|
|
|
|
query_mock = MagicMock()
|
|
query_mock.filter_by.return_value.first.return_value = None
|
|
mock_user_db_session.query.return_value = query_mock
|
|
|
|
service = ChatService(username="testuser")
|
|
|
|
with pytest.raises(ChatSessionNotFound):
|
|
service.get_session("nonexistent-id")
|
|
|
|
|
|
class TestChatServiceGetSessionMessages:
|
|
"""Tests for ChatService get_session_messages method."""
|
|
|
|
def _setup_query_side_effect(
|
|
self, mock_user_db_session, mock_messages, mock_steps=()
|
|
):
|
|
"""get_session_messages does TWO queries (ChatMessage
|
|
+ ChatProgressStep). Helper sets up the side_effect to dispatch."""
|
|
from src.local_deep_research.database.models import (
|
|
ChatMessage,
|
|
ChatProgressStep,
|
|
)
|
|
|
|
def query_side_effect(model):
|
|
q = MagicMock()
|
|
# Service applies SQL-level LIMIT now (was Python slice). Chain
|
|
# is: query → filter_by → order_by → limit → all
|
|
if model is ChatMessage:
|
|
(
|
|
q.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value
|
|
) = list(mock_messages)
|
|
elif model is ChatProgressStep:
|
|
(
|
|
q.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value
|
|
) = list(mock_steps)
|
|
return q
|
|
|
|
mock_user_db_session.query.side_effect = query_side_effect
|
|
|
|
def test_get_session_messages_returns_ordered_messages(
|
|
self, mock_user_db_session, sample_messages
|
|
):
|
|
"""Test that get_session_messages returns messages in order."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
mock_messages = []
|
|
for msg in sample_messages:
|
|
mock_msg = MagicMock()
|
|
for key, value in msg.items():
|
|
setattr(mock_msg, key, value)
|
|
mock_messages.append(mock_msg)
|
|
|
|
self._setup_query_side_effect(mock_user_db_session, mock_messages)
|
|
|
|
service = ChatService(username="testuser")
|
|
result = service.get_session_messages("session-123")
|
|
|
|
assert len(result) == len(sample_messages)
|
|
|
|
def test_get_session_messages_returns_empty_for_no_messages(
|
|
self, mock_user_db_session
|
|
):
|
|
"""Test that get_session_messages returns empty list when no messages."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
self._setup_query_side_effect(mock_user_db_session, [], [])
|
|
|
|
service = ChatService(username="testuser")
|
|
result = service.get_session_messages("session-123")
|
|
|
|
assert result == []
|
|
|
|
def test_get_session_messages_with_pagination(
|
|
self, mock_user_db_session, sample_messages
|
|
):
|
|
"""Test that get_session_messages respects limit and offset."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
mock_messages = []
|
|
for msg in sample_messages[:2]:
|
|
mock_msg = MagicMock()
|
|
for key, value in msg.items():
|
|
setattr(mock_msg, key, value)
|
|
mock_messages.append(mock_msg)
|
|
|
|
self._setup_query_side_effect(mock_user_db_session, mock_messages)
|
|
|
|
service = ChatService(username="testuser")
|
|
result = service.get_session_messages("session-123", limit=2, offset=0)
|
|
|
|
assert len(result) == 2
|
|
|
|
def test_get_session_messages_merges_step_rows(self, mock_user_db_session):
|
|
"""get_session_messages MUST merge ChatProgressStep
|
|
rows from chat_progress_steps into the response with
|
|
message_type='step', so chat.js's existing filter and the
|
|
in-flight reload UX continue to work.
|
|
|
|
Regression: chat.js fetchFormattedMessage filters
|
|
``m.message_type !== 'step'`` to skip per-iteration progress
|
|
milestones. The merged stream must surface step entries with
|
|
that exact discriminator value.
|
|
"""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
from src.local_deep_research.database.models import (
|
|
ChatMessage,
|
|
ChatProgressStep,
|
|
)
|
|
|
|
from datetime import datetime
|
|
|
|
msgs = [
|
|
MagicMock(
|
|
id="resp-1",
|
|
session_id="session-123",
|
|
role="assistant",
|
|
message_type="response",
|
|
content="Photosynthesis is the process by which plants...",
|
|
research_id="r-1",
|
|
sequence_number=1,
|
|
created_at=datetime(2024, 1, 1, 12, 0, 5),
|
|
),
|
|
]
|
|
steps = [
|
|
MagicMock(
|
|
id="step-uuid-1",
|
|
session_id="session-123",
|
|
research_id="r-1",
|
|
phase="search",
|
|
content="Starting research process",
|
|
sequence_number=1,
|
|
created_at=datetime(2024, 1, 1, 12, 0, 1),
|
|
),
|
|
MagicMock(
|
|
id="step-uuid-2",
|
|
session_id="session-123",
|
|
research_id="r-1",
|
|
phase="search",
|
|
content="Tool: web_search",
|
|
sequence_number=2,
|
|
created_at=datetime(2024, 1, 1, 12, 0, 2),
|
|
),
|
|
]
|
|
|
|
def query_side_effect(model):
|
|
q = MagicMock()
|
|
# Chain: filter_by → order_by → limit → all (service applies
|
|
# SQL-level LIMIT now).
|
|
if model is ChatMessage:
|
|
(
|
|
q.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value
|
|
) = msgs
|
|
elif model is ChatProgressStep:
|
|
(
|
|
q.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value
|
|
) = steps
|
|
return q
|
|
|
|
mock_user_db_session.query.side_effect = query_side_effect
|
|
|
|
service = ChatService(username="testuser")
|
|
result = service.get_session_messages("session-123")
|
|
|
|
# Merged + sorted by created_at: 2 steps then the response.
|
|
assert len(result) == 3
|
|
types = [r["message_type"] for r in result]
|
|
assert types == ["step", "step", "response"]
|
|
# Step IDs are prefixed to avoid collision with chat_messages ids.
|
|
assert result[0]["id"].startswith("step-")
|
|
# JS filter mirror: find the first assistant + has-content + non-step.
|
|
formatted = next(
|
|
(
|
|
m
|
|
for m in result
|
|
if m["role"] == "assistant"
|
|
and m["research_id"] == "r-1"
|
|
and m["content"]
|
|
and m["message_type"] != "step"
|
|
),
|
|
None,
|
|
)
|
|
assert formatted is not None
|
|
assert formatted["id"] == "resp-1"
|
|
assert "Photosynthesis" in formatted["content"]
|
|
|
|
|
|
class TestChatServiceTitleGeneration:
|
|
"""Tests for the split between sync fallback title and async LLM title."""
|
|
|
|
def test_create_session_does_not_invoke_llm(self, mock_user_db_session):
|
|
"""create_session must not block on the LLM even when settings ask for it.
|
|
|
|
Regression guard: the deferred-title refactor moved LLM title
|
|
generation out of the POST /api/chat/sessions path so the HTTP
|
|
response isn't blocked on a model round-trip.
|
|
"""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
mock_user_db_session.add = MagicMock()
|
|
mock_user_db_session.commit = MagicMock()
|
|
|
|
settings_snapshot = {"chat.llm_title_generation": {"value": True}}
|
|
|
|
with patch(
|
|
"src.local_deep_research.config.llm_config.get_llm"
|
|
) as mock_get_llm:
|
|
service = ChatService(username="testuser")
|
|
service.create_session(
|
|
initial_query="Does quantum entanglement violate locality?",
|
|
settings_snapshot=settings_snapshot,
|
|
)
|
|
|
|
mock_get_llm.assert_not_called()
|
|
|
|
def test_regenerate_title_with_llm_updates_session(
|
|
self, mock_user_db_session, mock_chat_session
|
|
):
|
|
"""regenerate_title_with_llm writes the LLM-returned title to the session."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
# Title must equal the non-LLM fallback so the idempotency check
|
|
# at the top of regenerate_title_with_llm doesn't short-circuit.
|
|
query = "Does quantum entanglement violate locality?"
|
|
mock_chat_session.title = query # fallback for short queries == query
|
|
setup_query_mock_with_session(mock_user_db_session, mock_chat_session)
|
|
mock_user_db_session.commit = MagicMock()
|
|
|
|
fake_llm = MagicMock()
|
|
fake_llm.invoke.return_value = MagicMock(
|
|
content=" Quantum Locality Question "
|
|
)
|
|
|
|
settings_snapshot = {"chat.llm_title_generation": {"value": True}}
|
|
|
|
with (
|
|
patch(
|
|
"src.local_deep_research.config.llm_config.get_llm",
|
|
return_value=fake_llm,
|
|
),
|
|
patch(
|
|
"src.local_deep_research.config.thread_settings.get_setting_from_snapshot",
|
|
return_value=True,
|
|
),
|
|
):
|
|
service = ChatService(username="testuser")
|
|
new_title = service.regenerate_title_with_llm(
|
|
mock_chat_session.id,
|
|
query=query,
|
|
settings_snapshot=settings_snapshot,
|
|
)
|
|
|
|
assert new_title == "Quantum Locality Question"
|
|
# The session object passed to the DB got the new title
|
|
assert mock_chat_session.title == "Quantum Locality Question"
|
|
|
|
def test_regenerate_title_strips_newlines_from_llm_output(
|
|
self, mock_user_db_session, mock_chat_session
|
|
):
|
|
"""Regression guard: LLM-generated titles must have CR/LF flattened.
|
|
|
|
The LLM-generated title is interpolated into loguru f-strings
|
|
(the "title already set" log line) and into document.title /
|
|
chatTitle.textContent on the client. An LLM that returns a title
|
|
containing a newline would otherwise forge what looks like a
|
|
second log entry in aggregators (log injection). The stored
|
|
title must have all CR/LF flattened to spaces.
|
|
"""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
query = "Does quantum entanglement violate locality?"
|
|
mock_chat_session.title = query # equals fallback → not skipped
|
|
setup_query_mock_with_session(mock_user_db_session, mock_chat_session)
|
|
mock_user_db_session.commit = MagicMock()
|
|
|
|
# Hostile/sloppy LLM output: embedded LF and CRLF that, unstripped,
|
|
# would split the audit log line into three.
|
|
fake_llm = MagicMock()
|
|
fake_llm.invoke.return_value = MagicMock(
|
|
content="Quantum Locality\nINFO faked log line\r\ntrailing"
|
|
)
|
|
|
|
settings_snapshot = {"chat.llm_title_generation": {"value": True}}
|
|
|
|
with (
|
|
patch(
|
|
"src.local_deep_research.config.llm_config.get_llm",
|
|
return_value=fake_llm,
|
|
),
|
|
patch(
|
|
"src.local_deep_research.config.thread_settings.get_setting_from_snapshot",
|
|
return_value=True,
|
|
),
|
|
):
|
|
service = ChatService(username="testuser")
|
|
new_title = service.regenerate_title_with_llm(
|
|
mock_chat_session.id,
|
|
query=query,
|
|
settings_snapshot=settings_snapshot,
|
|
)
|
|
|
|
assert new_title is not None
|
|
assert "\n" not in new_title, "newline must be stripped from title"
|
|
assert "\r" not in new_title, (
|
|
"carriage return must be stripped from title"
|
|
)
|
|
# The visible content survives, just flattened to spaces.
|
|
assert new_title.startswith("Quantum Locality")
|
|
# And the same flattened value is what landed on the session row.
|
|
assert mock_chat_session.title == new_title
|
|
|
|
def test_regenerate_title_skipped_when_user_has_edited_title(
|
|
self, mock_user_db_session, mock_chat_session
|
|
):
|
|
"""If the title no longer matches the non-LLM fallback (because the
|
|
user manually edited it, or a sibling tab's LLM-gen already ran),
|
|
skip the LLM call to avoid burning credits to overwrite their work."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
query = "Does quantum entanglement violate locality?"
|
|
mock_chat_session.title = "My Custom Title" # user-edited
|
|
setup_query_mock_with_session(mock_user_db_session, mock_chat_session)
|
|
|
|
fake_llm = MagicMock()
|
|
fake_llm.invoke.return_value = MagicMock(content="LLM Title")
|
|
|
|
with (
|
|
patch(
|
|
"src.local_deep_research.config.llm_config.get_llm",
|
|
return_value=fake_llm,
|
|
),
|
|
patch(
|
|
"src.local_deep_research.config.thread_settings.get_setting_from_snapshot",
|
|
return_value=True,
|
|
),
|
|
):
|
|
service = ChatService(username="testuser")
|
|
new_title = service.regenerate_title_with_llm(
|
|
mock_chat_session.id,
|
|
query=query,
|
|
settings_snapshot={
|
|
"chat.llm_title_generation": {"value": True}
|
|
},
|
|
)
|
|
|
|
assert new_title is None
|
|
fake_llm.invoke.assert_not_called()
|
|
# User's custom title is preserved
|
|
assert mock_chat_session.title == "My Custom Title"
|
|
|
|
def test_regenerate_title_with_llm_returns_none_for_empty_query(
|
|
self, mock_user_db_session
|
|
):
|
|
"""Empty query → None (no LLM call, no DB write)."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
service = ChatService(username="testuser")
|
|
assert (
|
|
service.regenerate_title_with_llm(
|
|
"sess-1", query="", settings_snapshot={}
|
|
)
|
|
is None
|
|
)
|
|
assert (
|
|
service.regenerate_title_with_llm(
|
|
"sess-1", query=None, settings_snapshot={}
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
class TestChatMessageEnumValidation:
|
|
"""Real-SQLite (not mocked) coverage for ChatRole/ChatMessageType validation.
|
|
|
|
The manual `valid_roles` / `valid_message_types` set checks were
|
|
replaced by `ChatRole(value)` / `ChatMessageType(value)` coercion that
|
|
re-raises as ValueError, preserving the HTTP 400 route-layer contract.
|
|
"""
|
|
|
|
def _setup_real_db(self, tmp_path):
|
|
"""File-backed SQLite with chat tables + a seeded session."""
|
|
import uuid as _uuid
|
|
from datetime import datetime as _dt, UTC as _utc
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
from src.local_deep_research.database.models import (
|
|
Base,
|
|
ChatSession,
|
|
)
|
|
|
|
db_path = tmp_path / "enum_validation.db"
|
|
engine = create_engine(f"sqlite:///{db_path}")
|
|
Base.metadata.create_all(engine)
|
|
|
|
Session = sessionmaker(bind=engine)
|
|
session_id = str(_uuid.uuid4())
|
|
with Session() as db:
|
|
db.add(
|
|
ChatSession(
|
|
id=session_id,
|
|
title="enum-test",
|
|
status="active",
|
|
accumulated_context={
|
|
"key_entities": [],
|
|
"topics": [],
|
|
"summary": "",
|
|
},
|
|
created_at=_dt.now(_utc),
|
|
message_count=0,
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
return engine, Session, session_id
|
|
|
|
def test_add_message_rejects_invalid_role(self, tmp_path):
|
|
"""Unknown role → ValueError (which routes map to HTTP 400)."""
|
|
from contextlib import contextmanager
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
engine, Session, session_id = self._setup_real_db(tmp_path)
|
|
|
|
@contextmanager
|
|
def real_get_user_db_session(username, password=None):
|
|
with Session() as db:
|
|
yield db
|
|
|
|
service = ChatService(username="testuser")
|
|
with patch(
|
|
"src.local_deep_research.chat.service.get_user_db_session",
|
|
real_get_user_db_session,
|
|
):
|
|
with pytest.raises(ValueError, match="Invalid role"):
|
|
service.add_message(
|
|
session_id=session_id,
|
|
role="usr", # typo — not in ChatRole
|
|
content="hi",
|
|
message_type="query",
|
|
)
|
|
engine.dispose()
|
|
|
|
def test_add_message_rejects_invalid_message_type(self, tmp_path):
|
|
"""Unknown message_type → ValueError."""
|
|
from contextlib import contextmanager
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
engine, Session, session_id = self._setup_real_db(tmp_path)
|
|
|
|
@contextmanager
|
|
def real_get_user_db_session(username, password=None):
|
|
with Session() as db:
|
|
yield db
|
|
|
|
service = ChatService(username="testuser")
|
|
with patch(
|
|
"src.local_deep_research.chat.service.get_user_db_session",
|
|
real_get_user_db_session,
|
|
):
|
|
with pytest.raises(ValueError, match="Invalid message_type"):
|
|
service.add_message(
|
|
session_id=session_id,
|
|
role="user",
|
|
content="hi",
|
|
message_type="queryz", # typo
|
|
)
|
|
engine.dispose()
|
|
|
|
def test_add_message_accepts_string_values(self, tmp_path):
|
|
"""Valid string values are coerced to enum members transparently."""
|
|
from contextlib import contextmanager
|
|
from src.local_deep_research.chat.service import ChatService
|
|
from src.local_deep_research.database.models import (
|
|
ChatMessage,
|
|
ChatMessageType,
|
|
ChatRole,
|
|
)
|
|
|
|
engine, Session, session_id = self._setup_real_db(tmp_path)
|
|
|
|
@contextmanager
|
|
def real_get_user_db_session(username, password=None):
|
|
with Session() as db:
|
|
yield db
|
|
|
|
service = ChatService(username="testuser")
|
|
with patch(
|
|
"src.local_deep_research.chat.service.get_user_db_session",
|
|
real_get_user_db_session,
|
|
):
|
|
service.add_message(
|
|
session_id=session_id,
|
|
role="user",
|
|
content="hi",
|
|
message_type="query",
|
|
)
|
|
|
|
with Session() as db:
|
|
msg = db.query(ChatMessage).filter_by(session_id=session_id).one()
|
|
assert msg.role is ChatRole.USER
|
|
assert msg.message_type is ChatMessageType.QUERY
|
|
# `(str, enum.Enum)` members compare equal to their string value.
|
|
assert msg.role == "user"
|
|
assert msg.message_type == "query"
|
|
engine.dispose()
|
|
|
|
|
|
class TestChatServiceGetInProgressResearchId:
|
|
"""Tests for ``ChatService.get_in_progress_research_id``.
|
|
|
|
The client (chat.js ``loadSession``) reads this value to restore the
|
|
"thinking" indicator on reload. The method queries ResearchHistory
|
|
via the partial-unique index
|
|
``ux_research_history_chat_session_in_progress`` (migration 0010) so
|
|
at most one row can match.
|
|
"""
|
|
|
|
def test_returns_none_when_no_in_progress_research(
|
|
self, mock_user_db_session
|
|
):
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
mock_user_db_session.query.return_value.filter.return_value.first.return_value = None
|
|
|
|
service = ChatService(username="testuser")
|
|
assert service.get_in_progress_research_id("session-123") is None
|
|
|
|
def test_returns_id_when_in_progress_research_exists(
|
|
self, mock_user_db_session
|
|
):
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
mock_user_db_session.query.return_value.filter.return_value.first.return_value = (
|
|
"research-abc-123",
|
|
)
|
|
|
|
service = ChatService(username="testuser")
|
|
assert (
|
|
service.get_in_progress_research_id("session-123")
|
|
== "research-abc-123"
|
|
)
|
|
|
|
def test_db_error_propagates(self, mock_user_db_session):
|
|
"""DB exceptions must propagate so the route handler can return
|
|
a 500 the client surfaces as an error banner. The previous
|
|
return-None behaviour was indistinguishable from "no research
|
|
running" — the UI silently re-enabled the send button and the
|
|
user's double-submit hit the unique-index guard. The strict
|
|
behaviour matches every other ChatService query method."""
|
|
import pytest
|
|
from sqlalchemy.exc import OperationalError
|
|
|
|
from src.local_deep_research.chat.service import ChatService
|
|
|
|
mock_user_db_session.query.side_effect = OperationalError(
|
|
"stmt", {}, Exception("boom")
|
|
)
|
|
|
|
service = ChatService(username="testuser")
|
|
with pytest.raises(OperationalError):
|
|
service.get_in_progress_research_id("session-123")
|
|
|
|
def test_query_filters_by_in_progress_status_and_session_id(
|
|
self, mock_user_db_session
|
|
):
|
|
"""Locks the query shape against accidental regressions — the
|
|
partial-unique-index lookup depends on the predicate matching
|
|
the index's WHERE clause exactly."""
|
|
from src.local_deep_research.chat.service import ChatService
|
|
from src.local_deep_research.constants import ResearchStatus
|
|
from src.local_deep_research.database.models import ResearchHistory
|
|
|
|
query_mock = MagicMock()
|
|
query_mock.filter.return_value.first.return_value = None
|
|
mock_user_db_session.query.return_value = query_mock
|
|
|
|
service = ChatService(username="testuser")
|
|
service.get_in_progress_research_id("session-xyz")
|
|
|
|
# Selected ResearchHistory.id (not the whole row)
|
|
mock_user_db_session.query.assert_called_once_with(ResearchHistory.id)
|
|
# Filter call received both clauses; we don't assert SQL text
|
|
# equality (SQLAlchemy ClauseElements compare by identity), only
|
|
# that two clauses were passed and IN_PROGRESS is referenced.
|
|
filter_args = query_mock.filter.call_args[0]
|
|
assert len(filter_args) == 2
|
|
# Verify the actual bound values rather than just that the enum exists
|
|
# (the previous `is not None` assertion was a tautology). Each clause is
|
|
# a BinaryExpression whose .right is a BindParameter carrying the
|
|
# compared value; this catches a regression that swaps IN_PROGRESS for
|
|
# another status or drops the session-id predicate.
|
|
bind_values = [
|
|
getattr(getattr(c, "right", None), "value", None)
|
|
for c in filter_args
|
|
]
|
|
assert ResearchStatus.IN_PROGRESS in bind_values
|
|
assert "session-xyz" in bind_values
|