""" Tests for scheduler, folder, and history endpoints in news/flask_api.py. Covers: - get_scheduler_status (user-scoped and with jobs) - start_scheduler (already running) - stop_scheduler (not running, no instance) - check_subscriptions_now (not initialized) - trigger_cleanup (not running) - create_folder (already exists) - update_folder (not found) - delete_folder (not found) - get_search_history (no user -> empty list) - add_search_history (missing query -> 400) - clear_search_history (no user -> success) - check_overdue (empty list, mixed success/failure) """ import sys import types import pytest from contextlib import contextmanager from unittest.mock import patch, MagicMock from flask import Flask # --------------------------------------------------------------------------- # Module-injection helpers # # Several endpoints do local imports of heavy modules (spacy, LibraryRAGService) # via: # from .subscription_manager.scheduler import get_background_job_scheduler # from .core.utils import get_local_date_string # # We inject lightweight fake modules into sys.modules so these imports succeed # without pulling the real (slow/heavy) dependencies. # --------------------------------------------------------------------------- def _ensure_parent_packages(module_name): """Ensure every parent package stub is present in sys.modules.""" parts = module_name.split(".") for i in range(1, len(parts)): pkg_name = ".".join(parts[:i]) if pkg_name not in sys.modules: sys.modules[pkg_name] = types.ModuleType(pkg_name) @contextmanager def _fake_scheduler_module(mock_scheduler): """Inject a fake subscription_manager.scheduler so get_background_job_scheduler returns mock_scheduler without importing the real (spacy-heavy) module.""" module_name = "local_deep_research.scheduler.background" orig = sys.modules.get(module_name) _ensure_parent_packages(module_name) mod = types.ModuleType(module_name) mod.get_background_job_scheduler = lambda: mock_scheduler mod.BackgroundJobScheduler = MagicMock sys.modules[module_name] = mod try: yield finally: if orig is None: sys.modules.pop(module_name, None) else: sys.modules[module_name] = orig @contextmanager def _fake_news_core_utils(date_string="2030-01-01"): """Inject a fake news.core.utils so get_local_date_string returns date_string without importing spacy-dependent code.""" module_name = "local_deep_research.news.core.utils" orig = sys.modules.get(module_name) _ensure_parent_packages(module_name) mod = types.ModuleType(module_name) mod.get_local_date_string = lambda *a, **kw: date_string sys.modules[module_name] = mod try: yield finally: if orig is None: sys.modules.pop(module_name, None) else: sys.modules[module_name] = orig # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def app(): """Create a minimal Flask app with the news_api blueprint registered.""" flask_app = Flask(__name__) flask_app.config["SECRET_KEY"] = "test-secret-key" flask_app.config["WTF_CSRF_ENABLED"] = False flask_app.config["TESTING"] = True from local_deep_research.news.flask_api import news_api_bp flask_app.register_blueprint(news_api_bp, url_prefix="/news/api") return flask_app @pytest.fixture def client(app): return app.test_client() # --------------------------------------------------------------------------- # Shared helper # --------------------------------------------------------------------------- def _authenticated_client(app, mock_db_mgr): """Return a test client with a session that satisfies @login_required.""" mock_db_mgr.is_user_connected.return_value = True c = app.test_client() with c.session_transaction() as sess: sess["username"] = "testuser" return c # --------------------------------------------------------------------------- # 1. get_scheduler_status — user-scoped (show_all=False) # --------------------------------------------------------------------------- class TestGetSchedulerStatusUserScoped: """show_all=False -> active_users counts only the current user.""" def test_get_scheduler_status_user_scoped(self, app): # spec limits attributes so hasattr(mock, 'scheduler') is False, # preventing the APScheduler job-listing branch from executing. mock_scheduler = MagicMock( spec=["is_running", "config", "user_sessions"] ) mock_scheduler.is_running = True mock_scheduler.config = {"interval": 60} mock_scheduler.user_sessions = { "testuser": {"scheduled_jobs": {"job1", "job2"}}, } with ( _fake_scheduler_module(mock_scheduler), patch( "local_deep_research.news.flask_api.get_env_setting", return_value=False, # show_all = False ), patch( "local_deep_research.web.auth.decorators.db_manager" ) as mock_db_mgr, ): c = _authenticated_client(app, mock_db_mgr) response = c.get("/news/api/scheduler/status") assert response.status_code == 200 data = response.get_json() assert data["is_running"] is True assert data["active_users"] == 1 # only testuser assert data["total_scheduled_jobs"] == 2 # testuser's 2 jobs assert data["scheduled_jobs"] == 2 # --------------------------------------------------------------------------- # 2. get_scheduler_status — apscheduler_jobs populated # --------------------------------------------------------------------------- class TestGetSchedulerStatusWithJobs: """When a real APScheduler instance is present its jobs appear in status.""" def test_get_scheduler_status_with_jobs(self, app): from datetime import datetime, timezone mock_job = MagicMock() mock_job.id = "job_abc" mock_job.name = "refresh_testuser" mock_job.next_run_time = datetime(2030, 1, 1, tzinfo=timezone.utc) mock_job.args = ["testuser"] # _is_job_owned_by_user checks args[0] inner_scheduler = MagicMock() inner_scheduler.get_jobs.return_value = [mock_job] mock_scheduler = MagicMock() mock_scheduler.is_running = True mock_scheduler.config = {} mock_scheduler.user_sessions = { "testuser": {"scheduled_jobs": {"job_abc"}}, } mock_scheduler.scheduler = inner_scheduler with ( _fake_scheduler_module(mock_scheduler), patch( "local_deep_research.news.flask_api.get_env_setting", return_value=False, ), patch( "local_deep_research.web.auth.decorators.db_manager" ) as mock_db_mgr, ): c = _authenticated_client(app, mock_db_mgr) response = c.get("/news/api/scheduler/status") assert response.status_code == 200 data = response.get_json() assert data["apscheduler_job_count"] == 1 assert len(data["apscheduler_jobs"]) == 1 assert data["apscheduler_jobs"][0]["id"] == "job_abc" assert data["apscheduler_jobs"][0]["name"] == "refresh_testuser" # --------------------------------------------------------------------------- # 3. start_scheduler — already running → 200 # --------------------------------------------------------------------------- class TestStartSchedulerAlreadyRunning: """If the scheduler is already running, start returns 200 + message.""" def test_start_scheduler_already_running(self, app): mock_scheduler = MagicMock() mock_scheduler.is_running = True with ( _fake_scheduler_module(mock_scheduler), patch( "local_deep_research.news.flask_api.get_env_setting", return_value=True, # allow_api_control = True ), patch( "local_deep_research.web.auth.decorators.db_manager" ) as mock_db_mgr, ): c = _authenticated_client(app, mock_db_mgr) response = c.post("/news/api/scheduler/start") assert response.status_code == 200 data = response.get_json() assert "already running" in data["message"].lower() # --------------------------------------------------------------------------- # 4. stop_scheduler — scheduler present but not running → 200 # --------------------------------------------------------------------------- class TestStopSchedulerNotRunning: """If the scheduler exists but is_running=False, stop returns 200.""" def test_stop_scheduler_not_running(self, app): mock_scheduler = MagicMock() mock_scheduler.is_running = False with ( patch( "local_deep_research.news.flask_api.get_env_setting", return_value=True, ), patch( "local_deep_research.web.auth.decorators.db_manager" ) as mock_db_mgr, ): # Inject news_scheduler into app before the test client runs app.background_job_scheduler = mock_scheduler c = _authenticated_client(app, mock_db_mgr) response = c.post("/news/api/scheduler/stop") assert response.status_code == 200 data = response.get_json() assert "not running" in data["message"].lower() # --------------------------------------------------------------------------- # 5. stop_scheduler — no scheduler instance → 404 # --------------------------------------------------------------------------- class TestStopSchedulerNoInstance: """If current_app has no news_scheduler, stop returns 404.""" def test_stop_scheduler_no_instance(self, app): # Ensure the attribute is absent from this fresh app if hasattr(app, "background_job_scheduler"): del app.background_job_scheduler with ( patch( "local_deep_research.news.flask_api.get_env_setting", return_value=True, ), patch( "local_deep_research.web.auth.decorators.db_manager" ) as mock_db_mgr, ): c = _authenticated_client(app, mock_db_mgr) response = c.post("/news/api/scheduler/stop") assert response.status_code == 404 data = response.get_json() assert "message" in data assert "scheduler" in data["message"].lower() # --------------------------------------------------------------------------- # 6. check_subscriptions_now — no scheduler → 503 # --------------------------------------------------------------------------- class TestCheckSubscriptionsNowNotInitialized: """When current_app has no news_scheduler, check-now returns 503.""" def test_check_subscriptions_now_not_initialized(self, app): if hasattr(app, "background_job_scheduler"): del app.background_job_scheduler with ( patch( "local_deep_research.news.flask_api.get_env_setting", return_value=True, ), patch( "local_deep_research.web.auth.decorators.db_manager" ) as mock_db_mgr, ): c = _authenticated_client(app, mock_db_mgr) response = c.post("/news/api/scheduler/check-now") assert response.status_code == 503 data = response.get_json() assert "error" in data # --------------------------------------------------------------------------- # 7. trigger_cleanup — scheduler not running → 400 # --------------------------------------------------------------------------- class TestTriggerCleanupNotRunning: """If the scheduler is not running, cleanup returns 400.""" def test_trigger_cleanup_not_running(self, app): mock_scheduler = MagicMock() mock_scheduler.is_running = False with ( _fake_scheduler_module(mock_scheduler), patch( "local_deep_research.news.flask_api.get_env_setting", return_value=True, ), patch( "local_deep_research.web.auth.decorators.db_manager" ) as mock_db_mgr, ): c = _authenticated_client(app, mock_db_mgr) response = c.post("/news/api/scheduler/cleanup-now") assert response.status_code == 400 data = response.get_json() assert "error" in data assert "not running" in data["error"].lower() # --------------------------------------------------------------------------- # 8. create_folder — folder already exists → 409 # --------------------------------------------------------------------------- class TestCreateFolderAlreadyExists: """Creating a folder whose name is already taken returns 409.""" def test_create_folder_already_exists(self, app): existing_folder = MagicMock() mock_db = MagicMock() mock_db.query.return_value.filter_by.return_value.first.return_value = ( existing_folder ) mock_ctx = MagicMock() mock_ctx.__enter__ = MagicMock(return_value=mock_db) mock_ctx.__exit__ = MagicMock(return_value=False) with ( patch( "local_deep_research.news.flask_api.get_user_db_session", return_value=mock_ctx, ), patch( "local_deep_research.news.flask_api.get_user_id", return_value="testuser", ), patch( "local_deep_research.web.auth.decorators.db_manager" ) as mock_db_mgr, ): c = _authenticated_client(app, mock_db_mgr) response = c.post( "/news/api/subscription/folders", json={"name": "Duplicate Folder"}, content_type="application/json", ) assert response.status_code == 409 data = response.get_json() assert "error" in data assert "already exists" in data["error"].lower() # --------------------------------------------------------------------------- # 9. update_folder — folder not found → 404 # --------------------------------------------------------------------------- class TestUpdateFolderNotFound: """Updating a non-existent folder returns 404.""" def test_update_folder_not_found(self, app): mock_manager = MagicMock() mock_manager.update_folder.return_value = None # not found mock_db = MagicMock() mock_ctx = MagicMock() mock_ctx.__enter__ = MagicMock(return_value=mock_db) mock_ctx.__exit__ = MagicMock(return_value=False) with ( patch( "local_deep_research.news.flask_api.get_user_db_session", return_value=mock_ctx, ), patch( "local_deep_research.news.flask_api.FolderManager", return_value=mock_manager, ), patch( "local_deep_research.web.auth.decorators.db_manager" ) as mock_db_mgr, ): c = _authenticated_client(app, mock_db_mgr) response = c.put( "/news/api/subscription/folders/nonexistent-id", json={"name": "New Name"}, content_type="application/json", ) assert response.status_code == 404 data = response.get_json() assert "error" in data assert "not found" in data["error"].lower() # --------------------------------------------------------------------------- # 10. delete_folder — folder not found → 404 # --------------------------------------------------------------------------- class TestDeleteFolderNotFound: """Deleting a non-existent folder returns 404.""" def test_delete_folder_not_found(self, app): mock_manager = MagicMock() mock_manager.delete_folder.return_value = False # not found mock_db = MagicMock() mock_ctx = MagicMock() mock_ctx.__enter__ = MagicMock(return_value=mock_db) mock_ctx.__exit__ = MagicMock(return_value=False) with ( patch( "local_deep_research.news.flask_api.get_user_db_session", return_value=mock_ctx, ), patch( "local_deep_research.news.flask_api.FolderManager", return_value=mock_manager, ), patch( "local_deep_research.web.auth.decorators.db_manager" ) as mock_db_mgr, ): c = _authenticated_client(app, mock_db_mgr) response = c.delete("/news/api/subscription/folders/nonexistent-id") assert response.status_code == 404 data = response.get_json() assert "error" in data assert "not found" in data["error"].lower() # --------------------------------------------------------------------------- # 11. get_search_history — current_user() returns None → empty list # --------------------------------------------------------------------------- class TestGetSearchHistoryNoUser: """When current_user() returns None, search history returns an empty list.""" def test_get_search_history_no_user(self, app): # current_user is imported locally from web.auth.decorators; patch there. with ( patch( "local_deep_research.web.auth.decorators.current_user", return_value=None, ), patch( "local_deep_research.web.auth.decorators.db_manager" ) as mock_db_mgr, ): c = _authenticated_client(app, mock_db_mgr) response = c.get("/news/api/search-history") assert response.status_code == 200 data = response.get_json() assert data == {"search_history": []} # --------------------------------------------------------------------------- # 12. add_search_history — missing query → 400 # --------------------------------------------------------------------------- class TestAddSearchHistoryNoQuery: """POSTing to search-history without 'query' returns 400.""" def test_add_search_history_no_query(self, app): with ( patch( "local_deep_research.web.auth.decorators.current_user", return_value="testuser", ), patch( "local_deep_research.web.auth.decorators.db_manager" ) as mock_db_mgr, ): c = _authenticated_client(app, mock_db_mgr) response = c.post( "/news/api/search-history", json={"type": "filter"}, # 'query' key intentionally absent content_type="application/json", ) assert response.status_code == 400 data = response.get_json() assert "error" in data assert "query" in data["error"].lower() # --------------------------------------------------------------------------- # 13. clear_search_history — current_user() returns None → success # --------------------------------------------------------------------------- class TestClearSearchHistoryNoUser: """When current_user() returns None, clear returns success immediately.""" def test_clear_search_history_no_user(self, app): with ( patch( "local_deep_research.web.auth.decorators.current_user", return_value=None, ), patch( "local_deep_research.web.auth.decorators.db_manager" ) as mock_db_mgr, ): c = _authenticated_client(app, mock_db_mgr) response = c.delete("/news/api/search-history") assert response.status_code == 200 data = response.get_json() assert data == {"status": "success"} # --------------------------------------------------------------------------- # 14. check_overdue — no overdue subscriptions → empty results # --------------------------------------------------------------------------- class TestCheckOverdueNoOverdue: """When there are no overdue subscriptions the response is empty.""" def test_check_overdue_no_overdue(self, app): mock_db = MagicMock() mock_db.query.return_value.filter.return_value.all.return_value = [] @contextmanager def fake_session(*a, **kw): yield mock_db mock_settings_cls = MagicMock() mock_settings_cls.return_value.get_setting.return_value = "UTC" # check_overdue_subscriptions does a local # `from ..database.session_context import get_user_db_session` # which bypasses a patch of flask_api.get_user_db_session. # Patching the source module ensures the local re-import gets the mock. with ( _fake_news_core_utils("2030-01-01"), patch( "local_deep_research.database.session_context.get_user_db_session", side_effect=fake_session, ), patch( # SettingsManager is also imported locally; patch at its source. "local_deep_research.settings.manager.SettingsManager", mock_settings_cls, ), patch( "local_deep_research.web.auth.decorators.db_manager" ) as mock_db_mgr, ): c = _authenticated_client(app, mock_db_mgr) response = c.post("/news/api/check-overdue") assert response.status_code == 200 data = response.get_json() assert data["status"] == "success" assert data["overdue_found"] == 0 assert data["started"] == 0 assert data["results"] == [] # --------------------------------------------------------------------------- # 15. check_overdue — mix of success and failure results # --------------------------------------------------------------------------- class TestCheckOverdueSuccessAndFailureMix: """Some subscriptions succeed (research starts) and some fail (HTTP error).""" def test_check_overdue_success_and_failure_mix(self, app): sub_ok = MagicMock() sub_ok.id = "uuid-ok" sub_ok.name = "Good Sub" sub_ok.query_or_topic = "climate YYYY-MM-DD" sub_ok.model_provider = "OLLAMA" sub_ok.model = "llama3" sub_ok.search_strategy = "news_aggregation" sub_ok.search_engine = None sub_ok.custom_endpoint = None sub_ok.refresh_interval_minutes = 60 sub_fail = MagicMock() sub_fail.id = "uuid-fail" sub_fail.name = "Bad Sub" sub_fail.query_or_topic = "politics YYYY-MM-DD" sub_fail.model_provider = "OLLAMA" sub_fail.model = "llama3" sub_fail.search_strategy = "news_aggregation" sub_fail.search_engine = None sub_fail.custom_endpoint = None sub_fail.refresh_interval_minutes = 60 mock_db = MagicMock() mock_db.query.return_value.filter.return_value.all.return_value = [ sub_ok, sub_fail, ] @contextmanager def fake_session(*a, **kw): yield mock_db # start_research result for sub_ok: research started successfully ok_result = { "status": "success", "research_id": "rid-ok", } # start_research result for sub_fail: research returned an error body fail_result = { "status": "error", "message": "model not found", } mock_settings_cls = MagicMock() mock_settings_cls.return_value.get_setting.return_value = "UTC" with ( _fake_news_core_utils("2030-01-01"), patch( "local_deep_research.database.session_context.get_user_db_session", side_effect=fake_session, ), patch( "local_deep_research.news.flask_api._call_start_research_internal", side_effect=[ok_result, fail_result], ), patch( "local_deep_research.settings.manager.SettingsManager", mock_settings_cls, ), patch( "local_deep_research.web.auth.decorators.db_manager" ) as mock_db_mgr, ): c = _authenticated_client(app, mock_db_mgr) response = c.post("/news/api/check-overdue") assert response.status_code == 200 data = response.get_json() assert data["status"] == "success" assert data["overdue_found"] == 2 assert data["started"] == 1 # only sub_ok succeeded by_id = {r["id"]: r for r in data["results"]} assert "uuid-ok" in by_id assert "uuid-fail" in by_id # The failed sub surfaces start_research's "message" text, not a generic # fallback — guards the message-over-error extraction. assert by_id["uuid-fail"]["error"] == "model not found" assert by_id["uuid-ok"]["research_id"] == "rid-ok" ok_result = next(r for r in data["results"] if r["id"] == "uuid-ok") fail_result = next(r for r in data["results"] if r["id"] == "uuid-fail") assert ok_result.get("research_id") == "rid-ok" assert "error" in fail_result