- 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
312 lines
11 KiB
Python
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]
|