This repository has been archived on 2026-08-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
kb-skills/tests/test_processors/test_download.py
T
starmen1265 ee0fc74378
PR Test / lint (pull_request) Successful in 1s
fix(download): return author name instead of mid in _extract_author
- 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>
2026-03-17 13:50:02 +08:00

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