返回 Social Auto Upload
test_xiaohongshu_uploader.py
根目录 / tests / test_xiaohongshu_uploader.py
1 import asyncio
2 import os
3 import tempfile
4 import unittest
5 from pathlib import Path
6 from unittest.mock import AsyncMock, patch
7
8 import uploader.xiaohongshu_uploader.main as xhs_main
9
10
11 class FakeLocator:
12 def __init__(self, name, count=0, src=None, children=None):
13 self.name = name
14 self._count = count
15 self._src = src
16 self._children = children or {}
17
18 @property
19 def first(self):
20 return self
21
22 def locator(self, selector):
23 return self._children.get(selector, FakeLocator(selector))
24
25 def get_by_text(self, text, exact=False):
26 return self._children.get(f"text:{text}", FakeLocator(text))
27
28 def filter(self, **kwargs):
29 return self
30
31 def nth(self, index):
32 return self
33
34 async def count(self):
35 return self._count
36
37 async def wait_for(self, **kwargs):
38 return None
39
40 async def get_attribute(self, name):
41 if name == "src":
42 return self._src
43 return None
44
45 async def fill(self, value):
46 return None
47
48 async def click(self):
49 return None
50
51
52 class RecordingKeyboard:
53 def __init__(self):
54 self.actions = []
55
56 async def press(self, key):
57 self.actions.append(("press", key))
58
59 async def type(self, text, delay=None):
60 self.actions.append(("type", text, delay))
61
62
63 class RecordingLocator(FakeLocator):
64 def __init__(self, name):
65 super().__init__(name, count=1)
66 self.actions = []
67
68 async def fill(self, value):
69 self.actions.append(("fill", value))
70
71 async def click(self):
72 self.actions.append(("click",))
73
74 async def wait_for(self, **kwargs):
75 self.actions.append(("wait_for", kwargs))
76
77
78 class RecordingPage:
79 def __init__(self):
80 self.keyboard = RecordingKeyboard()
81 self.locators = {
82 'input[placeholder*="填写标题"]': RecordingLocator("title"),
83 'p[data-placeholder*="输入正文描述"]': RecordingLocator("desc"),
84 '#creator-editor-topic-container': RecordingLocator("topic-container"),
85 '#creator-editor-topic-container .item': RecordingLocator("topic-item"),
86 }
87
88 def locator(self, selector):
89 return self.locators[selector]
90
91
92 class XiaohongshuUploaderTests(unittest.TestCase):
93 def test_creator_urls_keep_xiaohongshu_domain_by_default(self):
94 with patch.dict(os.environ, {"SAU_XHS_CREATOR_BASE_URL": ""}):
95 self.assertEqual(
96 xhs_main._build_xhs_creator_url("/login"),
97 "https://creator.xiaohongshu.com/login",
98 )
99
100 def test_creator_urls_use_configured_rednote_domain(self):
101 with patch.dict(
102 os.environ,
103 {"SAU_XHS_CREATOR_BASE_URL": "https://creator.rednote.com/"},
104 ):
105 self.assertEqual(
106 xhs_main._build_xhs_creator_url("/login"),
107 "https://creator.rednote.com/login",
108 )
109 self.assertEqual(
110 xhs_main._build_xhs_creator_url(
111 "/publish/publish?from=homepage&target=video"
112 ),
113 "https://creator.rednote.com/publish/publish?from=homepage&target=video",
114 )
115
116 def test_find_xhs_qrcode_locator_prefers_scan_sibling_inside_login_box(self):
117 qrcode_locator = FakeLocator("qrcode", count=1, src="data:image/png;base64,abc")
118 scan_text_locator = FakeLocator(
119 "scan-text",
120 count=1,
121 children={
122 "xpath=..//following-sibling::div//img": qrcode_locator,
123 },
124 )
125 login_box_locator = FakeLocator(
126 "login-box",
127 count=1,
128 children={
129 "div:has-text('扫一扫')": scan_text_locator,
130 "text:APP扫一扫登录": scan_text_locator,
131 },
132 )
133 page = FakeLocator(
134 "page",
135 children={
136 "div[class*='login-box']": login_box_locator,
137 ".login-box-container": login_box_locator,
138 },
139 )
140
141 locator = asyncio.run(xhs_main._find_xhs_qrcode_locator(page))
142 self.assertIs(locator, qrcode_locator)
143
144 def test_setup_returns_detail_when_cookie_invalid_without_handle(self):
145 with patch("uploader.xiaohongshu_uploader.main.os.path.exists", return_value=False):
146 result = asyncio.run(
147 xhs_main.xiaohongshu_setup(
148 "missing.json",
149 handle=False,
150 return_detail=True,
151 )
152 )
153 self.assertFalse(result["success"])
154 self.assertEqual(result["status"], "cookie_invalid")
155
156 def test_setup_uses_login_flow_when_handle_is_true(self):
157 login_result = {
158 "success": True,
159 "status": "success",
160 "message": "ok",
161 "account_file": "account.json",
162 "qrcode": {"image_path": "qrcode.png"},
163 "current_url": "https://creator.xiaohongshu.com/",
164 }
165 with patch("uploader.xiaohongshu_uploader.main.os.path.exists", return_value=False):
166 with patch(
167 "uploader.xiaohongshu_uploader.main.xiaohongshu_cookie_gen",
168 new=AsyncMock(return_value=login_result),
169 ) as mock_login:
170 result = asyncio.run(
171 xhs_main.xiaohongshu_setup(
172 "account.json",
173 handle=True,
174 return_detail=True,
175 )
176 )
177 self.assertTrue(result["success"])
178 mock_login.assert_awaited_once()
179
180 def test_video_validate_upload_args_normalizes_video_and_thumbnail(self):
181 with tempfile.TemporaryDirectory() as tmp_dir:
182 video_path = Path(tmp_dir) / "demo.mp4"
183 thumbnail_path = Path(tmp_dir) / "demo.png"
184 cookie_path = Path(tmp_dir) / "account.json"
185 video_path.write_bytes(b"video")
186 thumbnail_path.write_bytes(b"image")
187 cookie_path.write_text("{}")
188
189 app = xhs_main.XiaoHongShuVideo(
190 title="demo",
191 file_path=str(video_path),
192 tags=["xhs"],
193 publish_date=0,
194 account_file=str(cookie_path),
195 thumbnail_path=str(thumbnail_path),
196 )
197
198 with patch(
199 "uploader.xiaohongshu_uploader.main.cookie_auth",
200 new=AsyncMock(return_value=True),
201 ):
202 asyncio.run(app.validate_upload_args())
203
204 self.assertTrue(app.file_path.endswith("demo.mp4"))
205 self.assertTrue(app.thumbnail_path.endswith("demo.png"))
206
207 def test_note_uploader_exists_and_validates_required_fields(self):
208 note_cls = getattr(xhs_main, "XiaoHongShuNote")
209 app = note_cls(
210 image_paths=[],
211 note="",
212 tags=[],
213 publish_date=0,
214 account_file="account.json",
215 )
216
217 with patch.object(app, "validate_base_args", new=AsyncMock(return_value=None)):
218 with self.assertRaises(ValueError):
219 asyncio.run(app.validate_upload_args())
220
221 def test_video_fill_meta_uses_desc_then_first_tag(self):
222 app = xhs_main.XiaoHongShuVideo(
223 title="标题内容",
224 file_path="demo.mp4",
225 tags=["话题1"],
226 publish_date=0,
227 account_file="account.json",
228 desc="描述内容",
229 )
230 page = RecordingPage()
231
232 asyncio.run(app.fill_meta(page))
233
234 self.assertEqual(
235 page.locators['input[placeholder*="填写标题"]'].actions,
236 [("fill", "标题内容")],
237 )
238 self.assertEqual(
239 page.locators['p[data-placeholder*="输入正文描述"]'].actions,
240 [("click",)],
241 )
242 self.assertIn(("type", "描述内容", None), page.keyboard.actions)
243 self.assertIn(("type", "#话题1", 30), page.keyboard.actions)
244 self.assertEqual(
245 page.locators['#creator-editor-topic-container .item'].actions,
246 [("wait_for", {"state": "visible", "timeout": 2000}), ("click",)],
247 )
248
249 def test_video_fill_meta_can_fill_first_tag_without_desc(self):
250 app = xhs_main.XiaoHongShuVideo(
251 title="标题内容",
252 file_path="demo.mp4",
253 tags=["话题1"],
254 publish_date=0,
255 account_file="account.json",
256 )
257 page = RecordingPage()
258
259 asyncio.run(app.fill_meta(page))
260
261 self.assertEqual(
262 page.locators['p[data-placeholder*="输入正文描述"]'].actions,
263 [("click",)],
264 )
265 self.assertNotIn(("type", "", None), page.keyboard.actions)
266 self.assertIn(("type", "#话题1", 30), page.keyboard.actions)
267
268 def test_note_title_defaults_do_not_override_explicit_title(self):
269 app = xhs_main.XiaoHongShuNote(
270 image_paths=["a.png"],
271 note="正文",
272 tags=[],
273 publish_date=0,
274 account_file="account.json",
275 title="显式标题",
276 desc="图文正文",
277 )
278
279 self.assertEqual(app.title, "显式标题")
280 self.assertEqual(app.desc, "图文正文")
281
282
283 if __name__ == "__main__":
284 unittest.main()
285
285 lines PYTHON