Files
nanocode/skills/xiaohongshu-cli/tests/test_integration.py
T
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

444 lines
14 KiB
Python

"""
Integration tests for XHS API client.
These tests require actual XHS cookies (a logged-in browser session).
They test against the real XHS API to verify the complete signing + request pipeline.
Run with: uv run pytest tests/test_integration.py -v
Skip if no cookies available.
Write operations are tested as self-cleaning PAIRS:
- like + unlike → net effect = zero
- favorite + unfavorite → net effect = zero
- comment + delete-comment → net effect = zero
The undo operation always runs in a `finally` block.
"""
import time
import pytest
from xhs_cli.client import XhsClient
from xhs_cli.cookies import get_cookies
from xhs_cli.exceptions import NoCookieError, SessionExpiredError, XhsApiError
def _get_test_cookies():
"""Try to get valid cookies for integration testing."""
try:
cookies = get_cookies("chrome")
with XhsClient(cookies) as client:
client.get_self_info()
return cookies
except SessionExpiredError:
try:
cookies = get_cookies("chrome", force_refresh=True)
with XhsClient(cookies) as client:
client.get_self_info()
return cookies
except (NoCookieError, SessionExpiredError, XhsApiError, Exception):
return None
except (NoCookieError, Exception):
return None
# Skip all integration tests if no valid cookies are available
cookies = _get_test_cookies()
pytestmark = pytest.mark.skipif(cookies is None, reason="No valid XHS cookies available for integration testing")
# Cached values (discovered once, reused across tests)
_CACHED_NOTE_ID = None
_CACHED_XSEC_TOKEN = None
_CACHED_USER_ID = None
_CACHED_OTHER_USER_ID = None
def _ensure_test_note(client: XhsClient) -> tuple[str, str]:
"""Discover a note ID from search results for testing. Cached across tests."""
global _CACHED_NOTE_ID, _CACHED_XSEC_TOKEN
if _CACHED_NOTE_ID:
return _CACHED_NOTE_ID, _CACHED_XSEC_TOKEN or ""
search_data = client.search_notes("美食")
items = search_data.get("items", [])
if not items:
pytest.skip("No search results available for testing")
_CACHED_NOTE_ID = items[0].get("id", "")
_CACHED_XSEC_TOKEN = items[0].get("xsec_token", "")
if not _CACHED_NOTE_ID:
pytest.skip("No note ID in search results")
return _CACHED_NOTE_ID, _CACHED_XSEC_TOKEN or ""
def _ensure_self_user_id(client: XhsClient) -> str:
"""Get current user's ID. Cached across tests."""
global _CACHED_USER_ID
if _CACHED_USER_ID:
return _CACHED_USER_ID
info = client.get_self_info()
_CACHED_USER_ID = info.get("user_id", "")
if not _CACHED_USER_ID:
pytest.skip("Cannot determine current user ID")
return _CACHED_USER_ID
def _ensure_other_user_id(client: XhsClient) -> str:
"""Find another user ID for safe follow/unfollow testing."""
global _CACHED_OTHER_USER_ID
if _CACHED_OTHER_USER_ID:
return _CACHED_OTHER_USER_ID
self_user_id = _ensure_self_user_id(client)
data = client.search_users("美食达人")
users = data.get("user_info_dtos", []) if isinstance(data, dict) else []
for item in users:
user_id = item.get("user_base_dto", {}).get("user_id", "")
if user_id and user_id != self_user_id:
_CACHED_OTHER_USER_ID = user_id
return user_id
pytest.skip("No other user found for follow/unfollow testing")
@pytest.fixture
def client():
"""Create a test client with real cookies."""
assert cookies is not None
c = XhsClient(cookies)
yield c
c.close()
# ─── Auth ────────────────────────────────────────────────────────────────────
class TestAuth:
"""Test authentication and user info."""
def test_get_self_info(self, client: XhsClient):
info = client.get_self_info()
assert isinstance(info, dict)
assert any(k in info for k in ["nickname", "red_id", "user_id"])
def test_cookies_have_a1(self):
assert cookies is not None
assert "a1" in cookies
assert len(cookies["a1"]) > 0
# ─── Search ──────────────────────────────────────────────────────────────────
class TestSearch:
"""Test search functionality."""
def test_search_notes(self, client: XhsClient):
data = client.search_notes("美食", page=1)
items = data.get("items", [])
assert len(items) > 0
def test_search_with_sort(self, client: XhsClient):
time.sleep(1)
data = client.search_notes("旅行", sort="popularity_descending")
assert data is not None
def test_search_returns_note_cards(self, client: XhsClient):
time.sleep(1)
data = client.search_notes("咖啡")
items = data.get("items", [])
if items:
first = items[0]
assert "note_card" in first or "model_type" in first
def test_search_users(self, client: XhsClient):
"""Search for users by keyword."""
time.sleep(1)
data = client.search_users("美食达人")
assert data is not None
def test_search_topics(self, client: XhsClient):
time.sleep(1)
data = client.search_topics("美食")
assert data is not None
# ─── Feed ────────────────────────────────────────────────────────────────────
class TestFeed:
"""Test feed functionality."""
def test_get_home_feed(self, client: XhsClient):
time.sleep(1)
data = client.get_home_feed()
items = data.get("items", [])
assert len(items) > 0
def test_feed_items_have_structure(self, client: XhsClient):
time.sleep(1)
data = client.get_home_feed()
items = data.get("items", [])
if items:
assert "note_card" in items[0] or "id" in items[0]
def test_hot_feed(self, client: XhsClient):
"""Hot/trending feed by category."""
time.sleep(1)
data = client.get_hot_feed("homefeed.food_v3")
items = data.get("items", [])
assert len(items) > 0
# ─── Note Reading ────────────────────────────────────────────────────────────
class TestNoteRead:
"""Test reading individual notes."""
def test_read_note(self, client: XhsClient):
time.sleep(1)
note_id, xsec_token = _ensure_test_note(client)
time.sleep(1)
data = client.get_note_by_id(note_id, xsec_token=xsec_token)
assert data is not None
def test_get_comments(self, client: XhsClient):
time.sleep(1)
note_id, xsec_token = _ensure_test_note(client)
time.sleep(1)
data = client.get_comments(note_id, xsec_token=xsec_token)
assert data is not None
# ─── User Content ────────────────────────────────────────────────────────────
class TestUserContent:
"""Test user content queries."""
def test_get_user_info(self, client: XhsClient):
time.sleep(1)
user_id = _ensure_self_user_id(client)
data = client.get_user_info(user_id)
assert isinstance(data, dict)
def test_get_user_notes(self, client: XhsClient):
time.sleep(1)
user_id = _ensure_self_user_id(client)
data = client.get_user_notes(user_id)
assert data is not None
def test_get_user_favorites(self, client: XhsClient):
time.sleep(1)
user_id = _ensure_self_user_id(client)
data = client.get_user_favorites(user_id)
assert data is not None
def test_get_creator_note_list(self, client: XhsClient):
time.sleep(1)
data = client.get_creator_note_list()
assert data is not None
assert "notes" in data
assert "page" in data
# ─── Notifications ───────────────────────────────────────────────────────────
class TestNotifications:
"""Test notification endpoints (reverse-engineered)."""
def test_unread_count(self, client: XhsClient):
time.sleep(1)
data = client.get_unread_count()
assert isinstance(data, dict)
def test_notification_mentions(self, client: XhsClient):
time.sleep(1)
data = client.get_notification_mentions()
assert data is not None
def test_notification_likes(self, client: XhsClient):
time.sleep(1)
data = client.get_notification_likes()
assert data is not None
def test_notification_connections(self, client: XhsClient):
time.sleep(1)
data = client.get_notification_connections()
assert data is not None
# ─── Paired Write Tests (self-cleaning) ──────────────────────────────────────
class TestLikePair:
"""like + unlike → net effect = zero."""
def test_like_then_unlike(self, client: XhsClient):
time.sleep(1)
note_id, _ = _ensure_test_note(client)
time.sleep(1)
try:
result = client.like_note(note_id)
assert result is not None or result is True
finally:
time.sleep(1)
try:
client.unlike_note(note_id)
except XhsApiError:
pass # Best effort cleanup
class TestFavoritePair:
"""favorite + unfavorite → net effect = zero."""
def test_favorite_then_unfavorite(self, client: XhsClient):
time.sleep(1)
note_id, _ = _ensure_test_note(client)
time.sleep(1)
try:
result = client.favorite_note(note_id)
assert result is not None or result is True
finally:
time.sleep(1)
try:
client.unfavorite_note(note_id)
except XhsApiError:
pass # Best effort cleanup
class TestCommentPair:
"""comment + delete-comment → net effect = zero."""
def test_comment_then_delete(self, client: XhsClient):
time.sleep(1)
note_id, _ = _ensure_test_note(client)
time.sleep(1)
comment_data = client.post_comment(note_id, "🤖 Integration test — will delete shortly")
assert comment_data is not None
# Extract comment ID from response
comment_id = ""
if isinstance(comment_data, dict):
comment_id = (
comment_data.get("comment", {}).get("id", "")
or comment_data.get("id", "")
or comment_data.get("comment_id", "")
)
try:
assert comment_id, f"Should get comment ID back, got: {comment_data}"
finally:
if comment_id:
time.sleep(1)
try:
client.delete_comment(note_id, comment_id)
except XhsApiError:
pass # Best effort cleanup
# ─── End-to-End Workflows ────────────────────────────────────────────────────
class TestEndToEnd:
"""Full workflow tests combining multiple operations."""
def test_search_read_comments_workflow(self, client: XhsClient):
"""search → read note → get comments."""
time.sleep(1)
result = client.search_notes("Python编程")
items = result.get("items", [])
if not items:
pytest.skip("No search results")
first = items[0]
note_id = first.get("id", "")
xsec_token = first.get("xsec_token", "")
assert note_id
time.sleep(1)
note = client.get_note_by_id(note_id, xsec_token=xsec_token)
assert note is not None
time.sleep(1)
comments = client.get_comments(note_id, xsec_token=xsec_token)
assert comments is not None
def test_like_favorite_workflow(self, client: XhsClient):
"""Like + favorite → unfavorite + unlike. Full interaction lifecycle."""
time.sleep(1)
note_id, _ = _ensure_test_note(client)
liked = False
favorited = False
try:
time.sleep(1)
client.like_note(note_id)
liked = True
time.sleep(1)
client.favorite_note(note_id)
favorited = True
finally:
if favorited:
time.sleep(1)
try:
client.unfavorite_note(note_id)
except XhsApiError:
pass
if liked:
time.sleep(1)
try:
client.unlike_note(note_id)
except XhsApiError:
pass
class TestFollowPair:
"""follow + unfollow → net effect = zero."""
def test_follow_then_unfollow(self, client: XhsClient):
time.sleep(1)
user_id = _ensure_other_user_id(client)
time.sleep(1)
followed = False
try:
result = client.follow_user(user_id)
followed = True
assert isinstance(result, dict)
assert result.get("fstatus") in {"follows", "followed"}
finally:
if followed:
time.sleep(1)
try:
undo = client.unfollow_user(user_id)
assert isinstance(undo, dict)
assert undo.get("fstatus") in {"none", "unfollowed"}
except XhsApiError:
pass
def test_notifications_workflow(self, client: XhsClient):
"""unread → mentions → likes → connections."""
time.sleep(1)
unread = client.get_unread_count()
assert isinstance(unread, dict)
time.sleep(1)
assert client.get_notification_mentions(num=5) is not None
time.sleep(1)
assert client.get_notification_likes(num=5) is not None
time.sleep(1)
assert client.get_notification_connections(num=5) is not None