返回 last30days-skill
test_xiaohongshu_api.py
根目录 / tests / test_xiaohongshu_api.py
1 """Xiaohongshu source tests.
2
3 These stay fully mocked because the real source depends on a logged-in local
4 xiaohongshu-mcp service, which CI and contributors will not have by default.
5 """
6
7 from datetime import datetime, timezone
8 from unittest import mock
9
10 import pytest
11
12 import last30days as cli
13 from lib import env, http, pipeline, xiaohongshu_api
14
15
16 def test_search_flag_accepts_xhs_alias():
17 assert cli.parse_search_flag("xhs") == ["xiaohongshu"]
18 assert pipeline.normalize_requested_sources(["xhs"]) == ["xiaohongshu"]
19
20
21 def test_xiaohongshu_requested_source_requires_live_logged_in_service():
22 with mock.patch.object(pipeline.env, "is_xiaohongshu_available", return_value=False):
23 assert "xiaohongshu" not in pipeline.available_sources(
24 {}, requested_sources=["xiaohongshu"],
25 )
26
27 with mock.patch.object(pipeline.env, "is_xiaohongshu_available", return_value=True):
28 assert "xiaohongshu" in pipeline.available_sources(
29 {}, requested_sources=["xiaohongshu"],
30 )
31
32
33 def test_xiaohongshu_default_api_base_is_localhost():
34 assert env.get_xiaohongshu_api_base({}) == "http://localhost:18060"
35
36
37 def test_xiaohongshu_availability_prefers_localhost_and_caches_base():
38 config = {}
39
40 def fake_get(url, **kwargs):
41 # This mimics an x-mcp/browser-backed service running in the user's
42 # normal local browser context.
43 assert url.startswith("http://localhost:18060/")
44 if url.endswith("/health"):
45 return {"success": True}
46 if url.endswith("/api/v1/login/status"):
47 return {"data": {"is_logged_in": True}}
48 raise AssertionError(f"unexpected URL: {url}")
49
50 with mock.patch.object(http, "get", side_effect=fake_get) as get_mock:
51 assert env.is_xiaohongshu_available(config) is True
52
53 assert get_mock.call_count == 2
54 assert config[env.XIAOHONGSHU_RESOLVED_API_BASE_KEY] == "http://localhost:18060"
55 assert env.get_xiaohongshu_api_base(config) == "http://localhost:18060"
56
57
58 def test_xiaohongshu_availability_falls_back_to_docker_host():
59 config = {}
60
61 def fake_get(url, **kwargs):
62 if url.startswith("http://localhost:18060/"):
63 raise http.HTTPError("not running")
64 if url == "http://host.docker.internal:18060/health":
65 return {"success": True}
66 if url == "http://host.docker.internal:18060/api/v1/login/status":
67 return {"data": {"is_logged_in": True}}
68 raise AssertionError(f"unexpected URL: {url}")
69
70 with mock.patch.object(http, "get", side_effect=fake_get):
71 assert env.is_xiaohongshu_available(config) is True
72
73 assert (
74 config[env.XIAOHONGSHU_RESOLVED_API_BASE_KEY]
75 == "http://host.docker.internal:18060"
76 )
77 assert env.get_xiaohongshu_api_base(config) == "http://host.docker.internal:18060"
78
79
80 def test_xiaohongshu_explicit_api_base_skips_default_probe():
81 config = {"XIAOHONGSHU_API_BASE": "http://custom.local:18060/"}
82 seen_urls = []
83
84 def fake_get(url, **kwargs):
85 seen_urls.append(url)
86 assert url.startswith("http://custom.local:18060/")
87 if url.endswith("/health"):
88 return {"success": True}
89 if url.endswith("/api/v1/login/status"):
90 return {"data": {"is_logged_in": True}}
91 raise AssertionError(f"unexpected URL: {url}")
92
93 with mock.patch.object(http, "get", side_effect=fake_get):
94 assert env.is_xiaohongshu_available(config) is True
95
96 assert seen_urls == [
97 "http://custom.local:18060/health",
98 "http://custom.local:18060/api/v1/login/status",
99 ]
100 assert config[env.XIAOHONGSHU_RESOLVED_API_BASE_KEY] == "http://custom.local:18060"
101
102
103 def test_to_int_accepts_chinese_count_suffixes():
104 assert xiaohongshu_api._to_int("1.2万") == 12000
105 assert xiaohongshu_api._to_int("3亿") == 300000000
106 assert xiaohongshu_api._to_int("42") == 42
107 assert xiaohongshu_api._to_int(None) == 0
108
109
110 def test_search_feeds_normalizes_xiaohongshu_response():
111 timestamp_ms = int(datetime(2026, 7, 1, tzinfo=timezone.utc).timestamp() * 1000)
112 response = {
113 "data": {
114 "feeds": [
115 {
116 "id": "note-1",
117 "xsecToken": "token-1",
118 "noteCard": {
119 "displayTitle": "Popular matcha latte",
120 "desc": "A creator review with useful comments.",
121 "time": timestamp_ms,
122 "interactInfo": {
123 "likedCount": "1.2万",
124 "commentCount": "345",
125 "collectedCount": "6,789",
126 },
127 },
128 }
129 ]
130 }
131 }
132
133 with mock.patch.object(xiaohongshu_api.http, "get", return_value={"data": {"is_logged_in": True}}) as get_mock, \
134 mock.patch.object(xiaohongshu_api.http, "post", return_value=response) as post_mock:
135 items = xiaohongshu_api.search_feeds(
136 "matcha latte", "2026-06-01", "2026-07-01",
137 "http://localhost:18060/", depth="default",
138 )
139
140 assert get_mock.call_args.args[0] == "http://localhost:18060/api/v1/login/status"
141 assert post_mock.call_args.args[0] == "http://localhost:18060/api/v1/feeds/search"
142 payload = post_mock.call_args.args[1]
143 assert payload["keyword"] == "matcha latte"
144 assert payload["filters"]["publish_time"] == "一周内"
145
146 assert items == [
147 {
148 "id": "XHS1",
149 "title": "Popular matcha latte",
150 "url": "https://www.xiaohongshu.com/explore/note-1?xsec_token=token-1",
151 "source_domain": "xiaohongshu.com",
152 "snippet": "A creator review with useful comments.",
153 "date": "2026-07-01",
154 "date_confidence": "high",
155 "relevance": 1.0,
156 "why_relevant": "Xiaohongshu engagement: likes=12000, comments=345, favorites=6789",
157 "engagement": {
158 "likes": 12000,
159 "comments": 345,
160 "favorites": 6789,
161 },
162 }
163 ]
164
165
166 def test_search_feeds_requires_logged_in_xiaohongshu_session():
167 with mock.patch.object(xiaohongshu_api.http, "get", return_value={"data": {"is_logged_in": False}}):
168 with pytest.raises(http.HTTPError, match="not logged in"):
169 xiaohongshu_api.search_feeds(
170 "matcha latte", "2026-06-01", "2026-07-01",
171 "http://localhost:18060",
172 )
173
174
175 def test_xiaohongshu_activates_via_persisted_include_sources(monkeypatch):
176 from unittest import mock as _mock
177 from lib import env as _env, pipeline as _pipeline
178
179 probe = _mock.Mock(return_value=True)
180 monkeypatch.setattr(_env, "is_xiaohongshu_available", probe)
181
182 available = _pipeline.available_sources(
183 {"INCLUDE_SOURCES": "xiaohongshu"}, None, x_pending=False
184 )
185
186 assert "xiaohongshu" in available
187 probe.assert_called_once()
188
189
190 def test_xiaohongshu_probe_never_fires_without_any_opt_in(monkeypatch):
191 from unittest import mock as _mock
192 from lib import env as _env, pipeline as _pipeline
193
194 probe = _mock.Mock(return_value=True)
195 monkeypatch.setattr(_env, "is_xiaohongshu_available", probe)
196
197 available = _pipeline.available_sources({}, None, x_pending=False)
198
199 assert "xiaohongshu" not in available
200 probe.assert_not_called()
201
201 lines PYTHON