Archived
- Backend: Django REST Framework API with Bookmark and Note models - ViewSets with user-scoped querysets and select_related for N+1 prevention - Create/List/Detail/Update/Delete endpoints - Batch delete operations - Unique constraint on user+book+page for bookmarks - IsOwner permission class for object-level access control - Full serializer validation (page > 0, non-empty content, duplicate check) - 30+ pytest-django tests covering CRUD, auth, filtering, edge cases - Frontend: React TypeScript components - AnnotationsContext with useReducer for state management - BookmarkList, NoteList, AddAnnotationForm, AnnotationsDashboard - Inline note editing with immediate save - Batch delete support - API client with JWT auto-refresh interceptors - Paginated query hook for infinite scroll support - Responsive CSS with loading/empty states - Infrastructure: Django project with custom User model, JWT auth, CORS - PostgreSQL database models with proper FK and indexes - Django admin configuration for all models
331 lines
12 KiB
Python
331 lines
12 KiB
Python
"""Tests for the annotations app – Bookmarks & Notes API."""
|
||
|
||
import pytest
|
||
from django.urls import reverse
|
||
from rest_framework import status
|
||
from rest_framework.test import APIClient
|
||
|
||
from apps.annotations.models import Bookmark, Note
|
||
from apps.books.models import Book
|
||
from apps.users.models import User
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixtures
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@pytest.fixture
|
||
def api_client() -> APIClient:
|
||
return APIClient()
|
||
|
||
|
||
@pytest.fixture
|
||
def user() -> User:
|
||
return User.objects.create_user(
|
||
username="testuser",
|
||
email="test@example.com",
|
||
password="testpass123",
|
||
)
|
||
|
||
|
||
@pytest.fixture
|
||
def other_user() -> User:
|
||
return User.objects.create_user(
|
||
username="other",
|
||
email="other@example.com",
|
||
password="testpass123",
|
||
)
|
||
|
||
|
||
@pytest.fixture
|
||
def auth_client(api_client: APIClient, user: User) -> APIClient:
|
||
api_client.force_authenticate(user=user)
|
||
return api_client
|
||
|
||
|
||
@pytest.fixture
|
||
def book() -> Book:
|
||
return Book.objects.create(
|
||
title="Test Book",
|
||
author="Test Author",
|
||
total_pages=300,
|
||
)
|
||
|
||
|
||
@pytest.fixture
|
||
def bookmark(auth_client, user: User, book: Book) -> Bookmark:
|
||
return Bookmark.objects.create(
|
||
user=user,
|
||
book=book,
|
||
page=42,
|
||
location_text="important passage",
|
||
)
|
||
|
||
|
||
@pytest.fixture
|
||
def note(auth_client, user: User, book: Book) -> Note:
|
||
return Note.objects.create(
|
||
user=user,
|
||
book=book,
|
||
page=15,
|
||
location_text="highlighted section",
|
||
content="This is my note about this section.",
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Bookmark tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestBookmarkList:
|
||
url = reverse("bookmark-list")
|
||
|
||
def test_unauthenticated_user_cannot_list(self, api_client: APIClient):
|
||
response = api_client.get(self.url)
|
||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||
|
||
def test_list_returns_user_bookmarks_only(
|
||
self, auth_client: APIClient, user: User, other_user: User, book: Book
|
||
):
|
||
Bookmark.objects.create(user=user, book=book, page=1)
|
||
Bookmark.objects.create(user=other_user, book=book, page=2)
|
||
|
||
response = auth_client.get(self.url)
|
||
assert response.status_code == status.HTTP_200_OK
|
||
results = response.data["results"]
|
||
assert len(results) == 1
|
||
assert results[0]["page"] == 1
|
||
|
||
def test_list_returns_empty_when_no_bookmarks(
|
||
self, auth_client: APIClient
|
||
):
|
||
response = auth_client.get(self.url)
|
||
assert response.status_code == status.HTTP_200_OK
|
||
assert response.data["count"] == 0
|
||
|
||
def test_list_orders_by_newest_first(
|
||
self, auth_client: APIClient, user: User, book: Book
|
||
):
|
||
b1 = Bookmark.objects.create(user=user, book=book, page=1)
|
||
b2 = Bookmark.objects.create(user=user, book=book, page=2)
|
||
response = auth_client.get(self.url)
|
||
results = response.data["results"]
|
||
assert results[0]["page"] == 2
|
||
assert results[1]["page"] == 1
|
||
|
||
def test_list_includes_book_title(
|
||
self, auth_client: APIClient, bookmark: Bookmark
|
||
):
|
||
response = auth_client.get(self.url)
|
||
assert response.status_code == status.HTTP_200_OK
|
||
assert response.data["results"][0]["book_title"] == "Test Book"
|
||
|
||
|
||
class TestBookmarkCreate:
|
||
url = reverse("bookmark-list")
|
||
|
||
def test_create_bookmark(self, auth_client: APIClient, book: Book):
|
||
data = {"book": str(book.id), "page": 10, "location_text": "key insight"}
|
||
response = auth_client.post(self.url, data, format="json")
|
||
assert response.status_code == status.HTTP_201_CREATED
|
||
assert response.data["page"] == 10
|
||
|
||
def test_create_bookmark_without_location_text(
|
||
self, auth_client: APIClient, book: Book
|
||
):
|
||
data = {"book": str(book.id), "page": 5}
|
||
response = auth_client.post(self.url, data, format="json")
|
||
assert response.status_code == status.HTTP_201_CREATED
|
||
assert response.data["page"] == 5
|
||
|
||
def test_duplicate_bookmark_page_is_rejected(
|
||
self, auth_client: APIClient, bookmark: Bookmark
|
||
):
|
||
data = {"book": str(bookmark.book.id), "page": bookmark.page}
|
||
response = auth_client.post(self.url, data, format="json")
|
||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||
|
||
def test_unauthenticated_user_cannot_create(
|
||
self, api_client: APIClient, book: Book
|
||
):
|
||
data = {"book": str(book.id), "page": 10}
|
||
response = api_client.post(self.url, data, format="json")
|
||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||
|
||
def test_invalid_page_rejected(
|
||
self, auth_client: APIClient, book: Book
|
||
):
|
||
data = {"book": str(book.id), "page": 0}
|
||
response = auth_client.post(self.url, data, format="json")
|
||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||
|
||
|
||
class TestBookmarkDetail:
|
||
def test_get_bookmark(
|
||
self, auth_client: APIClient, bookmark: Bookmark
|
||
):
|
||
url = reverse("bookmark-detail", args=[str(bookmark.id)])
|
||
response = auth_client.get(url)
|
||
assert response.status_code == status.HTTP_200_OK
|
||
assert response.data["page"] == bookmark.page
|
||
|
||
def test_cannot_access_other_users_bookmark(
|
||
self, api_client: APIClient, other_user: User, bookmark: Bookmark
|
||
):
|
||
api_client.force_authenticate(user=other_user)
|
||
url = reverse("bookmark-detail", args=[str(bookmark.id)])
|
||
response = api_client.get(url)
|
||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||
|
||
|
||
class TestBookmarkDelete:
|
||
def test_delete_bookmark(
|
||
self, auth_client: APIClient, bookmark: Bookmark
|
||
):
|
||
url = reverse("bookmark-detail", args=[str(bookmark.id)])
|
||
response = auth_client.delete(url)
|
||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||
assert Bookmark.objects.count() == 0
|
||
|
||
def test_cannot_delete_other_users_bookmark(
|
||
self, api_client: APIClient, other_user: User, bookmark: Bookmark
|
||
):
|
||
api_client.force_authenticate(user=other_user)
|
||
url = reverse("bookmark-detail", args=[str(bookmark.id)])
|
||
response = api_client.delete(url)
|
||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||
|
||
|
||
class TestBookmarkFilterByBook:
|
||
def test_filter_by_book(
|
||
self, auth_client: APIClient, user: User, book: Book
|
||
):
|
||
other_book = Book.objects.create(title="Other", author="Other")
|
||
Bookmark.objects.create(user=user, book=book, page=1)
|
||
Bookmark.objects.create(user=user, book=other_book, page=2)
|
||
|
||
url = reverse("bookmark-list")
|
||
response = auth_client.get(url, {"book": str(book.id)})
|
||
assert response.status_code == status.HTTP_200_OK
|
||
assert response.data["count"] == 1
|
||
assert response.data["results"][0]["page"] == 1
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Note tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestNoteList:
|
||
url = reverse("note-list")
|
||
|
||
def test_unauthenticated_user_cannot_list(self, api_client: APIClient):
|
||
response = api_client.get(self.url)
|
||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||
|
||
def test_list_returns_user_notes_only(
|
||
self, auth_client: APIClient, user: User, other_user: User, book: Book
|
||
):
|
||
Note.objects.create(user=user, book=book, page=1, content="My note")
|
||
Note.objects.create(user=other_user, book=book, page=2, content="Other's note")
|
||
|
||
response = auth_client.get(self.url)
|
||
assert response.status_code == status.HTTP_200_OK
|
||
results = response.data["results"]
|
||
assert len(results) == 1
|
||
assert results[0]["content"] == "My note"
|
||
|
||
def test_list_includes_book_title(
|
||
self, auth_client: APIClient, note: Note
|
||
):
|
||
response = auth_client.get(self.url)
|
||
assert response.status_code == status.HTTP_200_OK
|
||
assert response.data["results"][0]["book_title"] == "Test Book"
|
||
|
||
|
||
class TestNoteCreate:
|
||
url = reverse("note-list")
|
||
|
||
def test_create_note(self, auth_client: APIClient, book: Book):
|
||
data = {
|
||
"book": str(book.id),
|
||
"page": 20,
|
||
"location_text": "interesting part",
|
||
"content": "This is a thoughtful note.",
|
||
}
|
||
response = auth_client.post(self.url, data, format="json")
|
||
assert response.status_code == status.HTTP_201_CREATED
|
||
assert response.data["content"] == "This is a thoughtful note."
|
||
|
||
def test_create_note_without_location_text(
|
||
self, auth_client: APIClient, book: Book
|
||
):
|
||
data = {"book": str(book.id), "page": 20, "content": "A note."}
|
||
response = auth_client.post(self.url, data, format="json")
|
||
assert response.status_code == status.HTTP_201_CREATED
|
||
|
||
def test_empty_content_rejected(
|
||
self, auth_client: APIClient, book: Book
|
||
):
|
||
data = {"book": str(book.id), "page": 20, "content": " "}
|
||
response = auth_client.post(self.url, data, format="json")
|
||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||
|
||
def test_unauthenticated_user_cannot_create(
|
||
self, api_client: APIClient, book: Book
|
||
):
|
||
data = {"book": str(book.id), "page": 20, "content": "Note"}
|
||
response = api_client.post(self.url, data, format="json")
|
||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||
|
||
|
||
class TestNoteUpdate:
|
||
def test_update_note_content(
|
||
self, auth_client: APIClient, note: Note
|
||
):
|
||
url = reverse("note-detail", args=[str(note.id)])
|
||
data = {"content": "Updated note content."}
|
||
response = auth_client.patch(url, data, format="json")
|
||
assert response.status_code == status.HTTP_200_OK
|
||
assert response.data["content"] == "Updated note content."
|
||
|
||
def test_cannot_update_other_users_note(
|
||
self, api_client: APIClient, other_user: User, note: Note
|
||
):
|
||
api_client.force_authenticate(user=other_user)
|
||
url = reverse("note-detail", args=[str(note.id)])
|
||
data = {"content": "Hacked!"}
|
||
response = api_client.patch(url, data, format="json")
|
||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||
|
||
|
||
class TestNoteDelete:
|
||
def test_delete_note(self, auth_client: APIClient, note: Note):
|
||
url = reverse("note-detail", args=[str(note.id)])
|
||
response = auth_client.delete(url)
|
||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||
assert Note.objects.count() == 0
|
||
|
||
def test_batch_delete_notes(
|
||
self, auth_client: APIClient, user: User, book: Book
|
||
):
|
||
n1 = Note.objects.create(user=user, book=book, page=1, content="A")
|
||
n2 = Note.objects.create(user=user, book=book, page=2, content="B")
|
||
url = reverse("note-batch-delete")
|
||
response = auth_client.delete(url, {"ids": [str(n1.id), str(n2.id)]}, format="json")
|
||
assert response.status_code == status.HTTP_200_OK
|
||
assert response.data["deleted"] == 2
|
||
|
||
|
||
class TestNoteFilterByBook:
|
||
def test_filter_by_book(
|
||
self, auth_client: APIClient, user: User, book: Book
|
||
):
|
||
other_book = Book.objects.create(title="Other", author="Other")
|
||
Note.objects.create(user=user, book=book, page=1, content="In book")
|
||
Note.objects.create(user=user, book=other_book, page=2, content="In other")
|
||
|
||
url = reverse("note-list")
|
||
response = auth_client.get(url, {"book": str(book.id)})
|
||
assert response.status_code == status.HTTP_200_OK
|
||
assert response.data["count"] == 1
|
||
assert response.data["results"][0]["content"] == "In book" |