From 57bf63e57645d95a195d9b54d1f987d2619d728a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 28 Mar 2026 00:29:32 -0400 Subject: [PATCH] feat: extend RSS item retention from 14 to 90 days Co-Authored-By: Claude Sonnet 4.6 --- src/fabledassistant/services/rss.py | 4 +- tests/test_news_api.py | 86 +++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 tests/test_news_api.py diff --git a/src/fabledassistant/services/rss.py b/src/fabledassistant/services/rss.py index 39457a4..d85b510 100644 --- a/src/fabledassistant/services/rss.py +++ b/src/fabledassistant/services/rss.py @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__) CONTENT_MAX_CHARS = 2000 # Keep only items from the last N days to avoid unbounded growth -ITEM_MAX_AGE_DAYS = 14 +ITEM_MAX_AGE_DAYS = 90 def extract_item(entry) -> dict: @@ -135,7 +135,7 @@ async def _prune_old_items(feed_id: int) -> None: text(""" DELETE FROM rss_items WHERE feed_id = :feed_id - AND published_at < NOW() - INTERVAL '14 days' + AND published_at < NOW() - INTERVAL '90 days' """).bindparams(feed_id=feed_id) ) await session.commit() diff --git a/tests/test_news_api.py b/tests/test_news_api.py new file mode 100644 index 0000000..b7bec2e --- /dev/null +++ b/tests/test_news_api.py @@ -0,0 +1,86 @@ +"""Tests for news API — retention constant and endpoint formatting.""" +import pytest +from unittest.mock import AsyncMock, MagicMock +from datetime import datetime, timezone + + +def test_rss_item_max_age_is_90_days(): + """Retention window should be 90 days.""" + from fabledassistant.services.rss import ITEM_MAX_AGE_DAYS + + assert ITEM_MAX_AGE_DAYS == 90 + + +def _make_mock_session(rows): + """Return a mock async context manager whose session.execute returns rows.""" + mock_result = MagicMock() + mock_result.mappings.return_value.all.return_value = rows + + mock_session = AsyncMock() + mock_session.execute = AsyncMock(return_value=mock_result) + + mock_cm = MagicMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_session) + mock_cm.__aexit__ = AsyncMock(return_value=False) + return mock_cm + + +def test_list_news_item_serialisation(): + """News item serialisation should truncate content to 300 chars and include reaction.""" + pub = datetime(2026, 3, 28, 8, 0, tzinfo=timezone.utc) + item = { + "id": 1, + "title": "EU AI Act deadline", + "url": "https://example.com/ai", + "content": "x" * 500, + "published_at": pub, + "topics": ["tech"], + "feed_title": "TechCrunch", + "reaction": "up", + } + + result = { + "id": item["id"], + "title": item["title"], + "url": item["url"], + "snippet": (item["content"] or "")[:300], + "published_at": item["published_at"].isoformat() if item["published_at"] else None, + "topics": item["topics"] or [], + "source": item["feed_title"], + "reaction": item["reaction"], + } + + assert result["snippet"] == "x" * 300 + assert result["source"] == "TechCrunch" + assert result["reaction"] == "up" + assert result["published_at"] == pub.isoformat() + + +def test_list_news_null_content_serialisation(): + """Null content should produce empty snippet without error.""" + item = { + "id": 2, + "title": "No content article", + "url": "https://example.com/b", + "content": None, + "published_at": None, + "topics": None, + "feed_title": "Hacker News", + "reaction": None, + } + + result = { + "id": item["id"], + "title": item["title"], + "url": item["url"], + "snippet": (item["content"] or "")[:300], + "published_at": item["published_at"].isoformat() if item["published_at"] else None, + "topics": item["topics"] or [], + "source": item["feed_title"], + "reaction": item["reaction"], + } + + assert result["snippet"] == "" + assert result["topics"] == [] + assert result["published_at"] is None + assert result["reaction"] is None