返回 last30days-skill
test_instagram_sc.py
根目录 / tests / test_instagram_sc.py
1 """Tests for instagram.py — ScrapeCreators Instagram search module."""
2
3 import os
4 import unittest
5 from unittest.mock import MagicMock, patch
6
7 # Add lib to path
8
9 from lib import instagram
10 from lib.relevance import tokenize as _tokenize
11
12
13 class TestTokenize(unittest.TestCase):
14 """Tests for tokenize() from relevance module."""
15
16 def test_strips_stopwords(self):
17 tokens = _tokenize("how to use the AI tools")
18 self.assertNotIn("how", tokens)
19 self.assertNotIn("the", tokens)
20 self.assertNotIn("to", tokens)
21
22 def test_expands_synonyms(self):
23 tokens = _tokenize("ai tools")
24 self.assertTrue("artificial" in tokens or "intelligence" in tokens)
25
26 def test_removes_single_char(self):
27 tokens = _tokenize("a b c python")
28 self.assertNotIn("a", tokens)
29 self.assertNotIn("b", tokens)
30 self.assertIn("python", tokens)
31
32 def test_lowercases(self):
33 tokens = _tokenize("Python REACT")
34 self.assertIn("python", tokens)
35 self.assertIn("react", tokens)
36
37 def test_strips_punctuation(self):
38 tokens = _tokenize("hello, world!")
39 self.assertIn("hello", tokens)
40 self.assertIn("world", tokens)
41
42
43 class TestComputeRelevance(unittest.TestCase):
44 """Tests for _compute_relevance()."""
45
46 def test_exact_match_high(self):
47 rel = instagram._compute_relevance("claude code", "Claude Code tricks and tips")
48 self.assertGreaterEqual(rel, 0.8)
49
50 def test_partial_match_lower(self):
51 rel = instagram._compute_relevance("claude code tips", "Best AI tools for coding")
52 self.assertLess(rel, 0.5)
53
54 def test_hashtag_boost(self):
55 base = instagram._compute_relevance("claude code", "random video about stuff")
56 boosted = instagram._compute_relevance("claude code", "random video about stuff", ["claudecode", "ai"])
57 self.assertGreater(boosted, base)
58
59 def test_no_match_returns_zero(self):
60 rel = instagram._compute_relevance("quantum physics", "cat dancing video")
61 self.assertEqual(rel, 0.0)
62
63 def test_empty_query_returns_default(self):
64 rel = instagram._compute_relevance("", "Some video title")
65 self.assertEqual(rel, 0.5)
66
67
68 class TestInstagramDepthConfig(unittest.TestCase):
69 """Tests for DEPTH_CONFIG."""
70
71 def test_all_depths_exist(self):
72 for depth in ("quick", "default", "deep"):
73 self.assertIn(depth, instagram.DEPTH_CONFIG)
74
75 def test_required_keys(self):
76 for depth, config in instagram.DEPTH_CONFIG.items():
77 self.assertIn("results_per_page", config)
78 self.assertIn("max_captions", config)
79
80 def test_deep_has_more_results(self):
81 self.assertGreater(
82 instagram.DEPTH_CONFIG["deep"]["results_per_page"],
83 instagram.DEPTH_CONFIG["quick"]["results_per_page"],
84 )
85
86
87 class TestHashtagFormCollapse(unittest.TestCase):
88 """Tests for _to_hashtag_form() — the multi-word retry workaround."""
89
90 def test_collapses_spaces(self):
91 self.assertEqual(instagram._to_hashtag_form("toronto real estate"), "torontorealestate")
92
93 def test_lowercases(self):
94 self.assertEqual(instagram._to_hashtag_form("Toronto REAL Estate"), "torontorealestate")
95
96 def test_idempotent_on_single_word(self):
97 self.assertEqual(instagram._to_hashtag_form("ozempic"), "ozempic")
98
99 def test_handles_extra_whitespace(self):
100 self.assertEqual(instagram._to_hashtag_form(" toronto real estate "), "torontorealestate")
101
102
103 class TestSearchRetryOn500(unittest.TestCase):
104 """Tests for the multi-word -> hashtag retry on SC's flaky 500 path.
105
106 SC's /v2/instagram/reels/search wraps Google Search and is documented
107 to be unreliable on multi-token queries. The retry collapses to a
108 hashtag form which hits the stable hashtag-page lookup path.
109 """
110
111 def test_multiword_500_triggers_retry_with_hashtag_form(self):
112 """Multi-word query 500 -> retry with collapsed hashtag form."""
113 from lib import http as http_module
114 first_error = http_module.HTTPError("HTTP 500: Server Error", 500, "")
115 second_payload = {"reels": []}
116 with patch.object(http_module, "get") as mock_http_get:
117 mock_http_get.side_effect = [first_error, second_payload]
118 instagram.search_instagram(
119 "toronto real estate", "2026-04-01", "2026-05-04",
120 depth="default", token="fake-token",
121 )
122 self.assertEqual(mock_http_get.call_count, 2)
123 # First call: original multi-word query
124 first_params = mock_http_get.call_args_list[0].kwargs["params"]
125 self.assertEqual(first_params["query"], "toronto real estate")
126 # Second call: collapsed hashtag form
127 second_params = mock_http_get.call_args_list[1].kwargs["params"]
128 self.assertEqual(second_params["query"], "torontorealestate")
129
130 def test_singleword_500_does_not_retry(self):
131 """Single-word query 500 has no spaces to collapse - no retry."""
132 from lib import http as http_module
133 only_error = http_module.HTTPError("HTTP 500: Server Error", 500, "")
134 with patch.object(http_module, "get") as mock_http_get:
135 mock_http_get.side_effect = only_error
136 result = instagram.search_instagram(
137 "ozempic", "2026-04-01", "2026-05-04",
138 depth="default", token="fake-token",
139 )
140 self.assertEqual(mock_http_get.call_count, 1)
141 self.assertIn("error", result)
142 self.assertEqual(result["items"], [])
143
144 def test_first_call_succeeds_no_retry(self):
145 """200 on first call -> retry path is never entered."""
146 from lib import http as http_module
147 ok_payload = {"reels": []}
148 with patch.object(http_module, "get") as mock_http_get:
149 mock_http_get.return_value = ok_payload
150 instagram.search_instagram(
151 "toronto real estate", "2026-04-01", "2026-05-04",
152 depth="default", token="fake-token",
153 )
154 self.assertEqual(mock_http_get.call_count, 1)
155
156 def test_no_token_short_circuits(self):
157 """No SCRAPECREATORS_API_KEY -> error returned without HTTP call."""
158 from lib import http as http_module
159 with patch.object(http_module, "get") as mock_http_get:
160 result = instagram.search_instagram(
161 "toronto real estate", "2026-04-01", "2026-05-04",
162 depth="default", token=None,
163 )
164 mock_http_get.assert_not_called()
165 self.assertIn("error", result)
166 self.assertIn("SCRAPECREATORS_API_KEY", result["error"])
167
168
169 class TestTranscriptTimeoutConfig(unittest.TestCase):
170 """Tests for LAST30DAYS_TRANSCRIPT_TIMEOUT configuration.
171
172 SC's /v2/instagram/media/transcript endpoint regularly takes >15s,
173 so the timeout must be configurable. Default is DEFAULT_TRANSCRIPT_TIMEOUT
174 (30s); the env var or per-call kwarg overrides it.
175 """
176
177 def setUp(self):
178 # Snapshot any pre-existing env so we don't leak across tests
179 self._saved_env = os.environ.pop("LAST30DAYS_TRANSCRIPT_TIMEOUT", None)
180
181 def tearDown(self):
182 os.environ.pop("LAST30DAYS_TRANSCRIPT_TIMEOUT", None)
183 if self._saved_env is not None:
184 os.environ["LAST30DAYS_TRANSCRIPT_TIMEOUT"] = self._saved_env
185
186 def _ok_payload(self):
187 return {"transcripts": [{"text": "hello world"}]}
188
189 def _video_item(self, vid="abc123"):
190 return {
191 "video_id": vid,
192 "url": f"https://www.instagram.com/reel/{vid}/",
193 "text": "",
194 }
195
196 def test_default_timeout_is_30s_when_nothing_set(self):
197 """No env var, no kwarg -> request uses 30s, not the legacy 15s."""
198 from lib import http as http_module
199 items = [self._video_item()]
200 with patch.object(http_module, "get") as mock_http_get:
201 mock_http_get.return_value = self._ok_payload()
202 instagram.fetch_captions(items, token="fake-token")
203 kwargs = mock_http_get.call_args.kwargs
204 self.assertEqual(kwargs["timeout"], 30.0)
205
206 def test_env_var_override(self):
207 """LAST30DAYS_TRANSCRIPT_TIMEOUT='60' -> request uses 60s."""
208 from lib import http as http_module
209 os.environ["LAST30DAYS_TRANSCRIPT_TIMEOUT"] = "60"
210 items = [self._video_item()]
211 with patch.object(http_module, "get") as mock_http_get:
212 mock_http_get.return_value = self._ok_payload()
213 instagram.fetch_captions(items, token="fake-token")
214 kwargs = mock_http_get.call_args.kwargs
215 self.assertEqual(kwargs["timeout"], 60.0)
216
217 def test_explicit_timeout_kwarg_wins_over_env(self):
218 """Explicit timeout= kwarg trumps the env var."""
219 from lib import http as http_module
220 os.environ["LAST30DAYS_TRANSCRIPT_TIMEOUT"] = "60"
221 items = [self._video_item()]
222 with patch.object(http_module, "get") as mock_http_get:
223 mock_http_get.return_value = self._ok_payload()
224 instagram.fetch_captions(items, token="fake-token", timeout=10)
225 kwargs = mock_http_get.call_args.kwargs
226 self.assertEqual(kwargs["timeout"], 10.0)
227
228 def test_config_dict_fallback_when_env_unset(self):
229 """config={'LAST30DAYS_TRANSCRIPT_TIMEOUT': '45'} -> request uses 45s."""
230 from lib import http as http_module
231 items = [self._video_item()]
232 with patch.object(http_module, "get") as mock_http_get:
233 mock_http_get.return_value = self._ok_payload()
234 instagram.fetch_captions(
235 items,
236 token="fake-token",
237 config={"LAST30DAYS_TRANSCRIPT_TIMEOUT": "45"},
238 )
239 kwargs = mock_http_get.call_args.kwargs
240 self.assertEqual(kwargs["timeout"], 45.0)
241
242 def test_invalid_env_value_falls_back_to_default(self):
243 """Garbage env var doesn't crash; falls back to 30s."""
244 from lib import http as http_module
245 os.environ["LAST30DAYS_TRANSCRIPT_TIMEOUT"] = "not-a-number"
246 items = [self._video_item()]
247 with patch.object(http_module, "get") as mock_http_get:
248 mock_http_get.return_value = self._ok_payload()
249 instagram.fetch_captions(items, token="fake-token")
250 kwargs = mock_http_get.call_args.kwargs
251 self.assertEqual(kwargs["timeout"], 30.0)
252
253 if __name__ == "__main__":
254 unittest.main()
255
255 lines PYTHON