Files
documente/tests/unit/test_api/test_sources.py
Luca Sacchi Ricciardi 3991ffdd7f 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
2026-04-06 01:42:07 +02:00

312 lines
11 KiB
Python

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