""" Extended tests for news/recommender/topic_based.py Tests cover: - TopicBasedRecommender initialization - generate_recommendations() method - _get_trending_topics() method - _filter_topics_by_preferences() method - _generate_topic_query() method - _create_recommendation_card() method - SearchBasedRecommender class """ from unittest.mock import Mock, patch class TestTopicBasedRecommenderInit: """Tests for TopicBasedRecommender initialization.""" def test_creates_instance(self): """Creates TopicBasedRecommender instance.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() assert recommender is not None def test_inherits_from_base_recommender(self): """Inherits from BaseRecommender.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) from local_deep_research.news.recommender.base_recommender import ( BaseRecommender, ) recommender = TopicBasedRecommender() assert isinstance(recommender, BaseRecommender) def test_default_max_recommendations(self): """Default max_recommendations is 5.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() assert recommender.max_recommendations == 5 def test_accepts_topic_registry(self): """Accepts topic_registry parameter.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) mock_registry = Mock() recommender = TopicBasedRecommender(topic_registry=mock_registry) assert recommender.topic_registry is mock_registry def test_accepts_preference_manager(self): """Accepts preference_manager parameter.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) mock_manager = Mock() recommender = TopicBasedRecommender(preference_manager=mock_manager) assert recommender.preference_manager is mock_manager class TestGenerateRecommendations: """Tests for generate_recommendations() method.""" def test_returns_list(self): """Returns a list.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() recommender._get_trending_topics = Mock(return_value=["AI"]) recommender._filter_topics_by_preferences = Mock(return_value=[]) recommender._get_user_preferences = Mock(return_value={}) result = recommender.generate_recommendations("user1") assert isinstance(result, list) def test_calls_get_trending_topics(self): """Calls _get_trending_topics.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() recommender._get_trending_topics = Mock(return_value=[]) recommender._filter_topics_by_preferences = Mock(return_value=[]) recommender._get_user_preferences = Mock(return_value={}) recommender.generate_recommendations("user1") recommender._get_trending_topics.assert_called_once() def test_calls_filter_topics_by_preferences(self): """Calls _filter_topics_by_preferences.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() recommender._get_trending_topics = Mock(return_value=["AI"]) recommender._filter_topics_by_preferences = Mock(return_value=[]) recommender._get_user_preferences = Mock(return_value={}) recommender.generate_recommendations("user1") recommender._filter_topics_by_preferences.assert_called_once() def test_limits_to_max_recommendations(self): """Limits topics to max_recommendations.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() recommender.max_recommendations = 2 recommender._get_trending_topics = Mock( return_value=["A", "B", "C", "D", "E"] ) recommender._filter_topics_by_preferences = Mock( return_value=["A", "B", "C", "D", "E"] ) recommender._get_user_preferences = Mock(return_value={}) recommender._create_recommendation_card = Mock(return_value=None) recommender.generate_recommendations("user1") # Should only process max_recommendations topics assert recommender._create_recommendation_card.call_count <= 2 def test_handles_empty_topics(self): """Handles empty topics list.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() recommender._get_trending_topics = Mock(return_value=[]) recommender._filter_topics_by_preferences = Mock(return_value=[]) recommender._get_user_preferences = Mock(return_value={}) result = recommender.generate_recommendations("user1") assert result == [] def test_handles_exception_gracefully(self): """Handles exception gracefully.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() recommender._get_trending_topics = Mock(side_effect=Exception("Error")) result = recommender.generate_recommendations("user1") # Should return empty list, not raise assert result == [] class TestGetTrendingTopics: """Tests for _get_trending_topics() method.""" def test_returns_list(self): """Returns a list.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() result = recommender._get_trending_topics(None) assert isinstance(result, list) def test_uses_topic_registry_if_available(self): """Uses topic_registry if available.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) mock_registry = Mock() mock_registry.get_trending_topics.return_value = ["AI", "ML"] recommender = TopicBasedRecommender(topic_registry=mock_registry) result = recommender._get_trending_topics(None) mock_registry.get_trending_topics.assert_called_once() assert "AI" in result def test_adds_context_topics(self): """Adds topics from context.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() context = {"current_news_topics": ["Technology"]} result = recommender._get_trending_topics(context) assert "Technology" in result def test_returns_fallback_topics_when_none_found(self): """Returns fallback topics when none found.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() result = recommender._get_trending_topics(None) # Should have fallback topics assert len(result) > 0 def test_fallback_includes_ai_topic(self): """Fallback includes AI topic.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() result = recommender._get_trending_topics(None) assert any("artificial intelligence" in t.lower() for t in result) class TestFilterTopicsByPreferences: """Tests for _filter_topics_by_preferences() method.""" def test_returns_list(self): """Returns a list.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() topics = ["AI", "Sports"] preferences = {} result = recommender._filter_topics_by_preferences(topics, preferences) assert isinstance(result, list) def test_filters_disliked_topics(self): """Filters out disliked topics.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() topics = ["AI News", "Sports News", "Tech Updates"] preferences = {"disliked_topics": ["sports"]} result = recommender._filter_topics_by_preferences(topics, preferences) assert "Sports News" not in result assert "AI News" in result def test_boosts_topics_matching_interests(self): """Boosts topics matching interests.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() topics = ["General News", "AI Developments"] preferences = {"interests": {"AI": 2.0}} result = recommender._filter_topics_by_preferences(topics, preferences) # AI topic should be first due to higher boost assert result[0] == "AI Developments" def test_preserves_all_topics_when_no_disliked(self): """Preserves all topics when no disliked topics.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() topics = ["AI", "Tech", "Science"] preferences = {} result = recommender._filter_topics_by_preferences(topics, preferences) assert len(result) == 3 def test_handles_empty_topics_list(self): """Handles empty topics list.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() result = recommender._filter_topics_by_preferences([], {}) assert result == [] def test_case_insensitive_dislike_matching(self): """Case insensitive dislike matching.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() topics = ["SPORTS News", "sports update"] preferences = {"disliked_topics": ["Sports"]} result = recommender._filter_topics_by_preferences(topics, preferences) assert len(result) == 0 class TestGenerateTopicQuery: """Tests for _generate_topic_query() method.""" def test_returns_string(self): """Returns a string.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() result = recommender._generate_topic_query("AI") assert isinstance(result, str) def test_includes_topic_in_query(self): """Includes topic in query.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() result = recommender._generate_topic_query("artificial intelligence") assert "artificial intelligence" in result def test_includes_news_keywords(self): """Includes news-related keywords.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() result = recommender._generate_topic_query("tech") assert "news" in result.lower() def test_includes_latest_keyword(self): """Includes 'latest' keyword for fresh news.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() result = recommender._generate_topic_query("topic") assert "latest" in result.lower() class TestCreateRecommendationCard: """Tests for _create_recommendation_card() method.""" def test_returns_none_on_search_error(self): """Returns None when search fails.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) with patch( "local_deep_research.news.recommender.topic_based.AdvancedSearchSystem" ) as MockSearch: mock_instance = Mock() mock_instance.analyze_topic.return_value = { "error": "Search failed" } MockSearch.return_value = mock_instance recommender = TopicBasedRecommender() result = recommender._create_recommendation_card( "topic", "query", "user1" ) assert result is None def test_returns_none_when_no_items_found(self): """Returns None when no news items found.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) with patch( "local_deep_research.news.recommender.topic_based.AdvancedSearchSystem" ) as MockSearch: mock_instance = Mock() mock_instance.analyze_topic.return_value = {"news_items": []} MockSearch.return_value = mock_instance recommender = TopicBasedRecommender() result = recommender._create_recommendation_card( "topic", "query", "user1" ) assert result is None def test_handles_exception_gracefully(self): """Handles exception gracefully.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) with patch( "local_deep_research.news.recommender.topic_based.AdvancedSearchSystem" ) as MockSearch: MockSearch.side_effect = Exception("Search system error") recommender = TopicBasedRecommender() result = recommender._create_recommendation_card( "topic", "query", "user1" ) assert result is None def test_returns_none_when_llm_not_configured(self): """Returns None when get_llm() raises ValueError; does NOT proceed to construct AdvancedSearchSystem. Scheduler runs repeatedly — must not propagate stack trace. Strengthened to distinguish the specific `except ValueError` path from the outer `except Exception` swallow: this asserts that the *warning* log was emitted (specific path) rather than `logger.exception` (catch-all path). Without this assertion, removing the targeted `except ValueError` block would still let the test pass — the outer handler would also return None. """ from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) from local_deep_research.security import sanitize_for_log with ( patch( "local_deep_research.config.llm_config.get_llm" ) as mock_get_llm, patch( "local_deep_research.news.recommender.topic_based.AdvancedSearchSystem" ) as MockSearch, patch( "local_deep_research.news.recommender.topic_based.logger" ) as mock_logger, ): mock_get_llm.side_effect = ValueError( "LLM model not configured. ..." ) # Topic carries a CRLF log-injection payload; the warning must # interpolate the *sanitized* form. Binding it to a variable (and # deriving the expected substring from it) removes the previous # assertion's coupling to a hard-coded "AI" literal. topic = "AI\r\ninjected-fake-log-line" recommender = TopicBasedRecommender() result = recommender._create_recommendation_card( topic, "query", "user1" ) assert result is None mock_get_llm.assert_called_once() # Critical: we returned before building the search system. MockSearch.assert_not_called() # The targeted except-ValueError path uses logger.warning; the # catch-all `except Exception` would use logger.exception. Asserting # warning was called and exception was NOT pins the code path. mock_logger.warning.assert_called_once() warning_msg = mock_logger.warning.call_args[0][0] assert "LLM not configured" in warning_msg # The sanitized topic is interpolated; the raw CR/LF payload never # reaches the log line (this is the log-injection guarantee). assert sanitize_for_log(topic) in warning_msg assert "\r" not in warning_msg assert "\n" not in warning_msg mock_logger.exception.assert_not_called() class TestSearchBasedRecommender: """Tests for SearchBasedRecommender class.""" def test_creates_instance(self): """Creates SearchBasedRecommender instance.""" from local_deep_research.news.recommender.topic_based import ( SearchBasedRecommender, ) recommender = SearchBasedRecommender() assert recommender is not None def test_inherits_from_base_recommender(self): """Inherits from BaseRecommender.""" from local_deep_research.news.recommender.topic_based import ( SearchBasedRecommender, ) from local_deep_research.news.recommender.base_recommender import ( BaseRecommender, ) recommender = SearchBasedRecommender() assert isinstance(recommender, BaseRecommender) def test_generate_recommendations_returns_list(self): """generate_recommendations returns a list.""" from local_deep_research.news.recommender.topic_based import ( SearchBasedRecommender, ) recommender = SearchBasedRecommender() result = recommender.generate_recommendations("user1") assert isinstance(result, list) def test_returns_empty_list_when_tracking_disabled(self): """Returns empty list when search tracking is disabled.""" from local_deep_research.news.recommender.topic_based import ( SearchBasedRecommender, ) recommender = SearchBasedRecommender() result = recommender.generate_recommendations("user1") # Currently returns empty since tracking is off by default assert result == [] def test_accepts_context(self): """Accepts context parameter.""" from local_deep_research.news.recommender.topic_based import ( SearchBasedRecommender, ) recommender = SearchBasedRecommender() result = recommender.generate_recommendations( "user1", context={"key": "value"} ) assert isinstance(result, list) class TestRecommenderProgressTracking: """Tests for progress tracking in recommenders.""" def test_has_progress_callback_support(self): """Recommender supports progress callback.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() # Should have progress_callback attribute assert hasattr(recommender, "progress_callback") def test_set_progress_callback(self): """Can set progress callback.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() callback = Mock() recommender.set_progress_callback(callback) assert recommender.progress_callback is callback class TestRecommenderEdgeCases: """Edge case tests for recommenders.""" def test_empty_user_id(self): """Handles empty user_id.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() recommender._get_trending_topics = Mock(return_value=[]) recommender._filter_topics_by_preferences = Mock(return_value=[]) recommender._get_user_preferences = Mock(return_value={}) result = recommender.generate_recommendations("") assert isinstance(result, list) def test_unicode_topic(self): """Handles unicode topics.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() result = recommender._generate_topic_query("日本語ニュース") assert "日本語ニュース" in result def test_very_long_topic(self): """Handles very long topics.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() long_topic = "A" * 1000 result = recommender._generate_topic_query(long_topic) assert long_topic in result def test_special_characters_in_topic(self): """Handles special characters in topics.""" from local_deep_research.news.recommender.topic_based import ( TopicBasedRecommender, ) recommender = TopicBasedRecommender() result = recommender._generate_topic_query("C++ & Python") assert "C++ & Python" in result