7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
1153 lines
42 KiB
Python
1153 lines
42 KiB
Python
"""
|
|
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
|