11"""Tests for RSS engine: pull_rss, match_torrent, refresh_rss, add_rss."""
22
3+ import asyncio
4+
35import pytest
46from unittest .mock import AsyncMock , patch
57
@@ -51,7 +53,9 @@ async def test_returns_only_new_torrents(self, rss_engine):
5153 Torrent (name = "new1" , url = "https://example.com/new1.torrent" ),
5254 Torrent (name = "new2" , url = "https://example.com/new2.torrent" ),
5355 ]
54- with patch .object (RSSEngine , "_get_torrents" , new_callable = AsyncMock ) as mock_get :
56+ with patch .object (
57+ RSSEngine , "_get_torrents" , new_callable = AsyncMock
58+ ) as mock_get :
5559 mock_get .return_value = all_torrents
5660 result = await rss_engine .pull_rss (rss_item )
5761
@@ -67,7 +71,9 @@ async def test_all_existing_returns_empty(self, rss_engine):
6771 existing = make_torrent (url = "https://example.com/only.torrent" , rss_id = 1 )
6872 rss_engine .torrent .add (existing )
6973
70- with patch .object (RSSEngine , "_get_torrents" , new_callable = AsyncMock ) as mock_get :
74+ with patch .object (
75+ RSSEngine , "_get_torrents" , new_callable = AsyncMock
76+ ) as mock_get :
7177 mock_get .return_value = [
7278 Torrent (name = "only" , url = "https://example.com/only.torrent" )
7379 ]
@@ -81,7 +87,9 @@ async def test_empty_feed_returns_empty(self, rss_engine):
8187 rss_engine .rss .add (rss_item )
8288 rss_item = rss_engine .rss .search_id (1 )
8389
84- with patch .object (RSSEngine , "_get_torrents" , new_callable = AsyncMock ) as mock_get :
90+ with patch .object (
91+ RSSEngine , "_get_torrents" , new_callable = AsyncMock
92+ ) as mock_get :
8593 mock_get .return_value = []
8694 result = await rss_engine .pull_rss (rss_item )
8795
@@ -99,9 +107,7 @@ def test_matches_by_title_raw_substring(self, rss_engine):
99107 bangumi = make_bangumi (title_raw = "Mushoku Tensei" , filter = "" )
100108 rss_engine .bangumi .add (bangumi )
101109
102- torrent = make_torrent (
103- name = "[Lilith-Raws] Mushoku Tensei - 11 [1080p].mkv"
104- )
110+ torrent = make_torrent (name = "[Lilith-Raws] Mushoku Tensei - 11 [1080p].mkv" )
105111 result = rss_engine .match_torrent (torrent )
106112
107113 assert result is not None
@@ -122,9 +128,7 @@ def test_filter_excludes_matching_torrent(self, rss_engine):
122128 bangumi = make_bangumi (title_raw = "Mushoku Tensei" , filter = "720" )
123129 rss_engine .bangumi .add (bangumi )
124130
125- torrent = make_torrent (
126- name = "[Sub] Mushoku Tensei - 01 [720p].mkv"
127- )
131+ torrent = make_torrent (name = "[Sub] Mushoku Tensei - 01 [720p].mkv" )
128132 result = rss_engine .match_torrent (torrent )
129133
130134 assert result is None
@@ -134,9 +138,7 @@ def test_empty_filter_allows_match(self, rss_engine):
134138 bangumi = make_bangumi (title_raw = "Mushoku Tensei" , filter = "" )
135139 rss_engine .bangumi .add (bangumi )
136140
137- torrent = make_torrent (
138- name = "[Sub] Mushoku Tensei - 01 [720p].mkv"
139- )
141+ torrent = make_torrent (name = "[Sub] Mushoku Tensei - 01 [720p].mkv" )
140142 result = rss_engine .match_torrent (torrent )
141143
142144 assert result is not None
@@ -147,9 +149,7 @@ def test_filter_case_insensitive(self, rss_engine):
147149 rss_engine .bangumi .add (bangumi )
148150
149151 # Torrent has "hevc" in lowercase - should still be filtered
150- torrent = make_torrent (
151- name = "[Sub] Mushoku Tensei - 01 [1080p][hevc].mkv"
152- )
152+ torrent = make_torrent (name = "[Sub] Mushoku Tensei - 01 [1080p][hevc].mkv" )
153153 result = rss_engine .match_torrent (torrent )
154154
155155 assert result is None
@@ -201,7 +201,9 @@ async def test_downloads_matched_torrents(self, rss_engine, mock_qb_client):
201201 name = "[Sub] Mushoku Tensei - 12 [1080p].mkv" ,
202202 url = "https://example.com/ep12.torrent" ,
203203 )
204- with patch .object (RSSEngine , "_get_torrents" , new_callable = AsyncMock ) as mock_get :
204+ with patch .object (
205+ RSSEngine , "_get_torrents" , new_callable = AsyncMock
206+ ) as mock_get :
205207 mock_get .return_value = [new_torrent ]
206208
207209 # Create a mock client
@@ -227,7 +229,9 @@ async def test_unmatched_torrents_stored_not_downloaded(self, rss_engine):
227229 name = "[Sub] Unknown Anime - 01 [1080p].mkv" ,
228230 url = "https://example.com/unknown.torrent" ,
229231 )
230- with patch .object (RSSEngine , "_get_torrents" , new_callable = AsyncMock ) as mock_get :
232+ with patch .object (
233+ RSSEngine , "_get_torrents" , new_callable = AsyncMock
234+ ) as mock_get :
231235 mock_get .return_value = [unmatched ]
232236 client = AsyncMock ()
233237 await rss_engine .refresh_rss (client )
@@ -244,7 +248,9 @@ async def test_refresh_specific_rss_id(self, rss_engine):
244248 rss_engine .rss .add (rss1 )
245249 rss_engine .rss .add (rss2 )
246250
247- with patch .object (RSSEngine , "_get_torrents" , new_callable = AsyncMock ) as mock_get :
251+ with patch .object (
252+ RSSEngine , "_get_torrents" , new_callable = AsyncMock
253+ ) as mock_get :
248254 mock_get .return_value = []
249255 client = AsyncMock ()
250256 await rss_engine .refresh_rss (client , rss_id = 2 )
@@ -254,7 +260,9 @@ async def test_refresh_specific_rss_id(self, rss_engine):
254260
255261 async def test_refresh_nonexistent_rss_id (self , rss_engine ):
256262 """refresh_rss with non-existent rss_id does nothing."""
257- with patch .object (RSSEngine , "_get_torrents" , new_callable = AsyncMock ) as mock_get :
263+ with patch .object (
264+ RSSEngine , "_get_torrents" , new_callable = AsyncMock
265+ ) as mock_get :
258266 client = AsyncMock ()
259267 await rss_engine .refresh_rss (client , rss_id = 999 )
260268
@@ -284,9 +292,7 @@ async def test_add_with_name(self, rss_engine):
284292
285293 async def test_add_without_name_fetches_title (self , rss_engine ):
286294 """add_rss without name calls get_rss_title to auto-discover title."""
287- with patch (
288- "module.rss.engine.RequestContent"
289- ) as MockReq :
295+ with patch ("module.rss.engine.RequestContent" ) as MockReq :
290296 mock_instance = AsyncMock ()
291297 mock_instance .get_rss_title = AsyncMock (return_value = "Fetched Title" )
292298 MockReq .return_value .__aenter__ = AsyncMock (return_value = mock_instance )
@@ -303,9 +309,7 @@ async def test_add_without_name_fetches_title(self, rss_engine):
303309
304310 async def test_add_without_name_fetch_fails (self , rss_engine ):
305311 """add_rss returns error when title fetch fails."""
306- with patch (
307- "module.rss.engine.RequestContent"
308- ) as MockReq :
312+ with patch ("module.rss.engine.RequestContent" ) as MockReq :
309313 mock_instance = AsyncMock ()
310314 mock_instance .get_rss_title = AsyncMock (return_value = None )
311315 MockReq .return_value .__aenter__ = AsyncMock (return_value = mock_instance )
@@ -332,3 +336,36 @@ async def test_add_duplicate_url_fails(self, rss_engine):
332336
333337 assert result .status is False
334338 assert result .status_code == 406
339+
340+
341+ class TestRefreshRssConcurrency :
342+ async def test_concurrent_requests_limited (self , rss_engine ):
343+ """refresh_rss should limit concurrent requests via semaphore."""
344+ rss_items = [
345+ make_rss_item (name = f"Feed { i } " , url = f"https://feed{ i } .com/rss" )
346+ for i in range (10 )
347+ ]
348+ for item in rss_items :
349+ rss_engine .rss .add (item )
350+
351+ active_count = 0
352+ max_active = 0
353+ lock = asyncio .Lock ()
354+
355+ async def track_concurrency (rss_item ):
356+ nonlocal active_count , max_active
357+ async with lock :
358+ active_count += 1
359+ max_active = max (max_active , active_count )
360+ await asyncio .sleep (0.01 )
361+ async with lock :
362+ active_count -= 1
363+ return [], None
364+
365+ with patch .object (
366+ rss_engine , "_pull_rss_with_status" , side_effect = track_concurrency
367+ ):
368+ client = AsyncMock ()
369+ await rss_engine .refresh_rss (client )
370+
371+ assert max_active <= 5
0 commit comments