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(
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,
),

View File

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