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
319 lines
12 KiB
Python
319 lines
12 KiB
Python
"""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
|