Files
documente/tests/unit/test_services/test_source_service.py
Luca Sacchi Ricciardi 3991ffdd7f test(sources): add comprehensive tests for Sprint 2
- 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
2026-04-06 01:42:07 +02:00

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)