Skip to content

Commit 23abcd1

Browse files
committed
feat: add RSS feed for status updates
- Add /rss.xml endpoint returning RSS 2.0 feed - New webstatuspi/_rss.py module with XML generation using stdlib - Add RssConfig to config.py with title, description, max_items, link options - Add comprehensive tests in tests/test_rss.py (21 tests) - Update FEATURE_SUGGESTIONS.md marking RSS as implemented Configuration example: ```yaml api: rss: enabled: true title: "WebStatusπ Status Feed" max_items: 20 ```
1 parent dd5c930 commit 23abcd1

5 files changed

Lines changed: 587 additions & 3 deletions

File tree

docs/FEATURE_SUGGESTIONS.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ This document outlines potential features that could add significant value to We
44

55
## High-Value Features
66

7-
### 1. RSS Feed for Status Updates (Priority: P2)
7+
### 1. RSS Feed for Status Updates (Priority: P2) ✅ IMPLEMENTED
8+
9+
> **Status**: Implemented in v0.2.0
10+
> - Endpoint: `GET /rss.xml`
11+
> - Configuration: `api.rss.enabled`, `api.rss.title`, `api.rss.max_items`, `api.rss.link`
12+
> - Tests: `tests/test_rss.py`
813
914
**Value**: Allows users to subscribe to status changes via RSS readers, enabling automatic notifications when services go down or recover.
1015

@@ -316,7 +321,7 @@ maintenance:
316321
## Implementation Priority
317322

318323
### Phase 1 (High Impact, Low Complexity)
319-
1. **RSS Feed** - Quick win, high user value
324+
1. **RSS Feed** - ✅ IMPLEMENTED
320325
2. **Dark/Light Mode Toggle** - Improves UX immediately
321326
3. **System Aggregated Statistics** - Enhances dashboard value
322327

