Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

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