c9ca1b538d
- Remove sys.path manipulation from kb_workflow.py - Update CLI imports to use kb_workflow.pipeline prefix - Update all test file imports and patch paths Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
354 lines
12 KiB
Python
354 lines
12 KiB
Python
"""End-to-end integration tests for the complete workflow.
|
|
|
|
Tests:
|
|
1. Full workflow executes from URL to analysis
|
|
2. Workflow handles errors gracefully
|
|
3. State is persisted correctly
|
|
4. Workflow is resumable (skip completed steps)
|
|
"""
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from unittest.mock import AsyncMock, patch, MagicMock
|
|
from pathlib import Path
|
|
from typing import Dict, Any
|
|
from datetime import datetime
|
|
|
|
from kb_workflow.pipeline.pipeline import Pipeline
|
|
from kb_workflow.pipeline.schemas import PipelineConfig, PipelineOutput, WorkflowState
|
|
from kb_workflow.pipeline.state import WorkflowStateManager
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_content_dir(tmp_path):
|
|
"""Create temp content directory for testing."""
|
|
content_dir = tmp_path / "content"
|
|
content_dir.mkdir(parents=True, exist_ok=True)
|
|
yield content_dir
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_state_manager(temp_content_dir):
|
|
"""Create mock workflow state manager."""
|
|
return WorkflowStateManager(temp_content_dir)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_processors():
|
|
"""Create mock processors for testing."""
|
|
processors = []
|
|
|
|
# Parse processor
|
|
parse_processor = MagicMock()
|
|
parse_processor.STEP_NAME = "parse"
|
|
parse_processor.process = AsyncMock(return_value=MagicMock(
|
|
success=True,
|
|
error_message=None,
|
|
platform="bilibili",
|
|
content_id="BV1test123",
|
|
content_type="video",
|
|
))
|
|
processors.append(parse_processor)
|
|
|
|
# Download processor
|
|
download_processor = MagicMock()
|
|
download_processor.STEP_NAME = "download"
|
|
download_processor.process = AsyncMock(return_value=MagicMock(
|
|
success=True,
|
|
error_message=None,
|
|
metadata_file="/tmp/metadata.json",
|
|
content_file="/tmp/video.mp4",
|
|
title="Test Video",
|
|
author="Test Author",
|
|
))
|
|
processors.append(download_processor)
|
|
|
|
# ASR processor
|
|
asr_processor = MagicMock()
|
|
asr_processor.STEP_NAME = "asr"
|
|
asr_processor.process = AsyncMock(return_value=MagicMock(
|
|
success=True,
|
|
error_message=None,
|
|
output_files={"json": "/tmp/subtitle.json", "txt": "/tmp/subtitle.txt"},
|
|
))
|
|
processors.append(asr_processor)
|
|
|
|
# Analyze processor
|
|
analyze_processor = MagicMock()
|
|
analyze_processor.STEP_NAME = "analyze"
|
|
analyze_processor.process = AsyncMock(return_value=MagicMock(
|
|
success=True,
|
|
error_message=None,
|
|
analysis_file="/tmp/analysis.md",
|
|
summary="Test summary",
|
|
))
|
|
processors.append(analyze_processor)
|
|
|
|
return processors
|
|
|
|
|
|
class TestFullWorkflowExecution:
|
|
"""Test 1: Full workflow executes from URL to analysis"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_full_workflow_executes_successfully(self, temp_content_dir, mock_processors):
|
|
"""Test that full workflow executes from URL to analysis."""
|
|
from kb_workflow.pipeline.schemas import PipelineConfig
|
|
|
|
config = PipelineConfig(
|
|
steps=["parse", "download", "asr", "analyze"],
|
|
output_dir=temp_content_dir,
|
|
)
|
|
|
|
with patch("kb_workflow.pipeline.pipeline.get_settings") as mock_settings:
|
|
mock_settings.return_value = MagicMock()
|
|
|
|
pipeline = Pipeline(
|
|
processors=mock_processors,
|
|
config=config,
|
|
output_dir=temp_content_dir,
|
|
)
|
|
|
|
result = await pipeline.run("https://www.bilibili.com/video/BV1test123")
|
|
|
|
assert result.success
|
|
for processor in mock_processors:
|
|
processor.process.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_workflow_creates_content_directory(self, temp_content_dir, mock_processors):
|
|
"""Test that workflow creates content directory with correct naming."""
|
|
from kb_workflow.pipeline.schemas import PipelineConfig
|
|
|
|
config = PipelineConfig(
|
|
steps=["parse"],
|
|
output_dir=temp_content_dir,
|
|
)
|
|
|
|
with patch("kb_workflow.pipeline.pipeline.get_settings") as mock_settings:
|
|
mock_settings.return_value = MagicMock()
|
|
|
|
pipeline = Pipeline(
|
|
processors=mock_processors[:1], # Only parse processor
|
|
config=config,
|
|
output_dir=temp_content_dir,
|
|
)
|
|
|
|
result = await pipeline.run("https://www.bilibili.com/video/BV1test123")
|
|
|
|
# Check content directory was created
|
|
expected_dir = temp_content_dir / "bilibili_BV1test123"
|
|
assert result.content_dir == expected_dir
|
|
|
|
|
|
class TestWorkflowErrorHandling:
|
|
"""Test 2: Workflow handles errors gracefully"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_workflow_handles_errors_gracefully(self, temp_content_dir):
|
|
"""Test that workflow handles errors gracefully."""
|
|
from kb_workflow.pipeline.schemas import PipelineConfig
|
|
|
|
# Create processor that fails
|
|
failing_processor = MagicMock()
|
|
failing_processor.STEP_NAME = "parse"
|
|
failing_processor.process = AsyncMock(return_value=MagicMock(
|
|
success=False,
|
|
error_message="Network error",
|
|
))
|
|
|
|
config = PipelineConfig(
|
|
steps=["parse"],
|
|
output_dir=temp_content_dir,
|
|
)
|
|
|
|
with patch("kb_workflow.pipeline.pipeline.get_settings") as mock_settings:
|
|
mock_settings.return_value = MagicMock()
|
|
|
|
pipeline = Pipeline(
|
|
processors=[failing_processor],
|
|
config=config,
|
|
output_dir=temp_content_dir,
|
|
)
|
|
|
|
result = await pipeline.run("https://invalid.url")
|
|
|
|
assert not result.success
|
|
assert result.error_message is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_workflow_handles_invalid_url(self, temp_content_dir):
|
|
"""Test that workflow handles invalid URL."""
|
|
from kb_workflow.pipeline.schemas import PipelineConfig
|
|
|
|
config = PipelineConfig(
|
|
steps=["parse"],
|
|
output_dir=temp_content_dir,
|
|
)
|
|
|
|
with patch("kb_workflow.pipeline.pipeline.get_settings") as mock_settings:
|
|
mock_settings.return_value = MagicMock()
|
|
|
|
# Empty processor list - pipeline will fail to parse URL
|
|
pipeline = Pipeline(
|
|
processors=[],
|
|
config=PipelineConfig(steps=[]),
|
|
output_dir=temp_content_dir,
|
|
)
|
|
|
|
result = await pipeline.run("https://www.unknown.com/123")
|
|
|
|
# URL should be parsed but no processors to run
|
|
# The pipeline should still handle this gracefully
|
|
assert result is not None
|
|
|
|
|
|
class TestStatePersistence:
|
|
"""Test 3: State is persisted correctly"""
|
|
|
|
def test_state_manager_creates_state_file(self, temp_content_dir):
|
|
"""Test that WorkflowStateManager creates state file."""
|
|
state_manager = WorkflowStateManager(temp_content_dir)
|
|
|
|
state = state_manager.get_or_create(
|
|
content_id="BV1test123",
|
|
platform="bilibili",
|
|
source_url="https://www.bilibili.com/video/BV1test123"
|
|
)
|
|
|
|
state_manager.save(state)
|
|
|
|
# Check state file exists
|
|
state_file = temp_content_dir / "workflow_state.json"
|
|
assert state_file.exists()
|
|
|
|
def test_state_manager_persists_step_completion(self, temp_content_dir):
|
|
"""Test that state manager persists step completion."""
|
|
state_manager = WorkflowStateManager(temp_content_dir)
|
|
|
|
state = state_manager.get_or_create(
|
|
content_id="BV1test123",
|
|
platform="bilibili",
|
|
source_url="https://www.bilibili.com/video/BV1test123"
|
|
)
|
|
|
|
# Mark step as completed
|
|
state.mark_step_completed("parse", "Successfully parsed")
|
|
state_manager.save(state)
|
|
|
|
# Reload state
|
|
loaded_state = state_manager.load()
|
|
|
|
assert loaded_state is not None
|
|
assert loaded_state.is_step_completed("parse")
|
|
|
|
def test_state_manager_loads_existing_state(self, temp_content_dir):
|
|
"""Test that state manager loads existing state."""
|
|
state_manager = WorkflowStateManager(temp_content_dir)
|
|
|
|
# Create and save initial state
|
|
state = state_manager.get_or_create(
|
|
content_id="BV1test123",
|
|
platform="bilibili",
|
|
source_url="https://www.bilibili.com/video/BV1test123"
|
|
)
|
|
state.mark_step_completed("parse")
|
|
state_manager.save(state)
|
|
|
|
# Get or create should return existing state
|
|
existing_state = state_manager.get_or_create(
|
|
content_id="BV1test123",
|
|
platform="bilibili",
|
|
source_url="https://www.bilibili.com/video/BV1test123"
|
|
)
|
|
|
|
assert existing_state.is_step_completed("parse")
|
|
|
|
|
|
class TestWorkflowResumability:
|
|
"""Test 4: Workflow is resumable (skip completed steps)"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_workflow_skips_completed_steps(self, temp_content_dir):
|
|
"""Test that workflow can resume (skip completed steps)."""
|
|
from kb_workflow.pipeline.schemas import PipelineConfig
|
|
|
|
# Create state with parse already completed
|
|
state_manager = WorkflowStateManager(temp_content_dir)
|
|
|
|
# First, create content directory with proper naming
|
|
content_dir = temp_content_dir / "bilibili_BV1test123"
|
|
content_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
state = WorkflowStateManager(content_dir).get_or_create(
|
|
content_id="BV1test123",
|
|
platform="bilibili",
|
|
source_url="https://www.bilibili.com/video/BV1test123"
|
|
)
|
|
state.mark_step_completed("parse")
|
|
WorkflowStateManager(content_dir).save(state)
|
|
|
|
# Create processors
|
|
parse_processor = MagicMock()
|
|
parse_processor.STEP_NAME = "parse"
|
|
parse_processor.process = AsyncMock()
|
|
|
|
download_processor = MagicMock()
|
|
download_processor.STEP_NAME = "download"
|
|
download_processor.process = AsyncMock(return_value=MagicMock(success=True))
|
|
|
|
config = PipelineConfig(
|
|
steps=["parse", "download"],
|
|
output_dir=temp_content_dir,
|
|
skip_completed=True,
|
|
)
|
|
|
|
with patch("kb_workflow.pipeline.pipeline.get_settings") as mock_settings:
|
|
mock_settings.return_value = MagicMock(intermediate_path=str(temp_content_dir))
|
|
|
|
pipeline = Pipeline(
|
|
processors=[parse_processor, download_processor],
|
|
config=config,
|
|
output_dir=temp_content_dir,
|
|
)
|
|
|
|
# Mock _ask_rerun_step to return False (don't re-run)
|
|
with patch.object(pipeline, '_ask_rerun_step', return_value=False):
|
|
# Run pipeline - parse should be skipped
|
|
result = await pipeline.run("https://www.bilibili.com/video/BV1test123")
|
|
|
|
# This test depends on pipeline implementation for skip logic
|
|
# The important thing is that the pipeline runs without error
|
|
assert result is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_workflow_reruns_all_steps_when_skip_disabled(self, temp_content_dir):
|
|
"""Test that workflow re-runs all steps when skip_completed is False."""
|
|
from kb_workflow.pipeline.schemas import PipelineConfig
|
|
|
|
# Create processors
|
|
parse_processor = MagicMock()
|
|
parse_processor.STEP_NAME = "parse"
|
|
parse_processor.process = AsyncMock(return_value=MagicMock(success=True))
|
|
|
|
config = PipelineConfig(
|
|
steps=["parse"],
|
|
output_dir=temp_content_dir,
|
|
skip_completed=False, # Disable skip
|
|
)
|
|
|
|
with patch("kb_workflow.pipeline.pipeline.get_settings") as mock_settings:
|
|
mock_settings.return_value = MagicMock()
|
|
|
|
pipeline = Pipeline(
|
|
processors=[parse_processor],
|
|
config=config,
|
|
output_dir=temp_content_dir,
|
|
)
|
|
|
|
result = await pipeline.run("https://www.bilibili.com/video/BV1test123")
|
|
|
|
# Process should be called even if previously completed
|
|
parse_processor.process.assert_called_once()
|
|
assert result.success
|