Archived
feat: full book management system with backend API, frontend UI, and spec docs
- 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
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user