Files
wehub-resource-sync 67296cb4a7
Deploy Docs / deploy (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:26:53 +08:00

421 lines
15 KiB
Python

"""Tests for TwitterScraper."""
import asyncio
from datetime import datetime, timedelta, timezone
import httpx
from src.models import TwitterConfig
from src.scrapers.twitter import TwitterScraper
def _make_config(**kwargs) -> TwitterConfig:
defaults = dict(
enabled=True,
users=["karpathy"],
fetch_limit=3,
actor_id="altimis~scweet",
apify_token_env="APIFY_TOKEN",
)
defaults.update(kwargs)
return TwitterConfig(**defaults)
def _tweet(
tweet_id: str = "123456",
screen_name: str = "karpathy",
text: str = "Hello world",
created_at: str = None,
conversation_id: str = None,
**extra,
) -> dict:
if created_at is None:
now = datetime.now(timezone.utc)
created_at = now.strftime("%a %b %d %H:%M:%S +0000 %Y")
return {
"id_str": tweet_id,
"conversation_id": conversation_id or tweet_id,
"created_at": created_at,
"full_text": text,
"user": {"screen_name": screen_name, "name": screen_name.capitalize()},
"favorite_count": 10,
"retweet_count": 2,
"reply_count": 1,
**extra,
}
def _reply_row(
tweet_id: str = "999",
handle: str = "someone",
text: str = "Great point!",
likes: int = 5,
) -> dict:
now = datetime.now(timezone.utc)
return {
"id": f"tweet-{tweet_id}",
"handle": handle,
"text": text,
"favorite_count": likes,
"reply_count": 0,
"created_at": now.strftime("%a %b %d %H:%M:%S +0000 %Y"),
"user": {"handle": handle, "name": handle},
}
def _run_resp(run_id="run1", dataset_id="ds1"):
return {"data": {"id": run_id, "defaultDatasetId": dataset_id}}
def _status_resp(status="SUCCEEDED"):
return {"data": {"status": status}}
# ---------------------------------------------------------------------------
# Existing fetch tests
# ---------------------------------------------------------------------------
def test_disabled_returns_empty():
transport = httpx.MockTransport(lambda r: httpx.Response(200, json=[]))
client = httpx.AsyncClient(transport=transport)
result = asyncio.run(
TwitterScraper(_make_config(enabled=False), client).fetch(
datetime.now(timezone.utc)
)
)
asyncio.run(client.aclose())
assert result == []
def test_no_users_returns_empty():
transport = httpx.MockTransport(lambda r: httpx.Response(200, json=[]))
client = httpx.AsyncClient(transport=transport)
result = asyncio.run(
TwitterScraper(_make_config(users=[]), client).fetch(
datetime.now(timezone.utc)
)
)
asyncio.run(client.aclose())
assert result == []
def test_missing_token_returns_empty(monkeypatch):
monkeypatch.delenv("APIFY_TOKEN", raising=False)
transport = httpx.MockTransport(lambda r: httpx.Response(200, json=[]))
client = httpx.AsyncClient(transport=transport)
result = asyncio.run(
TwitterScraper(_make_config(), client).fetch(datetime.now(timezone.utc))
)
asyncio.run(client.aclose())
assert result == []
def test_successful_fetch_returns_items(monkeypatch):
monkeypatch.setenv("APIFY_TOKEN", "test_token")
since = datetime.now(timezone.utc) - timedelta(hours=1)
tweets = [_tweet("1"), _tweet("2", text="Another tweet")]
def handler(request: httpx.Request) -> httpx.Response:
if "/runs" in request.url.path and request.method == "POST":
return httpx.Response(200, json=_run_resp())
if "/actor-runs/" in request.url.path:
return httpx.Response(200, json=_status_resp())
if "/datasets/" in request.url.path:
return httpx.Response(200, json=tweets)
raise AssertionError(f"Unexpected: {request.url}")
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport)
result = asyncio.run(TwitterScraper(_make_config(), client).fetch(since))
asyncio.run(client.aclose())
assert len(result) == 2
assert result[0].source_type.value == "twitter"
assert result[0].metadata["favorite_count"] == 10
def test_metadata_keys_aligned_for_analyzer(monkeypatch):
"""Analyzer reads favorite_count/retweet_count/reply_count — verify they are set."""
monkeypatch.setenv("APIFY_TOKEN", "test_token")
since = datetime.now(timezone.utc) - timedelta(hours=1)
tweets = [_tweet("42", **{"favorite_count": 99, "retweet_count": 7, "reply_count": 3})]
def handler(request: httpx.Request) -> httpx.Response:
if "/runs" in request.url.path and request.method == "POST":
return httpx.Response(200, json=_run_resp())
if "/actor-runs/" in request.url.path:
return httpx.Response(200, json=_status_resp())
if "/datasets/" in request.url.path:
return httpx.Response(200, json=tweets)
raise AssertionError(f"Unexpected: {request.url}")
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport)
result = asyncio.run(TwitterScraper(_make_config(), client).fetch(since))
asyncio.run(client.aclose())
assert len(result) == 1
meta = result[0].metadata
assert meta["favorite_count"] == 99
assert meta["retweet_count"] == 7
assert meta["reply_count"] == 3
assert meta["tweet_id"] == "42"
assert "conversation_id" in meta
def test_filters_old_tweets(monkeypatch):
monkeypatch.setenv("APIFY_TOKEN", "test_token")
since = datetime.now(timezone.utc)
old = (since - timedelta(hours=2)).strftime("%a %b %d %H:%M:%S +0000 %Y")
def handler(request: httpx.Request) -> httpx.Response:
if "/runs" in request.url.path and request.method == "POST":
return httpx.Response(200, json=_run_resp())
if "/actor-runs/" in request.url.path:
return httpx.Response(200, json=_status_resp())
if "/datasets/" in request.url.path:
return httpx.Response(200, json=[_tweet("1", created_at=old)])
raise AssertionError(f"Unexpected: {request.url}")
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport)
result = asyncio.run(TwitterScraper(_make_config(), client).fetch(since))
asyncio.run(client.aclose())
assert result == []
def test_run_failure_returns_empty(monkeypatch):
monkeypatch.setenv("APIFY_TOKEN", "test_token")
def handler(request: httpx.Request) -> httpx.Response:
if "/runs" in request.url.path and request.method == "POST":
return httpx.Response(200, json=_run_resp())
if "/actor-runs/" in request.url.path:
return httpx.Response(200, json=_status_resp("FAILED"))
raise AssertionError(f"Unexpected: {request.url}")
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport)
result = asyncio.run(
TwitterScraper(_make_config(), client).fetch(
datetime.now(timezone.utc) - timedelta(hours=1)
)
)
asyncio.run(client.aclose())
assert result == []
def test_start_run_http_error_returns_empty(monkeypatch):
monkeypatch.setenv("APIFY_TOKEN", "test_token")
transport = httpx.MockTransport(lambda r: httpx.Response(500, text="error"))
client = httpx.AsyncClient(transport=transport)
result = asyncio.run(
TwitterScraper(_make_config(), client).fetch(
datetime.now(timezone.utc) - timedelta(hours=1)
)
)
asyncio.run(client.aclose())
assert result == []
def test_no_results_item_skipped(monkeypatch):
monkeypatch.setenv("APIFY_TOKEN", "test_token")
since = datetime.now(timezone.utc) - timedelta(hours=1)
def handler(request: httpx.Request) -> httpx.Response:
if "/runs" in request.url.path and request.method == "POST":
return httpx.Response(200, json=_run_resp())
if "/actor-runs/" in request.url.path:
return httpx.Response(200, json=_status_resp())
if "/datasets/" in request.url.path:
return httpx.Response(200, json=[{"noResults": True}, _tweet("99")])
raise AssertionError(f"Unexpected: {request.url}")
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport)
result = asyncio.run(TwitterScraper(_make_config(), client).fetch(since))
asyncio.run(client.aclose())
assert len(result) == 1
assert result[0].id == "twitter:tweet:99"
def test_iso_date_fallback(monkeypatch):
monkeypatch.setenv("APIFY_TOKEN", "test_token")
since = datetime.now(timezone.utc) - timedelta(hours=1)
iso_tweet = _tweet("77")
iso_tweet["created_at"] = datetime.now(timezone.utc).isoformat()
def handler(request: httpx.Request) -> httpx.Response:
if "/runs" in request.url.path and request.method == "POST":
return httpx.Response(200, json=_run_resp())
if "/actor-runs/" in request.url.path:
return httpx.Response(200, json=_status_resp())
if "/datasets/" in request.url.path:
return httpx.Response(200, json=[iso_tweet])
raise AssertionError(f"Unexpected: {request.url}")
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport)
result = asyncio.run(TwitterScraper(_make_config(), client).fetch(since))
asyncio.run(client.aclose())
assert len(result) == 1
def test_url_constructed_when_missing(monkeypatch):
monkeypatch.setenv("APIFY_TOKEN", "test_token")
since = datetime.now(timezone.utc) - timedelta(hours=1)
tweet = _tweet("55", screen_name="testuser")
tweet.pop("url", None)
def handler(request: httpx.Request) -> httpx.Response:
if "/runs" in request.url.path and request.method == "POST":
return httpx.Response(200, json=_run_resp())
if "/actor-runs/" in request.url.path:
return httpx.Response(200, json=_status_resp())
if "/datasets/" in request.url.path:
return httpx.Response(200, json=[tweet])
raise AssertionError(f"Unexpected: {request.url}")
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport)
result = asyncio.run(TwitterScraper(_make_config(), client).fetch(since))
asyncio.run(client.aclose())
assert len(result) == 1
assert "testuser" in str(result[0].url)
assert "55" in str(result[0].url)
# ---------------------------------------------------------------------------
# Reply fetch tests
# ---------------------------------------------------------------------------
def test_fetch_replies_disabled_by_default():
"""fetch_reply_text defaults to False — verify config default."""
cfg = TwitterConfig()
assert cfg.fetch_reply_text is False
def test_fetch_replies_appends_top_comments(monkeypatch):
"""When fetch_reply_text=True, reply lines are appended under Top Comments."""
monkeypatch.setenv("APIFY_TOKEN", "test_token")
replies = [
_reply_row("r1", "alice", "Interesting take!", likes=20),
_reply_row("r2", "bob", "I disagree because...", likes=5),
_reply_row("r3", "carol", "Great point!", likes=15),
_reply_row("r4", "dave", "Low quality reply", likes=0),
]
run_counter = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
if "/runs" in request.url.path and request.method == "POST":
run_counter["n"] += 1
ds = f"ds{run_counter['n']}"
return httpx.Response(200, json=_run_resp(f"run{run_counter['n']}", ds))
if "/actor-runs/" in request.url.path:
return httpx.Response(200, json=_status_resp())
if "/datasets/" in request.url.path:
return httpx.Response(200, json=replies)
raise AssertionError(f"Unexpected: {request.url}")
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport)
cfg = _make_config(
fetch_reply_text=True,
max_replies_per_tweet=3,
reply_min_likes=1,
)
scraper = TwitterScraper(cfg, client)
from src.models import ContentItem, SourceType
from datetime import datetime, timezone
item = ContentItem(
id="twitter:tweet:42",
source_type=SourceType.TWITTER,
title="@karpathy: test tweet",
url="https://twitter.com/karpathy/status/42",
content="test tweet body",
author="Andrej Karpathy",
published_at=datetime.now(timezone.utc),
metadata={"tweet_id": "42", "conversation_id": "42"},
)
reply_lines = asyncio.run(scraper.fetch_replies_for_item(item))
asyncio.run(client.aclose())
# min_likes=1 filters out dave (0 likes); max 3 returned sorted by score
assert len(reply_lines) == 3
# alice (20 likes) should be first
assert "alice" in reply_lines[0]
assert "Interesting take!" in reply_lines[0]
# dave (0 likes) filtered out
assert not any("dave" in l for l in reply_lines)
def test_append_discussion_content_adds_marker():
from src.models import ContentItem, SourceType
item = ContentItem(
id="twitter:tweet:1",
source_type=SourceType.TWITTER,
title="test",
url="https://twitter.com/x/status/1",
content="original text",
author="x",
published_at=datetime.now(timezone.utc),
metadata={},
)
changed = TwitterScraper.append_discussion_content(item, ["[@alice | ❤️ 5 | 💬 1] reply text"])
assert changed is True
assert "--- Top Comments ---" in item.content
assert "alice" in item.content
def test_append_discussion_content_empty_lines_no_change():
from src.models import ContentItem, SourceType
item = ContentItem(
id="twitter:tweet:2",
source_type=SourceType.TWITTER,
title="test",
url="https://twitter.com/x/status/2",
content="original",
author="x",
published_at=datetime.now(timezone.utc),
metadata={},
)
changed = TwitterScraper.append_discussion_content(item, [])
assert changed is False
assert item.content == "original"
def test_fetch_replies_no_conversation_id_returns_empty(monkeypatch):
monkeypatch.setenv("APIFY_TOKEN", "test_token")
transport = httpx.MockTransport(lambda r: httpx.Response(200, json=[]))
client = httpx.AsyncClient(transport=transport)
cfg = _make_config(fetch_reply_text=True)
scraper = TwitterScraper(cfg, client)
from src.models import ContentItem, SourceType
item = ContentItem(
id="twitter:tweet:x",
source_type=SourceType.TWITTER,
title="test",
url="https://twitter.com/x/status/x",
content="body",
author="x",
published_at=datetime.now(timezone.utc),
metadata={}, # no conversation_id
)
result = asyncio.run(scraper.fetch_replies_for_item(item))
asyncio.run(client.aclose())
assert result == []