| 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 |