"""
Tests for QueueProcessorV2 — real integration tests for core queue orchestration.

These tests exercise the actual class methods with proper mocking of external
dependencies (database, password store, research service), unlike the fake tests
in test_processor_v2_core/operations/research.py that only test Python primitives.

Tests cover:
- notify_research_queued: direct vs queue mode branching
- notify_research_completed: status updates + notifications
- notify_research_failed: error message defaulting, sanitization
- _process_queue_loop: user:session parsing, removal logic
- _process_user_queue: slot calculation, empty queue, no password
- _start_research: new vs legacy settings format
- process_pending_operations_for_user: progress/error updates, rollback
"""

import time
import threading
from unittest.mock import Mock, MagicMock, patch

import pytest


MODULE = "local_deep_research.web.queue.processor_v2"
SETTINGS_MGR = "local_deep_research.settings.manager.SettingsManager"


def _make_processor():
    """Create a QueueProcessorV2 instance without triggering module-level side effects."""
    from local_deep_research.web.queue.processor_v2 import QueueProcessorV2

    return QueueProcessorV2(check_interval=1)


# ---------------------------------------------------------------------------
# notify_research_queued — direct vs queue mode branching
# ---------------------------------------------------------------------------


class TestNotifyResearchQueuedDirectMode:
    """Tests for notify_research_queued when queue_mode='direct'."""

    @patch(f"{MODULE}.get_user_db_session")
    @patch(f"{MODULE}.db_manager")
    @patch(f"{MODULE}.session_password_store")
    def test_direct_mode_slots_available_starts_directly(
        self, mock_pw_store, mock_db_mgr, mock_get_session
    ):
        """direct mode + slots available -> calls _start_research_directly."""
        processor = _make_processor()

        mock_pw_store.get_session_password.return_value = "pw"
        mock_db_mgr.open_user_database.return_value = Mock()

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        # SettingsManager says direct mode, max 3 concurrent
        mock_settings = Mock()
        mock_settings.get_setting.side_effect = lambda key, default: {
            "app.queue_mode": "direct",
            "app.max_concurrent_researches": 3,
        }.get(key, default)

        # 0 active researches -> has slots
        mock_session.query.return_value.filter_by.return_value.count.return_value = 0

        with patch(SETTINGS_MGR, return_value=mock_settings):
            with patch.object(
                processor, "_start_research_directly"
            ) as mock_start:
                processor.notify_research_queued(
                    "alice",
                    "r-001",
                    session_id="sess1",
                    query="test query",
                )
                mock_start.assert_called_once()

    @patch(f"{MODULE}.get_user_db_session")
    @patch(f"{MODULE}.db_manager")
    @patch(f"{MODULE}.session_password_store")
    def test_direct_mode_slots_full_falls_back_to_queue(
        self, mock_pw_store, mock_db_mgr, mock_get_session
    ):
        """direct mode + slots full -> queues instead."""
        processor = _make_processor()

        mock_pw_store.get_session_password.return_value = "pw"
        mock_db_mgr.open_user_database.return_value = Mock()

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        mock_settings = Mock()
        mock_settings.get_setting.side_effect = lambda key, default: {
            "app.queue_mode": "direct",
            "app.max_concurrent_researches": 2,
        }.get(key, default)

        # 2 active = full at max 2
        mock_session.query.return_value.filter_by.return_value.count.return_value = 2

        with patch(SETTINGS_MGR, return_value=mock_settings):
            with patch(f"{MODULE}.UserQueueService") as mock_qs_class:
                mock_qs = Mock()
                mock_qs_class.return_value = mock_qs

                processor.notify_research_queued(
                    "alice",
                    "r-002",
                    session_id="sess1",
                    query="test",
                )
                mock_qs.add_task_metadata.assert_called_once()

    @patch(f"{MODULE}.get_user_db_session")
    @patch(f"{MODULE}.db_manager")
    @patch(f"{MODULE}.session_password_store")
    def test_queue_mode_always_queues(
        self, mock_pw_store, mock_db_mgr, mock_get_session
    ):
        """queue_mode='queue' -> always queues regardless of slots."""
        processor = _make_processor()

        mock_pw_store.get_session_password.return_value = "pw"
        mock_db_mgr.open_user_database.return_value = Mock()

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        mock_settings = Mock()
        mock_settings.get_setting.side_effect = lambda key, default: {
            "app.queue_mode": "queue",
            "app.max_concurrent_researches": 3,
        }.get(key, default)

        with patch(SETTINGS_MGR, return_value=mock_settings):
            with patch(f"{MODULE}.UserQueueService") as mock_qs_class:
                mock_qs = Mock()
                mock_qs_class.return_value = mock_qs

                with patch.object(
                    processor, "_start_research_directly"
                ) as mock_start:
                    processor.notify_research_queued(
                        "alice",
                        "r-003",
                        session_id="sess1",
                        query="test",
                    )
                    mock_start.assert_not_called()
                    mock_qs.add_task_metadata.assert_called_once()

    @patch(f"{MODULE}.get_user_db_session")
    def test_missing_session_id_falls_back_to_queue(self, mock_get_session):
        """No session_id kwarg -> falls back to queue mode."""
        processor = _make_processor()

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        with patch(f"{MODULE}.UserQueueService") as mock_qs_class:
            mock_qs = Mock()
            mock_qs_class.return_value = mock_qs

            # No session_id in kwargs
            processor.notify_research_queued("alice", "r-004", query="test")
            mock_qs.add_task_metadata.assert_called_once()

    @patch(f"{MODULE}.get_user_db_session")
    @patch(f"{MODULE}.session_password_store")
    def test_missing_password_falls_back_to_queue(
        self, mock_pw_store, mock_get_session
    ):
        """No password for session -> falls back to queue mode."""
        processor = _make_processor()

        mock_pw_store.get_session_password.return_value = None

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        with patch(f"{MODULE}.UserQueueService") as mock_qs_class:
            mock_qs = Mock()
            mock_qs_class.return_value = mock_qs

            processor.notify_research_queued(
                "alice", "r-005", session_id="sess1", query="test"
            )
            mock_qs.add_task_metadata.assert_called_once()

    @patch(f"{MODULE}.get_user_db_session")
    @patch(f"{MODULE}.db_manager")
    @patch(f"{MODULE}.session_password_store")
    def test_exception_in_direct_falls_back_to_queue(
        self, mock_pw_store, mock_db_mgr, mock_get_session
    ):
        """Exception during direct execution -> falls back to queue."""
        processor = _make_processor()

        mock_pw_store.get_session_password.return_value = "pw"
        mock_db_mgr.open_user_database.side_effect = RuntimeError("DB error")

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        with patch(f"{MODULE}.UserQueueService") as mock_qs_class:
            mock_qs = Mock()
            mock_qs_class.return_value = mock_qs

            processor.notify_research_queued(
                "alice", "r-006", session_id="sess1", query="test"
            )
            mock_qs.add_task_metadata.assert_called_once()

    @patch(f"{MODULE}.get_user_db_session")
    def test_no_kwargs_queues_directly(self, mock_get_session):
        """No kwargs at all -> goes straight to queue fallback."""
        processor = _make_processor()

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        with patch(f"{MODULE}.UserQueueService") as mock_qs_class:
            mock_qs = Mock()
            mock_qs_class.return_value = mock_qs

            processor.notify_research_queued("alice", "r-007")
            mock_qs.add_task_metadata.assert_called_once()


