# 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//attempts/ POST /api/chat/sessions//attempts//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//attempts/ # =========================================================================== 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//attempts//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