279 lines
10 KiB
Python
279 lines
10 KiB
Python
"""Unit tests for XHS client request payloads, cookies, and endpoint selection."""
|
|
|
|
from collections import OrderedDict
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from xhs_cli.client import XhsClient
|
|
from xhs_cli.cookies import cache_note_context, get_cached_note_context
|
|
from xhs_cli.exceptions import UnsupportedOperationError, XhsApiError
|
|
|
|
|
|
class TestFavorites:
|
|
def test_unfavorite_uses_note_ids_payload(self, monkeypatch):
|
|
captured = {}
|
|
|
|
def fake_post(self, uri, data, header_overrides=None):
|
|
captured["uri"] = uri
|
|
captured["data"] = data
|
|
return True
|
|
|
|
monkeypatch.setattr(XhsClient, "_main_api_post", fake_post)
|
|
|
|
client = XhsClient({"a1": "cookie"})
|
|
try:
|
|
client.unfavorite_note("note-123")
|
|
finally:
|
|
client.close()
|
|
|
|
assert captured["uri"] == "/api/sns/web/v1/note/uncollect"
|
|
assert captured["data"] == {"note_ids": "note-123"}
|
|
|
|
|
|
class TestCreatorEndpoints:
|
|
def test_creator_note_list_uses_v2_endpoint(self, monkeypatch):
|
|
captured = {}
|
|
|
|
def fake_get(self, uri, params=None):
|
|
captured["uri"] = uri
|
|
captured["params"] = params
|
|
return {"notes": [], "page": -1}
|
|
|
|
monkeypatch.setattr(XhsClient, "_creator_get", fake_get)
|
|
|
|
client = XhsClient({"a1": "cookie"})
|
|
try:
|
|
result = client.get_creator_note_list(page=2)
|
|
finally:
|
|
client.close()
|
|
|
|
assert result["page"] == -1
|
|
assert captured["uri"] == "/api/galaxy/v2/creator/note/user/posted"
|
|
assert captured["params"] == {"tab": 0, "page": 2}
|
|
|
|
def test_delete_note_raises_unsupported_for_404(self, monkeypatch):
|
|
def fake_post(self, uri, data):
|
|
raise XhsApiError(
|
|
"API error: {\"status\": 404}",
|
|
response={"status": 404},
|
|
)
|
|
|
|
monkeypatch.setattr(XhsClient, "_creator_post", fake_post)
|
|
|
|
client = XhsClient({"a1": "cookie"})
|
|
try:
|
|
with pytest.raises(UnsupportedOperationError, match="Delete note is currently unavailable"):
|
|
client.delete_note("note-123")
|
|
finally:
|
|
client.close()
|
|
|
|
|
|
class TestTransportCookies:
|
|
def test_request_with_retry_merges_response_cookies(self, monkeypatch):
|
|
request = httpx.Request("POST", "https://edith.xiaohongshu.com/api/test")
|
|
response = httpx.Response(
|
|
200,
|
|
headers=[
|
|
("set-cookie", "web_session=real-session; Path=/; Domain=.xiaohongshu.com"),
|
|
("set-cookie", "web_session_sec=real-sec; Path=/; Domain=.xiaohongshu.com"),
|
|
],
|
|
json={"success": True, "data": {"ok": True}},
|
|
request=request,
|
|
)
|
|
|
|
class _FakeHttpClient:
|
|
def request(self, method, url, **kwargs):
|
|
return response
|
|
|
|
def close(self):
|
|
return None
|
|
|
|
client = XhsClient({"a1": "cookie", "web_session": "guest-session"}, request_delay=0)
|
|
client._http = _FakeHttpClient()
|
|
try:
|
|
resp = client._request_with_retry("POST", "https://edith.xiaohongshu.com/api/test")
|
|
finally:
|
|
client.close()
|
|
|
|
assert resp is response
|
|
assert client.cookies["web_session"] == "real-session"
|
|
assert client.cookies["web_session_sec"] == "real-sec"
|
|
|
|
|
|
class TestReadingEndpointBehavior:
|
|
def test_get_note_detail_prefers_cached_xsec_source(self, monkeypatch):
|
|
captured = {}
|
|
|
|
def fake_get_note_by_id(self, note_id, xsec_token="", xsec_source="pc_feed"):
|
|
captured["note_id"] = note_id
|
|
captured["token"] = xsec_token
|
|
captured["source"] = xsec_source
|
|
return {"items": [{"note_card": {"title": "ok"}}]}
|
|
|
|
monkeypatch.setattr(XhsClient, "get_note_by_id", fake_get_note_by_id)
|
|
cache_note_context("note-123", "token-xyz", "pc_search")
|
|
|
|
client = XhsClient({"a1": "cookie"})
|
|
try:
|
|
client.get_note_detail("note-123")
|
|
finally:
|
|
client.close()
|
|
|
|
assert captured == {
|
|
"note_id": "note-123",
|
|
"token": "token-xyz",
|
|
"source": "pc_search",
|
|
}
|
|
|
|
def test_search_notes_uses_browser_like_prewarm_sequence(self, monkeypatch):
|
|
calls = []
|
|
|
|
monkeypatch.setattr("xhs_cli.client_mixins._SEARCH_SESSION_CACHE", OrderedDict())
|
|
monkeypatch.setattr("xhs_cli.client_mixins._SEARCH_SESSION_CACHE_LOADED", True)
|
|
|
|
def fake_get(self, uri, params=None):
|
|
calls.append(("GET", uri, params))
|
|
return {"ok": True}
|
|
|
|
def fake_post(self, uri, data, header_overrides=None):
|
|
calls.append(("POST", uri, data))
|
|
if uri == "/api/sns/web/v1/search/notes":
|
|
return {"items": [], "has_more": False}
|
|
return {"ok": True}
|
|
|
|
monkeypatch.setattr(XhsClient, "_main_api_get", fake_get)
|
|
monkeypatch.setattr(XhsClient, "_main_api_post", fake_post)
|
|
|
|
client = XhsClient({"a1": "cookie"})
|
|
try:
|
|
client.search_notes("openclaw prompt", page=2)
|
|
finally:
|
|
client.close()
|
|
|
|
assert [call[1] for call in calls] == [
|
|
"/api/sns/web/v1/search/onebox",
|
|
"/api/sns/web/v1/search/filter",
|
|
"/api/sns/web/v1/search/notes",
|
|
"/api/sns/web/v1/search/recommend",
|
|
]
|
|
|
|
notes_payload = calls[2][2]
|
|
assert notes_payload["page"] == 2
|
|
assert notes_payload["filters"][0]["type"] == "sort_type"
|
|
assert notes_payload["filters"][1]["type"] == "filter_note_type"
|
|
|
|
def test_search_notes_reuses_search_id_across_pages(self, monkeypatch):
|
|
calls = []
|
|
|
|
monkeypatch.setattr("xhs_cli.client_mixins._SEARCH_SESSION_CACHE", OrderedDict())
|
|
monkeypatch.setattr("xhs_cli.client_mixins._SEARCH_SESSION_CACHE_LOADED", True)
|
|
|
|
def fake_get(self, uri, params=None):
|
|
calls.append(("GET", uri, params))
|
|
return {"ok": True}
|
|
|
|
def fake_post(self, uri, data, header_overrides=None):
|
|
calls.append(("POST", uri, data))
|
|
if uri == "/api/sns/web/v1/search/notes":
|
|
return {"items": [], "has_more": False}
|
|
return {"ok": True}
|
|
|
|
monkeypatch.setattr(XhsClient, "_main_api_get", fake_get)
|
|
monkeypatch.setattr(XhsClient, "_main_api_post", fake_post)
|
|
|
|
client = XhsClient({"a1": "cookie"})
|
|
try:
|
|
client.search_notes("openclaw", page=1)
|
|
client.search_notes("openclaw", page=2)
|
|
finally:
|
|
client.close()
|
|
|
|
notes_calls = [call for call in calls if call[1] == "/api/sns/web/v1/search/notes"]
|
|
assert len(notes_calls) == 2
|
|
assert notes_calls[0][2]["search_id"] == notes_calls[1][2]["search_id"]
|
|
|
|
onebox_calls = [call for call in calls if call[1] == "/api/sns/web/v1/search/onebox"]
|
|
filter_calls = [call for call in calls if call[1] == "/api/sns/web/v1/search/filter"]
|
|
recommend_calls = [call for call in calls if call[1] == "/api/sns/web/v1/search/recommend"]
|
|
assert len(onebox_calls) == 1
|
|
assert len(filter_calls) == 1
|
|
assert len(recommend_calls) == 1
|
|
|
|
def test_search_notes_reuses_search_id_after_cache_reload(self, monkeypatch, tmp_path):
|
|
first_calls = []
|
|
second_calls = []
|
|
|
|
monkeypatch.setattr("xhs_cli.client_mixins.get_config_dir", lambda: tmp_path)
|
|
monkeypatch.setattr("xhs_cli.client_mixins._SEARCH_SESSION_CACHE", OrderedDict())
|
|
monkeypatch.setattr("xhs_cli.client_mixins._SEARCH_SESSION_CACHE_LOADED", False)
|
|
monkeypatch.setattr("xhs_cli.client_mixins._SEARCH_SESSION_CACHE_PATH", None)
|
|
|
|
def fake_get_first(self, uri, params=None):
|
|
first_calls.append(("GET", uri, params))
|
|
return {"ok": True}
|
|
|
|
def fake_post_first(self, uri, data, header_overrides=None):
|
|
first_calls.append(("POST", uri, data))
|
|
if uri == "/api/sns/web/v1/search/notes":
|
|
return {"items": [], "has_more": False}
|
|
return {"ok": True}
|
|
|
|
monkeypatch.setattr(XhsClient, "_main_api_get", fake_get_first)
|
|
monkeypatch.setattr(XhsClient, "_main_api_post", fake_post_first)
|
|
|
|
client = XhsClient({"a1": "cookie"})
|
|
try:
|
|
client.search_notes("openclaw", page=1)
|
|
finally:
|
|
client.close()
|
|
|
|
monkeypatch.setattr("xhs_cli.client_mixins._SEARCH_SESSION_CACHE", OrderedDict())
|
|
monkeypatch.setattr("xhs_cli.client_mixins._SEARCH_SESSION_CACHE_LOADED", False)
|
|
monkeypatch.setattr("xhs_cli.client_mixins._SEARCH_SESSION_CACHE_PATH", None)
|
|
|
|
def fake_get_second(self, uri, params=None):
|
|
second_calls.append(("GET", uri, params))
|
|
return {"ok": True}
|
|
|
|
def fake_post_second(self, uri, data, header_overrides=None):
|
|
second_calls.append(("POST", uri, data))
|
|
if uri == "/api/sns/web/v1/search/notes":
|
|
return {"items": [], "has_more": False}
|
|
return {"ok": True}
|
|
|
|
monkeypatch.setattr(XhsClient, "_main_api_get", fake_get_second)
|
|
monkeypatch.setattr(XhsClient, "_main_api_post", fake_post_second)
|
|
|
|
client = XhsClient({"a1": "cookie"})
|
|
try:
|
|
client.search_notes("openclaw", page=2)
|
|
finally:
|
|
client.close()
|
|
|
|
first_notes_call = next(call for call in first_calls if call[1] == "/api/sns/web/v1/search/notes")
|
|
second_notes_call = next(call for call in second_calls if call[1] == "/api/sns/web/v1/search/notes")
|
|
assert first_notes_call[2]["search_id"] == second_notes_call[2]["search_id"]
|
|
assert [call[1] for call in second_calls] == ["/api/sns/web/v1/search/notes"]
|
|
|
|
def test_get_note_detail_invalidates_cached_context_after_feed_failure(self, monkeypatch):
|
|
cache_note_context("note-123", "stale-token", "pc_search")
|
|
|
|
def fake_get_note_by_id(self, note_id, xsec_token="", xsec_source="pc_feed"):
|
|
raise XhsApiError("stale token")
|
|
|
|
def fake_get_note_from_html(self, note_id, xsec_token="", xsec_source="pc_feed"):
|
|
return {"items": [{"note_card": {"title": "html-ok"}}]}
|
|
|
|
monkeypatch.setattr(XhsClient, "get_note_by_id", fake_get_note_by_id)
|
|
monkeypatch.setattr(XhsClient, "get_note_from_html", fake_get_note_from_html)
|
|
|
|
client = XhsClient({"a1": "cookie"})
|
|
try:
|
|
result = client.get_note_detail("note-123")
|
|
finally:
|
|
client.close()
|
|
|
|
assert result["items"][0]["note_card"]["title"] == "html-ok"
|
|
assert get_cached_note_context("note-123") == {}
|