Files
learningcircuit--local-deep…/tests/news/test_flask_api_scheduler_coverage.py
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

724 lines
25 KiB
Python

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