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:
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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",
|
|
||||||
)
|
|
||||||
|
|||||||
311
tests/unit/test_api/test_sources.py
Normal file
311
tests/unit/test_api/test_sources.py
Normal 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]
|
||||||
552
tests/unit/test_services/test_source_service.py
Normal file
552
tests/unit/test_services/test_source_service.py
Normal 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)
|
||||||
Reference in New Issue
Block a user