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
1307 lines
45 KiB
Python
1307 lines
45 KiB
Python
"""
|
|
Coverage tests for news/subscription_manager/scheduler.py
|
|
|
|
Targets the ~132 missing statements (77% -> higher coverage).
|
|
Focuses on:
|
|
- DocumentSchedulerSettings dataclass paths
|
|
- Settings cache (hit, miss, invalidation, error paths)
|
|
- start/stop lifecycle
|
|
- _schedule_user_subscriptions edge cases (naive datetime, no next_refresh, JobLookupError)
|
|
- _schedule_document_processing (existing job removal, job verification failure)
|
|
- _process_user_documents full flow (download_pdfs, extract_text, generate_rag paths)
|
|
- _check_subscription with DateTrigger reschedule
|
|
- _trigger_subscription_research_sync with/without search_engine
|
|
- _store_research_result edge cases (sources formatting, headline fallbacks, make_serializable)
|
|
- _reload_config with exception path
|
|
- get_document_scheduler_status exception path
|
|
- trigger_document_processing exception and job-verify-fail paths
|
|
- initialize_with_settings (success + exception)
|
|
- SettingsContext inner class
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import Mock, MagicMock, patch
|
|
from datetime import datetime, timedelta, UTC
|
|
|
|
|
|
# Commonly used patch targets (local imports in scheduler.py)
|
|
DB_SESSION = "local_deep_research.database.session_context.get_user_db_session"
|
|
SETTINGS_MGR = "local_deep_research.settings.manager.SettingsManager"
|
|
DOWNLOAD_SVC = "local_deep_research.research_library.services.download_service.DownloadService"
|
|
IS_DOWNLOADABLE = (
|
|
"local_deep_research.research_library.utils.is_downloadable_url"
|
|
)
|
|
QUICK_SUMMARY = "local_deep_research.api.research_functions.quick_summary"
|
|
SET_CTX = "local_deep_research.config.thread_settings.set_settings_context"
|
|
HEADLINE_GEN = (
|
|
"local_deep_research.news.utils.headline_generator.generate_headline"
|
|
)
|
|
TOPIC_GEN = "local_deep_research.news.utils.topic_generator.generate_topics"
|
|
REPORT_STORAGE = "local_deep_research.storage.get_report_storage"
|
|
FORMAT_LINKS = (
|
|
"local_deep_research.utilities.search_utilities.format_links_to_markdown"
|
|
)
|
|
CITATION_FMT = (
|
|
"local_deep_research.text_optimization.citation_formatter.CitationFormatter"
|
|
)
|
|
CITATION_MODE = (
|
|
"local_deep_research.text_optimization.citation_formatter.CitationMode"
|
|
)
|
|
GET_SETTING_SNAP = (
|
|
"local_deep_research.config.search_config.get_setting_from_snapshot"
|
|
)
|
|
DATE_STRING = "local_deep_research.news.core.utils.get_local_date_string"
|
|
|
|
# Top-level imports in scheduler.py.
|
|
# NOTE: RAG indexing moved out of _process_user_documents into the unified
|
|
# _reconcile_unindexed_documents reconciler (covered in tests/news/
|
|
# test_library_sweep.py), so this module no longer patches LibraryRAGService /
|
|
# get_default_library_id.
|
|
SCHED_MOD = "local_deep_research.scheduler.background"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_singleton():
|
|
"""Reset BackgroundJobScheduler singleton before each test."""
|
|
from local_deep_research.scheduler.background import (
|
|
BackgroundJobScheduler,
|
|
)
|
|
import local_deep_research.scheduler.background as mod
|
|
|
|
BackgroundJobScheduler._instance = None
|
|
mod._scheduler_instance = None
|
|
yield
|
|
BackgroundJobScheduler._instance = None
|
|
mod._scheduler_instance = None
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_bg():
|
|
"""Mock BackgroundScheduler globally."""
|
|
with patch(f"{SCHED_MOD}.BackgroundScheduler") as cls:
|
|
inst = MagicMock()
|
|
cls.return_value = inst
|
|
yield inst
|
|
|
|
|
|
@pytest.fixture
|
|
def sched(mock_bg):
|
|
from local_deep_research.scheduler.background import (
|
|
BackgroundJobScheduler,
|
|
)
|
|
|
|
instance = BackgroundJobScheduler()
|
|
instance.set_app(MagicMock())
|
|
return instance
|
|
|
|
|
|
@pytest.fixture
|
|
def running(sched):
|
|
sched.is_running = True
|
|
return sched
|
|
|
|
|
|
def _make_session(jobs=None):
|
|
"""Create a session dict (password stored separately in credential store)."""
|
|
return {
|
|
"last_activity": datetime.now(UTC),
|
|
"scheduled_jobs": jobs if jobs is not None else set(),
|
|
}
|
|
|
|
|
|
def _setup_user(sched, username, password="pw", jobs=None):
|
|
"""Set up a user session with credentials in the credential store."""
|
|
sched.user_sessions[username] = _make_session(jobs=jobs)
|
|
sched._credential_store.store(username, password)
|
|
|
|
|
|
def _ctx(mock_session):
|
|
"""Make mock_session usable as a context manager."""
|
|
mock_session.__enter__ = Mock(return_value=mock_session)
|
|
mock_session.__exit__ = Mock(return_value=False)
|
|
return mock_session
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DocumentSchedulerSettings
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDocumentSchedulerSettings:
|
|
def test_defaults_returns_instance(self):
|
|
from local_deep_research.scheduler.background import (
|
|
DocumentSchedulerSettings,
|
|
)
|
|
|
|
d = DocumentSchedulerSettings.defaults()
|
|
assert d.enabled is True
|
|
assert d.interval_seconds == 1800
|
|
assert d.download_pdfs is False
|
|
assert d.extract_text is True
|
|
assert d.generate_rag is False
|
|
assert d.last_run == ""
|
|
|
|
def test_frozen_immutable(self):
|
|
from local_deep_research.scheduler.background import (
|
|
DocumentSchedulerSettings,
|
|
)
|
|
|
|
d = DocumentSchedulerSettings()
|
|
with pytest.raises(AttributeError):
|
|
d.enabled = False
|
|
|
|
def test_custom_values(self):
|
|
from local_deep_research.scheduler.background import (
|
|
DocumentSchedulerSettings,
|
|
)
|
|
|
|
d = DocumentSchedulerSettings(
|
|
enabled=False,
|
|
interval_seconds=900,
|
|
download_pdfs=True,
|
|
extract_text=False,
|
|
generate_rag=True,
|
|
last_run="2024-01-01",
|
|
)
|
|
assert d.enabled is False
|
|
assert d.interval_seconds == 900
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# initialize_with_settings
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestInitializeWithSettings:
|
|
def test_initialize_success(self, sched):
|
|
sm = MagicMock()
|
|
sm.get_setting.side_effect = lambda k, default: default
|
|
sched.initialize_with_settings(sm)
|
|
assert sched.settings_manager is sm
|
|
|
|
def test_initialize_exception_keeps_defaults(self, sched):
|
|
sm = MagicMock()
|
|
sm.get_setting.side_effect = RuntimeError("boom")
|
|
old_config = sched.config.copy()
|
|
sched.initialize_with_settings(sm)
|
|
assert sched.config == old_config
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _get_setting
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetSetting:
|
|
def test_with_settings_manager(self, sched):
|
|
sm = MagicMock()
|
|
sm.get_setting.return_value = 42
|
|
sched.settings_manager = sm
|
|
assert sched._get_setting("some.key", 10) == 42
|
|
|
|
def test_without_settings_manager(self, sched):
|
|
assert sched._get_setting("some.key", 10) == 10
|
|
|
|
def test_with_none_settings_manager(self, sched):
|
|
sched.settings_manager = None
|
|
assert sched._get_setting("k", 99) == 99
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Settings cache
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSettingsCache:
|
|
def test_cache_hit(self, sched):
|
|
from local_deep_research.scheduler.background import (
|
|
DocumentSchedulerSettings,
|
|
)
|
|
|
|
settings = DocumentSchedulerSettings(enabled=False)
|
|
sched._settings_cache["alice"] = settings
|
|
result = sched._get_document_scheduler_settings("alice")
|
|
assert result is settings
|
|
|
|
def test_cache_miss_no_session_returns_defaults(self, sched):
|
|
result = sched._get_document_scheduler_settings("nobody")
|
|
assert result.enabled is True
|
|
|
|
def test_cache_miss_fetches_from_db(self, sched):
|
|
_setup_user(sched, "bob")
|
|
mock_sm = MagicMock()
|
|
mock_sm.get_setting.side_effect = lambda k, default: {
|
|
"document_scheduler.enabled": True,
|
|
"document_scheduler.interval_seconds": 600,
|
|
"document_scheduler.download_pdfs": True,
|
|
"document_scheduler.extract_text": False,
|
|
"document_scheduler.generate_rag": True,
|
|
"document_scheduler.last_run": "2024-06-01",
|
|
}.get(k, default)
|
|
|
|
mock_db = _ctx(MagicMock())
|
|
|
|
with (
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch(SETTINGS_MGR, return_value=mock_sm),
|
|
):
|
|
result = sched._get_document_scheduler_settings("bob")
|
|
assert result.interval_seconds == 600
|
|
assert "bob" in sched._settings_cache
|
|
|
|
def test_cache_miss_db_error_returns_defaults(self, sched):
|
|
_setup_user(sched, "carol")
|
|
with patch(DB_SESSION, side_effect=RuntimeError("db down")):
|
|
result = sched._get_document_scheduler_settings("carol")
|
|
assert result.enabled is True
|
|
|
|
def test_force_refresh_bypasses_cache(self, sched):
|
|
from local_deep_research.scheduler.background import (
|
|
DocumentSchedulerSettings,
|
|
)
|
|
|
|
sched._settings_cache["dave"] = DocumentSchedulerSettings(enabled=False)
|
|
_setup_user(sched, "dave")
|
|
mock_sm = MagicMock()
|
|
mock_sm.get_setting.side_effect = lambda k, default: default
|
|
mock_db = _ctx(MagicMock())
|
|
with (
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch(SETTINGS_MGR, return_value=mock_sm),
|
|
):
|
|
result = sched._get_document_scheduler_settings(
|
|
"dave", force_refresh=True
|
|
)
|
|
assert result.enabled is True
|
|
|
|
def test_invalidate_user_found(self, sched):
|
|
from local_deep_research.scheduler.background import (
|
|
DocumentSchedulerSettings,
|
|
)
|
|
|
|
sched._settings_cache["eve"] = DocumentSchedulerSettings()
|
|
assert sched.invalidate_user_settings_cache("eve") is True
|
|
|
|
def test_invalidate_user_not_found(self, sched):
|
|
assert sched.invalidate_user_settings_cache("ghost") is False
|
|
|
|
def test_invalidate_all(self, sched):
|
|
from local_deep_research.scheduler.background import (
|
|
DocumentSchedulerSettings,
|
|
)
|
|
|
|
sched._settings_cache["a"] = DocumentSchedulerSettings()
|
|
sched._settings_cache["b"] = DocumentSchedulerSettings()
|
|
count = sched.invalidate_all_settings_cache()
|
|
assert count == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# start / stop
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestStartStop:
|
|
def test_start_disabled(self, sched, mock_bg):
|
|
sched.config["enabled"] = False
|
|
sched.start()
|
|
assert sched.is_running is False
|
|
|
|
def test_start_already_running(self, running, mock_bg):
|
|
mock_bg.start.reset_mock()
|
|
running.start()
|
|
mock_bg.start.assert_not_called()
|
|
|
|
def test_start_success(self, sched, mock_bg):
|
|
sched.start()
|
|
assert sched.is_running is True
|
|
mock_bg.start.assert_called_once()
|
|
assert mock_bg.add_job.call_count == 3
|
|
|
|
def test_stop(self, running, mock_bg):
|
|
_setup_user(running, "x")
|
|
running.stop()
|
|
assert running.is_running is False
|
|
assert len(running.user_sessions) == 0
|
|
|
|
def test_stop_when_not_running(self, sched, mock_bg):
|
|
sched.stop()
|
|
mock_bg.shutdown.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _schedule_user_subscriptions edge cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestScheduleUserSubscriptionsEdgeCases:
|
|
def test_naive_next_refresh_assumed_utc(self, sched, mock_bg):
|
|
_setup_user(sched, "u")
|
|
sub = MagicMock()
|
|
sub.id = 10
|
|
sub.name = "Naive"
|
|
sub.query_or_topic = "query"
|
|
sub.refresh_interval_minutes = 120
|
|
sub.next_refresh = datetime(2099, 1, 1) # naive, future
|
|
|
|
mock_db = _ctx(MagicMock())
|
|
mock_db.query.return_value.filter.return_value.all.return_value = [sub]
|
|
|
|
with (
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch.object(sched, "_schedule_document_processing"),
|
|
):
|
|
sched._schedule_user_subscriptions("u")
|
|
mock_bg.add_job.assert_called()
|
|
|
|
def test_no_next_refresh_uses_calculated(self, sched, mock_bg):
|
|
_setup_user(sched, "u")
|
|
sub = MagicMock()
|
|
sub.id = 20
|
|
sub.name = "No Next"
|
|
sub.query_or_topic = "q"
|
|
sub.refresh_interval_minutes = 120
|
|
sub.next_refresh = None
|
|
|
|
mock_db = _ctx(MagicMock())
|
|
mock_db.query.return_value.filter.return_value.all.return_value = [sub]
|
|
|
|
with (
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch.object(sched, "_schedule_document_processing"),
|
|
):
|
|
sched._schedule_user_subscriptions("u")
|
|
mock_bg.add_job.assert_called()
|
|
|
|
def test_job_lookup_error_on_clear(self, sched, mock_bg):
|
|
from apscheduler.jobstores.base import JobLookupError
|
|
|
|
mock_bg.remove_job.side_effect = JobLookupError("x")
|
|
_setup_user(sched, "u", jobs={"old_job"})
|
|
|
|
mock_db = _ctx(MagicMock())
|
|
mock_db.query.return_value.filter.return_value.all.return_value = []
|
|
|
|
with (
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch.object(sched, "_schedule_document_processing"),
|
|
):
|
|
sched._schedule_user_subscriptions("u")
|
|
|
|
def test_db_exception_handled(self, sched, mock_bg):
|
|
_setup_user(sched, "u")
|
|
with (
|
|
patch(DB_SESSION, side_effect=RuntimeError("db fail")),
|
|
patch.object(sched, "_schedule_document_processing"),
|
|
):
|
|
sched._schedule_user_subscriptions("u")
|
|
|
|
def test_sub_name_none_uses_query(self, sched, mock_bg):
|
|
_setup_user(sched, "u")
|
|
sub = MagicMock()
|
|
sub.id = 30
|
|
sub.name = None
|
|
sub.query_or_topic = "a" * 50
|
|
sub.refresh_interval_minutes = 30
|
|
sub.next_refresh = None
|
|
|
|
mock_db = _ctx(MagicMock())
|
|
mock_db.query.return_value.filter.return_value.all.return_value = [sub]
|
|
|
|
with (
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch.object(sched, "_schedule_document_processing"),
|
|
):
|
|
sched._schedule_user_subscriptions("u")
|
|
call_kwargs = mock_bg.add_job.call_args[1]
|
|
assert len(call_kwargs["name"]) <= 36
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _schedule_document_processing edge cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestScheduleDocumentProcessingEdgeCases:
|
|
def test_existing_job_removed(self, sched, mock_bg):
|
|
_setup_user(sched, "u", jobs={"u_document_processing"})
|
|
from local_deep_research.scheduler.background import (
|
|
DocumentSchedulerSettings,
|
|
)
|
|
|
|
with patch.object(
|
|
sched,
|
|
"_get_document_scheduler_settings",
|
|
return_value=DocumentSchedulerSettings(enabled=True),
|
|
):
|
|
mock_bg.get_job.return_value = MagicMock(
|
|
next_run_time=datetime.now(UTC)
|
|
)
|
|
sched._schedule_document_processing("u")
|
|
# _schedule_document_processing also tears down the opt-in library
|
|
# sweep job, so the document-processing removal may not be the last
|
|
# remove_job call — assert it happened rather than that it was last.
|
|
mock_bg.remove_job.assert_any_call("u_document_processing")
|
|
|
|
def test_wires_up_reconciler(self, sched, mock_bg):
|
|
"""_schedule_document_processing must invoke _schedule_reconciler — the
|
|
ONLY production path that gives the library-sweep job a lifecycle.
|
|
Guards the integration seam: deleting the
|
|
self._schedule_reconciler(...) call would silently disable the whole
|
|
feature while every isolated reconciler test still passes.
|
|
"""
|
|
_setup_user(sched, "u")
|
|
from local_deep_research.scheduler.background import (
|
|
DocumentSchedulerSettings,
|
|
)
|
|
|
|
settings = DocumentSchedulerSettings(
|
|
enabled=True, sweep_library_collections=True
|
|
)
|
|
with (
|
|
patch.object(
|
|
sched,
|
|
"_get_document_scheduler_settings",
|
|
return_value=settings,
|
|
),
|
|
patch.object(sched, "_schedule_reconciler") as mock_reconciler,
|
|
):
|
|
sched._schedule_document_processing("u")
|
|
|
|
mock_reconciler.assert_called_once()
|
|
assert mock_reconciler.call_args[0][0] == "u"
|
|
|
|
def test_existing_job_not_found_ok(self, sched, mock_bg):
|
|
from apscheduler.jobstores.base import JobLookupError
|
|
from local_deep_research.scheduler.background import (
|
|
DocumentSchedulerSettings,
|
|
)
|
|
|
|
_setup_user(sched, "u")
|
|
mock_bg.remove_job.side_effect = JobLookupError("x")
|
|
with patch.object(
|
|
sched,
|
|
"_get_document_scheduler_settings",
|
|
return_value=DocumentSchedulerSettings(enabled=True),
|
|
):
|
|
mock_bg.get_job.return_value = MagicMock(
|
|
next_run_time=datetime.now(UTC)
|
|
)
|
|
sched._schedule_document_processing("u")
|
|
|
|
def test_job_verification_failure(self, sched, mock_bg):
|
|
from local_deep_research.scheduler.background import (
|
|
DocumentSchedulerSettings,
|
|
)
|
|
|
|
_setup_user(sched, "u")
|
|
with patch.object(
|
|
sched,
|
|
"_get_document_scheduler_settings",
|
|
return_value=DocumentSchedulerSettings(enabled=True),
|
|
):
|
|
mock_bg.get_job.return_value = None
|
|
sched._schedule_document_processing("u")
|
|
|
|
def test_exception_in_scheduling(self, sched, mock_bg):
|
|
_setup_user(sched, "u")
|
|
with patch.object(
|
|
sched,
|
|
"_get_document_scheduler_settings",
|
|
side_effect=RuntimeError("boom"),
|
|
):
|
|
sched._schedule_document_processing("u")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _process_user_documents full flow
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestProcessUserDocumentsFull:
|
|
def _dss(self, **kw):
|
|
from local_deep_research.scheduler.background import (
|
|
DocumentSchedulerSettings,
|
|
)
|
|
|
|
defaults = dict(
|
|
enabled=True,
|
|
download_pdfs=False,
|
|
extract_text=False,
|
|
generate_rag=False,
|
|
last_run="",
|
|
interval_seconds=1800,
|
|
)
|
|
defaults.update(kw)
|
|
return DocumentSchedulerSettings(**defaults)
|
|
|
|
def _make_research(self, id=1, title="T", completed_at=None):
|
|
r = MagicMock()
|
|
r.id = id
|
|
r.title = title
|
|
r.completed_at = completed_at or datetime.now(UTC)
|
|
return r
|
|
|
|
def _setup_db_chain(self, mock_db, research_sessions):
|
|
chain = mock_db.query.return_value
|
|
chain.filter.return_value = chain
|
|
chain.order_by.return_value = chain
|
|
chain.limit.return_value = chain
|
|
chain.filter_by.return_value = chain
|
|
chain.outerjoin.return_value = chain
|
|
chain.all.return_value = research_sessions
|
|
return chain
|
|
|
|
def test_no_new_research(self, sched):
|
|
_setup_user(sched, "u")
|
|
mock_db = _ctx(MagicMock())
|
|
self._setup_db_chain(mock_db, [])
|
|
with (
|
|
patch.object(
|
|
sched,
|
|
"_get_document_scheduler_settings",
|
|
return_value=self._dss(download_pdfs=True),
|
|
),
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch(SETTINGS_MGR, return_value=MagicMock()),
|
|
):
|
|
sched._process_user_documents("u")
|
|
|
|
def test_download_pdfs(self, sched):
|
|
_setup_user(sched, "u")
|
|
research = self._make_research()
|
|
mock_db = _ctx(MagicMock())
|
|
self._setup_db_chain(mock_db, [research])
|
|
|
|
mock_ds = MagicMock()
|
|
_ctx(mock_ds)
|
|
mock_ds.queue_research_downloads.return_value = 3
|
|
|
|
with (
|
|
patch.object(
|
|
sched,
|
|
"_get_document_scheduler_settings",
|
|
return_value=self._dss(download_pdfs=True),
|
|
),
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch(SETTINGS_MGR, return_value=MagicMock()),
|
|
patch(DOWNLOAD_SVC, return_value=mock_ds),
|
|
):
|
|
sched._process_user_documents("u")
|
|
mock_ds.queue_research_downloads.assert_called_once()
|
|
|
|
def test_download_pdfs_exception(self, sched):
|
|
_setup_user(sched, "u")
|
|
research = self._make_research()
|
|
mock_db = _ctx(MagicMock())
|
|
self._setup_db_chain(mock_db, [research])
|
|
|
|
with (
|
|
patch.object(
|
|
sched,
|
|
"_get_document_scheduler_settings",
|
|
return_value=self._dss(download_pdfs=True),
|
|
),
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch(SETTINGS_MGR, return_value=MagicMock()),
|
|
patch(DOWNLOAD_SVC, side_effect=RuntimeError("fail")),
|
|
):
|
|
sched._process_user_documents("u")
|
|
|
|
def test_extract_text_success_and_failure(self, sched):
|
|
_setup_user(sched, "u")
|
|
research = self._make_research()
|
|
|
|
r_ok = MagicMock(id=10, url="https://arxiv.org/pdf/1.pdf")
|
|
r_fail = MagicMock(id=11, url="https://arxiv.org/pdf/2.pdf")
|
|
|
|
mock_db = _ctx(MagicMock())
|
|
call_count = [0]
|
|
chain = mock_db.query.return_value
|
|
chain.filter.return_value = chain
|
|
chain.order_by.return_value = chain
|
|
chain.limit.return_value = chain
|
|
chain.filter_by.return_value = chain
|
|
|
|
def all_fn():
|
|
call_count[0] += 1
|
|
if call_count[0] == 1:
|
|
return [research]
|
|
return [r_ok, r_fail]
|
|
|
|
chain.all.side_effect = all_fn
|
|
|
|
mock_ds = MagicMock()
|
|
_ctx(mock_ds)
|
|
mock_ds.download_as_text.side_effect = [
|
|
(True, None),
|
|
(False, "timeout"),
|
|
]
|
|
|
|
with (
|
|
patch.object(
|
|
sched,
|
|
"_get_document_scheduler_settings",
|
|
return_value=self._dss(extract_text=True),
|
|
),
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch(SETTINGS_MGR, return_value=MagicMock()),
|
|
patch(DOWNLOAD_SVC, return_value=mock_ds),
|
|
patch(IS_DOWNLOADABLE, return_value=True),
|
|
):
|
|
sched._process_user_documents("u")
|
|
|
|
def test_extract_text_resource_exception(self, sched):
|
|
_setup_user(sched, "u")
|
|
research = self._make_research()
|
|
resource = MagicMock(id=10, url="https://arxiv.org/pdf/1.pdf")
|
|
|
|
mock_db = _ctx(MagicMock())
|
|
call_count = [0]
|
|
chain = mock_db.query.return_value
|
|
chain.filter.return_value = chain
|
|
chain.order_by.return_value = chain
|
|
chain.limit.return_value = chain
|
|
chain.filter_by.return_value = chain
|
|
|
|
def all_fn():
|
|
call_count[0] += 1
|
|
return [research] if call_count[0] == 1 else [resource]
|
|
|
|
chain.all.side_effect = all_fn
|
|
|
|
mock_ds = MagicMock()
|
|
_ctx(mock_ds)
|
|
mock_ds.download_as_text.side_effect = RuntimeError("boom")
|
|
|
|
with (
|
|
patch.object(
|
|
sched,
|
|
"_get_document_scheduler_settings",
|
|
return_value=self._dss(extract_text=True),
|
|
),
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch(SETTINGS_MGR, return_value=MagicMock()),
|
|
patch(DOWNLOAD_SVC, return_value=mock_ds),
|
|
patch(IS_DOWNLOADABLE, return_value=True),
|
|
):
|
|
sched._process_user_documents("u")
|
|
|
|
def test_extract_text_outer_exception(self, sched):
|
|
_setup_user(sched, "u")
|
|
research = self._make_research()
|
|
mock_db = _ctx(MagicMock())
|
|
self._setup_db_chain(mock_db, [research])
|
|
|
|
with (
|
|
patch.object(
|
|
sched,
|
|
"_get_document_scheduler_settings",
|
|
return_value=self._dss(extract_text=True),
|
|
),
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch(SETTINGS_MGR, return_value=MagicMock()),
|
|
patch(DOWNLOAD_SVC, side_effect=RuntimeError("import fail")),
|
|
):
|
|
sched._process_user_documents("u")
|
|
|
|
def test_generate_rag_alone_is_noop_in_process_user_documents(self, sched):
|
|
"""RAG indexing of research downloads was RETIRED from
|
|
_process_user_documents into the reconciler. With generate_rag=True but
|
|
download/extract OFF, this pass short-circuits and never opens a DB
|
|
session — there is no inline RAG block left to run.
|
|
"""
|
|
_setup_user(sched, "u")
|
|
mock_db = _ctx(MagicMock())
|
|
|
|
with (
|
|
patch.object(
|
|
sched,
|
|
"_get_document_scheduler_settings",
|
|
return_value=self._dss(generate_rag=True),
|
|
),
|
|
patch(DB_SESSION, return_value=mock_db) as mock_session,
|
|
patch(SETTINGS_MGR, return_value=MagicMock()),
|
|
):
|
|
sched._process_user_documents("u")
|
|
|
|
# generate_rag alone no longer enables the download/extract pass.
|
|
mock_session.assert_not_called()
|
|
|
|
def test_completed_at_string_parsing(self, sched):
|
|
_setup_user(sched, "u")
|
|
research = self._make_research(completed_at="2024-06-15T10:00:00Z")
|
|
mock_db = _ctx(MagicMock())
|
|
self._setup_db_chain(mock_db, [research])
|
|
mock_ds = MagicMock()
|
|
_ctx(mock_ds)
|
|
mock_ds.queue_research_downloads.return_value = 0
|
|
|
|
with (
|
|
patch.object(
|
|
sched,
|
|
"_get_document_scheduler_settings",
|
|
return_value=self._dss(download_pdfs=True),
|
|
),
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch(SETTINGS_MGR, return_value=MagicMock()),
|
|
patch(DOWNLOAD_SVC, return_value=mock_ds),
|
|
):
|
|
sched._process_user_documents("u")
|
|
|
|
def test_completed_at_invalid_string(self, sched):
|
|
_setup_user(sched, "u")
|
|
research = self._make_research(completed_at="not-a-date")
|
|
research.title = "A" * 60 # long title for truncation
|
|
mock_db = _ctx(MagicMock())
|
|
self._setup_db_chain(mock_db, [research])
|
|
mock_ds = MagicMock()
|
|
_ctx(mock_ds)
|
|
mock_ds.queue_research_downloads.return_value = 0
|
|
|
|
with (
|
|
patch.object(
|
|
sched,
|
|
"_get_document_scheduler_settings",
|
|
return_value=self._dss(download_pdfs=True),
|
|
),
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch(SETTINGS_MGR, return_value=MagicMock()),
|
|
patch(DOWNLOAD_SVC, return_value=mock_ds),
|
|
):
|
|
sched._process_user_documents("u")
|
|
|
|
def test_last_run_set_filters_query(self, sched):
|
|
_setup_user(sched, "u")
|
|
mock_db = _ctx(MagicMock())
|
|
self._setup_db_chain(mock_db, [])
|
|
|
|
with (
|
|
patch.object(
|
|
sched,
|
|
"_get_document_scheduler_settings",
|
|
return_value=self._dss(
|
|
download_pdfs=True, last_run="2024-01-01T00:00:00"
|
|
),
|
|
),
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch(SETTINGS_MGR, return_value=MagicMock()),
|
|
):
|
|
sched._process_user_documents("u")
|
|
|
|
def test_outer_exception(self, sched):
|
|
_setup_user(sched, "u")
|
|
with patch.object(
|
|
sched,
|
|
"_get_document_scheduler_settings",
|
|
side_effect=RuntimeError("total fail"),
|
|
):
|
|
sched._process_user_documents("u")
|
|
|
|
def test_completed_at_none(self, sched):
|
|
_setup_user(sched, "u")
|
|
research = self._make_research(completed_at=None)
|
|
research.title = None
|
|
research.completed_at = None
|
|
mock_db = _ctx(MagicMock())
|
|
self._setup_db_chain(mock_db, [research])
|
|
mock_ds = MagicMock()
|
|
_ctx(mock_ds)
|
|
mock_ds.queue_research_downloads.return_value = 0
|
|
|
|
with (
|
|
patch.object(
|
|
sched,
|
|
"_get_document_scheduler_settings",
|
|
return_value=self._dss(download_pdfs=True),
|
|
),
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch(SETTINGS_MGR, return_value=MagicMock()),
|
|
patch(DOWNLOAD_SVC, return_value=mock_ds),
|
|
):
|
|
sched._process_user_documents("u")
|
|
|
|
def test_research_processing_exception(self, sched):
|
|
_setup_user(sched, "u")
|
|
research = self._make_research()
|
|
mock_db = _ctx(MagicMock())
|
|
self._setup_db_chain(mock_db, [research])
|
|
|
|
with (
|
|
patch.object(
|
|
sched,
|
|
"_get_document_scheduler_settings",
|
|
return_value=self._dss(download_pdfs=True),
|
|
),
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch(SETTINGS_MGR, return_value=MagicMock()),
|
|
patch(DOWNLOAD_SVC, side_effect=RuntimeError("fail")),
|
|
):
|
|
sched._process_user_documents("u")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _check_subscription with DateTrigger reschedule
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckSubscriptionReschedule:
|
|
def test_reschedule_date_trigger(self, sched, mock_bg):
|
|
_setup_user(sched, "u")
|
|
|
|
sub = MagicMock()
|
|
sub.id = 5
|
|
sub.status = "active"
|
|
sub.query_or_topic = "no placeholder"
|
|
sub.refresh_interval_minutes = 60
|
|
sub.name = "Test"
|
|
sub.model_provider = "test"
|
|
sub.model = "test"
|
|
sub.search_strategy = "news"
|
|
sub.search_engine = "searxng"
|
|
|
|
mock_db = _ctx(MagicMock())
|
|
mock_db.query.return_value.get.return_value = sub
|
|
|
|
mock_job = MagicMock()
|
|
mock_job.trigger.__class__.__name__ = "DateTrigger"
|
|
mock_bg.get_job.return_value = mock_job
|
|
|
|
with (
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch.object(sched, "_trigger_subscription_research_sync"),
|
|
):
|
|
sched._check_subscription("u", 5)
|
|
assert mock_bg.add_job.call_count >= 1
|
|
|
|
def test_no_session_job_lookup_error(self, sched, mock_bg):
|
|
from apscheduler.jobstores.base import JobLookupError
|
|
|
|
mock_bg.remove_job.side_effect = JobLookupError("x")
|
|
sched._check_subscription("nobody", 1)
|
|
|
|
def test_exception_in_check(self, sched, mock_bg):
|
|
_setup_user(sched, "u")
|
|
with patch(DB_SESSION, side_effect=RuntimeError("fail")):
|
|
sched._check_subscription("u", 1)
|
|
|
|
def test_sub_not_active(self, sched, mock_bg):
|
|
_setup_user(sched, "u")
|
|
sub = MagicMock()
|
|
sub.status = "paused"
|
|
mock_db = _ctx(MagicMock())
|
|
mock_db.query.return_value.get.return_value = sub
|
|
with patch(DB_SESSION, return_value=mock_db):
|
|
sched._check_subscription("u", 1)
|
|
|
|
def test_interval_trigger_no_reschedule(self, sched, mock_bg):
|
|
"""Non-DateTrigger job should not be rescheduled."""
|
|
_setup_user(sched, "u")
|
|
sub = MagicMock()
|
|
sub.id = 5
|
|
sub.status = "active"
|
|
sub.query_or_topic = "q"
|
|
sub.refresh_interval_minutes = 60
|
|
sub.name = "T"
|
|
sub.model_provider = "t"
|
|
sub.model = "t"
|
|
sub.search_strategy = "n"
|
|
sub.search_engine = "a"
|
|
|
|
mock_db = _ctx(MagicMock())
|
|
mock_db.query.return_value.get.return_value = sub
|
|
|
|
mock_job = MagicMock()
|
|
mock_job.trigger.__class__.__name__ = "IntervalTrigger"
|
|
mock_bg.get_job.return_value = mock_job
|
|
|
|
with (
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch.object(sched, "_trigger_subscription_research_sync"),
|
|
):
|
|
sched._check_subscription("u", 5)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _trigger_subscription_research_sync
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTriggerResearchSync:
|
|
def _base_sub(self, search_engine="searxng"):
|
|
return {
|
|
"id": 1,
|
|
"name": "T",
|
|
"query": "q",
|
|
"original_query": "q",
|
|
"model_provider": "openai",
|
|
"model": "gpt-4",
|
|
"search_strategy": "news",
|
|
"search_engine": search_engine,
|
|
}
|
|
|
|
def test_no_search_engine(self, sched):
|
|
_setup_user(sched, "u")
|
|
mock_db = _ctx(MagicMock())
|
|
mock_sm = MagicMock()
|
|
mock_sm.get_settings_snapshot.return_value = {
|
|
"search.tool": {"value": "google", "ui_element": "select"}
|
|
}
|
|
|
|
with (
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch(SETTINGS_MGR, return_value=mock_sm),
|
|
patch(QUICK_SUMMARY, return_value={"report": "r"}) as qs,
|
|
patch(SET_CTX),
|
|
patch.object(sched, "_store_research_result"),
|
|
):
|
|
sched._trigger_subscription_research_sync(
|
|
"u", self._base_sub(search_engine=None)
|
|
)
|
|
qs.assert_called_once()
|
|
|
|
def test_with_search_engine(self, sched):
|
|
_setup_user(sched, "u")
|
|
mock_db = _ctx(MagicMock())
|
|
mock_sm = MagicMock()
|
|
snapshot = {}
|
|
mock_sm.get_settings_snapshot.return_value = snapshot
|
|
|
|
with (
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch(SETTINGS_MGR, return_value=mock_sm),
|
|
patch(QUICK_SUMMARY, return_value={"report": "r"}),
|
|
patch(SET_CTX),
|
|
patch.object(sched, "_store_research_result"),
|
|
):
|
|
sched._trigger_subscription_research_sync(
|
|
"u", self._base_sub(search_engine="bing")
|
|
)
|
|
assert snapshot["search.tool"]["value"] == "bing"
|
|
|
|
def test_exception_handled(self, sched):
|
|
_setup_user(sched, "u")
|
|
with patch(DB_SESSION, side_effect=RuntimeError("fail")):
|
|
sched._trigger_subscription_research_sync("u", self._base_sub())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _store_research_result edge cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestStoreResearchResult:
|
|
def _call_store(self, sched, result, sub=None, headline="H", topics=None):
|
|
sub = sub or {"name": "Sub", "query": "q"}
|
|
mock_db = _ctx(MagicMock())
|
|
mock_sm = MagicMock()
|
|
mock_sm.get_settings_snapshot.return_value = {}
|
|
mock_cf = MagicMock()
|
|
mock_cf.return_value.format_document.return_value = "formatted"
|
|
|
|
with (
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch(SETTINGS_MGR, return_value=mock_sm),
|
|
patch(HEADLINE_GEN, return_value=headline),
|
|
patch(TOPIC_GEN, return_value=topics or []),
|
|
patch(REPORT_STORAGE),
|
|
patch(FORMAT_LINKS, return_value="- [link](url)"),
|
|
patch(CITATION_FMT, mock_cf),
|
|
patch(GET_SETTING_SNAP, return_value="domain_id_hyperlinks"),
|
|
):
|
|
sched._store_research_result("u", "pw", "rid", 1, result, sub)
|
|
return mock_db
|
|
|
|
def test_with_sources(self, sched):
|
|
self._call_store(
|
|
sched,
|
|
{"report": "R", "query": "q", "sources": [{"url": "http://x"}]},
|
|
)
|
|
|
|
def test_no_report_uses_summary(self, sched):
|
|
self._call_store(sched, {"summary": "S", "query": "q", "sources": []})
|
|
|
|
def test_no_report_no_summary_uses_json(self, sched):
|
|
mock_db = _ctx(MagicMock())
|
|
mock_sm = MagicMock()
|
|
mock_sm.get_settings_snapshot.return_value = {}
|
|
|
|
with (
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch(SETTINGS_MGR, return_value=mock_sm),
|
|
patch(HEADLINE_GEN, return_value=None),
|
|
patch(TOPIC_GEN, return_value=[]),
|
|
patch(REPORT_STORAGE),
|
|
):
|
|
sched._store_research_result(
|
|
"u",
|
|
"pw",
|
|
"rid",
|
|
1,
|
|
{"query": "q", "sources": []},
|
|
{"name": "", "query": "q"},
|
|
)
|
|
|
|
def test_no_headline_with_name(self, sched):
|
|
mock_db = _ctx(MagicMock())
|
|
mock_sm = MagicMock()
|
|
mock_sm.get_settings_snapshot.return_value = {}
|
|
mock_cf = MagicMock()
|
|
mock_cf.return_value.format_document.return_value = "f"
|
|
|
|
with (
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch(SETTINGS_MGR, return_value=mock_sm),
|
|
patch(HEADLINE_GEN, return_value=None),
|
|
patch(TOPIC_GEN, return_value=[]),
|
|
patch(REPORT_STORAGE),
|
|
patch(CITATION_FMT, mock_cf),
|
|
patch(GET_SETTING_SNAP, return_value="number_hyperlinks"),
|
|
):
|
|
sched._store_research_result(
|
|
"u",
|
|
"pw",
|
|
"rid",
|
|
1,
|
|
{"report": "R", "query": "q", "sources": []},
|
|
{"name": "My Sub", "query": "q"},
|
|
)
|
|
|
|
def test_no_headline_no_name(self, sched):
|
|
mock_db = _ctx(MagicMock())
|
|
mock_sm = MagicMock()
|
|
mock_sm.get_settings_snapshot.return_value = {}
|
|
mock_cf = MagicMock()
|
|
mock_cf.return_value.format_document.return_value = "f"
|
|
|
|
with (
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch(SETTINGS_MGR, return_value=mock_sm),
|
|
patch(HEADLINE_GEN, return_value=None),
|
|
patch(TOPIC_GEN, return_value=[]),
|
|
patch(REPORT_STORAGE),
|
|
patch(CITATION_FMT, mock_cf),
|
|
patch(GET_SETTING_SNAP, return_value="no_hyperlinks"),
|
|
):
|
|
sched._store_research_result(
|
|
"u",
|
|
"pw",
|
|
"rid",
|
|
1,
|
|
{"report": "R", "query": "q" * 100, "sources": []},
|
|
{"name": "", "query": "q" * 100},
|
|
)
|
|
|
|
def test_make_serializable_with_dict_method(self, sched):
|
|
class Obj:
|
|
def dict(self):
|
|
return {"key": "val"}
|
|
|
|
result = Obj()
|
|
|
|
mock_db = _ctx(MagicMock())
|
|
mock_sm = MagicMock()
|
|
mock_sm.get_settings_snapshot.return_value = {}
|
|
mock_cf = MagicMock()
|
|
mock_cf.return_value.format_document.return_value = "f"
|
|
|
|
with (
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch(SETTINGS_MGR, return_value=mock_sm),
|
|
patch(HEADLINE_GEN, return_value="H"),
|
|
patch(TOPIC_GEN, return_value=[]),
|
|
patch(REPORT_STORAGE),
|
|
patch(CITATION_FMT, mock_cf),
|
|
patch(GET_SETTING_SNAP, return_value="domain_hyperlinks"),
|
|
):
|
|
sched._store_research_result(
|
|
"u", "pw", "rid", 1, result, {"name": "S", "query": "q"}
|
|
)
|
|
|
|
def test_store_exception(self, sched):
|
|
with patch(DB_SESSION, side_effect=RuntimeError("fail")):
|
|
sched._store_research_result(
|
|
"u", "p", "r", 1, {}, {"name": "S", "query": "q"}
|
|
)
|
|
|
|
def test_citation_format_domain_id_always(self, sched):
|
|
"""Test domain_id_always_hyperlinks citation mode."""
|
|
mock_db = _ctx(MagicMock())
|
|
mock_sm = MagicMock()
|
|
mock_sm.get_settings_snapshot.return_value = {}
|
|
mock_cf = MagicMock()
|
|
mock_cf.return_value.format_document.return_value = "f"
|
|
|
|
with (
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch(SETTINGS_MGR, return_value=mock_sm),
|
|
patch(HEADLINE_GEN, return_value="H"),
|
|
patch(TOPIC_GEN, return_value=["t1"]),
|
|
patch(REPORT_STORAGE),
|
|
patch(FORMAT_LINKS, return_value=""),
|
|
patch(CITATION_FMT, mock_cf),
|
|
patch(GET_SETTING_SNAP, return_value="domain_id_always_hyperlinks"),
|
|
):
|
|
sched._store_research_result(
|
|
"u",
|
|
"pw",
|
|
"rid",
|
|
1,
|
|
{"report": "R", "query": "q", "sources": [{"url": "x"}]},
|
|
{"name": "S", "query": "q"},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_document_scheduler_status exception
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDocSchedulerStatusException:
|
|
def test_exception_returns_error(self, sched):
|
|
_setup_user(sched, "u")
|
|
with patch.object(
|
|
sched,
|
|
"_get_document_scheduler_settings",
|
|
side_effect=ValueError("boom"),
|
|
):
|
|
status = sched.get_document_scheduler_status("u")
|
|
assert status["enabled"] is False
|
|
assert "ValueError" in status["message"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# trigger_document_processing edge cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTriggerDocProcessingEdgeCases:
|
|
def test_job_verification_fails(self, running, mock_bg):
|
|
_setup_user(running, "u")
|
|
mock_bg.get_job.return_value = None
|
|
assert running.trigger_document_processing("u") is False
|
|
|
|
def test_exception_returns_false(self, running, mock_bg):
|
|
_setup_user(running, "u")
|
|
mock_bg.add_job.side_effect = RuntimeError("fail")
|
|
assert running.trigger_document_processing("u") is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _reload_config
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestReloadConfigEdgeCases:
|
|
def test_exception_in_reload(self, sched, mock_bg):
|
|
sm = MagicMock()
|
|
sm.get_setting.side_effect = RuntimeError("fail")
|
|
sched.settings_manager = sm
|
|
sched._reload_config()
|
|
|
|
def test_no_retention_change(self, sched, mock_bg):
|
|
sm = MagicMock()
|
|
sm.get_setting.side_effect = lambda k, default: default
|
|
sched.settings_manager = sm
|
|
sched._reload_config()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _check_user_overdue_subscriptions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestOverdueSubscriptionsEdgeCases:
|
|
def test_exception_handled(self, sched, mock_bg):
|
|
_setup_user(sched, "u")
|
|
with patch(DB_SESSION, side_effect=RuntimeError("fail")):
|
|
sched._check_user_overdue_subscriptions("u")
|
|
|
|
def test_no_overdue(self, sched, mock_bg):
|
|
_setup_user(sched, "u")
|
|
mock_db = _ctx(MagicMock())
|
|
mock_db.query.return_value.filter.return_value.all.return_value = []
|
|
with patch(DB_SESSION, return_value=mock_db):
|
|
sched._check_user_overdue_subscriptions("u")
|
|
mock_bg.add_job.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cleanup with mixed active/inactive
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCleanupMixed:
|
|
def test_mixed_users(self, sched, mock_bg):
|
|
_setup_user(sched, "old", jobs={"j1"})
|
|
sched.user_sessions["old"]["last_activity"] = datetime.now(
|
|
UTC
|
|
) - timedelta(hours=100)
|
|
_setup_user(sched, "new")
|
|
cleaned = sched._cleanup_inactive_users()
|
|
assert cleaned == 1
|
|
assert "old" not in sched.user_sessions
|
|
assert "new" in sched.user_sessions
|
|
|
|
def test_cleanup_job_lookup_error(self, sched, mock_bg):
|
|
from apscheduler.jobstores.base import JobLookupError
|
|
|
|
_setup_user(sched, "old", jobs={"j1"})
|
|
sched.user_sessions["old"]["last_activity"] = datetime.now(
|
|
UTC
|
|
) - timedelta(hours=100)
|
|
mock_bg.remove_job.side_effect = JobLookupError("j1")
|
|
assert sched._cleanup_inactive_users() == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SnapshotSettingsContext usage
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSnapshotSettingsContext:
|
|
def test_settings_context_with_value_dicts(self, sched):
|
|
_setup_user(sched, "u")
|
|
sub = {
|
|
"id": 1,
|
|
"name": "T",
|
|
"query": "q",
|
|
"original_query": "q",
|
|
"model_provider": "openai",
|
|
"model": "gpt-4",
|
|
"search_strategy": "news",
|
|
"search_engine": "searxng",
|
|
}
|
|
|
|
mock_db = _ctx(MagicMock())
|
|
mock_sm = MagicMock()
|
|
mock_sm.get_settings_snapshot.return_value = {
|
|
"key1": {"value": "v1", "ui_element": "text"},
|
|
"key2": "plain_value",
|
|
}
|
|
|
|
captured = []
|
|
|
|
def capture(ctx):
|
|
captured.append(ctx)
|
|
|
|
with (
|
|
patch(DB_SESSION, return_value=mock_db),
|
|
patch(SETTINGS_MGR, return_value=mock_sm),
|
|
patch(QUICK_SUMMARY, return_value={"report": "r"}),
|
|
patch(SET_CTX, side_effect=capture),
|
|
patch.object(sched, "_store_research_result"),
|
|
):
|
|
sched._trigger_subscription_research_sync("u", sub)
|
|
|
|
assert len(captured) == 1
|
|
ctx = captured[0]
|
|
assert ctx.get_setting("key1") == "v1"
|
|
assert ctx.get_setting("key2") == "plain_value"
|
|
assert ctx.get_setting("missing", "default") == "default"
|