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
724 lines
25 KiB
Python
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
|