"""Direct integration tests for ``ChatService.add_progress_step``. This method previously had only indirect coverage via the migration / cascade tests; this file adds direct coverage. The method is the persistence half of the step-emit/persist symmetry that the chat-mode research path in research_service.py maintains. Its contract: - ``content`` is required (``ValueError`` on None). - Sequence number is allocated atomically by ``UPDATE...RETURNING`` on ``ResearchHistory.step_count``. - If the research row doesn't exist, ``ValueError("Research ... not found")`` is raised and no ``chat_progress_steps`` row is written. - Multiple steps for the same research get strictly increasing sequence numbers (1, 2, 3, ...). - The denormalised ``session_id`` column on ``chat_progress_steps`` mirrors the chat session the research was launched from. - ``phase`` is optional and stored verbatim. """ import uuid import pytest from src.local_deep_research.chat.service import ChatService from src.local_deep_research.database.models import ( ChatProgressStep, ChatSession, ChatSessionStatus, ResearchHistory, ) def _seed(db): """Insert a session + research row, return (session_id, research_id).""" sid = str(uuid.uuid4()) rid = str(uuid.uuid4()) db.add( ChatSession( id=sid, title="step-test", status=ChatSessionStatus.ACTIVE.value, message_count=0, ) ) db.add( ResearchHistory( id=rid, query="probe", mode="quick", status="in_progress", created_at="2026-05-14T00:00:00", chat_session_id=sid, ) ) db.commit() return sid, rid def _patch_user_db(monkeypatch, SessionLocal): """Patch get_user_db_session inside chat.service to yield our test DB.""" from contextlib import contextmanager @contextmanager def _ctx(_username, password=None): db = SessionLocal() try: yield db finally: db.close() monkeypatch.setattr( "src.local_deep_research.chat.service.get_user_db_session", _ctx ) def test_rejects_none_content(setup_database_for_all_tests, monkeypatch): """Content cannot be None — the row would violate NOT NULL.""" SessionLocal = setup_database_for_all_tests _patch_user_db(monkeypatch, SessionLocal) with SessionLocal() as db: sid, rid = _seed(db) service = ChatService(username="alice") with pytest.raises(ValueError, match="content is required"): service.add_progress_step(session_id=sid, research_id=rid, content=None) def test_rejects_unknown_research(setup_database_for_all_tests, monkeypatch): """The atomic UPDATE matches zero rows when the research doesn't exist, so add_progress_step raises and writes nothing.""" SessionLocal = setup_database_for_all_tests _patch_user_db(monkeypatch, SessionLocal) service = ChatService(username="alice") with pytest.raises(ValueError, match="Research .* not found"): service.add_progress_step( session_id=str(uuid.uuid4()), research_id=str(uuid.uuid4()), content="step content", ) def test_persists_row_and_returns_id(setup_database_for_all_tests, monkeypatch): """A successful call persists a row in chat_progress_steps and returns the new step's UUID.""" SessionLocal = setup_database_for_all_tests _patch_user_db(monkeypatch, SessionLocal) with SessionLocal() as db: sid, rid = _seed(db) service = ChatService(username="alice") step_id = service.add_progress_step( session_id=sid, research_id=rid, content="Searching for topic X", phase="search", ) assert isinstance(step_id, str) and len(step_id) >= 32 with SessionLocal() as db: row = db.query(ChatProgressStep).filter_by(id=step_id).first() assert row is not None assert row.session_id == sid assert row.research_id == rid assert row.content == "Searching for topic X" assert row.phase == "search" assert row.sequence_number == 1 def test_sequence_increments_monotonically( setup_database_for_all_tests, monkeypatch ): """Per-research sequence is allocated atomically via ``UPDATE step_count RETURNING``. Three consecutive calls must yield 1, 2, 3.""" SessionLocal = setup_database_for_all_tests _patch_user_db(monkeypatch, SessionLocal) with SessionLocal() as db: sid, rid = _seed(db) service = ChatService(username="alice") for _ in range(3): service.add_progress_step( session_id=sid, research_id=rid, content="step" ) with SessionLocal() as db: rows = ( db.query(ChatProgressStep) .filter_by(research_id=rid) .order_by(ChatProgressStep.sequence_number) .all() ) assert [r.sequence_number for r in rows] == [1, 2, 3] # And research_history.step_count must mirror the count. rh = db.query(ResearchHistory).filter_by(id=rid).first() assert rh.step_count == 3 def test_phase_is_optional(setup_database_for_all_tests, monkeypatch): """``phase`` defaults to None; the column stores NULL.""" SessionLocal = setup_database_for_all_tests _patch_user_db(monkeypatch, SessionLocal) with SessionLocal() as db: sid, rid = _seed(db) service = ChatService(username="alice") step_id = service.add_progress_step( session_id=sid, research_id=rid, content="step without phase" ) with SessionLocal() as db: row = db.query(ChatProgressStep).filter_by(id=step_id).first() assert row.phase is None