"""Tests for ZhihuAdapter Tests cover: - Answer content extraction - Zhuanlan (article) content extraction - Cookie-based authentication - Login page detection - AuthError handling Phase 09 - Pipeline Integration: - ZhihuAdapter now uses ZhihuCrawler instead of ZhihuPlaywrightExtractor - Comments are included by default and appended to content - Cookie expiration triggers QR code login - Idempotency via IdempotencyChecker """ import pytest from unittest.mock import MagicMock, patch, PropertyMock import asyncio from pathlib import Path from datetime import datetime from kb_workflow.pipeline.platforms.zhihu import ZhihuAdapter from kb_workflow.pipeline.errors import AuthError # ============================================================================= # Mock Fixtures for ZhihuCrawler Integration (Phase 09) # ============================================================================= @pytest.fixture def mock_crawl_answer_result(): """Mock CrawlResult from ZhihuCrawler.crawl_answer(). Simulates successful answer crawl with metadata and content. Used for testing ZhihuAdapter integration with new crawler. """ from kb_workflow.crawler.models import ( CrawlResult, AnswerData, AuthorInfo, ContentMetadata, CommentData, CommentAuthor ) return CrawlResult( success=True, content_type="answer", data=AnswerData( id=789012, question_id=123456, question_title="Test Question Title for Pipeline Integration", content="

This is the answer content from ZhihuCrawler.

