"""Tests for helpers in mcp.tools.scraping_tools.base.""" import asyncio from collections.abc import Coroutine from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest import marketing_intelligence.mcp.tools.scraping_tools.base as base_module from marketing_intelligence.mcp.tools.scraping_tools.base import ( ScrapingService, _extract_comments_from_body, _is_comment_date, _parse_comments, claim_video_id, click_and_await_comments, release_video_id, wait_for_comments_stable, ) def _run(coro: Coroutine[Any, Any, Any]) -> Any: return asyncio.run(coro) @pytest.fixture(autouse=True) def _clear_claimed_state() -> Any: base_module._claimed_video_ids.clear() yield base_module._claimed_video_ids.clear() # ── _is_comment_date ───────────────────────────────────────────────────────── class TestIsCommentDate: @pytest.mark.parametrize( "s", [ "5d ago", "2h ago", "3w ago", "1m ago", "30s ago", "1y ago", "12d ago", "2024-3-15", "2024-12-1", "3-15", "12-1", ], ) def test_valid_dates(self, s: str) -> None: assert _is_comment_date(s) is True @pytest.mark.parametrize( "s", [ "", "not a date", "hello world", "ago", "5x ago", "Reply", "2024/03/15", "March 15", "5 days ago", ], ) def test_invalid_dates(self, s: str) -> None: assert _is_comment_date(s) is False # ── _parse_comments ─────────────────────────────────────────────────────────── class TestParseComments: def test_single_comment(self) -> None: section = ["hello world", "3d ago"] assert _parse_comments(section) == ["hello world"] def test_skips_badge_falls_back_to_back2(self) -> None: section = ["hello world", "·Verified", "3d ago"] assert _parse_comments(section) == ["hello world"] def test_skips_known_noise_falls_back_to_back2(self) -> None: section = ["hello world", "Reply", "3d ago"] assert _parse_comments(section) == ["hello world"] def test_badge_at_back1_no_back2(self) -> None: section = ["·Verified", "3d ago"] assert _parse_comments(section) == [] def test_no_space_in_candidate_excluded(self) -> None: section = ["nospace", "3d ago"] assert _parse_comments(section) == [] def test_multiple_comments(self) -> None: section = ["first comment", "2d ago", "second comment", "5h ago"] result = _parse_comments(section) assert result == ["first comment", "second comment"] def test_deduplication(self) -> None: section = ["hello world", "2d ago", "hello world", "3d ago"] result = _parse_comments(section) assert result == ["hello world"] assert len(result) == 1 def test_no_dates_returns_empty(self) -> None: section = ["hello world", "some text", "more text"] assert _parse_comments(section) == [] def test_empty_section(self) -> None: assert _parse_comments([]) == [] def test_interleaved_badge_and_comment(self) -> None: section = [ "first comment", "2d ago", "·badge", "3d ago", "second comment", "5d ago", ] result = _parse_comments(section) assert "first comment" in result assert "second comment" in result def test_see_translation_skipped(self) -> None: section = ["hello world", "See translation", "3d ago"] assert _parse_comments(section) == ["hello world"] def test_view_more_skipped(self) -> None: section = ["hello world", "View more", "3d ago"] assert _parse_comments(section) == ["hello world"] # ── _extract_comments_from_body ─────────────────────────────────────────────── class TestExtractCommentsFromBody: def _body(self, *lines: str) -> str: return "\n".join(lines) def test_basic_extraction(self) -> None: body = self._body( "Header text", "1234 comments", "hello world", "3d ago", "Log in", ) result = _extract_comments_from_body(body) assert result == ["hello world"] def test_no_comments_marker(self) -> None: body = self._body("hello world", "3d ago") assert _extract_comments_from_body(body) == [] def test_stop_marker_log_in(self) -> None: body = self._body( "100 comments", "first comment", "2d ago", "Log in", "second comment", "3d ago", ) result = _extract_comments_from_body(body) assert result == ["first comment"] assert "second comment" not in result def test_stop_marker_you_may_like(self) -> None: body = self._body( "50 comments", "nice song", "5h ago", "You may like", "other text", "1d ago", ) result = _extract_comments_from_body(body) assert result == ["nice song"] def test_multiple_comments_before_stop(self) -> None: body = self._body( "200 comments", "first comment", "1d ago", "second comment", "2d ago", "Drag the slider", ) result = _extract_comments_from_body(body) assert len(result) == 2 assert "first comment" in result assert "second comment" in result def test_empty_body(self) -> None: assert _extract_comments_from_body("") == [] def test_blank_lines_inside_comments_ignored(self) -> None: body = self._body( "10 comments", "", "hello world", "3d ago", ) result = _extract_comments_from_body(body) assert result == ["hello world"] def test_comments_plural_marker_required(self) -> None: body = self._body( "10 comment", "hello world", "3d ago", ) assert _extract_comments_from_body(body) == [] # ── claim_video_id / release_video_id ───────────────────────────────────────── class TestClaimVideoId: def test_first_claim_returns_true(self) -> None: assert _run(claim_video_id("c1", "r1", "track", "vid1")) is True def test_duplicate_claim_returns_false(self) -> None: _run(claim_video_id("c1", "r1", "track", "vid1")) assert _run(claim_video_id("c1", "r1", "track", "vid1")) is False def test_different_track_keys_are_independent(self) -> None: assert _run(claim_video_id("c1", "r1", "track_a", "vid1")) is True assert _run(claim_video_id("c1", "r1", "track_b", "vid1")) is True def test_release_allows_re_claim(self) -> None: _run(claim_video_id("c1", "r1", "track", "vid1")) _run(release_video_id("c1", "r1", "track", "vid1")) assert _run(claim_video_id("c1", "r1", "track", "vid1")) is True def test_release_unknown_video_is_safe(self) -> None: _run(release_video_id("c1", "r1", "track", "never_claimed")) # ── wait_for_comments_stable ───────────────────────────────────────────────── class _FakePage: """Minimal page mock whose inner_text returns N 'Reply' repetitions per call.""" def __init__(self, reply_counts: list[int]) -> None: self._iter = iter(reply_counts) self._last = reply_counts[-1] if reply_counts else 0 self.evaluate_calls = 0 async def evaluate(self, _script: str) -> None: self.evaluate_calls += 1 async def inner_text(self, _sel: str) -> str: try: n = next(self._iter) except StopIteration: n = self._last return "Reply " * n class TestWaitForCommentsStable: def _run_stable( self, counts: list[int], *, max_iter: int = 20, min_iter: int = 3 ) -> _FakePage: page = _FakePage(counts) async def _go() -> None: await wait_for_comments_stable( page, max_iterations=max_iter, min_iterations=min_iter ) asyncio.run(_go()) return page def test_exits_when_count_stable(self) -> None: # non-zero counts: grows then stabilises — 3 calls before break page = self._run_stable([3, 5, 5], min_iter=1) assert page.evaluate_calls == 3 def test_min_iterations_prevents_early_exit_on_zeros(self) -> None: # 0,0,0 — with min_iter=3 we need 3 iterations before exiting page = self._run_stable([0, 0, 0], min_iter=3) assert page.evaluate_calls == 3 def test_zeros_with_min_iter_2_exits_after_two(self) -> None: # zeros stable from start; min_iter=2 prevents exit on first iteration page = self._run_stable([0, 0], min_iter=2) assert page.evaluate_calls == 2 def test_max_iterations_cap(self) -> None: # always growing — must not loop forever page = self._run_stable(list(range(1, 50)), max_iter=5, min_iter=1) assert page.evaluate_calls == 5 # ── click_and_await_comments ───────────────────────────────────────────────── class TestClickAndAwaitComments: def _make_ctx( self, *, raises_on_enter: bool = False, raises_on_exit: bool = False ) -> MagicMock: class _AwaitableNone: def __await__(self) -> Any: return iter(()) ctx = MagicMock() resp_info = MagicMock() resp_info.value = _AwaitableNone() if raises_on_enter: ctx.__aenter__ = AsyncMock(side_effect=asyncio.TimeoutError("no response")) else: ctx.__aenter__ = AsyncMock(return_value=resp_info) if raises_on_exit: ctx.__aexit__ = AsyncMock(side_effect=asyncio.TimeoutError("exit timeout")) else: ctx.__aexit__ = AsyncMock(return_value=False) return ctx def test_action_called_on_success(self) -> None: action = AsyncMock() page = MagicMock() page.expect_response = MagicMock(return_value=self._make_ctx()) _run(click_and_await_comments(page, action)) action.assert_awaited_once() def test_exception_on_enter_does_not_propagate(self) -> None: action = AsyncMock() page = MagicMock() page.expect_response = MagicMock( return_value=self._make_ctx(raises_on_enter=True) ) _run(click_and_await_comments(page, action)) # action was NOT called (exception happened in __aenter__ before action ran) action.assert_not_awaited() def test_timeout_on_exit_does_not_propagate(self) -> None: action = AsyncMock() page = MagicMock() page.expect_response = MagicMock( return_value=self._make_ctx(raises_on_exit=True) ) # Should not raise — exception caught _run(click_and_await_comments(page, action)) action.assert_awaited_once() # ── ScrapingService ─────────────────────────────────────────────────────────── class TestBuildSoundUrl: def test_returns_sound_url_response(self) -> None: from marketing_intelligence.mcp.tools.responses import SoundUrlResponse r = ScrapingService.build_sound_url("12345") assert isinstance(r, SoundUrlResponse) assert r.sound_id == "12345" assert r.url is not None assert "12345" in (r.url or "") def test_url_contains_tiktok_domain(self) -> None: r = ScrapingService.build_sound_url("99999") assert "tiktok.com" in (r.url or "") class TestScrapingServiceGetAdapter: def test_with_session_id_uses_session(self) -> None: mock_session = MagicMock() mock_session.proxy = None mock_adapter = MagicMock() with ( patch( "marketing_intelligence.mcp.tools.scraping_tools.base.get_session", return_value=mock_session, ), patch( "marketing_intelligence.mcp.tools.scraping_tools.base.create_adapter_from_session", return_value=mock_adapter, ) as mock_create, ): result = ScrapingService().get_adapter(session_id="sid1") assert result is mock_adapter mock_create.assert_called_once() def test_with_unknown_session_id_falls_through(self) -> None: mock_adapter = MagicMock() with ( patch( "marketing_intelligence.mcp.tools.scraping_tools.base.get_session", return_value=None, ), patch("marketing_intelligence.mcp.tools.scraping_tools.base.settings") as s, patch( "marketing_intelligence.mcp.tools.scraping_tools.base.create_adapter", return_value=mock_adapter, ) as mock_create, ): s.proxy_list = [] s.proxy_lambda_name = None s.browser_engine = "camoufox" s.browser_profile = "desktop" s.browser_human_behavior = True result = ScrapingService().get_adapter(session_id="unknown") assert result is mock_adapter mock_create.assert_called_once() def test_no_session_no_proxy_uses_default_adapter(self) -> None: mock_adapter = MagicMock() with ( patch("marketing_intelligence.mcp.tools.scraping_tools.base.settings") as s, patch( "marketing_intelligence.mcp.tools.scraping_tools.base.create_adapter", return_value=mock_adapter, ) as mock_create, ): s.proxy_list = [] s.proxy_lambda_name = None s.browser_engine = "camoufox" s.browser_profile = "desktop" s.browser_human_behavior = True result = ScrapingService().get_adapter() assert result is mock_adapter mock_create.assert_called_once_with( engine="camoufox", profile_name="desktop", proxy=None, human_behavior=True, ) def test_proxy_acquired_when_configured_and_no_explicit_proxy(self) -> None: mock_ps = MagicMock() mock_ps.to_playwright_dict.return_value = {"server": "http://p:8080"} mock_mgr = MagicMock() mock_mgr.acquire.return_value = mock_ps mock_adapter = MagicMock() with ( patch("marketing_intelligence.mcp.tools.scraping_tools.base.settings") as s, patch( "marketing_intelligence.mcp.tools.scraping_tools.base.ProxyManager", return_value=mock_mgr, ), patch( "marketing_intelligence.mcp.tools.scraping_tools.base.create_adapter", return_value=mock_adapter, ), ): s.proxy_list = ["http://p:8080"] s.proxy_lambda_name = None s.browser_engine = "camoufox" s.browser_profile = "desktop" s.browser_human_behavior = True ScrapingService().get_adapter() mock_mgr.acquire.assert_called_once() class TestScrapingServiceSignals: def test_delegates_to_page_signals(self) -> None: from marketing_intelligence.evasion.detection import PageSignals mock_snap = MagicMock() mock_snap.html = "" mock_snap.final_url = "https://t.co/page" mock_snap.response_time_ms = 300 mock_signals = MagicMock(spec=PageSignals) with patch.object( PageSignals, "from_page", return_value=mock_signals ) as mock_fp: result = ScrapingService().signals( mock_snap, "https://t.co/page", items_loaded=5, count_text="5M" ) assert result is mock_signals mock_fp.assert_called_once_with( html="", requested_url="https://t.co/page", final_url="https://t.co/page", response_time_ms=300, items_loaded=5, count_text="5M", ) class TestScrapingServiceTagPage: def _fake_signals(self) -> Any: from marketing_intelligence.evasion.detection import PageSignals return PageSignals() def test_returns_tag_page_response(self) -> None: from marketing_intelligence.mcp.tools.responses import TagPageResponse mock_snap = MagicMock() mock_snap.html = "" mock_snap.final_url = "https://t.co/tag/dance" mock_snap.response_time_ms = 200 mock_adapter = MagicMock() mock_adapter.fetch_with_scroll = AsyncMock(return_value=mock_snap) with ( patch.object(ScrapingService, "get_adapter", return_value=mock_adapter), patch( "marketing_intelligence.mcp.tools.scraping_tools.base.TikTokExtractor.tag_videos", return_value=([], "1.2M"), ), patch.object(ScrapingService, "signals", return_value=self._fake_signals()), ): r = asyncio.run(ScrapingService().tag_page("dance")) assert isinstance(r, TagPageResponse) assert r.tag == "dance" def test_strips_hash_prefix(self) -> None: mock_snap = MagicMock() mock_snap.html = "" mock_snap.final_url = None mock_snap.response_time_ms = 100 mock_adapter = MagicMock() mock_adapter.fetch_with_scroll = AsyncMock(return_value=mock_snap) with ( patch.object(ScrapingService, "get_adapter", return_value=mock_adapter), patch( "marketing_intelligence.mcp.tools.scraping_tools.base.TikTokExtractor.tag_videos", return_value=([], None), ), patch.object(ScrapingService, "signals", return_value=self._fake_signals()), ): r = asyncio.run(ScrapingService().tag_page("#dance")) assert r.tag == "dance" class TestScrapingServiceSoundPage: def _fake_signals(self) -> Any: from marketing_intelligence.evasion.detection import PageSignals return PageSignals() def test_returns_sound_page_response(self) -> None: from marketing_intelligence.mcp.tools.responses import SoundPageResponse mock_snap = MagicMock() mock_snap.html = "" mock_snap.title = "My Track | TikTok" mock_snap.final_url = "https://t.co/music/123" mock_snap.response_time_ms = 250 mock_adapter = MagicMock() mock_adapter.fetch_with_scroll = AsyncMock(return_value=mock_snap) with ( patch.object(ScrapingService, "get_adapter", return_value=mock_adapter), patch( "marketing_intelligence.mcp.tools.scraping_tools.base.TikTokExtractor.sound_video_urls", return_value=["url1", "url2"], ), patch( "marketing_intelligence.mcp.tools.scraping_tools.base.TikTokExtractor.video_count", return_value="2M", ), patch.object(ScrapingService, "signals", return_value=self._fake_signals()), ): r = asyncio.run(ScrapingService().sound_page("sid123")) assert isinstance(r, SoundPageResponse) assert r.sound_id == "sid123" assert r.title == "My Track" assert len(r.video_urls) == 2 def test_none_title_when_snap_title_none(self) -> None: mock_snap = MagicMock() mock_snap.html = "" mock_snap.title = None mock_snap.final_url = None mock_snap.response_time_ms = 100 mock_adapter = MagicMock() mock_adapter.fetch_with_scroll = AsyncMock(return_value=mock_snap) with ( patch.object(ScrapingService, "get_adapter", return_value=mock_adapter), patch( "marketing_intelligence.mcp.tools.scraping_tools.base.TikTokExtractor.sound_video_urls", return_value=[], ), patch( "marketing_intelligence.mcp.tools.scraping_tools.base.TikTokExtractor.video_count", return_value=None, ), patch.object(ScrapingService, "signals", return_value=self._fake_signals()), ): r = asyncio.run(ScrapingService().sound_page("sid123")) assert r.title is None class TestScrapingServiceArtistProfile: def _fake_signals(self) -> Any: from marketing_intelligence.evasion.detection import PageSignals return PageSignals() def test_returns_artist_profile_response(self) -> None: from marketing_intelligence.mcp.tools.responses import ArtistProfileResponse mock_snap = MagicMock() mock_snap.html = "" mock_snap.final_url = "https://t.co/@artist" mock_snap.response_time_ms = 150 mock_adapter = MagicMock() mock_adapter.fetch_with_scroll = AsyncMock(return_value=mock_snap) profile = {"nickname": "The Artist", "followers": 100000, "likes": 5000} with ( patch.object(ScrapingService, "get_adapter", return_value=mock_adapter), patch( "marketing_intelligence.mcp.tools.scraping_tools.base.TikTokExtractor.artist_profile", return_value=profile, ), patch.object(ScrapingService, "signals", return_value=self._fake_signals()), ): r = asyncio.run(ScrapingService().artist_profile("artist")) assert isinstance(r, ArtistProfileResponse) assert r.success is True assert r.nickname == "The Artist" assert r.followers == 100000 def test_returns_failure_when_no_profile(self) -> None: mock_snap = MagicMock() mock_snap.html = "" mock_snap.final_url = None mock_snap.response_time_ms = 100 mock_adapter = MagicMock() mock_adapter.fetch_with_scroll = AsyncMock(return_value=mock_snap) with ( patch.object(ScrapingService, "get_adapter", return_value=mock_adapter), patch( "marketing_intelligence.mcp.tools.scraping_tools.base.TikTokExtractor.artist_profile", return_value=None, ), patch.object(ScrapingService, "signals", return_value=self._fake_signals()), ): r = asyncio.run(ScrapingService().artist_profile("unknown")) assert r.success is False assert r.error is not None