""" Tests covering uncovered lines in news/flask_api.py. Targets: - scheduler_control_required decorator 403 path with session username logging - _is_job_owned_by_user() fallback via scheduler.user_sessions - run_subscription_now() subscription lookup, config extraction, response paths - check_subscriptions_now() overdue query + threading - trigger_cleanup() APScheduler job scheduling - update_subscription_folder() dynamic field update + next_refresh recalculation - get_search_history() / clear_search_history() unauthenticated return paths - add_search_history() data validation """ import pytest from unittest.mock import MagicMock, patch from flask import Flask, jsonify @pytest.fixture def app(): """Create a Flask app with the news blueprint registered.""" app = Flask(__name__) app.config["SECRET_KEY"] = "test-secret-key" app.config["WTF_CSRF_ENABLED"] = False app.config["TESTING"] = True from local_deep_research.news.flask_api import news_api_bp app.register_blueprint(news_api_bp, url_prefix="/news/api") return app @pytest.fixture def client(app): """Create a test client.""" return app.test_client() def _auth_session(client, username="testuser"): """Inject a valid session so login_required passes.""" with client.session_transaction() as sess: sess["username"] = username def _session_ctx_returning(sub): """Build a mocked get_user_db_session() context manager whose ``query(...).filter(...).first()`` yields ``sub`` (a real NewsSubscription or None). run_subscription_now reads the ORM row directly, so tests supply a real model instance rather than the trimmed api.get_subscriptions dict. """ session = MagicMock() session.query.return_value.filter.return_value.first.return_value = sub ctx = MagicMock() ctx.__enter__ = MagicMock(return_value=session) ctx.__exit__ = MagicMock(return_value=False) return ctx class TestCallStartResearchInternal: """Cover _call_start_research_internal: the in-process replacement for the old loopback HTTP POST to /research/api/start. The loopback could never pass CSRF (only the api_v1 blueprint is exempt), so both run_subscription_now and check_overdue_subscriptions were broken. Calling the route handler directly fixes that, but the nested request context MUST carry the caller's full auth context: start_research() resolves the user's DB password from session["session_id"] (-> password store) or g.user_password. Propagating only session["username"] makes research fail with "session expired" on encrypted databases — this test guards that regression. """ def test_propagates_session_and_db_to_research_handler(self, app): from local_deep_research.news.flask_api import ( _call_start_research_internal, ) captured = {} def _spy_start_research(): from flask import g as gg, request as rq from flask import session as ss captured["session"] = dict(ss) captured["db_session"] = getattr(gg, "db_session", None) captured["user_password"] = getattr(gg, "user_password", None) captured["payload"] = rq.get_json() return jsonify({"status": "success", "research_id": "spy_res"}) sentinel_db = object() with app.test_request_context(): from flask import g, session # Authenticated caller context: username AND session_id (the key # the DB-password store is keyed by) plus g state. session["username"] = "alice" session["session_id"] = "sid-abc123" g.db_session = sentinel_db g.user_password = "hunter2" # gitleaks:allow with patch( "local_deep_research.web.routes.research_routes.start_research", _spy_start_research, ): result = _call_start_research_internal({"query": "hello"}) # Returns the handler's JSON body as a dict. assert result == {"status": "success", "research_id": "spy_res"} # The request payload reached the handler. assert captured["payload"] == {"query": "hello"} # Critically: session_id is propagated (not just username), so # resolve_user_password() can find the password. The PR this supersedes # copied only username and would fail this assertion. assert captured["session"]["username"] == "alice" assert captured["session"]["session_id"] == "sid-abc123" # The handler sees the caller's db session and password. Note these # arrive via the shared app-context ``g`` (the nested request context # reuses it), so these two assertions document the contract rather than # exercise the helper's explicit (defensive) g-copying. The session_id # assertion above is the one that fails against the username-only bug. assert captured["db_session"] is sentinel_db assert captured["user_password"] == "hunter2" # gitleaks:allow def test_handles_tuple_response_with_status_code(self, app): """start_research may return (Response, status_code); the helper must unwrap the body either way.""" from local_deep_research.news.flask_api import ( _call_start_research_internal, ) def _spy_start_research(): return jsonify({"status": "error", "message": "boom"}), 400 with app.test_request_context(): from flask import session session["username"] = "alice" session["session_id"] = "sid-xyz" with patch( "local_deep_research.web.routes.research_routes.start_research", _spy_start_research, ): result = _call_start_research_internal({"query": "q"}) assert result == {"status": "error", "message": "boom"} class TestSchedulerControlRequiredLogging: """Cover the 403 branch that reads session username and remote_addr.""" def test_403_body_includes_session_username_in_log(self, app): """When blocked, the decorator reads session['username'] for logging.""" from local_deep_research.news.flask_api import ( scheduler_control_required, ) with app.test_request_context(): # Put a username in the Flask session so the decorator can read it from flask import session as flask_session flask_session["username"] = "alice" with patch( "local_deep_research.news.flask_api.get_env_setting", return_value=False, ): @scheduler_control_required def dummy(): return jsonify({"ok": True}), 200 response, status = dummy() assert status == 403 data = response.get_json() assert "disabled" in data["error"].lower() class TestIsJobOwnedByUser: """Cover _is_job_owned_by_user fallback through scheduler.user_sessions.""" def test_primary_match_via_job_args(self): """Job is owned when job.args[0] matches the username.""" from local_deep_research.news.flask_api import _is_job_owned_by_user job = MagicMock() job.args = ("alice", 42) scheduler = MagicMock(spec=[]) # no user_sessions attribute assert _is_job_owned_by_user(job, "alice", scheduler) is True def test_fallback_match_via_user_sessions(self): """Job is owned when its id appears in scheduler.user_sessions.""" from local_deep_research.news.flask_api import _is_job_owned_by_user job = MagicMock() job.args = ("other_user",) # primary check fails job.id = "job_123" scheduler = MagicMock() scheduler.user_sessions = { "alice": {"scheduled_jobs": {"job_123", "job_456"}}, } assert _is_job_owned_by_user(job, "alice", scheduler) is True def test_no_match_returns_false(self): """Returns False when neither primary nor fallback matches.""" from local_deep_research.news.flask_api import _is_job_owned_by_user job = MagicMock() job.args = ("other_user",) job.id = "job_999" scheduler = MagicMock() scheduler.user_sessions = { "alice": {"scheduled_jobs": {"job_123"}}, } assert _is_job_owned_by_user(job, "alice", scheduler) is False class TestRunSubscriptionNow: """Cover run_subscription_now route: subscription lookup, config, responses.""" def _setup_auth(self, client): _auth_session(client) def test_subscription_not_found_returns_404(self, client): """Returns 404 when the subscription id is not in the user's DB.""" self._setup_auth(client) with ( patch( "local_deep_research.web.auth.decorators.db_manager", ) as mock_db, patch( "local_deep_research.database.session_context.get_user_db_session", return_value=_session_ctx_returning(None), ), patch( "local_deep_research.news.core.utils.get_local_date_string", return_value="2026-03-20", ), patch("local_deep_research.settings.manager.SettingsManager"), ): mock_db.is_user_connected.return_value = True resp = client.post("/news/api/subscriptions/nonexistent/run") assert resp.status_code == 404 assert "not found" in resp.get_json()["error"].lower() def test_successful_run_returns_research_id(self, client, app): """Successful run returns status=success and a research_id.""" self._setup_auth(client) from local_deep_research.database.models.news import NewsSubscription mock_response = { "status": "success", "research_id": "res_42", } sub = NewsSubscription( id="sub_1", query_or_topic="AI news YYYY-MM-DD", subscription_type="topic", model_provider="OPENAI", model="gpt-4", search_strategy="news_aggregation", name="AI Digest", refresh_interval_minutes=60, ) with ( patch( "local_deep_research.web.auth.decorators.db_manager", ) as mock_db, patch( "local_deep_research.database.session_context.get_user_db_session", return_value=_session_ctx_returning(sub), ), patch( "local_deep_research.news.flask_api._call_start_research_internal", return_value=mock_response, ), patch( "local_deep_research.news.core.utils.get_local_date_string", return_value="2026-03-20", ), patch( "local_deep_research.settings.manager.SettingsManager", ), ): mock_db.is_user_connected.return_value = True resp = client.post("/news/api/subscriptions/sub_1/run") assert resp.status_code == 200 data = resp.get_json() assert data["status"] == "success" assert data["research_id"] == "res_42" def test_unset_model_config_falls_back_to_user_settings(self, client): """A subscription without model_provider/model must NOT force "ollama"/"llama3" onto the research request. The values are sent unset (None) so start_research falls back to the user's configured llm.provider / llm.model. Regression test: hardcoding the defaults here overrode the LLM for any subscription created without an explicit model. """ self._setup_auth(client) from local_deep_research.database.models.news import NewsSubscription mock_response = { "status": "success", "research_id": "res_99", } # Subscription with no saved model_provider/model. sub = NewsSubscription( id="sub_1", query_or_topic="AI news", subscription_type="topic", name="AI Digest", refresh_interval_minutes=60, ) with ( patch( "local_deep_research.web.auth.decorators.db_manager", ) as mock_db, patch( "local_deep_research.database.session_context.get_user_db_session", return_value=_session_ctx_returning(sub), ), patch( "local_deep_research.news.flask_api._call_start_research_internal", return_value=mock_response, ) as mock_post, patch( "local_deep_research.news.core.utils.get_local_date_string", return_value="2026-03-20", ), patch( "local_deep_research.settings.manager.SettingsManager", ), ): mock_db.is_user_connected.return_value = True resp = client.post("/news/api/subscriptions/sub_1/run") assert resp.status_code == 200 sent = mock_post.call_args.args[0] # Unset → falsy, so the backend resolves from user settings. assert not sent["model_provider"] assert not sent["model"] assert sent["model_provider"] != "ollama" assert sent["model"] != "llama3" def test_explicit_model_config_is_passed_through(self, client): """When the subscription carries an explicit provider/model, those exact values reach the research request unchanged.""" self._setup_auth(client) from local_deep_research.database.models.news import NewsSubscription mock_response = { "status": "success", "research_id": "res_100", } sub = NewsSubscription( id="sub_1", query_or_topic="AI news", subscription_type="topic", model_provider="openai", model="gpt-4o", name="AI Digest", refresh_interval_minutes=60, ) with ( patch( "local_deep_research.web.auth.decorators.db_manager", ) as mock_db, patch( "local_deep_research.database.session_context.get_user_db_session", return_value=_session_ctx_returning(sub), ), patch( "local_deep_research.news.flask_api._call_start_research_internal", return_value=mock_response, ) as mock_post, patch( "local_deep_research.news.core.utils.get_local_date_string", return_value="2026-03-20", ), patch( "local_deep_research.settings.manager.SettingsManager", ), ): mock_db.is_user_connected.return_value = True resp = client.post("/news/api/subscriptions/sub_1/run") assert resp.status_code == 200 sent = mock_post.call_args.args[0] assert sent["model_provider"] == "openai" assert sent["model"] == "gpt-4o" def test_failed_response_returns_error(self, client): """An error status from start_research surfaces as a 500 with the error message (the in-process call has no HTTP status code to relay).""" self._setup_auth(client) from local_deep_research.database.models.news import NewsSubscription mock_response = { "status": "error", "message": "Service unavailable", } sub = NewsSubscription( id="sub_1", query_or_topic="test", subscription_type="topic", refresh_interval_minutes=60, ) with ( patch( "local_deep_research.web.auth.decorators.db_manager", ) as mock_db, patch( "local_deep_research.database.session_context.get_user_db_session", return_value=_session_ctx_returning(sub), ), patch( "local_deep_research.news.flask_api._call_start_research_internal", return_value=mock_response, ), patch( "local_deep_research.news.core.utils.get_local_date_string", return_value="2026-03-20", ), patch( "local_deep_research.settings.manager.SettingsManager", ), ): mock_db.is_user_connected.return_value = True resp = client.post("/news/api/subscriptions/sub_1/run") assert resp.status_code == 500 # The actual start_research message must surface (it reports failures # under "message"), not a generic fallback — guards the # message-over-error extraction. assert resp.get_json()["error"] == "Service unavailable" def test_helper_exception_propagates_as_500(self, client): """If _call_start_research_internal raises (e.g. start_research blows up), run_subscription_now's outer handler returns a 500 rather than leaking the traceback. The old safe_post path converted HTTP failures to error dicts; the in-process call can raise, so this path matters.""" self._setup_auth(client) from local_deep_research.database.models.news import NewsSubscription sub = NewsSubscription( id="sub_1", query_or_topic="test", subscription_type="topic", refresh_interval_minutes=60, ) with ( patch( "local_deep_research.web.auth.decorators.db_manager", ) as mock_db, patch( "local_deep_research.database.session_context.get_user_db_session", return_value=_session_ctx_returning(sub), ), patch( "local_deep_research.news.flask_api._call_start_research_internal", side_effect=RuntimeError("boom"), ), patch( "local_deep_research.news.core.utils.get_local_date_string", return_value="2026-03-20", ), patch("local_deep_research.settings.manager.SettingsManager"), ): mock_db.is_user_connected.return_value = True resp = client.post("/news/api/subscriptions/sub_1/run") assert resp.status_code == 500 assert "error" in resp.get_json() def test_successful_run_advances_refresh_schedule(self, client): """A successful run advances the subscription's refresh schedule. Regression guard for the PR's headline behavior: without the advance_refresh_schedule() call, a manually-run subscription that was also overdue would be immediately re-run by the scheduler. Deleting the advance must fail this test. """ from datetime import datetime, timedelta, timezone from local_deep_research.database.models.news import NewsSubscription self._setup_auth(client) mock_response = { "status": "success", "research_id": "res_adv", } sub = NewsSubscription( id="sub_1", query_or_topic="AI news", subscription_type="topic", refresh_interval_minutes=60, last_refresh=None, next_refresh=None, ) with ( patch( "local_deep_research.web.auth.decorators.db_manager", ) as mock_db, patch( "local_deep_research.database.session_context.get_user_db_session", return_value=_session_ctx_returning(sub), ), patch( "local_deep_research.news.flask_api._call_start_research_internal", return_value=mock_response, ), patch( "local_deep_research.news.core.utils.get_local_date_string", return_value="2026-03-20", ), patch("local_deep_research.settings.manager.SettingsManager"), ): mock_db.is_user_connected.return_value = True before = datetime.now(timezone.utc) resp = client.post("/news/api/subscriptions/sub_1/run") after = datetime.now(timezone.utc) assert resp.status_code == 200 assert sub.last_refresh is not None, "schedule was not advanced" assert before <= sub.last_refresh <= after assert sub.next_refresh == sub.last_refresh + timedelta(minutes=60) def test_advance_skipped_when_failure_already_reset(self, client): """Compare-and-set guard: if a fast-failing run already reset next_refresh (worker thread) between the pre-spawn read and the post-POST advance, the advance must NOT clobber it and re-hide the failed subscription. Regression guard for the race in fix #1. """ from datetime import datetime, timezone from local_deep_research.database.models.news import NewsSubscription self._setup_auth(client) t0 = datetime(2026, 6, 10, 12, 0, tzinfo=timezone.utc) # Worker's failure handler reset next_refresh to a different value # before we reopen to advance. t1 = datetime(2026, 6, 10, 12, 5, tzinfo=timezone.utc) sub_read = NewsSubscription( id="sub_1", query_or_topic="q", subscription_type="topic", refresh_interval_minutes=60, next_refresh=t0, ) sub_after = NewsSubscription( id="sub_1", query_or_topic="q", subscription_type="topic", refresh_interval_minutes=60, next_refresh=t1, last_refresh=None, ) session = MagicMock() session.query.return_value.filter.return_value.first.side_effect = [ sub_read, sub_after, ] ctx = MagicMock() ctx.__enter__ = MagicMock(return_value=session) ctx.__exit__ = MagicMock(return_value=False) mock_response = { "status": "success", "research_id": "res_x", } with ( patch( "local_deep_research.web.auth.decorators.db_manager", ) as mock_db, patch( "local_deep_research.database.session_context.get_user_db_session", return_value=ctx, ), patch( "local_deep_research.news.flask_api._call_start_research_internal", return_value=mock_response, ), patch( "local_deep_research.news.core.utils.get_local_date_string", return_value="2026-06-10", ), patch("local_deep_research.settings.manager.SettingsManager"), ): mock_db.is_user_connected.return_value = True resp = client.post("/news/api/subscriptions/sub_1/run") assert resp.status_code == 200 # CAS skipped the advance: the worker's reset (t1) is preserved. assert sub_after.last_refresh is None, "advance clobbered the reset" assert sub_after.next_refresh == t1 class TestCheckOverdueSubscriptions: """Cover check_overdue_subscriptions: the per-overdue-sub run path builds request_data from the ORM object directly.""" def _make_sub(self, **overrides): from types import SimpleNamespace defaults = dict( id="sub_overdue_1", name="Overdue Digest", query_or_topic="AI news", model_provider=None, model=None, search_strategy=None, search_engine=None, custom_endpoint=None, refresh_interval_minutes=60, last_refresh=None, next_refresh=None, ) defaults.update(overrides) return SimpleNamespace(**defaults) def _run(self, client, sub): """POST /check-overdue with the DB query returning [sub]; return the request_data dict sent to the research backend.""" mock_response = { "status": "success", "research_id": "res_overdue", } db_mock = MagicMock() db_mock.query.return_value.filter.return_value.all.return_value = [sub] mock_ctx_mgr = MagicMock() mock_ctx_mgr.__enter__ = MagicMock(return_value=db_mock) mock_ctx_mgr.__exit__ = MagicMock(return_value=False) with ( patch( "local_deep_research.web.auth.decorators.db_manager", ) as mock_db, patch( "local_deep_research.database.session_context.get_user_db_session", return_value=mock_ctx_mgr, ), patch( "local_deep_research.news.flask_api._call_start_research_internal", return_value=mock_response, ) as mock_post, patch( "local_deep_research.news.core.utils.get_local_date_string", return_value="2026-03-20", ), patch( "local_deep_research.settings.manager.SettingsManager", ), ): mock_db.is_user_connected.return_value = True resp = client.post("/news/api/check-overdue") assert resp.status_code == 200, resp.get_json() return mock_post.call_args.args[0] def test_unset_model_config_falls_back_to_user_settings(self, client): """Overdue sub without provider/model must not force ollama/llama3.""" _auth_session(client) sent = self._run(client, self._make_sub()) assert not sent["model_provider"] assert not sent["model"] assert sent["model_provider"] != "ollama" assert sent["model"] != "llama3" def test_explicit_model_config_is_passed_through(self, client): """An overdue sub's explicit provider/model reach the request.""" _auth_session(client) sent = self._run( client, self._make_sub(model_provider="anthropic", model="claude-3"), ) assert sent["model_provider"] == "anthropic" assert sent["model"] == "claude-3" def test_successful_overdue_run_advances_schedule(self, client): """The overdue sweep advances each run subscription's schedule. Regression guard: without advance_refresh_schedule() the same overdue subscription would be picked up and re-run on every sweep. """ from datetime import timedelta _auth_session(client) sub = self._make_sub(refresh_interval_minutes=60) self._run(client, sub) assert sub.last_refresh is not None, "schedule was not advanced" assert sub.next_refresh == sub.last_refresh + timedelta(minutes=60) def test_overdue_run_skips_advance_when_reset_during_run(self, client): """Compare-and-set in the sweep: if a fast-failing run already reset next_refresh (worker thread, observed via db.refresh), the advance is skipped so the reset is not clobbered. Mirrors the run-now CAS guard. """ from datetime import datetime, timezone _auth_session(client) t0 = datetime(2026, 6, 10, 12, 0, tzinfo=timezone.utc) t1 = datetime(2026, 6, 10, 12, 5, tzinfo=timezone.utc) # worker reset sub = self._make_sub(next_refresh=t0, last_refresh=None) mock_response = { "status": "success", "research_id": "res_overdue", } db_mock = MagicMock() db_mock.query.return_value.filter.return_value.all.return_value = [sub] # Simulate the worker's failure reset becoming visible on db.refresh(). def _refresh(obj): obj.next_refresh = t1 db_mock.refresh.side_effect = _refresh mock_ctx_mgr = MagicMock() mock_ctx_mgr.__enter__ = MagicMock(return_value=db_mock) mock_ctx_mgr.__exit__ = MagicMock(return_value=False) with ( patch( "local_deep_research.web.auth.decorators.db_manager", ) as mock_db, patch( "local_deep_research.database.session_context.get_user_db_session", return_value=mock_ctx_mgr, ), patch( "local_deep_research.news.flask_api._call_start_research_internal", return_value=mock_response, ), patch( "local_deep_research.news.core.utils.get_local_date_string", return_value="2026-03-20", ), patch("local_deep_research.settings.manager.SettingsManager"), ): mock_db.is_user_connected.return_value = True resp = client.post("/news/api/check-overdue") assert resp.status_code == 200, resp.get_json() # CAS skipped the advance: the worker's reset (t1) is preserved. assert sub.last_refresh is None, "advance clobbered the reset" assert sub.next_refresh == t1 def test_failed_sub_rolls_back_and_sweep_continues(self, client): """A failing subscription (error result OR raised exception) must roll back the shared session and let the remaining overdue subs run. Regression guard: the loop shares one DB session with start_research, whose error path does not roll back. Without the per-iteration db.rollback() recovery, a PendingRollbackError would propagate while re-reading the expired sub and collapse the entire sweep into a 500, discarding all partial results. """ _auth_session(client) sub_err = self._make_sub(id="sub_err", name="Returns error") sub_raise = self._make_sub(id="sub_raise", name="Raises") sub_ok = self._make_sub(id="sub_ok", name="Succeeds") db_mock = MagicMock() db_mock.query.return_value.filter.return_value.all.return_value = [ sub_err, sub_raise, sub_ok, ] mock_ctx_mgr = MagicMock() mock_ctx_mgr.__enter__ = MagicMock(return_value=db_mock) mock_ctx_mgr.__exit__ = MagicMock(return_value=False) with ( patch( "local_deep_research.web.auth.decorators.db_manager", ) as mock_db, patch( "local_deep_research.database.session_context.get_user_db_session", return_value=mock_ctx_mgr, ), patch( "local_deep_research.news.flask_api._call_start_research_internal", side_effect=[ {"status": "error", "message": "kaboom"}, RuntimeError("explode"), {"status": "success", "research_id": "rid-ok"}, ], ), patch( "local_deep_research.news.core.utils.get_local_date_string", return_value="2026-03-20", ), patch("local_deep_research.settings.manager.SettingsManager"), ): mock_db.is_user_connected.return_value = True resp = client.post("/news/api/check-overdue") assert resp.status_code == 200, resp.get_json() data = resp.get_json() assert data["overdue_found"] == 3 assert data["started"] == 1 # Both failure branches recovered the session via rollback (>= 2 calls). assert db_mock.rollback.call_count >= 2 by_id = {r["id"]: r for r in data["results"]} assert by_id["sub_err"]["error"] == "kaboom" assert "error" in by_id["sub_raise"] assert by_id["sub_ok"]["research_id"] == "rid-ok" class TestUpdateSubscriptionFolderStatus: """Cover update_subscription_folder: the is_active->status translation (so the PUT route keeps status authoritative) and the response serialization (NewsSubscription has no to_dict).""" def _ctx_for(self, sub): """get_user_db_session() context whose query(...).filter_by(...).first() yields ``sub`` (this route looks the row up via filter_by).""" session = MagicMock() session.query.return_value.filter_by.return_value.first.return_value = ( sub ) ctx = MagicMock() ctx.__enter__ = MagicMock(return_value=session) ctx.__exit__ = MagicMock(return_value=False) return ctx def _put(self, client, sub, body): with ( patch( "local_deep_research.web.auth.decorators.db_manager", ) as mock_db, patch( "local_deep_research.news.flask_api.get_user_db_session", return_value=self._ctx_for(sub), ), ): mock_db.is_user_connected.return_value = True return client.put( "/news/api/subscription/subscriptions/sub_1", json=body ) def test_is_active_false_translates_to_paused_status(self, client): """A {"is_active": false} body must pause via status, not just flip the legacy column -- otherwise the status-keyed scheduler keeps running it. """ from local_deep_research.database.models.news import NewsSubscription _auth_session(client) sub = NewsSubscription( id="sub_1", query_or_topic="q", subscription_type="topic", status="active", refresh_interval_minutes=60, ) resp = self._put(client, sub, {"is_active": False}) assert resp.status_code == 200, resp.get_json() assert sub.status == "paused" body = resp.get_json() assert body["status"] == "paused" assert body["is_active"] is False def test_is_active_true_translates_to_active_status(self, client): from local_deep_research.database.models.news import NewsSubscription _auth_session(client) sub = NewsSubscription( id="sub_1", query_or_topic="q", subscription_type="topic", status="paused", refresh_interval_minutes=60, ) resp = self._put(client, sub, {"is_active": True}) assert resp.status_code == 200, resp.get_json() assert sub.status == "active" assert resp.get_json()["is_active"] is True def test_plain_field_update_returns_200_not_500(self, client): """A non-status field update must serialize a response without raising (NewsSubscription has no to_dict; the route builds the dict explicitly). """ from local_deep_research.database.models.news import NewsSubscription _auth_session(client) sub = NewsSubscription( id="sub_1", query_or_topic="q", subscription_type="topic", status="active", refresh_interval_minutes=60, ) resp = self._put(client, sub, {"folder_id": "folder_9"}) assert resp.status_code == 200, resp.get_json() assert sub.folder_id == "folder_9" assert resp.get_json()["id"] == "sub_1" class TestCheckSubscriptionsNow: """Cover check_subscriptions_now: DB query for overdue subs + threading.""" def test_scheduler_not_initialized_returns_503(self, client, app): """Returns 503 when news_scheduler is missing from current_app.""" _auth_session(client) with ( patch( "local_deep_research.web.auth.decorators.db_manager", ) as mock_db, patch( "local_deep_research.news.flask_api.get_env_setting", return_value=True, ), ): mock_db.is_user_connected.return_value = True # Ensure current_app has no background_job_scheduler if hasattr(app, "background_job_scheduler"): delattr(app, "background_job_scheduler") resp = client.post("/news/api/scheduler/check-now") assert resp.status_code == 503 def test_scheduler_not_running_returns_503(self, client, app): """Returns 503 when scheduler exists but is_running is False.""" _auth_session(client) app.background_job_scheduler = MagicMock() app.background_job_scheduler.is_running = False with ( patch( "local_deep_research.web.auth.decorators.db_manager", ) as mock_db, patch( "local_deep_research.news.flask_api.get_env_setting", return_value=True, ), ): mock_db.is_user_connected.return_value = True resp = client.post("/news/api/scheduler/check-now") assert resp.status_code == 503 # Clean up delattr(app, "background_job_scheduler") class TestTriggerCleanup: """Cover trigger_cleanup: scheduler.add_job scheduling.""" def test_cleanup_not_running_returns_400(self, client): """Returns 400 when scheduler is not running.""" _auth_session(client) mock_scheduler = MagicMock() mock_scheduler.is_running = False with ( patch( "local_deep_research.web.auth.decorators.db_manager", ) as mock_db, patch( "local_deep_research.news.flask_api.get_env_setting", return_value=True, ), patch( "local_deep_research.news.flask_api.get_background_job_scheduler", return_value=mock_scheduler, create=True, ), patch( "local_deep_research.scheduler.background.get_background_job_scheduler", return_value=mock_scheduler, ), ): mock_db.is_user_connected.return_value = True resp = client.post("/news/api/scheduler/cleanup-now") assert resp.status_code == 400 assert "not running" in resp.get_json()["error"].lower() def test_cleanup_triggered_returns_success(self, client): """Returns triggered status when scheduler is running.""" _auth_session(client) mock_scheduler = MagicMock() mock_scheduler.is_running = True mock_scheduler.scheduler = MagicMock() with ( patch( "local_deep_research.web.auth.decorators.db_manager", ) as mock_db, patch( "local_deep_research.news.flask_api.get_env_setting", return_value=True, ), patch( "local_deep_research.scheduler.background.get_background_job_scheduler", return_value=mock_scheduler, ), ): mock_db.is_user_connected.return_value = True resp = client.post("/news/api/scheduler/cleanup-now") assert resp.status_code == 200 data = resp.get_json() assert data["status"] == "triggered" # Verify add_job was called mock_scheduler.scheduler.add_job.assert_called_once() class TestSearchHistoryUnauthenticated: """Cover unauthenticated return paths in search history endpoints.""" def test_get_search_history_unauthenticated_returns_empty(self, client): """GET /search-history returns empty list when current_user is None.""" _auth_session(client) with ( patch( "local_deep_research.web.auth.decorators.db_manager", ) as mock_db, patch( "local_deep_research.news.flask_api.current_user", return_value=None, create=True, ), patch( "local_deep_research.web.auth.decorators.current_user", return_value=None, create=True, ), ): mock_db.is_user_connected.return_value = True resp = client.get("/news/api/search-history") assert resp.status_code == 200 assert resp.get_json()["search_history"] == [] def test_clear_search_history_unauthenticated_returns_success(self, client): """DELETE /search-history returns success when current_user is None.""" _auth_session(client) with ( patch( "local_deep_research.web.auth.decorators.db_manager", ) as mock_db, patch( "local_deep_research.news.flask_api.current_user", return_value=None, create=True, ), patch( "local_deep_research.web.auth.decorators.current_user", return_value=None, create=True, ), ): mock_db.is_user_connected.return_value = True resp = client.delete("/news/api/search-history") assert resp.status_code == 200 assert resp.get_json()["status"] == "success" def test_add_search_history_missing_query_returns_400(self, client): """POST /search-history returns 400 when query field is missing.""" _auth_session(client) with ( patch( "local_deep_research.web.auth.decorators.db_manager", ) as mock_db, patch( "local_deep_research.web.auth.decorators.current_user", return_value="testuser", ), ): mock_db.is_user_connected.return_value = True resp = client.post( "/news/api/search-history", json={"type": "filter"}, content_type="application/json", ) assert resp.status_code == 400 assert "query" in resp.get_json()["error"].lower() def test_add_search_history_unauthenticated_returns_401(self, client): """POST /search-history returns 401 when current_user is None.""" _auth_session(client) with ( patch( "local_deep_research.web.auth.decorators.db_manager", ) as mock_db, patch( "local_deep_research.news.flask_api.current_user", return_value=None, create=True, ), patch( "local_deep_research.web.auth.decorators.current_user", return_value=None, create=True, ), ): mock_db.is_user_connected.return_value = True resp = client.post( "/news/api/search-history", json={"query": "test"}, content_type="application/json", ) assert resp.status_code == 401