Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

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