# ---------------------------------------------------------------------------
# notify_research_completed — updates status + sends notification
# ---------------------------------------------------------------------------


class TestNotifyResearchCompleted:
    """Tests for notify_research_completed."""

    @patch(f"{MODULE}.send_research_completed_notification_from_session")
    @patch(f"{MODULE}.get_user_db_session")
    def test_updates_status_and_sends_notification(
        self, mock_get_session, mock_send_notif
    ):
        """Updates queue status to COMPLETED and sends notification."""
        processor = _make_processor()

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        with patch(f"{MODULE}.UserQueueService") as mock_qs_class:
            mock_qs = Mock()
            mock_qs_class.return_value = mock_qs

            processor.notify_research_completed(
                "alice", "r-010", user_password="pw"
            )

            mock_qs.update_task_status.assert_called_once()
            mock_send_notif.assert_called_once_with(
                username="alice",
                research_id="r-010",
                db_session=mock_session,
            )

    @patch(f"{MODULE}.get_user_db_session")
    def test_exception_does_not_propagate(self, mock_get_session):
        """Exception in status update is caught silently."""
        processor = _make_processor()

        mock_get_session.side_effect = RuntimeError("DB unavailable")

        # Should not raise
        processor.notify_research_completed("alice", "r-011")

    @patch(f"{MODULE}.send_research_completed_notification_from_session")
    @patch(f"{MODULE}.get_user_db_session")
    def test_passes_password_to_session(
        self, mock_get_session, mock_send_notif
    ):
        """User password is passed to get_user_db_session."""
        processor = _make_processor()

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        with patch(f"{MODULE}.UserQueueService"):
            processor.notify_research_completed(
                "bob", "r-012", user_password="secret"
            )

        mock_get_session.assert_called_once_with("bob", "secret")


