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