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