返回 last30days-skill
test_xquik.py
根目录 / tests / test_xquik.py
1 import unittest
2 from unittest.mock import patch
3
4 from lib import health
5 from lib.xquik import (
6 DEPTH_CONFIG,
7 _parse_tweet,
8 _safe_int,
9 expand_xquik_queries,
10 parse_xquik_response,
11 search_xquik,
12 )
13
14
15 class TestExpandXquikQueries(unittest.TestCase):
16 def test_quick_returns_one_query(self):
17 queries = expand_xquik_queries("latest trends in AI agents", "quick")
18 self.assertEqual(len(queries), 1)
19
20 def test_default_returns_up_to_two_queries(self):
21 queries = expand_xquik_queries("multi-agent systems research", "default")
22 self.assertLessEqual(len(queries), 2)
23 self.assertGreaterEqual(len(queries), 1)
24
25 def test_deep_returns_up_to_three_queries(self):
26 queries = expand_xquik_queries("best AI coding assistants 2026", "deep")
27 self.assertLessEqual(len(queries), 3)
28 self.assertGreaterEqual(len(queries), 1)
29
30 def test_single_word_topic(self):
31 queries = expand_xquik_queries("Bitcoin", "quick")
32 self.assertEqual(len(queries), 1)
33 self.assertIn("bitcoin", queries[0].lower())
34
35
36 class TestParseTweet(unittest.TestCase):
37 def test_valid_tweet(self):
38 tweet = {
39 "id": "123456",
40 "text": "This is a test tweet about AI agents",
41 "createdAt": "2026-03-15T12:00:00Z",
42 "likeCount": 42,
43 "retweetCount": 10,
44 "replyCount": 5,
45 "quoteCount": 2,
46 "viewCount": 5000,
47 "bookmarkCount": 8,
48 "author": {"username": "testuser", "name": "Test User"},
49 }
50 item = _parse_tweet(tweet, 0, "AI agents")
51 self.assertIsNotNone(item)
52 self.assertEqual(item["id"], "XQ1")
53 self.assertEqual(item["url"], "https://x.com/testuser/status/123456")
54 self.assertEqual(item["author_handle"], "testuser")
55 self.assertEqual(item["date"], "2026-03-15")
56 self.assertEqual(item["engagement"]["likes"], 42)
57 self.assertEqual(item["engagement"]["reposts"], 10)
58 self.assertEqual(item["engagement"]["replies"], 5)
59 self.assertEqual(item["engagement"]["quotes"], 2)
60 self.assertEqual(item["engagement"]["views"], 5000)
61 self.assertEqual(item["engagement"]["bookmarks"], 8)
62 self.assertGreater(item["relevance"], 0)
63
64 def test_missing_author_returns_none(self):
65 tweet = {"id": "123", "text": "test"}
66 item = _parse_tweet(tweet, 0, "test")
67 self.assertIsNone(item)
68
69 def test_at_prefix_stripped(self):
70 tweet = {
71 "id": "456",
72 "text": "hello",
73 "author": {"username": "@someone"},
74 }
75 item = _parse_tweet(tweet, 0, "hello")
76 self.assertIsNotNone(item)
77 self.assertEqual(item["author_handle"], "someone")
78
79 def test_zero_engagement_preserved(self):
80 tweet = {
81 "id": "789",
82 "text": "zero likes tweet",
83 "author": {"username": "user"},
84 "likeCount": 0,
85 "retweetCount": 0,
86 "replyCount": 0,
87 "quoteCount": 0,
88 "viewCount": 0,
89 "bookmarkCount": 0,
90 }
91 item = _parse_tweet(tweet, 0, "test")
92 self.assertIsNotNone(item)
93 self.assertEqual(item["engagement"]["likes"], 0)
94 self.assertEqual(item["engagement"]["reposts"], 0)
95 self.assertEqual(item["engagement"]["views"], 0)
96
97 def test_none_engagement_values(self):
98 tweet = {
99 "id": "101",
100 "text": "minimal tweet",
101 "author": {"username": "user"},
102 }
103 item = _parse_tweet(tweet, 0, "test")
104 self.assertIsNotNone(item)
105 self.assertIsNone(item["engagement"]["likes"])
106 self.assertIsNone(item["engagement"]["views"])
107
108 def test_text_truncated_at_500(self):
109 tweet = {
110 "id": "102",
111 "text": "x" * 600,
112 "author": {"username": "user"},
113 }
114 item = _parse_tweet(tweet, 0, "test")
115 self.assertIsNotNone(item)
116 self.assertEqual(len(item["text"]), 500)
117
118 def test_twitter_date_format(self):
119 tweet = {
120 "id": "103",
121 "text": "old format",
122 "createdAt": "Wed Jan 15 14:30:00 +0000 2026",
123 "author": {"username": "user"},
124 }
125 item = _parse_tweet(tweet, 0, "test")
126 self.assertIsNotNone(item)
127 self.assertEqual(item["date"], "2026-01-15")
128
129 def test_invalid_date_graceful(self):
130 tweet = {
131 "id": "104",
132 "text": "bad date",
133 "createdAt": "not-a-date",
134 "author": {"username": "user"},
135 }
136 item = _parse_tweet(tweet, 0, "test")
137 self.assertIsNotNone(item)
138 self.assertIsNone(item["date"])
139
140 def test_empty_author_dict(self):
141 tweet = {"id": "105", "text": "test", "author": {}}
142 item = _parse_tweet(tweet, 0, "test")
143 self.assertIsNone(item)
144
145 def test_index_offset(self):
146 tweet = {
147 "id": "106",
148 "text": "test",
149 "author": {"username": "user"},
150 }
151 item = _parse_tweet(tweet, 4, "test")
152 self.assertIsNotNone(item)
153 self.assertEqual(item["id"], "XQ5")
154
155
156 class TestSafeInt(unittest.TestCase):
157 def test_int_passthrough(self):
158 self.assertEqual(_safe_int(42), 42)
159
160 def test_string_int(self):
161 self.assertEqual(_safe_int("100"), 100)
162
163 def test_none_returns_none(self):
164 self.assertIsNone(_safe_int(None))
165
166 def test_invalid_string(self):
167 self.assertIsNone(_safe_int("abc"))
168
169 def test_zero(self):
170 self.assertEqual(_safe_int(0), 0)
171
172 def test_float_truncates(self):
173 self.assertEqual(_safe_int(3.7), 3)
174
175
176 class TestParseXquikResponse(unittest.TestCase):
177 def test_extracts_items(self):
178 response = {"items": [{"id": "1"}, {"id": "2"}]}
179 items = parse_xquik_response(response)
180 self.assertEqual(len(items), 2)
181
182 def test_empty_response(self):
183 self.assertEqual(parse_xquik_response({}), [])
184
185 def test_error_response(self):
186 response = {"items": [], "error": "something went wrong"}
187 self.assertEqual(parse_xquik_response(response), [])
188
189
190 class TestSearchXquik(unittest.TestCase):
191 def test_no_token_returns_error(self):
192 result = search_xquik("test", "2026-01-01", "2026-03-01", token="")
193 self.assertEqual(result["items"], [])
194 self.assertIn("XQUIK_API_KEY", result["error"])
195
196 @patch("lib.xquik.http.get")
197 def test_successful_search(self, mock_get):
198 mock_get.return_value = {
199 "tweets": [
200 {
201 "id": "111",
202 "text": "AI agents are amazing",
203 "createdAt": "2026-02-15T10:00:00Z",
204 "likeCount": 50,
205 "retweetCount": 12,
206 "replyCount": 3,
207 "quoteCount": 1,
208 "viewCount": 2000,
209 "bookmarkCount": 5,
210 "author": {"username": "aidev"},
211 },
212 ],
213 "has_next_page": False,
214 }
215 result = search_xquik("AI agents", "2026-02-01", "2026-03-01", token="test-key")
216 self.assertEqual(len(result["items"]), 1)
217 self.assertEqual(result["items"][0]["author_handle"], "aidev")
218 self.assertEqual(result["items"][0]["engagement"]["likes"], 50)
219 self.assertNotIn("error", result)
220
221 @patch("lib.xquik.http.get")
222 def test_deduplicates_across_queries(self, mock_get):
223 tweet = {
224 "id": "222",
225 "text": "duplicate tweet",
226 "author": {"username": "user"},
227 }
228 mock_get.return_value = {"tweets": [tweet]}
229 result = search_xquik("test topic", "2026-01-01", "2026-03-01", depth="default", token="key")
230 # Even with multiple queries, same tweet ID should appear only once
231 ids = [item.get("id") for item in result["items"]]
232 # All items should have unique XQ ids (deduped by tweet ID)
233 self.assertEqual(len(ids), len(set(ids)))
234
235 @patch("lib.xquik.http.get")
236 def test_auth_error_returns_error(self, mock_get):
237 from lib import http as http_mod
238 mock_get.side_effect = http_mod.HTTPError("Unauthorized", status_code=401)
239 result = search_xquik("test", "2026-01-01", "2026-03-01", token="bad-key")
240 self.assertEqual(result["items"], [])
241 self.assertIn("auth failed", result.get("error", ""))
242
243 @patch("lib.xquik.http.get")
244 def test_unpaid_402_surfaces_error_on_real_path(self, mock_get):
245 # An unpaid key (402) must surface an error on the normal search path,
246 # not settle silently empty — diagnose is opt-in.
247 from lib import http as http_mod
248 mock_get.side_effect = http_mod.HTTPError("Payment Required", status_code=402)
249 result = search_xquik("test", "2026-01-01", "2026-03-01", token="unpaid-key")
250 self.assertEqual(result["items"], [])
251 self.assertIn("unpaid", result.get("error", "").lower())
252 # The X retrieval branch classifies by message text only, so the fixed
253 # detail must carry a payment-required classifier marker (KTD6).
254 self.assertEqual("Xquik key unpaid: payment required (402)", result["error"])
255 self.assertEqual(
256 http_mod.classify_failure(message=result["error"]), health.PAYMENT_REQUIRED
257 )
258
259 @patch("lib.env.x_backend_chain", return_value=["xquik"])
260 @patch("lib.xquik.http.get")
261 def test_unpaid_402_yields_payment_required_source_outcome(self, mock_get, _chain):
262 # End to end through the X backend loop: a 402 from xquik as the sole
263 # backend settles the x source as payment-required, not auth-failed.
264 from lib import http as http_mod, pipeline, schema
265 mock_get.side_effect = http_mod.HTTPError("Payment Required", status_code=402)
266 sq = schema.SubQuery(label="primary", search_query="q", ranking_query="q?", sources=["x"])
267 runtime = schema.ProviderRuntime(
268 reasoning_provider="mock", planner_model="mock", rerank_model="mock",
269 x_search_backend=None,
270 )
271 with self.assertRaises(pipeline.SourceRunError) as ctx:
272 pipeline._retrieve_stream(
273 topic="q", subquery=sq, source="x", config={"XQUIK_API_KEY": "k"},
274 depth="default", date_range=("2026-05-19", "2026-06-18"),
275 runtime=runtime, mock=False,
276 )
277 self.assertEqual(health.PAYMENT_REQUIRED, ctx.exception.outcome_state)
278 state, attempted = pipeline._classify_source_failure(ctx.exception)
279 self.assertEqual((health.PAYMENT_REQUIRED, True), (state, attempted))
280
281 @patch("lib.xquik.http.get")
282 def test_empty_tweets_list(self, mock_get):
283 mock_get.return_value = {"tweets": []}
284 result = search_xquik("obscure topic", "2026-01-01", "2026-03-01", token="key")
285 self.assertEqual(result["items"], [])
286 self.assertNotIn("error", result)
287
288 @patch("lib.xquik.http.get")
289 def test_non_list_tweets_skipped(self, mock_get):
290 mock_get.return_value = {"tweets": "not a list"}
291 result = search_xquik("test", "2026-01-01", "2026-03-01", token="key")
292 self.assertEqual(result["items"], [])
293
294
295 class TestDepthConfig(unittest.TestCase):
296 def test_all_depths_have_limit_and_queries(self):
297 for depth_name, cfg in DEPTH_CONFIG.items():
298 self.assertIn("limit", cfg, f"{depth_name} missing 'limit'")
299 self.assertIn("queries", cfg, f"{depth_name} missing 'queries'")
300
301 def test_deep_has_highest_limit(self):
302 self.assertGreater(DEPTH_CONFIG["deep"]["limit"], DEPTH_CONFIG["default"]["limit"])
303 self.assertGreater(DEPTH_CONFIG["default"]["limit"], DEPTH_CONFIG["quick"]["limit"])
304
305 def test_deep_has_most_queries(self):
306 self.assertGreater(DEPTH_CONFIG["deep"]["queries"], DEPTH_CONFIG["quick"]["queries"])
307
308 class TestIsOwn(unittest.TestCase):
309 def test_own_tweet_detected(self):
310 from lib.xquik import _is_own
311 self.assertTrue(_is_own("https://x.com/elonmusk/status/123", "elonmusk"))
312 self.assertTrue(_is_own("https://twitter.com/elonmusk/status/123", "@elonmusk"))
313
314 def test_other_author_not_own(self):
315 from lib.xquik import _is_own
316 self.assertFalse(_is_own("https://x.com/someoneelse/status/123", "elonmusk"))
317
318 def test_empty_handle_or_url(self):
319 from lib.xquik import _is_own
320 self.assertFalse(_is_own("", "elonmusk"))
321 self.assertFalse(_is_own("https://x.com/a/status/1", ""))
322
323
324 class TestFromLane(unittest.TestCase):
325 def _resp(self, username, tid="1"):
326 return {"tweets": [{
327 "id": tid, "text": "anything", "createdAt": "2026-06-15T12:00:00Z",
328 "likeCount": 10, "author": {"username": username},
329 }]}
330
331 def test_from_query_shape_and_no_topic_anded(self):
332 from lib import xquik
333 with patch("lib.xquik.http.get", return_value=self._resp("elonmusk")) as m:
334 items = xquik.search_handles(["@elonmusk"], "Grok 4", "2026-05-19", "2026-06-18",
335 count_per=8, token="k")
336 url = m.call_args[0][0]
337 self.assertIn("from%3Aelonmusk", url) # from:elonmusk url-encoded
338 self.assertIn("since%3A2026-05-19", url)
339 self.assertNotIn("Grok", url) # topic must NOT be AND'd into query
340 self.assertEqual(1, len(items))
341 self.assertEqual("XF1", items[0]["id"]) # FROM-lane id prefix
342
343 def test_no_token_or_no_handles_returns_empty(self):
344 from lib import xquik
345 self.assertEqual([], xquik.search_handles(["@x"], "t", "a", "b", token=""))
346 self.assertEqual([], xquik.search_handles([], "t", "a", "b", token="k"))
347
348 def test_item_ids_unique_across_handles(self):
349 # Different tweets across two handles must not collide on item id.
350 from lib import xquik
351 responses = [
352 {"tweets": [{"id": "1", "text": "a", "createdAt": "2026-06-15T12:00:00Z",
353 "author": {"username": "h1"}}]},
354 {"tweets": [{"id": "2", "text": "b", "createdAt": "2026-06-15T12:00:00Z",
355 "author": {"username": "h2"}}]},
356 ]
357 with patch("lib.xquik.http.get", side_effect=responses):
358 items = xquik.search_handles(["h1", "h2"], "topic", "2026-05-19", "2026-06-18", token="k")
359 ids = [it["id"] for it in items]
360 self.assertEqual(len(ids), len(set(ids)))
361
362
363 class TestAboutLane(unittest.TestCase):
364 def test_mentions_drop_own_tweets(self):
365 from lib import xquik
366 resp = {"tweets": [
367 {"id": "1", "text": "@elonmusk nice", "createdAt": "2026-06-15T12:00:00Z",
368 "author": {"username": "fan"}},
369 {"id": "2", "text": "my own post", "createdAt": "2026-06-15T12:00:00Z",
370 "author": {"username": "elonmusk"}},
371 ]}
372 with patch("lib.xquik.http.get", return_value=resp) as m:
373 items = xquik.search_mentions(["elonmusk"], "2026-05-19", "2026-06-18",
374 topic="Grok 4", count_per=5, token="k")
375 url = m.call_args[0][0]
376 self.assertIn("%40elonmusk", url) # @elonmusk url-encoded
377 authors = {it["author_handle"] for it in items}
378 self.assertIn("fan", authors)
379 self.assertNotIn("elonmusk", authors) # own tweet dropped
380
381
382 class TestExpandGuard(unittest.TestCase):
383 @patch("lib.xquik._extract_core_subject", return_value="news")
384 def test_bare_generic_core_falls_back_to_topic(self, _m):
385 # #607: a single bare generic core must not be the query for a
386 # multi-word topic — fall back to the full topic.
387 qs = expand_xquik_queries("Grok 4 news", "quick")
388 self.assertEqual(["Grok 4 news"], qs)
389
390 @patch("lib.xquik._extract_core_subject", return_value="Grok 4")
391 def test_multiword_core_kept(self, _m):
392 qs = expand_xquik_queries("Grok 4 latest", "quick")
393 self.assertEqual(["Grok 4"], qs)
394
395
396 class TestProbeWorks(unittest.TestCase):
397 """U5: honest diagnose probe — tri-state, surfaces the unpaid (402) case."""
398
399 def setUp(self):
400 import lib.xquik as xq
401 xq._probe_cache = ("unset", "")
402
403 @patch("lib.xquik.http.get")
404 def test_funded_key_works(self, mock_get):
405 from lib import xquik
406 mock_get.return_value = {"tweets": [{"id": "1"}]}
407 self.assertIs(True, xquik.probe_works("k"))
408 self.assertEqual("ok", xquik.probe_reason())
409
410 @patch("lib.xquik.http.get")
411 def test_unpaid_402_is_false_with_reason(self, mock_get):
412 from lib import xquik, http as http_mod
413 mock_get.side_effect = http_mod.HTTPError("Payment Required", status_code=402)
414 self.assertIs(False, xquik.probe_works("k"))
415 self.assertIn("unpaid", xquik.probe_reason())
416
417 @patch("lib.xquik.http.get")
418 def test_auth_401_is_false(self, mock_get):
419 from lib import xquik, http as http_mod
420 mock_get.side_effect = http_mod.HTTPError("Unauthorized", status_code=401)
421 self.assertIs(False, xquik.probe_works("k"))
422 self.assertIn("auth failed", xquik.probe_reason())
423
424 @patch("lib.xquik.http.get")
425 def test_timeout_is_inconclusive_fail_open(self, mock_get):
426 from lib import xquik
427 mock_get.side_effect = TimeoutError("timed out")
428 self.assertIsNone(xquik.probe_works("k"))
429
430 def test_no_token_is_false(self):
431 from lib import xquik
432 self.assertIs(False, xquik.probe_works(""))
433 self.assertIn("no XQUIK_API_KEY", xquik.probe_reason())
434
435 @patch("lib.xquik.http.get")
436 def test_result_is_cached(self, mock_get):
437 from lib import xquik
438 mock_get.return_value = {"tweets": [{"id": "1"}]}
439 xquik.probe_works("k")
440 xquik.probe_works("k")
441 self.assertEqual(1, mock_get.call_count)
442
443
444 class TestDiagnoseSurfacesXquik(unittest.TestCase):
445 """U5: get_x_source_status reports xquik as the active X source when bird/
446 xAI/xurl are absent, and surfaces the probe reason."""
447
448 def setUp(self):
449 import lib.xquik as xq
450 xq._probe_cache = ("unset", "")
451
452 @patch("lib.xquik.http.get")
453 @patch("lib.xurl_x.is_available", return_value=False)
454 @patch("lib.bird_x.get_bird_status")
455 def test_xquik_is_active_x_source_when_only_key(self, mock_bird, _xurl, mock_get):
456 from lib import env
457 mock_bird.return_value = {"installed": False, "authenticated": False,
458 "username": "", "can_install": False}
459 mock_get.return_value = {"tweets": [{"id": "1"}]}
460 status = env.get_x_source_status({"XQUIK_API_KEY": "k"}, probe=True)
461 self.assertEqual("xquik", status["source"])
462 self.assertTrue(status["xquik_available"])
463 self.assertIs(True, status["xquik_working"])
464
465 @patch("lib.xurl_x.is_available", return_value=False)
466 @patch("lib.bird_x.get_bird_status")
467 def test_unpaid_xquik_not_active_source(self, mock_bird, _xurl):
468 from lib import env, http as http_mod
469 import lib.xquik as xq
470 mock_bird.return_value = {"installed": False, "authenticated": False,
471 "username": "", "can_install": False}
472 with patch("lib.xquik.http.get", side_effect=http_mod.HTTPError("pay", status_code=402)):
473 status = env.get_x_source_status({"XQUIK_API_KEY": "k"}, probe=True)
474 self.assertIsNone(status["source"]) # unpaid key is not a usable X source
475 self.assertIs(False, status["xquik_working"])
476 self.assertIn("unpaid", status["xquik_status"])
477
478
479 class TestMentionedHandles(unittest.TestCase):
480 """U3: xquik items carry leading-run @mentions so the first-party
481 interaction signal fires (shared parser with bird)."""
482
483 def _tweet(self, text):
484 return {
485 "id": "1", "text": text, "createdAt": "2026-06-15T12:00:00Z",
486 "author": {"username": "subject"},
487 }
488
489 def test_leading_mentions_captured(self):
490 item = _parse_tweet(self._tweet("@jack @pmarca thoughts on this"), 0, "topic")
491 self.assertEqual(["jack", "pmarca"], item["mentioned_handles"])
492
493 def test_midbody_mention_ignored(self):
494 item = _parse_tweet(self._tweet("I think @jack is right"), 0, "topic")
495 self.assertEqual([], item["mentioned_handles"])
496
497 def test_no_mentions(self):
498 item = _parse_tweet(self._tweet("Grok 4 just shipped"), 0, "topic")
499 self.assertEqual([], item["mentioned_handles"])
500
501
502 class TestNormalizePropagatesMentions(unittest.TestCase):
503 """U3: _normalize_x carries xquik mentioned_handles into metadata so rerank
504 can read them."""
505
506 def test_mentioned_handles_reach_metadata(self):
507 from lib import normalize
508 item = _parse_tweet(
509 {"id": "1", "text": "@jack hi", "createdAt": "2026-06-15T12:00:00Z",
510 "author": {"username": "subject"}}, 0, "topic")
511 normalized = normalize.normalize_source_items("xquik", [item], "2026-05-19", "2026-06-18")
512 self.assertEqual(["jack"], normalized[0].metadata.get("mentioned_handles"))
513
514
515 if __name__ == "__main__":
516 unittest.main()
517
517 lines PYTHON