11"""Tests for GetBlogPosts utility class."""
2- import json
32import time
43from unittest .mock import MagicMock , patch
54
1312 get_blog_posts ,
1413)
1514
16- SAMPLE_RESPONSE = {
17- "posts" : [
18- {
19- "title" : "Test Post" ,
20- "description" : "A test post." ,
21- "date" : "2026-01-01" ,
22- "url" : "https://www.litellm.ai/blog/test" ,
23- }
24- ]
25- }
15+ SAMPLE_RSS = """\
16+ <?xml version="1.0" encoding="UTF-8"?>
17+ <rss version="2.0">
18+ <channel>
19+ <title>LiteLLM Blog</title>
20+ <item>
21+ <title>Test Post</title>
22+ <link>https://docs.litellm.ai/blog/test</link>
23+ <description>A test post.</description>
24+ <pubDate>Wed, 01 Jan 2026 10:00:00 GMT</pubDate>
25+ </item>
26+ <item>
27+ <title>Second Post</title>
28+ <link>https://docs.litellm.ai/blog/second</link>
29+ <description>Another post.</description>
30+ <pubDate>Tue, 31 Dec 2025 10:00:00 GMT</pubDate>
31+ </item>
32+ </channel>
33+ </rss>
34+ """
2635
2736
2837@pytest .fixture (autouse = True )
@@ -45,26 +54,48 @@ def test_load_local_blog_posts_returns_list():
4554 assert "url" in first
4655
4756
48- def test_validate_blog_posts_valid ():
49- assert GetBlogPosts .validate_blog_posts (SAMPLE_RESPONSE ) is True
57+ def test_parse_rss_to_posts ():
58+ posts = GetBlogPosts .parse_rss_to_posts (SAMPLE_RSS , max_posts = 1 )
59+ assert len (posts ) == 1
60+ assert posts [0 ]["title" ] == "Test Post"
61+ assert posts [0 ]["url" ] == "https://docs.litellm.ai/blog/test"
62+ assert posts [0 ]["description" ] == "A test post."
63+ assert posts [0 ]["date" ] == "2026-01-01"
64+
65+
66+ def test_parse_rss_to_posts_multiple ():
67+ posts = GetBlogPosts .parse_rss_to_posts (SAMPLE_RSS , max_posts = 5 )
68+ assert len (posts ) == 2
69+ assert posts [1 ]["title" ] == "Second Post"
5070
5171
52- def test_validate_blog_posts_missing_posts_key ():
53- assert GetBlogPosts .validate_blog_posts ({"other" : []}) is False
72+ def test_parse_rss_to_posts_invalid_xml ():
73+ with pytest .raises (Exception ):
74+ GetBlogPosts .parse_rss_to_posts ("not xml" )
75+
76+
77+ def test_parse_rss_to_posts_missing_channel ():
78+ with pytest .raises (ValueError , match = "missing <channel>" ):
79+ GetBlogPosts .parse_rss_to_posts ("<rss></rss>" )
80+
81+
82+ def test_validate_blog_posts_valid ():
83+ posts = [{"title" : "T" , "description" : "D" , "date" : "2026-01-01" , "url" : "https://x.com" }]
84+ assert GetBlogPosts .validate_blog_posts (posts ) is True
5485
5586
5687def test_validate_blog_posts_empty_list ():
57- assert GetBlogPosts .validate_blog_posts ({ "posts" : []} ) is False
88+ assert GetBlogPosts .validate_blog_posts ([] ) is False
5889
5990
60- def test_validate_blog_posts_not_dict ():
61- assert GetBlogPosts .validate_blog_posts ("not a dict " ) is False
91+ def test_validate_blog_posts_not_list ():
92+ assert GetBlogPosts .validate_blog_posts ("not a list " ) is False
6293
6394
6495def test_get_blog_posts_success ():
65- """Fetches from remote on first call."""
96+ """Fetches from RSS on first call."""
6697 mock_response = MagicMock ()
67- mock_response .json . return_value = SAMPLE_RESPONSE
98+ mock_response .text = SAMPLE_RSS
6899 mock_response .raise_for_status = MagicMock ()
69100
70101 with patch ("litellm.litellm_core_utils.get_blog_posts.httpx.get" , return_value = mock_response ):
@@ -86,10 +117,10 @@ def test_get_blog_posts_network_error_falls_back_to_local():
86117 assert len (posts ) > 0
87118
88119
89- def test_get_blog_posts_invalid_json_falls_back_to_local ():
90- """Falls back when remote returns non-dict ."""
120+ def test_get_blog_posts_invalid_xml_falls_back_to_local ():
121+ """Falls back when remote returns invalid XML ."""
91122 mock_response = MagicMock ()
92- mock_response .json . return_value = "not a dict "
123+ mock_response .text = "not valid xml "
93124 mock_response .raise_for_status = MagicMock ()
94125
95126 with patch ("litellm.litellm_core_utils.get_blog_posts.httpx.get" , return_value = mock_response ):
@@ -101,7 +132,8 @@ def test_get_blog_posts_invalid_json_falls_back_to_local():
101132
102133def test_get_blog_posts_ttl_cache_not_refetched ():
103134 """Within TTL window, does not re-fetch."""
104- GetBlogPosts ._cached_posts = SAMPLE_RESPONSE ["posts" ]
135+ cached = [{"title" : "Cached" , "description" : "D" , "date" : "2026-01-01" , "url" : "https://x.com" }]
136+ GetBlogPosts ._cached_posts = cached
105137 GetBlogPosts ._last_fetch_time = time .time () # just now
106138
107139 call_count = 0
@@ -110,7 +142,7 @@ def mock_get(*args, **kwargs):
110142 nonlocal call_count
111143 call_count += 1
112144 m = MagicMock ()
113- m .json . return_value = SAMPLE_RESPONSE
145+ m .text = SAMPLE_RSS
114146 m .raise_for_status = MagicMock ()
115147 return m
116148
@@ -123,11 +155,12 @@ def mock_get(*args, **kwargs):
123155
124156def test_get_blog_posts_ttl_expired_refetches ():
125157 """After TTL window, re-fetches from remote."""
126- GetBlogPosts ._cached_posts = SAMPLE_RESPONSE ["posts" ]
158+ cached = [{"title" : "Cached" , "description" : "D" , "date" : "2026-01-01" , "url" : "https://x.com" }]
159+ GetBlogPosts ._cached_posts = cached
127160 GetBlogPosts ._last_fetch_time = time .time () - 7200 # 2 hours ago
128161
129162 mock_response = MagicMock ()
130- mock_response .json . return_value = SAMPLE_RESPONSE
163+ mock_response .text = SAMPLE_RSS
131164 mock_response .raise_for_status = MagicMock ()
132165
133166 with patch (
0 commit comments