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