""" Tests for async_web_fetcher module. Tests URL handling, media extraction, robots.txt compliance, and async fetching functionality. """ import pytest from unittest.mock import Mock, AsyncMock, patch, MagicMock import asyncio import aiohttp from arborist.sources.crawler.async_web_fetcher import ( CrawlMode, MediaItem, strip_uri_fragment, normalize_link, get_media_type_from_extension, get_media_type_from_mime, extract_media_from_html, AsyncWebFetcher, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS, AUDIO_EXTENSIONS, DEFAULT_CRAWL_DELAY, get_last_fetch_error, clear_last_fetch_error, ) class TestSharedSession: """Shared keepalive session lifecycle (fast-crawl connection reuse).""" def test_get_session_is_shared(self): async def go(): f = AsyncWebFetcher() s1 = await f._get_session() s2 = await f._get_session() assert s1 is s2 await f.aclose() asyncio.run(go()) def test_aclose_closes_and_resets(self): async def go(): f = AsyncWebFetcher() s = await f._get_session() await f.aclose() assert s.closed assert f._session is None await f.aclose() # idempotent — must not raise asyncio.run(go()) def test_get_session_rebuilds_after_close(self): async def go(): f = AsyncWebFetcher() s1 = await f._get_session() await f.aclose() s2 = await f._get_session() assert s2 is not s1 assert not s2.closed await f.aclose() asyncio.run(go()) class TestFastModeIgnoresCrawlDelay: """fast_mode must ignore crawl-delay regardless of robots.txt status. Regression: the delay was only zeroed on the robots-200 path, so a site with no robots.txt (404) fell back to default_crawl_delay (2s) and every concurrent fetch wave slept ~2s even under --fast.""" def test_fast_mode_skips_delay_even_with_default_delay_set(self): async def go(): f = AsyncWebFetcher(fast_mode=True) # Simulate the robots-404 fallback: a non-zero per-domain delay. f.domain_crawl_delays["x.com"] = 2.0 f.domain_last_fetched["x.com"] = __import__("time").time() t = __import__("time").time() await f._enforce_crawl_delay("x.com") assert (__import__("time").time() - t) < 0.5, "fast_mode must not sleep" asyncio.run(go()) def test_polite_mode_still_enforces_delay(self): async def go(): import time as _t f = AsyncWebFetcher(fast_mode=False, default_crawl_delay=0.3) f.domain_crawl_delays["x.com"] = 0.3 f.domain_last_fetched["x.com"] = _t.time() t = _t.time() await f._enforce_crawl_delay("x.com") assert (_t.time() - t) >= 0.25, "polite mode must still wait" asyncio.run(go()) class TestExtractPageLinks: """Link extraction for BFS (lxml fast path + BS4 fallback parity).""" HTML = ( '' 'A' 'ext' 'js' 'frag' 'nf' 'C' '' ) def test_lxml_extraction_rules(self): from arborist.sources.crawler.async_web_fetcher import extract_page_links out = extract_page_links(self.HTML, "https://x.com/") assert "https://x.com/a" in out # relative resolved assert "https://other.com/x" in out # off-domain kept (bridge filters) assert "https://x.com/c" in out # fragment stripped assert not any("javascript" in u for u in out) assert "https://x.com/b" not in out # rel=nofollow dropped assert not any("#" in u for u in out) def test_lxml_matches_bs4_fallback(self): from arborist.sources.crawler.async_web_fetcher import ( extract_page_links, _extract_page_links_bs4, ) assert ( extract_page_links(self.HTML, "https://x.com/") == _extract_page_links_bs4(self.HTML, "https://x.com/") ) def test_anchor_text_mode(self): from arborist.sources.crawler.async_web_fetcher import extract_page_links out = extract_page_links(self.HTML, "https://x.com/", extract_anchor_text=True) a = next(d for d in out if d["url"] == "https://x.com/a") assert a["anchor_text"] == "A" def test_malformed_html_falls_back_gracefully(self): from arborist.sources.crawler.async_web_fetcher import extract_page_links # Empty / whitespace markup makes lxml.fromstring raise — the # helper must fall back, not crash. assert extract_page_links(" ", "https://x.com/") == [] class TestCrawlMode: """Test CrawlMode enum.""" def test_crawl_mode_values(self): """Test all crawl mode values exist.""" assert CrawlMode.TEXT.value == "text" assert CrawlMode.IMAGES.value == "images" assert CrawlMode.VIDEOS.value == "videos" assert CrawlMode.MEDIA.value == "media" assert CrawlMode.ALL.value == "all" def test_crawl_mode_from_string(self): """Test creating CrawlMode from string.""" assert CrawlMode("text") == CrawlMode.TEXT assert CrawlMode("images") == CrawlMode.IMAGES assert CrawlMode("media") == CrawlMode.MEDIA class TestMediaItem: """Test MediaItem dataclass.""" def test_media_item_creation(self): """Test creating a MediaItem.""" item = MediaItem( url="https://example.com/image.jpg", source_page="https://example.com/", media_type="image", mime_type="image/jpeg", ) assert item.url == "https://example.com/image.jpg" assert item.source_page == "https://example.com/" assert item.media_type == "image" assert item.mime_type == "image/jpeg" assert item.discovered_at is not None def test_media_item_optional_fields(self): """Test MediaItem with optional fields.""" item = MediaItem( url="https://example.com/video.mp4", source_page="https://example.com/", media_type="video", alt_text="A video", width=1920, height=1080, ) assert item.alt_text == "A video" assert item.width == 1920 assert item.height == 1080 class TestURIHandling: """Test URI handling functions.""" def test_strip_uri_fragment_basic(self): """Test stripping fragment from URI.""" uri = "https://example.com/page#section" result = strip_uri_fragment(uri) assert result == "https://example.com/page" def test_strip_uri_fragment_no_fragment(self): """Test URI without fragment.""" uri = "https://example.com/page" result = strip_uri_fragment(uri) assert result == "https://example.com/page" def test_strip_uri_fragment_with_query(self): """Test URI with query and fragment.""" uri = "https://example.com/page?q=test#section" result = strip_uri_fragment(uri) assert result == "https://example.com/page?q=test" def test_strip_uri_fragment_empty(self): """Test empty URI.""" assert strip_uri_fragment("") == "" assert strip_uri_fragment(None) is None def test_normalize_link_dict(self): """Test normalizing link from dict.""" link = {"url": "https://example.com/page#section", "anchor_text": "Click here"} uri, anchor = normalize_link(link) assert uri == "https://example.com/page" assert anchor == "Click here" def test_normalize_link_string(self): """Test normalizing link from string.""" uri, anchor = normalize_link("https://example.com/page#section") assert uri == "https://example.com/page" assert anchor == "" class TestMediaTypeDetection: """Test media type detection functions.""" def test_get_media_type_from_extension_image(self): """Test detecting image from extension.""" assert get_media_type_from_extension("https://example.com/photo.jpg") == "image" assert get_media_type_from_extension("https://example.com/photo.png") == "image" assert get_media_type_from_extension("https://example.com/photo.webp") == "image" assert get_media_type_from_extension("https://example.com/photo.gif") == "image" def test_get_media_type_from_extension_video(self): """Test detecting video from extension.""" assert get_media_type_from_extension("https://example.com/video.mp4") == "video" assert get_media_type_from_extension("https://example.com/video.webm") == "video" assert get_media_type_from_extension("https://example.com/video.mov") == "video" def test_get_media_type_from_extension_audio(self): """Test detecting audio from extension.""" assert get_media_type_from_extension("https://example.com/audio.mp3") == "audio" assert get_media_type_from_extension("https://example.com/audio.wav") == "audio" assert get_media_type_from_extension("https://example.com/audio.ogg") == "audio" def test_get_media_type_from_extension_code(self): """Test detecting code from extension.""" assert get_media_type_from_extension("https://example.com/file.txt") == "code" assert get_media_type_from_extension("https://example.com/script.py") == "code" assert get_media_type_from_extension("https://example.com/app.js") == "code" def test_get_media_type_from_extension_unknown(self): """Test unknown extension.""" assert get_media_type_from_extension("https://example.com/page.html") is None assert get_media_type_from_extension("https://example.com/file.xyz") is None def test_get_media_type_from_mime_image(self): """Test detecting image from MIME type.""" assert get_media_type_from_mime("image/jpeg") == "image" assert get_media_type_from_mime("image/png") == "image" assert get_media_type_from_mime("image/webp") == "image" def test_get_media_type_from_mime_video(self): """Test detecting video from MIME type.""" assert get_media_type_from_mime("video/mp4") == "video" assert get_media_type_from_mime("video/webm") == "video" def test_get_media_type_from_mime_audio(self): """Test detecting audio from MIME type.""" assert get_media_type_from_mime("audio/mpeg") == "audio" assert get_media_type_from_mime("audio/wav") == "audio" def test_get_media_type_from_mime_code(self): """Test detecting code from MIME type.""" assert get_media_type_from_mime("application/json") == "code" assert get_media_type_from_mime("text/plain") == "code" assert get_media_type_from_mime("application/javascript") == "code" def test_get_media_type_from_mime_unknown(self): """Test unknown MIME type.""" assert get_media_type_from_mime("text/html") is None assert get_media_type_from_mime("application/octet-stream") is None class TestMediaExtraction: """Test media extraction from HTML.""" def test_extract_images_from_html(self): """Test extracting images from HTML.""" html = ''' A photo Logo ''' media = extract_media_from_html(html, "https://example.com/", CrawlMode.IMAGES) assert len(media) >= 1 urls = [m['url'] for m in media] # Should have resolved relative URL assert any('photo.jpg' in url for url in urls) def test_extract_videos_from_html(self): """Test extracting videos from HTML.""" html = ''' ''' media = extract_media_from_html(html, "https://example.com/", CrawlMode.VIDEOS) urls = [m['url'] for m in media] assert any('movie.mp4' in url or 'clip.webm' in url for url in urls) def test_extract_media_filters_by_mode(self): """Test that extraction respects crawl mode.""" html = ''' ''' # Images only images = extract_media_from_html(html, "https://example.com/", CrawlMode.IMAGES) image_urls = [m['url'] for m in images] assert any('photo.jpg' in url for url in image_urls) assert not any('video.mp4' in url for url in image_urls) # Videos only videos = extract_media_from_html(html, "https://example.com/", CrawlMode.VIDEOS) video_urls = [m['url'] for m in videos] assert any('video.mp4' in url for url in video_urls) class TestAsyncWebFetcher: """Test AsyncWebFetcher class.""" def test_fetcher_initialization(self): """Test AsyncWebFetcher initialization.""" fetcher = AsyncWebFetcher( user_agent="TestBot/1.0", default_crawl_delay=1.0, ) assert fetcher.user_agent == "TestBot/1.0" assert fetcher.default_crawl_delay == 1.0 def test_fetcher_default_values(self): """Test AsyncWebFetcher default values.""" fetcher = AsyncWebFetcher() assert fetcher.default_crawl_delay == DEFAULT_CRAWL_DELAY def test_get_domain(self): """Test domain extraction.""" fetcher = AsyncWebFetcher() assert fetcher._get_domain("https://example.com/page") == "example.com" assert fetcher._get_domain("https://sub.example.com/page") == "sub.example.com" def test_simple_stem(self): """Test simple word stemming.""" fetcher = AsyncWebFetcher() # Test stemming - actual behavior may vary stem = fetcher._simple_stem("running") assert "run" in stem # Should contain "run" # Short words should not be stemmed assert fetcher._simple_stem("go") == "go" def test_extract_text_content(self): """Test text extraction from HTML.""" fetcher = AsyncWebFetcher() html = ''' Test Page

