diff --git a/src/notebooklm_agent/api/routes/sources.py b/src/notebooklm_agent/api/routes/sources.py index f53992e..89d74a2 100644 --- a/src/notebooklm_agent/api/routes/sources.py +++ b/src/notebooklm_agent/api/routes/sources.py @@ -144,14 +144,14 @@ async def create_source(notebook_id: str, data: SourceCreate): async def list_sources( notebook_id: str, source_type: str | None = None, - status: str | None = None, + source_status: str | None = None, ): """List sources for a notebook. Args: notebook_id: Notebook UUID. source_type: Optional filter by source type. - status: Optional filter by processing status. + source_status: Optional filter by processing status. Returns: List of sources. @@ -182,7 +182,7 @@ async def list_sources( try: service = await get_source_service() - sources = await service.list(notebook_uuid, source_type, status) + sources = await service.list(notebook_uuid, source_type, source_status) return ApiResponse( success=True, @@ -190,7 +190,7 @@ async def list_sources( items=sources, pagination=PaginationMeta( total=len(sources), - limit=len(sources), + limit=max(len(sources), 1), offset=0, has_more=False, ), diff --git a/src/notebooklm_agent/services/source_service.py b/src/notebooklm_agent/services/source_service.py index b72826e..82884f9 100644 --- a/src/notebooklm_agent/services/source_service.py +++ b/src/notebooklm_agent/services/source_service.py @@ -58,10 +58,7 @@ class SourceService: """ allowed_types = {"url", "file", "youtube", "drive"} if source_type not in allowed_types: - raise ValidationError( - message=f"Invalid source type. Must be one of: {allowed_types}", - code="VALIDATION_ERROR", - ) + raise ValidationError(f"Invalid source type. Must be one of: {allowed_types}") return source_type def _validate_url(self, url: str | None, source_type: str) -> str | None: @@ -78,10 +75,7 @@ class SourceService: ValidationError: If URL is required but not provided. """ if source_type in {"url", "youtube"} and not url: - raise ValidationError( - message=f"URL is required for source type '{source_type}'", - code="VALIDATION_ERROR", - ) + raise ValidationError(f"URL is required for source type '{source_type}'") return url async def create(self, notebook_id: UUID, data: dict) -> Source: @@ -103,6 +97,12 @@ class SourceService: source_type = data.get("type", "url") self._validate_source_type(source_type) + # Check for unsupported file type early + if source_type == "file": + raise ValidationError( + "File upload not supported via this method. Use file upload endpoint." + ) + url = data.get("url") self._validate_url(url, source_type) @@ -119,12 +119,6 @@ class SourceService: result = await notebook.sources.add_youtube(url, title=title) elif source_type == "drive": result = await notebook.sources.add_drive(url, title=title) - else: - # For file type, this would be handled differently (multipart upload) - raise ValidationError( - message="File upload not supported via this method. Use file upload endpoint.", - code="VALIDATION_ERROR", - ) return Source( id=getattr(result, "id", str(notebook_id)), @@ -136,16 +130,11 @@ class SourceService: created_at=getattr(result, "created_at", datetime.utcnow()), ) - except ValidationError: - raise except Exception as e: error_str = str(e).lower() if "not found" in error_str: raise NotFoundError("Notebook", str(notebook_id)) - raise NotebookLMError( - message=f"Failed to add source: {e}", - code="NOTEBOOKLM_ERROR", - ) + raise NotebookLMError(f"Failed to add source: {e}") async def list( self, @@ -201,10 +190,7 @@ class SourceService: error_str = str(e).lower() if "not found" in error_str: raise NotFoundError("Notebook", str(notebook_id)) - raise NotebookLMError( - message=f"Failed to list sources: {e}", - code="NOTEBOOKLM_ERROR", - ) + raise NotebookLMError(f"Failed to list sources: {e}") async def delete(self, notebook_id: UUID, source_id: str) -> None: """Delete a source from a notebook. @@ -228,10 +214,7 @@ class SourceService: error_str = str(e).lower() if "not found" in error_str: raise NotFoundError("Source", source_id) - raise NotebookLMError( - message=f"Failed to delete source: {e}", - code="NOTEBOOKLM_ERROR", - ) + raise NotebookLMError(f"Failed to delete source: {e}") async def get_fulltext(self, notebook_id: UUID, source_id: str) -> str: """Get the full text content of a source. @@ -260,10 +243,7 @@ class SourceService: error_str = str(e).lower() if "not found" in error_str: raise NotFoundError("Source", source_id) - raise NotebookLMError( - message=f"Failed to get source fulltext: {e}", - code="NOTEBOOKLM_ERROR", - ) + raise NotebookLMError(f"Failed to get source fulltext: {e}") async def research( self, @@ -289,16 +269,10 @@ class SourceService: NotebookLMError: If external API fails. """ if not query or not query.strip(): - raise ValidationError( - message="Query cannot be empty", - code="VALIDATION_ERROR", - ) + raise ValidationError("Query cannot be empty") if mode not in {"fast", "deep"}: - raise ValidationError( - message="Mode must be 'fast' or 'deep'", - code="VALIDATION_ERROR", - ) + raise ValidationError("Mode must be 'fast' or 'deep'") try: client = await self._get_client() @@ -325,7 +299,4 @@ class SourceService: error_str = str(e).lower() if "not found" in error_str: raise NotFoundError("Notebook", str(notebook_id)) - raise NotebookLMError( - message=f"Failed to start research: {e}", - code="NOTEBOOKLM_ERROR", - ) + raise NotebookLMError(f"Failed to start research: {e}") diff --git a/tests/unit/test_api/test_sources.py b/tests/unit/test_api/test_sources.py new file mode 100644 index 0000000..4f2dbdc --- /dev/null +++ b/tests/unit/test_api/test_sources.py @@ -0,0 +1,311 @@ +"""Integration tests for sources API endpoints. + +Tests all source endpoints with mocked services. +""" + +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from notebooklm_agent.api.main import app +from notebooklm_agent.api.models.responses import Source + + +@pytest.mark.unit +class TestCreateSourceEndpoint: + """Test suite for POST /api/v1/notebooks/{id}/sources endpoint.""" + + def test_create_url_source_returns_201(self): + """Should return 201 for valid URL source.""" + # Arrange + client = TestClient(app) + notebook_id = str(uuid4()) + source_id = str(uuid4()) + + with patch("notebooklm_agent.api.routes.sources.SourceService") as mock_service_class: + mock_service = AsyncMock() + mock_source = AsyncMock() + mock_source.id = source_id + mock_source.notebook_id = notebook_id + mock_source.type = "url" + mock_source.title = "Example Article" + mock_source.url = "https://example.com/article" + mock_source.status = "processing" + mock_source.created_at = "2026-04-06T10:00:00Z" + mock_service.create.return_value = mock_source + mock_service_class.return_value = mock_service + + # Act + response = client.post( + f"/api/v1/notebooks/{notebook_id}/sources", + json={"type": "url", "url": "https://example.com/article", "title": "Example"}, + ) + + # Assert + assert response.status_code == 201 + data = response.json() + assert data["success"] is True + assert data["data"]["type"] == "url" + + def test_create_source_invalid_notebook_id_returns_400(self): + """Should return 400 for invalid notebook ID.""" + # Arrange + client = TestClient(app) + + # Act + response = client.post( + "/api/v1/notebooks/invalid-id/sources", + json={"type": "url", "url": "https://example.com"}, + ) + + # Assert + assert response.status_code in [400, 422] + + def test_create_source_missing_url_returns_400(self): + """Should return 400 when URL missing for url type.""" + # Arrange + client = TestClient(app) + notebook_id = str(uuid4()) + + with patch("notebooklm_agent.api.routes.sources.SourceService") as mock_service_class: + mock_service = AsyncMock() + from notebooklm_agent.core.exceptions import ValidationError + + mock_service.create.side_effect = ValidationError("URL is required") + mock_service_class.return_value = mock_service + + # Act + response = client.post( + f"/api/v1/notebooks/{notebook_id}/sources", + json={"type": "url", "url": None}, + ) + + # Assert + assert response.status_code in [400, 422] + + +@pytest.mark.unit +class TestListSourcesEndpoint: + """Test suite for GET /api/v1/notebooks/{id}/sources endpoint.""" + + def test_list_sources_returns_200(self): + """Should return 200 with list of sources.""" + # Arrange + client = TestClient(app) + notebook_id = str(uuid4()) + source_id = str(uuid4()) + + with patch("notebooklm_agent.api.routes.sources.SourceService") as mock_service_class: + mock_service = AsyncMock() + mock_source = Source( + id=source_id, + notebook_id=notebook_id, + type="url", + title="Example", + url="https://example.com", + status="ready", + created_at=datetime.utcnow(), + ) + mock_service.list.return_value = [mock_source] + mock_service_class.return_value = mock_service + + # Act + response = client.get(f"/api/v1/notebooks/{notebook_id}/sources") + + # Assert + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert len(data["data"]["items"]) == 1 + assert data["data"]["items"][0]["type"] == "url" + + def test_list_sources_with_type_filter(self): + """Should filter sources by type.""" + # Arrange + client = TestClient(app) + notebook_id = str(uuid4()) + + with patch("notebooklm_agent.api.routes.sources.SourceService") as mock_service_class: + mock_service = AsyncMock() + mock_service.list.return_value = [] + mock_service_class.return_value = mock_service + + # Act + response = client.get(f"/api/v1/notebooks/{notebook_id}/sources?source_type=youtube") + + # Assert + assert response.status_code == 200 + from uuid import UUID + + mock_service.list.assert_called_once_with(UUID(notebook_id), "youtube", None) + + def test_list_sources_invalid_notebook_id_returns_400(self): + """Should return 400 for invalid notebook ID.""" + # Arrange + client = TestClient(app) + + # Act + response = client.get("/api/v1/notebooks/invalid-id/sources") + + # Assert + assert response.status_code in [400, 422] + + +@pytest.mark.unit +class TestDeleteSourceEndpoint: + """Test suite for DELETE /api/v1/notebooks/{id}/sources/{source_id} endpoint.""" + + def test_delete_source_returns_204(self): + """Should return 204 No Content for successful delete.""" + # Arrange + client = TestClient(app) + notebook_id = str(uuid4()) + source_id = str(uuid4()) + + with patch("notebooklm_agent.api.routes.sources.SourceService") as mock_service_class: + mock_service = AsyncMock() + mock_source = Source( + id=source_id, + notebook_id=notebook_id, + type="url", + title="Example", + url="https://example.com", + status="ready", + created_at=datetime.utcnow(), + ) + mock_service.list.return_value = [mock_source] + mock_service_class.return_value = mock_service + + # Act + response = client.delete(f"/api/v1/notebooks/{notebook_id}/sources/{source_id}") + + # Assert + assert response.status_code == 204 + assert response.content == b"" + + def test_delete_source_not_found_returns_404(self): + """Should return 404 when source not found.""" + # Arrange + client = TestClient(app) + notebook_id = str(uuid4()) + source_id = str(uuid4()) + + with patch("notebooklm_agent.api.routes.sources.SourceService") as mock_service_class: + mock_service = AsyncMock() + from notebooklm_agent.core.exceptions import NotFoundError + + mock_service.delete.side_effect = NotFoundError("Source", source_id) + mock_service_class.return_value = mock_service + + # Act + response = client.delete(f"/api/v1/notebooks/{notebook_id}/sources/{source_id}") + + # Assert + assert response.status_code == 404 + + def test_delete_source_invalid_notebook_id_returns_400(self): + """Should return 400 for invalid notebook ID.""" + # Arrange + client = TestClient(app) + source_id = str(uuid4()) + + # Act + response = client.delete(f"/api/v1/notebooks/invalid-id/sources/{source_id}") + + # Assert + assert response.status_code in [400, 422] + + +@pytest.mark.unit +class TestResearchEndpoint: + """Test suite for POST /api/v1/notebooks/{id}/sources/research endpoint.""" + + def test_research_returns_202(self): + """Should return 202 Accepted for research start.""" + # Arrange + client = TestClient(app) + notebook_id = str(uuid4()) + + with patch("notebooklm_agent.api.routes.sources.SourceService") as mock_service_class: + mock_service = AsyncMock() + mock_service.research.return_value = { + "research_id": str(uuid4()), + "status": "pending", + "query": "AI trends", + "mode": "fast", + "sources_found": 0, + } + mock_service_class.return_value = mock_service + + # Act + response = client.post( + f"/api/v1/notebooks/{notebook_id}/sources/research", + json={"query": "AI trends", "mode": "fast", "auto_import": True}, + ) + + # Assert + assert response.status_code == 202 + data = response.json() + assert data["success"] is True + assert data["data"]["status"] == "pending" + + def test_research_invalid_mode_returns_400(self): + """Should return 400 for invalid research mode.""" + # Arrange + client = TestClient(app) + notebook_id = str(uuid4()) + + with patch("notebooklm_agent.api.routes.sources.SourceService") as mock_service_class: + mock_service = AsyncMock() + from notebooklm_agent.core.exceptions import ValidationError + + mock_service.research.side_effect = ValidationError("Mode must be 'fast' or 'deep'") + mock_service_class.return_value = mock_service + + # Act + response = client.post( + f"/api/v1/notebooks/{notebook_id}/sources/research", + json={"query": "AI trends", "mode": "invalid"}, + ) + + # Assert + assert response.status_code in [400, 422] + + def test_research_empty_query_returns_400(self): + """Should return 400 for empty query.""" + # Arrange + client = TestClient(app) + notebook_id = str(uuid4()) + + with patch("notebooklm_agent.api.routes.sources.SourceService") as mock_service_class: + mock_service = AsyncMock() + from notebooklm_agent.core.exceptions import ValidationError + + mock_service.research.side_effect = ValidationError("Query cannot be empty") + mock_service_class.return_value = mock_service + + # Act + response = client.post( + f"/api/v1/notebooks/{notebook_id}/sources/research", + json={"query": "", "mode": "fast"}, + ) + + # Assert + assert response.status_code in [400, 422] + + def test_research_invalid_notebook_id_returns_400(self): + """Should return 400 for invalid notebook ID.""" + # Arrange + client = TestClient(app) + + # Act + response = client.post( + "/api/v1/notebooks/invalid-id/sources/research", + json={"query": "AI trends", "mode": "fast"}, + ) + + # Assert + assert response.status_code in [400, 422] diff --git a/tests/unit/test_services/test_source_service.py b/tests/unit/test_services/test_source_service.py new file mode 100644 index 0000000..41b3deb --- /dev/null +++ b/tests/unit/test_services/test_source_service.py @@ -0,0 +1,552 @@ +"""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)