tests/test_rss.py

Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
"""Tests for the RSS feed module."""
2+
3+
import xml.etree.ElementTree as ET
4+
from datetime import UTC, datetime
5+
6+
import pytest
7+
8+
from webstatuspi._rss import (
9+
_format_rfc822,
10+
_status_to_description,
11+
generate_rss_feed,
12+
)
13+
from webstatuspi.config import ConfigError, RssConfig
14+
from webstatuspi.models import UrlStatus
15+
16+
17+
@pytest.fixture
18+
def sample_status() -> UrlStatus:
19+
"""Create a sample URL status."""
20+
return UrlStatus(
21+
url_name="TEST_URL",
22+
url="https://example.com",
23+
is_up=True,
24+
last_status_code=200,
25+
last_response_time_ms=150,
26+
last_error=None,
27+
last_check=datetime(2026, 1, 28, 10, 30, 0, tzinfo=UTC),
28+
checks_24h=24,
29+
uptime_24h=99.5,
30+
)
31+
32+
33+
@pytest.fixture
34+
def sample_status_down() -> UrlStatus:
35+
"""Create a sample URL status that is down."""
36+
return UrlStatus(
37+
url_name="DOWN_SVC",
38+
url="https://down.example.com",
39+
is_up=False,
40+
last_status_code=503,
41+
last_response_time_ms=5000,
42+
last_error="Service Unavailable",
43+
last_check=datetime(2026, 1, 28, 10, 25, 0, tzinfo=UTC),
44+
checks_24h=24,
45+
uptime_24h=75.0,
46+
)
47+
48+
49+
@pytest.fixture
50+
def default_rss_config() -> RssConfig:
51+
"""Create default RSS configuration."""
52+
return RssConfig()
53+
54+
55+
@pytest.fixture
56+
def custom_rss_config() -> RssConfig:
57+
"""Create custom RSS configuration."""
58+
return RssConfig(
59+
enabled=True,
60+
title="My Status Feed",
61+
description="Custom description",
62+
max_items=5,
63+
link="https://status.example.com",
64+
)
65+
66+
67+
class TestRssConfig:
68+
"""Tests for RssConfig dataclass."""
69+
70+
def test_default_values(self) -> None:
71+
"""Default RssConfig has expected values."""
72+
config = RssConfig()
73+
assert config.enabled is True
74+
assert config.title == "WebStatusπ Status Feed"
75+
assert config.description == "Real-time status updates for monitored services"
76+
assert config.max_items == 20
77+
assert config.link == ""
78+
79+
def test_custom_values(self, custom_rss_config: RssConfig) -> None:
80+
"""Custom RssConfig preserves values."""
81+
assert custom_rss_config.title == "My Status Feed"
82+
assert custom_rss_config.description == "Custom description"
83+
assert custom_rss_config.max_items == 5
84+
assert custom_rss_config.link == "https://status.example.com"
85+
86+
def test_max_items_minimum(self) -> None:
87+
"""max_items must be at least 1."""
88+
with pytest.raises(ConfigError, match="must be at least 1"):
89+
RssConfig(max_items=0)
90+
91+
def test_max_items_maximum(self) -> None:
92+
"""max_items must not exceed 100."""
93+
with pytest.raises(ConfigError, match="must not exceed 100"):
94+
RssConfig(max_items=101)
95+
96+
97+
class TestFormatRfc822:
98+
"""Tests for _format_rfc822 function."""
99+
100+
def test_formats_datetime_correctly(self) -> None:
101+
"""Datetime is formatted as RFC 822."""
102+
dt = datetime(2026, 1, 28, 10, 30, 0, tzinfo=UTC)
103+
result = _format_rfc822(dt)
104+
# RFC 822 format: "Tue, 28 Jan 2026 10:30:00 +0000" or similar
105+
assert "28 Jan 2026" in result
106+
assert "10:30:00" in result
107+
108+
109+
class TestStatusToDescription:
110+
"""Tests for _status_to_description function."""
111+
112+
def test_up_status_description(self, sample_status: UrlStatus) -> None:
113+
"""UP status generates correct description."""
114+
desc = _status_to_description(sample_status)
115+
assert "Status: UP" in desc
116+
assert "HTTP Status: 200" in desc
117+
assert "Response Time: 150ms" in desc
118+
assert "Uptime (24h): 99.5%" in desc
119+
120+
def test_down_status_description(self, sample_status_down: UrlStatus) -> None:
121+
"""DOWN status generates correct description with error."""
122+
desc = _status_to_description(sample_status_down)
123+
assert "Status: DOWN" in desc
124+
assert "HTTP Status: 503" in desc
125+
assert "Error: Service Unavailable" in desc
126+
assert "Uptime (24h): 75.0%" in desc
127+
128+
def test_ssl_expiring_soon(self) -> None:
129+
"""SSL certificate expiring soon is included."""
130+
status = UrlStatus(
131+
url_name="SSL_WARN",
132+
url="https://ssl.example.com",
133+
is_up=True,
134+
last_status_code=200,
135+
last_response_time_ms=100,
136+
last_error=None,
137+
last_check=datetime(2026, 1, 28, 10, 30, 0, tzinfo=UTC),
138+
checks_24h=24,
139+
uptime_24h=100.0,
140+
ssl_cert_expires_in_days=15,
141+
)
142+
desc = _status_to_description(status)
143+
assert "SSL Certificate: Expires in 15 days" in desc
144+
145+
def test_ssl_expired(self) -> None:
146+
"""Expired SSL certificate is flagged."""
147+
status = UrlStatus(
148+
url_name="SSL_EXP",
149+
url="https://expired.example.com",
150+
is_up=True,
151+
last_status_code=200,
152+
last_response_time_ms=100,
153+
last_error=None,
154+
last_check=datetime(2026, 1, 28, 10, 30, 0, tzinfo=UTC),
155+
checks_24h=24,
156+
uptime_24h=100.0,
157+
ssl_cert_expires_in_days=-5,
158+
)
159+
desc = _status_to_description(status)
160+
assert "SSL Certificate: EXPIRED (5 days ago)" in desc
161+
162+
163+
class TestGenerateRssFeed:
164+
"""Tests for generate_rss_feed function."""
165+
166+
def test_generates_valid_xml(self, sample_status: UrlStatus, default_rss_config: RssConfig) -> None:
167+
"""Generated RSS is valid XML."""
168+
xml_str = generate_rss_feed([sample_status], default_rss_config)
169+
# Should not raise
170+
root = ET.fromstring(xml_str)
171+
assert root.tag == "rss"
172+
assert root.attrib["version"] == "2.0"
173+
174+
def test_includes_xml_declaration(self, sample_status: UrlStatus, default_rss_config: RssConfig) -> None:
175+
"""RSS feed includes XML declaration."""
176+
xml_str = generate_rss_feed([sample_status], default_rss_config)
177+
assert xml_str.startswith("<?xml version")
178+
179+
def test_channel_metadata(self, sample_status: UrlStatus, custom_rss_config: RssConfig) -> None:
180+
"""Channel metadata is correctly set."""
181+
xml_str = generate_rss_feed([sample_status], custom_rss_config)
182+
root = ET.fromstring(xml_str)
183+
channel = root.find("channel")
184+
185+
assert channel is not None
186+
assert channel.find("title").text == "My Status Feed"
187+
assert channel.find("description").text == "Custom description"
188+
assert channel.find("link").text == "https://status.example.com"
189+
assert channel.find("generator").text == "WebStatusπ"
190+
191+
def test_item_content(self, sample_status: UrlStatus, default_rss_config: RssConfig) -> None:
192+
"""Item contains correct content."""
193+
xml_str = generate_rss_feed([sample_status], default_rss_config)
194+
root = ET.fromstring(xml_str)
195+
channel = root.find("channel")
196+
item = channel.find("item")
197+
198+
assert item is not None
199+
assert "✅ UP" in item.find("title").text
200+
assert "TEST_URL" in item.find("title").text
201+
assert item.find("link").text == "https://example.com"
202+
assert "Status: UP" in item.find("description").text
203+
assert item.find("guid") is not None
204+
assert item.find("pubDate") is not None
205+
206+
def test_down_status_title(self, sample_status_down: UrlStatus, default_rss_config: RssConfig) -> None:
207+
"""DOWN status shows correct title."""
208+
xml_str = generate_rss_feed([sample_status_down], default_rss_config)
209+
root = ET.fromstring(xml_str)
210+
channel = root.find("channel")
211+
item = channel.find("item")
212+
213+
assert "❌ DOWN" in item.find("title").text
214+
215+
def test_max_items_limit(self, default_rss_config: RssConfig) -> None:
216+
"""Feed respects max_items limit."""
217+
# Create more statuses than max_items
218+
statuses = [
219+
UrlStatus(
220+
url_name=f"SVC_{i}",
221+
url=f"https://example{i}.com",
222+
is_up=True,
223+
last_status_code=200,
224+
last_response_time_ms=100,
225+
last_error=None,
226+
last_check=datetime(2026, 1, 28, 10, i, 0, tzinfo=UTC),
227+
checks_24h=24,
228+
uptime_24h=100.0,
229+
)
230+
for i in range(30) # More than default 20
231+
]
232+
xml_str = generate_rss_feed(statuses, default_rss_config)
233+
root = ET.fromstring(xml_str)
234+
channel = root.find("channel")
235+
items = channel.findall("item")
236+
237+
assert len(items) == 20 # max_items default
238+
239+
def test_custom_max_items(self, custom_rss_config: RssConfig) -> None:
240+
"""Feed respects custom max_items."""
241+
statuses = [
242+
UrlStatus(
243+
url_name=f"SVC_{i}",
244+
url=f"https://example{i}.com",
245+
is_up=True,
246+
last_status_code=200,
247+
last_response_time_ms=100,
248+
last_error=None,
249+
last_check=datetime(2026, 1, 28, 10, i, 0, tzinfo=UTC),
250+
checks_24h=24,
251+
uptime_24h=100.0,
252+
)
253+
for i in range(10)
254+
]
255+
xml_str = generate_rss_feed(statuses, custom_rss_config)
256+
root = ET.fromstring(xml_str)
257+
channel = root.find("channel")
258+
items = channel.findall("item")
259+
260+
assert len(items) == 5 # custom max_items
261+
262+
def test_sorted_by_last_check(self, default_rss_config: RssConfig) -> None:
263+
"""Items are sorted by last_check descending."""
264+
statuses = [
265+
UrlStatus(
266+
url_name="OLD",
267+
url="https://old.com",
268+
is_up=True,
269+
last_status_code=200,
270+
last_response_time_ms=100,
271+
last_error=None,
272+
last_check=datetime(2026, 1, 28, 8, 0, 0, tzinfo=UTC),
273+
checks_24h=24,
274+
uptime_24h=100.0,
275+
),
276+
UrlStatus(
277+
url_name="NEW",
278+
url="https://new.com",
279+
is_up=True,
280+
last_status_code=200,
281+
last_response_time_ms=100,
282+
last_error=None,
283+
last_check=datetime(2026, 1, 28, 12, 0, 0, tzinfo=UTC),
284+
checks_24h=24,
285+
uptime_24h=100.0,
286+
),
287+
]
288+
xml_str = generate_rss_feed(statuses, default_rss_config)
289+
root = ET.fromstring(xml_str)
290+
channel = root.find("channel")
291+
items = channel.findall("item")
292+
293+
# NEW should be first (more recent)
294+
assert "NEW" in items[0].find("title").text
295+
assert "OLD" in items[1].find("title").text
296+
297+
def test_empty_statuses(self, default_rss_config: RssConfig) -> None:
298+
"""Empty statuses list generates valid feed with no items."""
299+
xml_str = generate_rss_feed([], default_rss_config)
300+
root = ET.fromstring(xml_str)
301+
channel = root.find("channel")
302+
items = channel.findall("item")
303+
304+
assert len(items) == 0
305+
assert channel.find("title") is not None
306+
307+
def test_build_date_custom(self, sample_status: UrlStatus, default_rss_config: RssConfig) -> None:
308+
"""Custom build_date is used in feed."""
309+
build_date = datetime(2026, 1, 28, 15, 0, 0, tzinfo=UTC)
310+
xml_str = generate_rss_feed([sample_status], default_rss_config, build_date=build_date)
311+
root = ET.fromstring(xml_str)
312+
channel = root.find("channel")
313+
last_build = channel.find("lastBuildDate").text
314+
315+
assert "28 Jan 2026" in last_build
316+
assert "15:00:00" in last_build
317+
318+
def test_no_link_when_empty(self, sample_status: UrlStatus) -> None:
319+
"""No link element when config.link is empty."""
320+
config = RssConfig(link="")
321+
xml_str = generate_rss_feed([sample_status], config)
322+
root = ET.fromstring(xml_str)
323+
channel = root.find("channel")
324+
325+
# link element should not exist or be empty
326+
link = channel.find("link")
327+
assert link is None or link.text == ""
328+
329+
def test_guid_is_unique(self, default_rss_config: RssConfig) -> None:
330+
"""Each item has a unique GUID."""
331+
statuses = [
332+
UrlStatus(
333+
url_name="SVC_A",
334+
url="https://a.com",
335+
is_up=True,
336+
last_status_code=200,
337+
last_response_time_ms=100,
338+
last_error=None,
339+
last_check=datetime(2026, 1, 28, 10, 0, 0, tzinfo=UTC),
340+
checks_24h=24,
341+
uptime_24h=100.0,
342+
),
343+
UrlStatus(
344+
url_name="SVC_B",
345+
url="https://b.com",
346+
is_up=False,
347+
last_status_code=500,
348+
last_response_time_ms=200,
349+
last_error="Error",
350+
last_check=datetime(2026, 1, 28, 10, 0, 0, tzinfo=UTC),
351+
checks_24h=24,
352+
uptime_24h=50.0,
353+
),
354+
]
355+
xml_str = generate_rss_feed(statuses, default_rss_config)
356+
root = ET.fromstring(xml_str)
357+
channel = root.find("channel")
358+
items = channel.findall("item")
359+
guids = [item.find("guid").text for item in items]
360+
361+
assert len(guids) == len(set(guids)) # All unique

0 commit comments

Comments
 (0)