Archived
- Backend: Book model with reading progress, DRF ViewSet with full CRUD, search, sort, filter, pagination, mark-as-finished, stats endpoint - Frontend: Library grid, BookCard, BookDetail, BookForm components with React 19 + TypeScript + Vite - Tests: 29 passing tests covering models, API, serializers, permissions - Spec: backend api-spec.md and frontend component-spec.md in docs/ Closes crisleo-hermes/cloud-reader#3
325 lines
13 KiB
Python
325 lines
13 KiB
Python
"""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 |