"""Tests for news subscription run helpers and the active/due predicates. These cover the consolidation of three previously-duplicated subscription run paths (manual run-now, the overdue sweep, the background scheduler) onto: * ``NewsSubscription.active_filter`` / ``due_filter`` -- the single authoritative definition of "active" / "due", keyed on the ``status`` column. * ``build_subscription_request_data`` -- the shared /research/api/start payload. * ``advance_refresh_schedule`` -- the shared refresh-timestamp arithmetic. The predicate tests pin the bug they fixed: a subscription whose ``status`` is "paused" must never be treated as active/due, even though its legacy ``is_active`` column may still read ``True`` (create_subscription leaves the column at its default). """ from datetime import datetime, timezone, timedelta import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from local_deep_research.database.models.news import NewsSubscription from local_deep_research.news.subscription_runner import ( advance_refresh_schedule, advance_refresh_schedule_by_id, build_subscription_request_data, mark_subscription_due_by_id, ) @pytest.fixture def db_session(): """In-memory SQLite session with just the news_subscriptions table.""" engine = create_engine("sqlite:///:memory:") NewsSubscription.__table__.create(engine) session = sessionmaker(bind=engine)() yield session session.close() def _make_sub(session, sub_id, **overrides): defaults = dict( id=sub_id, query_or_topic="some query", subscription_type="topic", status="active", refresh_interval_minutes=60, ) defaults.update(overrides) sub = NewsSubscription(**defaults) session.add(sub) session.commit() return sub class TestActiveAndDueFilters: def test_paused_with_stale_is_active_column_is_not_due(self, db_session): """A paused subscription must not be due even if is_active is True. This is the bug the consolidation fixed: create_subscription writes only status, leaving the is_active column at its default True, so filtering on is_active ran paused subscriptions. """ now = datetime.now(timezone.utc) _make_sub( db_session, "paused", status="paused", is_active=True, # stale mirror, must be ignored next_refresh=now - timedelta(hours=1), ) due = ( db_session.query(NewsSubscription) .filter(NewsSubscription.due_filter(now)) .all() ) active = ( db_session.query(NewsSubscription) .filter(NewsSubscription.active_filter()) .all() ) assert due == [] assert active == [] def test_active_overdue_is_due(self, db_session): now = datetime.now(timezone.utc) _make_sub(db_session, "a", next_refresh=now - timedelta(minutes=1)) due = ( db_session.query(NewsSubscription) .filter(NewsSubscription.due_filter(now)) .all() ) assert [s.id for s in due] == ["a"] def test_active_with_future_refresh_is_not_due(self, db_session): now = datetime.now(timezone.utc) _make_sub(db_session, "future", next_refresh=now + timedelta(hours=1)) due = ( db_session.query(NewsSubscription) .filter(NewsSubscription.due_filter(now)) .all() ) assert due == [] def test_active_with_null_refresh_is_not_due(self, db_session): now = datetime.now(timezone.utc) _make_sub(db_session, "nullref", next_refresh=None) due = ( db_session.query(NewsSubscription) .filter(NewsSubscription.due_filter(now)) .all() ) assert due == [] def test_active_filter_ignores_refresh_timing(self, db_session): """active_filter is about on/off, independent of next_refresh.""" now = datetime.now(timezone.utc) _make_sub(db_session, "future", next_refresh=now + timedelta(hours=1)) _make_sub(db_session, "nullref", next_refresh=None) _make_sub(db_session, "paused", status="paused", next_refresh=None) active = ( db_session.query(NewsSubscription) .filter(NewsSubscription.active_filter()) .all() ) assert sorted(s.id for s in active) == ["future", "nullref"] class TestBuildSubscriptionRequestData: def test_replaces_date_placeholder_only_in_query(self): data = build_subscription_request_data( query_template="news on YYYY-MM-DD please", current_date="2026-06-10", triggered_by="manual", subscription_id="sub-1", title="My sub", ) assert data["query"] == "news on 2026-06-10 please" # original_query keeps the placeholder; processed_query has the date assert data["metadata"]["original_query"] == "news on YYYY-MM-DD please" assert ( data["metadata"]["processed_query"] == "news on 2026-06-10 please" ) assert data["metadata"]["news_date"] == "2026-06-10" def test_news_metadata_block(self): data = build_subscription_request_data( query_template="q", current_date="2026-06-10", triggered_by="overdue_check", subscription_id=42, title="T", ) meta = data["metadata"] assert meta["is_news_search"] is True assert meta["search_type"] == "news_analysis" assert meta["display_in"] == "news_feed" assert meta["triggered_by"] == "overdue_check" # subscription_id is always stringified regardless of input type assert meta["subscription_id"] == "42" assert meta["title"] == "T" assert data["mode"] == "quick" def test_unset_model_passed_through_as_falsy(self): """Unset provider/model stay falsy so the backend uses user settings.""" data = build_subscription_request_data( query_template="q", current_date="2026-06-10", triggered_by="manual", subscription_id="s", model_provider=None, model=None, search_strategy=None, ) assert data["model_provider"] is None assert data["model"] is None # strategy falls back to the news default assert data["strategy"] == "news_aggregation" def test_optional_fields_included_only_when_set(self): without = build_subscription_request_data( query_template="q", current_date="2026-06-10", triggered_by="manual", subscription_id="s", ) assert "search_engine" not in without assert "custom_endpoint" not in without with_optional = build_subscription_request_data( query_template="q", current_date="2026-06-10", triggered_by="manual", subscription_id="s", search_engine="searxng", custom_endpoint="https://example.test", search_strategy="focused-iteration", ) assert with_optional["search_engine"] == "searxng" assert with_optional["custom_endpoint"] == "https://example.test" assert with_optional["strategy"] == "focused-iteration" def test_empty_title_normalized_to_none(self): data = build_subscription_request_data( query_template="q", current_date="2026-06-10", triggered_by="manual", subscription_id="s", title="", ) assert data["metadata"]["title"] is None class TestAdvanceRefreshSchedule: def test_sets_last_and_next_refresh(self): now = datetime(2026, 6, 10, 12, 0, tzinfo=timezone.utc) sub = NewsSubscription( id="s", query_or_topic="q", subscription_type="topic", refresh_interval_minutes=90, ) advance_refresh_schedule(sub, now) assert sub.last_refresh == now assert sub.next_refresh == now + timedelta(minutes=90) def test_defaults_now_to_current_utc(self): sub = NewsSubscription( id="s", query_or_topic="q", subscription_type="topic", refresh_interval_minutes=30, ) before = datetime.now(timezone.utc) advance_refresh_schedule(sub) after = datetime.now(timezone.utc) assert before <= sub.last_refresh <= after assert sub.next_refresh == sub.last_refresh + timedelta(minutes=30) class TestAdvanceRefreshScheduleById: def test_advances_existing_subscription(self, db_session): sub = _make_sub(db_session, "s1", next_refresh=None) before = datetime.now(timezone.utc) assert advance_refresh_schedule_by_id(db_session, "s1") is True assert sub.last_refresh >= before assert sub.next_refresh == sub.last_refresh + timedelta(minutes=60) def test_missing_subscription_returns_false(self, db_session): assert advance_refresh_schedule_by_id(db_session, "nope") is False def test_non_string_id_is_stringified(self, db_session): _make_sub(db_session, "42", next_refresh=None) # Callers pass ids from metadata dicts, which may be ints. assert advance_refresh_schedule_by_id(db_session, 42) is True class TestMarkSubscriptionDueById: """The failure-path reset that undoes the spawn-time schedule advance. run_subscription_now advances next_refresh at spawn time so the scheduler will not double-run an in-flight subscription. If that run fails, the success-only completion advance never fires; without this reset the subscription would be hidden from the scheduler for a full interval. """ def test_resets_next_refresh_to_now(self, db_session): # Simulate the spawn-time advance: next_refresh pushed far out. future = datetime.now(timezone.utc) + timedelta(hours=24) last = datetime.now(timezone.utc) - timedelta(minutes=1) _make_sub(db_session, "s1", next_refresh=future, last_refresh=last) before = datetime.now(timezone.utc) assert mark_subscription_due_by_id(db_session, "s1") is True after = datetime.now(timezone.utc) sub = ( db_session.query(NewsSubscription) .filter(NewsSubscription.id == "s1") .first() ) # next_refresh reset to ~now so due_filter(now) selects it again. assert before <= sub.next_refresh <= after # last_refresh is the last *successful* refresh and must be untouched. assert sub.last_refresh == last def test_missing_subscription_returns_false(self, db_session): assert mark_subscription_due_by_id(db_session, "nope") is False def test_reset_makes_subscription_due_again(self, db_session): """End-to-end: a spawn-advanced sub is not due, then is after reset.""" future = datetime.now(timezone.utc) + timedelta(hours=24) _make_sub(db_session, "s1", next_refresh=future) now = datetime.now(timezone.utc) not_due = ( db_session.query(NewsSubscription) .filter(NewsSubscription.due_filter(now)) .all() ) assert not_due == [] mark_subscription_due_by_id(db_session, "s1") db_session.commit() due = ( db_session.query(NewsSubscription) .filter(NewsSubscription.due_filter(datetime.now(timezone.utc))) .all() ) assert [s.id for s in due] == ["s1"] def test_non_string_id_is_stringified(self, db_session): _make_sub(db_session, "7", next_refresh=None) assert mark_subscription_due_by_id(db_session, 7) is True