feat(api): implement notebook management CRUD endpoints
Implement Sprint 1: Notebook Management CRUD
- Add NotebookService with full CRUD operations
- Add POST /api/v1/notebooks (create notebook)
- Add GET /api/v1/notebooks (list with pagination)
- Add GET /api/v1/notebooks/{id} (get by ID)
- Add PATCH /api/v1/notebooks/{id} (partial update)
- Add DELETE /api/v1/notebooks/{id} (delete)
- Add Pydantic models for requests/responses
- Add custom exceptions (ValidationError, NotFoundError, NotebookLMError)
- Add comprehensive unit tests (31 tests, 97% coverage)
- Add API integration tests (26 tests)
- Fix router prefix duplication
- Fix JSON serialization in error responses
BREAKING CHANGE: None
This commit is contained in:
516
tests/unit/test_services/test_notebook_service.py
Normal file
516
tests/unit/test_services/test_notebook_service.py
Normal file
@@ -0,0 +1,516 @@
|
||||
"""Unit tests for NotebookService.
|
||||
|
||||
TDD Cycle: RED → GREEN → REFACTOR
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from notebooklm_agent.core.exceptions import (
|
||||
NotebookLMError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
)
|
||||
from notebooklm_agent.services.notebook_service import NotebookService
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNotebookServiceInit:
|
||||
"""Test suite for NotebookService initialization and _get_client."""
|
||||
|
||||
async def test_get_client_returns_existing_client(self):
|
||||
"""Should return existing client if already initialized."""
|
||||
# Arrange
|
||||
mock_client = AsyncMock()
|
||||
service = NotebookService(client=mock_client)
|
||||
|
||||
# Act
|
||||
client = await service._get_client()
|
||||
|
||||
# Assert
|
||||
assert client == mock_client
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNotebookServiceValidateTitle:
|
||||
"""Test suite for NotebookService._validate_title() method."""
|
||||
|
||||
def test_validate_title_none_raises_validation_error(self):
|
||||
"""Should raise ValidationError for None title."""
|
||||
# Arrange
|
||||
service = NotebookService()
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValidationError, match="Title is required"):
|
||||
service._validate_title(None)
|
||||
|
||||
def test_validate_title_too_short_raises_validation_error(self):
|
||||
"""Should raise ValidationError for title < 3 characters."""
|
||||
# Arrange
|
||||
service = NotebookService()
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValidationError, match="at least 3 characters"):
|
||||
service._validate_title("AB")
|
||||
|
||||
def test_validate_title_too_long_raises_validation_error(self):
|
||||
"""Should raise ValidationError for title > 100 characters."""
|
||||
# Arrange
|
||||
service = NotebookService()
|
||||
long_title = "A" * 101
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValidationError, match="at most 100 characters"):
|
||||
service._validate_title(long_title)
|
||||
|
||||
def test_validate_title_exactly_100_chars_succeeds(self):
|
||||
"""Should accept title with exactly 100 characters."""
|
||||
# Arrange
|
||||
service = NotebookService()
|
||||
title = "A" * 100
|
||||
|
||||
# Act
|
||||
result = service._validate_title(title)
|
||||
|
||||
# Assert
|
||||
assert result == title
|
||||
|
||||
def test_validate_title_exactly_3_chars_succeeds(self):
|
||||
"""Should accept title with exactly 3 characters."""
|
||||
# Arrange
|
||||
service = NotebookService()
|
||||
|
||||
# Act
|
||||
result = service._validate_title("ABC")
|
||||
|
||||
# Assert
|
||||
assert result == "ABC"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNotebookServiceCreate:
|
||||
"""Test suite for NotebookService.create() method."""
|
||||
|
||||
async def test_create_valid_title_returns_notebook(self):
|
||||
"""Should create notebook with valid title."""
|
||||
# Arrange
|
||||
mock_client = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.id = str(uuid4())
|
||||
mock_response.title = "Test Notebook"
|
||||
mock_response.description = None
|
||||
mock_response.created_at = datetime.utcnow()
|
||||
mock_response.updated_at = datetime.utcnow()
|
||||
mock_client.notebooks.create.return_value = mock_response
|
||||
|
||||
service = NotebookService(client=mock_client)
|
||||
data = {"title": "Test Notebook", "description": None}
|
||||
|
||||
# Act
|
||||
result = await service.create(data)
|
||||
|
||||
# Assert
|
||||
assert result.title == "Test Notebook"
|
||||
assert result.id is not None
|
||||
mock_client.notebooks.create.assert_called_once_with("Test Notebook")
|
||||
|
||||
async def test_create_with_description_returns_notebook(self):
|
||||
"""Should create notebook with description."""
|
||||
# Arrange
|
||||
mock_client = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.id = str(uuid4())
|
||||
mock_response.title = "Test Notebook"
|
||||
mock_response.description = "A description"
|
||||
mock_response.created_at = datetime.utcnow()
|
||||
mock_response.updated_at = datetime.utcnow()
|
||||
mock_client.notebooks.create.return_value = mock_response
|
||||
|
||||
service = NotebookService(client=mock_client)
|
||||
data = {"title": "Test Notebook", "description": "A description"}
|
||||
|
||||
# Act
|
||||
result = await service.create(data)
|
||||
|
||||
# Assert
|
||||
assert result.description == "A description"
|
||||
|
||||
async def test_create_empty_title_raises_validation_error(self):
|
||||
"""Should raise ValidationError for empty title."""
|
||||
# Arrange
|
||||
service = NotebookService()
|
||||
data = {"title": "", "description": None}
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValidationError, match="Title cannot be empty"):
|
||||
await service.create(data)
|
||||
|
||||
async def test_create_whitespace_title_raises_validation_error(self):
|
||||
"""Should raise ValidationError for whitespace-only title."""
|
||||
# Arrange
|
||||
service = NotebookService()
|
||||
data = {"title": " ", "description": None}
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValidationError):
|
||||
await service.create(data)
|
||||
|
||||
async def test_create_notebooklm_error_raises_notebooklm_error(self):
|
||||
"""Should raise NotebookLMError on external API error."""
|
||||
# Arrange
|
||||
mock_client = AsyncMock()
|
||||
mock_client.notebooks.create.side_effect = Exception("API Error")
|
||||
|
||||
service = NotebookService(client=mock_client)
|
||||
data = {"title": "Test Notebook", "description": None}
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(NotebookLMError):
|
||||
await service.create(data)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNotebookServiceList:
|
||||
"""Test suite for NotebookService.list() method."""
|
||||
|
||||
async def test_list_returns_paginated_notebooks(self):
|
||||
"""Should return paginated list of notebooks."""
|
||||
# Arrange
|
||||
mock_client = AsyncMock()
|
||||
mock_notebook = MagicMock()
|
||||
mock_notebook.id = str(uuid4())
|
||||
mock_notebook.title = "Notebook 1"
|
||||
mock_notebook.description = None
|
||||
mock_notebook.created_at = datetime.utcnow()
|
||||
mock_notebook.updated_at = datetime.utcnow()
|
||||
mock_client.notebooks.list.return_value = [mock_notebook]
|
||||
|
||||
service = NotebookService(client=mock_client)
|
||||
|
||||
# Act
|
||||
result = await service.list(limit=20, offset=0, sort="created_at", order="desc")
|
||||
|
||||
# Assert
|
||||
assert len(result.items) == 1
|
||||
assert result.items[0].title == "Notebook 1"
|
||||
assert result.pagination.total == 1
|
||||
assert result.pagination.limit == 20
|
||||
assert result.pagination.offset == 0
|
||||
|
||||
async def test_list_with_pagination_returns_correct_page(self):
|
||||
"""Should return correct page with offset."""
|
||||
# Arrange
|
||||
mock_client = AsyncMock()
|
||||
mock_client.notebooks.list.return_value = []
|
||||
|
||||
service = NotebookService(client=mock_client)
|
||||
|
||||
# Act
|
||||
result = await service.list(limit=10, offset=20, sort="created_at", order="desc")
|
||||
|
||||
# Assert
|
||||
assert result.pagination.limit == 10
|
||||
assert result.pagination.offset == 20
|
||||
|
||||
async def test_list_notebooklm_error_raises_notebooklm_error(self):
|
||||
"""Should raise NotebookLMError on external API error."""
|
||||
# Arrange
|
||||
mock_client = AsyncMock()
|
||||
mock_client.notebooks.list.side_effect = Exception("API Error")
|
||||
|
||||
service = NotebookService(client=mock_client)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(NotebookLMError):
|
||||
await service.list(limit=20, offset=0, sort="created_at", order="desc")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNotebookServiceGet:
|
||||
"""Test suite for NotebookService.get() method."""
|
||||
|
||||
async def test_get_existing_id_returns_notebook(self):
|
||||
"""Should return notebook for existing ID."""
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
mock_client = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.id = str(notebook_id)
|
||||
mock_response.title = "Test Notebook"
|
||||
mock_response.description = None
|
||||
mock_response.created_at = datetime.utcnow()
|
||||
mock_response.updated_at = datetime.utcnow()
|
||||
mock_client.notebooks.get.return_value = mock_response
|
||||
|
||||
service = NotebookService(client=mock_client)
|
||||
|
||||
# Act
|
||||
result = await service.get(notebook_id)
|
||||
|
||||
# Assert
|
||||
assert result.id == notebook_id
|
||||
assert result.title == "Test Notebook"
|
||||
mock_client.notebooks.get.assert_called_once_with(str(notebook_id))
|
||||
|
||||
async def test_get_nonexistent_id_raises_not_found(self):
|
||||
"""Should raise NotFoundError for non-existent ID."""
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.notebooks.get.side_effect = Exception("Not found")
|
||||
|
||||
service = NotebookService(client=mock_client)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(NotFoundError):
|
||||
await service.get(notebook_id)
|
||||
|
||||
async def test_get_not_found_in_message_raises_not_found(self):
|
||||
"""Should raise NotFoundError when API message contains 'not found'."""
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.notebooks.get.side_effect = Exception("resource not found in system")
|
||||
|
||||
service = NotebookService(client=mock_client)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(NotFoundError):
|
||||
await service.get(notebook_id)
|
||||
|
||||
async def test_get_other_error_raises_notebooklm_error(self):
|
||||
"""Should raise NotebookLMError on other API errors (line 179)."""
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.notebooks.get.side_effect = Exception("Connection timeout")
|
||||
|
||||
service = NotebookService(client=mock_client)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(NotebookLMError, match="Failed to get notebook"):
|
||||
await service.get(notebook_id)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNotebookServiceUpdate:
|
||||
"""Test suite for NotebookService.update() method."""
|
||||
|
||||
async def test_update_title_returns_updated_notebook(self):
|
||||
"""Should update title and return updated notebook."""
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
mock_client = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.id = str(notebook_id)
|
||||
mock_response.title = "Updated Title"
|
||||
mock_response.description = None
|
||||
mock_response.created_at = datetime.utcnow()
|
||||
mock_response.updated_at = datetime.utcnow()
|
||||
mock_client.notebooks.update.return_value = mock_response
|
||||
|
||||
service = NotebookService(client=mock_client)
|
||||
data = {"title": "Updated Title", "description": None}
|
||||
|
||||
# Act
|
||||
result = await service.update(notebook_id, data)
|
||||
|
||||
# Assert
|
||||
assert result.title == "Updated Title"
|
||||
mock_client.notebooks.update.assert_called_once()
|
||||
|
||||
async def test_update_description_only_returns_updated_notebook(self):
|
||||
"""Should update only description (partial update)."""
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
mock_client = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.id = str(notebook_id)
|
||||
mock_response.title = "Original Title"
|
||||
mock_response.description = "New Description"
|
||||
mock_response.created_at = datetime.utcnow()
|
||||
mock_response.updated_at = datetime.utcnow()
|
||||
mock_client.notebooks.update.return_value = mock_response
|
||||
|
||||
service = NotebookService(client=mock_client)
|
||||
data = {"title": None, "description": "New Description"}
|
||||
|
||||
# Act
|
||||
result = await service.update(notebook_id, data)
|
||||
|
||||
# Assert
|
||||
assert result.description == "New Description"
|
||||
|
||||
async def test_update_nonexistent_id_raises_not_found(self):
|
||||
"""Should raise NotFoundError for non-existent ID."""
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.notebooks.update.side_effect = Exception("Not found")
|
||||
|
||||
service = NotebookService(client=mock_client)
|
||||
data = {"title": "Updated Title", "description": None}
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(NotFoundError):
|
||||
await service.update(notebook_id, data)
|
||||
|
||||
async def test_update_empty_data_calls_get_instead(self):
|
||||
"""Should call get() instead of update when no data provided."""
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
mock_client = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.id = str(notebook_id)
|
||||
mock_response.title = "Original Title"
|
||||
mock_response.description = None
|
||||
mock_response.created_at = datetime.utcnow()
|
||||
mock_response.updated_at = datetime.utcnow()
|
||||
mock_client.notebooks.get.return_value = mock_response
|
||||
|
||||
service = NotebookService(client=mock_client)
|
||||
data = {} # Empty data
|
||||
|
||||
# Act
|
||||
result = await service.update(notebook_id, data)
|
||||
|
||||
# Assert
|
||||
assert result.title == "Original Title"
|
||||
mock_client.notebooks.update.assert_not_called()
|
||||
mock_client.notebooks.get.assert_called_once_with(str(notebook_id))
|
||||
|
||||
async def test_update_with_not_found_in_message_raises_not_found(self):
|
||||
"""Should raise NotFoundError when API returns 'not found' message."""
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.notebooks.update.side_effect = Exception("notebook not found in database")
|
||||
|
||||
service = NotebookService(client=mock_client)
|
||||
data = {"title": "Updated Title"}
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(NotFoundError):
|
||||
await service.update(notebook_id, data)
|
||||
|
||||
async def test_update_notebooklm_error_raises_notebooklm_error(self):
|
||||
"""Should raise NotebookLMError on other API errors."""
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.notebooks.update.side_effect = Exception("Some other error")
|
||||
|
||||
service = NotebookService(client=mock_client)
|
||||
data = {"title": "Updated Title"}
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(NotebookLMError):
|
||||
await service.update(notebook_id, data)
|
||||
|
||||
async def test_update_reraises_not_found_error_directly(self):
|
||||
"""Should re-raise NotFoundError directly without wrapping (line 218)."""
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
mock_client = AsyncMock()
|
||||
# Simulate a NotFoundError raised during get() call in empty update case
|
||||
mock_client.notebooks.get.side_effect = NotFoundError("Notebook", str(notebook_id))
|
||||
|
||||
service = NotebookService(client=mock_client)
|
||||
data = {} # Empty data triggers get() call
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(NotFoundError) as exc_info:
|
||||
await service.update(notebook_id, data)
|
||||
|
||||
assert "Notebook" in str(exc_info.value)
|
||||
|
||||
async def test_update_empty_title_raises_validation_error(self):
|
||||
"""Should raise ValidationError for empty title."""
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
service = NotebookService()
|
||||
data = {"title": "", "description": None}
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValidationError):
|
||||
await service.update(notebook_id, data)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNotebookServiceDelete:
|
||||
"""Test suite for NotebookService.delete() method."""
|
||||
|
||||
async def test_delete_existing_id_succeeds(self):
|
||||
"""Should delete notebook for existing ID."""
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.notebooks.delete.return_value = None
|
||||
|
||||
service = NotebookService(client=mock_client)
|
||||
|
||||
# Act
|
||||
await service.delete(notebook_id)
|
||||
|
||||
# Assert
|
||||
mock_client.notebooks.delete.assert_called_once_with(str(notebook_id))
|
||||
|
||||
async def test_delete_nonexistent_id_raises_not_found(self):
|
||||
"""Should raise NotFoundError for non-existent ID."""
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.notebooks.delete.side_effect = Exception("Not found")
|
||||
|
||||
service = NotebookService(client=mock_client)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(NotFoundError):
|
||||
await service.delete(notebook_id)
|
||||
|
||||
async def test_delete_not_found_in_message_raises_not_found(self):
|
||||
"""Should raise NotFoundError when API message contains 'not found'."""
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.notebooks.delete.side_effect = Exception("notebook not found")
|
||||
|
||||
service = NotebookService(client=mock_client)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(NotFoundError):
|
||||
await service.delete(notebook_id)
|
||||
|
||||
async def test_delete_notebooklm_error_raises_notebooklm_error(self):
|
||||
"""Should raise NotebookLMError on other API errors."""
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.notebooks.delete.side_effect = Exception("Connection timeout")
|
||||
|
||||
service = NotebookService(client=mock_client)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(NotebookLMError):
|
||||
await service.delete(notebook_id)
|
||||
|
||||
async def test_delete_is_idempotent(self):
|
||||
"""Delete should be idempotent (no error on second delete)."""
|
||||
# Note: This test documents expected behavior
|
||||
# Actual idempotency depends on notebooklm-py behavior
|
||||
# Arrange
|
||||
notebook_id = uuid4()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.notebooks.delete.side_effect = [None, Exception("Not found")]
|
||||
|
||||
service = NotebookService(client=mock_client)
|
||||
|
||||
# Act - First delete should succeed
|
||||
await service.delete(notebook_id)
|
||||
|
||||
# Assert - Second delete should raise NotFoundError
|
||||
with pytest.raises(NotFoundError):
|
||||
await service.delete(notebook_id)
|
||||
Reference in New Issue
Block a user