Hello World

This is a test paragraph.

''' text = fetcher.extract_text_content(html) assert "Hello World" in text assert "test paragraph" in text assert "alert" not in text # Script content should be removed assert ".hidden" not in text # Style content should be removed def test_extract_title(self): """Test title extraction from HTML.""" fetcher = AsyncWebFetcher() html = 'Page Title' title = fetcher.extract_title(html) assert title == "Page Title" def test_extract_title_missing(self): """Test title extraction when missing.""" fetcher = AsyncWebFetcher() html = '' title = fetcher.extract_title(html) # Returns "Untitled" when no title present assert title == "Untitled" or title == "" class TestAsyncWebFetcherScoring: """Test link and page scoring functionality.""" def test_score_link_keyword_match(self): """Test link scoring with keyword matches.""" fetcher = AsyncWebFetcher() # Link with keyword in URL score = fetcher._score_link( "https://example.com/pricing", "View Pricing", query_keywords=["pricing", "cost"], ) assert score > 0 # Should have positive score # Link without keywords score_none = fetcher._score_link( "https://example.com/about", "About Us", query_keywords=["pricing", "cost"], ) assert score > score_none # Keyword match should score higher def test_score_link_anchor_text(self): """Test link scoring with anchor text.""" fetcher = AsyncWebFetcher() # Anchor text with keyword score = fetcher._score_link( "https://example.com/page", "Our Pricing Plans", query_keywords=["pricing"], ) assert score > 0 def test_score_page_keyword_match(self): """Test page scoring with keyword matches.""" fetcher = AsyncWebFetcher() page_data = { "url": "https://example.com/pricing", "title": "Pricing Plans", "extracted_text": "Our pricing is competitive. See our cost breakdown.", } score = fetcher._score_page( page_data, query_keywords=["pricing", "cost"], ) assert score > 0 class TestAsyncWebFetcherAsync: """Test async methods of AsyncWebFetcher.""" @pytest.mark.asyncio async def test_can_fetch_respects_robots(self): """Test that _can_fetch respects robots.txt.""" fetcher = AsyncWebFetcher() # Mock the robots.txt fetch to return a parser that disallows with patch.object(fetcher, '_fetch_robots_txt', new_callable=AsyncMock) as mock_robots: mock_parser = MagicMock() mock_parser.can_fetch.return_value = False mock_robots.return_value = mock_parser result = await fetcher._can_fetch("https://example.com/blocked") assert result is False @pytest.mark.asyncio async def test_can_fetch_allows_when_parser_allows(self): """Test _can_fetch when robots parser allows.""" fetcher = AsyncWebFetcher() with patch.object(fetcher, '_fetch_robots_txt', new_callable=AsyncMock) as mock_robots: mock_parser = MagicMock() mock_parser.can_fetch.return_value = True mock_robots.return_value = mock_parser result = await fetcher._can_fetch("https://example.com/allowed") assert result is True @pytest.mark.asyncio async def test_enforce_crawl_delay(self): """Test crawl delay enforcement.""" fetcher = AsyncWebFetcher(default_crawl_delay=0.1) domain = "example.com" # First request should not delay start = asyncio.get_event_loop().time() await fetcher._enforce_crawl_delay(domain) first_time = asyncio.get_event_loop().time() - start # Second request should delay start = asyncio.get_event_loop().time() await fetcher._enforce_crawl_delay(domain) second_time = asyncio.get_event_loop().time() - start # Second call should have some delay (at least part of 0.1s) assert second_time >= 0.05 # Allow some tolerance @pytest.mark.asyncio async def test_check_media_url_signature(self): """Test check_media_url function signature.""" fetcher = AsyncWebFetcher() # Verify the method exists and has correct signature import inspect sig = inspect.signature(fetcher.check_media_url) params = list(sig.parameters.keys()) # Should have url parameter assert 'url' in params or len(params) >= 1 class TestErrorHandling: """Test error handling functionality.""" def test_get_last_fetch_error_initially_none(self): """Test that last fetch error is None initially.""" clear_last_fetch_error() error = get_last_fetch_error() assert error is None def test_clear_last_fetch_error(self): """Test clearing the last fetch error.""" clear_last_fetch_error() assert get_last_fetch_error() is None class TestExtensionSets: """Test extension and MIME type sets.""" def test_image_extensions(self): """Test image extension set.""" assert '.jpg' in IMAGE_EXTENSIONS assert '.jpeg' in IMAGE_EXTENSIONS assert '.png' in IMAGE_EXTENSIONS assert '.gif' in IMAGE_EXTENSIONS assert '.webp' in IMAGE_EXTENSIONS assert '.svg' in IMAGE_EXTENSIONS def test_video_extensions(self): """Test video extension set.""" assert '.mp4' in VIDEO_EXTENSIONS assert '.webm' in VIDEO_EXTENSIONS assert '.mov' in VIDEO_EXTENSIONS assert '.avi' in VIDEO_EXTENSIONS def test_audio_extensions(self): """Test audio extension set.""" assert '.mp3' in AUDIO_EXTENSIONS assert '.wav' in AUDIO_EXTENSIONS assert '.ogg' in AUDIO_EXTENSIONS assert '.flac' in AUDIO_EXTENSIONS if __name__ == '__main__': pytest.main([__file__, '-v'])