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
758 lines
28 KiB
Python
758 lines
28 KiB
Python
# allow: no-sut-import — black-box HTTP test; drives real routes through the Flask test client
|
|
"""Tests for per-attempt delete + retry endpoints.
|
|
|
|
Issue #4659: when a chat-triggered research fails (OoM, crash, dead LLM
|
|
endpoint), the user message stays in the thread and there's no in-chat
|
|
way to retry without copy-pasting. The new endpoints
|
|
|
|
DELETE /api/chat/sessions/<sid>/attempts/<rid>
|
|
POST /api/chat/sessions/<sid>/attempts/<rid>/retry
|
|
|
|
let the client delete or re-run a single attempt atomically.
|
|
|
|
Coverage in this file:
|
|
|
|
- Service-level (``ChatService.delete_attempt`` /
|
|
``get_original_attempt_query``): happy path, missing, in-progress
|
|
refusal, stale-IN_PROGRESS reclaim, message_count decrement,
|
|
research_meta fallback.
|
|
- HTTP-level: happy paths, 404 / 409 mappings, CSRF enforcement,
|
|
concurrent-message 409 race via the partial unique index.
|
|
"""
|
|
|
|
import json
|
|
import uuid
|
|
from datetime import datetime, UTC
|
|
from contextlib import contextmanager
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from src.local_deep_research.chat.service import (
|
|
AttemptInProgress,
|
|
AttemptNotFound,
|
|
ChatService,
|
|
)
|
|
from src.local_deep_research.constants import ResearchStatus
|
|
from src.local_deep_research.database.models import (
|
|
ChatMessage,
|
|
ChatProgressStep,
|
|
ChatRole,
|
|
ChatSession,
|
|
ChatSessionStatus,
|
|
ResearchHistory,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Seed helpers — used by both service-level and HTTP-level tests.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@contextmanager
|
|
def _service_db(SessionLocal):
|
|
"""Redirect ``ChatService.get_user_db_session`` to the test DB.
|
|
|
|
ChatService opens the per-user encrypted DB by username; the test
|
|
DB from ``setup_database_for_all_tests`` is a plain SQLite file.
|
|
Same pattern as ``tests/chat/test_chat_delete_terminates_research.py``.
|
|
"""
|
|
with patch(
|
|
"src.local_deep_research.chat.service.get_user_db_session"
|
|
) as ctx:
|
|
ctx.return_value.__enter__.return_value = SessionLocal()
|
|
ctx.return_value.__exit__.return_value = False
|
|
yield
|
|
|
|
|
|
def _seed_attempt(
|
|
db,
|
|
username: str,
|
|
session_id: str,
|
|
research_id: str,
|
|
*,
|
|
research_status: str = ResearchStatus.FAILED.value,
|
|
user_content: str = "What is quantum computing?",
|
|
assistant_content: str | None = "Quantum computing uses qubits...",
|
|
include_research_meta: bool = True,
|
|
user_message_id: str | None = None,
|
|
):
|
|
"""Insert a full attempt (user msg + research + optional assistant msg).
|
|
|
|
Returns ``(user_message_id, assistant_message_id_or_None)``.
|
|
"""
|
|
# Look up the session to find the next sequence_number.
|
|
session = db.query(ChatSession).filter_by(id=session_id).first()
|
|
next_seq = (session.message_count if session else 0) + 1
|
|
|
|
user_msg_id = user_message_id or str(uuid.uuid4())
|
|
# Mirror production: _spawn_chat_research inserts the user message with
|
|
# research_id=NULL and links it to the attempt only via
|
|
# research_meta.submission.message_id. Only legacy rows that predate the
|
|
# research_meta field carried research_id on the user message — model
|
|
# those when include_research_meta is False so the fallback lookup path
|
|
# in get_original_attempt_query / delete_attempt stays exercised.
|
|
user_research_id = None if include_research_meta else research_id
|
|
db.add(
|
|
ChatMessage(
|
|
id=user_msg_id,
|
|
session_id=session_id,
|
|
research_id=user_research_id,
|
|
role=ChatRole.USER.value,
|
|
message_type="query",
|
|
content=user_content,
|
|
sequence_number=next_seq,
|
|
)
|
|
)
|
|
|
|
research_meta = None
|
|
if include_research_meta:
|
|
research_meta = {
|
|
"submission": {
|
|
"chat_session_id": session_id,
|
|
"message_id": user_msg_id,
|
|
"research_mode": "quick",
|
|
}
|
|
}
|
|
|
|
db.add(
|
|
ResearchHistory(
|
|
id=research_id,
|
|
query=user_content,
|
|
mode="quick",
|
|
status=research_status,
|
|
created_at=datetime.now(UTC).isoformat(),
|
|
chat_session_id=session_id,
|
|
research_meta=research_meta,
|
|
)
|
|
)
|
|
|
|
assistant_msg_id = None
|
|
if assistant_content is not None:
|
|
assistant_msg_id = str(uuid.uuid4())
|
|
db.add(
|
|
ChatMessage(
|
|
id=assistant_msg_id,
|
|
session_id=session_id,
|
|
research_id=research_id,
|
|
role=ChatRole.ASSISTANT.value,
|
|
message_type="response",
|
|
content=assistant_content,
|
|
sequence_number=next_seq + 1,
|
|
)
|
|
)
|
|
|
|
# A progress step tied to this research.
|
|
db.add(
|
|
ChatProgressStep(
|
|
id=str(uuid.uuid4()),
|
|
research_id=research_id,
|
|
session_id=session_id,
|
|
phase="search",
|
|
content="Searching...",
|
|
sequence_number=1,
|
|
)
|
|
)
|
|
|
|
# Bump session message_count to match what we inserted.
|
|
if session:
|
|
session.message_count = session.message_count + (
|
|
2 if assistant_content is not None else 1
|
|
)
|
|
|
|
db.commit()
|
|
return user_msg_id, assistant_msg_id
|
|
|
|
|
|
def _make_session(db, username: str, title: str = "test chat") -> str:
|
|
session_id = str(uuid.uuid4())
|
|
db.add(
|
|
ChatSession(
|
|
id=session_id,
|
|
title=title,
|
|
status=ChatSessionStatus.ACTIVE.value,
|
|
message_count=0,
|
|
)
|
|
)
|
|
db.commit()
|
|
return session_id
|
|
|
|
|
|
# ===========================================================================
|
|
# Service-level: ChatService.delete_attempt
|
|
# ===========================================================================
|
|
|
|
|
|
class TestDeleteAttemptService:
|
|
"""Direct ChatService.delete_attempt tests."""
|
|
|
|
def test_delete_attempt_happy_path(self, setup_database_for_all_tests):
|
|
"""Deleting a FAILED attempt removes the user msg, assistant msg,
|
|
progress step, and research row; decrements message_count by 2."""
|
|
SessionLocal = setup_database_for_all_tests
|
|
username = f"alice_{uuid.uuid4().hex[:8]}"
|
|
service = ChatService(username=username)
|
|
|
|
with SessionLocal() as db:
|
|
session_id = _make_session(db, username)
|
|
research_id = str(uuid.uuid4())
|
|
user_msg_id, assistant_msg_id = _seed_attempt(
|
|
db,
|
|
username,
|
|
session_id,
|
|
research_id,
|
|
research_status=ResearchStatus.FAILED.value,
|
|
)
|
|
# Sanity: 2 messages (user + assistant) + 1 step + 1 research
|
|
# exist. Count by session_id, NOT research_id — the user message
|
|
# is seeded with research_id=NULL (production behaviour), so a
|
|
# research_id filter would only see the assistant row.
|
|
assert (
|
|
db.query(ChatMessage).filter_by(session_id=session_id).count()
|
|
== 2
|
|
)
|
|
# Regression guard for #4659: the user message must be linked
|
|
# to the attempt only via research_meta, with research_id NULL.
|
|
user_row = db.query(ChatMessage).filter_by(id=user_msg_id).first()
|
|
assert user_row is not None
|
|
assert user_row.research_id is None
|
|
assert (
|
|
db.query(ChatProgressStep)
|
|
.filter_by(research_id=research_id)
|
|
.count()
|
|
== 1
|
|
)
|
|
|
|
with _service_db(SessionLocal):
|
|
result = service.delete_attempt(session_id, research_id)
|
|
assert result is True
|
|
|
|
with SessionLocal() as db:
|
|
# All attempt rows gone — including the NULL-research_id user
|
|
# message (the #4659 bug left it orphaned in the thread).
|
|
assert (
|
|
db.query(ChatMessage).filter_by(session_id=session_id).count()
|
|
== 0
|
|
)
|
|
assert (
|
|
db.query(ChatMessage).filter_by(id=user_msg_id).first() is None
|
|
)
|
|
assert (
|
|
db.query(ChatProgressStep)
|
|
.filter_by(research_id=research_id)
|
|
.count()
|
|
== 0
|
|
)
|
|
assert (
|
|
db.query(ResearchHistory).filter_by(id=research_id).first()
|
|
is None
|
|
)
|
|
# Session still exists and message_count was decremented.
|
|
session = db.query(ChatSession).filter_by(id=session_id).first()
|
|
assert session is not None
|
|
assert session.message_count == 0
|
|
|
|
def test_delete_attempt_failed_no_assistant_removes_user_msg(
|
|
self, setup_database_for_all_tests
|
|
):
|
|
"""Primary #4659 case: a FAILED attempt with NO assistant reply.
|
|
|
|
The user message is the only chat row and is stored with
|
|
research_id=NULL (linked via research_meta.submission.message_id).
|
|
Before the fix, delete_attempt filtered solely on research_id, so it
|
|
removed nothing user-visible and never decremented message_count —
|
|
leaving an orphaned user bubble. Delete must remove the user message
|
|
and decrement the counter by exactly 1.
|
|
"""
|
|
SessionLocal = setup_database_for_all_tests
|
|
username = f"alice_{uuid.uuid4().hex[:8]}"
|
|
service = ChatService(username=username)
|
|
|
|
with SessionLocal() as db:
|
|
session_id = _make_session(db, username)
|
|
research_id = str(uuid.uuid4())
|
|
user_msg_id, assistant_msg_id = _seed_attempt(
|
|
db,
|
|
username,
|
|
session_id,
|
|
research_id,
|
|
research_status=ResearchStatus.FAILED.value,
|
|
assistant_content=None,
|
|
)
|
|
assert assistant_msg_id is None
|
|
# Only the user message exists, and it has NULL research_id.
|
|
assert (
|
|
db.query(ChatMessage).filter_by(session_id=session_id).count()
|
|
== 1
|
|
)
|
|
user_row = db.query(ChatMessage).filter_by(id=user_msg_id).first()
|
|
assert user_row is not None and user_row.research_id is None
|
|
assert (
|
|
db.query(ChatSession)
|
|
.filter_by(id=session_id)
|
|
.first()
|
|
.message_count
|
|
== 1
|
|
)
|
|
|
|
with _service_db(SessionLocal):
|
|
assert service.delete_attempt(session_id, research_id) is True
|
|
|
|
with SessionLocal() as db:
|
|
assert (
|
|
db.query(ChatMessage).filter_by(id=user_msg_id).first() is None
|
|
)
|
|
assert (
|
|
db.query(ChatMessage).filter_by(session_id=session_id).count()
|
|
== 0
|
|
)
|
|
assert (
|
|
db.query(ResearchHistory).filter_by(id=research_id).first()
|
|
is None
|
|
)
|
|
# The counter was decremented by exactly 1 (the orphaned user
|
|
# bubble), not left drifting at 1.
|
|
assert (
|
|
db.query(ChatSession)
|
|
.filter_by(id=session_id)
|
|
.first()
|
|
.message_count
|
|
== 0
|
|
)
|
|
|
|
def test_delete_attempt_missing(self, setup_database_for_all_tests):
|
|
"""Unknown research_id → AttemptNotFound."""
|
|
SessionLocal = setup_database_for_all_tests
|
|
username = f"alice_{uuid.uuid4().hex[:8]}"
|
|
service = ChatService(username=username)
|
|
|
|
with SessionLocal() as db:
|
|
session_id = _make_session(db, username)
|
|
|
|
with _service_db(SessionLocal):
|
|
with pytest.raises(AttemptNotFound):
|
|
service.delete_attempt(session_id, str(uuid.uuid4()))
|
|
|
|
def test_delete_attempt_wrong_session(self, setup_database_for_all_tests):
|
|
"""Research exists but belongs to a different session_id →
|
|
AttemptNotFound (scoped lookup refuses to return cross-session
|
|
rows)."""
|
|
SessionLocal = setup_database_for_all_tests
|
|
username = f"alice_{uuid.uuid4().hex[:8]}"
|
|
service = ChatService(username=username)
|
|
|
|
with SessionLocal() as db:
|
|
session_a = _make_session(db, username, title="A")
|
|
session_b = _make_session(db, username, title="B")
|
|
research_id = str(uuid.uuid4())
|
|
_seed_attempt(
|
|
db,
|
|
username,
|
|
session_a,
|
|
research_id,
|
|
research_status=ResearchStatus.FAILED.value,
|
|
)
|
|
|
|
# Asking for the research under session_b must 404.
|
|
with _service_db(SessionLocal):
|
|
with pytest.raises(AttemptNotFound):
|
|
service.delete_attempt(session_b, research_id)
|
|
|
|
def test_delete_attempt_in_progress_with_live_thread(
|
|
self, setup_database_for_all_tests
|
|
):
|
|
"""IN_PROGRESS + alive thread → AttemptInProgress (409 mapping)
|
|
and the worker is flagged for termination."""
|
|
SessionLocal = setup_database_for_all_tests
|
|
username = f"alice_{uuid.uuid4().hex[:8]}"
|
|
service = ChatService(username=username)
|
|
|
|
with SessionLocal() as db:
|
|
session_id = _make_session(db, username)
|
|
research_id = str(uuid.uuid4())
|
|
_seed_attempt(
|
|
db,
|
|
username,
|
|
session_id,
|
|
research_id,
|
|
research_status=ResearchStatus.IN_PROGRESS.value,
|
|
)
|
|
|
|
flagged = []
|
|
with (
|
|
patch(
|
|
"src.local_deep_research.chat.service.set_termination_flag",
|
|
side_effect=lambda rid: flagged.append(rid),
|
|
),
|
|
patch(
|
|
"src.local_deep_research.chat.service.is_research_thread_alive",
|
|
return_value=True,
|
|
),
|
|
_service_db(SessionLocal),
|
|
):
|
|
with pytest.raises(AttemptInProgress):
|
|
service.delete_attempt(session_id, research_id)
|
|
|
|
# Worker was flagged before the raise.
|
|
assert flagged == [research_id]
|
|
|
|
# Row still exists — we refused to hard-delete.
|
|
with SessionLocal() as db:
|
|
assert (
|
|
db.query(ResearchHistory).filter_by(id=research_id).first()
|
|
is not None
|
|
)
|
|
|
|
def test_delete_attempt_stale_in_progress_reclaims(
|
|
self, setup_database_for_all_tests
|
|
):
|
|
"""IN_PROGRESS but thread is dead → reclaimed and deleted (200),
|
|
not 409."""
|
|
SessionLocal = setup_database_for_all_tests
|
|
username = f"alice_{uuid.uuid4().hex[:8]}"
|
|
service = ChatService(username=username)
|
|
|
|
with SessionLocal() as db:
|
|
session_id = _make_session(db, username)
|
|
research_id = str(uuid.uuid4())
|
|
_seed_attempt(
|
|
db,
|
|
username,
|
|
session_id,
|
|
research_id,
|
|
research_status=ResearchStatus.IN_PROGRESS.value,
|
|
)
|
|
|
|
# is_research_thread_alive returns False → sweep reclaims.
|
|
with (
|
|
patch(
|
|
"src.local_deep_research.chat.service.is_research_thread_alive",
|
|
return_value=False,
|
|
),
|
|
_service_db(SessionLocal),
|
|
):
|
|
assert service.delete_attempt(session_id, research_id) is True
|
|
|
|
with SessionLocal() as db:
|
|
assert (
|
|
db.query(ResearchHistory).filter_by(id=research_id).first()
|
|
is None
|
|
)
|
|
|
|
|
|
# ===========================================================================
|
|
# Service-level: ChatService.get_original_attempt_query
|
|
# ===========================================================================
|
|
|
|
|
|
class TestGetOriginalAttemptQuery:
|
|
"""Direct ChatService.get_original_attempt_query tests."""
|
|
|
|
def test_uses_research_meta_message_id_fast_path(
|
|
self, setup_database_for_all_tests
|
|
):
|
|
SessionLocal = setup_database_for_all_tests
|
|
username = f"alice_{uuid.uuid4().hex[:8]}"
|
|
service = ChatService(username=username)
|
|
|
|
with SessionLocal() as db:
|
|
session_id = _make_session(db, username)
|
|
research_id = str(uuid.uuid4())
|
|
_seed_attempt(
|
|
db,
|
|
username,
|
|
session_id,
|
|
research_id,
|
|
user_content="Probe question",
|
|
include_research_meta=True,
|
|
)
|
|
|
|
with _service_db(SessionLocal):
|
|
result = service.get_original_attempt_query(session_id, research_id)
|
|
assert result == "Probe question"
|
|
|
|
def test_falls_back_to_chatmessage_query_when_meta_missing(
|
|
self, setup_database_for_all_tests
|
|
):
|
|
"""Older rows predate research_meta; the fallback lookup by
|
|
ChatMessage.research_id + role='user' must succeed."""
|
|
SessionLocal = setup_database_for_all_tests
|
|
username = f"alice_{uuid.uuid4().hex[:8]}"
|
|
service = ChatService(username=username)
|
|
|
|
with SessionLocal() as db:
|
|
session_id = _make_session(db, username)
|
|
research_id = str(uuid.uuid4())
|
|
_seed_attempt(
|
|
db,
|
|
username,
|
|
session_id,
|
|
research_id,
|
|
user_content="Legacy query",
|
|
include_research_meta=False,
|
|
)
|
|
|
|
with _service_db(SessionLocal):
|
|
result = service.get_original_attempt_query(session_id, research_id)
|
|
assert result == "Legacy query"
|
|
|
|
def test_missing_research_raises(self, setup_database_for_all_tests):
|
|
SessionLocal = setup_database_for_all_tests
|
|
username = f"alice_{uuid.uuid4().hex[:8]}"
|
|
service = ChatService(username=username)
|
|
|
|
with SessionLocal() as db:
|
|
session_id = _make_session(db, username)
|
|
|
|
with _service_db(SessionLocal):
|
|
with pytest.raises(AttemptNotFound):
|
|
service.get_original_attempt_query(
|
|
session_id, str(uuid.uuid4())
|
|
)
|
|
|
|
|
|
# ===========================================================================
|
|
# HTTP-level: DELETE /api/chat/sessions/<sid>/attempts/<rid>
|
|
# ===========================================================================
|
|
|
|
|
|
class TestDeleteAttemptRoute:
|
|
"""HTTP tests for the delete-attempt endpoint."""
|
|
|
|
def test_delete_happy_path(self, authenticated_client):
|
|
"""DELETE removes the attempt and returns 200."""
|
|
create_resp = authenticated_client.post(
|
|
"/api/chat/sessions",
|
|
json={"initial_query": "test"},
|
|
content_type="application/json",
|
|
)
|
|
session_id = json.loads(create_resp.data)["session_id"]
|
|
|
|
# Drive a real send_message with mocked spawn so we get a real
|
|
# ResearchHistory + ChatMessage + step row to delete.
|
|
with patch("local_deep_research.chat.routes.start_research_process"):
|
|
send_resp = authenticated_client.post(
|
|
f"/api/chat/sessions/{session_id}/messages",
|
|
json={"content": "what is X?", "trigger_research": True},
|
|
content_type="application/json",
|
|
)
|
|
assert send_resp.status_code == 200
|
|
research_id = json.loads(send_resp.data)["research_id"]
|
|
assert research_id is not None
|
|
|
|
# Sanity: research exists, message_count is at least 1.
|
|
get_resp = authenticated_client.get(f"/api/chat/sessions/{session_id}")
|
|
assert get_resp.status_code == 200
|
|
assert json.loads(get_resp.data)["session"]["message_count"] >= 1
|
|
|
|
# Now delete the attempt.
|
|
del_resp = authenticated_client.delete(
|
|
f"/api/chat/sessions/{session_id}/attempts/{research_id}"
|
|
)
|
|
assert del_resp.status_code == 200
|
|
assert json.loads(del_resp.data)["success"] is True
|
|
|
|
def test_delete_missing_research_returns_404(self, authenticated_client):
|
|
create_resp = authenticated_client.post(
|
|
"/api/chat/sessions",
|
|
json={"initial_query": "test"},
|
|
content_type="application/json",
|
|
)
|
|
session_id = json.loads(create_resp.data)["session_id"]
|
|
|
|
del_resp = authenticated_client.delete(
|
|
f"/api/chat/sessions/{session_id}/attempts/{uuid.uuid4()}"
|
|
)
|
|
assert del_resp.status_code == 404
|
|
|
|
def test_delete_missing_session_returns_404(self, authenticated_client):
|
|
"""Unknown session_id → 404 (research can't belong to it)."""
|
|
del_resp = authenticated_client.delete(
|
|
f"/api/chat/sessions/{uuid.uuid4()}/attempts/{uuid.uuid4()}"
|
|
)
|
|
assert del_resp.status_code == 404
|
|
|
|
def test_delete_in_progress_returns_409(self, authenticated_client):
|
|
"""IN_PROGRESS research with a live thread → 409 with
|
|
active_research_id."""
|
|
create_resp = authenticated_client.post(
|
|
"/api/chat/sessions",
|
|
json={"initial_query": "test"},
|
|
content_type="application/json",
|
|
)
|
|
session_id = json.loads(create_resp.data)["session_id"]
|
|
|
|
with patch("local_deep_research.chat.routes.start_research_process"):
|
|
send_resp = authenticated_client.post(
|
|
f"/api/chat/sessions/{session_id}/messages",
|
|
json={"content": "what is X?", "trigger_research": True},
|
|
content_type="application/json",
|
|
)
|
|
research_id = json.loads(send_resp.data)["research_id"]
|
|
|
|
# Patch is_research_thread_alive to True so delete_attempt
|
|
# treats the just-spawned research as live.
|
|
with patch(
|
|
"local_deep_research.chat.service.is_research_thread_alive",
|
|
return_value=True,
|
|
):
|
|
del_resp = authenticated_client.delete(
|
|
f"/api/chat/sessions/{session_id}/attempts/{research_id}"
|
|
)
|
|
|
|
assert del_resp.status_code == 409
|
|
body = json.loads(del_resp.data)
|
|
assert body["success"] is False
|
|
assert body["active_research_id"] == research_id
|
|
|
|
|
|
# ===========================================================================
|
|
# HTTP-level: POST /api/chat/sessions/<sid>/attempts/<rid>/retry
|
|
# ===========================================================================
|
|
|
|
|
|
class TestRetryAttemptRoute:
|
|
"""HTTP tests for the retry-attempt endpoint."""
|
|
|
|
def test_retry_happy_path_returns_new_research_id(
|
|
self, authenticated_client
|
|
):
|
|
"""Retry of a FAILED attempt: returns 200 + a fresh research_id
|
|
+ message_id."""
|
|
create_resp = authenticated_client.post(
|
|
"/api/chat/sessions",
|
|
json={"initial_query": "test"},
|
|
content_type="application/json",
|
|
)
|
|
session_id = json.loads(create_resp.data)["session_id"]
|
|
|
|
with patch("local_deep_research.chat.routes.start_research_process"):
|
|
send_resp = authenticated_client.post(
|
|
f"/api/chat/sessions/{session_id}/messages",
|
|
json={"content": "what is X?", "trigger_research": True},
|
|
content_type="application/json",
|
|
)
|
|
old_research_id = json.loads(send_resp.data)["research_id"]
|
|
|
|
# The retry route's stale-row sweep only fires for rows older
|
|
# than the grace window (30s); the just-spawned research is
|
|
# brand-new so the sweep skips it and the per-session guard
|
|
# would refuse with 409. Patch the cap check to short-circuit
|
|
# so we can exercise the actual delete + re-spawn path. The
|
|
# cap-check itself is covered by the dedicated 409 test below.
|
|
# Also patch is_research_thread_alive=False so delete_attempt's
|
|
# own IN_PROGRESS check reclaims the row instead of refusing.
|
|
with (
|
|
patch(
|
|
"local_deep_research.chat.routes._enforce_chat_session_research_slot",
|
|
return_value=None,
|
|
),
|
|
patch(
|
|
"local_deep_research.chat.service.is_research_thread_alive",
|
|
return_value=False,
|
|
),
|
|
patch("local_deep_research.chat.routes.start_research_process"),
|
|
):
|
|
retry_resp = authenticated_client.post(
|
|
f"/api/chat/sessions/{session_id}/attempts/{old_research_id}/retry"
|
|
)
|
|
|
|
assert retry_resp.status_code == 200, retry_resp.data
|
|
body = json.loads(retry_resp.data)
|
|
assert body["success"] is True
|
|
new_research_id = body["research_id"]
|
|
new_message_id = body["message_id"]
|
|
assert new_research_id is not None
|
|
assert new_message_id is not None
|
|
# The retry produced a DIFFERENT research id (the old one was
|
|
# deleted; new UUID allocated).
|
|
assert new_research_id != old_research_id
|
|
|
|
def test_retry_missing_research_returns_404(self, authenticated_client):
|
|
create_resp = authenticated_client.post(
|
|
"/api/chat/sessions",
|
|
json={"initial_query": "test"},
|
|
content_type="application/json",
|
|
)
|
|
session_id = json.loads(create_resp.data)["session_id"]
|
|
|
|
retry_resp = authenticated_client.post(
|
|
f"/api/chat/sessions/{session_id}/attempts/{uuid.uuid4()}/retry"
|
|
)
|
|
assert retry_resp.status_code == 404
|
|
|
|
def test_retry_on_missing_session_returns_404(self, authenticated_client):
|
|
retry_resp = authenticated_client.post(
|
|
f"/api/chat/sessions/{uuid.uuid4()}/attempts/{uuid.uuid4()}/retry"
|
|
)
|
|
assert retry_resp.status_code == 404
|
|
|
|
def test_retry_archived_session_returns_409(self, authenticated_client):
|
|
"""Archived sessions are read-only; retry must refuse."""
|
|
create_resp = authenticated_client.post(
|
|
"/api/chat/sessions",
|
|
json={"initial_query": "test"},
|
|
content_type="application/json",
|
|
)
|
|
session_id = json.loads(create_resp.data)["session_id"]
|
|
|
|
# Archive it.
|
|
archived = authenticated_client.patch(
|
|
f"/api/chat/sessions/{session_id}",
|
|
json={"status": "archived"},
|
|
content_type="application/json",
|
|
)
|
|
assert archived.status_code == 200
|
|
|
|
retry_resp = authenticated_client.post(
|
|
f"/api/chat/sessions/{session_id}/attempts/{uuid.uuid4()}/retry"
|
|
)
|
|
assert retry_resp.status_code == 409
|
|
|
|
|
|
# ===========================================================================
|
|
# CSRF enforcement
|
|
# ===========================================================================
|
|
|
|
|
|
class TestAttemptEndpointsCSRF:
|
|
"""DELETE and POST retry must reject requests without a CSRF token."""
|
|
|
|
def test_delete_without_csrf_token_rejected(
|
|
self, csrf_authenticated_client
|
|
):
|
|
client, token = csrf_authenticated_client
|
|
|
|
create_resp = client.post(
|
|
"/api/chat/sessions",
|
|
json={"initial_query": "Test"},
|
|
content_type="application/json",
|
|
headers={"X-CSRFToken": token},
|
|
)
|
|
assert create_resp.status_code == 200
|
|
session_id = json.loads(create_resp.data)["session_id"]
|
|
|
|
# DELETE without token — must be rejected.
|
|
response = client.delete(
|
|
f"/api/chat/sessions/{session_id}/attempts/{uuid.uuid4()}"
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
def test_retry_without_csrf_token_rejected(self, csrf_authenticated_client):
|
|
client, token = csrf_authenticated_client
|
|
|
|
create_resp = client.post(
|
|
"/api/chat/sessions",
|
|
json={"initial_query": "Test"},
|
|
content_type="application/json",
|
|
headers={"X-CSRFToken": token},
|
|
)
|
|
assert create_resp.status_code == 200
|
|
session_id = json.loads(create_resp.data)["session_id"]
|
|
|
|
# POST retry without token — must be rejected.
|
|
response = client.post(
|
|
f"/api/chat/sessions/{session_id}/attempts/{uuid.uuid4()}/retry"
|
|
)
|
|
assert response.status_code == 400
|