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:
Luca Sacchi Ricciardi
2026-04-06 01:58:47 +02:00
parent 081f3f0d89
commit 83fd30a2a2
8 changed files with 2184 additions and 1 deletions

View 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

View 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