# ---------------------------------------------------------------------------
# notify_research_failed — error message defaulting, sanitization
# ---------------------------------------------------------------------------


class TestNotifyResearchFailed:
    """Tests for notify_research_failed."""

    @patch(f"{MODULE}.send_research_failed_notification_from_session")
    @patch(f"{MODULE}.get_user_db_session")
    def test_sends_error_message_to_queue_service(
        self, mock_get_session, mock_send_notif
    ):
        """Passes error_message to queue service update."""
        processor = _make_processor()

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        with patch(f"{MODULE}.UserQueueService") as mock_qs_class:
            mock_qs = Mock()
            mock_qs_class.return_value = mock_qs

            processor.notify_research_failed(
                "alice", "r-020", error_message="OOM", user_password="pw"
            )

            mock_qs.update_task_status.assert_called_once()
            args, kwargs = mock_qs.update_task_status.call_args
            assert (
                kwargs.get("error_message") == "OOM" or args[2]
                if len(args) > 2
                else True
            )

    @patch(f"{MODULE}.send_research_failed_notification_from_session")
    @patch(f"{MODULE}.get_user_db_session")
    def test_defaults_error_message_to_unknown(
        self, mock_get_session, mock_send_notif
    ):
        """None error_message defaults to 'Unknown error' for notification."""
        processor = _make_processor()

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        with patch(f"{MODULE}.UserQueueService"):
            processor.notify_research_failed(
                "alice", "r-021", user_password="pw"
            )

        mock_send_notif.assert_called_once_with(
            username="alice",
            research_id="r-021",
            error_message="Unknown error",
            db_session=mock_session,
        )

    @patch(f"{MODULE}.get_user_db_session")
    def test_exception_does_not_propagate(self, mock_get_session):
        """Exception in failure handling is caught."""
        processor = _make_processor()

        mock_get_session.side_effect = RuntimeError("DB error")

        # Should not raise
        processor.notify_research_failed("alice", "r-022", error_message="bad")


# ---------------------------------------------------------------------------
# _process_queue_loop — user:session parsing, removal logic
# ---------------------------------------------------------------------------


class TestProcessQueueLoop:
    """Tests for _process_queue_loop."""

    def test_parses_user_session_format(self):
        """Correctly parses (username, session_id) tuple entries."""
        processor = _make_processor()
        processor._users_to_check.add(("alice", "sess-abc"))

        with patch.object(
            processor, "_process_user_queue", return_value=True
        ) as mock_pq:
            # Run one iteration then stop
            processor.running = True

            def stop_after_one():
                time.sleep(0.05)
                processor.running = False

            threading.Thread(target=stop_after_one).start()
            processor._process_queue_loop()

            mock_pq.assert_called_with("alice", "sess-abc")

    def test_removes_users_with_empty_queues(self):
        """Users whose queues are empty get removed from check set."""
        processor = _make_processor()
        processor._users_to_check.add(("alice", "sess1"))
        processor._users_to_check.add(("bob", "sess2"))

        def side_effect(username, session_id):
            return username == "alice"  # alice's queue is empty

        with patch.object(
            processor, "_process_user_queue", side_effect=side_effect
        ):
            processor.running = True

            def stop_after_one():
                time.sleep(0.05)
                processor.running = False

            threading.Thread(target=stop_after_one).start()
            processor._process_queue_loop()

        assert ("alice", "sess1") not in processor._users_to_check
        assert ("bob", "sess2") in processor._users_to_check

    def test_keeps_users_on_error(self):
        """Users are kept if _process_user_queue raises."""
        processor = _make_processor()
        processor._users_to_check.add(("alice", "sess1"))

        with patch.object(
            processor,
            "_process_user_queue",
            side_effect=RuntimeError("transient"),
        ):
            processor.running = True

            def stop_after_one():
                time.sleep(0.05)
                processor.running = False

            threading.Thread(target=stop_after_one).start()
            processor._process_queue_loop()

        assert ("alice", "sess1") in processor._users_to_check


# ---------------------------------------------------------------------------
# _process_user_queue — slot calculation, empty queue, no password
# ---------------------------------------------------------------------------


