返回 douyin-downloader
test_user_downloader_modes.py
根目录 / tests / test_user_downloader_modes.py
1 import asyncio
2 from typing import Any, Dict, List
3
4 from control.queue_manager import QueueManager
5 from core.user_downloader import UserDownloader
6 from storage.file_manager import FileManager
7
8
9 def _make_aweme(aweme_id: str) -> Dict[str, Any]:
10 return {
11 "aweme_id": aweme_id,
12 "desc": f"desc-{aweme_id}",
13 "create_time": 1700000000,
14 "author": {"nickname": "tester", "uid": "uid-1"},
15 "video": {"play_addr": {"url_list": ["https://example.com/video.mp4"]}},
16 }
17
18
19 class _FakeConfig:
20 def __init__(self, data: Dict[str, Any]):
21 self._data = data
22
23 def get(self, key: str, default: Any = None) -> Any:
24 return self._data.get(key, default)
25
26
27 class _FakeCookieManager:
28 pass
29
30
31 class _NoopRateLimiter:
32 async def acquire(self):
33 return
34
35
36 class _FakeAPIClient:
37 def __init__(self):
38 self.user_info_calls = []
39 self.collect_calls = 0
40 self.collect_mix_calls = 0
41
42 async def get_user_info(self, _sec_uid: str):
43 self.user_info_calls.append(_sec_uid)
44 return {"uid": "uid-1", "nickname": "tester", "aweme_count": 99}
45
46 async def get_user_post(self, _sec_uid: str, max_cursor: int = 0, count: int = 20):
47 if max_cursor > 0:
48 return {"items": [], "has_more": False, "max_cursor": max_cursor, "status_code": 0}
49 return {
50 "items": [_make_aweme("111"), _make_aweme("222")],
51 "has_more": False,
52 "max_cursor": 0,
53 "status_code": 0,
54 }
55
56 async def get_user_like(self, _sec_uid: str, max_cursor: int = 0, count: int = 20):
57 if max_cursor > 0:
58 return {"items": [], "has_more": False, "max_cursor": max_cursor, "status_code": 0}
59 return {
60 "items": [_make_aweme("222"), _make_aweme("333")],
61 "has_more": False,
62 "max_cursor": 0,
63 "status_code": 0,
64 }
65
66 async def get_user_mix(self, _sec_uid: str, max_cursor: int = 0, count: int = 20):
67 return {
68 "items": [_make_aweme("444")],
69 "has_more": False,
70 "max_cursor": 0,
71 "status_code": 0,
72 }
73
74 async def get_user_music(self, _sec_uid: str, max_cursor: int = 0, count: int = 20):
75 return {
76 "items": [_make_aweme("555")],
77 "has_more": False,
78 "max_cursor": 0,
79 "status_code": 0,
80 }
81
82 async def get_user_collects(self, _sec_uid: str, max_cursor: int = 0, count: int = 20):
83 self.collect_calls += 1
84 return {
85 "items": [{"collects_id_str": "collect-1", "collects_name": "默认收藏夹"}],
86 "has_more": False,
87 "max_cursor": 0,
88 "status_code": 0,
89 }
90
91 async def get_collect_aweme(self, collects_id: str, max_cursor: int = 0, count: int = 20):
92 assert collects_id == "collect-1"
93 return {
94 "items": [_make_aweme("666")],
95 "has_more": False,
96 "max_cursor": 0,
97 "status_code": 0,
98 }
99
100
101 def _build_downloader(tmp_path, mode: List[str]) -> UserDownloader:
102 config_data = {
103 "number": {"post": 0, "like": 0, "mix": 0, "music": 0},
104 "increase": {"post": False, "like": False, "mix": False, "music": False},
105 "mode": mode,
106 "thread": 2,
107 "browser_fallback": {"enabled": False},
108 }
109 config = _FakeConfig(config_data)
110 file_manager = FileManager(str(tmp_path / "Downloaded"))
111 downloader = UserDownloader(
112 config=config,
113 api_client=_FakeAPIClient(),
114 file_manager=file_manager,
115 cookie_manager=_FakeCookieManager(),
116 database=None,
117 rate_limiter=_NoopRateLimiter(),
118 retry_handler=None,
119 queue_manager=QueueManager(max_workers=2),
120 )
121 return downloader
122
123
124 def test_user_downloader_processes_modes_and_deduplicates_across_modes(tmp_path, monkeypatch):
125 downloader = _build_downloader(tmp_path, mode=["post", "like"])
126
127 async def _always_true(*_args, **_kwargs):
128 return True
129
130 async def _download_ok(*_args, **_kwargs):
131 return True
132
133 monkeypatch.setattr(downloader, "_should_download", _always_true)
134 monkeypatch.setattr(downloader, "_download_aweme_assets", _download_ok)
135
136 result = asyncio.run(downloader.download({"sec_uid": "sec_uid_x"}))
137
138 # 去重后应仅 3 条(111,222,333)
139 assert result.total == 3
140 assert result.success == 3
141 assert result.failed == 0
142 assert result.skipped == 0
143
144
145 def test_user_downloader_supports_mix_and_music_modes(tmp_path, monkeypatch):
146 downloader = _build_downloader(tmp_path, mode=["mix", "music"])
147
148 async def _always_true(*_args, **_kwargs):
149 return True
150
151 async def _download_ok(*_args, **_kwargs):
152 return True
153
154 monkeypatch.setattr(downloader, "_should_download", _always_true)
155 monkeypatch.setattr(downloader, "_download_aweme_assets", _download_ok)
156
157 result = asyncio.run(downloader.download({"sec_uid": "sec_uid_x"}))
158
159 assert result.total == 2
160 assert result.success == 2
161
162
163 def test_user_downloader_supports_self_collect_mode(tmp_path, monkeypatch):
164 downloader = _build_downloader(tmp_path, mode=["collect"])
165
166 async def _always_true(*_args, **_kwargs):
167 return True
168
169 async def _download_ok(*_args, **_kwargs):
170 return True
171
172 monkeypatch.setattr(downloader, "_should_download", _always_true)
173 monkeypatch.setattr(downloader, "_download_aweme_assets", _download_ok)
174
175 result = asyncio.run(downloader.download({"sec_uid": "self"}))
176
177 assert result.total == 1
178 assert result.success == 1
179 assert downloader.api_client.user_info_calls == []
180
181
182 def test_user_downloader_rejects_non_self_collect_mode(tmp_path, monkeypatch):
183 downloader = _build_downloader(tmp_path, mode=["collect"])
184
185 async def _always_true(*_args, **_kwargs):
186 return True
187
188 async def _download_ok(*_args, **_kwargs):
189 return True
190
191 monkeypatch.setattr(downloader, "_should_download", _always_true)
192 monkeypatch.setattr(downloader, "_download_aweme_assets", _download_ok)
193
194 result = asyncio.run(downloader.download({"sec_uid": "sec_uid_x"}))
195
196 assert result.total == 0
197 assert result.success == 0
198 assert downloader.api_client.user_info_calls == []
199 assert downloader.api_client.collect_calls == 0
200
201
202 def test_user_downloader_post_mode_uses_batch_db_insert(tmp_path, monkeypatch):
203 """Post-mode should write all aweme records via a single add_aweme_batch
204 instead of N individual add_aweme commits."""
205 from storage.database import Database
206
207 db_path = tmp_path / "test.db"
208 database = Database(str(db_path))
209 asyncio.run(database.initialize())
210
211 config_data = {
212 "number": {"post": 0, "like": 0, "mix": 0, "music": 0},
213 "increase": {"post": False, "like": False, "mix": False, "music": False},
214 "mode": ["post"],
215 "thread": 2,
216 "browser_fallback": {"enabled": False},
217 }
218 config = _FakeConfig(config_data)
219 file_manager = FileManager(str(tmp_path / "Downloaded"))
220 downloader = UserDownloader(
221 config=config,
222 api_client=_FakeAPIClient(),
223 file_manager=file_manager,
224 cookie_manager=_FakeCookieManager(),
225 database=database,
226 rate_limiter=_NoopRateLimiter(),
227 retry_handler=None,
228 queue_manager=QueueManager(max_workers=2),
229 )
230
231 add_aweme_calls = {"n": 0}
232 add_aweme_batch_calls: List[List[Dict[str, Any]]] = []
233
234 original_add_aweme = database.add_aweme
235 original_add_aweme_batch = database.add_aweme_batch
236
237 async def counting_add_aweme(record):
238 add_aweme_calls["n"] += 1
239 return await original_add_aweme(record)
240
241 async def counting_add_aweme_batch(records):
242 add_aweme_batch_calls.append(list(records))
243 return await original_add_aweme_batch(records)
244
245 monkeypatch.setattr(database, "add_aweme", counting_add_aweme)
246 monkeypatch.setattr(database, "add_aweme_batch", counting_add_aweme_batch)
247
248 async def _always_true(*_args, **_kwargs):
249 return True
250
251 async def _fake_download_aweme_assets(item, _author, *, mode=None, db_batch=None):
252 if db_batch is not None:
253 db_batch.append(
254 {
255 "aweme_id": item.get("aweme_id"),
256 "aweme_type": "video",
257 "title": item.get("desc"),
258 "author_id": item["author"]["uid"],
259 "author_name": item["author"]["nickname"],
260 "create_time": item.get("create_time"),
261 "file_path": "/tmp",
262 "metadata": "{}",
263 }
264 )
265 return True
266
267 monkeypatch.setattr(downloader, "_should_download", _always_true)
268 monkeypatch.setattr(downloader, "_download_aweme_assets", _fake_download_aweme_assets)
269
270 result = asyncio.run(downloader.download({"sec_uid": "sec_uid_x"}))
271
272 assert result.success == 2
273 assert add_aweme_calls["n"] == 0, (
274 f"post mode should not call add_aweme per item; got {add_aweme_calls['n']} single inserts"
275 )
276 assert len(add_aweme_batch_calls) == 1
277 assert {r["aweme_id"] for r in add_aweme_batch_calls[0]} == {"111", "222"}
278
279 # Verify rows actually landed in the DB.
280 assert asyncio.run(database.is_downloaded("111")) is True
281 assert asyncio.run(database.is_downloaded("222")) is True
282
283 asyncio.run(database.close())
284
285
286 def test_user_downloader_rejects_mixed_self_collect_and_regular_modes(tmp_path, monkeypatch):
287 downloader = _build_downloader(tmp_path, mode=["collect", "post"])
288
289 async def _always_true(*_args, **_kwargs):
290 return True
291
292 async def _download_ok(*_args, **_kwargs):
293 return True
294
295 monkeypatch.setattr(downloader, "_should_download", _always_true)
296 monkeypatch.setattr(downloader, "_download_aweme_assets", _download_ok)
297
298 result = asyncio.run(downloader.download({"sec_uid": "self"}))
299
300 assert result.total == 0
301 assert result.success == 0
302 assert downloader.api_client.user_info_calls == []
303 assert downloader.api_client.collect_calls == 0
304
304 lines PYTHON