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