class TestProcessUserQueue:
    """Tests for _process_user_queue."""

    @patch(f"{MODULE}.session_password_store")
    def test_no_password_returns_true(self, mock_pw_store):
        """No password -> returns True (remove from checking)."""
        processor = _make_processor()
        mock_pw_store.get_session_password.return_value = None

        result = processor._process_user_queue("alice", "sess1")
        assert result is True

    @patch(f"{MODULE}.get_user_db_session")
    @patch(f"{MODULE}.db_manager")
    @patch(f"{MODULE}.session_password_store")
    def test_empty_queue_returns_true(
        self, mock_pw_store, mock_db_mgr, mock_get_session
    ):
        """Empty queue -> returns True."""
        processor = _make_processor()

        mock_pw_store.get_session_password.return_value = "pw"
        mock_db_mgr.open_user_database.return_value = Mock()

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        mock_settings = Mock()
        mock_settings.get_setting.return_value = 3

        mock_qs = Mock()
        mock_qs.get_queue_status.return_value = {
            "active_tasks": 0,
            "queued_tasks": 0,
        }

        with patch(SETTINGS_MGR, return_value=mock_settings):
            with patch(f"{MODULE}.UserQueueService", return_value=mock_qs):
                result = processor._process_user_queue("alice", "sess1")

        assert result is True

    @patch(f"{MODULE}.get_user_db_session")
    @patch(f"{MODULE}.db_manager")
    @patch(f"{MODULE}.session_password_store")
    def test_no_slots_available_returns_false(
        self, mock_pw_store, mock_db_mgr, mock_get_session
    ):
        """No available slots -> returns False (keep checking)."""
        processor = _make_processor()

        mock_pw_store.get_session_password.return_value = "pw"
        mock_db_mgr.open_user_database.return_value = Mock()

        mock_session = MagicMock()
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        mock_settings = Mock()
        mock_settings.get_setting.return_value = 2

        mock_qs = Mock()
        mock_qs.get_queue_status.return_value = {
            "active_tasks": 2,
            "queued_tasks": 1,
        }

        with patch(SETTINGS_MGR, return_value=mock_settings):
            with patch(f"{MODULE}.UserQueueService", return_value=mock_qs):
                result = processor._process_user_queue("alice", "sess1")

        assert result is False

    @patch(f"{MODULE}.db_manager")
    @patch(f"{MODULE}.session_password_store")
    def test_failed_db_open_returns_false(self, mock_pw_store, mock_db_mgr):
        """Failed database open -> returns False (keep checking)."""
        processor = _make_processor()

        mock_pw_store.get_session_password.return_value = "pw"
        mock_db_mgr.open_user_database.return_value = None

        result = processor._process_user_queue("alice", "sess1")
        assert result is False


# ---------------------------------------------------------------------------
# _start_research — new vs legacy settings format
# ---------------------------------------------------------------------------


class TestStartResearch:
    """Tests for _start_research."""

    @patch(f"{MODULE}.start_research_process")
    def test_new_format_extracts_submission_and_settings(
        self, mock_start_research
    ):
        """New format: {submission: {...}, settings_snapshot: {...}}."""
        processor = _make_processor()

        mock_thread = Mock()
        mock_thread.ident = 12345
        mock_start_research.return_value = mock_thread

        mock_session = MagicMock()
        mock_research = Mock()
        mock_research.status = "queued"
        mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research

        queued = Mock()
        queued.research_id = "r-100"
        queued.query = "test query"
        queued.mode = "standard"
        queued.settings_snapshot = {
            "submission": {
                "model_provider": "openai",
                "model": "gpt-4",
                "strategy": "graph-based",
            },
            "settings_snapshot": {"setting_a": "value_a"},
        }

        with patch(f"{MODULE}.UserActiveResearch"):
            processor._start_research(mock_session, "alice", "pw", queued)

        # Verify start_research_process was called with new-format extracted params
        call_kwargs = mock_start_research.call_args.kwargs
        assert call_kwargs["model_provider"] == "openai"
        assert call_kwargs["model"] == "gpt-4"
        assert call_kwargs["strategy"] == "graph-based"
        assert call_kwargs["settings_snapshot"] == {"setting_a": "value_a"}

    @patch(f"{MODULE}.start_research_process")
    def test_legacy_format_uses_flat_dict(self, mock_start_research):
        """Legacy format: flat dict used as submission_params."""
        processor = _make_processor()

        mock_thread = Mock()
        mock_thread.ident = 12345
        mock_start_research.return_value = mock_thread

        mock_session = MagicMock()
        mock_research = Mock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research

        queued = Mock()
        queued.research_id = "r-101"
        queued.query = "legacy query"
        queued.mode = "standard"
        queued.settings_snapshot = {
            "model_provider": "anthropic",
            "model": "claude-3",
        }

        with patch(f"{MODULE}.UserActiveResearch"):
            processor._start_research(mock_session, "alice", "pw", queued)

        call_kwargs = mock_start_research.call_args.kwargs
        assert call_kwargs["model_provider"] == "anthropic"
        assert call_kwargs["model"] == "claude-3"
        assert call_kwargs["settings_snapshot"] == {}

    @patch(f"{MODULE}.start_research_process")
    def test_none_settings_snapshot_handled(self, mock_start_research):
        """None settings_snapshot treated as empty dict."""
        processor = _make_processor()

        mock_thread = Mock()
        mock_thread.ident = 12345
        mock_start_research.return_value = mock_thread

        mock_session = MagicMock()
        mock_research = Mock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research

        queued = Mock()
        queued.research_id = "r-102"
        queued.query = "test"
        queued.mode = "standard"
        queued.settings_snapshot = None

        with patch(f"{MODULE}.UserActiveResearch"):
            processor._start_research(mock_session, "alice", "pw", queued)

        call_kwargs = mock_start_research.call_args.kwargs
        assert call_kwargs["settings_snapshot"] == {}

    def test_research_not_found_raises(self):
        """Raises ValueError when research record not found."""
        processor = _make_processor()

        mock_session = MagicMock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = None

        queued = Mock()
        queued.research_id = "r-999"
        queued.settings_snapshot = {}

        with pytest.raises(ValueError, match="not found"):
            processor._start_research(mock_session, "alice", "pw", queued)


