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:
102
tests/unit/test_core/test_config.py
Normal file
102
tests/unit/test_core/test_config.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Tests for core configuration."""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from notebooklm_agent.core.config import Settings, get_settings
|
||||
from notebooklm_agent.core.exceptions import NotebookLMAgentError, ValidationError
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSettings:
|
||||
"""Test suite for Settings configuration."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""Should create settings with default values."""
|
||||
# Arrange & Act
|
||||
settings = Settings()
|
||||
|
||||
# Assert
|
||||
assert settings.port == 8000
|
||||
assert settings.host == "0.0.0.0"
|
||||
assert settings.log_level == "INFO"
|
||||
assert settings.debug is False
|
||||
assert settings.testing is False
|
||||
|
||||
def test_custom_values_from_env(self, monkeypatch):
|
||||
"""Should load custom values from environment variables."""
|
||||
# Arrange
|
||||
monkeypatch.setenv("NOTEBOOKLM_AGENT_PORT", "9000")
|
||||
monkeypatch.setenv("NOTEBOOKLM_AGENT_HOST", "127.0.0.1")
|
||||
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
|
||||
monkeypatch.setenv("DEBUG", "true")
|
||||
|
||||
# Act
|
||||
settings = Settings()
|
||||
|
||||
# Assert
|
||||
assert settings.port == 9000
|
||||
assert settings.host == "127.0.0.1"
|
||||
assert settings.log_level == "DEBUG"
|
||||
assert settings.debug is True
|
||||
|
||||
def test_cors_origins_parsing_from_string(self):
|
||||
"""Should parse CORS origins from comma-separated string."""
|
||||
# Arrange & Act
|
||||
settings = Settings(cors_origins="http://localhost:3000, http://localhost:8080")
|
||||
|
||||
# Assert
|
||||
assert settings.cors_origins == ["http://localhost:3000", "http://localhost:8080"]
|
||||
|
||||
def test_cors_origins_from_list(self):
|
||||
"""Should accept CORS origins as list."""
|
||||
# Arrange & Act
|
||||
origins = ["http://localhost:3000", "http://localhost:8080"]
|
||||
settings = Settings(cors_origins=origins)
|
||||
|
||||
# Assert
|
||||
assert settings.cors_origins == origins
|
||||
|
||||
def test_log_level_validation_valid(self):
|
||||
"""Should accept valid log levels."""
|
||||
# Arrange & Act & Assert
|
||||
for level in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
|
||||
settings = Settings(log_level=level)
|
||||
assert settings.log_level == level
|
||||
|
||||
def test_log_level_validation_invalid(self):
|
||||
"""Should reject invalid log levels."""
|
||||
# Arrange & Act & Assert
|
||||
with pytest.raises(ValueError, match="log_level must be one of"):
|
||||
Settings(log_level="INVALID")
|
||||
|
||||
def test_is_production_property(self):
|
||||
"""Should correctly identify production mode."""
|
||||
# Arrange & Act & Assert
|
||||
assert Settings(debug=False, testing=False).is_production is True
|
||||
assert Settings(debug=True, testing=False).is_production is False
|
||||
assert Settings(debug=False, testing=True).is_production is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetSettings:
|
||||
"""Test suite for get_settings function."""
|
||||
|
||||
def test_returns_settings_instance(self):
|
||||
"""Should return a Settings instance."""
|
||||
# Arrange & Act
|
||||
settings = get_settings()
|
||||
|
||||
# Assert
|
||||
assert isinstance(settings, Settings)
|
||||
|
||||
def test_caching(self):
|
||||
"""Should cache settings instance."""
|
||||
# Arrange & Act
|
||||
settings1 = get_settings()
|
||||
settings2 = get_settings()
|
||||
|
||||
# Assert - same instance due to lru_cache
|
||||
assert settings1 is settings2
|
||||
161
tests/unit/test_core/test_exceptions.py
Normal file
161
tests/unit/test_core/test_exceptions.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Tests for core exceptions."""
|
||||
|
||||
import pytest
|
||||
|
||||
from notebooklm_agent.core.exceptions import (
|
||||
AuthenticationError,
|
||||
NotebookLMAgentError,
|
||||
NotebookLMError,
|
||||
NotFoundError,
|
||||
RateLimitError,
|
||||
ValidationError,
|
||||
WebhookError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNotebookLMAgentError:
|
||||
"""Test suite for base exception."""
|
||||
|
||||
def test_default_code(self):
|
||||
"""Should have default error code."""
|
||||
# Arrange & Act
|
||||
error = NotebookLMAgentError("Test message")
|
||||
|
||||
# Assert
|
||||
assert error.code == "AGENT_ERROR"
|
||||
assert str(error) == "Test message"
|
||||
|
||||
def test_custom_code(self):
|
||||
"""Should accept custom error code."""
|
||||
# Arrange & Act
|
||||
error = NotebookLMAgentError("Test message", code="CUSTOM_ERROR")
|
||||
|
||||
# Assert
|
||||
assert error.code == "CUSTOM_ERROR"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestValidationError:
|
||||
"""Test suite for ValidationError."""
|
||||
|
||||
def test_default_details(self):
|
||||
"""Should have empty details by default."""
|
||||
# Arrange & Act
|
||||
error = ValidationError("Validation failed")
|
||||
|
||||
# Assert
|
||||
assert error.code == "VALIDATION_ERROR"
|
||||
assert error.details == []
|
||||
|
||||
def test_with_details(self):
|
||||
"""Should store validation details."""
|
||||
# Arrange
|
||||
details = [{"field": "title", "error": "Required"}]
|
||||
|
||||
# Act
|
||||
error = ValidationError("Validation failed", details=details)
|
||||
|
||||
# Assert
|
||||
assert error.details == details
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAuthenticationError:
|
||||
"""Test suite for AuthenticationError."""
|
||||
|
||||
def test_default_message(self):
|
||||
"""Should have default error message."""
|
||||
# Arrange & Act
|
||||
error = AuthenticationError()
|
||||
|
||||
# Assert
|
||||
assert error.code == "AUTH_ERROR"
|
||||
assert str(error) == "Authentication failed"
|
||||
|
||||
def test_custom_message(self):
|
||||
"""Should accept custom message."""
|
||||
# Arrange & Act
|
||||
error = AuthenticationError("Custom auth error")
|
||||
|
||||
# Assert
|
||||
assert str(error) == "Custom auth error"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNotFoundError:
|
||||
"""Test suite for NotFoundError."""
|
||||
|
||||
def test_message_formatting(self):
|
||||
"""Should format message with resource info."""
|
||||
# Arrange & Act
|
||||
error = NotFoundError("Notebook", "abc123")
|
||||
|
||||
# Assert
|
||||
assert error.code == "NOT_FOUND"
|
||||
assert error.resource == "Notebook"
|
||||
assert error.resource_id == "abc123"
|
||||
assert "Notebook" in str(error)
|
||||
assert "abc123" in str(error)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNotebookLMError:
|
||||
"""Test suite for NotebookLMError."""
|
||||
|
||||
def test_stores_original_error(self):
|
||||
"""Should store original exception."""
|
||||
# Arrange
|
||||
original = ValueError("Original error")
|
||||
|
||||
# Act
|
||||
error = NotebookLMError("NotebookLM failed", original_error=original)
|
||||
|
||||
# Assert
|
||||
assert error.code == "NOTEBOOKLM_ERROR"
|
||||
assert error.original_error is original
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRateLimitError:
|
||||
"""Test suite for RateLimitError."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""Should have default message and no retry_after."""
|
||||
# Arrange & Act
|
||||
error = RateLimitError()
|
||||
|
||||
# Assert
|
||||
assert error.code == "RATE_LIMITED"
|
||||
assert str(error) == "Rate limit exceeded"
|
||||
assert error.retry_after is None
|
||||
|
||||
def test_with_retry_after(self):
|
||||
"""Should store retry_after value."""
|
||||
# Arrange & Act
|
||||
error = RateLimitError(retry_after=60)
|
||||
|
||||
# Assert
|
||||
assert error.retry_after == 60
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestWebhookError:
|
||||
"""Test suite for WebhookError."""
|
||||
|
||||
def test_without_webhook_id(self):
|
||||
"""Should work without webhook_id."""
|
||||
# Arrange & Act
|
||||
error = WebhookError("Webhook failed")
|
||||
|
||||
# Assert
|
||||
assert error.code == "WEBHOOK_ERROR"
|
||||
assert error.webhook_id is None
|
||||
|
||||
def test_with_webhook_id(self):
|
||||
"""Should store webhook_id."""
|
||||
# Arrange & Act
|
||||
error = WebhookError("Webhook failed", webhook_id="webhook123")
|
||||
|
||||
# Assert
|
||||
assert error.webhook_id == "webhook123"
|
||||
71
tests/unit/test_core/test_logging.py
Normal file
71
tests/unit/test_core/test_logging.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Tests for logging module."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import structlog
|
||||
|
||||
from notebooklm_agent.core.config import Settings
|
||||
from notebooklm_agent.core.logging import setup_logging
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSetupLogging:
|
||||
"""Test suite for setup_logging function."""
|
||||
|
||||
@patch("notebooklm_agent.core.logging.structlog.configure")
|
||||
@patch("notebooklm_agent.core.logging.logging.basicConfig")
|
||||
def test_configures_structlog(self, mock_basic_config, mock_structlog_configure):
|
||||
"""Should configure structlog with correct processors."""
|
||||
# Arrange
|
||||
settings = Settings(log_level="INFO", log_format="json")
|
||||
|
||||
# Act
|
||||
setup_logging(settings)
|
||||
|
||||
# Assert
|
||||
mock_structlog_configure.assert_called_once()
|
||||
call_args = mock_structlog_configure.call_args
|
||||
assert "processors" in call_args.kwargs
|
||||
|
||||
@patch("notebooklm_agent.core.logging.structlog.configure")
|
||||
@patch("notebooklm_agent.core.logging.logging.basicConfig")
|
||||
def test_uses_json_renderer_for_json_format(self, mock_basic_config, mock_structlog_configure):
|
||||
"""Should use JSONRenderer for json log format."""
|
||||
# Arrange
|
||||
settings = Settings(log_level="INFO", log_format="json")
|
||||
|
||||
# Act
|
||||
setup_logging(settings)
|
||||
|
||||
# Assert
|
||||
processors = mock_structlog_configure.call_args.kwargs["processors"]
|
||||
assert any("JSONRenderer" in str(p) for p in processors)
|
||||
|
||||
@patch("notebooklm_agent.core.logging.structlog.configure")
|
||||
@patch("notebooklm_agent.core.logging.logging.basicConfig")
|
||||
def test_uses_console_renderer_for_console_format(self, mock_basic_config, mock_structlog_configure):
|
||||
"""Should use ConsoleRenderer for console log format."""
|
||||
# Arrange
|
||||
settings = Settings(log_level="INFO", log_format="console")
|
||||
|
||||
# Act
|
||||
setup_logging(settings)
|
||||
|
||||
# Assert
|
||||
processors = mock_structlog_configure.call_args.kwargs["processors"]
|
||||
assert any("ConsoleRenderer" in str(p) for p in processors)
|
||||
|
||||
@patch("notebooklm_agent.core.logging.structlog.configure")
|
||||
@patch("notebooklm_agent.core.logging.logging.basicConfig")
|
||||
def test_sets_uvicorn_log_level(self, mock_basic_config, mock_structlog_configure):
|
||||
"""Should set uvicorn loggers to WARNING."""
|
||||
# Arrange
|
||||
settings = Settings(log_level="INFO", log_format="json")
|
||||
|
||||
# Act
|
||||
setup_logging(settings)
|
||||
|
||||
# Assert
|
||||
# basicConfig should be called
|
||||
mock_basic_config.assert_called_once()
|
||||
Reference in New Issue
Block a user