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
This commit is contained in:
Luca Sacchi Ricciardi
2026-04-06 01:42:07 +02:00
parent d869ab215c
commit 3991ffdd7f
4 changed files with 882 additions and 48 deletions

View File

@@ -144,14 +144,14 @@ async def create_source(notebook_id: str, data: SourceCreate):
async def list_sources( async def list_sources(
notebook_id: str, notebook_id: str,
source_type: str | None = None, source_type: str | None = None,
status: str | None = None, source_status: str | None = None,
): ):
"""List sources for a notebook. """List sources for a notebook.
Args: Args:
notebook_id: Notebook UUID. notebook_id: Notebook UUID.
source_type: Optional filter by source type. source_type: Optional filter by source type.
status: Optional filter by processing status. source_status: Optional filter by processing status.
Returns: Returns:
List of sources. List of sources.
@@ -182,7 +182,7 @@ async def list_sources(
try: try:
service = await get_source_service() 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( return ApiResponse(
success=True, success=True,
@@ -190,7 +190,7 @@ async def list_sources(
items=sources, items=sources,
pagination=PaginationMeta( pagination=PaginationMeta(
total=len(sources), total=len(sources),
limit=len(sources), limit=max(len(sources), 1),
offset=0, offset=0,
has_more=False, has_more=False,
), ),

View File

@@ -58,10 +58,7 @@ class SourceService:
""" """
allowed_types = {"url", "file", "youtube", "drive"} allowed_types = {"url", "file", "youtube", "drive"}
if source_type not in allowed_types: if source_type not in allowed_types:
raise ValidationError( raise ValidationError(f"Invalid source type. Must be one of: {allowed_types}")
message=f"Invalid source type. Must be one of: {allowed_types}",
code="VALIDATION_ERROR",
)
return source_type return source_type
def _validate_url(self, url: str | None, source_type: str) -> str | None: 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. ValidationError: If URL is required but not provided.
""" """
if source_type in {"url", "youtube"} and not url: if source_type in {"url", "youtube"} and not url:
raise ValidationError( raise ValidationError(f"URL is required for source type '{source_type}'")
message=f"URL is required for source type '{source_type}'",
code="VALIDATION_ERROR",
)
return url return url
async def create(self, notebook_id: UUID, data: dict) -> Source: async def create(self, notebook_id: UUID, data: dict) -> Source:
@@ -103,6 +97,12 @@ class SourceService:
source_type = data.get("type", "url") source_type = data.get("type", "url")
self._validate_source_type(source_type) 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") url = data.get("url")
self._validate_url(url, source_type) self._validate_url(url, source_type)
@@ -119,12 +119,6 @@ class SourceService:
result = await notebook.sources.add_youtube(url, title=title) result = await notebook.sources.add_youtube(url, title=title)
elif source_type == "drive": elif source_type == "drive":
result = await notebook.sources.add_drive(url, title=title) 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( return Source(
id=getattr(result, "id", str(notebook_id)), id=getattr(result, "id", str(notebook_id)),
@@ -136,16 +130,11 @@ class SourceService:
created_at=getattr(result, "created_at", datetime.utcnow()), created_at=getattr(result, "created_at", datetime.utcnow()),
) )
except ValidationError:
raise
except Exception as e: except Exception as e:
error_str = str(e).lower() error_str = str(e).lower()
if "not found" in error_str: if "not found" in error_str:
raise NotFoundError("Notebook", str(notebook_id)) raise NotFoundError("Notebook", str(notebook_id))
raise NotebookLMError( raise NotebookLMError(f"Failed to add source: {e}")
message=f"Failed to add source: {e}",
code="NOTEBOOKLM_ERROR",
)
async def list( async def list(
self, self,
@@ -201,10 +190,7 @@ class SourceService:
error_str = str(e).lower() error_str = str(e).lower()
if "not found" in error_str: if "not found" in error_str:
raise NotFoundError("Notebook", str(notebook_id)) raise NotFoundError("Notebook", str(notebook_id))
raise NotebookLMError( raise NotebookLMError(f"Failed to list sources: {e}")
message=f"Failed to list sources: {e}",
code="NOTEBOOKLM_ERROR",
)
async def delete(self, notebook_id: UUID, source_id: str) -> None: async def delete(self, notebook_id: UUID, source_id: str) -> None:
"""Delete a source from a notebook. """Delete a source from a notebook.
@@ -228,10 +214,7 @@ class SourceService:
error_str = str(e).lower() error_str = str(e).lower()
if "not found" in error_str: if "not found" in error_str:
raise NotFoundError("Source", source_id) raise NotFoundError("Source", source_id)
raise NotebookLMError( raise NotebookLMError(f"Failed to delete source: {e}")
message=f"Failed to delete source: {e}",
code="NOTEBOOKLM_ERROR",
)
async def get_fulltext(self, notebook_id: UUID, source_id: str) -> str: async def get_fulltext(self, notebook_id: UUID, source_id: str) -> str:
"""Get the full text content of a source. """Get the full text content of a source.
@@ -260,10 +243,7 @@ class SourceService:
error_str = str(e).lower() error_str = str(e).lower()
if "not found" in error_str: if "not found" in error_str:
raise NotFoundError("Source", source_id) raise NotFoundError("Source", source_id)
raise NotebookLMError( raise NotebookLMError(f"Failed to get source fulltext: {e}")
message=f"Failed to get source fulltext: {e}",
code="NOTEBOOKLM_ERROR",
)
async def research( async def research(
self, self,
@@ -289,16 +269,10 @@ class SourceService:
NotebookLMError: If external API fails. NotebookLMError: If external API fails.
""" """
if not query or not query.strip(): if not query or not query.strip():
raise ValidationError( raise ValidationError("Query cannot be empty")
message="Query cannot be empty",
code="VALIDATION_ERROR",
)
if mode not in {"fast", "deep"}: if mode not in {"fast", "deep"}:
raise ValidationError( raise ValidationError("Mode must be 'fast' or 'deep'")
message="Mode must be 'fast' or 'deep'",
code="VALIDATION_ERROR",
)
try: try:
client = await self._get_client() client = await self._get_client()
@@ -325,7 +299,4 @@ class SourceService:
error_str = str(e).lower() error_str = str(e).lower()
if "not found" in error_str: if "not found" in error_str:
raise NotFoundError("Notebook", str(notebook_id)) raise NotFoundError("Notebook", str(notebook_id))
raise NotebookLMError( raise NotebookLMError(f"Failed to start research: {e}")
message=f"Failed to start research: {e}",
code="NOTEBOOKLM_ERROR",
)

View File

@@ -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]

View File

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