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:
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Placeholder for tests package
|
||||
1
tests/e2e/__init__.py
Normal file
1
tests/e2e/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Placeholder for e2e tests package
|
||||
22
tests/e2e/test_workflows/test_full_workflow.py
Normal file
22
tests/e2e/test_workflows/test_full_workflow.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""E2E tests placeholder.
|
||||
|
||||
These tests will verify complete workflows with real NotebookLM API.
|
||||
Requires authentication and should be run manually.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestFullWorkflow:
|
||||
"""End-to-end workflow tests."""
|
||||
|
||||
async def test_research_to_podcast_workflow(self):
|
||||
"""Should complete full research to podcast workflow."""
|
||||
# TODO: Implement E2E test
|
||||
# 1. Create notebook
|
||||
# 2. Add sources
|
||||
# 3. Generate audio
|
||||
# 4. Wait for completion
|
||||
# 5. Download artifact
|
||||
pytest.skip("E2E tests require NotebookLM authentication")
|
||||
1
tests/integration/__init__.py
Normal file
1
tests/integration/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Placeholder for integration tests package
|
||||
31
tests/integration/test_api/test_notebooks.py
Normal file
31
tests/integration/test_api/test_notebooks.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Integration tests placeholder.
|
||||
|
||||
These tests will verify the API endpoints with mocked external dependencies.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestApiHealth:
|
||||
"""Health check endpoint tests."""
|
||||
|
||||
async def test_health_endpoint_returns_ok(self):
|
||||
"""Should return OK status for health check."""
|
||||
# TODO: Implement when API is ready
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestNotebooksApi:
|
||||
"""Notebook API endpoint tests."""
|
||||
|
||||
async def test_create_notebook(self):
|
||||
"""Should create notebook via API."""
|
||||
# TODO: Implement when API is ready
|
||||
pass
|
||||
|
||||
async def test_list_notebooks(self):
|
||||
"""Should list notebooks via API."""
|
||||
# TODO: Implement when API is ready
|
||||
pass
|
||||
1
tests/unit/__init__.py
Normal file
1
tests/unit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Placeholder for unit tests package
|
||||
1
tests/unit/test_api/__init__.py
Normal file
1
tests/unit/test_api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Placeholder for API tests package
|
||||
56
tests/unit/test_api/test_health.py
Normal file
56
tests/unit/test_api/test_health.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Tests for health routes."""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from notebooklm_agent.api.main import app
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestHealthEndpoints:
|
||||
"""Test suite for health check endpoints."""
|
||||
|
||||
def test_health_check_returns_healthy(self):
|
||||
"""Should return healthy status."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
|
||||
# Act
|
||||
response = client.get("/health/")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "healthy"
|
||||
assert "timestamp" in data
|
||||
assert data["service"] == "notebooklm-agent-api"
|
||||
assert data["version"] == "0.1.0"
|
||||
|
||||
def test_readiness_check_returns_ready(self):
|
||||
"""Should return ready status."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
|
||||
# Act
|
||||
response = client.get("/health/ready")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ready"
|
||||
assert "timestamp" in data
|
||||
assert "checks" in data
|
||||
|
||||
def test_liveness_check_returns_alive(self):
|
||||
"""Should return alive status."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
|
||||
# Act
|
||||
response = client.get("/health/live")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "alive"
|
||||
assert "timestamp" in data
|
||||
51
tests/unit/test_api/test_main.py
Normal file
51
tests/unit/test_api/test_main.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Tests for API main module."""
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from notebooklm_agent.api.main import app, create_application
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCreateApplication:
|
||||
"""Test suite for create_application function."""
|
||||
|
||||
def test_returns_fastapi_instance(self):
|
||||
"""Should return FastAPI application instance."""
|
||||
# Act
|
||||
application = create_application()
|
||||
|
||||
# Assert
|
||||
assert isinstance(application, FastAPI)
|
||||
assert application.title == "NotebookLM Agent API"
|
||||
assert application.version == "0.1.0"
|
||||
|
||||
def test_includes_health_router(self):
|
||||
"""Should include health check router."""
|
||||
# Act
|
||||
application = create_application()
|
||||
|
||||
# Assert
|
||||
routes = [route.path for route in application.routes]
|
||||
assert any("/health" in route for route in routes)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestRootEndpoint:
|
||||
"""Test suite for root endpoint."""
|
||||
|
||||
def test_root_returns_api_info(self):
|
||||
"""Should return API information."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
|
||||
# Act
|
||||
response = client.get("/")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "NotebookLM Agent API"
|
||||
assert data["version"] == "0.1.0"
|
||||
assert "/docs" in data["documentation"]
|
||||
639
tests/unit/test_api/test_notebooks.py
Normal file
639
tests/unit/test_api/test_notebooks.py
Normal file
@@ -0,0 +1,639 @@
|
||||
"""Tests for notebooks API routes.
|
||||
|
||||
TDD for DEV-002: POST /api/v1/notebooks
|
||||
TDD for DEV-003: GET /api/v1/notebooks
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from notebooklm_agent.api.main import app
|
||||
from notebooklm_agent.core.exceptions import ValidationError
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCreateNotebookEndpoint:
|
||||
"""Test suite for POST /api/v1/notebooks endpoint."""
|
||||
|
||||
def test_create_notebook_valid_data_returns_201(self):
|
||||
"""Should return 201 Created for valid notebook data."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
notebook_id = str(uuid4())
|
||||
|
||||
with patch("notebooklm_agent.api.routes.notebooks.NotebookService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.id = notebook_id
|
||||
mock_response.title = "Test Notebook"
|
||||
mock_response.description = None
|
||||
mock_response.created_at = datetime.utcnow()
|
||||
mock_response.updated_at = datetime.utcnow()
|
||||
mock_service.create.return_value = mock_response
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.post(
|
||||
"/api/v1/notebooks",
|
||||
json={"title": "Test Notebook", "description": None},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["data"]["title"] == "Test Notebook"
|
||||
assert data["data"]["id"] == notebook_id
|
||||
|
||||
def test_create_notebook_with_description_returns_201(self):
|
||||
"""Should return 201 for notebook with description."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
notebook_id = str(uuid4())
|
||||
|
||||
with patch("notebooklm_agent.api.routes.notebooks.NotebookService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.id = notebook_id
|
||||
mock_response.title = "Test Notebook"
|
||||
mock_response.description = "A description"
|
||||
mock_response.created_at = datetime.utcnow()
|
||||
mock_response.updated_at = datetime.utcnow()
|
||||
mock_service.create.return_value = mock_response
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.post(
|
||||
"/api/v1/notebooks",
|
||||
json={"title": "Test Notebook", "description": "A description"},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["data"]["description"] == "A description"
|
||||
|
||||
def test_create_notebook_short_title_returns_400(self):
|
||||
"""Should return 400 for title too short."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
|
||||
with patch("notebooklm_agent.api.routes.notebooks.NotebookService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
mock_service.create.side_effect = ValidationError("Title must be at least 3 characters")
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.post(
|
||||
"/api/v1/notebooks",
|
||||
json={"title": "AB", "description": None},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert data["error"]["code"] == "VALIDATION_ERROR"
|
||||
|
||||
def test_create_notebook_missing_title_returns_422(self):
|
||||
"""Should return 422 for missing title (Pydantic validation)."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
|
||||
# Act
|
||||
response = client.post(
|
||||
"/api/v1/notebooks",
|
||||
json={"description": "A description"},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 422
|
||||
data = response.json()
|
||||
assert "detail" in data
|
||||
|
||||
def test_create_notebook_empty_title_returns_400(self):
|
||||
"""Should return 400 for empty title."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
|
||||
with patch("notebooklm_agent.api.routes.notebooks.NotebookService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
mock_service.create.side_effect = ValidationError("Title cannot be empty")
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.post(
|
||||
"/api/v1/notebooks",
|
||||
json={"title": "", "description": None},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
|
||||
def test_create_notebook_response_has_meta(self):
|
||||
"""Should include meta in response."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
notebook_id = str(uuid4())
|
||||
|
||||
with patch("notebooklm_agent.api.routes.notebooks.NotebookService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.id = notebook_id
|
||||
mock_response.title = "Test Notebook"
|
||||
mock_response.description = None
|
||||
mock_response.created_at = datetime.utcnow()
|
||||
mock_response.updated_at = datetime.utcnow()
|
||||
mock_service.create.return_value = mock_response
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.post(
|
||||
"/api/v1/notebooks",
|
||||
json={"title": "Test Notebook", "description": None},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert "meta" in data
|
||||
assert "timestamp" in data["meta"]
|
||||
assert "request_id" in data["meta"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestListNotebooksEndpoint:
|
||||
"""Test suite for GET /api/v1/notebooks endpoint."""
|
||||
|
||||
def test_list_notebooks_returns_200(self):
|
||||
"""Should return 200 with list of notebooks."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
|
||||
with patch("notebooklm_agent.api.routes.notebooks.NotebookService") as mock_service_class:
|
||||
mock_service = 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_paginated = MagicMock()
|
||||
mock_paginated.items = [mock_notebook]
|
||||
mock_paginated.pagination.total = 1
|
||||
mock_paginated.pagination.limit = 20
|
||||
mock_paginated.pagination.offset = 0
|
||||
mock_paginated.pagination.has_more = False
|
||||
|
||||
mock_service.list.return_value = mock_paginated
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.get("/api/v1/notebooks")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert len(data["data"]["items"]) == 1
|
||||
assert data["data"]["pagination"]["total"] == 1
|
||||
|
||||
def test_list_notebooks_with_pagination_returns_correct_page(self):
|
||||
"""Should return correct page with limit and offset."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
|
||||
with patch("notebooklm_agent.api.routes.notebooks.NotebookService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
mock_paginated = MagicMock()
|
||||
mock_paginated.items = []
|
||||
mock_paginated.pagination.total = 100
|
||||
mock_paginated.pagination.limit = 10
|
||||
mock_paginated.pagination.offset = 20
|
||||
mock_paginated.pagination.has_more = True
|
||||
|
||||
mock_service.list.return_value = mock_paginated
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.get("/api/v1/notebooks?limit=10&offset=20")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["data"]["pagination"]["limit"] == 10
|
||||
assert data["data"]["pagination"]["offset"] == 20
|
||||
assert data["data"]["pagination"]["has_more"] is True
|
||||
|
||||
def test_list_notebooks_with_sort_returns_sorted(self):
|
||||
"""Should sort notebooks by specified field."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
|
||||
with patch("notebooklm_agent.api.routes.notebooks.NotebookService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
mock_paginated = MagicMock()
|
||||
mock_paginated.items = []
|
||||
mock_paginated.pagination.total = 0
|
||||
mock_paginated.pagination.limit = 20
|
||||
mock_paginated.pagination.offset = 0
|
||||
mock_paginated.pagination.has_more = False
|
||||
|
||||
mock_service.list.return_value = mock_paginated
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.get("/api/v1/notebooks?sort=title&order=asc")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
mock_service.list.assert_called_once_with(limit=20, offset=0, sort="title", order="asc")
|
||||
|
||||
def test_list_notebooks_empty_list_returns_200(self):
|
||||
"""Should return 200 with empty list when no notebooks."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
|
||||
with patch("notebooklm_agent.api.routes.notebooks.NotebookService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
mock_paginated = MagicMock()
|
||||
mock_paginated.items = []
|
||||
mock_paginated.pagination.total = 0
|
||||
mock_paginated.pagination.limit = 20
|
||||
mock_paginated.pagination.offset = 0
|
||||
mock_paginated.pagination.has_more = False
|
||||
|
||||
mock_service.list.return_value = mock_paginated
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.get("/api/v1/notebooks")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["data"]["items"] == []
|
||||
assert data["data"]["pagination"]["total"] == 0
|
||||
|
||||
def test_list_notebooks_invalid_limit_returns_422(self):
|
||||
"""Should return 422 for invalid limit parameter."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
|
||||
# Act
|
||||
response = client.get("/api/v1/notebooks?limit=200")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_list_notebooks_invalid_sort_returns_422(self):
|
||||
"""Should return 422 for invalid sort parameter."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
|
||||
# Act
|
||||
response = client.get("/api/v1/notebooks?sort=invalid_field")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetNotebookEndpoint:
|
||||
"""Test suite for GET /api/v1/notebooks/{id} endpoint."""
|
||||
|
||||
def test_get_notebook_existing_id_returns_200(self):
|
||||
"""Should return 200 with notebook for existing ID."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
notebook_id = str(uuid4())
|
||||
|
||||
with patch("notebooklm_agent.api.routes.notebooks.NotebookService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.id = notebook_id
|
||||
mock_response.title = "Test Notebook"
|
||||
mock_response.description = "A description"
|
||||
mock_response.created_at = datetime.utcnow()
|
||||
mock_response.updated_at = datetime.utcnow()
|
||||
mock_service.get.return_value = mock_response
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.get(f"/api/v1/notebooks/{notebook_id}")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["data"]["id"] == notebook_id
|
||||
assert data["data"]["title"] == "Test Notebook"
|
||||
|
||||
def test_get_notebook_nonexistent_id_returns_404(self):
|
||||
"""Should return 404 for non-existent notebook ID."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
notebook_id = str(uuid4())
|
||||
|
||||
with patch("notebooklm_agent.api.routes.notebooks.NotebookService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
from notebooklm_agent.core.exceptions import NotFoundError
|
||||
|
||||
mock_service.get.side_effect = NotFoundError("Notebook", notebook_id)
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.get(f"/api/v1/notebooks/{notebook_id}")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert data["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
def test_get_notebook_invalid_uuid_returns_400(self):
|
||||
"""Should return 400 for invalid UUID format."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
|
||||
# Act
|
||||
response = client.get("/api/v1/notebooks/invalid-uuid")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_get_notebook_response_has_meta(self):
|
||||
"""Should include meta in response."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
notebook_id = str(uuid4())
|
||||
|
||||
with patch("notebooklm_agent.api.routes.notebooks.NotebookService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.id = notebook_id
|
||||
mock_response.title = "Test Notebook"
|
||||
mock_response.description = None
|
||||
mock_response.created_at = datetime.utcnow()
|
||||
mock_response.updated_at = datetime.utcnow()
|
||||
mock_service.get.return_value = mock_response
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.get(f"/api/v1/notebooks/{notebook_id}")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "meta" in data
|
||||
assert "timestamp" in data["meta"]
|
||||
assert "request_id" in data["meta"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeleteNotebookEndpoint:
|
||||
"""Test suite for DELETE /api/v1/notebooks/{id} endpoint."""
|
||||
|
||||
def test_delete_notebook_valid_id_returns_204(self):
|
||||
"""Should return 204 No Content for valid notebook ID."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
notebook_id = str(uuid4())
|
||||
|
||||
with patch("notebooklm_agent.api.routes.notebooks.NotebookService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
mock_service.delete.return_value = None
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.delete(f"/api/v1/notebooks/{notebook_id}")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 204
|
||||
assert response.content == b""
|
||||
|
||||
def test_delete_notebook_nonexistent_id_returns_404(self):
|
||||
"""Should return 404 for non-existent notebook ID."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
notebook_id = str(uuid4())
|
||||
|
||||
with patch("notebooklm_agent.api.routes.notebooks.NotebookService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
from notebooklm_agent.core.exceptions import NotFoundError
|
||||
|
||||
mock_service.delete.side_effect = NotFoundError("Notebook", notebook_id)
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.delete(f"/api/v1/notebooks/{notebook_id}")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert data["detail"]["success"] is False
|
||||
assert data["detail"]["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
def test_delete_notebook_invalid_uuid_returns_400(self):
|
||||
"""Should return 400 for invalid UUID format."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
|
||||
# Act
|
||||
response = client.delete("/api/v1/notebooks/invalid-uuid")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["detail"]["success"] is False
|
||||
assert data["detail"]["error"]["code"] == "VALIDATION_ERROR"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUpdateNotebookEndpoint:
|
||||
"""Test suite for PATCH /api/v1/notebooks/{id} endpoint."""
|
||||
|
||||
def test_update_notebook_title_returns_200(self):
|
||||
"""Should return 200 with updated notebook when updating title."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
notebook_id = str(uuid4())
|
||||
|
||||
with patch("notebooklm_agent.api.routes.notebooks.NotebookService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.id = notebook_id
|
||||
mock_response.title = "Updated Title"
|
||||
mock_response.description = None
|
||||
mock_response.created_at = datetime.utcnow()
|
||||
mock_response.updated_at = datetime.utcnow()
|
||||
mock_service.update.return_value = mock_response
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.patch(
|
||||
f"/api/v1/notebooks/{notebook_id}",
|
||||
json={"title": "Updated Title"},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["data"]["title"] == "Updated Title"
|
||||
|
||||
def test_update_notebook_description_only_returns_200(self):
|
||||
"""Should return 200 when updating only description."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
notebook_id = str(uuid4())
|
||||
|
||||
with patch("notebooklm_agent.api.routes.notebooks.NotebookService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.id = 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_service.update.return_value = mock_response
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.patch(
|
||||
f"/api/v1/notebooks/{notebook_id}",
|
||||
json={"description": "New Description"},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["data"]["description"] == "New Description"
|
||||
|
||||
def test_update_notebook_both_fields_returns_200(self):
|
||||
"""Should return 200 when updating both title and description."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
notebook_id = str(uuid4())
|
||||
|
||||
with patch("notebooklm_agent.api.routes.notebooks.NotebookService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.id = notebook_id
|
||||
mock_response.title = "Updated Title"
|
||||
mock_response.description = "Updated Description"
|
||||
mock_response.created_at = datetime.utcnow()
|
||||
mock_response.updated_at = datetime.utcnow()
|
||||
mock_service.update.return_value = mock_response
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.patch(
|
||||
f"/api/v1/notebooks/{notebook_id}",
|
||||
json={"title": "Updated Title", "description": "Updated Description"},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["data"]["title"] == "Updated Title"
|
||||
assert data["data"]["description"] == "Updated Description"
|
||||
|
||||
def test_update_notebook_nonexistent_id_returns_404(self):
|
||||
"""Should return 404 for non-existent notebook ID."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
notebook_id = str(uuid4())
|
||||
|
||||
with patch("notebooklm_agent.api.routes.notebooks.NotebookService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
from notebooklm_agent.core.exceptions import NotFoundError
|
||||
|
||||
mock_service.update.side_effect = NotFoundError("Notebook", notebook_id)
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.patch(
|
||||
f"/api/v1/notebooks/{notebook_id}",
|
||||
json={"title": "Updated Title"},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert data["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
def test_update_notebook_invalid_title_returns_400(self):
|
||||
"""Should return 400 for invalid title."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
notebook_id = str(uuid4())
|
||||
|
||||
with patch("notebooklm_agent.api.routes.notebooks.NotebookService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
from notebooklm_agent.core.exceptions import ValidationError
|
||||
|
||||
mock_service.update.side_effect = ValidationError("Title too short")
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.patch(
|
||||
f"/api/v1/notebooks/{notebook_id}",
|
||||
json={"title": "AB"},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert data["error"]["code"] == "VALIDATION_ERROR"
|
||||
|
||||
def test_update_notebook_invalid_uuid_returns_400(self):
|
||||
"""Should return 400 for invalid UUID format."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
|
||||
# Act
|
||||
response = client.patch(
|
||||
"/api/v1/notebooks/invalid-uuid",
|
||||
json={"title": "Updated Title"},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_update_notebook_empty_body_returns_200(self):
|
||||
"""Should return 200 with unchanged notebook for empty body."""
|
||||
# Arrange
|
||||
client = TestClient(app)
|
||||
notebook_id = str(uuid4())
|
||||
|
||||
with patch("notebooklm_agent.api.routes.notebooks.NotebookService") as mock_service_class:
|
||||
mock_service = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.id = notebook_id
|
||||
mock_response.title = "Original Title"
|
||||
mock_response.description = "Original Description"
|
||||
mock_response.created_at = datetime.utcnow()
|
||||
mock_response.updated_at = datetime.utcnow()
|
||||
mock_service.update.return_value = mock_response
|
||||
mock_service_class.return_value = mock_service
|
||||
|
||||
# Act
|
||||
response = client.patch(
|
||||
f"/api/v1/notebooks/{notebook_id}",
|
||||
json={},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
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()
|
||||
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