This repository has been archived on 2026-07-21. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
cloud-reader/backend/apps/annotations/tests.py
Marko (Hermes Implementer) 3b5b301e42 feat: bookmarks and notes management
- 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
2026-05-26 00:50:06 +00:00

331 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"