Files
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

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"