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:
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]
|
||||
Reference in New Issue
Block a user