"""Tests for the Book search & discovery endpoints.""" import pytest from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient from apps.books.models import Book, ReadingStatus # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def api_client(): return APIClient() @pytest.fixture def user(django_user_model): return django_user_model.objects.create_user( email="reader@example.com", password="testpass123", ) @pytest.fixture def auth_client(api_client, user): api_client.force_authenticate(user=user) return api_client @pytest.fixture def books(): books_data = [ Book.objects.create( title="Dune", author="Frank Herbert", genre="Science Fiction", reading_status=ReadingStatus.FINISHED, total_pages=688, description="A desert planet saga.", ), Book.objects.create( title="Neuromancer", author="William Gibson", genre="Science Fiction", reading_status=ReadingStatus.READING, total_pages=271, description="Cyberpunk classic.", ), Book.objects.create( title="The Hobbit", author="J.R.R. Tolkien", genre="Fantasy", reading_status=ReadingStatus.WANT_TO_READ, total_pages=310, description="A hobbit's adventure.", ), Book.objects.create( title="1984", author="George Orwell", genre="Dystopian", reading_status=ReadingStatus.FINISHED, total_pages=328, description="Big Brother is watching.", ), ] return books_data # --------------------------------------------------------------------------- # Search tests # --------------------------------------------------------------------------- @pytest.mark.django_db class TestBookSearch: """Verify the search endpoint returns correct results.""" def test_search_by_title(self, auth_client, books): url = reverse("book-list") response = auth_client.get(url, {"q": "Dune"}) assert response.status_code == status.HTTP_200_OK titles = [b["title"] for b in response.data["results"]] assert "Dune" in titles assert "Neuromancer" not in titles def test_search_by_author(self, auth_client, books): url = reverse("book-list") response = auth_client.get(url, {"q": "Tolkien"}) assert response.status_code == status.HTTP_200_OK titles = [b["title"] for b in response.data["results"]] assert "The Hobbit" in titles def test_search_by_genre(self, auth_client, books): url = reverse("book-list") response = auth_client.get(url, {"q": "Fantasy"}) assert response.status_code == status.HTTP_200_OK titles = [b["title"] for b in response.data["results"]] assert "The Hobbit" in titles def test_search_case_insensitive(self, auth_client, books): url = reverse("book-list") response = auth_client.get(url, {"q": "dune"}) assert response.status_code == status.HTTP_200_OK assert any(b["title"] == "Dune" for b in response.data["results"]) def test_search_partial_match(self, auth_client, books): url = reverse("book-list") response = auth_client.get(url, {"q": "Neu"}) assert response.status_code == status.HTTP_200_OK titles = [b["title"] for b in response.data["results"]] assert "Neuromancer" in titles def test_search_empty_query_returns_all(self, auth_client, books): url = reverse("book-list") response = auth_client.get(url, {"q": ""}) assert response.status_code == status.HTTP_200_OK assert len(response.data["results"]) == 4 def test_search_no_results(self, auth_client, books): url = reverse("book-list") response = auth_client.get(url, {"q": "zzzznotfound"}) assert response.status_code == status.HTTP_200_OK assert len(response.data["results"]) == 0 # --------------------------------------------------------------------------- # Filter tests # --------------------------------------------------------------------------- @pytest.mark.django_db class TestBookFilters: """Verify filters for genre, author, and reading_status.""" def test_filter_by_genre(self, auth_client, books): url = reverse("book-list") response = auth_client.get(url, {"genre": "Science Fiction"}) assert response.status_code == status.HTTP_200_OK titles = [b["title"] for b in response.data["results"]] assert "Dune" in titles assert "Neuromancer" in titles assert "The Hobbit" not in titles def test_filter_by_author(self, auth_client, books): url = reverse("book-list") response = auth_client.get(url, {"author": "George Orwell"}) assert response.status_code == status.HTTP_200_OK titles = [b["title"] for b in response.data["results"]] assert "1984" in titles assert "Dune" not in titles def test_filter_by_reading_status(self, auth_client, books): url = reverse("book-list") response = auth_client.get(url, {"reading_status": ReadingStatus.FINISHED}) assert response.status_code == status.HTTP_200_OK titles = [b["title"] for b in response.data["results"]] assert "Dune" in titles assert "1984" in titles assert "Neuromancer" not in titles assert "The Hobbit" not in titles def test_filter_combined_with_search(self, auth_client, books): """Search + filter should intersect results.""" url = reverse("book-list") response = auth_client.get(url, {"q": "Dune", "reading_status": ReadingStatus.FINISHED}) assert response.status_code == status.HTTP_200_OK titles = [b["title"] for b in response.data["results"]] assert "Dune" in titles # 1984 matches reading_status but not search assert "1984" not in titles # --------------------------------------------------------------------------- # Discovery endpoints # --------------------------------------------------------------------------- @pytest.mark.django_db class TestBookDiscovery: """Verify genre and author discovery endpoints.""" def test_genres_endpoint(self, auth_client, books): url = reverse("book-genres") response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK assert isinstance(response.data, list) assert "Science Fiction" in response.data assert "Fantasy" in response.data assert "Dystopian" in response.data # No duplicate genres assert response.data.count("Science Fiction") == 1 def test_authors_endpoint(self, auth_client, books): url = reverse("book-authors") response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK assert isinstance(response.data, list) assert "Frank Herbert" in response.data assert "J.R.R. Tolkien" in response.data def test_genres_requires_auth(self, api_client, books): url = reverse("book-genres") response = api_client.get(url) assert response.status_code == status.HTTP_401_UNAUTHORIZED def test_authors_requires_auth(self, api_client, books): url = reverse("book-authors") response = api_client.get(url) assert response.status_code == status.HTTP_401_UNAUTHORIZED # --------------------------------------------------------------------------- # Detail view # --------------------------------------------------------------------------- @pytest.mark.django_db class TestBookDetail: """Verify the book detail endpoint.""" def test_retrieve_book(self, auth_client, books): book = books[0] url = reverse("book-detail", kwargs={"pk": book.pk}) response = auth_client.get(url) assert response.status_code == status.HTTP_200_OK assert response.data["title"] == "Dune" assert response.data["author"] == "Frank Herbert" assert response.data["description"] == "A desert planet saga." assert response.data["total_pages"] == 688 def test_retrieve_nonexistent_returns_404(self, auth_client, books): url = reverse("book-detail", kwargs={"pk": 99999}) response = auth_client.get(url) assert response.status_code == status.HTTP_404_NOT_FOUND def test_pagination(self, auth_client, books): """List with small page size should paginate.""" url = reverse("book-list") response = auth_client.get(f"{url}?page_size=2") assert response.status_code == status.HTTP_200_OK assert "count" in response.data assert "results" in response.data assert response.data["count"] == 4 def test_ordering(self, auth_client, books): url = reverse("book-list") response = auth_client.get(url, {"ordering": "title"}) assert response.status_code == status.HTTP_200_OK titles = [b["title"] for b in response.data["results"]] assert titles == sorted(titles)