- Add 28 unit tests for SourceService (TEST-004) - Test all CRUD operations - Test validation logic - Test error handling - Test research functionality - Add 13 integration tests for sources API (TEST-005) - Test POST /sources endpoint - Test GET /sources endpoint with filters - Test DELETE /sources endpoint - Test POST /sources/research endpoint - Fix ValidationError signatures in SourceService - Fix NotebookLMError signatures - Fix status parameter shadowing in sources router Coverage: 28/28 unit tests pass, 13/13 integration tests pass
553 lines
18 KiB
Python
553 lines
18 KiB
Python
"""Unit tests for SourceService.
|
|
|
|
TDD Cycle: RED → GREEN → REFACTOR
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
from uuid import UUID, uuid4
|
|
|
|
import pytest
|
|
|
|
from notebooklm_agent.core.exceptions import (
|
|
NotebookLMError,
|
|
NotFoundError,
|
|
ValidationError,
|
|
)
|
|
from notebooklm_agent.services.source_service import SourceService
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSourceServiceInit:
|
|
"""Test suite for SourceService initialization."""
|
|
|
|
async def test_get_client_returns_existing_client(self):
|
|
"""Should return existing client if already initialized."""
|
|
# Arrange
|
|
mock_client = AsyncMock()
|
|
service = SourceService(client=mock_client)
|
|
|
|
# Act
|
|
client = await service._get_client()
|
|
|
|
# Assert
|
|
assert client == mock_client
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSourceServiceValidateSourceType:
|
|
"""Test suite for SourceService._validate_source_type()."""
|
|
|
|
def test_validate_valid_types(self):
|
|
"""Should accept valid source types."""
|
|
# Arrange
|
|
service = SourceService()
|
|
valid_types = ["url", "file", "youtube", "drive"]
|
|
|
|
# Act & Assert
|
|
for source_type in valid_types:
|
|
result = service._validate_source_type(source_type)
|
|
assert result == source_type
|
|
|
|
def test_validate_invalid_type_raises_validation_error(self):
|
|
"""Should raise ValidationError for invalid type."""
|
|
# Arrange
|
|
service = SourceService()
|
|
|
|
# Act & Assert
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
service._validate_source_type("invalid_type")
|
|
|
|
assert "Invalid source type" in str(exc_info.value)
|
|
assert exc_info.value.code == "VALIDATION_ERROR"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSourceServiceValidateUrl:
|
|
"""Test suite for SourceService._validate_url()."""
|
|
|
|
def test_validate_url_required_for_url_type(self):
|
|
"""Should raise error if URL missing for url type."""
|
|
# Arrange
|
|
service = SourceService()
|
|
|
|
# Act & Assert
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
service._validate_url(None, "url")
|
|
|
|
assert "URL is required" in str(exc_info.value)
|
|
|
|
def test_validate_url_required_for_youtube_type(self):
|
|
"""Should raise error if URL missing for youtube type."""
|
|
# Arrange
|
|
service = SourceService()
|
|
|
|
# Act & Assert
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
service._validate_url(None, "youtube")
|
|
|
|
assert "URL is required" in str(exc_info.value)
|
|
|
|
def test_validate_url_optional_for_drive(self):
|
|
"""Should accept None URL for drive type."""
|
|
# Arrange
|
|
service = SourceService()
|
|
|
|
# Act
|
|
result = service._validate_url(None, "drive")
|
|
|
|
# Assert
|
|
assert result is None
|
|
|
|
def test_validate_url_optional_for_file(self):
|
|
"""Should accept None URL for file type."""
|
|
# Arrange
|
|
service = SourceService()
|
|
|
|
# Act
|
|
result = service._validate_url(None, "file")
|
|
|
|
# Assert
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSourceServiceCreate:
|
|
"""Test suite for SourceService.create() method."""
|
|
|
|
async def test_create_url_source_returns_source(self):
|
|
"""Should create URL source and return Source."""
|
|
# Arrange
|
|
notebook_id = uuid4()
|
|
mock_client = AsyncMock()
|
|
mock_notebook = AsyncMock()
|
|
mock_result = MagicMock()
|
|
mock_result.id = str(uuid4())
|
|
mock_result.title = "Example Article"
|
|
mock_result.status = "processing"
|
|
mock_result.created_at = datetime.utcnow()
|
|
|
|
mock_notebook.sources.add_url.return_value = mock_result
|
|
mock_client.notebooks.get.return_value = mock_notebook
|
|
|
|
service = SourceService(client=mock_client)
|
|
data = {"type": "url", "url": "https://example.com/article", "title": "Custom Title"}
|
|
|
|
# Act
|
|
result = await service.create(notebook_id, data)
|
|
|
|
# Assert
|
|
assert result.type == "url"
|
|
assert result.url == "https://example.com/article"
|
|
assert result.notebook_id == notebook_id
|
|
mock_notebook.sources.add_url.assert_called_once_with(
|
|
"https://example.com/article", title="Custom Title"
|
|
)
|
|
|
|
async def test_create_youtube_source_returns_source(self):
|
|
"""Should create YouTube source and return Source."""
|
|
# Arrange
|
|
notebook_id = uuid4()
|
|
mock_client = AsyncMock()
|
|
mock_notebook = AsyncMock()
|
|
mock_result = MagicMock()
|
|
mock_result.id = str(uuid4())
|
|
mock_result.title = "YouTube Video"
|
|
mock_result.status = "processing"
|
|
mock_result.created_at = datetime.utcnow()
|
|
|
|
mock_notebook.sources.add_youtube.return_value = mock_result
|
|
mock_client.notebooks.get.return_value = mock_notebook
|
|
|
|
service = SourceService(client=mock_client)
|
|
data = {"type": "youtube", "url": "https://youtube.com/watch?v=123", "title": None}
|
|
|
|
# Act
|
|
result = await service.create(notebook_id, data)
|
|
|
|
# Assert
|
|
assert result.type == "youtube"
|
|
assert result.url == "https://youtube.com/watch?v=123"
|
|
mock_notebook.sources.add_youtube.assert_called_once()
|
|
|
|
async def test_create_drive_source_returns_source(self):
|
|
"""Should create Drive source and return Source."""
|
|
# Arrange
|
|
notebook_id = uuid4()
|
|
mock_client = AsyncMock()
|
|
mock_notebook = AsyncMock()
|
|
mock_result = MagicMock()
|
|
mock_result.id = str(uuid4())
|
|
mock_result.title = "Drive Document"
|
|
mock_result.status = "processing"
|
|
mock_result.created_at = datetime.utcnow()
|
|
|
|
mock_notebook.sources.add_drive.return_value = mock_result
|
|
mock_client.notebooks.get.return_value = mock_notebook
|
|
|
|
service = SourceService(client=mock_client)
|
|
data = {"type": "drive", "url": "https://drive.google.com/file/d/123", "title": None}
|
|
|
|
# Act
|
|
result = await service.create(notebook_id, data)
|
|
|
|
# Assert
|
|
assert result.type == "drive"
|
|
mock_notebook.sources.add_drive.assert_called_once()
|
|
|
|
async def test_create_file_type_raises_validation_error(self):
|
|
"""Should raise error for file type (not supported)."""
|
|
# Arrange
|
|
notebook_id = uuid4()
|
|
service = SourceService()
|
|
data = {"type": "file", "url": None, "title": None}
|
|
|
|
# Act & Assert
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
await service.create(notebook_id, data)
|
|
|
|
assert "File upload not supported" in str(exc_info.value)
|
|
|
|
async def test_create_invalid_source_type_raises_validation_error(self):
|
|
"""Should raise ValidationError for invalid source type."""
|
|
# Arrange
|
|
notebook_id = uuid4()
|
|
service = SourceService()
|
|
data = {"type": "invalid", "url": "https://example.com", "title": None}
|
|
|
|
# Act & Assert
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
await service.create(notebook_id, data)
|
|
|
|
assert "Invalid source type" in str(exc_info.value)
|
|
|
|
async def test_create_missing_url_for_url_type_raises_validation_error(self):
|
|
"""Should raise ValidationError if URL missing for url type."""
|
|
# Arrange
|
|
notebook_id = uuid4()
|
|
service = SourceService()
|
|
data = {"type": "url", "url": None, "title": None}
|
|
|
|
# Act & Assert
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
await service.create(notebook_id, data)
|
|
|
|
assert "URL is required" in str(exc_info.value)
|
|
|
|
async def test_create_notebook_not_found_raises_not_found(self):
|
|
"""Should raise NotFoundError if notebook not found."""
|
|
# Arrange
|
|
notebook_id = uuid4()
|
|
mock_client = AsyncMock()
|
|
mock_client.notebooks.get.side_effect = Exception("notebook not found")
|
|
|
|
service = SourceService(client=mock_client)
|
|
data = {"type": "url", "url": "https://example.com", "title": None}
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotFoundError) as exc_info:
|
|
await service.create(notebook_id, data)
|
|
|
|
assert str(notebook_id) in str(exc_info.value)
|
|
|
|
async def test_create_api_error_raises_notebooklm_error(self):
|
|
"""Should raise NotebookLMError on API error."""
|
|
# Arrange
|
|
notebook_id = uuid4()
|
|
mock_client = AsyncMock()
|
|
mock_client.notebooks.get.side_effect = Exception("connection timeout")
|
|
|
|
service = SourceService(client=mock_client)
|
|
data = {"type": "url", "url": "https://example.com", "title": None}
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotebookLMError) as exc_info:
|
|
await service.create(notebook_id, data)
|
|
|
|
assert "Failed to add source" in str(exc_info.value)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSourceServiceList:
|
|
"""Test suite for SourceService.list() method."""
|
|
|
|
async def test_list_returns_sources(self):
|
|
"""Should return list of sources."""
|
|
# Arrange
|
|
notebook_id = uuid4()
|
|
mock_client = AsyncMock()
|
|
mock_notebook = AsyncMock()
|
|
|
|
mock_source = MagicMock()
|
|
mock_source.id = str(uuid4())
|
|
mock_source.type = "url"
|
|
mock_source.title = "Example"
|
|
mock_source.url = "https://example.com"
|
|
mock_source.status = "ready"
|
|
mock_source.created_at = datetime.utcnow()
|
|
|
|
mock_notebook.sources.list.return_value = [mock_source]
|
|
mock_client.notebooks.get.return_value = mock_notebook
|
|
|
|
service = SourceService(client=mock_client)
|
|
|
|
# Act
|
|
result = await service.list(notebook_id)
|
|
|
|
# Assert
|
|
assert len(result) == 1
|
|
assert result[0].type == "url"
|
|
assert result[0].title == "Example"
|
|
|
|
async def test_list_with_type_filter_returns_filtered(self):
|
|
"""Should filter sources by type."""
|
|
# Arrange
|
|
notebook_id = uuid4()
|
|
mock_client = AsyncMock()
|
|
mock_notebook = AsyncMock()
|
|
|
|
url_source = MagicMock()
|
|
url_source.id = str(uuid4())
|
|
url_source.type = "url"
|
|
url_source.title = "URL Source"
|
|
url_source.url = "https://example.com"
|
|
url_source.status = "ready"
|
|
url_source.created_at = datetime.utcnow()
|
|
|
|
youtube_source = MagicMock()
|
|
youtube_source.id = str(uuid4())
|
|
youtube_source.type = "youtube"
|
|
youtube_source.title = "YouTube Source"
|
|
youtube_source.url = "https://youtube.com"
|
|
youtube_source.status = "ready"
|
|
youtube_source.created_at = datetime.utcnow()
|
|
|
|
mock_notebook.sources.list.return_value = [url_source, youtube_source]
|
|
mock_client.notebooks.get.return_value = mock_notebook
|
|
|
|
service = SourceService(client=mock_client)
|
|
|
|
# Act
|
|
result = await service.list(notebook_id, source_type="url")
|
|
|
|
# Assert
|
|
assert len(result) == 1
|
|
assert result[0].type == "url"
|
|
|
|
async def test_list_with_status_filter_returns_filtered(self):
|
|
"""Should filter sources by status."""
|
|
# Arrange
|
|
notebook_id = uuid4()
|
|
mock_client = AsyncMock()
|
|
mock_notebook = AsyncMock()
|
|
|
|
ready_source = MagicMock()
|
|
ready_source.id = str(uuid4())
|
|
ready_source.type = "url"
|
|
ready_source.title = "Ready"
|
|
ready_source.url = "https://example.com"
|
|
ready_source.status = "ready"
|
|
ready_source.created_at = datetime.utcnow()
|
|
|
|
processing_source = MagicMock()
|
|
processing_source.id = str(uuid4())
|
|
processing_source.type = "url"
|
|
processing_source.title = "Processing"
|
|
processing_source.url = "https://example2.com"
|
|
processing_source.status = "processing"
|
|
processing_source.created_at = datetime.utcnow()
|
|
|
|
mock_notebook.sources.list.return_value = [ready_source, processing_source]
|
|
mock_client.notebooks.get.return_value = mock_notebook
|
|
|
|
service = SourceService(client=mock_client)
|
|
|
|
# Act
|
|
result = await service.list(notebook_id, status="ready")
|
|
|
|
# Assert
|
|
assert len(result) == 1
|
|
assert result[0].status == "ready"
|
|
|
|
async def test_list_empty_returns_empty_list(self):
|
|
"""Should return empty list if no sources."""
|
|
# Arrange
|
|
notebook_id = uuid4()
|
|
mock_client = AsyncMock()
|
|
mock_notebook = AsyncMock()
|
|
mock_notebook.sources.list.return_value = []
|
|
mock_client.notebooks.get.return_value = mock_notebook
|
|
|
|
service = SourceService(client=mock_client)
|
|
|
|
# Act
|
|
result = await service.list(notebook_id)
|
|
|
|
# Assert
|
|
assert result == []
|
|
|
|
async def test_list_notebook_not_found_raises_not_found(self):
|
|
"""Should raise NotFoundError if notebook not found."""
|
|
# Arrange
|
|
notebook_id = uuid4()
|
|
mock_client = AsyncMock()
|
|
mock_client.notebooks.get.side_effect = Exception("not found")
|
|
|
|
service = SourceService(client=mock_client)
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotFoundError):
|
|
await service.list(notebook_id)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSourceServiceDelete:
|
|
"""Test suite for SourceService.delete() method."""
|
|
|
|
async def test_delete_existing_source_succeeds(self):
|
|
"""Should delete source successfully."""
|
|
# Arrange
|
|
notebook_id = uuid4()
|
|
source_id = str(uuid4())
|
|
mock_client = AsyncMock()
|
|
mock_notebook = AsyncMock()
|
|
mock_notebook.sources.delete.return_value = None
|
|
mock_client.notebooks.get.return_value = mock_notebook
|
|
|
|
service = SourceService(client=mock_client)
|
|
|
|
# Act
|
|
await service.delete(notebook_id, source_id)
|
|
|
|
# Assert
|
|
mock_notebook.sources.delete.assert_called_once_with(source_id)
|
|
|
|
async def test_delete_source_not_found_raises_not_found(self):
|
|
"""Should raise NotFoundError if source not found."""
|
|
# Arrange
|
|
notebook_id = uuid4()
|
|
source_id = str(uuid4())
|
|
mock_client = AsyncMock()
|
|
mock_notebook = AsyncMock()
|
|
mock_notebook.sources.delete.side_effect = Exception("source not found")
|
|
mock_client.notebooks.get.return_value = mock_notebook
|
|
|
|
service = SourceService(client=mock_client)
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotFoundError) as exc_info:
|
|
await service.delete(notebook_id, source_id)
|
|
|
|
assert source_id in str(exc_info.value)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSourceServiceResearch:
|
|
"""Test suite for SourceService.research() method."""
|
|
|
|
async def test_research_fast_mode_returns_result(self):
|
|
"""Should start research in fast mode."""
|
|
# Arrange
|
|
notebook_id = uuid4()
|
|
mock_client = AsyncMock()
|
|
mock_notebook = AsyncMock()
|
|
mock_result = MagicMock()
|
|
mock_result.id = str(uuid4())
|
|
mock_result.status = "pending"
|
|
mock_result.sources_found = 5
|
|
|
|
mock_notebook.sources.research.return_value = mock_result
|
|
mock_client.notebooks.get.return_value = mock_notebook
|
|
|
|
service = SourceService(client=mock_client)
|
|
|
|
# Act
|
|
result = await service.research(notebook_id, "AI trends", mode="fast", auto_import=True)
|
|
|
|
# Assert
|
|
assert result["status"] == "pending"
|
|
assert result["query"] == "AI trends"
|
|
assert result["mode"] == "fast"
|
|
mock_notebook.sources.research.assert_called_once_with(
|
|
query="AI trends", mode="fast", auto_import=True
|
|
)
|
|
|
|
async def test_research_deep_mode_returns_result(self):
|
|
"""Should start research in deep mode."""
|
|
# Arrange
|
|
notebook_id = uuid4()
|
|
mock_client = AsyncMock()
|
|
mock_notebook = AsyncMock()
|
|
mock_result = MagicMock()
|
|
mock_result.id = str(uuid4())
|
|
mock_result.status = "pending"
|
|
mock_result.sources_found = 10
|
|
|
|
mock_notebook.sources.research.return_value = mock_result
|
|
mock_client.notebooks.get.return_value = mock_notebook
|
|
|
|
service = SourceService(client=mock_client)
|
|
|
|
# Act
|
|
result = await service.research(
|
|
notebook_id, "machine learning", mode="deep", auto_import=False
|
|
)
|
|
|
|
# Assert
|
|
assert result["mode"] == "deep"
|
|
assert result["query"] == "machine learning"
|
|
|
|
async def test_research_empty_query_raises_validation_error(self):
|
|
"""Should raise ValidationError for empty query."""
|
|
# Arrange
|
|
notebook_id = uuid4()
|
|
service = SourceService()
|
|
|
|
# Act & Assert
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
await service.research(notebook_id, "", mode="fast")
|
|
|
|
assert "Query cannot be empty" in str(exc_info.value)
|
|
|
|
async def test_research_invalid_mode_raises_validation_error(self):
|
|
"""Should raise ValidationError for invalid mode."""
|
|
# Arrange
|
|
notebook_id = uuid4()
|
|
service = SourceService()
|
|
|
|
# Act & Assert
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
await service.research(notebook_id, "AI trends", mode="invalid")
|
|
|
|
assert "Mode must be 'fast' or 'deep'" in str(exc_info.value)
|
|
|
|
async def test_research_notebook_not_found_raises_not_found(self):
|
|
"""Should raise NotFoundError if notebook not found."""
|
|
# Arrange
|
|
notebook_id = uuid4()
|
|
mock_client = AsyncMock()
|
|
mock_client.notebooks.get.side_effect = Exception("not found")
|
|
|
|
service = SourceService(client=mock_client)
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotFoundError):
|
|
await service.research(notebook_id, "AI trends", mode="fast")
|
|
|
|
async def test_research_api_error_raises_notebooklm_error(self):
|
|
"""Should raise NotebookLMError on API error."""
|
|
# Arrange
|
|
notebook_id = uuid4()
|
|
mock_client = AsyncMock()
|
|
mock_notebook = AsyncMock()
|
|
mock_notebook.sources.research.side_effect = Exception("API error")
|
|
mock_client.notebooks.get.return_value = mock_notebook
|
|
|
|
service = SourceService(client=mock_client)
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotebookLMError) as exc_info:
|
|
await service.research(notebook_id, "AI trends", mode="fast")
|
|
|
|
assert "Failed to start research" in str(exc_info.value)
|