ee0fc74378
PR Test / lint (pull_request) Successful in 1s
- Fix download processor to return actual author name (e.g. "HOLA小测佬") instead of B站用户ID (mid, e.g. "406636263") - Add 5 test cases for author extraction logic - Resolves report display issue where UP主名称 showed numeric ID Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
577 lines
22 KiB
Python
577 lines
22 KiB
Python
"""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 == ""
|
|
|