", excerpt="This is the answer content from ZhihuCrawler.", metadata=ContentMetadata( author=AuthorInfo( name="Test Author", url_token="test-author-123", headline="Test Author Headline" ), voteup_count=1234, comment_count=56, created_time=1705312800, # 2024-01-15 10:00:00 UTC updated_time=1705399200 # 2024-01-16 10:00:00 UTC ) ), comments=[ CommentData( id=111, content="Great answer! Very helpful.", author=CommentAuthor(name="Commenter1", url_token="commenter-1"), created_time=1705316400, like_count=42, child_count=3, is_author=False ), CommentData( id=222, content="Thanks for sharing!", author=CommentAuthor(name="Commenter2", url_token="commenter-2"), created_time=1705320000, like_count=15, child_count=0, is_author=False ) ], error=None ) @pytest.fixture def mock_crawl_article_result(): """Mock CrawlResult from ZhihuCrawler.crawl_article(). Simulates successful zhuanlan article crawl. Used for testing ZhihuAdapter integration with new crawler. """ from kb_workflow.crawler.models import ( CrawlResult, ArticleData, AuthorInfo, ContentMetadata, CommentData, CommentAuthor ) return CrawlResult( success=True, content_type="article", data=ArticleData( id=123456, title="Test Article Title for Pipeline Integration", content="

Article Content

This is the article content.

", excerpt="Article Content This is the article content.", metadata=ContentMetadata( author=AuthorInfo( name="Test Article Author", url_token="test-article-author", headline="Writer and Developer" ), voteup_count=5678, comment_count=89, created_time=1705226400, # 2024-01-14 10:00:00 UTC updated_time=None ) ), comments=[ CommentData( id=333, content="Excellent article!", author=CommentAuthor(name="Reader1", url_token="reader-1"), created_time=1705230000, like_count=28, child_count=1, is_author=False ) ], error=None ) @pytest.fixture def mock_crawl_no_comments_result(): """Mock CrawlResult without comments.""" from kb_workflow.crawler.models import ( CrawlResult, AnswerData, AuthorInfo, ContentMetadata ) return CrawlResult( success=True, content_type="answer", data=AnswerData( id=789012, question_id=123456, question_title="Test Question No Comments", content="

Answer without comments.

", excerpt="Answer without comments.", metadata=ContentMetadata( author=AuthorInfo(name="Test Author", url_token="test-author"), voteup_count=100, comment_count=0, created_time=1705312800, updated_time=None ) ), comments=[], error=None ) @pytest.fixture def mock_comments_list(): """Mock list of CommentData for testing comment formatting. Provides a realistic set of comments with various attributes. """ from kb_workflow.crawler.models import CommentData, CommentAuthor return [ CommentData( id=1001, content="This is the first comment with some content.", author=CommentAuthor(name="User1", url_token="user-1"), created_time=1705316400, like_count=100, child_count=5, is_author=False ), CommentData( id=1002, content="This is the second comment, from the author.", author=CommentAuthor(name="AuthorName", url_token="author-token"), created_time=1705320000, like_count=50, child_count=2, is_author=True ), CommentData( id=1003, content="Third comment here.", author=CommentAuthor(name="User3", url_token="user-3"), created_time=1705323600, like_count=25, child_count=0, is_author=False ) ] @pytest.fixture def mock_zhihu_login_result(): """Mock ZhihuLoginResult from ZhihuLogin.login_by_qrcode(). Simulates successful QR code login result. Used for testing cookie refresh flow. """ from kb_workflow.auth.zhihu_login import ZhihuLoginResult from pathlib import Path return ZhihuLoginResult( success=True, cookie_str="z_c0=test_cookie_value; _xsrf=test_xsrf; d_c0=test_d_c0", qrcode_path=Path("/tmp/test_qrcode.png"), error_message=None ) @pytest.fixture def mock_crawl_failed_result(): """Mock failed CrawlResult for error handling tests.""" from kb_workflow.crawler.models import CrawlResult return CrawlResult( success=False, content_type="answer", data=None, comments=None, error="Cookie expired or invalid" ) @pytest.fixture def mock_idempotency_checker(): """Mock IdempotencyChecker for testing idempotent behavior.""" mock = MagicMock() mock.is_processed.return_value = False return mock # ============================================================================= # Basic Adapter Tests # ============================================================================= class TestZhihuAdapterBasic: """Tests for basic ZhihuAdapter functionality""" def test_get_content_type_answer(self): """Test content type detection for answer URLs""" adapter = ZhihuAdapter() adapter._cookie = "test" # Avoid config loading assert adapter.get_content_type("https://www.zhihu.com/question/123456/answer/789012") == "answer" assert adapter.get_content_type("https://zhihu.com/question/123/answer/456") == "answer" def test_get_content_type_zhuanlan(self): """Test content type detection for zhuanlan URLs""" adapter = ZhihuAdapter() adapter._cookie = "test" # Avoid config loading assert adapter.get_content_type("https://zhuanlan.zhihu.com/p/123456") == "zhuanlan" assert adapter.get_content_type("https://zhuanlan.zhihu.com/p/abc123") == "zhuanlan" def test_get_content_type_unknown(self): """Test content type detection for unknown URLs""" adapter = ZhihuAdapter() adapter._cookie = "test" # Avoid config loading assert adapter.get_content_type("https://www.zhihu.com/question/123456") == "unknown" assert adapter.get_content_type("https://www.zhihu.com/") == "unknown" assert adapter.get_content_type("https://example.com") == "unknown" def test_get_output_filename(self): """Test output filename generation""" adapter = ZhihuAdapter() adapter._cookie = "test" # Avoid config loading assert adapter.get_output_filename("789012", "answer", "metadata.json") == "zhihu_789012_metadata.json" assert adapter.get_output_filename("123456", "zhuanlan", "content.md") == "zhihu_123456_content.md" def test_platform_constant(self): """Test PLATFORM constant""" adapter = ZhihuAdapter() adapter._cookie = "test" # Avoid config loading assert adapter.PLATFORM == "zhihu" # ============================================================================= # ZhihuCrawler Integration Tests (Phase 09) # ============================================================================= class TestZhihuAdapterCrawlerIntegration: """Tests for ZhihuAdapter integration with ZhihuCrawler. These tests verify the adapter correctly uses the new ZhihuCrawler instead of ZhihuPlaywrightExtractor. """ @pytest.mark.asyncio async def test_fetch_metadata_answer(self, mock_crawl_answer_result): """Test fetching answer metadata via ZhihuCrawler. Verifies: - ZhihuCrawler.crawl_answer() is called with include_comments=True - Metadata is correctly extracted from CrawlResult - Output format matches expected adapter response """ adapter = ZhihuAdapter() adapter._cookie = "test_cookie" with patch.object(adapter, '_crawl_with_crawler', return_value=mock_crawl_answer_result): metadata = await adapter.fetch_metadata("789012", "answer", source_url="https://www.zhihu.com/question/123456/answer/789012") assert metadata["title"] == "Test Question Title for Pipeline Integration" assert metadata["author"] == "Test Author" assert metadata["type"] == "answer" assert metadata["voteup_count"] == 1234 @pytest.mark.asyncio async def test_fetch_metadata_article(self, mock_crawl_article_result): """Test fetching zhuanlan article metadata via ZhihuCrawler. Verifies: - ZhihuCrawler.crawl_article() is called with correct URL - Metadata is correctly extracted from CrawlResult - Output format matches expected adapter response """ adapter = ZhihuAdapter() adapter._cookie = "test_cookie" with patch.object(adapter, '_crawl_with_crawler', return_value=mock_crawl_article_result): metadata = await adapter.fetch_metadata("123456", "zhuanlan") assert metadata["title"] == "Test Article Title for Pipeline Integration" assert metadata["author"] == "Test Article Author" assert metadata["type"] == "zhuanlan" @pytest.mark.asyncio async def test_fetch_content_with_comments(self, mock_crawl_answer_result): """Test fetching content with comments appended. Verifies: - Content is fetched with include_comments=True - Comments are appended to content in correct Markdown format - Comment format: numbered list with author mention """ adapter = ZhihuAdapter() adapter._cookie = "test_cookie" with patch.object(adapter, '_crawl_with_crawler', return_value=mock_crawl_answer_result): content = await adapter.fetch_content("789012", "answer", source_url="https://www.zhihu.com/question/123456/answer/789012") # Verify YAML frontmatter assert "---" in content assert "title:" in content # Verify main content assert "Test Question Title for Pipeline Integration" in content # Verify comments section is appended assert "## 热门评论" in content assert "@Commenter1" in content assert "Great answer!" in content @pytest.mark.asyncio async def test_fetch_metadata_no_cookie_raises_auth_error(self): """Test that missing cookie raises AuthError""" adapter = ZhihuAdapter() adapter._cookie = "" # Empty cookie with pytest.raises(AuthError) as exc_info: await adapter.fetch_metadata("789012", "answer") assert exc_info.value.auth_type == "zhihu_cookie" # ============================================================================= # Cookie Expiration and Auto-Login Tests (Task 2) # ============================================================================= class TestZhihuAdapterCookieHandling: """Tests for cookie handling and auto-login""" @pytest.mark.asyncio async def test_cookie_valid_returns_data(self, mock_crawl_answer_result): """Test that valid cookie allows normal data retrieval""" adapter = ZhihuAdapter() adapter._cookie = "valid_cookie" with patch.object(adapter, '_crawl_with_crawler', return_value=mock_crawl_answer_result): metadata = await adapter.fetch_metadata("789012", "answer") assert metadata["title"] == "Test Question Title for Pipeline Integration" @pytest.mark.asyncio async def test_cookie_expired_triggers_login(self, mock_crawl_failed_result, mock_zhihu_login_result, mock_crawl_answer_result): """Test that expired cookie triggers QR code login flow. Verifies: - CrawlResult with success=False triggers login - ZhihuLogin.login_by_qrcode() is called - New cookie is used to retry the fetch """ adapter = ZhihuAdapter() adapter._cookie = "expired_cookie" # First call fails (cookie expired), second succeeds after login crawl_results = [mock_crawl_failed_result, mock_crawl_answer_result] with patch.object(adapter, '_crawl_with_crawler', side_effect=crawl_results): with patch.object(adapter, '_trigger_qrcode_login', return_value=mock_zhihu_login_result): metadata = await adapter.fetch_metadata("789012", "answer") # Verify login was triggered and cookie was updated assert adapter._cookie == mock_zhihu_login_result.cookie_str @pytest.mark.asyncio async def test_login_retry_success(self, mock_crawl_failed_result, mock_zhihu_login_result, mock_crawl_answer_result): """Test that after successful login, retry succeeds""" adapter = ZhihuAdapter() adapter._cookie = "expired_cookie" crawl_results = [mock_crawl_failed_result, mock_crawl_answer_result] with patch.object(adapter, '_crawl_with_crawler', side_effect=crawl_results): with patch.object(adapter, '_trigger_qrcode_login', return_value=mock_zhihu_login_result): content = await adapter.fetch_content("789012", "answer") # Verify login was triggered and cookie was updated assert adapter._cookie == mock_zhihu_login_result.cookie_str assert "Test Question Title for Pipeline Integration" in content @pytest.mark.asyncio async def test_login_failure_raises_error(self, mock_crawl_failed_result): """Test that failed login raises appropriate error""" from kb_workflow.auth.zhihu_login import ZhihuLoginResult adapter = ZhihuAdapter() adapter._cookie = "expired_cookie" failed_login = ZhihuLoginResult( success=False, cookie_str="", error_message="Login timeout" ) with patch.object(adapter, '_crawl_with_crawler', return_value=mock_crawl_failed_result): with patch.object(adapter, '_trigger_qrcode_login', return_value=failed_login): with pytest.raises(AuthError) as exc_info: await adapter.fetch_metadata("789012", "answer") assert exc_info.value.auth_type == "zhihu_cookie" # ============================================================================= # Comment Formatting Tests (Task 3) # ============================================================================= class TestZhihuAdapterCommentFormatting: """Tests for comment formatting functionality""" @pytest.mark.asyncio async def test_comment_formatting_with_comments(self, mock_crawl_answer_result): """Test that comments are formatted with proper Markdown structure. Verifies: - Comments section starts with "---" separator - "## 热门评论" heading is present - Each comment is numbered with bold author name """ adapter = ZhihuAdapter() adapter._cookie = "test_cookie" with patch.object(adapter, '_crawl_with_crawler', return_value=mock_crawl_answer_result): content = await adapter.fetch_content("789012", "answer") # Check comment section structure assert "---" in content assert "## 热门评论" in content # Check comment formatting: numbered list with author assert "1. **@Commenter1**" in content or "1. **@Commenter1**" in content assert "Great answer!" in content @pytest.mark.asyncio async def test_comment_formatting_no_comments(self, mock_crawl_no_comments_result): """Test that no comments section is added when there are no comments. Verifies: - No "---" separator for empty comments - No "## 热门评论" heading when comments list is empty """ adapter = ZhihuAdapter() adapter._cookie = "test_cookie" with patch.object(adapter, '_crawl_with_crawler', return_value=mock_crawl_no_comments_result): content = await adapter.fetch_content("789012", "answer") # No comments section should be present assert "## 热门评论" not in content def test_format_comments_section(self, mock_comments_list): """Test internal _format_comments_section method. Verifies: - Comments are formatted as numbered list - Author name is bold/mentioned - Like count is included - Format matches expected style: 1. **@AuthorName** (100 likes): Comment content... """ adapter = ZhihuAdapter() adapter._cookie = "test" result = adapter._format_comments_section(mock_comments_list) # Check format structure assert "1. **@User1**" in result assert "(100 likes)" in result or "100 likes" in result assert "This is the first comment" in result # Check author comment indicator assert "@AuthorName" in result # ============================================================================= # Idempotency Tests (Task 3) # ============================================================================= class TestZhihuAdapterIdempotency: """Tests for idempotent execution""" @pytest.mark.asyncio async def test_idempotent_execution_skips_processed(self, mock_crawl_answer_result, tmp_path): """Test that idempotent check prevents reprocessing when output_dir is set. Verifies: - If output_dir is set and content is already processed, cached result is returned - No duplicate fetches are made """ import json adapter = ZhihuAdapter(output_dir=tmp_path) adapter._cookie = "test_cookie" # Create existing files to simulate already processed content metadata_file = tmp_path / "zhihu_789012_metadata.json" content_file = tmp_path / "zhihu_789012_content.md" cached_metadata = { "title": "Cached Title", "author": "Cached Author", "type": "answer" } metadata_file.write_text(json.dumps(cached_metadata)) content_file.write_text("Cached content") # When content is already processed, should return cached result with patch.object(adapter, '_crawl_with_crawler') as mock_crawl: metadata = await adapter.fetch_metadata("789012", "answer") # Should NOT have called the crawler since content is cached mock_crawl.assert_not_called() # Should return cached metadata assert metadata["title"] == "Cached Title" @pytest.mark.asyncio async def test_different_content_processes_independently(self, mock_crawl_answer_result, mock_crawl_article_result): """Test that different content IDs are processed independently. Verifies: - Processing one content doesn't affect processing of another - Each content_id is tracked separately """ adapter = ZhihuAdapter() adapter._cookie = "test_cookie" # Process answer with patch.object(adapter, '_crawl_with_crawler', return_value=mock_crawl_answer_result): metadata_answer = await adapter.fetch_metadata("789012", "answer") # Process article with patch.object(adapter, '_crawl_with_crawler', return_value=mock_crawl_article_result): metadata_article = await adapter.fetch_metadata("123456", "zhuanlan") # Both should return their respective data assert metadata_answer["title"] == "Test Question Title for Pipeline Integration" assert metadata_article["title"] == "Test Article Title for Pipeline Integration" # ============================================================================= # Cookie Settings Tests # ============================================================================= class TestZhihuAdapterCookieSettings: """Tests for cookie loading from settings""" def test_cookie_from_settings(self): """Test cookie is loaded from settings""" with patch('kb_workflow.pipeline.platforms.zhihu.get_settings') as mock_settings: mock_secret = MagicMock() mock_secret.get_secret_value.return_value = "test_cookie_value" mock_settings.return_value.zhihu_cookie = mock_secret adapter = ZhihuAdapter() assert adapter.cookie == "test_cookie_value"