""" Extended tests for notifications/queue_helpers.py — _from_session functions. The existing test_queue_helpers.py only verifies "function exists" and "handles None db_session" for the _from_session functions. These tests exercise the actual DB-lookup + notification-sending logic (~300 lines). NOTE: This commit also fixes a bug where the _from_session functions used 3-dot relative imports (from ...settings) instead of 2-dot (from ..settings), causing all _from_session notifications to silently fail. Tests cover: - send_queue_failed_notification_from_session: settings retrieval, success/failure logging - send_research_completed_notification_from_session: research lookup, report truncation, URL building - send_research_failed_notification_from_session: error sanitization, research lookup """ from unittest.mock import Mock, MagicMock, patch HELPERS = "local_deep_research.notifications.queue_helpers" # Patch targets for lazy imports inside _from_session functions. # These must be patched at their SOURCE module since the functions use # 'from ..X import Y' which resolves to the source, not the caller module. SETTINGS_MGR = "local_deep_research.settings.SettingsManager" NOTIF_MGR = "local_deep_research.notifications.manager.NotificationManager" URL_BUILDER = ( "local_deep_research.notifications.url_builder.build_notification_url" ) REPORT_STORAGE = "local_deep_research.storage.get_report_storage" # --------------------------------------------------------------------------- # send_queue_failed_notification_from_session # --------------------------------------------------------------------------- class TestSendQueueFailedNotificationFromSession: """Tests for send_queue_failed_notification_from_session.""" @patch(f"{HELPERS}.send_queue_failed_notification") def test_success_path_sends_notification(self, mock_send): """Success path: creates settings snapshot, calls send.""" from local_deep_research.notifications.queue_helpers import ( send_queue_failed_notification_from_session, ) mock_send.return_value = True mock_db = MagicMock() mock_settings = Mock() mock_settings.get_settings_snapshot.return_value = {"snap": True} with patch(SETTINGS_MGR, return_value=mock_settings): send_queue_failed_notification_from_session( username="alice", research_id="r-001", query="test query", error_message="Timeout", db_session=mock_db, ) mock_send.assert_called_once_with( username="alice", research_id="r-001", query="test query", error_message="Timeout", settings_snapshot={"snap": True}, ) @patch(f"{HELPERS}.send_queue_failed_notification") def test_send_returns_false_logs_warning(self, mock_send): """When send returns False, logs warning (doesn't raise).""" from local_deep_research.notifications.queue_helpers import ( send_queue_failed_notification_from_session, ) mock_send.return_value = False mock_db = MagicMock() mock_settings = Mock() mock_settings.get_settings_snapshot.return_value = {} with patch(SETTINGS_MGR, return_value=mock_settings): # Should not raise send_queue_failed_notification_from_session( username="alice", research_id="r-002", query="q", error_message="err", db_session=mock_db, ) def test_settings_manager_raises_caught_silently(self): """SettingsManager exception is caught silently.""" from local_deep_research.notifications.queue_helpers import ( send_queue_failed_notification_from_session, ) mock_db = MagicMock() with patch(SETTINGS_MGR, side_effect=RuntimeError("bad session")): # Should not raise send_queue_failed_notification_from_session( username="alice", research_id="r-003", query="q", error_message="err", db_session=mock_db, ) @patch(f"{HELPERS}.send_queue_failed_notification") def test_send_returns_true_logs_info(self, mock_send): """When send returns True, logs info (success path).""" from local_deep_research.notifications.queue_helpers import ( send_queue_failed_notification_from_session, ) mock_send.return_value = True mock_db = MagicMock() mock_settings = Mock() mock_settings.get_settings_snapshot.return_value = {} with patch(SETTINGS_MGR, return_value=mock_settings): send_queue_failed_notification_from_session( username="alice", research_id="r-004", query="q", error_message="err", db_session=mock_db, ) mock_send.assert_called_once() # --------------------------------------------------------------------------- # send_research_completed_notification_from_session # --------------------------------------------------------------------------- class TestSendResearchCompletedNotificationFromSession: """Tests for send_research_completed_notification_from_session.""" @patch(REPORT_STORAGE) @patch(URL_BUILDER) @patch(NOTIF_MGR) def test_research_found_builds_context_with_query_and_url( self, mock_nm_class, mock_build_url, mock_get_storage ): """When research is found, context includes query, URL, summary.""" from local_deep_research.notifications.queue_helpers import ( send_research_completed_notification_from_session, ) from local_deep_research.notifications.templates import EventType mock_db = MagicMock() mock_research = Mock() mock_research.query = "How does AI work?" mock_db.query.return_value.filter_by.return_value.first.return_value = ( mock_research ) mock_build_url.return_value = "https://app.test/research/r-100" mock_storage = Mock() mock_storage.get_report.return_value = "Short report" mock_get_storage.return_value = mock_storage mock_nm = Mock() mock_nm.send_notification.return_value = True mock_nm_class.return_value = mock_nm mock_settings = Mock() mock_settings.get_settings_snapshot.return_value = {} with patch(SETTINGS_MGR, return_value=mock_settings): send_research_completed_notification_from_session( username="alice", research_id="r-100", db_session=mock_db, ) call_kwargs = mock_nm.send_notification.call_args.kwargs assert call_kwargs["event_type"] == EventType.RESEARCH_COMPLETED ctx = call_kwargs["context"] assert ctx["query"] == "How does AI work?" assert ctx["url"] == "https://app.test/research/r-100" assert ctx["summary"] == "Short report" @patch(REPORT_STORAGE) @patch(URL_BUILDER) @patch(NOTIF_MGR) def test_report_over_200_chars_truncated( self, mock_nm_class, mock_build_url, mock_get_storage ): """Report content >200 chars is truncated with '...'.""" from local_deep_research.notifications.queue_helpers import ( send_research_completed_notification_from_session, ) mock_db = MagicMock() mock_research = Mock() mock_research.query = "query" mock_db.query.return_value.filter_by.return_value.first.return_value = ( mock_research ) mock_build_url.return_value = "https://app.test/r" long_report = "A" * 300 mock_storage = Mock() mock_storage.get_report.return_value = long_report mock_get_storage.return_value = mock_storage mock_nm = Mock() mock_nm.send_notification.return_value = True mock_nm_class.return_value = mock_nm mock_settings = Mock() mock_settings.get_settings_snapshot.return_value = {} with patch(SETTINGS_MGR, return_value=mock_settings): send_research_completed_notification_from_session( username="alice", research_id="r-101", db_session=mock_db ) ctx = mock_nm.send_notification.call_args.kwargs["context"] assert ctx["summary"] == "A" * 200 + "..." assert len(ctx["summary"]) == 203 @patch(REPORT_STORAGE) @patch(URL_BUILDER) @patch(NOTIF_MGR) def test_report_exactly_200_chars_not_truncated( self, mock_nm_class, mock_build_url, mock_get_storage ): """Report content <=200 chars is used as-is.""" from local_deep_research.notifications.queue_helpers import ( send_research_completed_notification_from_session, ) mock_db = MagicMock() mock_research = Mock() mock_research.query = "query" mock_db.query.return_value.filter_by.return_value.first.return_value = ( mock_research ) mock_build_url.return_value = "https://app.test/r" report_200 = "B" * 200 mock_storage = Mock() mock_storage.get_report.return_value = report_200 mock_get_storage.return_value = mock_storage mock_nm = Mock() mock_nm.send_notification.return_value = True mock_nm_class.return_value = mock_nm mock_settings = Mock() mock_settings.get_settings_snapshot.return_value = {} with patch(SETTINGS_MGR, return_value=mock_settings): send_research_completed_notification_from_session( username="alice", research_id="r-102", db_session=mock_db ) ctx = mock_nm.send_notification.call_args.kwargs["context"] assert ctx["summary"] == "B" * 200 @patch(REPORT_STORAGE) @patch(URL_BUILDER) @patch(NOTIF_MGR) def test_report_none_uses_default_summary( self, mock_nm_class, mock_build_url, mock_get_storage ): """Report is None -> summary is 'No summary available'.""" from local_deep_research.notifications.queue_helpers import ( send_research_completed_notification_from_session, ) mock_db = MagicMock() mock_research = Mock() mock_research.query = "query" mock_db.query.return_value.filter_by.return_value.first.return_value = ( mock_research ) mock_build_url.return_value = "https://app.test/r" mock_storage = Mock() mock_storage.get_report.return_value = None mock_get_storage.return_value = mock_storage mock_nm = Mock() mock_nm.send_notification.return_value = True mock_nm_class.return_value = mock_nm mock_settings = Mock() mock_settings.get_settings_snapshot.return_value = {} with patch(SETTINGS_MGR, return_value=mock_settings): send_research_completed_notification_from_session( username="alice", research_id="r-103", db_session=mock_db ) ctx = mock_nm.send_notification.call_args.kwargs["context"] assert ctx["summary"] == "No summary available" @patch(NOTIF_MGR) def test_research_not_found_sends_minimal_context(self, mock_nm_class): """Research not found -> sends notification with minimal context.""" from local_deep_research.notifications.queue_helpers import ( send_research_completed_notification_from_session, ) mock_db = MagicMock() mock_db.query.return_value.filter_by.return_value.first.return_value = ( None ) mock_nm = Mock() mock_nm.send_notification.return_value = True mock_nm_class.return_value = mock_nm mock_settings = Mock() mock_settings.get_settings_snapshot.return_value = {} with patch(SETTINGS_MGR, return_value=mock_settings): send_research_completed_notification_from_session( username="alice", research_id="r-999", db_session=mock_db ) ctx = mock_nm.send_notification.call_args.kwargs["context"] assert ctx["query"] == "Research r-999" assert ctx["summary"] == "Research completed but details unavailable" def test_exception_caught_silently(self): """Top-level exception is caught silently.""" from local_deep_research.notifications.queue_helpers import ( send_research_completed_notification_from_session, ) mock_db = MagicMock() mock_db.query.side_effect = RuntimeError("DB gone") # Should not raise send_research_completed_notification_from_session( username="alice", research_id="r-998", db_session=mock_db ) @patch(REPORT_STORAGE) @patch(URL_BUILDER) @patch(NOTIF_MGR) def test_send_returns_false_does_not_raise( self, mock_nm_class, mock_build_url, mock_get_storage ): """send_notification returning False doesn't raise.""" from local_deep_research.notifications.queue_helpers import ( send_research_completed_notification_from_session, ) mock_db = MagicMock() mock_research = Mock() mock_research.query = "query" mock_db.query.return_value.filter_by.return_value.first.return_value = ( mock_research ) mock_build_url.return_value = "https://app.test/r" mock_storage = Mock() mock_storage.get_report.return_value = "report" mock_get_storage.return_value = mock_storage mock_nm = Mock() mock_nm.send_notification.return_value = False mock_nm_class.return_value = mock_nm mock_settings = Mock() mock_settings.get_settings_snapshot.return_value = {} with patch(SETTINGS_MGR, return_value=mock_settings): # Should not raise send_research_completed_notification_from_session( username="alice", research_id="r-104", db_session=mock_db ) # --------------------------------------------------------------------------- # send_research_failed_notification_from_session # --------------------------------------------------------------------------- class TestSendResearchFailedNotificationFromSession: """Tests for send_research_failed_notification_from_session.""" @patch(NOTIF_MGR) def test_sanitizes_error_message(self, mock_nm_class): """Error message is sanitized to generic 'Check logs' message.""" from local_deep_research.notifications.queue_helpers import ( send_research_failed_notification_from_session, ) mock_db = MagicMock() mock_research = Mock() mock_research.query = "test" mock_db.query.return_value.filter_by.return_value.first.return_value = ( mock_research ) mock_nm = Mock() mock_nm.send_notification.return_value = True mock_nm_class.return_value = mock_nm mock_settings = Mock() mock_settings.get_settings_snapshot.return_value = {} with patch(SETTINGS_MGR, return_value=mock_settings): send_research_failed_notification_from_session( username="alice", research_id="r-200", error_message="SECRET: API key abc123 leaked", db_session=mock_db, ) ctx = mock_nm.send_notification.call_args.kwargs["context"] assert ctx["error"] == "Research failed. Check logs for details." @patch(NOTIF_MGR) def test_original_error_not_in_notification(self, mock_nm_class): """Original error_message is NOT passed to notification context.""" from local_deep_research.notifications.queue_helpers import ( send_research_failed_notification_from_session, ) mock_db = MagicMock() mock_research = Mock() mock_research.query = "test" mock_db.query.return_value.filter_by.return_value.first.return_value = ( mock_research ) mock_nm = Mock() mock_nm.send_notification.return_value = True mock_nm_class.return_value = mock_nm mock_settings = Mock() mock_settings.get_settings_snapshot.return_value = {} with patch(SETTINGS_MGR, return_value=mock_settings): send_research_failed_notification_from_session( username="alice", research_id="r-201", error_message="password=hunter2 connection refused", db_session=mock_db, ) ctx = mock_nm.send_notification.call_args.kwargs["context"] assert "hunter2" not in str(ctx) assert "password" not in str(ctx) @patch(NOTIF_MGR) def test_research_not_found_sends_minimal(self, mock_nm_class): """Research not found -> sends minimal context.""" from local_deep_research.notifications.queue_helpers import ( send_research_failed_notification_from_session, ) mock_db = MagicMock() mock_db.query.return_value.filter_by.return_value.first.return_value = ( None ) mock_nm = Mock() mock_nm.send_notification.return_value = True mock_nm_class.return_value = mock_nm mock_settings = Mock() mock_settings.get_settings_snapshot.return_value = {} with patch(SETTINGS_MGR, return_value=mock_settings): send_research_failed_notification_from_session( username="alice", research_id="r-999", error_message="not found", db_session=mock_db, ) ctx = mock_nm.send_notification.call_args.kwargs["context"] assert ctx["query"] == "Research r-999" assert ctx["error"] == "Research failed. Check logs for details." def test_exception_caught_silently(self): """Top-level exception is caught silently.""" from local_deep_research.notifications.queue_helpers import ( send_research_failed_notification_from_session, ) mock_db = MagicMock() mock_db.query.side_effect = RuntimeError("DB gone") # Should not raise send_research_failed_notification_from_session( username="alice", research_id="r-998", error_message="error", db_session=mock_db, ) @patch(NOTIF_MGR) def test_research_found_uses_query_from_db(self, mock_nm_class): """When research is found, uses research.query from DB.""" from local_deep_research.notifications.queue_helpers import ( send_research_failed_notification_from_session, ) mock_db = MagicMock() mock_research = Mock() mock_research.query = "What is quantum computing?" mock_db.query.return_value.filter_by.return_value.first.return_value = ( mock_research ) mock_nm = Mock() mock_nm.send_notification.return_value = True mock_nm_class.return_value = mock_nm mock_settings = Mock() mock_settings.get_settings_snapshot.return_value = {} with patch(SETTINGS_MGR, return_value=mock_settings): send_research_failed_notification_from_session( username="alice", research_id="r-202", error_message="timeout", db_session=mock_db, ) ctx = mock_nm.send_notification.call_args.kwargs["context"] assert ctx["query"] == "What is quantum computing?"