# ---------------------------------------------------------------------------
# process_pending_operations_for_user — progress/error updates, rollback
# ---------------------------------------------------------------------------


class TestProcessPendingOperationsForUser:
    """Tests for process_pending_operations_for_user."""

    def test_progress_update_sets_progress(self):
        """Progress update operation sets research.progress."""
        processor = _make_processor()

        # Queue a progress update
        processor.queue_progress_update("alice", "r-200", 42.5)

        mock_session = MagicMock()
        mock_research = Mock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research

        count = processor.process_pending_operations_for_user(
            "alice", mock_session
        )

        assert count == 1
        assert mock_research.progress == 42.5
        mock_session.commit.assert_called()

    def test_error_update_sets_all_fields(self):
        """Error update sets status, error_message, metadata, completed_at."""
        processor = _make_processor()

        processor.queue_error_update(
            username="alice",
            research_id="r-201",
            status="failed",
            error_message="OOM killed",
            metadata={"retries": 3},
            completed_at="2026-01-01T00:00:00",
            report_path="/tmp/error.html",
        )

        mock_session = MagicMock()
        mock_research = Mock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research

        count = processor.process_pending_operations_for_user(
            "alice", mock_session
        )

        assert count == 1
        assert mock_research.status == "failed"
        assert mock_research.error_message == "OOM killed"
        assert mock_research.research_meta == {"retries": 3}
        assert mock_research.completed_at == "2026-01-01T00:00:00"
        assert mock_research.report_path == "/tmp/error.html"

    def test_no_operations_returns_zero(self):
        """No pending operations for user -> returns 0."""
        processor = _make_processor()

        mock_session = MagicMock()
        count = processor.process_pending_operations_for_user(
            "alice", mock_session
        )

        assert count == 0

    def test_operations_for_other_users_not_processed(self):
        """Only processes operations for the specified user."""
        processor = _make_processor()

        processor.queue_progress_update("bob", "r-300", 50.0)
        processor.queue_progress_update("alice", "r-301", 75.0)

        mock_session = MagicMock()
        mock_research = Mock()
        mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research

        count = processor.process_pending_operations_for_user(
            "alice", mock_session
        )

        assert count == 1
        assert mock_research.progress == 75.0
        # Bob's operation should still be pending
        assert len(processor.pending_operations) == 1

    def test_exception_triggers_rollback(self):
        """Exception during processing triggers session rollback."""
        processor = _make_processor()

        processor.queue_progress_update("alice", "r-400", 50.0)

        mock_session = MagicMock()
        mock_session.query.side_effect = RuntimeError("DB error")

        count = processor.process_pending_operations_for_user(
            "alice", mock_session
        )

        assert count == 0
        mock_session.rollback.assert_called_once()

    def test_error_update_without_report_path(self):
        """Error update without report_path doesn't set report_path."""
        processor = _make_processor()

        processor.queue_error_update(
            username="alice",
            research_id="r-401",
            status="suspended",
            error_message="Timeout",
            metadata={},
            completed_at="2026-01-01",
        )

        mock_session = MagicMock()
        mock_research = Mock(
            spec=[
                "status",
                "error_message",
                "research_meta",
                "completed_at",
                "report_path",
            ]
        )
        mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research

        count = processor.process_pending_operations_for_user(
            "alice", mock_session
        )
        assert count == 1
        assert mock_research.status == "suspended"
