feat(api): add content generation endpoints (Sprint 4)
Implement Sprint 4: Content Generation
- Add ArtifactService with generation methods for 9 content types
- Add POST /generate/audio - Generate podcast
- Add POST /generate/video - Generate video
- Add POST /generate/slide-deck - Generate slides
- Add POST /generate/infographic - Generate infographic
- Add POST /generate/quiz - Generate quiz
- Add POST /generate/flashcards - Generate flashcards
- Add POST /generate/report - Generate report
- Add POST /generate/mind-map - Generate mind map (instant)
- Add POST /generate/data-table - Generate data table
- Add GET /artifacts - List artifacts
- Add GET /artifacts/{id}/status - Check artifact status
Models:
- AudioGenerationRequest, VideoGenerationRequest
- QuizGenerationRequest, FlashcardsGenerationRequest
- SlideDeckGenerationRequest, InfographicGenerationRequest
- ReportGenerationRequest, DataTableGenerationRequest
- Artifact, GenerationResponse, ArtifactList
Tests:
- 13 unit tests for ArtifactService
- 6 integration tests for generation API
- 19/19 tests passing
Related: Sprint 4 - Content Generation
This commit is contained in:
246
tests/unit/test_api/test_generation.py
Normal file
246
tests/unit/test_api/test_generation.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""Integration tests for generation API endpoints.
|
||||
|
||||
Tests key generation endpoints with mocked services.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, 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 Artifact, GenerationResponse
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGenerateAudioEndpoint:
|
||||
"""Test suite for POST /generate/audio endpoint."""
|
||||
|
||||
def test_generate_audio_returns_202(self):
|
||||
"""Should return 202 Accepted for audio generation."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
notebook_id = str(uuid4())
|
||||
artifact_id = str(uuid4())
|
||||
|
||||
with patch("notebooklm_agent.api.routes.generation.ArtifactService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
mock_response = GenerationResponse(
|
||||
artifact_id=artifact_id,
|
||||
status="processing",
|
||||
message="Audio generation started",
|
||||
estimated_time_seconds=600,
|
||||
)
|
||||
mock_service.generate_audio.return_value = mock_response
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.post(
|
||||
f"/api/v1/notebooks/{notebook_id}/generate/audio",
|
||||
json={
|
||||
"instructions": "Make it engaging",
|
||||
"format": "deep-dive",
|
||||
"length": "long",
|
||||
"language": "en",
|
||||
},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["data"]["status"] == "processing"
|
||||
assert data["data"]["estimated_time_seconds"] == 600
|
||||
|
||||
def test_generate_audio_invalid_notebook_returns_400(self):
|
||||
"""Should return 400 for invalid notebook ID."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
|
||||
# Act
|
||||
response = client.post(
|
||||
"/api/v1/notebooks/invalid-id/generate/audio",
|
||||
json={"format": "deep-dive"},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code in [400, 422]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGenerateQuizEndpoint:
|
||||
"""Test suite for POST /generate/quiz endpoint."""
|
||||
|
||||
def test_generate_quiz_returns_202(self):
|
||||
"""Should return 202 Accepted for quiz generation."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
notebook_id = str(uuid4())
|
||||
artifact_id = str(uuid4())
|
||||
|
||||
with patch("notebooklm_agent.api.routes.generation.ArtifactService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
mock_response = GenerationResponse(
|
||||
artifact_id=artifact_id,
|
||||
status="processing",
|
||||
message="Quiz generation started",
|
||||
estimated_time_seconds=600,
|
||||
)
|
||||
mock_service.generate_quiz.return_value = mock_response
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.post(
|
||||
f"/api/v1/notebooks/{notebook_id}/generate/quiz",
|
||||
json={"difficulty": "medium", "quantity": "standard"},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGenerateMindMapEndpoint:
|
||||
"""Test suite for POST /generate/mind-map endpoint."""
|
||||
|
||||
def test_generate_mind_map_returns_202(self):
|
||||
"""Should return 202 Accepted for mind map generation."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
notebook_id = str(uuid4())
|
||||
artifact_id = str(uuid4())
|
||||
|
||||
with patch("notebooklm_agent.api.routes.generation.ArtifactService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
mock_response = GenerationResponse(
|
||||
artifact_id=artifact_id,
|
||||
status="completed",
|
||||
message="Mind map generated",
|
||||
estimated_time_seconds=10,
|
||||
)
|
||||
mock_service.generate_mind_map.return_value = mock_response
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.post(f"/api/v1/notebooks/{notebook_id}/generate/mind-map")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
assert data["data"]["status"] == "completed"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestListArtifactsEndpoint:
|
||||
"""Test suite for GET /artifacts endpoint."""
|
||||
|
||||
def test_list_artifacts_returns_200(self):
|
||||
"""Should return 200 with list of artifacts."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
notebook_id = str(uuid4())
|
||||
|
||||
with patch("notebooklm_agent.api.routes.generation.ArtifactService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
mock_artifact = Artifact(
|
||||
id=uuid4(),
|
||||
notebook_id=notebook_id,
|
||||
type="audio",
|
||||
title="Podcast",
|
||||
status="completed",
|
||||
created_at=datetime.utcnow(),
|
||||
completed_at=datetime.utcnow(),
|
||||
download_url="https://example.com/download",
|
||||
)
|
||||
mock_service.list_artifacts.return_value = [mock_artifact]
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.get(f"/api/v1/notebooks/{notebook_id}/artifacts")
|
||||
|
||||
# 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"] == "audio"
|
||||
|
||||
def test_list_artifacts_empty_returns_empty_list(self):
|
||||
"""Should return empty list if no artifacts."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
notebook_id = str(uuid4())
|
||||
|
||||
with patch("notebooklm_agent.api.routes.generation.ArtifactService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
mock_service.list_artifacts.return_value = []
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.get(f"/api/v1/notebooks/{notebook_id}/artifacts")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["data"]["items"] == []
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetArtifactStatusEndpoint:
|
||||
"""Test suite for GET /artifacts/{id}/status endpoint."""
|
||||
|
||||
def test_get_artifact_status_returns_200(self):
|
||||
"""Should return 200 with artifact status."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
notebook_id = str(uuid4())
|
||||
artifact_id = str(uuid4())
|
||||
|
||||
with patch("notebooklm_agent.api.routes.generation.ArtifactService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
mock_artifact = Artifact(
|
||||
id=artifact_id,
|
||||
notebook_id=notebook_id,
|
||||
type="video",
|
||||
title="Video",
|
||||
status="processing",
|
||||
created_at=datetime.utcnow(),
|
||||
completed_at=None,
|
||||
download_url=None,
|
||||
)
|
||||
mock_service.get_status.return_value = mock_artifact
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.get(f"/api/v1/notebooks/{notebook_id}/artifacts/{artifact_id}/status")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["data"]["status"] == "processing"
|
||||
|
||||
def test_get_artifact_status_not_found_returns_404(self):
|
||||
"""Should return 404 when artifact not found."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
notebook_id = str(uuid4())
|
||||
artifact_id = str(uuid4())
|
||||
|
||||
with patch("notebooklm_agent.api.routes.generation.ArtifactService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
from notebooklm_agent.core.exceptions import NotFoundError
|
||||
|
||||
mock_service.get_status.side_effect = NotFoundError("Artifact", artifact_id)
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.get(f"/api/v1/notebooks/{notebook_id}/artifacts/{artifact_id}/status")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 404
|
||||
292
tests/unit/test_services/test_artifact_service.py
Normal file
292
tests/unit/test_services/test_artifact_service.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""Unit tests for ArtifactService.
|
||||
|
||||
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,
|
||||
)
|
||||
from notebooklm_agent.services.artifact_service import ArtifactService
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestArtifactServiceInit:
|
||||
"""Test suite for ArtifactService initialization."""
|
||||
|
||||
async def test_get_client_returns_existing_client(self):
|
||||
"""Should return existing client if already initialized."""
|
||||
# Arrange
|
||||
mock_client = AsyncMock()
|
||||
service = ArtifactService(client=mock_client)
|
||||
|
||||
# Act
|
||||
client = await service._get_client()
|
||||
|
||||
# Assert
|
||||
assert client == mock_client
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestArtifactServiceGenerateAudio:
|
||||
"""Test suite for generate_audio method."""
|
||||
|
||||
async def test_generate_audio_returns_generation_response(self):
|
||||
"""Should start audio generation and return response."""
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
mock_client = AsyncMock()
|
||||
mock_notebook = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.id = str(uuid4())
|
||||
mock_result.status = "processing"
|
||||
|
||||
mock_notebook.generate_audio.return_value = mock_result
|
||||
mock_client.notebooks.get.return_value = mock_notebook
|
||||
|
||||
service = ArtifactService(client=mock_client)
|
||||
|
||||
# Act
|
||||
result = await service.generate_audio(
|
||||
notebook_id,
|
||||
instructions="Make it engaging",
|
||||
format="deep-dive",
|
||||
length="long",
|
||||
language="en",
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.status == "processing"
|
||||
assert str(result.artifact_id) == str(mock_result.id)
|
||||
assert result.estimated_time_seconds == 600
|
||||
assert "Audio generation started" in result.message
|
||||
|
||||
async def test_generate_audio_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 = ArtifactService(client=mock_client)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(NotFoundError) as exc_info:
|
||||
await service.generate_audio(notebook_id)
|
||||
|
||||
assert str(notebook_id) in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestArtifactServiceGenerateVideo:
|
||||
"""Test suite for generate_video method."""
|
||||
|
||||
async def test_generate_video_returns_generation_response(self):
|
||||
"""Should start video generation and return response."""
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
mock_client = AsyncMock()
|
||||
mock_notebook = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.id = str(uuid4())
|
||||
mock_result.status = "processing"
|
||||
|
||||
mock_notebook.generate_video.return_value = mock_result
|
||||
mock_client.notebooks.get.return_value = mock_notebook
|
||||
|
||||
service = ArtifactService(client=mock_client)
|
||||
|
||||
# Act
|
||||
result = await service.generate_video(
|
||||
notebook_id,
|
||||
instructions="Create engaging video",
|
||||
style="whiteboard",
|
||||
language="en",
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.status == "processing"
|
||||
assert result.estimated_time_seconds == 1800
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestArtifactServiceGenerateQuiz:
|
||||
"""Test suite for generate_quiz method."""
|
||||
|
||||
async def test_generate_quiz_returns_generation_response(self):
|
||||
"""Should start quiz generation and return response."""
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
mock_client = AsyncMock()
|
||||
mock_notebook = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.id = str(uuid4())
|
||||
mock_result.status = "processing"
|
||||
|
||||
mock_notebook.generate_quiz.return_value = mock_result
|
||||
mock_client.notebooks.get.return_value = mock_notebook
|
||||
|
||||
service = ArtifactService(client=mock_client)
|
||||
|
||||
# Act
|
||||
result = await service.generate_quiz(
|
||||
notebook_id,
|
||||
difficulty="medium",
|
||||
quantity="standard",
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.status == "processing"
|
||||
assert result.estimated_time_seconds == 600
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestArtifactServiceGenerateMindMap:
|
||||
"""Test suite for generate_mind_map method."""
|
||||
|
||||
async def test_generate_mind_map_returns_generation_response(self):
|
||||
"""Should generate mind map (instant) and return response."""
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
mock_client = AsyncMock()
|
||||
mock_notebook = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.id = str(uuid4())
|
||||
mock_result.status = "completed"
|
||||
|
||||
mock_notebook.generate_mind_map.return_value = mock_result
|
||||
mock_client.notebooks.get.return_value = mock_notebook
|
||||
|
||||
service = ArtifactService(client=mock_client)
|
||||
|
||||
# Act
|
||||
result = await service.generate_mind_map(notebook_id)
|
||||
|
||||
# Assert
|
||||
assert result.status == "completed"
|
||||
assert result.estimated_time_seconds == 10
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestArtifactServiceListArtifacts:
|
||||
"""Test suite for list_artifacts method."""
|
||||
|
||||
async def test_list_artifacts_returns_list(self):
|
||||
"""Should return list of artifacts."""
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
mock_client = AsyncMock()
|
||||
mock_notebook = AsyncMock()
|
||||
|
||||
mock_artifact = MagicMock()
|
||||
mock_artifact.id = str(uuid4())
|
||||
mock_artifact.type = "audio"
|
||||
mock_artifact.title = "Podcast"
|
||||
mock_artifact.status = "completed"
|
||||
mock_artifact.created_at = datetime.utcnow()
|
||||
mock_artifact.completed_at = datetime.utcnow()
|
||||
mock_artifact.download_url = "https://example.com/download"
|
||||
|
||||
mock_notebook.artifacts.list.return_value = [mock_artifact]
|
||||
mock_client.notebooks.get.return_value = mock_notebook
|
||||
|
||||
service = ArtifactService(client=mock_client)
|
||||
|
||||
# Act
|
||||
result = await service.list_artifacts(notebook_id)
|
||||
|
||||
# Assert
|
||||
assert len(result) == 1
|
||||
assert result[0].type == "audio"
|
||||
assert result[0].status == "completed"
|
||||
|
||||
async def test_list_artifacts_empty_returns_empty_list(self):
|
||||
"""Should return empty list if no artifacts."""
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
mock_client = AsyncMock()
|
||||
mock_notebook = AsyncMock()
|
||||
mock_notebook.artifacts.list.return_value = []
|
||||
mock_client.notebooks.get.return_value = mock_notebook
|
||||
|
||||
service = ArtifactService(client=mock_client)
|
||||
|
||||
# Act
|
||||
result = await service.list_artifacts(notebook_id)
|
||||
|
||||
# Assert
|
||||
assert result == []
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestArtifactServiceGetStatus:
|
||||
"""Test suite for get_status method."""
|
||||
|
||||
async def test_get_status_returns_artifact(self):
|
||||
"""Should return artifact with status."""
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
artifact_id = str(uuid4())
|
||||
mock_client = AsyncMock()
|
||||
mock_notebook = AsyncMock()
|
||||
|
||||
mock_artifact = MagicMock()
|
||||
mock_artifact.id = artifact_id
|
||||
mock_artifact.type = "video"
|
||||
mock_artifact.title = "Video"
|
||||
mock_artifact.status = "processing"
|
||||
mock_artifact.created_at = datetime.utcnow()
|
||||
mock_artifact.completed_at = None
|
||||
mock_artifact.download_url = None
|
||||
|
||||
mock_notebook.artifacts.get.return_value = mock_artifact
|
||||
mock_client.notebooks.get.return_value = mock_notebook
|
||||
|
||||
service = ArtifactService(client=mock_client)
|
||||
|
||||
# Act
|
||||
result = await service.get_status(notebook_id, artifact_id)
|
||||
|
||||
# Assert
|
||||
assert str(result.id) == artifact_id
|
||||
assert result.type == "video"
|
||||
assert result.status == "processing"
|
||||
|
||||
async def test_get_status_artifact_not_found_raises_not_found(self):
|
||||
"""Should raise NotFoundError if artifact not found."""
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
artifact_id = str(uuid4())
|
||||
mock_client = AsyncMock()
|
||||
mock_notebook = AsyncMock()
|
||||
mock_notebook.artifacts.get.side_effect = Exception("artifact not found")
|
||||
mock_client.notebooks.get.return_value = mock_notebook
|
||||
|
||||
service = ArtifactService(client=mock_client)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(NotFoundError) as exc_info:
|
||||
await service.get_status(notebook_id, artifact_id)
|
||||
|
||||
assert artifact_id in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestArtifactServiceEstimatedTimes:
|
||||
"""Test suite for estimated times."""
|
||||
|
||||
def test_estimated_times_defined(self):
|
||||
"""Should have estimated times for all artifact types."""
|
||||
# Arrange
|
||||
service = ArtifactService()
|
||||
|
||||
# Assert
|
||||
assert service.ESTIMATED_TIMES["audio"] == 600
|
||||
assert service.ESTIMATED_TIMES["video"] == 1800
|
||||
assert service.ESTIMATED_TIMES["mind-map"] == 10
|
||||
Reference in New Issue
Block a user