444 lines
14 KiB
Python
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
|