"""Tests for DownloadProcessor and DownloadInput/DownloadOutput schemas. Test coverage: - DownloadInput schema validation - DownloadOutput schema validation - DownloadProcessor metadata fetching - DownloadProcessor content downloading - DownloadProcessor state recording """ import pytest from datetime import datetime from unittest.mock import AsyncMock, MagicMock, patch from pathlib import Path from kb_workflow.pipeline.schemas import DownloadInput, DownloadOutput class TestDownloadInputSchema: """Tests for DownloadInput schema validation.""" def test_schema_download_input_validates_required_fields(self): """Test 1: DownloadInput validates required fields""" # Test with all required fields input_data = DownloadInput( content_id="BV1xx", platform="bilibili", content_dir="/tmp/content", content_type="video" ) assert input_data.content_id == "BV1xx" assert input_data.platform == "bilibili" assert input_data.content_dir == "/tmp/content" assert input_data.content_type == "video" def test_schema_download_input_requires_content_dir(self): """DownloadInput should require content_dir""" with pytest.raises(Exception): # ValidationError from pydantic DownloadInput( content_id="BV1xx", platform="bilibili", content_type="video" # Missing content_dir ) def test_schema_download_input_requires_content_type(self): """DownloadInput should require content_type""" with pytest.raises(Exception): # ValidationError from pydantic DownloadInput( content_id="BV1xx", platform="bilibili", content_dir="/tmp/content" # Missing content_type ) class TestDownloadOutputSchema: """Tests for DownloadOutput schema validation.""" def test_schema_download_output_validates_required_fields(self): """Test 2: DownloadOutput validates required fields""" output = DownloadOutput( success=True, metadata_file="/tmp/content/bilibili_BV1xx_metadata.json", content_file="/tmp/content/bilibili_BV1xx_content.md", title="Test Video", author="Test Author" ) assert output.success is True assert output.metadata_file == "/tmp/content/bilibili_BV1xx_metadata.json" assert output.content_file == "/tmp/content/bilibili_BV1xx_content.md" assert output.title == "Test Video" assert output.author == "Test Author" assert output.duration is None # Optional field def test_schema_download_output_handles_video_content_type(self): """Test 3: DownloadOutput handles video content type with duration""" output = DownloadOutput( success=True, metadata_file="/tmp/content/bilibili_BV1xx_metadata.json", content_file="", # Video content handled by ASR title="Test Video", author="Test Author", duration=300 # 5 minutes ) assert output.success is True assert output.duration == 300 assert output.content_file == "" def test_schema_download_output_handles_article_content_type(self): """Test 3: DownloadOutput handles article content type without duration""" output = DownloadOutput( success=True, metadata_file="/tmp/content/bilibili_CV123_metadata.json", content_file="/tmp/content/bilibili_CV123_content.md", title="Test Article", author="Test Author", duration=None # Articles don't have duration ) assert output.success is True assert output.duration is None assert output.content_file == "/tmp/content/bilibili_CV123_content.md" def test_schema_download_output_handles_zhihu_answer(self): """Test 3: DownloadOutput handles Zhihu answer content type""" output = DownloadOutput( success=True, metadata_file="/tmp/content/zhihu_123456_metadata.json", content_file="/tmp/content/zhihu_123456_content.md", title="Test Answer", author="Test Author" ) assert output.success is True assert "zhihu" in output.metadata_file def test_schema_download_output_handles_failure(self): """DownloadOutput should handle failure case""" output = DownloadOutput( success=False, error_message="Network error", metadata_file="", content_file="", title="", author="" ) assert output.success is False assert output.error_message == "Network error" class TestDownloadProcessorMetadata: """Tests for DownloadProcessor metadata fetching.""" @pytest.fixture def mock_bilibili_adapter(self): """Create a mock BilibiliAdapter.""" adapter = MagicMock() adapter.fetch_metadata = AsyncMock(return_value={ "metadata": { "title": "Test Video Title", "author": "Test UP Author", "mid": 12345, "bvid": "BV1xx", }, "stats": { "view": 1000, "like": 100, }, "description": "Test description" }) adapter.get_output_filename = MagicMock( return_value="bilibili_BV1xx_metadata.json" ) return adapter @pytest.fixture def mock_zhihu_adapter(self): """Create a mock ZhihuAdapter.""" adapter = MagicMock() adapter.fetch_metadata = AsyncMock(return_value={ "title": "Test Answer Title", "author": "Test Author", "url": "https://www.zhihu.com/question/123/answer/456", "content_markdown": "Test content" }) adapter.fetch_content = AsyncMock(return_value="---\ntitle: Test\n---\nTest content") adapter.get_output_filename = MagicMock( return_value="zhihu_456_metadata.json" ) return adapter @pytest.fixture def mock_state_manager(self): """Create a mock WorkflowStateManager.""" manager = MagicMock() manager.load = MagicMock(return_value=None) manager.save = MagicMock() return manager @pytest.mark.asyncio async def test_metadata_fetches_bilibili_video_metadata_correctly( self, mock_bilibili_adapter, mock_state_manager, tmp_path ): """Test 1: DownloadProcessor fetches Bilibili video metadata correctly""" from kb_workflow.pipeline.processors.download import DownloadProcessor from kb_workflow.pipeline.schemas import DownloadInput input_data = DownloadInput( content_id="BV1xx", platform="bilibili", content_dir=str(tmp_path), content_type="video" ) with patch("kb_workflow.pipeline.processors.base.get_settings") as mock_settings: mock_settings.return_value = MagicMock() with patch.object( DownloadProcessor, '_get_adapter', return_value=mock_bilibili_adapter ): processor = DownloadProcessor(state_manager=mock_state_manager) result = await processor.process(input_data) assert result.success is True assert result.title == "Test Video Title" assert "bilibili_BV1xx_metadata.json" in result.metadata_file # Verify adapter was called correctly mock_bilibili_adapter.fetch_metadata.assert_called_once_with( "BV1xx", "video" ) @pytest.mark.asyncio async def test_metadata_fetches_zhihu_answer_metadata_correctly( self, mock_zhihu_adapter, mock_state_manager, tmp_path ): """Test 2: DownloadProcessor fetches Zhihu answer metadata correctly""" from kb_workflow.pipeline.processors.download import DownloadProcessor from kb_workflow.pipeline.schemas import DownloadInput input_data = DownloadInput( content_id="456", platform="zhihu", content_dir=str(tmp_path), content_type="answer" ) with patch("kb_workflow.pipeline.processors.base.get_settings") as mock_settings: mock_settings.return_value = MagicMock() with patch.object( DownloadProcessor, '_get_adapter', return_value=mock_zhihu_adapter ): processor = DownloadProcessor(state_manager=mock_state_manager) result = await processor.process(input_data) assert result.success is True assert result.title == "Test Answer Title" # Verify adapter was called correctly mock_zhihu_adapter.fetch_metadata.assert_called_once_with( "456", "answer" ) @pytest.mark.asyncio async def test_metadata_records_state_on_success( self, mock_bilibili_adapter, mock_state_manager, tmp_path ): """Test 3: DownloadProcessor records state on success""" from kb_workflow.pipeline.processors.download import DownloadProcessor from kb_workflow.pipeline.schemas import DownloadInput, WorkflowState # Create a mock state that will be updated mock_state = MagicMock(spec=WorkflowState) mock_state.is_step_completed = MagicMock(return_value=False) mock_state_manager.load.return_value = mock_state input_data = DownloadInput( content_id="BV1xx", platform="bilibili", content_dir=str(tmp_path), content_type="video" ) with patch("kb_workflow.pipeline.processors.base.get_settings") as mock_settings: mock_settings.return_value = MagicMock() with patch.object( DownloadProcessor, '_get_adapter', return_value=mock_bilibili_adapter ): processor = DownloadProcessor(state_manager=mock_state_manager) result = await processor.process(input_data) assert result.success is True # Verify state was recorded mock_state.mark_step_completed.assert_called_once() call_args = mock_state.mark_step_completed.call_args assert call_args[0][0] == "download" # step_name mock_state_manager.save.assert_called_once_with(mock_state) @pytest.mark.asyncio async def test_metadata_records_state_on_failure( self, mock_bilibili_adapter, mock_state_manager, tmp_path ): """Test 4: DownloadProcessor records state on failure""" from kb_workflow.pipeline.processors.download import DownloadProcessor from kb_workflow.pipeline.schemas import DownloadInput, WorkflowState from kb_workflow.pipeline.errors import NetworkError # Make adapter raise an error mock_bilibili_adapter.fetch_metadata = AsyncMock( side_effect=NetworkError( step_name="download", input_summary="BV1xx", status_code=503, cause=Exception("Network timeout") ) ) # Create a mock state that will be updated mock_state = MagicMock(spec=WorkflowState) mock_state.is_step_completed = MagicMock(return_value=False) mock_state_manager.load.return_value = mock_state input_data = DownloadInput( content_id="BV1xx", platform="bilibili", content_dir=str(tmp_path), content_type="video" ) with patch("kb_workflow.pipeline.processors.base.get_settings") as mock_settings: mock_settings.return_value = MagicMock() with patch.object( DownloadProcessor, '_get_adapter', return_value=mock_bilibili_adapter ): processor = DownloadProcessor(state_manager=mock_state_manager) result = await processor.process(input_data) assert result.success is False assert "503" in result.error_message # NetworkError message includes status code # Verify failure was recorded mock_state.mark_step_failed.assert_called_once() call_args = mock_state.mark_step_failed.call_args assert call_args[0][0] == "download" # step_name mock_state_manager.save.assert_called_once_with(mock_state) class TestDownloadProcessorContent: """Tests for DownloadProcessor content downloading.""" @pytest.fixture def mock_bilibili_article_adapter(self): """Create a mock BilibiliAdapter for articles.""" adapter = MagicMock() adapter.fetch_metadata = AsyncMock(return_value={ "title": "Test Article Title", "author": "Test Author", "cvid": "CV123", "url": "https://www.bilibili.com/read/cv123", }) adapter.fetch_content = AsyncMock(return_value="---\ntitle: Test Article\n---\nArticle content here") adapter.get_output_filename = MagicMock(side_effect=lambda cid, ctype, suffix: f"bilibili_{cid}_{suffix}") return adapter @pytest.fixture def mock_bilibili_video_adapter(self): """Create a mock BilibiliAdapter for videos.""" adapter = MagicMock() adapter.fetch_metadata = AsyncMock(return_value={ "metadata": { "title": "Test Video Title", "author": "Test Video Author", "mid": 12345, "bvid": "BV1xx", "duration": 300, }, "stats": {"view": 1000}, }) adapter.get_output_filename = MagicMock(side_effect=lambda cid, ctype, suffix: f"bilibili_{cid}_{suffix}") return adapter @pytest.fixture def mock_state_manager(self): """Create a mock WorkflowStateManager.""" manager = MagicMock() manager.load = MagicMock(return_value=None) manager.save = MagicMock() return manager @pytest.mark.asyncio async def test_content_downloads_article_content( self, mock_bilibili_article_adapter, mock_state_manager, tmp_path ): """Test 1: DownloadProcessor downloads Bilibili article content""" from kb_workflow.pipeline.processors.download import DownloadProcessor from kb_workflow.pipeline.schemas import DownloadInput input_data = DownloadInput( content_id="CV123", platform="bilibili", content_dir=str(tmp_path), content_type="article" ) with patch("kb_workflow.pipeline.processors.base.get_settings") as mock_settings: mock_settings.return_value = MagicMock() with patch.object( DownloadProcessor, '_get_adapter', return_value=mock_bilibili_article_adapter ): processor = DownloadProcessor(state_manager=mock_state_manager) result = await processor.process(input_data) assert result.success is True assert result.content_file != "" # Content should be downloaded assert "bilibili_CV123_content.md" in result.content_file # Verify content file was created content_path = Path(result.content_file) assert content_path.exists() # Verify fetch_content was called mock_bilibili_article_adapter.fetch_content.assert_called_once_with("CV123", "article") @pytest.mark.asyncio async def test_content_skips_video_download( self, mock_bilibili_video_adapter, mock_state_manager, tmp_path ): """Test 2: DownloadProcessor skips video download (handled by ASR)""" from kb_workflow.pipeline.processors.download import DownloadProcessor from kb_workflow.pipeline.schemas import DownloadInput input_data = DownloadInput( content_id="BV1xx", platform="bilibili", content_dir=str(tmp_path), content_type="video" ) with patch("kb_workflow.pipeline.processors.base.get_settings") as mock_settings: mock_settings.return_value = MagicMock() with patch.object( DownloadProcessor, '_get_adapter', return_value=mock_bilibili_video_adapter ): processor = DownloadProcessor(state_manager=mock_state_manager) result = await processor.process(input_data) assert result.success is True assert result.content_file == "" # Video content not downloaded (handled by ASR) assert result.duration == 300 # Duration should be extracted # Verify fetch_content was NOT called for video mock_bilibili_video_adapter.fetch_content.assert_not_called() @pytest.mark.asyncio async def test_content_creates_correct_output_file_paths( self, mock_bilibili_article_adapter, mock_state_manager, tmp_path ): """Test 3: DownloadProcessor creates correct output file paths""" from kb_workflow.pipeline.processors.download import DownloadProcessor from kb_workflow.pipeline.schemas import DownloadInput input_data = DownloadInput( content_id="CV123", platform="bilibili", content_dir=str(tmp_path), content_type="article" ) with patch("kb_workflow.pipeline.processors.base.get_settings") as mock_settings: mock_settings.return_value = MagicMock() with patch.object( DownloadProcessor, '_get_adapter', return_value=mock_bilibili_article_adapter ): processor = DownloadProcessor(state_manager=mock_state_manager) result = await processor.process(input_data) assert result.success is True # Check file paths follow naming convention assert "bilibili_CV123_metadata.json" in result.metadata_file assert "bilibili_CV123_content.md" in result.content_file # Check files exist assert Path(result.metadata_file).exists() assert Path(result.content_file).exists() class TestDownloadProcessorModuleExport: """Tests for DownloadProcessor module export.""" def test_download_processor_importable_from_processors(self): """Test 4: DownloadProcessor can be imported from kb_workflow.pipeline.processors""" from kb_workflow.pipeline.processors import DownloadProcessor assert DownloadProcessor is not None assert hasattr(DownloadProcessor, 'STEP_NAME') assert DownloadProcessor.STEP_NAME == "download" def test_all_processors_exported(self): """All processors should be exported from kb_workflow.pipeline.processors""" from kb_workflow.pipeline.processors import BaseProcessor, ParseProcessor, DownloadProcessor, ASRProcessor assert BaseProcessor is not None assert ParseProcessor is not None assert DownloadProcessor is not None assert ASRProcessor is not None class TestDownloadProcessorAuthorExtraction: """Tests for _extract_author method - ensure it returns author name, not mid.""" def test_extract_author_returns_author_name_not_mid(self): """_extract_author should return author name, not mid (user ID)""" from kb_workflow.pipeline.processors.download import DownloadProcessor processor = DownloadProcessor() # Bilibili metadata with both author and mid metadata = { "metadata": { "title": "Test Video", "author": "HOLA小测佬", "mid": 406636263, "bvid": "BV1QXcCzfECp", } } author = processor._extract_author(metadata, "bilibili") # Should return author name, not mid assert author == "HOLA小测佬" assert author != "406636263" assert author != str(metadata["metadata"]["mid"]) def test_extract_author_handles_nested_metadata(self): """_extract_author should handle nested metadata structure""" from kb_workflow.pipeline.processors.download import DownloadProcessor processor = DownloadProcessor() # Nested metadata structure metadata = { "metadata": { "author": "Test Author Name", "mid": 12345, } } author = processor._extract_author(metadata, "bilibili") assert author == "Test Author Name" def test_extract_author_handles_flat_metadata(self): """_extract_author should handle flat metadata structure""" from kb_workflow.pipeline.processors.download import DownloadProcessor processor = DownloadProcessor() # Flat metadata structure (no nested 'metadata' key) metadata = { "author": "Flat Author", "mid": 67890, } author = processor._extract_author(metadata, "bilibili") assert author == "Flat Author" def test_extract_author_handles_zhihu_platform(self): """_extract_author should handle zhihu platform""" from kb_workflow.pipeline.processors.download import DownloadProcessor processor = DownloadProcessor() # Zhihu metadata structure metadata = { "author": "Zhihu User", } author = processor._extract_author(metadata, "zhihu") assert author == "Zhihu User" def test_extract_author_returns_empty_string_for_missing_author(self): """_extract_author should return empty string when author is missing""" from kb_workflow.pipeline.processors.download import DownloadProcessor processor = DownloadProcessor() # Metadata without author metadata = { "metadata": { "title": "Test Video", "mid": 12345, } } author = processor._extract_author(metadata, "bilibili") assert author == ""