""" 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 = '''
'''
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 = '''
'''
# 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 = '''
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 = '