| 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 |