"""Tests for the books app — API endpoints, models, serializers, and permissions.""" from __future__ import annotations from django.contrib.auth.models import User from django.test import TestCase, override_settings from rest_framework import status from rest_framework.test import APIClient from books.models import Book, ReadingStatus class BookModelTests(TestCase): """Tests for the Book model.""" def setUp(self) -> None: self.user = User.objects.create_user(username="testuser", password="testpass123") self.book = Book.objects.create( title="Test Book", author="Test Author", total_pages=200, current_page=50, reading_status=ReadingStatus.READING, owner=self.user, ) def test_reading_progress_calculates_correctly(self) -> None: """Reading progress should be (current_page / total_pages) * 100.""" assert self.book.reading_progress == 25.0 def test_reading_progress_returns_zero_when_no_pages(self) -> None: """When total_pages is 0, reading_progress should be 0.0.""" book = Book.objects.create( title="No Pages", author="Author", total_pages=0, current_page=50, owner=self.user, ) assert book.reading_progress == 0.0 def test_mark_as_finished_sets_progress_to_100(self) -> None: """mark_as_finished should set status to FINISHED and progress to 100%.""" self.book.mark_as_finished() self.book.refresh_from_db() assert self.book.reading_status == ReadingStatus.FINISHED assert self.book.current_page == self.book.total_pages def test_mark_as_finished_with_zero_pages(self) -> None: """mark_as_finished with total_pages=0 should set 0/0.""" book = Book.objects.create( title="Empty", author="Author", total_pages=0, current_page=0, owner=self.user, ) book.mark_as_finished() book.refresh_from_db() assert book.reading_status == ReadingStatus.FINISHED assert book.current_page == 0 def test_str_method(self) -> None: """__str__ should return 'Title by Author'.""" assert str(self.book) == "Test Book by Test Author" class BookAPITests(TestCase): """Tests for the Book API endpoints.""" def setUp(self) -> None: self.client = APIClient() self.user = User.objects.create_user(username="testuser", password="testpass123") self.other_user = User.objects.create_user(username="other", password="testpass123") self.client.force_authenticate(user=self.user) self.book = Book.objects.create( title="My Book", author="My Author", genre="Fiction", description="A great book", total_pages=200, current_page=50, reading_status=ReadingStatus.READING, owner=self.user, ) # Other user's book (should not be visible) Book.objects.create( title="Other Book", author="Other Author", total_pages=100, owner=self.other_user, ) # --- List --- def test_list_books_returns_owned_books_only(self) -> None: """GET /api/books/ should only return books owned by the current user.""" resp = self.client.get("/api/books/") assert resp.status_code == status.HTTP_200_OK assert resp.data["count"] == 1 assert resp.data["results"][0]["title"] == "My Book" def test_list_books_requires_authentication(self) -> None: """GET /api/books/ without auth should return 403.""" self.client.force_authenticate(user=None) resp = self.client.get("/api/books/") assert resp.status_code == status.HTTP_403_FORBIDDEN # --- Create --- def test_create_book_sets_owner(self) -> None: """POST /api/books/ should create a book owned by the current user.""" resp = self.client.post("/api/books/", { "title": "New Book", "author": "New Author", }, format="json") assert resp.status_code == status.HTTP_201_CREATED assert resp.data["owner"] == "testuser" assert Book.objects.filter(title="New Book", owner=self.user).exists() def test_create_book_validates_required_fields(self) -> None: """POST /api/books/ with missing title should fail.""" resp = self.client.post("/api/books/", { "author": "Author Only", }, format="json") assert resp.status_code == status.HTTP_400_BAD_REQUEST # --- Retrieve --- def test_retrieve_book(self) -> None: """GET /api/books/{id}/ should return full book details.""" resp = self.client.get(f"/api/books/{self.book.id}/") assert resp.status_code == status.HTTP_200_OK assert resp.data["title"] == "My Book" assert resp.data["reading_progress"] == 25.0 assert resp.data["owner"] == "testuser" def test_retrieve_other_users_book_returns_404(self) -> None: """GET /api/books/{other_id}/ should return 404 for another user's book.""" other_book = Book.objects.get(title="Other Book") resp = self.client.get(f"/api/books/{other_book.id}/") assert resp.status_code == status.HTTP_404_NOT_FOUND # --- Update --- def test_update_book(self) -> None: """PATCH /api/books/{id}/ should update book fields.""" resp = self.client.patch(f"/api/books/{self.book.id}/", { "title": "Updated Title", "current_page": 100, }, format="json") assert resp.status_code == status.HTTP_200_OK assert resp.data["title"] == "Updated Title" assert resp.data["reading_progress"] == 50.0 def test_update_current_page_exceeds_total(self) -> None: """PATCH with current_page > total_pages should fail.""" resp = self.client.patch(f"/api/books/{self.book.id}/", { "current_page": 999, }, format="json") assert resp.status_code == status.HTTP_400_BAD_REQUEST # --- Delete --- def test_delete_book(self) -> None: """DELETE /api/books/{id}/ should delete the book.""" resp = self.client.delete(f"/api/books/{self.book.id}/") assert resp.status_code == status.HTTP_204_NO_CONTENT assert not Book.objects.filter(id=self.book.id).exists() # --- Mark Finished --- def test_mark_finished_sets_100_percent(self) -> None: """POST /api/books/{id}/mark_finished/ should set progress to 100%.""" resp = self.client.post(f"/api/books/{self.book.id}/mark_finished/") assert resp.status_code == status.HTTP_200_OK assert resp.data["reading_status"] == ReadingStatus.FINISHED assert resp.data["reading_progress"] == 100.0 def test_mark_finished_idempotent(self) -> None: """Calling mark_finished twice should be safe.""" self.client.post(f"/api/books/{self.book.id}/mark_finished/") resp = self.client.post(f"/api/books/{self.book.id}/mark_finished/") assert resp.status_code == status.HTTP_200_OK def test_mark_finished_other_users_book(self) -> None: """POST mark_finished on another user's book should return 404.""" other_book = Book.objects.get(title="Other Book") resp = self.client.post(f"/api/books/{other_book.id}/mark_finished/") assert resp.status_code == status.HTTP_404_NOT_FOUND # --- Stats --- def test_stats_returns_counts(self) -> None: """GET /api/books/stats/ should return aggregate counts.""" resp = self.client.get("/api/books/stats/") assert resp.status_code == status.HTTP_200_OK assert resp.data["total_books"] == 1 assert resp.data["finished"] == 0 assert resp.data["reading"] == 1 assert resp.data["not_started"] == 0 # --- Filtering & Sorting --- def test_filter_by_reading_status(self) -> None: """GET /api/books/?reading_status=reading should filter correctly.""" resp = self.client.get("/api/books/", {"reading_status": "reading"}) assert resp.status_code == status.HTTP_200_OK assert resp.data["count"] == 1 resp = self.client.get("/api/books/", {"reading_status": "finished"}) assert resp.status_code == status.HTTP_200_OK assert resp.data["count"] == 0 def test_search_by_title(self) -> None: """GET /api/books/?search=My should filter by title.""" resp = self.client.get("/api/books/", {"search": "My"}) assert resp.status_code == status.HTTP_200_OK assert resp.data["count"] == 1 resp = self.client.get("/api/books/", {"search": "Nonexistent"}) assert resp.status_code == status.HTTP_200_OK assert resp.data["count"] == 0 def test_search_by_author(self) -> None: """GET /api/books/?search=My should filter by author.""" resp = self.client.get("/api/books/", {"search": "My Author"}) assert resp.status_code == status.HTTP_200_OK assert resp.data["count"] == 1 def test_sort_by_title(self) -> None: """GET /api/books/?sort_by=title should sort alphabetically.""" Book.objects.create(title="Aardvark", author="Author", owner=self.user) Book.objects.create(title="Zebra", author="Author", owner=self.user) resp = self.client.get("/api/books/", {"sort_by": "title"}) assert resp.status_code == status.HTTP_200_OK titles = [b["title"] for b in resp.data["results"]] assert titles == sorted(titles) def test_sort_by_author_desc(self) -> None: """GET /api/books/?sort_by=-author should sort by author desc.""" resp = self.client.get("/api/books/", {"sort_by": "-author"}) assert resp.status_code == status.HTTP_200_OK # --- Pagination --- def test_pagination_default_page_size(self) -> None: """Books should be paginated with default page size.""" for i in range(25): Book.objects.create( title=f"Book {i}", author="Author", owner=self.user, ) resp = self.client.get("/api/books/") assert resp.status_code == status.HTTP_200_OK assert len(resp.data["results"]) == 20 # PAGE_SIZE = 20 assert resp.data["count"] == 26 # 1 original + 25 new def test_pagination_second_page(self) -> None: """GET /api/books/?page=2 should return remaining items.""" for i in range(25): Book.objects.create( title=f"Book {i}", author="Author", owner=self.user, ) resp = self.client.get("/api/books/", {"page": 2}) assert resp.status_code == status.HTTP_200_OK assert len(resp.data["results"]) == 6 class BookSerializerTests(TestCase): """Tests for serializers — validation rules.""" def setUp(self) -> None: self.user = User.objects.create_user(username="testuser", password="testpass123") def test_current_page_cannot_exceed_total(self) -> None: """Serializer should reject current_page > total_pages.""" from books.serializers import BookDetailSerializer data = { "title": "Test", "author": "Author", "total_pages": 100, "current_page": 150, } serializer = BookDetailSerializer(data=data) assert not serializer.is_valid() assert "current_page" in serializer.errors def test_title_cannot_be_blank(self) -> None: """Serializer should reject blank title.""" from books.serializers import BookDetailSerializer data = { "title": " ", "author": "Author", } serializer = BookDetailSerializer(data=data) assert not serializer.is_valid() assert "title" in serializer.errors def test_author_cannot_be_blank(self) -> None: """Serializer should reject blank author.""" from books.serializers import BookDetailSerializer data = { "title": "Test", "author": "", } serializer = BookDetailSerializer(data=data) assert not serializer.is_valid() assert "author" in serializer.errors def test_list_serializer_has_minimal_fields(self) -> None: """BookListSerializer should exclude sensitive/fluff fields.""" from books.serializers import BookListSerializer serializer = BookListSerializer() fields = set(serializer.fields.keys()) assert "id" in fields assert "title" in fields assert "author" in fields assert "reading_progress" in fields assert "description" not in fields assert "isbn" not in fields