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